[번역] The React Compiler

mocossi·2026년 7월 19일

해외 Articles

목록 보기
6/6
post-thumbnail

React Compiler는 빌드 타임에 컴포넌트를 메모이제이션하며, 사람이 직접 하는 것보다 더 뛰어난 방식으로 이를 수행합니다. React Compiler가 무엇인지, 컴파일된 결과물이 어떻게 동작하는지, 어떻게 활성화하는지, 그리고 기존 코드베이스에 있는 useMemo 호출은 어떻게 처리해야 하는지 알아보겠습니다.

원문 : https://neciudan.dev/react-compiler-explained


지금 이 순간에도 코드베이스 어딘가에는 React.memo로 감싼 컴포넌트가
키를 입력할 때마다 계속 리렌더링되고 있을 가능성이 있습니다.

겉보기에는 래퍼도 정상적으로 적용되어 있고, 컴포넌트도 메모이제이션되어 있으며,
콜백도 useCallback으로 감싸져 있습니다. 작성자는 최적화를 위해 분명히 신경을 썼습니다.

하지만 컴포넌트 트리 어딘가 상위에서 부모 컴포넌트가 인라인 화살표 함수를 props로 전달하거나,
렌더링 중 새로운 옵션 객체를 생성하거나, 자신의 부모에게 받은 props를 그대로 전개해서 전달하는 경우가 있습니다.

이때 memo 내부의 비교는 props가 변경되었다고 판단하고, 결국 해당 컴포넌트를 매번 다시 리렌더링합니다.
마치 React.memo를 적용하지 않은 것과 완전히 동일하게 동작합니다.

React는 이런 상황이 발생해도 아무런 경고를 하지 않습니다.
애플리케이션은 정상적으로 동작하고 테스트도 모두 통과하기 때문에,
잘못된 최적화는 그대로 남아 있고 그 비용은 조용히 계속 누적됩니다.

수동 메모이제이션은 규모가 커질수록 한계가 있습니다.

Meta 역시 같은 문제를 겪었고, 개발자가 직접 컴포넌트를 최적화하지 않아도 되도록 메모이제이션을 자동화하는 Compiler를 개발했습니다.

이 Compiler는 현재 Instagram과 Meta Quest Store의 프로덕션 환경에서 사용되고 있으며,
컴포넌트 내부의 의존성을 자동으로 분석해 컴파일된 코드에 최적의 메모이제이션을 삽입합니다.

덕분에 개발 시간은 줄어들고 버그는 감소하며, 소스 코드에서 별도의 수동 메모이제이션 없이도 항상 가장 효율적인 코드가 배포됩니다.

React Compiler를 만나보겠습니다.

React Compiler란?

React Compiler는 빌드 타임 도구입니다.
빌드 과정에서 실행되며 메모이제이션이 적용된 형태로 컴포넌트를 다시 작성합니다.

이미 빌드 과정에서는 JSX를 JavaScript로 변환하는 것처럼 코드가 여러 번 변환됩니다.
Compiler는 Babel 플러그인으로 동작하며 이러한 빌드 과정에 자연스럽게 통합됩니다.

최적화는 빌드 결과물에서 이루어지기 때문에,
소스 코드에서는 아무런 메모이제이션 없이 원래 형태 그대로 작성하면 됩니다.

function List({ items, onSelect }) {
  const handleClick = (item) => {
    onSelect(item.id);
  };

  return (
    <ul>
      {items.map(item => (
        <Item key={item.id} label={item.label} onClick={() => handleClick(item)} />
      ))}
    </ul>
  );
}

위 코드에는 수동 메모이제이션이 전혀 없습니다.
즉, 컴포넌트 곳곳에 useMemo, useCallback, React.memo를 추가할 필요가 없습니다.

Compiler는 어떤 값이 어떤 props와 state에 의존하는지 분석한 뒤,
각 값을 캐싱하는 코드를 생성하고 실제 입력값이 변경될 때만 다시 계산하도록 만듭니다.

이 프로젝트는 원래 Meta 내부에서 React Forget이라는 코드명으로 시작했습니다.
목표는 개발자가 메모이제이션 자체를 잊어버릴 수 있도록 만드는 것이었습니다.

