개발 블로깅/React 개념

React Compiler 파헤치기

Hello이뇽 2024. 7. 24. 02:20

 

우리는 리액트에서 렌더링 최적화를 위해, 변화되지 않는 부분들을 리렌더링 되지 않도록 useMemo, React.memo, useCallback Hook을 사용한다. 

그러나 이러한 함수들을 사용할 때, 정확하게 필요한 곳에 적용하여 완전히 렌더링 최적화를 시킬 수 있다면 아주 좋겠지만, 사실상 Deep하게 이러한 위치를 찾는 것과 리렌더링 최적화 분석을 하는 것이 굉장히 까다롭고 시간이 들어간다.

적절한 곳에 사용하지 못하면 해당 Hook에 대해 큰 이점을 챙길 수 없을 뿐더러 그렇다고 모든 곳에 메모제이션 처리를 해버리면, 그만큼 메모리에 저장되는 메모제이션 상태가 많아지기 때문에 성능에 악영향을 끼칠 수 있게 된다.

그만큼 React 내에서 메모제이션 기능을 통한 렌더링 최적화를 적용하는게 마냥 쉽지 않은 영역인데, 이를 쉽게 해결해줄 react Compiler라는 기능이 주목을 받고 있다.

 

 

 

React Compiler의 동작원리

compiler는 Javascript knowledge(아마 그냥 순수 자바스크립트를 얘기한듯?)와 React's rules를 이용하여, 컴포넌트와 Hooks 내 값들과 그룹을 메모제이션한다. 여기서 React's rules란, 간단하게 React에서 권장하는 코드를 작성하기 위한 Rule인데, 아래 Rules of React 섹션에서 자세히 다루고 있다.

만약 Rule이 맞지 않는 부분을 만나면, 해당하는 부분만 자동으로 스킵하고 계속하서 다른 부분을 컴파일을 한다.

Compiler는 크게 아래의 두 문제에 대해 해결하려고 하고 있다.

[자식 컴포넌트의 리렌더링 최적화]

일반적으로, React.memo를 감싸져 있지 않는 자식 컴포넌트는, 내려받는 Props의 변경사항이 없어도 부모 컴포넌트의 상태가 변하고 리렌더링 될 때마다 자식 컴포넌트까지 리렌더링 되는 이슈가 있었다. 이를 자동으로 최적화를 시켜준다.

# React 코드

function FriendList({ friends }) {
  const onlineCount = useFriendOnlineCount();
  if (friends.length === 0) {
    return <NoFriends />;
  }
  return (
    <div>
      <span>{onlineCount} online</span>
      {friends.map((friend) => (
        <FriendListCard key={friend.id} friend={friend} />
      ))}
      <MessageButton />
    </div>
  );
}

 

# Compiler를 통한 코드

function FriendList(t0) {
  const $ = _c(9);
  const { friends } = t0;
  const onlineCount = useFriendOnlineCount();
  if (friends.length === 0) {
    let t1;
    if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
      t1 = <NoFriends />;
      $[0] = t1;
    } else {
      t1 = $[0];
    }
    return t1;
  }
  let t1;
  if ($[1] !== onlineCount) {
    t1 = <span>{onlineCount} online</span>;
    $[1] = onlineCount;
    $[2] = t1;
  } else {
    t1 = $[2];
  }
  let t2;
  if ($[3] !== friends) {
    t2 = friends.map(_temp);
    $[3] = friends;
    $[4] = t2;
  } else {
    t2 = $[4];
  }
  let t3;
  if ($[5] === Symbol.for("react.memo_cache_sentinel")) {
    t3 = <MessageButton />;
    $[5] = t3;
  } else {
    t3 = $[5];
  }
  let t4;
  if ($[6] !== t1 || $[7] !== t2) {
    t4 = (
      <div>
        {t1}
        {t2}
        {t3}
      </div>
    );
    $[6] = t1;
    $[7] = t2;
    $[8] = t4;
  } else {
    t4 = $[8];
  }
  return t4;
}
function _temp(friend) {
  return <FriendListCard key={friend.id} friend={friend} />;
}

Reference PlayGround

 

[React 스코브 바깥의 무거운 함수 실행의 최적화]

