FEConf2025에서 발표한 <'memo'를 지울 결심 : React Compiler가 제안하는 미래>를 정리한 글입니다. React 컴파일러를 통해 수동 메모이제이션의 부담 없이 자동으로 컴포넌트 성능을 최적화하는 기술의 원리와 한계를 다룹니다. 본문에 삽입된 이미지의 출처는 모두 이 콘텐츠와 같은 제목의 발표 자료로, 따로 출처를 표기하지 않았습니다.
'memo'를 지울 결심 : React Compiler가 제안하는 미래
리멤버앤컴퍼니 장용석 개발자
React 컴파일러에 대해 들어보셨나요? 키워드로만 접하셨거나 오늘 처음 듣는 분들도 계실 겁니다. React 컴파일러는 'React Forget'이라는 프로젝트명으로 2021년 React 컨퍼런스에서 처음 언급되었습니다. 그 이후 간간히 개발 소식이 공유되다가 2024년 React 컨퍼런스에서 'React Compiler'라는 정식 명칭으로 오픈소스로 공개되었습니다.

React 컴파일러에 대해 알아보기 전에, 먼저 React의 특성을 다시 한번 짚어보도록 하겠습니다.
React 컴포넌트들은 부모와 자식으로 연결된 트리 형태로 구성되어 있습니다. 부모 컴포넌트가 리렌더링되면 자식 컴포넌트도 재귀적으로 호출되어 리렌더링됩니다. 이 과정에서 우리는 불필요한 리렌더링을 겪곤 합니다. React도 이에 대한 대비책을 마련해두었습니다. 불필요한 리렌더링을 막기 위해 `useMemo`, `useCallback`, `memo`와 같은 도구들을 이용하여 참조를 안정화하거나 렌더링 조건을 부여하여 불필요한 렌더링을 예방해줍니다.

메모이제이션은 공짜가 아니다
그렇다면 모든 곳에 메모이제이션을 해주면 좋지 않을까요? 하지만 메모이제이션은 공짜가 아닙니다. 이전값을 비교하기 위해 메모리가 사용되어야 하고, 런타임에서 비교 작업을 수행하다 보면 오버헤드가 발생할 수 있습니다. 또한 `useMemo`, `useCallback`과 같은 훅들은 의존성 배열에 대한 관리도 신경 써야 합니다. 제대로 관리되지 못한다면 의도하지 않은 메모이제이션이 발생하게 됩니다.
마지막으로, 모든 곳에 메모이제이션을 해주게 되면 최적화 코드가 더 많아져서 코드의 본래 의도가 희석되기 마련입니다.

어디에, 얼마나 적용해야 하는가
그렇다면 모든 곳에 메모이제이션을 해주는 것은 어려워 보입니다. 그럼 우리는 어떤 기준을 가지고 메모이제이션을 해야 할까요? 이런 고민 없이 개발자가 메모이제이션을 신경 쓰지 않아도 최적의 성능을 낼 수 있다면 어떨까요? 이런 질문을 던져보면 자연스럽게 "자동으로 적절한 곳에 해주면 좋겠다"는 생각으로 이어지게 됩니다.
이것이 바로 React 컴파일러의 목적입니다. 개발자가 메모이제이션에 대한 고민과 부담 없이 개발에만 집중할 수 있도록 하는 것입니다. React 컴파일러를 도입했을 때 어떤 성과가 있었을까요? 2024년 React 컨퍼런스에서 React 컴파일러 공개 당시, Meta는 이미 Quest Store와 Instagram.com에 적용 중이었다고 밝혔습니다.
발표된 벤치마크 결과에 따르면, 인터랙션 성능의 경우 2.5배, 초기 로드 및 내비게이션의 경우 속도가 12% 개선되었으며, 이 과정에서 메모리 사용량의 증가 없이 이러한 성능 향상을 달성했다고 합니다. 대단한 수치라고 할 수 있습니다.

그렇다면 어떻게 이런 효과를 내는지 동작을 통해 알아보도록 하겠습니다.
간단한 예시 컴포넌트를 살펴보겠습니다. `App` 컴포넌트라는 부모 컴포넌트가 있고 `Text`라는 자식 컴포넌트를 가지고 있습니다. 이 `Text` 컴포넌트는 `color`를 props로 받아서 폰트 색상을 변경해주는 간단한 컴포넌트입니다.