2021년에 첫 공개 데모가 등장했고, React Conf 2024에서 실험 버전이 공개되었으며,
2025년 10월 7일에 1.0 버전이 정식 출시되었습니다.

React 19와 같은 시기에 공개되었지만, React Compiler는 react 패키지의 일부가 아닙니다.

React 19를 설치하면 컴파일된 코드가 의존하는 런타임은 제공되지만,
실제 컴파일 기능은 별도의 도구로 빌드 파이프라인에 직접 추가해야 합니다.

컴파일된 결과물은 실제로 어떻게 생겼을까?

React Compiler가 무엇이고 어떤 역할을 하는지 살펴봤으니,
이제 실제로 코드베이스에 적용하면 어떤 결과물이 생성되는지 살펴보겠습니다.

처음에는 Compiler가 최종 결과물에 useMemouseCallback을 자동으로 삽입해 줄 것이라고 생각할 수도 있습니다.

하지만 실제로는 그렇지 않습니다. 그 이유는 useMemo 자체가 생각보다 복잡하기 때문입니다.

useMemo는 호출될 때마다 캐시된 값을 재사용할 수 있는지 확인하기 위해 새로운 클로저와 새로운 의존성 배열을 생성합니다. 직접 다섯 개 정도의 값을 메모이제이션하는 경우에는 이 오버헤드가 거의 무시할 수준입니다.

하지만 Compiler가 모든 컴포넌트에서 거의 모든 값을 메모이제이션한다면,
이러한 오버헤드는 무시할 수 없을 정도로 커질 수 있습니다.

그래서 Compiler는 useMemo 대신 자체적인 캐싱 메커니즘을 생성합니다.

예를 들어 다음과 같은 코드가 있다고 가정해 보겠습니다.

function TodoList({ todos, tab }) {
  const visibleTodos = filterTodos(todos, tab);
  return <ul>{visibleTodos.map(todo => <li key={todo.id}>{todo.text}</li>)}</ul>;
}

Compiler는 이를 다음과 같은 형태로 변환합니다. (가독성을 위해 단순화한 코드입니다.)

function TodoList(t0) {
  const $ = _c(5); // 이 컴포넌트를 위한 캐시 슬롯 5개를 요청합니다.
  const { todos, tab } = t0;

  let visibleTodos;
  if ($[0] !== todos || $[1] !== tab) {
    // 입력값이 변경되었으므로 다시 계산하고 저장합니다.
    visibleTodos = filterTodos(todos, tab);
    $[0] = todos;
    $[1] = tab;
    $[2] = visibleTodos;
  } else {
    // 입력값이 동일하므로 캐시된 값을 재사용합니다.
    visibleTodos = $[2];
  }

  // JSX에도 동일한 패턴이 적용됩니다.
}

이 코드에서 실제로 어떤 일이 일어나는지 살펴보겠습니다.

먼저 t0 매개변수는 단순히 props 객체의 이름이 변경된 것일 뿐이며, 이후 구조 분해 할당됩니다.

_c(5)는 해당 컴포넌트 인스턴스에 연결된 다섯 개의 슬롯을 가진 배열을 생성합니다.

렌더링이 발생할 때마다 현재 입력값과 저장된 입력값을 비교합니다.
값이 동일하면 캐시된 결과를 그대로 사용하고, 다르면 다시 계산한 뒤 캐시를 갱신합니다.

비교 방식은 memo나 의존성 배열과 동일하게 참조 동일성을 사용합니다.

만약 부모 컴포넌트가 렌더링마다 내용은 같지만 새로운 todos 배열을 생성한다면, 이후의 모든 비교는 실패하게 되고 캐시된 값도 모두 다시 계산됩니다. Compiler 역시 React가 기존부터 사용하던 동일한 참조 비교 규칙을 그대로 따릅니다.

Compiler가 생성하는 비교 코드는 배열을 몇 번 읽고 if 문으로 비교하는 수준이기 때문에 JavaScript에서 거의 가장 저렴한 비용의 연산이라고 할 수 있습니다.

