React Compiler 톺아보기

작성일: 2025. 08. 25최종 수정: 2025. 10. 05. 06시 10분

들어가며

image.png 이번에 열린 Feconf 2025에서 React Compiler에 대한 발표를 들었다. 흥미로운 내용이었지만 솔직히 말하면 완전히 이해하지는 못했다. Rules of React부터 시작해서, HIR, SSA 같은 어려운 용어들이 나오고 컴파일 과정들을 연사님께서 잘 설명해주셨다. 그러나 배경지식이 부족해서 따라가기 어려운 부분들이 많았다.

그래서 이번 기회에 부족한 배경지식들을 차근차근 채워가면서 발표 내용을 다시 이해해보려고 한다. 혹시라도 feconf에 참여해서 해당 강연을 들었는데 나와 같이 이해가 잘 되지 않았던 분들이거나, 후에 유튜브로 영상이 나와 관련 검색을 찾다가 나의 글로 오는 분들도 도움이 되었으면 좋겠다.

React 렌더링의 기본 원리

React Compiler를 이해하려면 먼저 React가 어떻게 렌더링하는지 알아야 한다.

React는 왜 컴포넌트 전체를 재실행할까

React에서 상태가 변경되면 컴포넌트 함수 전체가 다시 실행된다.

javascript
function Counter() {
  const [count, setCount] = useState(0);

  console.log('Counter 컴포넌트가 실행됨');

  const handleClick = () => {
    setCount(count + 1);
  };

  return <button onClick={handleClick}>{count}</button>;
}

위 코드에서 setCount가 호출될 때마다 Counter 함수 전체가 다시 실행된다. React가 선언적(declarative) 패러다임을 채택했기 때문이다. 개발자는 "현재 상태에서 UI가 어떻게 보여야 하는지"만 기술하면 되고, React가 알아서 변경 사항을 처리한다.

부모-자식 리렌더링의 연쇄 반응

더 중요한 것은 부모 컴포넌트가 리렌더링되면 자식 컴포넌트도 무조건 리렌더링된다.

javascript
function App() {
  const [appCount, setAppCount] = useState(0);

  return (
    <div>
      <button onClick={() => setAppCount(appCount + 1)}>
        App Count: {appCount}
      </button>
      <ExpensiveChild />
    </div>
  );
}

function ExpensiveChild() {
  console.log('ExpensiveChild 리렌더링됨!');

  // 무거운 계산
  const expensiveValue = heavyCalculation();

  return <div>{expensiveValue}</div>;
}

위 예제에서 App의 appCount가 변경되면 ExpensiveChild도 함께 리렌더링된다. ExpensiveChildappCount와 전혀 관련이 없음에도 불구하고 말이다.

언제 이것이 문제가 될까

대부분의 경우 이런 리렌더링은 큰 문제가 되지 않는다. 하지만 다음과 같은 상황에서는 성능 이슈가 발생할 수 있다.

  1. 무거운 계산이 포함된 컴포넌트
  2. 복잡한 UI를 렌더링하는 컴포넌트
  3. 깊은 컴포넌트 트리
  4. 자주 업데이트되는 상태

이런 문제를 해결하기 위해 React는 메모이제이션이라는 해결책을 제시했다.

메모이제이션의 등장과 한계

React.memo, useMemo, useCallback의 등장

React는 불필요한 리렌더링을 방지하기 위해 여러 최적화 도구들을 제공한다.

javascript
const ExpensiveChild = React.memo(function ExpensiveChild() {
  console.log('ExpensiveChild 리렌더링됨!');
  const expensiveValue = heavyCalculation();
  return <div>{expensiveValue}</div>;
});

function App() {
  const [appCount, setAppCount] = useState(0);

  const memoizedValue = useMemo(() => {
    return heavyCalculation();
  }, []);

  const memoizedCallback = useCallback(() => {
    console.log('버튼 클릭됨');
  }, []);

  return (
    <div>
      <button onClick={() => setAppCount(appCount + 1)}>
        App Count: {appCount}
      </button>
      <ExpensiveChild />
    </div>
  );
}

이제 appCount가 변경되어도 ExpensiveChild는 리렌더링되지 않는다.

수동 메모이제이션의 문제점들

하지만 수동 메모이제이션에는 여러 문제가 있다.

1. 과도한 리소스 소모

메모이제이션 자체도 비용이다. 값을 비교하고 캐시를 관리하는 오버헤드가 있다. 때로는 메모이제이션 비용이 리렌더링 비용보다 클 수도 있다.

2. 의존성 관리 복잡성

