단지 지도 페이지를 새로 만들던 어느 날, 페이지를 띄우자마자 카카오맵이 멈출 줄을 몰랐어요. 콘솔에 setCenter 로그가 끝없이 찍혔고, React DevTools 의 Profiler 를 켜보니 부모 컴포넌트가 미친 듯이 리렌더링되고 있었습니다. 결국 원인은 SDK 가 아니라 우리가 prop 을 넘기는 방식에 있었어요.
1. 진단하기
먼저 어디서 리렌더링이 시작되는지 좁혀야 했어요. React DevTools 의 Profiler 로 commit 들을 기록해보니, 매번 같은 부모 컴포넌트가 리렌더되고 있었어요. props 변화를 비교해봤을 때 흥미로운 게 보였어요. <KakaoMap> 에 넘기는 center prop 의 안쪽 값(lat, lng)은 늘 같은데, prop 객체 자체는 매번 다른 객체로 표시되고 있었어요.
여기서 감이 왔어요. JavaScript 에서 객체는 참조로 비교돼요. 같은 { lat: 37.5, lng: 127 } 라도 매 렌더마다 새로 만들어진 객체라면 === 비교에선 다 다른 객체로 인식돼요. 라이브러리 입장에서는 “어, center 가 또 바뀌었네” 가 되는 거죠.
react-kakao-maps-sdk 의 내부 구현을 잠깐 들여다봤어요. <Map> 컴포넌트는 내부적으로 useEffect 의 dependency 로 center 와 level 을 watch 하면서, 변할 때마다 map.setCenter() 를 호출하는 구조였어요. 그 호출이 카카오맵 인스턴스의 bounds 를 바꾸고, 그러면 onBoundsChanged 콜백이 발화하고, 우리가 그 콜백 안에서 외부 상태를 업데이트하면서 부모를 리렌더, 또 새 객체… 무한 루프의 윤곽이 보였어요.
2. 재현하기
가설을 확실히 하려고 격리된 환경에서 재현해봤어요.
function Demo() {
const [bounds, setBounds] = useState(null)
return (
<KakaoMap
center={{ lat: 37.5, lng: 127 }} // 매 렌더마다 새 객체
level={3}
onBoundsChanged={(b) => setBounds(b)}
/>
)
}
이 컴포넌트는 마운트 직후부터 리렌더링이 멈추지 않아요. setBounds 가 호출되면 부모가 리렌더되고, 리렌더되면서 { lat: 37.5, lng: 127 } 객체가 또 새로 만들어지고, SDK 가 그걸 새 center 로 보고 setCenter 를 또 호출하고, 그 결과 onBoundsChanged 가 또 발화하고… 이런 식으로 영원히 굴러가요.
객체를 컴포넌트 바깥에 상수로 빼두면 루프가 멈추는지도 확인해봤어요.
const FIXED_CENTER = { lat: 37.5, lng: 127 }
function Demo() {
const [bounds, setBounds] = useState(null)
return (
<KakaoMap
center={FIXED_CENTER}
level={3}
onBoundsChanged={(b) => setBounds(b)}
/>
)
}
이렇게 하면 리렌더링이 정상화돼요. 같은 참조의 객체이므로 SDK 가 “변경 없음” 으로 인식하는 거죠. 가설은 정확히 맞았어요.
3. 수정하기
실제 코드에선 initialStatus 가 props 로 들어오는 동적 값이라 단순히 모듈 상수로 뺄 수는 없었어요. 그래서 마운트 시점의 값을 한 번만 freeze 하는 방식으로 풀었어요.
// before
function SaveHomeMap({ initialStatus, ... }) {
return (
<KakaoMap
center={{ lat: initialStatus.lat, lng: initialStatus.lng }}
level={initialStatus.zoom}
onBoundsChanged={updateBounds}
>
...
</KakaoMap>
)
}
// after
function SaveHomeMap({ initialStatus, ... }) {
const [initialCenter] = useState(() => ({
lat: initialStatus.lat,
lng: initialStatus.lng,
}))
const [initialLevel] = useState(() => initialStatus.zoom)
return (
<KakaoMap
center={initialCenter}
level={initialLevel}
onBoundsChanged={updateBounds}
>
...
</KakaoMap>
)
}
useState 의 initializer 는 첫 렌더에서 한 번 평가된 후로는 다시 호출되지 않아요. 그래서 initialCenter 는 마운트 이후에도 같은 객체 참조를 유지하고요. 이후의 위치 변경은 SDK 가 자기 안에서 알아서 관리해요. 핵심은 외부 상태로 center 를 양방향으로 묶지 않는 거예요. 외부에서는 “여기서 시작해줘” 정도만 알려주고, 그 다음부턴 SDK 의 내부 상태를 신뢰하는 거죠.
useMemo 로도 비슷하게 풀 수 있어요. 다만 useMemo 는 dependency 가 한 번이라도 바뀌면 새 객체가 만들어져버리니까, 정말 “한 번만 평가” 가 필요할 땐 dependency 가 없는 useState initializer 가 더 안전해요.
4. 재발 방지하기
이런 함정은 한 번 빠지면 다음에도 빠질 가능성이 커요. 그래서 몇 가지 가드를 마련했어요.
첫째, “초기값” 과 “현재값” 을 같은 prop 자리에 같이 넣지 않아요. 라이브러리가 prop 이름으로 의도를 분명히 해주지 않더라도, 한 번 잠그고 싶은 값은 컴포넌트 내부에서 명시적으로 한 번만 평가되도록 분리해요. 외부에서 변화시키고 싶으면 라이브러리가 노출하는 명령형 API (map.panTo(), map.setBounds() 같은 메서드들) 를 통해서 처리해요. 선언형 prop 과 명령형 메서드의 역할을 헷갈리지 않으면 이런 무한 루프 함정을 거의 다 피할 수 있어요.
둘째, 3rd-party 컴포넌트에 inline 객체 / 배열 / 함수를 prop 으로 넘기지 않으려고 해요. 코드 리뷰에서도 “이 prop, 매 렌더마다 새로 만들어지는 거 아냐?” 를 묻는 습관을 만들었어요. 특히 라이브러리가 그 prop 을 useEffect 의 dependency 로 쓰고 있다면 거의 확실히 사고 지점이 돼요. 라이브러리 prop 을 처음 다룰 땐 한 번이라도 그 prop 이 어떻게 쓰이는지 (특히 dependency 로 들어가는지) 살펴보면 사고를 미리 막을 수 있어요.
셋째, 만능 린트 규칙은 없다는 걸 인정해요. eslint-plugin-react 의 jsx-no-constructed-context-values 같은 규칙은 Context.Provider 의 value prop 한정이라 일반 prop 에는 적용되지 않아요. 결국 가장 실용적인 가드는 코드 리뷰의 질문 한 줄과, 라이브러리의 동작 모델을 한 번 이해하고 시작하는 습관이었어요.
이 사건 덕분에 React 와 외부 라이브러리의 경계를 어떻게 그어야 하는지 한 번 더 생각하게 됐어요. 라이브러리 안에서 어떤 일이 벌어지는지 모르는 채로 prop 만 넘기다 보면 이런 함정에 빠지기 쉬워요. 의심하는 습관 하나가 한참의 디버깅을 줄여줘요.