이 단순한 컴포넌트가 컴파일러를 통과하면 어떻게 변할까요? 꽤 복잡한 컴포넌트로 변하게 됩니다. 차근차근 의미를 파악해보도록 하겠습니다.

먼저 기존 코드에는 없던 `useMemoCache`라는 훅이 새롭게 생겨났습니다. `useMemoCache`는 캐시 사이즈를 인자로 받아서 그 사이즈만큼 크기를 가진 배열 형태의 캐시를 파이버 노드에 저장하는 훅입니다. `Text` 컴포넌트는 컴파일 이후에 크기 2짜리 캐시를 가지게 됩니다. `App` 컴포넌트의 경우는 크기 1짜리 캐시가 생성됩니다.
이어서 기존에는 없던 조건문들도 생겨났습니다. 첫 번째 조건문을 살펴보면, 캐시의 첫 번째 값과 특정 심볼 값을 비교합니다. 이 심볼 값은 캐시의 초깃값이라고 보시면 됩니다. 즉, 처음 렌더링될 때 흘러가는 흐름입니다. 그렇다면 `else`문의 경우에는 리렌더링 시 실행되는 분기라고 볼 수 있습니다.
더 이해하기 쉽게 풀어보자면, 처음 렌더링할 때 `Text` 컴포넌트의 참조를 캐시에 담아두었다가 리렌더링 시에는 캐시의 값이 존재하므로 그 캐시 값을 다시 반환하는 형태로 이해할 수 있습니다.

`Text` 컴포넌트도 살펴보겠습니다. `Text` 컴포넌트는 크기 2짜리 캐시 배열을 받았습니다. 첫 번째 조건문을 살펴보면 props로 전달받은 `color` 값이 캐시의 첫 번째 값과 다르다면 새로운 JSX 엘리먼트를 생성하고, 그 값을 `color` 값과 함께 캐시에 저장합니다. 반대로 `else`문의 경우는 props가 같은 경우입니다. 이때는 이전에 저장했던 값을 리턴합니다. 즉, `color`가 달라질 때만 새로운 JSX를 생성하게 됩니다.
단순한 예시여서 그런지 오히려 코드 사이즈만 늘어난 것 같다고 느껴집니다. 하지만 우리가 작성하는 코드들은 이렇게 단순한 경우가 흔치 않습니다.
부모의 상태가 하나 추가된다면 어떻게 달라질까요? `count`라는 상태를 추가해 보겠습니다.
기존의 동작 방식이라면 `count`가 증가할 때 `App` 컴포넌트가 리렌더링되면서 자식인 `Text` 컴포넌트도 함께 리렌더링됩니다. 지금은 `Text` 컴포넌트가 간단하지만, 만약 비싼 연산이 있었다면 어떻게 되었을까요? `count`가 증가할 때마다 이 계산도 매번 다시 수행되었을 것입니다.

이런 경우 우리는 메모이제이션을 통해서 불필요한 재계산을 방지해왔습니다. `useMemo`를 통해서 `color`가 달라지지 않는 경우 같은 값을 유지하도록 해주었습니다.
`useMemo`도 좋은 도구이지만 추가하는 과정에서 억울한 부분도 있습니다. 리렌더링을 유발한 대상은 부모의 `count` 값인데, 이 `count`라는 상태와 `Text` 컴포넌트는 로직적인 관계가 없습니다. 그럼에도 불구하고 이 계산의 낭비를 막기 위해서 메모이제이션을 하고 의존성 배열을 관리해야 하는 일까지 추가되었습니다.

