useMemo, 컴파일러에 맡겨도 될까

ReactNext.js

2025. 10. 13.

2025년 10월에 쓴 글을 2026년 9월에 다시 썼습니다.

2025년 10월, Next.js 16 베타가 나온 직후에 이 주제로 글을 썼습니다. 다시 읽어 보니 발표 자료를 옮겨 적은 수준이었습니다. 컴파일러가 "알아서 최적화해 준다"고 써 놓고, 정작 컴파일러가 뭘 만드는지는 한 번도 열어 보지 않았습니다.

그래서 이번엔 직접 돌려 봤습니다. babel-plugin-react-compiler 1.0으로 예제 컴포넌트와 이 블로그 코드를 컴파일하고, 결과를 보면서 원래 질문에 다시 답해 봤습니다.

처음 질문

1function ProductList({ products, onSelect }) {
2  const filtered = products.filter((p) => p.inStock);
3
4  return (
5    <ul>
6      {filtered.map((p) => (
7        <ProductCard key={p.id} product={p} onSelect={() => onSelect(p.id)} />
8      ))}
9    </ul>
10  );
11}

예전 같으면 여기서 고민이 시작됩니다. filtered는 useMemo로 감쌀지, onSelect에 넘기는 화살표 함수는 useCallback으로 뺄지. 팀마다 답이 다르고, 결국 "일단 다 감싸자"가 이기는 경우가 많았습니다.

React 팀도 이걸 문제로 봤습니다. Meta 안에서 수동 메모이제이션이 들어간 PR은 전체의 8% 정도였는데, 그런 PR은 작성 시간이 31~46% 더 걸렸다고 합니다. 감싸는 것보다 의존성 배열을 맞추는 데 품이 들기 때문입니다.

컴파일러가 만든 코드

위 컴포넌트를 컴파일하면 이렇게 나옵니다.

1import { c as _c } from "react/compiler-runtime";
2
3function ProductList(t0) {
4  const $ = _c(7);
5  const { products, onSelect } = t0;
6
7  let t1;
8  if ($[0] !== onSelect || $[1] !== products) {
9    const filtered = products.filter(_temp);
10    let t2;
11    if ($[3] !== onSelect) {
12      t2 = (p_0) => <ProductCard key={p_0.id} product={p_0} onSelect={() => onSelect(p_0.id)} />;
13      $[3] = onSelect;
14      $[4] = t2;
15    } else {
16      t2 = $[4];
17    }
18    t1 = filtered.map(t2);
19    $[0] = onSelect;
20    $[1] = products;
21    $[2] = t1;
22  } else {
23    t1 = $[2];
24  }
25
26  let t2;
27  if ($[5] !== t1) {
28    t2 = <ul>{t1}</ul>;
29    $[5] = t1;
30    $[6] = t2;
31  } else {
32    t2 = $[6];
33  }
34  return t2;
35}
36
37function _temp(p) {
38  return p.inStock;
39}

처음 보면 낯설지만 규칙은 단순합니다.

_c(7)은 이 컴포넌트가 쓸 캐시 칸 7개로, 렌더링이 끝나도 남아 있습니다. 그다음부터는 지난번 값과 지금 값을 !==로 비교해서, 바뀌었으면 다시 계산하고 아니면 칸에 넣어 둔 결과를 꺼냅니다. useMemo가 하던 일을 코드로 풀어 쓴 셈입니다.

눈에 띈 건 두 가지였습니다. 하나는 <ul> JSX까지 캐시된다는 점입니다. 목록이 그대로면 지난번과 같은 엘리먼트를 돌려주니, React가 그 아래를 비교하지 않고 넘어갑니다. 다른 하나는 filter에 넘긴 콜백이 바깥 함수 _temp로 빠졌다는 점입니다. 아무것도 캡처하지 않으니 렌더링마다 새로 만들 이유가 없습니다.

손으로 썼다면 useMemo 하나, useCallback 하나, 의존성 배열 두 개였을 겁니다. 컴파일러는 의존성을 코드에서 읽어 내니까 배열을 틀릴 일이 없습니다.

조용히 건너뛰는 코드

여기까지는 기대한 대로였는데, 규칙을 어긴 코드를 넣어 보니 얘기가 달라졌습니다.

1function Counter({ items }) {
2  const renders = useRef(0);
3  renders.current += 1; // 렌더링 중에 ref를 읽고 쓴다
4
5  const total = items.reduce((s, i) => s + i.price, 0);
6  return <p>{total} ({renders.current})</p>;
7}

결과는 원본 그대로였습니다. _c도 없고 캐시 칸도 없습니다. 컴파일러 로그를 켜야 이유가 보입니다.

CompileError: Cannot access refs during render

빌드는 멀쩡히 성공합니다. 컴파일러는 확신할 수 없는 컴포넌트를 에러로 멈추지 않고, 그 컴포넌트만 최적화하지 않고 넘어갑니다. "컴파일러 켰으니 다 됐겠지" 하고 넘어가면, 정작 느린 컴포넌트가 이런 식으로 빠져 있어도 모릅니다.

기존 useMemo가 오히려 발목을 잡는 경우도 있었습니다.

1function Total({ items }) {
2  const total = useMemo(() => items.reduce((s, i) => s + i.price, 0), []);
3  return <p>{total}</p>;
4}

의존성 배열에 items를 빼먹은 코드입니다. 컴파일러는 이걸 고쳐 주지 않고, 컴포넌트 전체를 포기합니다.

CompileError: Existing memoization could not be preserved
The inferred dependency was `items`, but the source dependencies were [].