그래서 사람이 직접 useMemo를 사용하는 것보다 훨씬 더 촘촘한 수준으로 메모이제이션을 적용할 수 있습니다.

의존성 추적 역시 더 정확합니다.

직접 useMemo를 사용할 경우 어떤 값을 의존성 배열에 넣을지 개발자가 직접 결정해야 하며, 실수할 가능성이 있습니다.

반면 Compiler는 코드의 실제 데이터 흐름을 분석해 의존성을 계산하기 때문에, 실제 계산에서 사용하는 값만 정확하게 비교 대상으로 사용합니다.

또한 Compiler는 useMemo를 사용할 수 없는 위치까지 메모이제이션을 적용할 수 있습니다.

Hook은 조건문 안에서 호출하거나 조기 반환 이후에 호출할 수 없기 때문에, 다음과 같은 컴포넌트는 사람이 직접 메모이제이션할 수 없습니다.

function FriendList({ friends }) {
  if (friends.length === 0) {
    return <NoFriends />; // 조기 반환 이후에는 Hook을 사용할 수 없습니다.
  }

  const summary = buildSummary(friends); // useMemo를 사용할 수 없습니다.
  return <Summary data={summary} />;
}

하지만 Compiler는 이런 제약을 받지 않습니다.
Compiler가 사용하는 캐시 슬롯은 소스 코드 안의 Hook이 아니기 때문입니다.

따라서 각 분기를 독립적으로 메모이제이션할 수 있으며,
조기 반환 내부의 <NoFriends /> JSX까지도 메모이제이션합니다.

직접 자신의 컴포넌트에서 결과를 확인해 보고 싶다면 React Compiler Playground에 코드를 붙여 넣어 보면 됩니다.


React.memo 없이 리렌더링을 건너뛰는 방법

앞에서 살펴본 예시는 값이 캐시되는 과정을 보여주지만, 필터링된 배열을 캐시한다고 해서 자식 컴포넌트의 리렌더링이 자동으로 막히는 것은 아닙니다.

Compiler는 다른 방식으로 리렌더링을 건너뜁니다.

먼저 JSX가 실제로 무엇인지부터 살펴보겠습니다. <Item label={label} onClick={handleClick} />는 렌더링할 내용을 설명하는 일반적인 객체인 엘리먼트 객체를 생성하는 함수 호출입니다.

부모 컴포넌트가 렌더링될 때마다 일반적으로 각 자식 컴포넌트에 대해 새로운 엘리먼트 객체가 생성됩니다.

React에는 오래전부터 적용되어 온 최적화 규칙이 하나 있습니다. 컴포넌트가 이전 렌더링에서 받았던 것과 동일한 엘리먼트 객체를 다시 전달받으면 React는 해당 컴포넌트의 리렌더링을 건너뜁니다. 동일한 렌더링 결과를 설명하는 객체라면 다른 결과를 만들어낼 수 없기 때문입니다.

컴포넌트를 children으로 전달하는 패턴이 리렌더링을 방지하는 것도 같은 이유입니다.

React Compiler는 이 규칙을 체계적으로 활용합니다.

Compiler는 JSX 자체를 캐시 슬롯에 저장합니다. 따라서 특정 엘리먼트가 의존하는 값이 변경되지 않았다면, 부모 컴포넌트의 컴파일된 코드는 이전 렌더링에서 사용했던 동일한 엘리먼트 객체를 React에 다시 전달합니다. 그러면 React는 해당 서브트리 전체의 리렌더링을 건너뜁니다.

어디에도 React.memo 래퍼는 없습니다. 부모 컴포넌트가 새로운 렌더링 결과를 더 이상 생성하지 않을 뿐입니다.

즉, 리렌더링을 건너뛰는 로직은 자식 컴포넌트가 아니라 부모 컴포넌트의 컴파일된 결과물에 포함됩니다.

만약 리프 컴포넌트만 Compiler로 컴파일하고 부모 컴포넌트는 컴파일하지 않았다면, 부모는 렌더링될 때마다 계속 새로운 엘리먼트 객체를 생성하게 됩니다. 그 결과 컴파일된 리프 컴포넌트도 이전과 똑같이 계속 리렌더링됩니다.