그렇다면 이 경우 컴파일러는 우리를 어떻게 도울 수 있을까요? 다시 한번 컴파일해보면, 아까보다 약 2배 정도 더 길어집니다. `App` 컴포넌트의 경우 라인 수가 거의 2배가량 됩니다. 하지만 자세히 보면 어렵지 않습니다. 우리가 언제 자식이 다시 계산될 것인지를 먼저 살펴보면 좋을 것 같습니다.
`Text` 컴포넌트가 등장하는 부분을 자세히 살펴보면, 아까 살펴봤던 분기와 동일합니다. 최초 렌더링될 때 JSX 엘리먼트를 반환하고 그 이후에는 다시 등장하지 않습니다. `App` 컴포넌트의 `count` 값이 달라져도 `Text`에는 영향 없이 리렌더링을 건너뛸 수 있게 됩니다.
`Text` 컴포넌트는 위와 마찬가지로 `color` 값이 바뀐 경우에만 계산이 이루어지도록 바뀌어 있는 것을 볼 수 있습니다.

그렇다면 이 마법 같은, 복잡한 코드의 변화는 어떻게 만들어지는 것일까요? 컴파일 과정을 깊이 들어가보겠습니다.
먼저 왜 컴파일이라고 부를까요? 보통 컴파일이라고 하면 고수준의 언어를 저수준의 언어로 바꿔주는 역할이라고 보편적으로 알고 있습니다. 하지만 아까의 결과물을 보면 저수준으로 변형된 것이 아닌 동일한 React 코드였습니다. 오히려 트랜스파일러가 가까운 것이 아닌가 하는 의문이 들기도 합니다.
하지만 그 과정, 즉 자동 메모이제이션을 해주기 위한 과정에서 답을 찾아볼 수 있습니다. 전체 플로우를 살펴보면 꽤 많은 변환이 존재합니다. 이 모든 최적화 과정들을 알아보기에는 시간이 부족하니 대표적인 변환 과정들만 살펴보도록 하겠습니다. 크게 4가지 정도의 변환을 거쳐 메모이제이션이 된 컴포넌트로 변화합니다.

먼저 컴파일러가 React 코드를 이해하기에 앞서 JavaScript 코드를 이해해야 합니다. 이때 우리에게 친숙한 도구인 Babel을 통해서 접근하게 됩니다.
Babel은 JavaScript 코드를 파싱하여 컴파일러에게 AST, 즉 추상 구문 트리(Abstract Syntax Tree) 형태로 전달하게 됩니다. 추상 구문 트리는 코드의 구조를 트리 형태로 표현한 것으로 볼 수 있습니다. 각 노드들은 변수의 선언, 파라미터, 반환 값 등 코드의 구성 요소들을 나타냅니다.

하지만 이런 트리 형태만으로는 React의 동작을 파악하기 어렵습니다. 이때 한 단계 중요한 변환이 등장하게 됩니다. React 컴파일러 파이프라인의 첫 번째 과정이자 가장 근본적인 변화는 Babel AST를 HIR(High-level Intermediate Representation)이라고 부르는 고수준 중간 표현으로 바꿔주는 것입니다.
AST가 코드의 구조를 상하 관계로 표현하는 데 적합한 트리 구조라면, HIR은 제어 흐름 그래프(Control Flow Graph) 형태로 표현됩니다. 제어 흐름 그래프는 코드의 실행 경로를 명시적으로 나타낸 그래프 구조라고 볼 수 있습니다.

실제 AST 코드는 HIR 형태로 변환되는데, 이 HIR 형태는 '블록'이라는 단위와 '엣지'라는 실행 흐름으로 만들어지게 됩니다. 조금 더 이해하기 쉽게 그래프로 그려보면 시각화할 수 있습니다.
AST가 코드의 구조를 나타내는 틀이라면, 제어 흐름 그래프는 예를 들어 분기문에 따라 어떤 흐름으로 이어질지에 대한 표현이라고 볼 수 있습니다. 여기서 실행 흐름에 대한 이해가 필요한 이유는 어떤 값이 어떻게 변화하는가에 따른 추적을 하기 위함입니다.

HIR로 변환한 이후에는 제어 흐름을 파악할 수 있게 되었습니다. 그다음으로는 SSA 변환을 거치게 됩니다. SSA 변환은 Static Single Assignment의 약자로 정적 단일 대입이라는 뜻입니다. 각 변수가 단 한 번만 할당되도록 제한하는 형식입니다.