특정 함수가 가지고 있는 무거운 실행이, 파라미터가 변하면 이에 대해 새롭게 계산이 되어야 하는게 맞지만, 파라미터가 변하지 않아도 계속해서 계산이 되는 문제가 있었다. 이전엔 이러한 부분을 useMemo()를 통해 최적화를 시켰으나 compiler에서는 자동으로 최적화를 시켜준다.

 

# React 코드

// **Not** memoized by React Compiler, since this is not a component or hook
function expensivelyProcessAReallyLargeArrayOfObjects() { /* ... */ }

// Memoized by React Compiler since this is a component
function TableContainer({ items }) {
  const data = expensivelyProcessAReallyLargeArrayOfObjects(items);

  return <div>{data}</div>
}

 

# Compiler를 통한 코드

function expensivelyProcessAReallyLargeArrayOfObjects() {}
function TableContainer(t0) {
  const $ = _c(4);
  const { items } = t0;
  let t1;
  if ($[0] !== items) {
    t1 = expensivelyProcessAReallyLargeArrayOfObjects(items);
    $[0] = items;
    $[1] = t1;
  } else {
    t1 = $[1];
  }
  const data = t1;
  let t2;
  if ($[2] !== data) {
    t2 = <div>{data}</div>;
    $[2] = data;
    $[3] = t2;
  } else {
    t2 = $[3];
  }
  return t2;
}​

PlayGround

그러나 만약 위처럼 expensivelyProcessAReallyLargeArrayOfObjects 함수가 실제로 엄청 무거운 함수라면, 리액 밖에서 자체 메모제이션을 하는 구현을 고려해볼 수 있다.

React Compiler는 실제로 Component와 Hooks에 대해서만 memoize를 하는데, 해당 함수가 각기 다른 컴포넌트에서 사용될때마다 심지어 완전히 동일한 Props가 전달된다고 해도 해당 함수는 각 컴포넌트에서 중복되게 동작하게 될 것이기 때문이다.

예를 들어 아래와 같은 경우이다.

 

function expensivelyProcessAReallyLargeArrayOfObjects() { /* ... */ }

function TableOne({ items }) {
  const data = expensivelyProcessAReallyLargeArrayOfObjects(items);

  return <div>{data}</div>
}

function TableTwo({ items }) {
  const data = expensivelyProcessAReallyLargeArrayOfObjects(items);

  return <div>{data}</div>
}

 

위 코드와 같은 상황이고 두 Table Component에서 전달받은 items Props가 같다고 했을 때, 만약 React Compiler가 Component와 Hooks를 포함한 모든 function들을 memoize를 할 수 있었다면 두 개의 Table 중 한군데 에서만 무거운 함수가 실행되고, 다른 컴포넌트에서는 이전에 메모이즈된 값을 그대로 사용할 수 있었을 것이다.

그러나 Compiler는 리액트 바깥의 함수는 메모이즈 대상으로 판단하지 않기 때문에, 각기 별도로 무거운 함수가 수행되게 된다.

그래서 필요하다면 해당 함수 자체에 메모제이션 처리를 하도록 하는게 효율적일 수 있다.

만약 해당 함수가 실질적으로 영향도가 큰 함수인지 확인하고 싶다면, profiling을 이용해 볼 수 있다고 나오는데, 막상 보면 아래 코드처럼 렌더링 시간 측정하는 방식이 나온다.

 

console.time('filter array');
const visibleTodos = filterTodos(todos, tab);
console.timeEnd('filter array');

 

 

react Compiler 코드 직접 까보기

 

그렇다면 실제로 메모이즈를 담고있는 _c는 어떻게 구현이 되어 있을까?
그 실체를 파악하기 위해 직접 코드를 까보았다.

 

// compiler/packages/react-compiler-runtimes/src/index.ts

type MemoCache = Array<number | typeof $empty>;

const $empty = Symbol.for('react.memo_cache_sentinel');
/**
 * DANGER: this hook is NEVER meant to be called directly!
 **/
export function c(size: number) {
  return React.useState(() => {
    const $ = new Array(size);
    for (let ii = 0; ii < size; ii++) {
      $[ii] = $empty;
    }
    // This symbol is added to tell the react devtools that this array is from
    // useMemoCache.
    // @ts-ignore
    $[$empty] = true;
    return $;
  })[0];
}

 

위 코드에 보다시피, useState로 생성한 변수에 불변성을 담고있지 않는 형태로 데이터를 저장한다.
생각보다 간단해서 당황했다.

