React 재렌더링과 함수 재할당이 OOM으로 이어지기까지
시리즈 ‘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. 여기서 메모리로 번진다
effect가 매번 돌면서 그 안에서 타이머·리스너·구독을 만들거나, 매 렌더마다 새 클로저가 이전 스코프를 붙잡고 있으면, GC가 회수하지 못한 참조가 쌓인다. 리스트가 길거나 화면 전환이 잦은 페이지에서 이게 누적되면 결국 메모리가 계속 우상향하고, 심하면 OOM(Out Of Memory)까지 간다. 한 번의 재할당은 싸지만, “매 렌더 × 항목 수 × 체류 시간”으로 곱해지면 싸지 않다.
해결: 참조를 안정화한다 (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 산출물)로 바꿨다. 이제data의 참조가 흔들려도 실제 사용하는 필드가 그대로면 effect는 다시 돌지 않는다. - 이 메모된 객체는 공유하기 버튼의
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로 인한 이벤트 오작동 사례)