javascript
const memoizedValue = useMemo(() => {
  return expensiveCalculation(a, b, c);
}, [a, b]); // c가 빠졌다

3. 가독성 저하

javascript
function ComplexComponent({ data, filter, sort }) {
  const filteredData = useMemo(() => {
    return data.filter(filter);
  }, [data, filter]);

  const sortedData = useMemo(() => {
    return filteredData.sort(sort);
  }, [filteredData, sort]);

  const handleClick = useCallback((item) => {
    console.log(item);
  }, []);

  return (
    <div>
      {sortedData.map(item =>
        <ExpensiveItem
          key={item.id}
          item={item}
          onClick={handleClick}
        />
      )}
    </div>
  );
}

개발자는 로직에 집중해야 하는데 성능 최적화에 너무 많은 시간을 쓰게 된다.

어떤 기준으로 메모이제이션을 해야 할지에 대한 고민이 자동 메모이제이션으로 이어졌다. 이것이 바로 React Compiler가 해결하려는 문제다.

Rules of React: 컴파일러가 의존하는 약속

React Compiler가 제대로 동작하려면 개발자가 "Rules of React"를 지켜야 한다. 이 룰들은 React 공식 문서에 명시되어 있으며, React Compiler는 이 룰들이 지켜졌다는 낙관적 가정 하에 최적화를 수행한다.

낙관적 가정(Optimistic Assumption)이란?
컴파일러가 "개발자가 규칙을 지켰을 것"이라고 믿고 최적화하는 방식이다. 런타임 검증 없이 코드가 올바르다고 가정해서 성능을 높인다. 하지만 규칙을 어기면 잘못된 최적화로 인해 예상치 못한 버그가 발생할 수 있다. TypeScript가 타입을 믿고 최적화하는 것과 비슷한 원리다.

1. Components and Hooks must be pure (순수해야 한다)

가장 중요한 룰이다. 컴포넌트와 훅은 순수함수여야 한다.

순수함수란 무엇인가

순수함수는 다음 조건을 만족한다.

  1. 같은 입력에 대해 항상 같은 출력 (idempotent)
  2. 사이드 이펙트가 없음 (렌더링 중 외부 상태 변경 금지)
javascript
// Bad: 매번 다른 결과
function BadClock() {
  const time = new Date(); // 호출할 때마다 다른 값
  return <span>{time.toLocaleString()}</span>;
}

// Good: 같은 props에 대해 같은 결과
function GoodClock({ time }) {
  return <span>{time.toLocaleString()}</span>;
}

사이드 이펙트는 렌더링 밖에서

렌더링 중에는 외부 상태를 변경하면 안 된다.

javascript
// Bad: 렌더링 중 사이드 이펙트
function BadComponent() {
  document.title = '새로운 제목'; // 렌더링 중 DOM 조작
  return <div>컴포넌트</div>;
}

// Good: useEffect에서 사이드 이펙트
function GoodComponent() {
  useEffect(() => {
    document.title = '새로운 제목'; // 렌더링 후 실행
  }, []);

  return <div>컴포넌트</div>;
}

Props와 State는 불변

전달받은 props나 state를 직접 수정하면 안 된다.

javascript
// Bad: props 직접 수정
function BadUserList({ users }) {
  users.sort((a, b) => a.name.localeCompare(b.name)); // props 직접 수정
  return <div>{users.map(user => user.name)}</div>;
}

// Good: 새 배열 생성
function GoodUserList({ users }) {
  const sortedUsers = [...users].sort((a, b) => a.name.localeCompare(b.name));
  return <div>{sortedUsers.map(user => user.name)}</div>;
}

2. React calls Components and Hooks

React가 컴포넌트와 훅을 호출한다. 개발자가 직접 호출하면 안 된다.

javascript
// Bad: 컴포넌트 직접 호출
function App() {
  return (
    <div>
      {MyComponent()}
    </div>
  );
}

// Good: JSX 사용
function App() {
  return (
    <div>
      <MyComponent />
    </div>
  );
}

3. Rules of Hooks

훅 사용에 관한 규칙들이다.

최상위에서만 호출

javascript
// Bad: 조건문 내 훅 사용
function BadComponent({ shouldUseEffect }) {
  if (shouldUseEffect) {
    useEffect(() => {
      console.log('useEffect 내부');
    }, []);
  }
}

// Good: 최상위 훅 사용
function GoodComponent({ shouldUseEffect }) {
  useEffect(() => {
    if (shouldUseEffect) {
      // 조건논리는 훅 내부에서
    }
  }, [shouldUseEffect]);
}

React 함수에서만 호출

훅은 컴포넌트나 커스텀 훅에서만 사용할 수 있다.