손으로 쓴 메모이제이션을 함부로 바꾸면 동작이 달라질 수 있으니, 맞지 않으면 아예 손대지 않습니다. 틀린 의존성 배열이 있던 컴포넌트는 컴파일러를 켜도 이득이 없습니다.

이런 걸 빌드 로그 대신 에디터에서 보려면 eslint-plugin-react-hooks 최신 버전을 쓰면 됩니다. 1.0 발표에서 컴파일러와 같은 규칙으로 검사하는 린트를 이 플러그인에 합쳤다고 합니다.

잡지 못한 버그

예전 글에는 컴파일러가 "Rules of React를 검증해서 숨어 있던 버그까지 찾아 준다"고 썼습니다. 이것도 확인해 봤습니다.

1function ProductList({ products }) {
2  const sorted = products.sort((a, b) => a.price - b.price);
3  return <ul>{sorted.map((p) => <li key={p.id}>{p.name}</li>)}</ul>;
4}

sort는 새 배열을 만들지 않고 원본을 정렬합니다. 부모에게 받은 products를 자식이 몰래 바꾸는 거라 명백한 규칙 위반입니다.

결과는 CompileSuccess였습니다. 경고 없이 캐시만 씌워서 컴파일됐습니다. 적어도 이 경우엔 버그를 찾아 주지 않았습니다.

그리고 이 코드, 예전 글 맨 처음에 "이걸 메모이제이션해야 할까요?"라고 올렸던 예제입니다. 메모이제이션을 고민하기 전에 toSorted()로 고칠 게 먼저였네요.

const sorted = products.toSorted((a, b) => a.price - b.price);

이 블로그에 돌려 보면

이 블로그 코드에도 같은 방식으로 돌려 봤습니다. .tsx 파일 37개에서 컴포넌트와 훅 54개가 나왔고, 전부 에러 없이 컴파일됐습니다. 직접 쓴 메모이제이션은 두 군데뿐인데(이미지 확대의 useCallback, About 페이지 폰 목업의 useMemo), 둘 다 그대로 보존된 채 통과했고, 폰 목업 컴포넌트는 캐시 칸을 146개나 받았습니다.

그래도 켜지는 않았습니다. 37개 파일 중 'use client'가 붙은 건 15개뿐이고, 나머지는 빌드 때 한 번 그리고 끝나는 서버 컴포넌트라 다시 렌더링될 일이 거의 없기 때문입니다.

그래서 useMemo는

돌려 보고 나니 답이 조금 달라졌습니다.

새로 쓰는 코드라면 컴파일러에 맡기고 useMemo, useCallback은 쓰지 않습니다. 다만 effect 의존성으로 쓰는 값처럼 언제 바뀌는지 정확히 정해야 할 때는 여전히 직접 씁니다. React 팀도 이걸 "탈출구"로 남겨 뒀습니다.

이미 있는 메모이제이션은 지우지 않습니다. 지우면 컴파일 결과가 달라질 수 있다는 게 공식 권고입니다. 대신 의존성 배열이 틀린 건 먼저 고쳐야 합니다. 위에서 봤듯이 틀린 채로 두면 그 컴포넌트 전체가 최적화에서 빠집니다.

컴파일러를 안 쓰는 프로젝트라면 예전 기준 그대로입니다. 먼저 재 보고, 계산이 1ms를 넘거나 memo로 감싼 자식에게 넘기는 값, Context에 넣는 객체 정도만 감쌉니다.

그리고 컴파일러가 캐시하는 건 컴포넌트와 훅 안쪽뿐입니다. 일반 유틸 함수는 그대로고, 같은 계산을 여러 컴포넌트가 하면 각자 따로 합니다.

Next.js에서 켜기

pnpm add -D babel-plugin-react-compiler
1// next.config.ts
2const nextConfig: NextConfig = {
3  reactCompiler: true,
4};

한 번에 켜기 부담스러우면 compilationMode: 'annotation'으로 두고, 켜고 싶은 컴포넌트 맨 위에 'use memo'를 적는 식으로 하나씩 늘려 갈 수 있습니다. 반대로 문제가 생긴 컴포넌트는 'use no memo'로 뺄 수도 있습니다.

Babel을 거치는 만큼 빌드는 조금 느려집니다. Next.js 16.3부터는 Babel 대신 Turbopack 안에서 도는 Rust 버전(experimental.turbopackRustReactCompiler)을 실험 기능으로 켤 수 있습니다.

아쉬운 점

성능은 재지 않았습니다. 이번엔 컴파일 결과만 봤습니다. Meta는 Quest Store에서 첫 로딩과 페이지 이동이 최대 12%, 일부 인터랙션은 2.5배 넘게 빨라졌다고 발표했지만, 내 코드에서 얼마나 나오는지는 Profiler로 직접 재 봐야 압니다.

예제가 작습니다. 컴파일러가 건너뛰는 경우는 여기서 본 두 가지 말고도 많습니다. 실제 프로젝트라면 린트부터 켜 보고 몇 개가 빠지는지 세어 보는 게 먼저라고 봅니다.

마치며

2025년 10월에는 "컴파일러가 알아서 해 준다"로 글을 끝냈는데, 열어 보니 컴파일러가 대신해 주는 건 의존성 배열을 맞추는 일까지였습니다. 규칙을 지키는 건 여전히 코드를 쓰는 쪽 몫이고, 지키지 않은 컴포넌트는 조용히 빠집니다.

그래도 useMemo를 쓸지 말지 PR마다 고민하던 시간은 확실히 줄어들 것 같습니다.

참고 자료

이 글이 도움되셨나요?

공유해주시면 더 많은 사람들이 볼 수 있어요!