React 재렌더링과 참조 불안정이 이벤트 중복 발화로 이어지기까지
시리즈 ‘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를 뚫고useEffectdeps를 흔들면, 같은 이벤트가 두세 번 중복 발화된다. (재할당 자체가 메모리 누수를 만드는 건 아니다 — 누수는 정리 누락에서 온다.) - 그래서 “이 객체/함수의 참조를 안정시켜야 하는가?”를 먼저 묻는다. 답이 yes면
useMemo/useCallback. 특히 effect deps에 들어가는 객체는 참조 안정화 1순위 후보다. - 다만 무지성 memoize는 또 다른 문제(다음 편에서 다룬다)를 부른다. 참고 자료를 뒤져봐도 결론은 늘 “측정하고, 참조가 실제로 문제를 일으키는 곳만 잡아라”로 수렴한다.
관련 작업
- promotionDetailEventData를 useMemo로 묶어 오브젝트 재생성 방지, 공유하기 클릭시 promotion_detail_click 추가
- view_event 의존성 배열 요소 변경 — searchParams.get(‘from’)에 의존하도록 변경 (deps로 인한 이벤트 오작동 사례)