얼마전에 진행하던 사이드 프로젝트에서 React 19로 버전을 업그레이드를 하고, React 19로 업그레이드를 하는 김에 React Compiler를 도입해보았습니다. 

 

React Compiler를 도입하는 과정 자체는 어렵지 않았습니다.

그러나, React Compiler를 왜 만들게 되었고 내부적으로 어떤 방식으로 동작하는지 아무런 이해없이 넘어갈 수는 없었습니다. 

 

React 팀은 왜 React Compiler를 도입하게 되었고, 이것이 어떤 방식으로 동작하는지 살펴보고 이해하기 위한 글을 작성해봅니다. 

최적화를 자동으로 해준다고 하지만, 어떤 tradeoff가 있는지도 알아봅시다.

 


 

React Compiler의 목적

React compiler의 목적은 React 공식문서에서 찾을 수 있었습니다.

"React Compiler는 빌드 시점에 React 애플리케이션을 자동으로 최적화합니다. (중략) 수동 메모이제이션은 번거롭고, 실수하기 쉬우며, 유지보수 코드를 늘립니다. React Compiler는 이 최적화 작업을 대신 수행해 주므로, 개발자는 이런 정신적 부담에서 벗어나 오직 기능 개발에만 집중할 수 있습니다." 

 

개발자는 비즈니스 로직과 UI 구현에만 집중하고, 사용자에게는 컴파일러 단위에서 최적화된 앱을 기본값으로 제공하는 것이 주요 목적이었습니다.

  • useMemo, useCallback, React.memo 같은 코드가 많아짐.
  • 의존성 배열에 값을 빼먹거나 잘못 넣었을 경우 버그가 발생하고, 최적화가 제대로 되지 않음.
  • 메모이제이션을 해야할지, 함수는 재생성되어도 괜찮을지 고민하는 시간이 많아짐.

 

 

 

React Compiler의 10단계 파이프라인

React Compiler가 코드를 어떻게 분석하고 최적화된 캐시 코드로 변환하는지, 간단한 코드 예시와 함께 10단계를 살펴봅시다.

 

 

0단계: 원본 코드

function UserProfile({ user, isActive }) {
  // 사용되지 않는 변수
  const unusedMsg = "Hello!"; 
  
  // user.name이 안 바뀌어도 렌더링될 때마다 재실행됨
  const upperName = user.name.toUpperCase(); 

  // isActive가 안 바뀌어도 렌더링될 때마다 새로운 div 객체를 생성함
  return (
    <div className={isActive ? 'active' : ''}>
      Name: {upperName}
    </div>
  );
}

 

 

1단계: AST 파싱(Babel Parsing)

개발자가 작성한 텍스트 형태의 자바스크립트 코드를 컴퓨터가 구조를 파악할 수 있는 추상 구문 트리(AST, Abstract Syntax Tree)로 변환합니다. 

 

컴퓨터는 const a = 1이라는 단순한 텍스트를 논리적으로 이해하지 못합니다. 이것이 변순 선언문인지, 함수 호출인지 트리 형태로 쪼개어 문법적 구조를 파악해야 컴파일러가 분석을 시작할 수 있습니다. 배송받은 조립 가구의 상자를 뜯어, 부품들을 바닥에 쭉 펼쳐놓고 부품 리스트를 만드는 과정과 같습니다.

 

// 코드가 거대한 JSON 트리 구조로 변환됩니다.
{
  "type": "FunctionDeclaration",
  "id": { "name": "UserProfile" },
  "params": ["user", "isActive"],
  "body": [
    { "type": "VariableDeclaration", "declarations": [{ "id": "unusedMsg", "init": "Hello!" }] },
    // ... 나머지 코드 생략
  ]
}

 

 

2단계: React 규칙 검증

파싱된 코드가 React의 규칙(Rules of React)을 잘 지켰는지 검사합니다. 

