React 최적화의 미래: React Compiler는 우리를 어디로 이끌까?

reactoptimizationreact-compiler

Introduce

React 개발자라면 누구나 useMemo와 useCallback을 이용한 최적화와 씨름해 본 경험이 있을 것이다. 불필요한 리렌더링을 막기 위해 의존성 배열을 신중하게 관리하고, 참조 동일성을 유지하려 노력한다. 하지만 이 과정은 종종 코드의 복잡성을 높이고, 사소한 실수로 인해 버그를 만들기도 한다.

"최적화는 개발자가 직접, 그리고 신중하게 해야 한다"는 이 명제는 오랫동안 React 개발의 일부였다. 하지만 만약 이 번거로운 수동 최적화 과정을 대신해준다면 어떨까? React 팀은 바로 이 질문에 대한 답으로, React Compiler를 제시했다.

1. 수동 최적화의 한계

useMemo와 useCallback은 유용한 도구지만, 태생적인 한계를 가진다.

  • 실수의 가능성: 의존성 배열에 값을 하나라도 빠뜨리면, 최적화가 깨지거나 오래된 값을 사용하는 버그가 발생한다.
  • 인지 부하: 개발자는 항상 "이 함수는 어떤 값을 의존하고 있지?"를 생각해야 한다. 로직이 복잡해질수록 이는 상당한 리소스를 쓰게 된다.
  • 코드의 복잡성: 훅으로 감싸진 코드는 본래의 의도를 파악하기 어렵게 만들고, 코드의 양을 늘린다.

React 팀은 개발자가 기계가 더 잘할 수 있는 일을 수동으로 처리하는 것은 이상적이지 않다고 보았다. 그래서 그들은 "개발자가 최적화를 잊어도 되게 만들자"는 목표로 React Compiler(과거 프로젝트명: React Forget)를 개발하기 시작했다고 한다.

2. React Compiler는 무엇을 믿고 동작하는가?

React Compiler는 우리 모두가 알듯이 마법이 아니다. 그것은 개발자와 React 사이에 맺어진 '신뢰 계약' 위에서 동작한다. 이 계약의 내용이 바로 React의 규칙이다.

  1. 렌더링은 순수해야 한다 : 동일한 props와 state가 주어지면 항상 동일한 UI를 반환해야 하며, 렌더링 과정에서 부수 효과(side effect)를 일으켜서는 안된다.
  2. 상태는 불변적으로 다뤄야 한다 : setState를 통하지 않고 직접 상태를 변경해서는 안된다.

개발자가 이 간단하지만 중요한 규칙들을 지킬 것이라고 믿기 때문에, 컴파일러는 코드를 예측하고 분석할 수 있다. 우리가 약속을 지켰기에, 컴파일러는 안심하고 코드를 최적화할 수 있는 것이다.

3. React Compiler, 깊이 들여다보기: 원리와 한계

React Compiler는 단순히 useMemo와 useCallback을 추가하는 매크로가 아니다. React의 핵심 규칙을 이해하는 정교한 분석 도구라고 보는편이 타당하다. 컴파일러가 어떻게 코드를 최적화하고, 또 언제 최적화를 포기하는지 그 원리를 자세히 살펴보자.

어떻게 코드를 이해하는가? (정적 분석과 모델링)

컴파일러는 코드를 실행하지 않고, 코드 자체의 구조를 분석하는 정적 분석(Static Analysis)을 수행한다. 이 과정에서 컴파일러는 다음과 같은 두 가지 핵심 모델을 만든다.

  1. 의미론적 모델 (Semantic Model): 코드의 의미를 이해한다. "이 변수는 상태다", "이 함수는 상태를 변경한다", "이 값은 props에서 왔다" 와 같이 코드의 각 부분이 어떤 역할을 하는지 파악한다.
  2. 제어 흐름 그래프 (Control Flow Graph): 코드의 실행 순서를 이해한다. if 문이나 for 루프 등에 따라 코드가 어떤 경로로 실행될 수 있는지 모든 가능성을 지도로 그린다.

이 두 가지 모델을 통해 컴파일러는, 우리가 수동으로 하던 의존성 추적을 훨씬 더 정교하고 안전하게 수행한다. 어떤 값이 변경될 때 어떤 코드가 영향을 받는지 정확히 파악하여, 꼭 필요한 부분만 다시 실행되도록 코드를 재구성하는 것이다. 이 재구성의 결과가 바로 '자동 메모이제이션'이다.