javascript
// Bad: 일반 함수에서 훅 사용
function regularFunction() {
  const [state, setState] = useState(0);
}

// Good: 컴포넌트에서 훅 사용
function MyComponent() {
  const [state, setState] = useState(0);
}

// Good: 커스텀 훅에서 훅 사용
function useCustomHook() {
  const [state, setState] = useState(0);
}

React Compiler가 이 룰들에 의존하는 이유

React Compiler는 이러한 룰들이 지켜졌다고 가정하고 최적화를 수행한다.

  1. 순수성: 같은 입력에 대해 같은 출력이라고 가정하기 때문에 결과를 캐시할 수 있다
  2. 불변성: 값이 변하지 않는다고 가정하기 때문에 참조 동등성 비교로 최적화할 수 있다
  3. 훅 순서: 훅이 항상 같은 순서로 호출된다고 가정하기 때문에 캐시 슬롯을 안정적으로 할당할 수 있다

만약 이 룰들을 위반하면 React Compiler가 잘못된 최적화를 수행할 수 있고, 예상치 못한 버그가 발생할 수 있다. feconf 발표에서 언급된 "낙관적 가정의 문제"가 바로 이것이다.

React Compiler의 등장

React Forget에서 React Compiler로

React Compiler는 하루 아침에 나타난 것이 아니다. 2021년 React 컨퍼런스에서 React Forget이라는 이름으로 처음 소개되었다. 당시에는 실험적인 프로젝트였지만, 3년이 지난 2024년에 React Compiler라는 이름으로 다시 태어났다.

React Forget은 "개발자가 메모이제이션을 잊어버려도 된다"는 의미였다면, React Compiler는 "컴파일러가 최적화를 담당한다"는 뜻이다.

자동 메모이제이션이라는 해답

React 팀이 React Compiler를 만든 이유는 명확하다. 개발자가 성능 최적화보다 개발 자체에 집중할 수 있게 하기 위해서다.

지금까지의 React 개발을 생각해보자. 컴포넌트를 만들고, 상태를 관리하고, 로직을 구현하는 것만으로도 복잡하다. 여기에 "어디에 memo를 써야 할까", "이 useMemo는 필요할까", "useCallback의 의존성 배열을 제대로 했을까" 같은 최적화 고민까지 더해지면 개발이 어려워진다.

React Compiler는 이런 고민을 컴파일 시점으로 옮긴다. 개발자는 평범하게 React 코드를 작성하면 되고, 컴파일러가 최적화가 필요한 부분을 찾아서 메모이제이션을 적용한다.

Meta의 프로덕션 사례

React Compiler가 이론만 멋진 것이 아니라는 증거가 있다. 2024년 Meta는 10만개 이상의 React 컴포넌트를 가진 거대한 코드베이스에 React Compiler를 성공적으로 적용했다.

2024년 2월 Instagram.com에 처음 배포된 후, Facebook과 Threads로 확대 적용되었다. 놀라운 점은 코드 변경이 거의 필요하지 않았다는 것이다. 이미 수년간 React 전문가들이 최적화한 앱에서도 몇 퍼센트의 성능 개선을 달성했다.

더 중요한 것은 개발 생산성 향상이다. Meta의 통계에 따르면 React PR 중 단 8%만이 수동 메모이제이션을 사용했고, 이를 사용한 PR은 작성에 31-46% 더 오래 걸렸다. React Compiler는 이런 인지적 부담을 없애고 개발자가 비즈니스 로직에 집중할 수 있게 해준다.

React Compiler의 동작 원리

컴파일 결과 먼저 살펴보기

복잡한 변환 과정을 설명하기 전에, React Compiler가 코드를 어떻게 바꾸는지 결과물부터 살펴보자.

javascript
function MyComponent({ color }) {
  const expensiveValue = heavyCalculation();

  return (
    <div>
      <Text color={color} />
      <span>{expensiveValue}</span>
    </div>
  );
}

React Compiler를 거치면 이 코드는 다음과 같이 변한다.

javascript
function MyComponent(props) {
  const $ = useMemoCache(4);
  const { color } = props;

  let t0;
  if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
    t0 = heavyCalculation();
    $[0] = t0;
  } else {
    t0 = $[0];
  }

  let t1;
  if ($[1] !== color) {
    t1 = <Text color={color} />;
    $[1] = color;
    $[2] = t1;
  } else {
    t1 = $[2];
  }

  let t2;
  if ($[3] !== t0) {
    t2 = (
      <div>
        {t1}
        <span>{t0}</span>
      </div>
    );
    $[3] = t0;
  } else {
    t2 = $[3];
  }

  return t2;
}

