React.memo가 안 듣는 이유 — 200번 리렌더를 0으로 만든 조건
프론트엔드 성능 측정 시리즈
(4편)- 1.채용공고 1순위가 성능 최적화인데, 내 라이브러리엔 React.memo가 0개였어요
- 2.프론트엔드 성능 측정 도구 지도 — 어떤 질문에 어떤 도구를 쓰나
- 3.React.memo가 안 듣는 이유 — 200번 리렌더를 0으로 만든 조건
- 4.React Profiler를 스크린샷 말고 코드로 — 실제 컴포넌트 재보기
1편에서 제 라이브러리를 세어보니 이랬어요.
그때 "이 useCallback 16개가 효과가 있는지 없는지 모르겠다"고 썼는데, 이번에 실제로 재봤어요. 결론부터 말하면 효과가 정확히 0이었어요.
네 가지 조합을 재봤어요
행 200개짜리 리스트를 만들고, 부모 상태를 한 번 바꿨을 때 자식이 몇 번 다시 그려지는지 셌어요. 조합은 네 가지예요.
자식 memo | 콜백 | 리렌더 | |
|---|---|---|---|
| A | ❌ | 인라인 화살표 | 200 |
| B | ✅ | 인라인 화살표 | 200 |
| C | ✅ | 안정된 참조 | 0 |
| D | ❌ | 안정된 참조 | 200 |
실행 결과 그대로예요.
C만 0이에요. 나머지 셋은 전부 200이고요.
여기서 두 가지가 드러나요. memo만 붙이면(B) 아무 일도 안 일어나고, useCallback만 쓰면(D) 역시 아무 일도 안 일어나요. 둘 다 있어야 처음으로 0이 돼요.
그리고 D가 지금 hanui 상태예요. useCallback은 16개 쓰고 memo는 0개. 비용은 치르고 효과는 못 받는, 정확히 그 칸이었어요.
실험 코드는 저장소에 넣어뒀어요. 직접 돌려볼 수 있어요.
왜 B가 200인가
React.memo는 이전 props와 새 props를 얕은 비교해요. 같으면 다시 그리지 않고 넘어가요.
문제는 함수도 값이라는 거예요.
부모가 다시 그려질 때마다 이 화살표 함수는 새로 만들어져요. 내용은 같아도 참조가 달라요. memo는 Object.is로 비교하니까 "props가 바뀌었네" 하고 자식을 다시 그려요.
memo를 붙였는데 효과가 없다면 십중팔구 이거예요. 비교 비용만 추가로 내고 있는 셈이죠.
왜 D가 200인가
반대 경우도 마찬가지예요. 콜백 참조를 아무리 안정시켜도, 자식이 memo로 감싸져 있지 않으면 부모가 그려질 때 같이 그려져요. props가 같든 다르든 상관없어요.
useCallback이 하는 일은 "참조를 유지하는 것"뿐이에요. 그 참조를 받는 쪽이 비교를 안 하면 아무 의미가 없어요.
이게 1편에서 말한 미스매치의 실체예요. 이력서에 "useCallback으로 렌더링 최적화"라고 적었는데 memo가 하나도 없으면, 실제로 줄어든 리렌더는 0이에요.
그럼 useCallback deps에 state를 넣으면?
여기서 진짜 문제가 시작돼요. 콜백이 상태를 읽는 순간 곤란해져요.
[]로 두면 참조는 안정적인데 값이 낡아요(stale closure). [count]를 넣으면 값은 최신인데 count가 바뀔 때마다 새 함수가 만들어져서 자식 memo가 깨져요.
| 원하는 것 | useCallback(fn, [count]) | useCallback(fn, []) |
|---|---|---|
| 최신 상태 읽기 | ✅ | ❌ 낡은 값 |
| 참조 안정 (memo 유지) | ❌ 매번 새 함수 | ✅ |
둘이 정면으로 충돌해요. useCallback만으로는 이 둘을 동시에 못 가져요.
ref로 우회하기
오래 쓰인 우회법이 있어요. 최신 값을 ref에 담아두는 거예요.
useRef가 돌려주는 객체는 참조가 절대 안 바뀌어요. .current만 갈아끼우는 거죠. 콜백은 실행되는 시점에 ref.current를 읽으니까 항상 최신 값을 봐요.
동작은 하는데, 상태가 늘어나면 관리가 나빠져요. 초기값 나열 한 번, 매 렌더 대입 한 번 — 같은 목록을 두 군데 적어야 해요. 상태가 열 개를 넘어가면 두 목록이 어긋나기 시작하고, 한쪽에 새 상태를 빠뜨리면 그 콜백만 조용히 옛날 값을 봐요. 에러도 안 나요.
훅으로 감추기
그래서 이 패턴은 훅 안에 넣는 게 맞아요. 값이 아니라 함수 자체를 ref에 담는 방식이에요.
쓰는 쪽은 그냥 평범한 클로저처럼 써요.
돌려주는 건 ref.current(...args)를 호출하는 껍데기예요. 껍데기는 useCallback([])이라 참조가 고정되고, 안에 든 실제 함수는 매 렌더 최신으로 교체돼요. 최신 값과 안정된 참조를 동시에 갖는 거죠.
상태를 나열할 필요가 없다는 게 핵심이에요. 새 상태를 추가해도 훅 밖에서 할 일이 없으니, 아까 말한 "한쪽을 빠뜨려서 생기는 stale 버그"가 구조적으로 불가능해져요.
useLayoutEffect를 쓴 이유가 있어요. 커밋 직후 즉시 ref를 갱신해야, 그 사이에 발생한 이벤트 핸들러가 최신 함수를 봐요. 일반 useEffect는 페인트 이후라 타이밍이 미묘해져요.
참고로 React 팀도 비슷한 걸 useEffectEvent라는 이름으로 만들고 있어요. 다만 아직 실험 단계고, 자식에게 prop으로 넘기는 걸 lint가 막고 있어서 지금 이 용도로는 못 써요.
React Compiler가 있으면 필요 없지 않나요
React 19의 컴파일러가 useMemo/useCallback을 자동으로 넣어줘요. 그래서 수동 최적화가 필요 없어진다고들 하죠. 대체로 맞아요.
다만 컴파일러가 보는 범위는 컴포넌트 함수 스코프예요. 이런 건 시야 밖으로 나가요.
가상화 리스트의 renderItem처럼 콜백을 넘겨받아 실행하는 구조가 대표적이에요. 이런 경계에선 여전히 손으로 잡아야 해요.
hanui는 아직 React 18이라 컴파일러 얘기 자체가 해당 없고요. 쓰고 계신 버전을 먼저 확인하는 게 순서예요.
참조 안정성이라는 더 넓은 원칙
콜백만의 얘기가 아니에요. 파생 데이터를 만들 때도 같은 함정이 있어요.
값은 똑같은데 참조가 매번 새로 생겨요. 그래서 파생 객체를 만들 때는 바뀐 항목만 새로 만들고 나머지는 이전 참조를 그대로 재사용하는 게 중요해요.
원칙 하나로 줄이면 이거예요. 값이 같으면 참조도 같게 유지한다. memo가 작동하는 조건이 결국 이것 하나거든요.
언제 이게 필요한가
솔직히 말하면 일반 CRUD 화면에선 거의 안 써요. 폼 몇 개, 목록 스무 줄짜리 화면에서 이걸 하면 과최적화예요.
효과가 나는 건 조합이 맞을 때예요. 항목이 많고, 부모 상태가 자주 바뀌는 화면.
- 가상화 리스트 (수백~수천 행)
- 트리 — 한 노드를 펼칠 때 나머지가 전부 다시 그려지는 경우
- 지도·캔버스 — 좌표가 자주 갱신되고 마커가 많은 경우
- 드래그앤드롭 — 위치 상태만 빠르게 바뀌고 나머지는 그대로
이 조건이 아니면 memo를 붙일수록 손해예요. 비교 비용은 공짜가 아니니까요.
면접에서는
1편에서 채용공고 1순위가 성능 최적화라고 했는데, 이 주제에서 갈리는 지점이 명확해요.
꼭 설명할 수 있어야 하는 것
React.memo가 얕은 비교를 한다는 것- 함수·객체 리터럴은 매 렌더 새 참조라는 것
useCallback의 deps가 stale과 stable 사이의 선택이라는 것
있으면 좋은 것
- ref로 우회하는 패턴과 그 이유
useLayoutEffect와useEffect의 타이밍 차이- 컴파일러가 못 잡는 경계
굳이 안 외워도 되는 것
useEventCallback구현 세부 — 필요할 때 짜면 돼요
미들급이면 앞의 셋을 정확히 말할 수 있으면 충분해요. "성능 최적화 경험 있는 시니어"를 뽑는 자리라면 가운데까지 필요하고요.
그리고 어느 쪽이든, "memo 사용 경험"보다 "200 → 0"이 훨씬 강해요. 이 글의 표가 그 숫자를 만드는 방법이에요.
hanui는 어떻게 할 거냐면
memo를 146개 파일에 붙이진 않을 거예요. 위 조건에 해당하는 게 별로 없거든요.
.map()으로 리스트를 그리는 컴포넌트가 29개인데, 그중 행이 수백 개까지 갈 수 있는 건 테이블 정도예요. 거기부터 재고, 실제로 느리면 그때 memo + 안정된 콜백을 세트로 넣을 거예요. 하나만 넣으면 오늘 본 대로 0이니까요.
useCallback 16개도 다시 볼 거예요. 훅 안에서 소비자에게 내보내는 것들은 그대로 두는 게 맞고(외부에서 memo를 쓸 수 있으니까), 내부에서만 쓰이면서 memo된 자식이 없는 건 정리 대상이에요.
다음 편
다음은 오늘의 합성 예제 말고 실제 hanui 컴포넌트를 재볼게요. <Profiler> API로 DevTools와 같은 데이터를 코드로 받아서, DataTable이 행 50/200/1000개일 때 어떻게 달라지는지 확인해요.
관련 링크
- useCallback — React 공식 문서
- Before You memo() — Dan Abramov
- HANUI — KRDS 기반 디자인 시스템
- claude-settings —
/perf,/optimize커맨드 저장소
시리즈: 프론트엔드 성능 측정
- 1편: 채용공고 1순위가 성능 최적화인데, 내 라이브러리엔 React.memo가 0개였어요
- 2편: 성능 측정 도구 지도 — 어떤 질문에 어떤 도구를 쓰나
- 3편: React.memo가 안 듣는 이유 — 200번 리렌더를 0으로 만든 조건 ← 현재 글
- 4편: React Profiler를 스크린샷 말고 코드로 — 실제 컴포넌트 재보기
HANUI
KRDS 기반 React 컴포넌트 라이브러리. 공공 웹 개발을 더 쉽게.