React

리액트 컴파일러 (React Compiler) 알아보기

D cron 2024. 4. 14. 16:28

이 문서는 React Compiler가 무엇인지 알아보기 위해 여러 자료들을 수집해서 정리해 놓은 것입니다.
React Compiler가 무엇인지 궁금하신 분들에게 도움이 되었으면 합니다.

React Compiler 소식

많은 사람들이 React Compiler는 React 19에 등장할 것이라고 예상했습니다. 하지만 리액트 팀의 Joe Savona는 React 19에서는 Compiler가 등장하지 않을 것이라고 합니다. Compiler가 리액트에 적용되는 시기는 2024년 말쯤으로 예상되고 있지만 더 늦어질 수도 있습니다.

 

 

Compiler의 등장 시기보다 더 중요한 것은 '리액트팀이 왜 Compiler를 적용시키려고 하는가?' 입니다. 지금부터 차근차근 알아가 보겠습니다.

Compiler가 왜 등장했을까?

리액트의 역사를 크게 3가지 시대로 나눌 수 있습니다.

 

1. 클래스 컴포넌트의 시대

2. 훅의 시대 

3. 컴파일의 시대

 

 

클래스 컴포넌트의 문제점과 hook의 등장

클래스 컴포넌트는 코드를 재사용할 수 있는 원시성(primitive)이 없었습니다.

리액트에서는 다음과 같이 재사용 가능한 JSX를 만들기가 쉽습니다.

 

 

하지만 클래스 컴포넌트에서 state와 lifecycle 메서드를 어떻게 재사용해야 할지는 불명확했습니다. 왜냐하면 클래스 컴포넌트의 내부의 state, lifecycle은 클래스 내부에 존재하기 때문에 다른 클래스와 공유할 수 없기 때문입니다. 이를 극복하기 위해 HoC, Render props와 같은 디자인 패턴들이 탄생했습니다. 그러나 이상적인 해결방법이 아니었습니다. 클래스 자체가 필요한 수준의 합성을 제공하지 못했습니다.

 

그래서 리액트팀은 원시성을 제공해 줄 수 있는 함수형 컴포넌트에 관심을 가졌고, 함수형 컴포넌트와 리액트 lifecycle를 연결(hook)할 수 있는 hook을 탄생시켰습니다. 이에 따라 컴포넌트 로직의 재사용성이 증가했고 코드가 간결해졌습니다.

 

하지만 이 결정은 부작용을 낳게 되는데...

 

모든 코드를 하나의 함수로 통합하면 합성은 쉬워지지만 모든 코드를 메모해야 한다는 단점이 생깁니다(그 당시에는 이 부작용을 알지 못했다고 합니다). 

 

리렌더링의 특성을 고려할 때, 클래스 컴포넌트는 사실 메모이제이션 작업으로부터 우리를 보호하고 있었습니다!

  • 클래스 컴포넌트의 render 메서드는 다른 lifecycle 메서드와 독립적으로 동작합니다. 즉, render 메서드는 리렌더링시에 호출되지만 이 과정이 다른 lifecycle 메서드의 실행에 직접적인 영향을 주지 않습니다.

 

  • 또한 클래스 컴포넌트에서의 메서드들은 클래스의 인스턴스에 바인딩되어 있습니다. 즉, 해당 컴포넌트의 인스턴스가 생성될 때 메서드들이 정의되고, 인스턴스가 메모리에 존재하는 동안 계속해서 재사용됩니다. render 메서드나 다른 lifecycle 메서드들은 리렌더링 사이클에서 새로 생성되지 않습니다.
  • 클래스 컴포넌트는 사실 메모이제이션을 사용하지 않아도 효율적인 리렌더링과 성능 최적화를 자연스럽게 달성할 수 있는 구조였던 것입니다. (물론 그래도 사용하기 싫습니다)

리액트 memo

함수형 컴포넌트에서는 왜 메모가 필요할까요?

 

  • 이 onSubmit 함수는 리렌더링이 있을 때마다 다시 생성될 것입니다. 왜냐하면 리액트에서 함수형 컴포넌트는 실제로 함수이기 때문입니다. 이 App 컴포넌트 함수는 props나 state가 변경될 때마다 호출됩니다. 함수가 호출되면 그 내부에 정의된 모든 로직과 함수들이 새로 실행됩니다.
  • 따라서 이 예제에서 state가 변경되어 리렌더링이 일어나면 메모리에 onSubmit 함수가 있는데도 별개의 새로운 onSubmit 함수가 생성됩니다.
  • 일반적으로 성능상 큰 문제는 되지 않습니다. 하지만 클래스 컴포넌트에서는 이런 일이 일어나지 않습니다.

 

리팩트링: Form을 새 컴포넌트로 분리

 

  • 이 경우 App이 리렌더링 되면 Form도 리렌더링 됩니다.
  • 왜냐하면 부모 컴포넌트가 리렌더링 되면 자식 컴포넌트는 (props 변경과 관계없이) 리렌더링 되기 때문입니다.

 