코드가 두 배 가까이 길어졌다. 하지만 자세히 보면 복잡하지 않다. 핵심은 useMemoCache라는 훅이다.

useMemoCache 작동 원리

useMemoCache(4)는 4개짜리 저장공간을 만든다고 생각하면 된다. 이 4라는 숫자도 컴파일러가 코드를 분석해서 "이 컴포넌트는 4개 값을 캐시하면 되겠네"라고 계산한 결과다. 다른 컴포넌트라면 useMemoCache(2)useMemoCache(10) 같은 식으로 될 수 있다.

실제 구현은 React의 Fiber 노드에 캐시를 저장하는 방식으로 동작한다. 간단하게 표현하면 이렇다.

javascript
// 실제 React Compiler 런타임의 단순화된 버전
function useMemoCache(size) {
  // Fiber 노드에서 캐시 가져오기
  const fiber = getCurrentFiber();

  // 첫 렌더링이면 캐시 배열 생성
  if (!fiber.memoCache) {
    fiber.memoCache = new Array(size);
    // 초기값은 특별한 심볼로 표시
    for (let i = 0; i < size; i++) {
      fiber.memoCache[i] = Symbol.for("react.memo_cache_sentinel");
    }
  }

  return fiber.memoCache;
}

위 코드에서 Symbol.for("react.memo_cache_sentinel")는 "아직 계산된 적 없음"을 나타내는 특별한 값이다. 컴파일된 코드에서 if ($[0] === Symbol.for("react.memo_cache_sentinel"))로 체크하는 이유가 바로 이것이다. (React Compiler 런타임 구현체 참고)

각 저장공간에 뭐가 들어갈지는 컴파일러가 코드를 보고 결정한다. 위 예제에서는 이렇게 배치했다.

  • $[0]: heavyCalculation() 결과
  • $[1], $[2]: color 값과 <Text /> 컴포넌트
  • $[3]: 최종 JSX

다른 컴포넌트라면 완전히 다르게 배치될 수 있다. 컴파일러가 "어떤 값들을 캐시해야 성능이 좋아질까?"를 분석해서 자동으로 정한다.

작동 방식을 단순하게 말하면 이렇다.

  1. 처음 실행할 때는 계산하고 저장공간에 넣는다
  2. 다음에 실행할 때는 "값이 바뀌었나?" 확인한다
  3. 바뀌지 않았으면 저장공간에서 꺼내 쓴다
  4. 바뀌었으면 다시 계산하고 저장공간을 업데이트한다

예를 들어 color가 "red"로 그대로라면, <Text color={color} />를 새로 만들지 않는다. 저장해둔 것을 그대로 사용한다. heavyCalculation()도 마찬가지다.

부모-자식 리렌더링 문제 해결

이제 앞서 살펴본 부모-자식 리렌더링 문제를 다시 생각해보자.

javascript
function App() {
  const [appCount, setAppCount] = useState(0);

  return (
    <div>
      <button onClick={() => setAppCount(appCount + 1)}>
        App Count: {appCount}
      </button>
      <ExpensiveChild />
    </div>
  );
}

React Compiler가 적용되면 appCount가 변해도 <ExpensiveChild />의 참조는 캐시에서 가져온다. 따라서 ExpensiveChild 컴포넌트는 실제로 리렌더링되지 않는다.

개발자가 수동으로 React.memo(ExpensiveChild)를 적용한 것과 같은 효과를 자동으로 얻는다.

컴파일 과정 깊이 들여다보기

앞서 컴파일 결과를 살펴봤지만, 실제로 React Compiler는 어떤 과정을 거쳐서 코드를 변환하는 걸까? feconf 발표에서 언급된 AST → HIR → SSA → Reactive Function 변환 과정을 차근차근 알아보자.

왜 "컴파일"이라고 부를까

먼저 용어에 대한 의문부터 해결해보자. 일반적으로 "컴파일"은 고수준 언어를 저수준 언어로 바꾸는 과정을 뜻한다. JavaScript를 JavaScript로 바꾸는 것은 "트랜스파일"에 가깝지 않을까?

하지만 React Compiler가 하는 일을 보면 코드 변환만 하는 것이 아니다. 소스 코드를 분석해서 데이터 플로우를 파악하고, 최적화 지점을 찾고, 전체적인 실행 구조를 재구성한다. 전통적인 Compiler가 하는 일과 유사해 그렇게 부르는 것이다.

1단계: AST (Abstract Syntax Tree)

모든 것은 AST에서 시작된다. AST는 작성한 코드를 트리 구조로 표현한 것이다.