이런 과정은 왜 필요한 것일까요? 변수는 여러 번 할당될 수 있습니다. 이는 특정 순간에 변수가 어떤 값을 가지는지 알기 어렵게 만듭니다. 정적 단일 대입은 각 변수를 일종의 버전 관리한다고 생각하시면 됩니다.
예시를 보면, `x`는 위에서 5, 아래서 10으로 다시 할당됩니다. 변수의 이름은 같지만 사용되는 곳에서는 서로 다른 값을 가지게 됩니다. 이를 변환하면 각각 `x1`, `x2`와 같이 단 하나의 값만 할당될 수 있도록 바뀝니다. 이렇게 되면 조금 더 디테일하게 각 변수의 흐름을 추적할 수 있게 됩니다.
이제 본격적으로 변화할 수 있는 값, 즉 메모이제이션의 대상을 추적해 나가게 됩니다. 리액티브한 값은 시간에 따라 변화할 수 있는 값을 의미합니다.
이에 대한 추적은 먼저 확실한 대상으로부터 파생되는 값들을 추적하는 방식으로 파악합니다. 부모로부터 전달되는 props와 같은 값들은 자식 입장에서 언제든 변화할 수 있는 값입니다. 또한 `useState`와 같은 훅에서 호출되는 결과들도 언제든지 변화할 수 있는 기본값으로 판단합니다.

그리고 이 값들로부터 파생되는 다른 값들 또한 언제든 변화할 수 있는 값으로 생각할 수 있습니다. 이런 식으로 기본적인 값들로부터 파생되는 리액티브 값을 전파시키는 것입니다. 다시 정리하자면, 리액티브 값이란 리렌더링을 유발할 수 있는 잠재적 값들로 볼 수 있습니다. 이 값들을 메모이제이션 시 유심히 살펴보아야 합니다.
앞서 살펴보았던 제어 흐름 그래프에서 제어 흐름을 따라서 리액티브 값을 전파시키게 됩니다. 이런 식으로 리액티브 값의 전파가 끝나면 각 리액티브 값들의 변화에 영향을 받는 코드 영역들을 그룹화하는 과정을 거치게 됩니다. 이 스코프를 통해서 메모이제이션 할 영역을 파악하는 것입니다.

메모이제이션 할 대상들을 그룹화했으니 이제 다시 React 코드로 돌아갈 준비가 되었습니다. React 코드로 다시 변환하기 전에 제어 흐름 그래프 형태의 HIR을 다시 중간 트리 형태로 변환시켜 줍니다. 그리고 최종적으로 메모이제이션 도구들과 함께 React 코드로 변환하게 됩니다.
중간에 많은 과정들과 많은 알고리즘들을 다 설명하지는 못했지만, 위와 같은 큰 흐름으로 메모이제이션 대상을 파악한다고 정리할 수 있습니다. 즉, 여기서 '컴파일'이라는 의미는 메모이제이션을 해주기 위한 과정에서 찾아볼 수 있었습니다. React 코드의 흐름을 이해하기 위해서 수행했던 중간 표현으로의 변환, 이 과정이 핵심이라고 볼 수 있습니다.
위와 같은 과정을 통해서 대략적인 컴파일러의 흐름을 살펴보았습니다. 그렇다면 컴파일러의 한계점은 없을까요?
공식 문서의 런타임 이슈 부분에도 나와 있듯이, 컴파일된 코드가 예상과 다르게 동작하는 경우가 발생하곤 합니다. 이는 주로 React의 규칙을 위반할 때 발생합니다.
React 컴파일러는 기본적으로 React의 규칙을 지켰다는 가정하에 동작합니다. 만약 규칙을 어기고 순수성을 위배한 코드들을 작성하게 된다면, 컴파일러는 이를 정상적인 구현으로 간주하고 메모이제이션을 합니다. 그 결과 의도치 않은 메모이제이션이 발생하게 됩니다. 이 에러의 경우 에러로 잡히지 않고 의도대로 동작하지 않는 형태로 나타나게 됩니다. 실제로 라이브러리를 사용하는 데 있어서 컴파일러에 의해 동작이 변경되는 경우들을 경험하기도 했습니다.

