← 목록으로
React Compiler 동작 원리

React는 상태가 바뀔 때마다 함수를 다시 실행합니다. 성능 최적화는 useMemo, useCallback, React.memo로 개발자가 직접 해야 했고, 이 수동 메모이제이션의 한계를 해결하기 위해 React Compiler가 등장했습니다.

수동 메모이제이션의 한계

useMemouseCallback은 hook이라서 조건문이나 early return 뒤에 올 수 없습니다.

function ThemeProvider(props) {
  // useMemo는 early return보다 위에 있어야 함
  const theme = useMemo(() => mergeTheme(props.theme), [props.theme]);

  if (!props.children) {
    return null; // children이 없어도 mergeTheme은 이미 실행됨
  }

  return <ThemeContext value={theme}>{props.children}</ThemeContext>;
}

의존성 배열도 문제입니다. 빠뜨리면 stale 값을 참조하고, 너무 많이 넣으면 캐시가 의미 없이 깨집니다.

컴파일러가 하는 일

React Compiler는 빌드 타임에 메모이제이션 코드를 자동 삽입하는 소스 코드 변환 도구입니다. 핵심은 세분화된 메모이제이션입니다.

방식캐싱 단위무효화 조건
React.memo컴포넌트 전체props 하나라도 변경
useMemo개발자가 감싼 범위deps 배열 변경
Compiler개별 표현식, JSX, 콜백해당 값의 의존성만 변경

컴파일러는 hook 규칙에 얽매이지 않으므로, 앞서 본 ThemeProvider도 early return 이후의 코드만 별도로 메모이제이션할 수 있습니다.

// 컴파일러가 변환한 ThemeProvider (개념적)
function ThemeProvider(props) {
  if (!props.children) {
    return null; // 여기서 반환되면 아래 코드는 실행되지 않음
  }

  // props.children이 있을 때만 theme 계산 + 캐싱
  const theme = mergeTheme(props.theme);  // ← 조건부로 메모이제이션됨
  return <ThemeContext value={theme}>{props.children}</ThemeContext>;
}

컴파일 파이프라인

Source Code
    │
    ▼
  [AST]          ← Babel이 파싱
    │
    ▼
  [HIR]          ← Control Flow Graph로 변환
    │
    ▼
  [SSA]          ← 변수마다 단일 할당
    │
    ▼
  [Effect 분석]   ← Read / Mutate / Freeze 분류
    │
    ▼
  [Reactive 분석] ← props/state 의존성 전파
    │
    ▼
  [Scope 생성]   ← 캐싱 단위 결정
    │
    ▼
  [Code 생성]    ← _c() 기반 메모이제이션 코드 출력

HIR — Control Flow Graph

AST는 구문 구조(syntax)를 표현하지만, 메모이제이션에는 실행 흐름(control flow)이 필요합니다. HIR은 코드를 기본 블록(basic block)으로 쪼개서 실행 경로를 명확히 합니다.

ThemeProvider의 CFG:

  ┌─────────────────────────────────┐
  │ Block 0 (entry)                 │
  │   $0 = LoadLocal props          │
  │   $1 = PropertyLoad $0.children │
  │   if $1 → Block 2, else Block 1 │
  └──────────┬──────────┬───────────┘
             │          │
     ┌───────▼──┐  ┌────▼──────────────────────┐
     │ Block 1  │  │ Block 2                    │
     │ return   │  │   $2 = PropertyLoad $0.theme│
     │   null   │  │   $3 = Call mergeTheme($2) │
     └──────────┘  │   $4 = JSX ThemeContext     │
                   │        {value: $3}          │
                   │   return $4                 │
                   └────────────────────────────┘

블록 1과 2가 분리되어 있으므로, 컴파일러는 mergeThemenull 경로에서 실행되지 않는다는 걸 구조적으로 압니다. "High-level"이라는 이름답게 array.map(fn) 같은 고수준 표현이 IR에서도 유지되어, 컴파일 결과가 원본과 비슷하게 읽힙니다.

SSA — Static Single Assignment

모든 변수가 정확히 한 번만 할당되게 만드는 변환입니다.

