작년 10월에 React Compiler 1.0이 나왔을 때, 솔직히 반신반의했다. Meta가 자기네 앱에서 잘 돌아간다는 건 알겠는데, 내가 관리하는 5년 된 Next.js 프로젝트에서도 그럴까. 반년 정도 프로덕션에서 돌린 지금, 이야기할 게 좀 쌓였다.
300개의 useMemo가 사라진 월요일
Next.js 16.2에서 React Compiler 지원이 stable로 올라온 걸 보고 바로 켰다. next.config.ts에 옵션 하나 추가하고 빌드 돌리니까, eslint-plugin-react-hooks가 "이 useMemo는 불필요합니다"를 300개 넘게 뱉어냈다. 손으로 넣었던 useMemo, useCallback, React.memo — 전부 컴파일러가 빌드 타임에 자동으로 처리해버린다.
원리 자체는 직관적이다. 컴파일러가 컴포넌트의 데이터 흐름과 변경 가능성을 정적 분석해서, 값이 안 바뀌면 캐시하고, 콜백 참조를 유지하고, JSX 출력이 동일하면 자식 리렌더를 스킵한다. 개발자가 수동으로 하던 걸 빌드 단계에서 자동화한 거다.
재밌는 건 사람보다 더 세밀하다는 점이다. useMemo는 의존성 배열 전체를 비교하지만, 컴파일러는 실제로 사용되는 값만 추적한다. 조건 분기 안쪽에서만 참조되는 값까지 잡아내는데, 이건 사람 손으로 최적화하려면 코드가 너무 지저분해져서 포기했던 영역이다. 거기를 기계가 묵묵히 처리해주니까 좀 허탈하기도 하다.
숫자로 보면
Meta Quest Store 기준 초기 로딩 12% 개선, 인터랙션 속도 2.5배. Wakelet은 INP가 확 줄었다고 했다. 내 프로젝트는 Lighthouse Performance가 88에서 93으로 올랐다. 번들은 컴파일러 런타임 코드 포함해서 미세하게 늘었지만, 체감 성능은 확실히 달라졌다. 메모리 사용량은 전후 차이 없었다.
컴파일러가 못 고치는 영역
여기서부터가 진짜다.
올해 초 Dom Wozniak이 쓴 글이 정확히 이걸 짚었다. PagerView 안에 52주치 달력 — WeekStripDay 컴포넌트 364개가 초기 마운트 시 한 방에 생성되는 구조. 컴파일러가 각 컴포넌트의 리렌더를 아무리 잘 막아봐야, "364개를 동시에 마운트하지 말자"는 판단은 못 내린다. FlatList로 교체해서 가상화 적용하는 건 여전히 사람 몫이다. 렌더링 최적화 도구한테 아키텍처 결정을 기대하면 안 된다.
내가 직접 밟은 지뢰도 있다:
// 컴파일러가 잘 처리하는 영역
const filtered = items.filter(item => item.active);
const sorted = filtered.sort((a, b) => a.name.localeCompare(b.name));
// 컴파일러가 손 못 대는 영역
useEffect(() => {
fetchItems(); // 부모에서 이미 fetch 했는데 또 호출
fetchCategories(); // 직렬 워터폴
fetchUserPrefs(); // 또 직렬 워터폴
}, []);
fetch 워터폴, API 중복 호출, 컴포넌트 트리 구조의 비효율 — 이건 프로파일러 열어서 사람이 판단해야 한다. "컴파일러 켰으니 성능 끝"이라는 마인드셋이 제일 위험하다. 오히려 마이크로 최적화를 기계가 맡아주니까, 이제야 진짜 중요한 매크로 레벨 병목에 집중할 시간이 생긴 거다.
라이브러리 호환성도 체크해야 한다. React Hook Form이 대표적인데, 내부에서 React 규칙을 살짝 우회하는 패턴을 쓰는 라이브러리들은 정적 분석과 충돌한다. 우리 프로젝트에서는 폼 관련 컴포넌트만 컴파일러 대상에서 제외하고 나머지를 먼저 적용했다.
도입 방법
전부 한 번에 적용하겠다는 욕심은 접는 게 좋다. 세 가지 점진적 전략이 있다.
Babel overrides로 디렉토리 단위 적용. 컴포넌트에 "use memo" 디렉티브를 넣어서 명시적 옵트인. 피처 플래그로 런타임에 토글. 한 페이지씩 돌려보면서 React DevTools Profiler 확인하는 게 현실적인 경로다. Next.js 16.2는 설정 한 줄이고, Expo SDK 54는 기본 활성화, Vite는 Babel 플러그인으로 붙인다.
그래서 useMemo 다 지워도 되나
"지워도 된다"보다 "새로 안 써도 된다"가 정확하다. 기존 코드에 뿌려둔 useMemo와 useCallback은 컴파일러가 더 세밀한 방식으로 대체해준다. 단, useEffect 의존성 배열에서 참조 동일성이 중요한 특수 케이스에서는 수동 메모이제이션이 escape hatch로 남아 있다. React 팀도 완전히 없애라고는 안 했다.
5년 동안 성능 튜닝이라면서 useMemo 뿌리고 다녔는데, 결국 그 시간의 대부분은 기계가 더 잘하는 일에 쓰고 있었던 거다. 이제 그 뇌 용량을 "이 컴포넌트가 왜 여기 있지?"에 쓸 수 있다. 솔직히 이게 자동 메모이제이션보다 더 큰 변화 같다.