React Compiler 활성화하기

React Compiler는 Babel 플러그인입니다. 따라서 설정 방법은 사용하는 빌드 도구에 따라 달라집니다.

또한 Compiler는 코드를 변환하기 전의 원본 소스를 분석해야 하므로, Babel 플러그인 파이프라인에서 가장 먼저 실행되어야 합니다.

Vite

Vite에서는 Compiler 플러그인과 함께 Babel 브리지를 설치해야 합니다.

npm install --save-dev --save-exact babel-plugin-react-compiler@latest
npm install --save-dev @rolldown/plugin-babel
// vite.config.js
import { defineConfig } from 'vite';
import react, { reactCompilerPreset } from '@vitejs/plugin-react';
import babel from '@rolldown/plugin-babel';

export default defineConfig({
  plugins: [
    react(),
    babel({
      presets: [reactCompilerPreset()],
    }),
  ],
});

만약 @vitejs/plugin-react 6.0 이전 버전을 사용하고 있다면 더 간단한 설정을 사용할 수 있습니다.

예전에는 react({ babel: { plugins: ['babel-plugin-react-compiler'] } })처럼 Babel 플러그인을 직접 전달할 수 있었습니다.

하지만 6.0 버전에서 이 옵션이 제거되었기 때문에, 현재는 별도의 Babel 플러그인을 통해 설정해야 합니다.

Next.js

Next.js에서는 16 버전부터 React Compiler가 정식 지원되며, 설정은 하나의 옵션만 추가하면 됩니다.

// next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  reactCompiler: true,
};

export default nextConfig;

하지만 한 가지 알아둘 점이 있습니다. Next.js는 기본적으로 Babel을 사용하지 않습니다.

대신 Babel보다 훨씬 빠른 Rust 기반 컴파일러인 SWC를 사용하며,
Next.js 팀도 성능 향상을 위해 SWC를 채택했습니다.

반면 현재 React Compiler는 Babel에서만 동작합니다.

따라서 React Compiler를 활성화하면 Babel이 다시 빌드 파이프라인에 추가되고,
개발 환경과 프로덕션 빌드 모두 이전보다 느려질 수 있습니다.

Next.js는 이를 완화하기 위해 사전 분석 단계를 수행합니다.
JSX나 Hook이 포함된 파일만 찾아 React Compiler를 실행하기 때문에 모든 파일을 컴파일하지는 않습니다.

현재 React Compiler를 위한 공식 SWC 플러그인도 개발 중입니다.

Expo

React 팀은 앞으로 React Compiler가 React 코드를 빌드하는 기본 방식이 될 것이라고 명확하게 밝혔으며, 생태계도 이에 맞춰 단계적으로 전환되고 있습니다.

가장 먼저 적용한 것은 Expo입니다.

SDK 54 이상에서 생성한 새로운 Expo 프로젝트는 기본 템플릿에 React Compiler가 활성화된 상태로 제공됩니다.

따라서 npx expo start를 실행하는 순간부터 모든 컴포넌트가 React Compiler를 통해 컴파일됩니다.

또한 Expo CLI에서 J 키를 누르면 컴포넌트 패널을 열 수 있으며, 어떤 컴포넌트가 메모이제이션되었는지 직접 확인할 수 있습니다.


어떤 React 버전에서 사용할 수 있을까?

컴파일된 결과물은 런타임에서 _c 캐시 Hook이 필요하며, 이 Hook은 React 19에 포함되어 있습니다.
따라서 기본적으로 React Compiler는 React 19를 대상으로 동작합니다.

하지만 이전 버전의 React를 사용한다고 해서 React Compiler를 사용할 수 없는 것은 아닙니다.

React Compiler는 별도의 런타임 패키지를 일반 의존성으로 설치하면 React 17과 React 18도 공식적으로 지원합니다.

npm install react-compiler-runtime@latest

그다음 Compiler에게 어떤 React 버전을 대상으로 코드를 생성할지 알려주면 됩니다.

