React Compiler를 켰는데 왜 아직 useCallback과 memo를 쓰고 있을까?

"React Compiler 켜면 useCallback이랑 memo 다 필요 없는 거 아니야?"
최근 프로젝트 코드 리뷰에서 이런 질문을 받았다. 충분히 나올 수 있는 질문이다. 2025년 10월 React Conf 2025에서 React Compiler 1.0이 정식 릴리스되면서, 빌드 타임에 컴포넌트와 훅을 자동으로 메모이제이션해주는 시대가 열렸기 때문이다. useMemo, useCallback, React.memo를 손으로 붙이던 노동에서 어느 정도 해방된다는 것이 이 도구의 핵심 약속이다.
그런데 내 프로젝트에는 컴파일러가 켜져 있는데도 useCallback과 memo가 꽤 남아 있었다. 모순처럼 보이지만, 사실 여기엔 "성능 최적화"라는 단어 하나로 뭉뚱그릴 수 없는 이야기가 있다.
이 글은 React Compiler를 켠 코드베이스에서 어떤 수동 메모이제이션은 제거하고, 어떤 것은 남길 수 있는지에 대한 판단 기록이다.
먼저, 컴파일러는 진짜 켜져 있다
오해를 막기 위해 상태부터 확인하자.
// next.config.ts
const nextConfig: NextConfig = {
reactCompiler: true,
};
// package.json
"babel-plugin-react-compiler": "1.0.0"
reactCompiler: true로 프로젝트 차원에서 React Compiler를 활성화했다. 다만 여기서 짚어둘 점이 있다. 이 설정이 있다고 해서 모든 파일과 모든 함수가 무조건 최적화되는 것은 아니다. 실제 최적화 대상은 Next.js와 Compiler의 추론 규칙에 따라 React 컴포넌트와 훅으로 판단되는 코드다. "프로젝트에서 컴파일러가 켜져 있다"와 "모든 코드가 컴파일러에 의해 최적화된다"는 같은 말이 아니다.
그래도 이 프로젝트의 전제는 분명하다. 컴파일러를 안 써서 어쩔 수 없이 수동 메모이제이션을 한 것이 아니라, 컴파일러를 켠 상태에서 의도적으로 일부 코드를 남긴 것이다.
컴파일러와 수동 메모이제이션은 충돌하지 않는가
본론에 들어가기 전에 자주 받는 질문 하나를 짚고 가자.
"컴파일러가 자동으로 메모이제이션하는데
useCallback도 같이 쓰면 이중으로 감싸지는 거 아닌가?"
핵심은 "같은 대상을 단순히 두 번 감싸서 성능이 망가진다"가 아니다. React Compiler는 기존의 useMemo, useCallback, React.memo 호출을 보존하려고 한다. 수동 메모이제이션이 있다면, 컴파일러는 그 코드에 이유가 있다고 보고 함부로 제거하지 않는다.
다만 한 가지 주의할 점이 있다. 부정확한 dependency 배열은 컴파일러가 코드의 데이터 흐름을 이해하는 것을 방해할 수 있다. dependency가 빠진 useCallback이나 useMemo가 있으면, 컴파일러가 해당 컴포넌트나 훅에 추가 최적화를 적용하지 못할 수 있다.
그래서 수동 메모이제이션을 남길 때 기준은 더 엄격해진다. 남길 거라면 dependency가 정확해야 하고, 왜 남겼는지 설명할 수 있어야 한다.
여기서부터가 본론이다. 왜 자동 메모이제이션이 돌아가는 환경에서 수동 메모이제이션을 남겼는가?
useCallback이 전부 "성능"을 위한 건 아니다
핵심 통찰부터 말하면 이렇다. 현재 프로젝트의 useCallback에는 성격이 다른 세 종류가 섞여 있었다.
- effect / event listener의 참조 안정성
- custom hook 사이의 콜백 계약
- 비싼 렌더링 경계에 대한 명시적 방어
이 중 컴파일러가 깔끔하게 대체해줄 수 있는 것은 일부다. 나머지는 단순히 렌더 횟수를 줄이기 위한 코드라기보다, effect 경계와 생명주기 의도를 드러내는 장치에 가까웠다. 하나씩 보자.
1. effect와 event listener의 참조 안정성
채팅 워크스페이스에는 스크롤 상태를 추적하는 리스너가 있다.
useEffect(() => {
const element = scrollRef.current;
if (!element) return;
element.addEventListener("scroll", updateScrollState, { passive: true });
return () => element.removeEventListener("scroll", updateScrollState);
}, [updateScrollState]);
여기서 updateScrollState가 매 렌더마다 새 함수로 생성되면, effect의 dependency가 매번 바뀌면서 리스너를 떼고 다시 붙이는 일이 반복될 수 있다.
이때 useCallback을 붙이는 이유는 단순히 "자식 리렌더링을 줄이기 위해서"가 아니다. 이 함수는 effect의 dependency로 쓰이고, event listener의 setup/cleanup과 같은 identity를 공유해야 한다. 즉 이 함수의 참조 안정성은 렌더링 비용뿐 아니라 외부 시스템과의 동기화 방식에 영향을 준다.
물론 여기에도 선택지는 있다. 어떤 함수가 effect 내부에서만 쓰인다면, 굳이 useCallback을 쓰기보다 함수를 effect 안으로 옮겨 dependency 자체를 줄이는 편이 더 단순할 수 있다. 하지만 이 프로젝트의 경우 updateScrollState는 effect 경계 밖에서도 의미가 있고, 스크롤 상태 관리 로직의 일부로 명시적으로 분리되어 있었다. 그래서 이 경우에는 useCallback을 남겨두는 쪽이 더 읽기 쉬웠다.
이 함수는 단순한 렌더 최적화용 콜백이 아니다. DOM event listener의 생명주기와 연결된 콜백이다.
2. custom hook 사이의 콜백 계약
스트리밍 로직은 책임별로 훅을 나눠 두었다.
ChatApp
└─ useChatStream
└─ useChatSessionMessagesuseChatStream은 아래 콜백들을 인자로 받는다.
useChatStream({
appendAssistantChunk,
completeAssistantMessage,
failAssistantMessage,
startOptimisticTurn,
});
이 함수들은 스트림 reader, 에러 복구, cleanup 로직과 맞물려 동작한다. 예를 들어 appendAssistantChunk는 스트림 reader가 살아 있는 동안 반복적으로 호출되는 콜백이다.
// useChatStream 내부 (개념도)
const reader = response.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
appendAssistantChunk(chunk);
}
여기서 주의할 점이 있다. appendAssistantChunk의 참조가 바뀐다고 해서 이미 실행 중인 async loop의 클로저가 자동으로 새 함수로 갈아끼워지는 것은 아니다. JavaScript는 그렇게 동작하지 않는다.
실제 문제는 다른 곳에 있다. 이 콜백이 effect dependency에 포함되어 있다면, 콜백 참조 변경이 cleanup, abort, stream restart를 유발할 수 있다. 반대로 dependency에서 빼버리면 오래된 콜백을 캡처하는 stale closure 문제가 생길 수 있다.
즉 스트리밍 훅에서 중요한 것은 "함수 하나를 메모이제이션했는가"가 아니라, 스트림 생명주기 동안 콜백 참조를 어떤 전략으로 다룰 것인가다. 호출자 쪽에서 useCallback으로 안정화할 수도 있고, 훅 내부에서 ref를 사용해 최신 콜백을 읽도록 설계할 수도 있다. 중요한 것은 호출자와 훅 구현자가 이 전략을 공유해야 한다는 점이다.
이 프로젝트에서는 호출자 쪽 콜백을 useCallback으로 안정화하는 방식을 택했다. useCallback은 그 계약을 런타임에서 강제하는 도구는 아니다. 하지만 코드를 읽는 사람에게 "이 콜백들은 단순 이벤트 핸들러가 아니라 생명주기 경계에 있는 함수"라는 의도를 드러낸다.
이런 경우의 useCallback은 단순 성능 최적화라기보다, custom hook API의 사용 의도를 표현하는 장치에 가깝다.
3. 비싼 렌더링 경계에 대한 명시적 방어
메시지 렌더링 컴포넌트에는 memo를 붙였다.
export const MessageBubble = memo(MessageBubbleBase);
export const MarkdownMessage = memo(MarkdownMessageBase);
특히 마크다운 렌더링은 일반 텍스트보다 파싱과 변환 비용이 클 수 있다. 메시지 리스트처럼 같은 화면에 여러 개가 쌓이는 UI에서는 부모 상태 변화가 전체 메시지 렌더링으로 번지는 것을 조심해야 한다.
다만 여기서는 표현을 정확히 해야 한다. memo는 "props가 같으면 절대 다시 렌더링하지 않는다"는 보장이 아니다. React 공식 문서도 memo는 성능 최적화이지 보장이 아니며, React가 여전히 리렌더링할 수 있다고 설명한다. 부모의 state 변경, context 변경, key 변경 등으로 memo 컴포넌트도 다시 렌더링될 수 있다.
따라서 이 코드의 의미는 이렇게 보는 것이 맞다.
이 컴포넌트는 props가 같을 때 가능한 한 리렌더링을 건너뛰는 것이 중요하다는 의도를 남긴다.
솔직하게 말하면, 이 영역이 React Compiler와 가장 많이 겹친다. 단순 props 전달 경계에서의 memo는 컴파일러가 충분히 처리할 수 있는 영역이다. 그래서 MessageBubble과 MarkdownMessage의 memo는 영구적으로 남겨야 할 코드라기보다, 후속 리팩토링 후보에 가깝다.
다만 제거할 때는 감으로 지우지 않는다. 메시지 리스트와 마크다운 렌더링은 실제 사용자 체감 성능에 영향을 줄 수 있는 영역이므로, React Profiler로 제거 전후를 확인한 뒤 판단하는 것이 맞다.
즉 이 부분의 결론은 이렇다. memo를 남길 수는 있지만, Compiler 시대에는 가장 먼저 검증하고 줄여볼 후보이기도 하다.
컴파일러가 자동으로 해결해주지 않는 것들
React Compiler는 강력하지만, 모든 문제를 해결해주는 도구는 아니다.
컴파일러는 React 렌더링 경로의 메모이제이션을 자동화해준다. 하지만 WebSocket, stream reader, DOM event listener, timer 같은 외부 시스템의 생명주기를 대신 설계해주지는 않는다. 다음과 같은 문제는 여전히 개발자가 직접 다뤄야 한다.
- 언제 구독을 시작할 것인가
- 언제 cleanup할 것인가
- abort는 어떤 타이밍에 발생해야 하는가
- 오래된 closure를 어떻게 피할 것인가
- 외부 listener에 같은 함수 identity를 넘겨야 하는가
이런 문제는 React Compiler가 "자동 메모이제이션"으로 해결해주는 영역이 아니다. 컴파일러는 렌더링 과정에서 불필요한 재계산과 리렌더링을 줄여줄 수 있지만, 외부 시스템과의 연결·해제·중단 타이밍까지 대신 결정하지 않는다.
또한 React Compiler는 정적 분석 기반이므로 Rules of React를 지키는 코드일수록 잘 동작한다. React 공식 문서도 Compiler가 지원하지 않는 패턴이나 Rules of React 위반을 감지하면 해당 컴포넌트와 훅을 건너뛸 수 있다고 설명한다.
그래서 수동 메모이제이션을 남길지 판단할 때는 질문을 바꿔야 한다.
예전 질문은 이랬다.
이걸 메모이제이션하면 렌더링이 줄어드나?
React Compiler를 켠 뒤의 질문은 이쪽에 가깝다.
이 메모이제이션은 Compiler가 대체 가능한 단순 렌더 최적화인가, 아니면 외부 생명주기와 의도를 표현하는 코드인가?
그래서, 뭘 줄일 수 있나
균형이 중요하다. "컴파일러를 켰으니 useCallback은 전부 필요 없다"도 틀렸고, "그래도 다 남겨두는 게 맞다"도 틀렸다.
컴파일러가 켜진 환경에서 줄일 수 있는 후보는 다음과 같다.
- 단순히 자식 컴포넌트에 prop으로 넘기기만 하는
useCallback - effect 내부로 옮기면 사라질 수 있는 함수 dependency
- 비용이 크지 않은 컴포넌트의
React.memo - 렌더 타이밍과 무관한 순수 계산용
useMemo - 이유를 설명할 수 없는 관성적인 메모이제이션
반대로 남겨둘 가치가 있는 영역은 다음과 같다.
- effect dependency로 쓰이며, effect 내부로 이동하기 어렵고 참조 변경이 실제 재구독 비용에 영향을 주는 콜백
- DOM event listener, subscription, stream cleanup처럼 setup/cleanup과 같은 identity를 공유해야 하는 함수
- custom hook 경계를 넘고, 호출자와 훅 구현자가 안정성 전략을 공유해야 하는 생명주기 콜백
- Profiler로 비용이 확인된 렌더링 경계의
memo - Compiler가 분석하기 어려운 패턴 주변에서 명시적 제어가 필요한 코드
판단 기준을 한 문장으로 줄이면 이렇다. 이 메모이제이션이 단지 렌더 횟수를 줄이는 용도인가, 아니면 동작의 정합성이나 의도를 표현하는 용도인가. 전자라면 컴파일러에 맡기고, 후자라면 남길 수 있다.
단, 남기는 순간 책임도 생긴다. dependency 배열은 정확해야 하고, 왜 필요한지 설명할 수 있어야 한다.
도입할 때 알아두면 좋은 것
컴파일러를 켤 때 한 가지 운영상 주의점이 있다. React Compiler는 자동 메모이제이션을 수행하는 빌드 타임 도구다. 버전 변화에 따라 최적화 결과가 달라질 수 있으므로, 자동 업그레이드보다는 버전을 고정(--save-exact)하고 변경 시 직접 검증하는 쪽이 안전하다.
도입 전에는 eslint-plugin-react-hooks의 recommended 프리셋으로 Rules of React 위반과 dependency 문제를 먼저 점검하는 것을 권한다. 컴파일러는 규칙을 잘 지킨 코드일수록 더 안전하게 최적화할 수 있다.
자동 메모이제이션은 "이제 아무것도 신경 쓰지 않아도 된다"는 뜻이 아니다. 오히려 반대에 가깝다. 컴파일러가 많은 메모이제이션을 대신해주기 때문에, 남겨둔 수동 메모이제이션은 더 분명한 이유를 가져야 한다.
정리
이번 프로젝트를 한 줄로 요약하면 이렇다.
- React Compiler: 프로젝트 차원에서 활성화되어 있음
- useCallback / memo: 일부는 참조 안정성·의도 표현용으로 유지
- 후속 리팩토링 대상: 단순 props 전달 경계의
useCallback, 비용이 검증되지 않은memo - 판단의 축: 스트리밍·스크롤·타이머처럼 외부 생명주기와 묶인 로직은 명시성을 우선
React Compiler는 분명 수동 메모이제이션의 상당 부분을 줄여준다. 하지만 effect, 외부 구독, custom hook 경계, 스트림 생명주기처럼 렌더링 바깥의 세계와 연결되는 지점에서는 useCallback과 memo가 여전히 의미를 가질 수 있다.
다만 그 의미는 예전과 달라졌다. React Compiler 이전에는 수동 메모이제이션이 성능 최적화의 기본 도구처럼 쓰였다. React Compiler 이후에는 기본값이 아니라 예외에 가까워진다.
내가 가져갈 판단 기준은 명확하다.
React Compiler 시대의 수동 메모이제이션은 기본값이 아니라 예외여야 한다.
effect dependency, 외부 구독, 스트림 생명주기, custom hook API, Profiler로 확인된 고비용 렌더링 경계에서는 여전히 수동 메모이제이션이 의도를 표현하는 도구가 될 수 있다. 하지만 중요한 건 남겼다는 사실이 아니다. 왜 남겼는지 설명할 수 있느냐이다.
컴파일러를 켜면 "왜 이걸 메모이제이션했지?"라는 질문에 답할 수 없는 useCallback과 memo는 사라져야 한다. 답할 수 있는 것만 남는다. 그게 컴파일러가 켜진 코드베이스에서 수동 메모이제이션이 가지는 새로운 의미라고 본다.