AST가 왜 필요한가?
코드는 문자열이지만, 컴파일러는 코드의 의미와 구조를 이해해야 한다. AST는 코드를 "이해 가능한 데이터 구조"로 변환해서 분석, 변환, 최적화를 가능하게 한다. 마치 문장을 주어-동사-목적어로 분석하는 것처럼, 코드를 구조화된 형태로 파싱하는 것이다.

javascript
function MyComponent({ name }) {
  return <div>Hello {name}</div>;
}

이 간단한 코드도 AST로 표현하면 다음과 같은 구조를 가진다. Babel AST Explorer에서 확인 가능하다.

plain
FunctionDeclaration
├── Identifier: "MyComponent"
├── Parameters
│   └── ObjectPattern
│       └── Property: "name"
└── BlockStatement
    └── ReturnStatement
        └── JSXElement
            ├── JSXOpeningElement: "div"
            ├── JSXText: "Hello "
            ├── JSXExpressionContainer
            │   └── Identifier: "name"
            └── JSXClosingElement: "div"

React Compiler도 내부적으로는 Babel이나 유사한 파서를 사용해서 이런 AST를 생성한다.

AST는 코드의 구조를 나타내지만, 실행 흐름은 알려주지 않는다. "이 변수가 언제 사용되는지", "어떤 값에 의존하는지" 같은 정보는 담고 있지 않다.

2단계: HIR (High-level Intermediate Representation)

AST가 코드의 구조를 나타낸다면, HIR은 코드의 실행 흐름을 나타낸다.

HIR에서는 코드를 블록(Block) 단위로 나누고, 각 블록 사이의 관계를 엣지(Edge)로 표현한다.

블록(Block)은 연속으로 실행되는 코드 덩어리다. 중간에 분기(if문, 반복문 등)가 없이 쭉 실행되는 부분이다.

엣지(Edge)는 블록들 사이의 연결선이다. "이 블록 다음에 어느 블록으로 갈 수 있는가?"를 나타낸다.

javascript
function MyComponent({ name }) {
  return <div>Hello {name}</div>;
}

이 간단한 코드는 분기가 없어서 블록이 하나만 생긴다. React Compiler Playground에서 실제로 확인해보면 다음과 같은 HIR이 나온다.