컴파일러는 안전이 최우선입니다. 훅을 조건문 안에서 호출했거나, 지역 변수를 임의로 변형하는 등 예측 불가능한 코드를 억지로 최적화하면 앱이 망가질 수 있습니다. 규칙을 어겼다면 이 단계에서 컴파일을 포기(Bailout)하고, 원본 코드를 그대로 통과시킵니다. 부품 조립 라인에 올리기 전, 재표가 불량품이거나 오염되지는 않았는지 검수하는 과정이라고 생각하면 되겠습니다. 

 

import { CompilerError } from '../CompilerError';

// AST로 변환된 개별 컴포넌트(함수)를 컴파일하는 메인 진입점
export function compileFn(funcAST) {
  try {
    // 1. 컴파일러의 10단계 파이프라인 실행 시도
    const hir = lower(funcAST); // 기계어(HIR)로 변환
    const reactiveFunction = buildReactiveScopes(hir); // 스코프 분석
    /* ... 다양한 최적화 로직 ... */
    
    // 컴파일에 성공하면 최적화된 새로운 코드를 반환
    return codegen(reactiveFunction); 

  } catch (err) {
    // 2. 파이프라인 도중 React 규칙 위반이 발견되면 CompilerError가 발생(throw)함
    if (err instanceof CompilerError) {
      // 🚨 Bailout 발생!
      // 앱을 망가뜨리지 않기 위해 에러를 무시하고, 원본 코드를 그대로 반환합니다.
      return funcAST; 
    }
    
    // 규칙 위반이 아닌 컴파일러 자체의 치명적 버그일 경우에만 에러를 던짐
    throw err;
  }
}

 

 

3단계: HIR 변환 (High-level Intermediate Representation)

복잡한 자바스크립트 AST를 컴파일러 전용의 단순하고 통일된 중간 언어(HIR)로 변환합니다. 제어 흐름(if, for 등)을 평면적인 '기본 블럭' 단위로 쪼갭니다. 

자바스크립트는 언어적 유연성이 너무 커서 분석이 끔찍하게 어렵습니다. 복잡한 구조를 평탄화하여 기계가 연산 흐름을 쉽게 추적할 수 있도록 규격화해야합니다. 

 

// 코드가 순차적인 블록으로 쪼개집니다.
[Block 0]
  $0 = LoadLocal user
  $1 = LoadLocal isActive
  $2 = "Hello!"
  $3 = PropertyLoad $0.name
  $4 = Call $3.toUpperCase()
  $5 = JsxElement <div className={...}>Name: {$4}</div>
  Return $5

 

 

4단계: SSA 구축 (Static Single Assignment)

"모든 변수는 단 한 번만 할당된다"는 규칙을 강제하여 코드에 버전을 매깁니다.

변수가 중간에 어떻게 덮어씌워졌는지 헷갈리지 않게 하기 위해서입니다. SSA를 적용하면 어떤 값이 어디서 만들어져서 어디로 흘러가는지 추적할 수 있습니다. 

 

// 위 HIR 블록에 SSA가 적용되어 변수가 단일 할당으로 확정됩니다.
// (예제에서는 재할당이 없었으므로 HIR과 유사하지만, 로직상 완전히 고정됩니다)
$user_0 = LoadLocal user
$upperName_0 = Call $user_0.name.toUpperCase()

 

 

5단계: 타입 및 별칭 분석

각 변수들의 타입을 추론하고, 어떤 변수들이 같은 메모리 주소를 가리키고 있는지 추적합니다.

자바스크립트에서 객체나 배열은 참조(Reference)로 전달됩니다. a.name = 'yeom'을 수정했는데 엉뚱하게 b가 바뀌는 일이 생길 수 있습니다. 이를 정확히 파악해야 나중에 '함께 묶어서 캐싱할 덩어리'를 안전하게 계산할 수 있습니다. 

 

// 내부적인 타입/참조 테이블 기록
Type($user_0) = Object;
Type($isActive_0) = Boolean;
Type($upperName_0) = String;
AliasMap.set($user_0.name, [/* 의존성 묶음 */]);

 

 

6단계: 사용하지 않는 코드 제거

앞선 분석을 바탕으로, 최종 결과에 아무런 영향을 미치지 않는 의미없는 변수나 도달할 수 없는 코드를 삭제합니다. 