// 원본                         // SSA 변환 후
let label = 'default';         label_0 = 'default'
if (isActive) {                if (isActive):
  label = 'active';              label_1 = 'active'
}                              label_2 = φ(label_0, label_1)
console.log(label);            console.log(label_2)

φ(phi) 함수는 분기 합류 지점에서 경로에 따라 값을 선택하는 분석용 표현입니다. 이 형태 자체가 데이터 의존성 그래프가 되어, label_2 → isActive 의존 관계가 자동으로 드러납니다.

Effect 분석

각 연산이 데이터에 미치는 영향을 분류합니다. "이 값을 캐시해도 안전한가?"를 판단하는 근거입니다.

Effect예시캐싱 영향
Readprops.name안전하게 캐싱 가능
Storeobj.field = valuealias 추적 필요
Capture() => externalVar캡처된 값의 변이 주의
Mutatearray.push(item)scope 확장하여 변이 전체를 묶음
FreezeJSX에 전달 → 안전하게 캐싱
function Example({ items }) {
  //          Effect 분석 결과:
  const list = items.filter(   // items: Read
    i => i.active              // i: Read, i.active: Read
  );
  const result = [];           // result: Create(local)
  result.push(list.length);   // result: Mutate, list: Read
  return <List data={result} /> // result: Freeze
  //       ↑ Mutate와 Freeze가 같은 값(result)에 걸리므로
  //         생성 → 변이 → 동결을 하나의 scope로 묶음
}

Reactive 분석

props, state, context가 reactive value의 시작점입니다. SSA 그래프를 따라 reactive 여부가 전파됩니다.

// 모듈 스코프 — non-reactive (렌더링 사이에 불변)
const LIMIT = 10;
const formatName = (n) => n.toUpperCase();

function UserList({ users, threshold }) {
  //                  ↓ reactive (prop)    ↓ reactive (prop)

  const filtered = users.filter(          // ← reactive (users에서 파생)
    u => u.score > threshold              // ← reactive (threshold에서 파생)
  );

  const count = filtered.length;          // ← reactive (filtered에서 파생)

  const label = formatName('Users');      // ← non-reactive (모두 non-reactive 입력)

  const max = LIMIT * 2;                  // ← non-reactive (상수 연산)

  return (
    <div>
      <h1>{label}</h1>        {/* non-reactive → 메모이제이션 불필요 */}
      <span>{count}/{max}</span>
      <List data={filtered} /> {/* reactive → 메모이제이션 대상 */}
    </div>
  );
}

non-reactive 값은 매번 같은 결과를 내므로 캐싱하지 않습니다. 모든 걸 캐싱하면 비교 비용이 오히려 성능을 깎아먹기 때문에, 바뀔 수 있는 것만 캐싱합니다.

Reactive Scope

Reactive 분석이 끝나면 같은 의존성을 공유하는 연산들을 reactive scope로 묶습니다. 각 scope는 독립적으로 동작합니다.

function Dashboard({ users, theme }) {
  // ┌─── scope 1: users에 의존 ──────────────────┐
  // │                                              │
       const activeUsers = users.filter(u => u.isActive);
       const userList = <UserList data={activeUsers} />;
  // │                                              │
  // └──────────────────────────────────────────────┘

  // ┌─── scope 2: theme에 의존 ──────────────────┐
  // │                                              │
       const headerStyle = { color: theme.primary };
       const header = <Header style={headerStyle} />;
  // │                                              │
  // └──────────────────────────────────────────────┘

  return <div>{header}{userList}</div>;
}
변경 시나리오:

users만 변경 → scope 1 재계산, scope 2 캐시 사용
theme만 변경 → scope 1 캐시 사용, scope 2 재계산
둘 다 변경   → 둘 다 재계산

React.memo는 props 하나만 바뀌어도 컴포넌트 전체를 다시 실행하는 all-or-nothing 방식입니다. reactive scope는 컴포넌트 내부를 여러 독립적인 캐시 단위로 쪼개서, 바뀐 부분만 정확히 재계산합니다.

컴파일 결과물

컴파일러는 _c(이전 이름: useMemoCache) hook으로 고정 크기 캐시 배열을 할당합니다. 단순한 예제부터 봅시다.