// babel.config.js
module.exports = {
  plugins: [
    ['babel-plugin-react-compiler', { target: '18' }], // '17' | '18' | '19'
  ],
};

target 옵션이 동작하는 방식은 단순합니다.

target: '19'를 사용하면 컴파일된 코드는 React 자체에 포함된 react/compiler-runtime에서 캐시 Hook을 가져옵니다.

반면 target: '17' 또는 target: '18'을 사용하면 별도로 설치한 react-compiler-runtime 패키지에서 동일한 Hook을 가져옵니다. 이 패키지는 해당 React 버전에서 사용할 수 있는 API를 기반으로 동일한 기능을 구현합니다.

즉, 아직 업그레이드할 수 없는 의존성 하나 때문에 React 18을 유지하고 있는 팀도 지금 바로 자동 메모이제이션의 이점을 누릴 수 있습니다.

이후 React 19로 업그레이드하면 target만 변경하고 react-compiler-runtime 패키지를 제거하면 됩니다. 그러면 컴파일된 결과물도 React에 내장된 구현을 사용하도록 변경됩니다.

다만 react-compiler-runtime은 어디까지나 폴리필입니다.

React 19에 내장된 구현과 비교하면 일부 상황에서는 캐시된 값을 동일하게 유지하지 못할 수도 있으므로, React 19로 넘어가기 위한 임시 다리 역할로 생각하는 것이 좋습니다.

한편 클래스 컴포넌트는 지원 대상이 아닙니다.

React Compiler는 함수형 컴포넌트와 Hook만 최적화합니다. 따라서 클래스 컴포넌트는 빌드 과정에서 아무런 변경 없이 그대로 통과하며, 기존처럼 정상적으로 동작하지만 최적화의 이점은 얻지 못합니다.

기존 useMemo와 useCallback은 어떻게 될까?

이 질문은 가장 많이 받는 질문입니다. 먼저 새로운 코드부터 이야기해 보겠습니다.

앞으로는 useMemo, useCallback을 직접 작성하지 않아도 됩니다.
React.memo로 컴포넌트를 감쌀 필요도 없습니다.

React Compiler의 메모이제이션이 이 세 가지 API가 수행하던 역할을 모두 대신하기 때문입니다.

그렇다면 기존 코드에 있는 useMemouseCallback은 어떻게 해야 할까요?

적어도 처음에는 그대로 두는 것이 좋습니다. 코드베이스에 있는 모든 useMemo를 한꺼번에 삭제하는 거대한 Pull Request를 만들고 싶을 수도 있습니다.

하지만 React 공식 문서에서는 그렇게 하지 말 것을 권장합니다.

예를 들어 메모이제이션된 값이 useEffect의 의존성 배열에 포함되어 있다면, 해당 참조의 안정성이 Effect가 언제 실행되는지를 결정합니다.

이 상태에서 useMemo를 제거하면 컴파일 결과가 달라질 수 있습니다.

만약 해당 위치에서 Compiler가 개발자가 작성한 메모이제이션과 조금이라도 다른 경계를 선택한다면, Effect는 이전보다 더 자주 실행되거나 덜 실행될 수 있습니다.

반대의 상황도 발생할 수 있습니다. Compiler를 처음 활성화하는 순간, 이전에는 매 렌더링마다 새롭게 생성되던 객체가 Compiler에 의해 동일한 참조를 유지하게 될 수도 있습니다.

그러면 매 렌더링마다 다시 실행되던 Effect가 갑자기 실행되지 않게 됩니다.
만약 기존 동작이 이러한 반복 실행에 암묵적으로 의존하고 있었다면, 그 동작 역시 함께 사라지게 됩니다.

앞에서 --save-exact 옵션을 사용했던 이유도 바로 여기에 있습니다.

Compiler 버전이 변경되면 메모이제이션 경계도 달라질 수 있으므로, Compiler를 업그레이드하기 전에 반드시 End-to-End 테스트가 이를 검증할 수 있도록 해야 합니다.

기존의 useMemouseCallback이 Compiler에 의해 무조건 제거되는 것도 아닙니다.