쓰레기 데이터가 섞여 있으면 이후 진행될 '캐싱 그룹화' 작업이 불필요하게 무거워지고 복잡해지기 때문입니다. 

 

// $2 ("Hello!")는 생성되었으나 이후 사용되지 않음.
// -> HIR 명령줄에서 $2 생성 코드를 과감히 삭제!

 

 

7단계: 반응형 스코프 추론 (Infer Reactive Scopes)

★ 어떤 변수들이 함께 묶여서 업데이트되어야 하는지 계산하여 '메모이제이션할 경계'를 그립니다. 수동으로 작성하던 useMemo 블록을 자동으로 찾아내는 과정입니다. 상태나 props 중 어떤 값이 변했을 때, UI의 어느 부분만 다시 계산할 지 결정하기 위함입니다. SSA 분석을 바탕으로 데이터의 의존성을 파악합니다.

 

[Scope 1: 문자열 연산 블록]
- 의존성 감시: user.name
- 결과물 생성: upperName

[Scope 2: JSX 생성 블록]
- 의존성 감시: isActive, upperName
- 결과물 생성: <div> 엘리먼트

 

🔎 컴파일러가 스코프를 찾는 원리 

컴파일러가 메모이제이션 경계(Scope)를 자동으로 찾는 과정은 우연이나 마법이 아닙니다.
앞선 단계에서 만들어둔 SSA와 타입/별칭 분석 데이터를 활용하여 수학적인 그래프를 그리는 과정입니다.

구체적으로 다음 3가지 논리를 거쳐 자동으로 스코프를 찾아냅니다.

1. 데이터의 족보 추적 (의존성 그래프 구축)
앞선 4단계(SSA)에서 컴파일러는 모든 값에 $1, $2 같은 고유 번호를 붙였습니다. 만약 $3 = $1 + $2 라는 코드가 있다면, 컴파일러는 즉시 "$3을 만들려면 $1과 $2가 필요하다"는 데이터 의존성 그래프를 그립니다. 즉, 누가 누구에게 영향을 받는지 꼬리표를 따라가는 것입니다.

2. 값이 "완성"되는 시점 찾기 (변이 추적 및 경계 설정)
자바스크립트에서 객체나 배열은 선언된 후에도 계속 내용물이 추가될 수 있습니다. 컴파일러는 변수가 태어나서, 조작되다가, 더 이상 내용물이 변하지 않고 "읽기만" 하는 시점을 포착합니다. 변수가 생성된 곳부터 마지막으로 변이된 곳까지를 하나의 덩어리로 묶어버립니다. 이것이 바로 스코프의 '크기'가 됩니다.

3. 입력(Input)과 출력(Output) 정의하기
블록의 크기가 정해졌다면, 컴파일러는 이렇게 질문합니다.
출력(Output): "이 블록이 최종적으로 만들어내는 결과물은 무엇인가?"
입력(Input): "이 블록 밖에서 안으로 끌어다 쓴 재료(변수)는 무엇인가?"
여기서 이 '입력(Input) 재료'들이 바로 우리가 그토록 수동으로 적어주던 useMemo의 [의존성 배열]이 되는 것입니다!


🔍 이해를 돕는 구체적인 예시
우리가 다음과 같은 코드를 작성했다고 가정해 봅시다.

// 1. 배열 생성 (스코프 시작!) 
const tags = []; 

// 2. 외부 변수(user.job)를 사용해 변이(Mutation) 발생 
if (user.job === 'developer') { 
	tags.push('Dev'); 
} 

// 3. 마지막 변이 (여기까지가 하나의 스코프!) 
tags.push('Member'); 

// 4. 이후 tags는 렌더링에만 사용됨 (읽기 전용 상태) 
return <Profile tags={tags} />;


컴파일러는 이 코드를 훑어보고 다음과 같이 기계적으로 추론합니다.