// 변환 전                              // 변환 후
function Greeting({ name }) {          function Greeting(t0) {
  return (                               const $ = _c(2);
    <div>                                const { name } = t0;
      <p>{name}</p>                      let t1;
    </div>                               if ($[0] !== name) {
  );                                       t1 = <div><p>{name}</p></div>;
}                                          $[0] = name;
                                           $[1] = t1;
                                         } else {
                                           t1 = $[1];
                                         }
                                         return t1;
                                       }

$[0]에 의존성, $[1]에 결과를 저장합니다. 다음 렌더링에서 name이 같으면 캐시된 JSX를 반환합니다.

scope가 여러 개인 경우를 봅시다.

// Dashboard 컴파일 결과
function Dashboard(t0) {
  const $ = _c(6);
  const { users, theme } = t0;

  // scope 1: users 의존
  let t1, t2;
  if ($[0] !== users) {
    t1 = users.filter(u => u.isActive);
    t2 = <UserList data={t1} />;
    $[0] = users;
    $[1] = t2;
  } else {
    t2 = $[1];
  }

  // scope 2: theme 의존
  let t3, t4;
  if ($[2] !== theme) {
    t3 = { color: theme.primary };
    t4 = <Header style={t3} />;
    $[2] = theme;
    $[3] = t4;
  } else {
    t4 = $[3];
  }

  // scope 3: 두 scope의 결과 조합
  let t5;
  if ($[4] !== t4 || $[4 + 1] !== t2) {
    t5 = <div>{t4}{t2}</div>;
    $[4] = t4;
    $[5] = t2;
  } else {
    t5 = $[5];
  }
  return t5;
}

_c(6) — 슬롯 6개. 각 scope가 독립적으로 캐시를 확인하고, 의존성이 바뀐 scope만 재계산합니다. 수동으로 이 수준의 useMemo를 쓰는 것은 비현실적입니다.

Rules of React

컴파일러가 안전하게 최적화하는 근거입니다.

// ✅ 규칙을 지키는 코드                // ❌ 규칙을 위반하는 코드

// 순수 함수                           // 렌더링 중 side effect
function Price({ amount }) {          function Price({ amount }) {
  const formatted = `$${amount}`;       analytics.track('render');
  return <span>{formatted}</span>;      return <span>${amount}</span>;
}                                     }

// 불변 업데이트                        // 직접 변이
function addItem(items, item) {       function addItem(items, item) {
  return [...items, item];              items.push(item);
}                                       return items;
                                      }

위반 코드를 발견하면 컴파일러는 해당 컴포넌트의 최적화를 건너뜁니다. 빌드 실패나 런타임 에러가 아니라, 최적화가 적용되지 않을 뿐입니다. 다만 모든 위반을 정적으로 잡지는 못하므로, eslint-plugin-react-hooks의 컴파일러 관련 규칙을 활성화해두는 게 중요합니다.

기존 코드와의 공존

// 기존 코드 — 컴파일러를 켜도 그대로 동작
const filtered = useMemo(
  () => items.filter(i => i.active),
  [items]
);
// 컴파일러가 위에 자체 최적화를 추가로 적용
// → 기존 useMemo를 제거하지 않아도 문제 없음
// → 새 코드에서는 useMemo 없이 작성하면 됨

기존 useMemo/useCallback/React.memo 위에 컴파일러가 추가 최적화를 적용합니다. 기존 코드를 제거할 때는 useMemo 반환값 변경이 effect 의존성에 영향을 줄 수 있으므로 동작 확인이 필요합니다.

정리

React Compiler는 빌드 타임에 코드를 분석해서 reactive value의 변경 범위에 따라 자동으로 메모이제이션을 적용합니다. HIR 기반의 파이프라인으로 데이터 흐름을 추적하고, reactive scope 단위로 세밀하게 캐싱합니다. 수동 메모이제이션으로는 불가능한 조건부 최적화까지 처리합니다. Meta 프로덕션에서 초기 로드 최대 12%, 특정 인터랙션 2.5배 빠른 결과를 보여주고 있고 v1.0이 릴리스된 상태입니다.

개발자가 할 일은 Rules of React를 지키는 것뿐입니다.

References