그렇다면 이를 어떻게 방지할 수 있을까요? 먼저 React 규칙을 잘 지키면 됩니다. 이를 지키기 위한 도구로 ESLint와 같은 도구들이 준비되어 있습니다. 컴파일러의 규칙은 React Hooks의 규칙에 포함되어 있습니다. 하지만 이런 런타임 이슈들은 주로 우리가 간혹 의도적으로 ESLint의 규칙을 어기고 빠져나갈 때 발생합니다. 예를 들면 마운트 시점을 잡기 위해서 `useEffect`의 의존성을 다르게 넣어준다든지 하는 경우입니다. 그렇게 되면 React 컴파일러가 주는 이점을 누릴 수 없게 됩니다.
점진적으로 도입하는 방법도 있습니다. `'use no memo'`와 같이 특정 컴포넌트의 컴파일을 끌 수도 있고, 반대로 `'use memo'`와 같이 특정 컴포넌트만 컴파일하게 할 수도 있습니다. 이런 지시자들을 통해서 점진적으로 도입할 수 있는 방법도 도움이 될 수 있습니다.
다른 방법으로는 규칙을 잘 지킬 수 있도록 하는 도구들을 이용하는 것입니다. React 컴파일러의 코드베이스를 보면 'React Forget IDE'라는 실험적인 VS Code 익스텐션도 존재합니다. 이 익스텐션은 React 컴파일러를 일종의 Language Server Protocol로 사용하는 도구입니다.

아직은 실험적인 상태이지만 많은 기능들을 포함하고 있습니다. 예를 들면, 컴파일 결과를 미리 확인해 볼 수 있다거나, 밸리데이션을 확인할 수 있고, 의존성 추론도 더 명확하게 해주는 기능들이 개발 중입니다.
두 번째로 번들 크기에 대해서 이야기할 수 있습니다. 앞서 예시에서 살펴보았듯이 컴파일러를 거치게 되면 코드의 양이 많이 증가합니다. 그리고 컴파일러는 컴포넌트 단위로 컴파일을 하다 보니 생각보다 양이 커지게 됩니다. 예시를 컴파일하면 거의 2배가량 증가합니다. 이는 메모이제이션 할 대상, 즉 리액티브한 값들이 많아질수록 컴포넌트에 더 많은 분기들이 추가되기 때문입니다.

위 예시의 경우 실제 번들에서는 작은 비중을 차지하기에 큰 변화가 있다고는 볼 수 없었습니다. 하지만 더 복잡한 컴포넌트였다면, 혹은 다른 컴포넌트들도 복잡하고 코드 스플리팅이 잘되지 않았다면, 컴포넌트가 늘어날 때마다 선형적으로 증가하게 됩니다. 어쩔 수 없이 성능상의 이점을 얻기 위해 어느 정도 트레이드오프는 발생하기 마련입니다.
그렇다면 React 컴파일러를 적용하고 나서 우리가 어떤 멘탈 모델을 장착하고 개발을 해야 할까요?
React를 언어처럼 엄격하게 다루기
먼저 앞서 살펴보았듯이 React 컴파일러의 혜택을 누리기 위해서는 규칙을 잘 지켜야 합니다. React를 일종의 언어와도 같은 레벨로 취급하여 좀 더 엄격하게 규칙을 지켜야 하지 않을까 생각합니다.
리액티브 값의 중첩을 이해하기
그리고 새롭게 변화하는 값들이 어떻게 흘러가는지에 대해서 집중하면 좋을 것 같습니다. 간단한 컴포넌트 A를 준비했습니다. 이 A 컴포넌트는 인자로 딸기, 당근, 수박을 받아서 비싼 계산을 한 뒤 보여주는 심플한 컴포넌트입니다. 상태가 딸기, 당근, 수박을 순회하거나 반복하는 경우가 있다고 가정해 봅시다.

상태가 없으니 이렇게도 표현해 볼 수 있습니다. 각 값에 따라 분기문을 추가해 보았습니다. 딸기일 때, 당근일 때, 그 외일 때 이런 식으로 나눠보았습니다.