컴파일러의 생각:
1. tags라는 배열이 만들어졌군.
2. 어? 밑에서 .push()로 내용물을 막 바꾸고 있네? 어디까지 바꾸나 보자.
3. tags.push('Member') 여기까지 바꾸고, 그다음부터는 렌더링할 때 읽기만 하네!
4. 좋아, "배열 생성부터 마지막 push까지"를 하나의 스코프(Scope)로 묶자.
5. 이 스코프 안에서 외부 변수를 쓴 게 있나? 아! user.job을 가져다 썼네.
6. 결론: 이 스코프의 의존성 배열은 [user.job]이다. user.job이 안 바뀌면 이 배열 생성~push 과정 전체를 통째로 건너뛰어도 되겠다! (캐싱 확정)

이처럼 컴파일러는 코드를 위에서 아래로 읽어 내리며, 변수의 생애 주기(생성 -> 변경 -> 사용)와 외부 재료(Input)를 수학적으로 추적하여 완벽한 메모이제이션 바운더리를 자동으로 그려냅니다.

 

8단계: 스코프 최적화 및 변합

7단계에서 묶어낸 스코프들이 너무 자잘하다면, 묶어서 하나로 합치거나 불필요한 스코프를 제거합니다.

너무 잘게 쪼개서 모든 것을 캐싱하려고 하면 오버헤드가 심해서 오히려 성능이 좋지 않아질 수 있습니다. 적절한 덩어리로 묶는 과정입니다.

 

더보기

💡 컴파일러는 어떤 기준으로 스코프를 묶을까? 

컴파일러는 추론된 자잘한 스코프들을 다음의 3가지 기준을 통해 합칩니다.

1. 연쇄적인 의존성

가장 대표적인 병합 기준입니다. A가 변할 때 필연적으로 B도 변해야 하는 '연속된 데이터 흐름(Sequential Flow)'을 가지면, 컴파일러는 이를 하나의 스코프로 묶어버립니다.

 

// [예시 코드]
const a = data * 2;   // (Scope 1)
const b = a + 10;     // (Scope 2)
const c = b + 5;      // (Scope 3)
  • 병합 전 (비효율): data가 변했는지 체크해서 a를 캐싱하고, a가 변했는지 체크해서 b를 캐싱하고... 쓸데없는 캐시 비교문(if)이 3번이나 생깁니다.
  • 컴파일러의 판단: "어차피 data가 바뀌면 a, b, c가 도미노처럼 다 바뀌잖아? 중간 과정을 굳이 따로 캐싱할 필요가 없지!"
  • 병합 후: data 하나만 감시하고, 한 번에 c를 만들어내는 거대한 Scope 하나로 합쳐버립니다. (캐시 검사 1번으로 단축)

2. 연산 비용 vs 캐싱 비용

컴파일러는 연산의 종류를 분석하여 이것이 무거운 연산인지, 아주 가벼운 연산인지 판별합니다.

  • user.name 처럼 단순히 객체의 속성을 읽는 행위(PropertyLoad)나 단순 사칙연산은 CPU 비용이 0에 가깝습니다.
  • 반면, 이런 가벼운 연산을 캐싱하려고 배열 슬롯(useMemoCache)을 할당하고 if문으로 이전 값과 비교하는 행위가 오히려 비용이 더 듭니다.
  • 컴파일러의 판단: "이런 단순 연산은 굳이 독립적인 스코프를 주지 말고, 이 값을 최종적으로 사용하는 더 큰 스코프(예: JSX 렌더링 블록) 안으로 흡수시켜 버리자."

3. 생명주기의 완전한 겹침

두 개의 다른 변수가 완전히 동일한 조건문(예: 같은 if 블록 안)에서 생성되고 소멸한다면(Overlapping), 굳이 두 개의 스코프를 따로 관리할 필요가 없습니다.

 
// [예시 코드]
if (isLogin) {
  const title = "환영합니다";         // (Scope 1)
  const desc = "VIP 멤버십 혜택...";  // (Scope 2)
  return <Banner title={title} desc={desc} />; // (Scope 3)
}
  • 컴파일러의 판단: "title과 desc와 Banner는 항상 isLogin이 참일 때 동시에 태어나고 동시에 쓰이네? 생명주기가 완전히 겹치니 그냥 하나의 묶음으로 처리하자."
  • 결과: 변수 2개와 JSX 1개가 하나의 커다란 Scope로 병합됩니다.