자식 컴포넌트의 리렌더링을 막기 위해 Form을 메모

 

  • 리액트는 변수가 변경되었는지 확인하기 위해 ===와 Object.is()등의 엄격 동등성 검사 방법에 크게 의존합니다.
  • 자바스크립트에서 원시값을 ===로 비교하면 값으로 비교하지만, 배열, 객체, 함수를 비교할 때는 메모리 주소(아이덴티티)를 비교합니다.
  • 아까 알아봤던 것처럼 App이 리렌더링 될 때마다 onSubmit 함수의 메모리 주소는 매번 바뀝니다. 결국 항상 Form은 리렌더링 하게 되므로 React.memo가 쓸모 없어졌습니다!

 

useCallback으로 메모하기

 

이런 상황을 위해서 리액트에는 useCallback 훅이 존재합니다.

  • App이 리렌더링 될 때 onSubmit의 아이덴티티가 변경되지 않도록 useCallback을 이용해 고정했습니다.
  • React Training에서는 이것을 구현 출혈(implementation bleed)라고 부릅니다. 내부 구현사항이 사용자에게 불필요한 추가 작업이나 복잡성을 강요하기 때문이죠.

 

더 많은 코드 (settings 객체) 추가

 

  • settings객체는 App이 리렌더링 될 때마다 다시 생성되는 상황입니다.
  • 그런데 린터가 useCallback의 의존성 배열에 settings를 넣으라고 요청합니다.

 

 

린터의 충고대로 수정한 코드

 

이제 무슨 일이 일어날까요?

  • App이 리렌더링 됩니다.
  • 리액트는 ===로 이전 렌더링의 settings와 현재 settings를 비교합니다.
  • settings는 달라지지 않았지만 새롭게 메모리에 생성되어 주소값이 달라져서 settings가 달라진 것으로 간주됩니다.
  • onSubmit가 변경되어서 Form이 리렌더링 됩니다.

 

결과적으로 Form의 메모이제이션은 쓸모가 없어졌습니다. 그렇다고 린터를 무시하면 버그가 발생할 가능성이 높아지죠.

React Compiler의 등장: React Forget

앞선 기나긴 여정을 통해 함수형 컴포넌트에서 상태가 변경되면 리렌더링이 너무 많이 발생할 수 있고, 이에 대한 해결책으로 useMemo, useCallback, memo가 등장했으나, 정확하게 사용하기가 쉽지 않다는 것을 배웠습니다.

 

또한 개발자가 수동으로 메모이제이션을 관리할 경우 코어 로직에 집중하지 못하고 최적화를 위한 메모 사용으로 코드가 읽기 어려워지고, 수정도 힘들어졌습니다. 한마디로 DX(Developer Experience)가 낮아졌습니다. run time전에 메모 작업을 할 수 있다면 얼마나 좋을까요?

 

그래서 등장한 것이 React Compiler입니다. 이 컴파일러의 이름은 React Forget인데, Forget의 의미는 모든 메모이제이션을 컴파일러가 관리해주어 리액트를 사용하는 사람들이 말 그대로 메모이제이션을 잊어버리도록(forget)하는 것이라고 합니다.

 

React Forget은 결과적으로 2개의 목표를 달성하기 위해 만들어졌습니다.

1. 수동 메모를 생각할 필요가 없도록 해서 개별환경을 좋게 만듭니다. 

2. 기본적으로 성능 최적화가 이루어집니다.

 

어떻게 컴파일이 이루어질까?

잠깐! JavaScript에서 정적 분석이 가능할까요? 정적 분석(static analysis)이란 코드를 실행하기 전에 분석하는 프로세스입니다.

JavaScript는 정말 다이나믹한 언어라서 처음에는 이게 가능할 것 같지 않았다고 합니다. 하지만 리액트를 컴파일 가능하도록 한 것은 리액트의 특성들에 있습니다. 

 

캡슐화(encapsulation)

  • 컴포넌트로 캡슐화가 이루어지기 때문에 컴포넌트 안에서 작업할 때는 다른 모든 영역을 신경 쓸 필요가 없습니다.
  • 따라서 컴파일러는 전체 앱을 통째로 컴파일할 필요 없습니다.
  • 그리고 컴파일러는 병렬적으로 여러 컴포넌트를 컴파일 할 수 있습니다.

 

불변성(immutability)

  • props는 읽기 전용이며 부모에서 자식으로 한 방향으로 흐릅니다.
  • 이 속성 덕분에 컴파일러는 자동으로 메모를 할 수 있게 되었습니다.

 

선언적(Declarative) UI

  • 선언적 방식 덕분에 컴파일러는 미리 많은 정보를 알 수 있습니다.

 

이 외에도 순수성, hooks등 많은 리액트의 특성들 덕분에 컴파일러를 만들 수 있었다고 합니다.

 