사실 이 두 개의 컴포넌트는 분기가 추가되었을 뿐, 결과물에 있어서는 큰 차이가 없을 것 같습니다. 실제로 돌려보면 어떻게 될까요? 여기서 이 값들이 딸기, 당근, 수박으로 순회하게 된다면 어떤 컴포넌트가 더 효율적일까요? 보이는 결과물은 똑같을 텐데 말입니다.
먼저 A 컴포넌트의 경우 값을 순회하게 되면 매번 비싼 연산이 다시 계산됩니다. 예상했던 동작입니다. 그렇다면 메모이제이션을 해주면 달라질까요? 메모이제이션을 해주게 되면 직전값과 비교하기 때문에 사실 순회하는 경우에는 크게 의미가 없습니다. 당연히 다시 계산해야 하는 값이기 때문입니다.
그렇다면 B 컴포넌트의 경우도 똑같을까요? 두 번째 컴포넌트 B의 경우에는 다시 같은 값이 돌아왔을 때 계산이 다시 이루어지지 않았습니다. 이게 어떻게 가능한 것일까요? 컴파일러는 답을 알고 있습니다.

우선 컴파일 결과물을 비교해 봅시다. A 컴포넌트의 경우는 크기 4짜리 캐시를 가지고 있고, B 컴포넌트의 경우는 크기 12짜리 캐시를 가지고 있습니다. 조금 더 용량이 큽니다. 분기문도 확인해보겠습니다. 왼쪽 A 컴포넌트의 경우 A의 계산은 `value`가 이전값과 다를 때만 다시 계산됩니다. 즉 마치 `useMemo`를 걸어준 것 같은 느낌입니다. 부모에 의해 리렌더링된 상황이라면 같은 값이기에 계산되지 않았겠지만, 이번에는 매번 계산됩니다.
그렇다면 B는 어떨까요? B의 경우에는 앞서 추가한 분기문 안에 `value`에 대한 비교문이 들어가 있습니다. 이미 위의 분기문에서 딸기인지 판단해 주었으므로, 그 스코프 안쪽의 `value`들은 모두 딸기입니다.
그렇다면 딸기라는 값과 딸기라는 결과물을 각각 0번, 1번 캐시에 저장하게 됩니다. 리렌더링된 상황에서도 0번 캐시의 값은 계속 딸기이기 때문에 이전에 계산된 캐시의 값을 가져오게 됩니다.

조금 더 내려가 볼까요? 당근의 경우도 똑같습니다. 당근 또한 당근이라는 값과 당근의 결괏값을 각각 4번, 5번 캐시에 저장하게 되어 최초 이후의 리렌더링 시에는 다시 계산이 이루어지지 않게 됩니다. 마지막 분기도 비슷합니다. 이와 같은 상황을 리액티브한 값의 중첩이라고 설명해 보았습니다. 첫 번째 컴포넌트의 경우는 `value`에 어떤 값이 올지 모르는 상황입니다. 컴파일러 입장에서도 확정 지을 수 없습니다. 그렇기에 첫 번째 경우는 하나의 캐시 슬롯에 모든 경우가 중첩되어 있는 상황이라고 볼 수 있습니다.
반면, 두 번째 컴포넌트의 경우 각 분기마다 하나의 값만 관측됩니다. 컴파일러 입장에서는 이 경우 명확하게 각 분기마다 캐시를 따로 둘 수 있습니다. 그렇기에 캐시의 크기는 스코프마다 증가하게 되지만 계산에 있어서는 메모이제이션을 하게 되어 연산을 줄여주었습니다.
흥미롭지 않나요? 이런 변화하는 값들이 어떻게 관측되고 흘러가는지에 따라 다른 결과를 보일 수 있다는 것을 알게 되었습니다.

그러면 우리는 이제 앞으로 어떤 것에 집중해야 할까요? 컴파일러를 통해서 수동 메모이제이션으로부터는 해방되었습니다. 앞으로는 앞서 살펴보았던 예시처럼 변화하는 값들이 어떻게 흘러가는지에 대해서 좀 더 집중하고 이해를 가지고 접근해 보면 좋을 것 같습니다. React 컴파일러는 현재도 활발하게 기능들이 개발되고 있어서, 앞으로도 모두 관심 있게 지켜봐 주시면 더욱 활발한 생태계를 만들어갈 수 있을 겁니다.
©️요즘IT의 모든 콘텐츠는 저작권법의 보호를 받는 바, 무단 전재와 복사, 배포 등을 금합니다.