요약하자면

컴파일러가 말하는 '적절한 덩어리'란, "캐시 비교문(if)의 개수를 최소화하면서도, 불필요한 재렌더링은 막을 수 있는 가장 가성비 좋은 단위"를 의미합니다. 무조건 잘게 쪼개는 것이 아니라, 데이터가 엮여있거나 너무 가벼운 연산이라면 하나로 통째로 묶어버리는 판단을 내리는 것입니다.

 

9단계: 캐시 할당

각 스코프와 변수들이 저장될 메모리 공간(캐시 배열의 인덱스 번호)을 배정합니다. 내부적으로 useMemoCache라는 훅을 사용합니다.

실제 브라우저(런타임)에서 이전 렌더링 때의 값을 꺼내와서 비교하려면 명확한 저장소 주소가 필요합니다.

 

 

10단계: 최종 코드 생성

지금까지 분석하고 할당한 모든 정보(AST, 스코프, 캐시 인덱스)를 바탕으로, 브라우저가 실행할 수 있는 최종적이고 완벽하게 최적화된 자바스크립트 코드를 출력합니다.

 

 

실제 컴파일러가 코드를 보는 방식

 

React Compiler Playground에서 확인할 수 있습니다.

 

 

💡 실제 컴파일러 코드에서 엿보는 3가지 디테일

출력된 실제 코드를 자세히 들여다보면, 어떤 방식으로 코드를 최적화하는지 알 수 있습니다.

 

1. Props 구조 분해의 평탄화 (t0): 우리는 function UserProfile({ user, isActive })라고 우아하게 작성했지만, 컴파일러는 이를 function UserProfile(t0)로 바꾼 뒤 내부에서 const { user, isActive } = t0;로 다시 분해했습니다. 기계 입장에선 모든 인자를 t0라는 하나의 덩어리로 취급하고 일관되게 추적하는 것이 훨씬 안전하고 분석하기 쉽기 때문입니다.

 

2. 3항 연산자까지 분리하는 집요함 (t2): 원본 코드에서는 JSX 안에 className={isActive ? 'active' : ''}라고 썼습니다. 하지만 컴파일러는 이 연산조차 JSX에서 분리해 내어 const t2 = isActive ? "active" : ""; 라는 독립적인 변수로 빼냈습니다. 컴파일러는 아주 작은 연산 하나도 놓치지 않고 쪼개어 의존성을 추적합니다.

 

3. 캐시 메모리의 정확한 할당량 (_c(5)): 이 컴포넌트를 캐싱하기 위해 컴파일러는 총 5칸(_c(5))의 메모리 배열을 할당했습니다.

  • $[0]: 의존성 user.name 저장
  • $[1]: 결과값 t1 (user.name.toUpperCase()) 저장
  • $[2]: 의존성 t2 (클래스 이름 연산 결과) 저장 (위 2번의 분리 작업 때문에 슬롯이 1개 추가됨!)
  • $[3]: 의존성 upperName 저장
  • $[4]: 결과값 t3 (최종 렌더링된 가상 DOM <div>) 저장

결론적으로, 개발자가 useMemo를 단 한 줄도 쓰지 않았음에도 무거운 이름 대문자 변환 연산비용이 큰 JSX 가상 DOM 생성이 두 개의 거대한 if / else 블록을 통해 완벽하게 메모이제이션 되었습니다. 

 


 

참고자료

 

React 공식 문서 (React Compiler 소개): "Without the compiler, you need to manually memoize... React Compiler automatically applies the equivalent of manual memoization"

 

Introduction – React

The library for web and native user interfaces

react.dev

 

https://yongseok.me/blog/react_compiler_1/#react-compiler

 

React Compiler, 어떻게 동작할까 [1] - 바벨 플러그인을 통한 진입점 | 장용석 블로그

React Compiler에 대해 깊이 파헤쳐보고자 한다. 우선은 바벨 플러그인을 통해 컴파일러의 진입점을 살펴보자.

yongseok.me