이외에 같은 파일에 있는 다른 코드들도 궁금해서 따라가보았더니,
대부분 runtime에 사용되는 코드가 아니라, 컴파일 시 @babel/core의 옵션으로 이용되는 함수들이었다.

 

 

React Compiler가 컴파일 할 코드에 기대하고 있는 부분

1. 유효하고 시멘틱한 자바스크립트 코드
2. nullable하고 optional한 값, 그리고 접근하기 전에 선언이 되어 있음. (Typescript 사용 중이면, strictNullChecks로 확인 가능)
3. Rules of React에 따르는 코드.

Compiler는 Rules of React에 많이 의존된다. 만약 이에 맞지 않는 코드 영역은 과감히 스킵이 된다. 만약 Rules of React 기준에 벗어나는지 확인하려면 eslint-plugin-react-compiler를 사용해볼 수 있다.

 

지금까지 React Compiler가 어떤 부분을 도와주는 것인지에 대해 알아보았는데, 중간 중간에 소개되면서 Compiler가 Rules of React에 의존된다는 말이 많이 나왔을 것이다.

그러면 도대체 Rules of React 가 무엇일까?
아래에서 자세하게 한번 알아보자.

 

Rules of React

우선 간단하게 소개한다면, React 관점으로 권장하는 코드 표현 방식이라고 할 수 있다.

각 프로그래밍 언어마다 코드의 작성 방식이 많이 다른 만큼, 리액트에서도 역시 방대하고 넓은 작성 방식 중 좋은 형태의 코드 구성을 위한 권장 표현 방식이 있는데, Rules of React는 이에 대해서 가이드를 하고 있다.

따라서 해당 Rule를 잘 숙지하고 따를수록 잘 구성된 애플리케이션을 구축할 수 있지만, 만약 반대로 Rules에 위반이 될수록 버그율이 높아지고, 추론과 이해가 어려운 코드가 될 확률이 높다고 한다.

따라서 최대한 Rule을 따르되, 이를 위해서 React StrictMode와 Rule에 관련된 ESLint Plugin을 사용하는 것이 좋다.

Rule은 크게 세가지로 나뉜다.

- Components and Hooks must be pure
- React calls Components and Hooks
- Rules of Hooks

 

[Components And Hooks must be pure]

컴포넌트와 Hooks를 Pure하게 구현하는 것은 앱을 디버깅하기 쉽고 코드의 흐름이 예측하기 쉽게 해준다. Pure해야한다는 것을 조금 더 세분화 시킨다면 아래와 같다.


- 컴포넌트는 멱등성을 가져야 한다.
- 사이드 이펙트는 렌더링 밖에서 일어나야 한다.
- props와 state는 immutable 해야한다.
- Hooks의 Arguments와 Return 값은 immutable 해야한다.
- 값이 JSX로 전달되었을때는 Immutable 해야한다.

각 내용마다 엄청 Deep하게 가이드가 되어 있긴 하지만, 큰 틀에서는 "의도되지 않은 타이밍에 값이 변경되지 않도록 구성시킨다"이다.

예시로 다음 코드를 한번 보자.

function Clock() {
  const time = new Date(); // 🔴 Bad: always returns a different result!
  return <span>{time.toLocaleString()}</span>
}

 

위 코드는 렌더링 될 때마다 time 값이 변하게 되는데, 이러면 우리는 time 값을 예측할 수 없고 언제 값이 변하는 시도를 하는지도 알 수 없다.

이러한 문제를 해결하기 위해서는 아래처럼 변경할 수 있다.

 

import { useState, useEffect } from 'react';

function useTime() {
  // 1. Keep track of the current date's state. `useState` receives an initializer function as its
  //    initial state. It only runs once when the hook is called, so only the current date at the
  //    time the hook is called is set first.
  const [time, setTime] = useState(() => new Date());

  useEffect(() => {
    // 2. Update the current date every second using `setInterval`.
    const id = setInterval(() => {
      setTime(new Date()); // ✅ Good: non-idempotent code no longer runs in render
    }, 1000);
    // 3. Return a cleanup function so we don't leak the `setInterval` timer.
    return () => clearInterval(id);
  }, []);

  return time;
}