plain
function MyComponent
MyComponent(<unknown> #t0$0): <unknown> $7
bb0 (block):
  [1] <unknown> $2 = Destructure Let { name: <unknown> name$1 } = <unknown> #t0$0
  [2] <unknown> $3 = JSXText "Hello "
  [3] <unknown> $4 = LoadLocal <unknown> name$1
  [4] <unknown> $5 = JSX <div>{<unknown> $3}{<unknown> $4}</div>
  [5] Return Explicit <unknown> $5

처음엔 복잡해 보이지만, 이건 React Compiler가 "어떤 부분을 캐시할 수 있을까?"를 판단하기 위한 중간 언어다. 각 줄이 하나의 연산이고, $ 기호가 붙은 것들이 임시 변수다. 이걸 풀어서 설명하면 다음과 같다.

  1. #t0$0은 첫 번째 매개변수(props 객체)
  2. [1]: props에서 name을 구조분해해서 name$1에 저장
  3. [2]: "Hello " 텍스트를 $3에 저장
  4. [3]: name$1 변수를 읽어서 $4에 저장
  5. [4]: JSX를 만들어서 $5에 저장
  6. [5]: $5를 반환

이렇게 단계별로 나누는 이유는 컴파일러가 최적화 지점을 찾기 위해서다.

한 줄 코드지만 컴파일러는 "어느 부분을 캐시할 수 있을까?"를 분석한다. 컴포넌트가 리렌더링될 때 name이 바뀌지 않으면 3번부터 5번까지 다시 실행할 필요 없이 캐시된 결과를 재사용할 수 있다. "Hello " 텍스트는 절대 안 바뀌니까 2번도 한 번만 실행하면 된다.

HIR을 통해 React Compiler는 어떤 값이 언제 계산되고, 어떤 조건에서 실행되는지 파악할 수 있다.

왜 이렇게 복잡한 과정을 거칠까

여기까지 보면서 "굳이 이렇게 복잡하게?"라는 생각이 들 수 있다. 단순히 "모든 값을 메모이제이션하면 되지 않을까"라고 생각할 수 있다. 하지만 그렇게 하면 성능이 오히려 나빠질 수 있다.

React Compiler는 이 복잡한 분석 과정을 통해 다음을 달성한다.

  1. 정확한 최적화: 정말 필요한 부분만 메모이제이션
  2. 최소한의 오버헤드: 불필요한 캐시와 비교 연산 최소화
  3. 안정적인 동작: Rules of React를 지킨 코드에서만 동작 보장

이 과정을 거쳐야만 React Compiler는 개발자의 의도를 해치지 않으면서도 효과적인 최적화를 수행할 수 있다.

3단계: SSA (Static Single Assignment)

SSA는 "정적 단일 대입"이라는 뜻이다. 간단히 말하면 변수를 한 번만 사용하기다.

SSA는 어디서 왔나?
SSA는 1980년대 IBM에서 개발된 컴파일러 최적화 기법이다. GCC, LLVM 같은 현대 컴파일러들의 핵심 기술로, 데이터 플로우 분석과 최적화를 쉽게 만든다. React Compiler가 이 검증된 기법을 차용한 이유는 "어떤 값이 언제 변했는지" 정확히 추적해서 메모이제이션 지점을 찾기 위해서다.

일반적인 코드에서는 같은 변수를 여러 번 덮어쓸 수 있다.

javascript
let x = 1;    // x에 1 대입
x = x + 2;    // x에 3 대입 (덮어씀)
x = x * 4;    // x에 12 대입 (또 덮어씀)

하지만 SSA에서는 변수를 한 번만 쓸 수 있게 바꾼다.

javascript
let x1 = 1;         // x1에 1 대입
let x2 = x1 + 2;    // x2에 3 대입
let x3 = x2 * 4;    // x3에 12 대입

왜 이렇게 할까?

컴파일러가 "이 변수가 언제 어떤 값인지" 추적하기 쉬워진다. x라는 변수 하나로는 "지금 x가 1인가? 3인가? 12인가?" 헷갈리지만, x1, x2, x3으로 나누면 각각이 명확하다.

React Compiler에서는 "name이 바뀌지 않았으면 x2부터 다시 계산하지 마" 같은 최적화를 정확하게 할 수 있다.

React Compiler에서 SSA 변환

React Compiler는 HIR에서 SSA로 변환할 때 변수들의 번호를 매긴다.

우리 MyComponent 예시의 실제 SSA 결과를 보면 이렇다.

plain
function MyComponent
MyComponent(<unknown> #t0$8): <unknown> $7
bb0 (block):
  [1] <unknown> $10 = Destructure Let { name: <unknown> name$9 } = <unknown> #t0$8
  [2] <unknown> $11 = JSXText "Hello "
  [3] <unknown> $12 = LoadLocal <unknown> name$9
  [4] <unknown> $13 = JSX <div>{<unknown> $11}{<unknown> $12}</div>
  [5] Return Explicit <unknown> $13

각 변수가 고유한 번호를 가진다.

  • #t0$8: props 객체
  • name$9: 구조분해된 name
  • $10, $11, $12, $13: 각 연산 결과

컴파일러는 이제 "name9가바뀌지않았으면9가 바뀌지 않았으면 12와 $13을 재사용하자"고 판단할 수 있다.

4단계: Reactive 분석

이제 React Compiler의 핵심이다. React Compiler는 "리렌더링될 때 값이 바뀔 수 있는가?"를 분석한다.

React Compiler Playground에서는 InferReactivePlaces 단계에서 이 분석이 일어난다.

우리 MyComponent 예시의 실제 결과를 보면 다음과 같다.

javascript
function MyComponent({ name }) {
  return <div>Hello {name}</div>;
}
plain
[1] mutate? $10{reactive} = Destructure Let { name: mutate? name$9{reactive} } = read #t0$8{reactive}
[2] mutate? $11:TPrimitive = JSXText "Hello "
[3] mutate? $12{reactive} = LoadLocal read name$9{reactive}
[4] mutate? $13{reactive} = JSX <div>{read $11:TPrimitive}{read $12{reactive}}</div>

여기서 {reactive} 표시가 붙은 값들이 reactive 값이다. name$9{reactive}, $12{reactive}, $13{reactive}는 모두 reactive하지만, $11:TPrimitive ("Hello" 문자열)는 reactive 표시가 없다.

Reactive 분석을 더 잘 보여주기 위해 다른 예시를 살펴보자.

javascript
function ReactiveExample({ count, name }) {
  const doubled = count * 2;
  const greeting = "Hello";
  return <div>{doubled} {greeting} {name}</div>;
}

이 코드의 InferReactivePlaces 결과를 보면 다음과 같다.

plain
[1] mutate? $23{reactive} = Destructure Let { count: mutate? count$21{reactive}, name: mutate? name$22{reactive} } = read #t0$20{reactive}
[4] mutate? $26{reactive} = Binary read $24{reactive} * read $25:TPrimitive
[5] mutate? doubled$27{reactive} = read $26{reactive}
[10] mutate? $34:TPrimitive = "Hello"
[12] mutate? $36{reactive} = LoadLocal read name$22{reactive}
[13] mutate? $37{reactive} = JSX <div>{read $32{reactive}}{read $34:TPrimitive}{read $36{reactive}}</div>

여기서 count$21{reactive}, doubled$27{reactive}, name$22{reactive}는 모두 reactive하지만, $34:TPrimitive ("Hello" 문자열)는 reactive 표시가 없다.

이 분석을 통해 React Compiler는 어떤 값을 메모이제이션해야 하는지 결정한다.

5단계: Scope 기반 메모이제이션

마지막으로 reactive한 값들을 scope 단위로 그룹화해서 메모이제이션한다.

같은 의존성을 가진 값들은 하나의 scope로 묶여서 함께 캐시해 캐시 효율성을 높이고 불필요한 재계산을 방지한다.

BuildReactiveFunction 단계에서 실제 scope 구조를 볼 수 있다.

MyComponent의 실제 결과다.

plain
scope @0 [4:7] dependencies=[$11:TPrimitive, name$9] declarations=[$13_@0] {
  [5] mutate? $13_@0 = JSX <div>{read $11:TPrimitive}{read $12{reactive}}</div>
}

여기서 scope는 하나만 생성되고, 의존성은 $11:TPrimitive ("Hello")와 name$9다.

ReactiveExample에서는 더 복잡한 scope를 볼 수 있다.

plain
scope @0 [11:14] dependencies=[doubled$27, $33, $34, $35, name$22] {
  [12] mutate? $37_@0 = JSX <div>{read $32{reactive}}{read $33}{read $34}{read $35}{read $36{reactive}}</div>
}

여기서도 scope는 하나지만, 더 많은 의존성을 가진다. doubled$27, 공백과 "Hello" 텍스트들, 그리고 name$22가 모두 포함되어 있다.

ReactiveFunction으로의 변환

이 모든 분석이 끝나면 최종적으로 ReactiveFunction이 생성된다. 이는 트리 구조로 되어 있으며, 각 노드가 하나의 scope를 담당한다.

ReactiveFunction은 다시 JavaScript 코드로 변환되어 앞서 본 useMemoCache를 사용하는 코드가 된다.

React Compiler의 한계와 트레이드오프

React Compiler는 매력적이지만 완벽하지는 않다. 도입하기 전에 알아둬야 할 한계점들이 있다.

낙관적 가정의 위험성

React Compiler의 가장 큰 문제는 낙관적 가정이다. Rules of React를 지켰다고 가정하고 최적화를 수행하기 때문에, 룰을 위반한 코드에서는 예상치 못한 버그가 발생할 수 있다.

사이드 이펙트가 숨겨지는 경우

javascript
let globalCounter = 0;

function BadComponent({ items }) {
  const processedItems = items.map(item => {
    globalCounter++;
    return { ...item, id: globalCounter };
  });

  return <div>{processedItems.length} items</div>;
}

이 코드는 Rules of React를 위반한다. 렌더링 중에 외부 상태를 변경하고 있기 때문이다.

React Compiler는 이를 정상적인 코드로 가정하고 processedItems를 메모이제이션할 수 있다. 그러면 items가 같은 경우 globalCounter가 증가하지 않게 되어, 원래 개발자가 의도한 동작과 달라진다.

더 심각한 것은 이런 버그가 에러로 드러나지 않는다는 점이다. 코드는 정상적으로 실행되지만 예상과 다른 결과를 낸다.

직접 변경(Mutation)의 함정

javascript
function AnotherBadComponent({ users }) {
  const sortedUsers = users.sort((a, b) => a.name.localeCompare(b.name));

  return (
    <ul>
      {sortedUsers.map(user => <li key={user.id}>{user.name}</li>)}
    </ul>
  );
}

Array.prototype.sort는 원본 배열을 변경한다. 이는 props 불변성을 위반하는 코드다.

React Compiler가 이를 메모이제이션하면 users props가 같을 때 정렬이 수행되지 않을 수 있다. 하지만 첫 번째 렌더링에서 이미 원본 배열이 변경되었으므로, 부모 컴포넌트에서도 정렬된 상태로 보이게 되어 매우 찾기 어려운 버그를 만들 수 있다.

번들 크기 증가

React Compiler는 모든 최적화 대상 컴포넌트에 useMemoCache 코드를 추가한다. 이로 인해 번들 크기가 증가한다.

실제 측정 데이터 (2024년)

"How React Compiler Performs on Real Code" 실제 측정 결과에 따르면,

  • useMemo 훅은 최종 빌드에 0.02kB 추가
  • 컴파일러 패키지와 2개 컴포넌트 메모이제이션시 1.03kB 추가
  • feconf 발표에서 언급된 단순 예제는 컴포넌트 크기가 2.27배 증가

React 18.3과 React 19.0.0 모두 새 컴포넌트나 메모이제이션된 변수가 추가될 때 동일하게 크기가 증가한다. 수동 메모이제이션을 걱정하지 않아도 되는 편리함은 번들 크기 증가라는 대가를 치른다.

javascript
// 원본 10줄 코드
function SimpleComponent({ name }) {
  const greeting = `Hello, ${name}!`;
  return <div>{greeting}</div>;
}

// 컴파일 후 25줄 (2.5배 증가)
function SimpleComponent(props) {
  const $ = useMemoCache(3);
  const { name } = props;

  let t0;
  if ($[0] !== name) {
    t0 = `Hello, ${name}!`;
    $[0] = name;
    $[1] = t0;
  } else {
    t0 = $[1];
  }

  let t1;
  if ($[2] !== t0) {
    t1 = <div>{t0}</div>;
    $[2] = t0;
  } else {
    t1 = $[2];
  }

  return t1;
}

전체 번들 크기로 보면 비율이 미미할 수 있지만, 코드 스플리팅이 제대로 되어 있지 않다면 선형적으로 증가할 수 있다.

런타임 오버헤드

메모이제이션 자체도 비용이다. 값을 비교하고, 캐시를 관리하고, 조건문을 실행하는 데 시간이 걸린다.

특히 변경이 자주 발생하는 props를 가진 컴포넌트에서는 메모이제이션 비용이 리렌더링 비용보다 클 수 있다.

javascript
function FrequentlyChangingComponent({ timestamp }) {
  const formatted = new Date(timestamp).toLocaleString();
  return <div>{formatted}</div>;
}

이런 컴포넌트에서는 캐시가 항상 무효화되므로 메모이제이션이 의미가 없다. 오히려 캐시 확인과 업데이트 로직만 추가 부담이 된다.

언제 React Compiler를 사용하지 말아야 할까

다음과 같은 상황에서는 React Compiler 도입을 신중히 고려해야 한다.

  1. 레거시 코드가 많은 프로젝트: Rules of React를 위반하는 코드가 많다면 예상치 못한 버그 위험이 크다
  2. 번들 크기가 중요한 프로젝트: 모바일 환경이나 네트워크가 제한적인 환경
  3. 자주 변경되는 데이터를 다루는 컴포넌트: 실시간 데이터나 애니메이션이 많은 경우
  4. 팀의 React 숙련도가 낮은 경우: 문제 발생 시 디버깅과 해결이 어려울 수 있다

해결 방안들

React 팀도 이런 한계를 인식하고 해결책을 제시한다.

점진적 도입

use memo, use no memo 디렉티브를 사용해서 컴포넌트별로 선택적 적용이 가능하다.

javascript
'use memo'; // 이 컴포넌트는 컴파일러 적용

function OptimizedComponent() {
  // ...
}

'use no memo'; // 이 컴포넌트는 컴파일러 제외

function ProblematicComponent() {
  // Rules of React를 위반하는 코드
}

도구 지원 강화

eslint-plugin-react-hooks에 React Compiler 관련 룰이 추가되었고, React Forgive VS Code extension 같은 도구들이 개발되고 있다.

하지만 가장 중요한 것은 Rules of React를 철저히 지키는 것이다. React Compiler는 올바른 React 코드에서만 안전하게 동작한다.

참고 자료

공식 문서 및 React Compiler 자료

실제 적용 사례 및 성능 분석

컴파일러 이론 및 기술적 배경

개발 도구 및 추가 자료

  • Web Vitals - React Compiler 적용 후 성능 개선을 측정할 수 있는 Core Web Vitals 지표를 설명한다.
  • React DevTools Profiler - 컴포넌트 리렌더링 패턴을 분석하여 React Compiler 효과를 확인할 수 있는 도구다.
  • ESLint Plugin React Hooks - Rules of React 준수 여부를 자동으로 검증해주는 ESLint 플러그인이다.
  • [[React Compiler Playground 용어 완전 정리]] - React Playground에서 사용되는 모든 용어들을 상세히 해설한다.
  • feconf 2025 발표 "'memo'를 지울 결심: React Compiler가 제안하는 미래" - React Compiler의 실무 적용 경험과 미래 전망을 다룬다.