무엇을 최적화하는가? (메모이제이션과 그 이상)

컴파일러의 주된 역할은 메모이제이션(Memoization)입니다.

  • 값의 재사용: 렌더링 사이에 변경되지 않는 객체나 배열, 계산 결과를 발견하면 자동으로 useMemo와 동일한 최적화를 적용한다.
  • 함수의 재사용: 렌더링 사이에 재생성될 필요가 없는 함수를 발견하면 자동으로 useCallback과 동일한 최적화를 적용한다.

하지만 컴파일러의 능력은 여기서 그치지 않는다. JSX 자체도 분석하여, 내용이 변하지 않는 UI 조각(e.g. <Header />, <div>...</div>)을 찾아내 해당 부분의 리렌더링을 건너뛰도록 만들 수도 있다. 이는 단순한 훅의 적용을 넘어, 더 넓은 범위의 렌더링 최적화를 가능하게 한다.

언제 최적화를 포기하는가? (Bailout의 구체적 사례)

컴파일러는 만능이 아니다. 코드의 의미나 흐름을 확신할 수 없을 때, 위험을 감수하는 대신 안전하게 최적화를 포기(Bailout)한다. 컴파일러를 혼란스럽게 만드는 대표적인 코드는 다음과 같다.

  1. React 규칙 위반:
// 조건문 안에서 훅을 호출
if (user) {
  // 훅의 호출 순서가 매번 달라질 수 있어 예측이 불가능하다.
  const [bookmarks, setBookmarks] = useState([]);
}
  1. 불변성 위반:
// 렌더링 중 props나 state를 직접 수정
function MessyComponent({ items }) {
  // 컴파일러는 'items'가 정말 변하지 않았는지 확신할 수 없게 된다.
  items.sort(); // 원본 배열을 직접 변경한다.
  return <ul>...</ul>
}
  1. 지나치게 동적인 코드:
// key가 동적으로 변하는 객체에서 값을 읽는 경우
const config = { a: 1, b: 2 };
const key = someDynamicValue; // 'a'일수도 'b'일수도 있음
const value = config[key]; // 컴파일러가 'value'의 출처를 추적하기 어려워짐

이처럼 컴파일러가 코드를 이해할 수 없을 때 최적화는 적용되지 않는다. 결국 컴파일러의 혜택을 온전히 누리기 위해서는, 우리가 여전히 예측 가능하고 규칙을 잘 따르는 코드를 작성해야 한다는 결론에 이르게 된다.

4. 최적화 습관 돌아보기

React Compiler의 등장은 단순히 새로운 기술의 도입을 넘어, 우리에게 현재의 코드 작성 습관을 되돌아보게 만드는 중요한 계기가 된다. 우리는 스스로에게 다음과 같은 질문을 던져볼 수 있다.

"우리는 과연 '필요해서' useMemo와 useCallback을 사용하고 있을까, 아니면 '불안해서' 혹은 '습관적으로' 사용하고 있을까?"

컴파일러가 최적화를 포기하는 'Bailout' 사례들은 결국 'React의 규칙'을 잘 지키는 것의 중요성을 다시 한번 일깨운다. 조건부 훅 호출을 피하고, 데이터의 불변성을 지키는 것은 컴파일러를 위해서가 아니라, 원래부터 더 예측 가능하고 견고한 React 코드를 작성하기 위한 기본 원칙이다.

결국 컴파일러의 존재는 우리에게 훅의 의존성 배열을 외우게 하는 대신, 더 좋은 코드의 본질이 무엇인지 고민하게 만든다. '기계 친화적인 코드'를 작성하려는 노력이 역설적으로 '더 인간적인, 이해하기 쉬운 코드'로 이어지는 것이다.

5. 더 중요한 것에 집중하는 것

React Compiler를 시작으로 React가 바라보는 미래는 개발자가 최적화라는 반복적이고 사소한 고민에서 해방되는 것일 수 있다. 우리는 그 에너지를 아껴 더 나은 사용자 경험, 더 견고한 애플리케이션 아키텍처, 더 창의적인 문제 해결에 쏟아부을 수 있게 될 것이다.

결국 기술의 발전은, 우리가 더 중요한 본질에 집중할 수 있도록 돕는 방향으로 나아가고 있다고 생각한다. React Compiler는 그 흐름을 보여주는 증거 중 하나가 아닐까?

📚 참고 링크