export default function Clock() {
  const time = useTime();
  return <span>{time.toLocaleString()}</span>;
}

 

이처럼 구현하면 렌더링 될때마다 변경되지 않고, 명확한 타이밍에 값이 변경이 됨으로써, 값을 예측하고 디버깅할 수 있다.

 

[React calls Components and hooks]

리액트는 유저에게 최적화된 방식으로 화면을 구성하는 것을 포커싱을 두고 있기 때문에, 컴포넌트와 Hooks의를 작성할 때 지켜야할 규칙을 명시하고 있다.

아래는 해당 Rule에 대해서 세 단계로 나누어 설명하는 부분이다.

Never call component functions directly

해당 규칙은 component 함수를 직접 코드 상에서 호출하지 말라는 뜻인데, 예시로 아래와 같다.

function BlogPost() {
  return <Layout><Article /></Layout>; // ✅ Good: Only use components in JSX
}

function BlogPost() {
  return <Layout>{Article()}</Layout>; // 🔴 Bad: Never call them directly
}

컴포넌트 함수가 렌더링 중에 함수가 호출되는 시점이 결정되어야 하는데, 이를 JSX를 이용해서 하기 때문에 함수를 직접적으로 호출하면 안된다.

또한 Hooks를 포함하는 컴포넌트라면, 컴포넌트를 직접적으로 호출하게 된다면 Rules of Hooks의 규칙을 위반하게 되므로 조심해야한다.

 

Never pass around Hooks as regular values

Hooks의 장점은 특정 기능에 대해 독립적으로 모듈화 해서 각 컴포넌트에 사용하도록 할 수 있다는 것이다.
이러한 Hooks는 컴포넌트에 특화된 기능이므로 Hooks의 Rules에 맞추어 사용해야하는 것을 가이드 한다.

Don't dynamically mutate a Hook

Hooks를 동적인 방식으로 정의하여 사용할 수 없다. 예시로는 아래와 같다.

function ChatInput() {
  const useDataWithLogging = withLogging(useData); // 🔴 Bad: don't write higher order Hooks
  const data = useDataWithLogging();
}

 

Don't dynamically use Hooks

또한 Hooks는 사용할 때도 동적인 방식으로 사용할 수 없다. 

 

function ChatInput() {
  return <Button useData={useDataWithLogging} /> // 🔴 Bad: don't pass Hooks as props
}

----------------------------------------

function ChatInput() {
  return <Button />
}

function Button() {
  const data = useDataWithLogging(); // ✅ Good: Use the Hook directly
}

function useDataWithLogging() {
  // If there's any conditional logic to change the Hook's behavior, it should be inlined into
  // the Hook
}

 

[Rules of Hooks]

Hooks는 컴포넌트 함수 내에서 사용할 수 있는 함수인데, 이 또한 별도의 Rule을 가지고 있으며 아래에 상세하게 다룬다.

 

Only call Hooks at the top level.

우선 Hooks는 use라는 prefix로 네이밍이 된다. Hooks는 Loop 함수 내에서 호출할 수 없고, 중첩 함수, try/catch/finally, if구문 내 등안에서 사용할 수 없으며, 무조건 React 함수 내부의 Top Level에서 호출이 되어야 한다. 

function Counter() {
  // ✅ Good: top-level in a function component
  const [count, setCount] = useState(0);
  // ...
}

function useWindowWidth() {
  // ✅ Good: top-level in a custom Hook
  const [width, setWidth] = useState(window.innerWidth);
  // ...
}

 

Only call Hooks from React functions.

요건 당연한 얘기지만, Hooks는 무조건 React 함수 내에서만 호출해야한다.

 

지금까지 Rules of React에 대해 알아보았다.
해당 Rule은 좋은 코드 구성이 될 수 있도록 가이드 되는 표현 형식이기도 하지만, React Compiler의 최적화 대상으로 잘 타겟팅 되어 최적화된 렌더링으로 동작하는 코드로 컴파일이 되기 위해 따를 필요가 있다.

 

React Compiler 직접 사용해보기

우선 React Compiler를 설치하기 전에, 우리의 코드베이스가 Compiler에 적합한지 체크해볼 수 있다.

npx react-compiler-healthcheck@latest

 