컴파일러로 본 리액트

 

  • 이렇게 컴파일러는 선언적 UI덕분에 여러 정보를 미리 알 수 있습니다. 그래서 derived value(파생된 값)을 다음 children에게 넘기기 전에 메모할 수 있습니다.
  • JSX에서 todos와 filter가 props로 정해져 있기 때문에 컴파일러는 todos, filter를 의존성 배열에 넣고 메모할 수 있습니다.

 

  • 컴파일이 정확히 이런 방식으로 진행되는 것은 아닙니다. 더 자세한 내용은 다음 챕터인 '컴파일 과정 맛보기'에서 설명드리겠습니다.

컴파일 과정 맛보기

기본적으로 React Forget은 SSA(Static Single Assignment form) 형태의 컴파일이라고 합니다. SSA는 컴파일 최적화의 한 형태로 각 변수가 선언된 이후에 단 한 번만 할당되도록 코드를 변환하는 기술입니다.

 

컴파일을 진행하면 어떤 모습으로 코드가 바뀌는지 살펴봅시다.

 

컴파일 전

 

 

컴파일 후

 

  • useMemoCache는 컴파일러가 내부적으로 사용하는 최적화 도구입니다 (실제 리액트 API 아님 주의)
  • 자세한 내용은 주석에 적어두었습니다.
  • 리렌더링 중에 변하지 않는 것들은 자동으로 캐싱해 준다고 생각하면 됩니다. 

컴파일 과정이 더 궁금한 분들은 여기(https://www.recompiled.dev/blog/ssa/)를 참고해 보세요.

 

Compiler를 적용한 결과

그럼 React Compiler를 쓰면 뭐가 바뀌는 걸까요?

그걸 알아보기 위해 Quest Store라는 곳과 협업하여 React Compiler를 시범 적용해 보았습니다. Quest Store는 React Native입니다.

 

리렌더링이 얼마나 줄었는지 확인해 보시죠.

 

React Compiler 적용 전

 

  • 리액트 프로파일러에서 오른쪽의 다이어그램은 React component tree이고, 각각의 사각형들은 컴포넌트를 의미합니다.
    • 초록 사각형들은 리렌더링시에 호출된 컴포넌트 → BAD
    • 회색 사각형들은 리렌더링시에 호출되지 않은 컴포넌트 → GOOD



React Compiler 적용 후

 

  • 이미 useMemo, useCallback, memo 등의 수동 작업을 통해서 리렌더링시 호출되지 않는 컴포넌트들이 많았음에도 불구하고 빨간색의 부분들이 추가로 + 자동으로 리렌더링시에 메모된 것을 확인할 수 있습니다.

성능 면에서도 tab change가 150% 향상되었고, page load가 4~12% 향상되었다고 합니다.

 

그런데 Compiler를 쓰는 게 항상 좋을까요?

 

세밀한 메모이제이션은 상호작용성에는 좋지만, 컴파일러가 너무 많이 메모이제이션을 하면 다른 앱 지표에 부정적일 수 있습니다.

웹에서는 메모이제이션된 코드는 번들 크기의 증가로 직접적으로 연결되며, 이는 시작 시간(startup time)을 증가시킬 수 있습니다.

  • React Native에서는 앱의 파일 시스템에 포함된 JavaScript 파일을 로드합니다. 이는 네트워크 연결에 의존하지 않기 때문에 시작 시간에 영향을 덜 미칩니다.

더 많은 데이터를 확인해 보기 위해 인스타그램에 적용해 보았습니다.

 

그 결과 상용 환경의 인스타그램 프로필 페이지에서 압축된 코드 크기의 1% 미만의 증가를 확인했습니다. 컴파일러는 2023년 11월 기준 인스타그램의 컴포넌트 중 95%를 이해했습니다. 그리고 이걸 더 증가시키기 위해 노력하고 있다고 합니다.

 

결론

'또 새로운 게 나와서 공부할게 많겠군...' 하면서 걱정했지만 개발자인 우리가 직접 컴파일을 할 필요가 없습니다. 컴파일러가 무엇이고 왜 나오게 되었는지를 이해하는 것이 중요합니다.

 

컴파일이 적용된 리액트가 나오면 useMemo, useCallback, memo 등을 걷어내기만 하면 됩니다.

 

수동 메모이제이션을 고려할 필요가 없어서 리액트로 개발하는 개발자 입장에서 유지보수가 더 편해질 것 같아 좋습니다.

 

빨리 컴파일된 버전이 나와서 적용해보고 싶습니다. 

 

참고자료

React without memo(처음으로 메모 없애자는 이야기가 나온 영상) [2021. 12. 9]

 

React Forget: React for developers and compilers [2023. 10. 15]

 

Understanding Idiomatic React [2023. 11. 2]

 

react 공식문서 [2024. 2. 15]

 

react training : react will be compiled [2024. 2. 16]

 

React 컴파일러 이해하기 [2024. 2. 22]

'React' 카테고리의 다른 글

ref 와 state 차이  (0) 2023.03.04