시리즈 ‘React 성능·메모리’ 2편. 오늘 한 일 노트를 정리하며, “재렌더링 → 함수/객체 재할당 → 참조 불안정 → 이벤트 중복 발화”의 연결고리를 따라가 본다.

오늘 이벤트 로깅 쪽을 손보다가 문득 “이거 왜 매번 이벤트가 두 번 찍히지?”에서 시작해서, 결국 “왜 이 컴포넌트가 이렇게 자주 리렌더되지?”까지 파고들었다. 처음엔 성능 얘기였는데 파다 보니 메모리 얘기로 이어졌다. 오늘 정리한 사고 흐름을 그대로 적어둔다.

재렌더링이란 무엇을 다시 하는가

리렌더링은 DOM을 통째로 다시 그리는 게 아니다. 함수 컴포넌트가 다시 호출되는 것이다. 다시 호출되면 그 안의 모든 것이 새로 만들어진다.

function Component({ data }) {
  // 리렌더될 때마다 아래 전부 "새로" 생성된다
  const handleClick = () => doSomething(data); // 새 함수 객체
  const config = { id: data.id, label: data.title }; // 새 객체
  return <Child onClick={handleClick} config={config} />;
}

handleClick도, config도 렌더마다 주소가 다른 새 값이다. 값은 같아 보여도 참조(reference)가 다르다. 이게 성능·메모리 두 관점에서 다 문제가 된다.

함수/객체 재할당이 만드는 나비효과

1. 자식의 불필요한 리렌더

React.memo로 감싼 자식이라도, props의 참조가 매번 바뀌면 memo는 무력화된다. handleClick이 매번 새 함수면 자식은 “props 바뀌었네” 하고 또 렌더한다.

2. useEffect가 매번 재실행

더 골치 아픈 건 의존성 배열이다. 새로 만든 객체를 deps에 넣으면 effect가 렌더마다 다시 돈다.

const eventData = { promotionId: data?.key, label: data?.title };

useEffect(() => {
  sendEvent(eventName, eventData);
}, [eventData]); // eventData가 매 렌더 새 객체 → effect 무한에 가깝게 재실행

이벤트가 두 번, 세 번 찍히던 원인이 바로 이거였다. eventData는 내용이 같아도 매번 새 객체라 [eventData]가 항상 “바뀐 것”으로 판정된다.

3. 그럼 메모리는? — 재할당 자체는 누수가 아니다

여기서 흔한 오해 하나를 짚어야 한다. 매 렌더의 새 함수·객체가 그대로 쌓여 OOM으로 간다는 그림은 틀렸다. 이전 렌더의 클로저·객체는 새 값으로 교체되는 순간 도달 불가능해져 GC 대상이 된다. 즉 재할당은 GC가 처리하는 **할당 부하(churn)**일 뿐, 회수되지 않고 남는 **점유 메모리(retained heap)**와는 다른 축이다. 메모리가 실제로 우상향한다면 그건 재할당 때문이 아니라, effect가 만든 타이머·리스너·구독을 정리(cleanup)하지 않거나, ref·전역이 참조를 계속 붙잡고 있을 때다. 원인은 재할당이 아니라 정리 누락이다.

해결: 참조를 안정화한다 (useMemo)

기획전 상세 페이지에서 실제로 이 패턴을 정리했다. 이벤트 데이터 객체를 useMemo로 묶어 data가 바뀔 때만 새로 만들도록 했다.

// Before: 렌더마다 새 객체, effect가 searchParams/data 참조로 계속 재실행
useEffect(() => {
  const eventData = {
    screen: '기획전 상세',
    action: '기획전 상세 조회',
    promotionId: data?.key,
    payType: data?.buyTypes.join(','),
    isNew: data?.openAt ? isNewWithin7Days(data.openAt) : false,
    promotionStatus: data?.status,
    label: data?.title,
  };
  sendAllTypeEvent(eventName, eventData);
}, [searchParams, data]);
// After: 객체 생성을 useMemo로 격리 → 참조 안정화
const promotionDetailEventData = useMemo(
  () => ({
    promotionId: data?.key,
    payType: data?.buyTypes.join(','),
    isNew: data?.openAt ? isNewWithin7Days(data.openAt) : false,
    promotionStatus: data?.status,
    label: data?.title,
  }),
  [data],
);

useEffect(() => {
  const eventData = {
    from: searchParams.get('from'),
    screen: '기획전 상세',
    action: '기획전 상세 조회',
    ...promotionDetailEventData,
  };
  sendAllTypeEvent(eventName, eventData);
}, [searchParams, promotionDetailEventData]);

포인트:

  • deps를 data(전체 객체)에서 promotionDetailEventData(useMemo 산출물)로 바꿨다. 다만 짚어둘 게 있다. useMemo(() => ({...}), [data])의 의존성은 data참조로 비교하므로, data가 매 렌더 새 참조로 오면 메모 객체도 매번 새로 만들어진다 — 필드 값이 같다고 자동으로 걸러주지는 않는다. 참조가 실제로 안정될 때만 이득이 있다. 이벤트 중복 발화를 확실히 줄인 건, effect deps에서 통째 객체 대신 실제로 쓰는 원시값(예: searchParams.get('from'))으로 좁힌 쪽이었다.
  • 이 메모된 객체는 공유하기 버튼의 additionalEventData로도 재사용된다. 같은 참조를 여러 소비처가 나눠 쓰니 생성 비용과 참조 불일치를 동시에 줄인다.
<ShareButton
  screen="기획전 상세"
  customEventName="promotion_detail_click"
  additionalEventData={promotionDetailEventData}
/>

오늘의 결론

  • 리렌더는 함수 컴포넌트를 “다시 호출”하는 것이고, 그 안의 함수·객체는 전부 새로 만들어진다.
  • 새 참조가 memo를 뚫고 useEffect deps를 흔들면, 같은 이벤트가 두세 번 중복 발화된다. (재할당 자체가 메모리 누수를 만드는 건 아니다 — 누수는 정리 누락에서 온다.)
  • 그래서 “이 객체/함수의 참조를 안정시켜야 하는가?”를 먼저 묻는다. 답이 yes면 useMemo/useCallback. 특히 effect deps에 들어가는 객체는 참조 안정화 1순위 후보다.
  • 다만 무지성 memoize는 또 다른 문제(다음 편에서 다룬다)를 부른다. 참고 자료를 뒤져봐도 결론은 늘 “측정하고, 참조가 실제로 문제를 일으키는 곳만 잡아라”로 수렴한다.

관련 작업

  • promotionDetailEventData를 useMemo로 묶어 오브젝트 재생성 방지, 공유하기 클릭시 promotion_detail_click 추가
  • view_event 의존성 배열 요소 변경 — searchParams.get(‘from’)에 의존하도록 변경 (deps로 인한 이벤트 오작동 사례)