Compiler는 자신이 추론한 메모이제이션이 개발자가 직접 작성한 메모이제이션과 같거나 더 우수하다고 판단되는 경우에만 컴포넌트를 컴파일합니다.

그렇지 않다면 메모이제이션 수준을 낮추는 대신 해당 컴포넌트 자체를 컴파일하지 않습니다.

preserve-manual-memoization 린트 규칙이 바로 이러한 경우를 알려줍니다.
Compiler가 컴파일한 이후에도 남아 있는 불필요한 Hook은 런타임 비용이 거의 없습니다.

따라서 서둘러 삭제할 필요는 없으며, Compiler를 충분히 운영해 본 뒤 테스트를 함께 수행하면서 점진적으로 정리하면 됩니다.

그래도 유지해야 하는 수동 메모이제이션

일부 수동 메모이제이션은 앞으로도 계속 유지해야 합니다.
대표적인 예가 useEffect의 의존성을 직접 제어해야 하는 경우입니다.

다음 코드를 보겠습니다.

// Effect는 query가 실제로 변경될 때만 다시 실행되어야 합니다.
// 따라서 Compiler의 메모이제이션 경계에 의존하지 않고
// 직접 참조를 고정합니다.
const options = useMemo(() => ({ query, page }), [query, page]);

useEffect(function syncSearchSubscription() {
  const sub = subscribeToSearch(options);
  return () => sub.unsubscribe();
}, [options]);

여기서 useMemo를 제거하면 options 객체는 렌더링마다 새롭게 생성됩니다.
그러면 useEffect의 의존성도 매번 변경되고, Effect는 렌더링마다 종료된 뒤 다시 실행됩니다.
실제 구독 로직이라면 사용자가 키를 입력할 때마다 구독을 해제하고 다시 연결하는 일이 반복될 수 있습니다.

또 다른 예는 외부 라이브러리가 함수의 참조를 보관하는 경우입니다.

예를 들어 차트 라이브러리가 초기화 시점에 onClick 핸들러를 등록하거나, DOM API 또는 React와 무관한 SDK가 함수 참조를 저장하는 경우가 있습니다.
이러한 시스템은 React Compiler가 분석할 수 없는 영역입니다.

따라서 함수의 참조 동일성이 외부 시스템과의 계약이라면 useCallback을 계속 유지해야 합니다.

다만 React 19.2에서는 useEffectEvent가 추가되면서 이러한 경우도 더 줄어들었습니다.

useEffectEvent는 Effect 내부에서 값을 읽어야 하지만, 그 값이 변경된다고 해서 Effect 자체를 다시 실행하고 싶지는 않은 경우를 위한 Hook입니다.

React Compiler가 컴포넌트를 건너뛰는 경우

React Compiler는 안전하다고 증명할 수 있는 코드만 메모이제이션할 수 있습니다.

그리고 그 증명은 코드가 React의 규칙을 따르고 있다는 것을 전제로 합니다.

  • 컴포넌트는 렌더링 중 순수해야 합니다.
  • props와 state를 직접 변경해서는 안 됩니다.
  • Hook은 항상 동일한 순서로 호출되어야 합니다.

Compiler가 이러한 규칙을 위반한 코드를 발견하면, 코드를 수정하려고 하지도 않고 망가뜨리지도 않습니다. 대신 해당 컴포넌트 전체를 건너뛰고 원래 작성한 코드 그대로 유지합니다.

어떤 컴포넌트가 성공적으로 컴파일되었는지는 React DevTools의 Components 탭에서 확인할 수 있습니다.

컴파일된 컴포넌트에는 작은 Memo ✨ 배지가 표시됩니다.
반대로 배지가 없다면 Compiler가 해당 컴포넌트의 최적화를 포기했다는 의미입니다.

대부분은 React 규칙을 위반했기 때문이지만, 개발자가 작성한 수동 메모이제이션이 Compiler와 일치하는지 증명하지 못한 경우일 수도 있습니다.

어떤 이유인지는 eslint-plugin-react-hooks의 린트 규칙이 알려줍니다.
이 배지는 개발 환경에서도 확인할 수 있습니다.
React Compiler는 Babel 플러그인이 실행되는 모든 곳에서 함께 동작하기 때문입니다.

