떠오를 때마다 적어둬요

카카오맵이 무한 리렌더링됐어요.

inline 객체를 SDK에 넘긴 결과예요.

단지 지도 페이지를 새로 만들던 어느 날, 페이지를 띄우자마자 카카오맵이 멈출 줄을 몰랐어요. 콘솔에 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 로 centerlevel 을 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 만 넘기다 보면 이런 함정에 빠지기 쉬워요. 의심하는 습관 하나가 한참의 디버깅을 줄여줘요.