위를 실행하면 
- 컴포넌트들이 얼마나 성공적으로 최적화가 될지, 얼마나 더 나아지는지 확인.
- StrictMode 사용성을 체크함. 이것을 활성화하고 따르는 것이 Rules of React를 따르는 부분에 더 최적화 시킬 수 있음.
- Compiler와 적합하지 않는 라이브러리 사용성을 체크함. 

을 확인해볼 수 있다.

 

위 프로젝트에서는 총 176개의 컴포넌트 중 168개각 성공적으로 컴파일 했다고 나온다.

 

eslint-plugin-react-compiler

React compiler는 eslint plugin을 포함한다. 이를 통해 편집기에서 바로 컴파일러의 분석을 표시하여 확인할 수 있다. 참고로 이 플러그인은 compiler에 의존되지 않은 상태로 동작하면서, 프로젝트 내에 compiler를 사용하지 않아도 사용할 수 있다.
React팀에서는 이 린트 플러그인이 코드베이스의 퀄리티 향상 개선에 도움을 줄 수 있기 때문에 사용하는 것을 권장하고 있다.

$ yarn add -D eslint-plugin-react-compiler

 

// eslint config
module.exports = {
  plugins: [
    'eslint-plugin-react-compiler',
  ],
  rules: {
    'react-compiler/react-compiler': "error",
  },
}

해당 eslint Plugin은 Editor에서 Rules of React 규칙의 위반사항을 표시한다. 이 작업이 수행될 때, 컴파일러는 컴포넌트와 Hook의 최적화를 스킵한 것을 의미한다. 그리고 컴파일러는 다른 컴포넌트들을 또 서치하고 최적화를 시도한다.

꼭 Compiler에 의해 최적화가 되어야한다는 것이 아니라면, 너무 억지로 해당 Lint Rule에 맞추어서 수정하려는 노력을 하지 않아도 된다. 단지, 최대한 Lint Rule에 적합하게 되어 있을수록 코드베이스의 Health Check가 좋다는 걸 증명할 수 있다.

 

`babel-plugin-react-compiler` 를 이용해서 빌드 파이프라인 때 compiler를 실행시킬 수 있는 babel plugin을 제공한다.
프로젝트 install 후에, babel config 세팅을 할 때, 무조건 제일 첫번째로 해당 플러그인이 실행되도록 해야한다.

const ReactCompilerConfig = { /* ... */ };

module.exports = function () {
  return {
    plugins: [
      ['babel-plugin-react-compiler', ReactCompilerConfig], // must run first!
      // ...
    ],
  };
};

 

[주의 사항]

Javascript의 유연한 특성 때문에, compiler가 모든 위반 사항에 대해서 캐치를 못할 수도 있고 이로인해 정의되지 않는 동작을 하는 React 규칙을 위반하는 컴포넌트나 Hooks를 실수로 컴파일 할 수도 있다.

이러한 이유로 만약 기존에 있던 프로젝트에 Compiler를 사용하고 싶다면, 작은 단위의 디렉토리부터 적용을 하는 것을 추천한다.

const ReactCompilerConfig = {
  sources: (filename) => {
    return filename.indexOf('src/path/to/dir') !== -1;
  },
};

 

특수한 케이스로, "compilationMode: annotation" 기능을 이용해서 opt-in 기능을 사용할 수 있는데, 이는 "use memo" 어노테이션이 붙은 Component와 Hooks에만 Compiler가 동작하도록 할 수 있다. 

const ReactCompilerConfig = {
  compilationMode: "annotation",
};

// src/app.jsx
export default function App() {
  "use memo";
  // ...
}

 

 

 

 

[현재 세팅할 수 있는 프레임워크]

Vite, Next.js, Remix, Webpack, Expo, Rsbuild, Rspack

 

Should I try out the compiler?

React Compiler 기능은 아직 experimental 단계이므로, 우와좌왕하는 부분이 많다. 프로덕션 앱에 해당 기능을 적용하는 것 자체가 코드베이스가 Rules of React에 잘 따르는지 체크가 가능할 수 있음. 굳이 해당 기능을 빠르게 적용하려고 할 필요가 없다. 천천히 stable Release될 때까지 기다렸다가 적용하는 것을 추천한다. 

그러나 해당 기능이 관심이 많아서 미리 써본다면 별도의 Working Group이 있어서, 여기에 합류해서 추가 정보를 얻어보는 것을 추천한다.

반응형