즉, 개발 서버에서 이미 컴파일된 동작을 확인하고 성능을 측정할 수 있으며, 프로덕션 빌드에서만 최적화가 적용되는 것은 아닙니다.

Compiler를 활성화하기 전에 프로젝트에서 다음 명령을 실행해 볼 수도 있습니다.

npx react-compiler-healthcheck

이 명령은 코드베이스를 검사하여 얼마나 많은 컴포넌트가 성공적으로 컴파일될 수 있는지 알려줍니다.

만약 Compiler를 전체 프로젝트에 활성화했는데 특정 컴포넌트에서 문제가 발생한다면, 해당 컴포넌트만 제외할 수 있습니다.

function LegacyDataGrid() {
  'use no memo';
  // Compiler가 이 컴포넌트를 완전히 건너뜁니다.
}

반대로 컴포넌트를 하나씩 점진적으로 적용하고 싶다면 Compiler 설정에서 compilationMode: 'annotation'을 사용하면 됩니다.

그러면 명시적으로 지정한 컴포넌트만 컴파일됩니다.

function ProductList() {
  'use memo';
  // annotation이 있는 함수만 컴파일됩니다.
}

실제 서비스 환경에서는 또 다른 방법도 있습니다.

Compiler의 gating 설정을 사용하면 컴파일된 버전과 원본 버전,
두 가지 함수를 모두 생성한 뒤 개발자가 제공한 Feature Flag 함수에 따라 런타임에서 둘 중 하나를 선택하도록 만들 수 있습니다.

즉, React Compiler를 카나리 배포 방식으로 적용할 수 있습니다.

최적화된 버전과 기존 버전을 함께 배포한 뒤, 먼저 전체 사용자 중 5%에게만 활성화하고 오류율과 성능 지표를 확인합니다.

이후 충분한 신뢰가 확보되면 점진적으로 적용 범위를 확대하면 됩니다.

React Compiler가 하지 않는 일

React Compiler를 사용할 때 반드시 알아두어야 할 몇 가지 한계가 있습니다.

캐시는 컴포넌트 단위로 관리됩니다.
예를 들어 다섯 개의 컴포넌트가 모두 동일한 data를 전달하면서 processHugeDataset(data)를 호출한다고 가정해 보겠습니다.

이 경우 해당 함수는 다섯 번 실행되며, 각 컴포넌트는 자신의 결과를 별도로 캐시합니다.
즉, Compiler는 여러 컴포넌트에서 공통으로 사용하는 계산을 하나의 공유 캐시로 올려주지 않습니다. 이러한 크로스 컴포넌트 캐싱은 여전히 개발자가 직접 처리해야 합니다.

계산을 상위 컴포넌트로 끌어올리거나, Context에 저장하거나, TanStack Query와 같은 라이브러리에 맡기는 방식은 그대로 필요합니다.

React Compiler는 내가 작성한 코드만 컴파일합니다.
node_modules 내부의 코드는 모두 그대로 통과합니다. Compiler는 원본 소스를 분석해야 하는데, 대부분의 라이브러리는 이미 빌드가 완료된 결과물만 배포하기 때문입니다.

따라서 라이브러리가 React Compiler의 최적화를 적용하려면, 라이브러리 작성자가 배포 전에 직접 Compiler를 실행해야 합니다.

Compiler는 볼 수 없는 것은 최적화할 수 없습니다.
예를 들어 서드파티 Hook이 호출될 때마다 새로운 객체를 반환한다면, Compiler는 매번 새로운 참조를 전달받게 됩니다. 이미 앞에서 살펴본 캐시 동작 방식처럼 참조 비교는 렌더링마다 실패하게 되고, 해당 객체에 의존하는 캐시된 값도 모두 다시 계산됩니다.

즉, 내 컴포넌트는 최적화되더라도 외부에서 전달되는 불안정한 참조까지 안정적으로 만들어 주지는 않습니다.

Server Component에서도 얻을 수 있는 이점은 거의 없습니다.
Server Component는 서버에서 한 번 렌더링된 뒤 클라이언트에서 다시 리렌더링되지 않습니다.

메모이제이션은 리렌더링을 건너뛰기 위한 기술이므로, 애초에 리렌더링이 없는 Server Component에서는 Compiler가 최적화할 대상이 거의 없습니다.

또한 대부분의 애플리케이션이 느려지는 주요 원인을 해결해 주는 것도 아닙니다. 네트워크 워터폴, 과도하게 큰 번들, 느린 서버, useEffect에서의 데이터 요청과 같은 문제는 그대로 남아 있습니다.

React Compiler는 리렌더링 비용만 줄여줄 뿐입니다.
애플리케이션이 다른 이유 때문에 느리다면 Compiler를 활성화해도 그대로 느립니다. 그리고 앞에서 설명했듯이 클래스 컴포넌트도 지원하지 않습니다. 클래스 컴포넌트는 그대로 건너뛰며 어떠한 최적화도 적용되지 않습니다.

실제 성능 수치는 어떨까?

Meta는 Meta Quest Store에 React Compiler를 적용한 뒤 성능을 측정했습니다.
그 결과 초기 로딩과 페이지 간 이동 속도가 최대 12% 향상되었으며,
일부 상호작용은 2.5배 이상 빨라졌습니다.

메모리 사용량은 거의 변화가 없었습니다.
Sanity는 Studio 패키지를 미리 컴파일한 결과 다음과 같은 성과를 얻었습니다.

  • 전체 렌더링 시간과 지연 시간이 20~30% 감소
  • 전체 1,411개의 컴포넌트 중 1,231개가 첫 번째 컴파일에서 성공적으로 최적화

Wakelet은 React Compiler를 전체 사용자에게 적용한 뒤 두 가지 Core Web Vitals를 측정했습니다.

  • LCP(주요 콘텐츠가 화면에 표시될 때까지의 시간)는 약 10% 개선되었습니다. (2.6초 → 2.4초)
  • INP(사용자 입력부터 화면이 반응하기까지의 시간)는 약 15% 개선되었습니다. (275ms → 240ms)

또한 드롭다운처럼 React만으로 구성된 화면에서는 상호작용 속도가 약 30%까지 향상되었습니다.

물론 실제 성능 향상은 애플리케이션이 기존에 얼마나 많은 불필요한 리렌더링을 발생시키고 있었는지에 따라 달라집니다.

이제 무엇을 해야 할까?

새로운 프로젝트라면 첫날부터 React Compiler를 활성화하는 것을 추천합니다.
이제는 메모이제이션을 신경 쓰지 않고 컴포넌트를 작성하면 됩니다.

Expo는 이미 기본적으로 React Compiler를 활성화하고 있으며,
Vite와 Next.js도 설정 하나만 추가하면 사용할 수 있습니다.

기존 프로젝트라면 먼저 react-compiler-healthcheck를 실행해 현재 상태를 확인해 보세요.
그리고 eslint-plugin-react-hooks를 6 버전 이상으로 업그레이드한 뒤 린트가 알려주는 문제를 먼저 수정하는 것이 좋습니다.

그다음에는 개별 컴포넌트가 아니라 컴포넌트 트리 단위로 React Compiler를 적용해 보세요.
적용 전후의 성능을 프로파일링으로 비교하고, 기존에 작성한 useMemo는 테스트를 수행하면서 점진적으로 제거하는 것이 안전합니다.

React 19.2에서는 이러한 프로파일링도 더 쉬워졌습니다. 이제 React는 Chrome DevTools Profiler에 Performance Tracks를 기본 제공합니다. Scheduler Track과 Components Track을 통해 어떤 컴포넌트가 언제 렌더링되었는지 더욱 쉽게 확인할 수 있습니다.

React Compiler를 활성화해 보세요.


참고 자료


혹시 번역이 어색하거나 잘못 전달된 부분, 또는 보완하면 좋을 내용이 있다면 댓글로 남겨주시면 감사하겠습니다 :)

profile
Design + FE = 🔥

0개의 댓글