React Compiler: 수동 useMemo와 useCallback에 작별을 고할까?

1. 개요

많은 React 프로젝트에는 useMemo, useCallback 그리고 React.memo가 코드베이스 전체에 흩어져 있습니다. 목표는 불필요한 re-render를 피하는 것이지만, 그 대가는 코드가 길어지고 dependency 배열을 실수하기 쉬워진다는 것입니다. React Compiler는 이러한 memoization을 빌드 시점으로 옮깁니다. 이 도구는 component를 분석하고 필요한 곳에 자동으로 캐시를 삽입합니다.

이 글에서는 React Compiler가 무엇인지, 그리고 왜 주목받고 있는지 설명합니다. Vite 프로젝트에서 compiler를 활성화하는 방법, 각 부분을 점진적으로 전환하는 방법, memoization 동작에 대한 테스트를 작성하는 방법, 그리고 사용을 피해야 할 경우를 배우게 될 것입니다.

2. 수동 memoization의 문제점

필터 입력 창과 무관한 카운터가 있는 주문 목록 페이지를 생각해 봅시다:

import { useState, useMemo, useCallback, memo } from "react";

type Order = { id: number; customer: string; total: number };

const OrderList = memo(function OrderList({
  orders,
  onSelect,
}: {
  orders: Order[];
  onSelect: (id: number) => void;
}) {
  return (
    <ul>
      {orders.map((o) => (
        <li key={o.id} onClick={() => onSelect(o.id)}>
          {o.customer}: {o.total}
        </li>
      ))}
    </ul>
  );
});

export function OrdersPage({ orders }: { orders: Order[] }) {
  const [keyword, setKeyword] = useState("");
  const [clicks, setClicks] = useState(0);

  const filtered = useMemo(
    () => orders.filter((o) => o.customer.includes(keyword)),
    [orders, keyword]
  );

  const handleSelect = useCallback((id: number) => {
    console.log("선택된 주문", id);
  }, []);

  return (
    <>
      <input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
      <button onClick={() => setClicks((c) => c + 1)}>클릭: {clicks}</button>
      <OrderList orders={filtered} onSelect={handleSelect} />
    </>
  );
}

코드는 올바르게 작동하지만 세 가지 취약점이 있습니다:

  • OrderList의 memo를 제거하면 useMemo와 useCallback은 거의 쓸모없어집니다.
  • dependency 배열에서 keyword를 빼먹으면 필터링된 목록이 이전 데이터를 표시합니다.
  • style={{ color: "red" }}처럼 새로운 객체 리터럴 prop을 추가하기만 해도 모든 memoization 노력이 무효화됩니다.

수동 memoization은 암묵적인 계약입니다. component를 수정하는 모든 사람이 이를 이해해야 하며, 버그는 보통 누군가 성능을 프로파일링할 때까지 드러나지 않습니다.

3. React Compiler란 무엇인가

React Compiler는 빌드 시점에 실행되는 도구로, 보통 Babel plugin 형태입니다. 이 도구는 component와 hook의 코드를 읽고 데이터 흐름을 이해한 후, 다음에 대한 캐시를 생성합니다:

  • 계산된 값: useMemo와 동등합니다.
  • 함수: useCallback과 동등합니다.
  • JSX element: child를 memo로 감싸는 것과 동등합니다. element의 입력이 변경되지 않으면 compiler는 이전 element를 재사용하고 React는 그 subtree의 re-render를 건너뜁니다.

다음은 compile 후 코드가 어떨 모습인지 보여주는 예시입니다. 이것은 단순화된 버전이며 실제 출력은 아닙니다:

function OrdersPage({ orders }) {
  const $ = useMemoCache(6); // component 인스턴스에 붙어있는 캐시 배열
  const [keyword, setKeyword] = useState("");

  let filtered;
  if ($[0] !== orders || $[1] !== keyword) {
    filtered = orders.filter((o) => o.customer.includes(keyword));
    $[0] = orders;
    $[1] = keyword;
    $[2] = filtered;
  } else {
    filtered = $[2];
  }
  // ... handleSelect와 JSX element도 유사하게 처리됨
}

Compiler는 각 입력값을 이전 render와 비교합니다. 입력이 변경될 때만 다시 계산합니다. 수동 memoization과 비교하면 두 가지 중요한 차이가 있습니다:

  1. 더 세밀합니다: compiler는 조건문 뒤에 있는 코드도 memoize할 수 있으며, useMemo는 조건부로 호출될 수 없기 때문에 이를 수행할 수 없습니다.
  2. 수동으로 작성한 dependency 배열이 없습니다: dependency는 코드에서 추론되므로 빠뜨릴 수 없습니다.

4. React Compiler가 주목받는 이유

이 주제가 많이 언급되는 이유는 여러 가지입니다:

  • 보일러플레이트 감소: 코드는 “순수 React” 형태로 돌아가며, 읽기 쉽고 검토하기 편합니다.
  • 기본적으로 더 나은 성능: 팀이 성능을 프로파일링하는 전문가가 없어도 불필요한 re-render를 피할 수 있습니다.
  • React 규칙 준수 강제: compiler는 “정확한” 코드만 최적화할 수 있습니다. 따라서 compiler가 규칙을 위반하는 코드를 감지하는 데 도움이 되는 lint 규칙을 함께 제공합니다. 아직 compiler를 활성화하지 않았을 때도 유용합니다.
  • 점진적 호환성: 디렉터리나 하나의 component에 대해서만 활성화할 수 있으며, 전체 애플리케이션을 다시 작성할 필요가 없습니다.

주의: Compiler는 모든 곳에서 코드를 더 빠르게 실행하지는 않습니다. 이 도구는 불필요한 render 작업을 줄입니다. 병목이 네트워크, 번들 크기 또는 입력이 변경될 때마다 실행되는 무거운 루프라면, compiler는 도움이 되지 않습니다.

5. 프로젝트에서 React Compiler 활성화

5.1. Vite로 설치하기

plugin과 lint를 설치합니다. package 이름은 시간이 지나면서 변할 수 있으므로, 사용 중인 React 버전에 대해 공식 문서를 확인하세요.

npm install -D babel-plugin-react-compiler eslint-plugin-react-hooks

Vite 설정에 plugin을 추가합니다:

// vite.config.js
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [
    react({
      babel: {
        plugins: [["babel-plugin-react-compiler", {}]],
      },
    }),
  ],
});

Compiler는 Babel plugin 체인에서 먼저 실행되어야 합니다. 다른 plugin이 코드를 변환하기 전에 원본 코드를 읽어야 하기 때문입니다.

더 오래된 React를 사용하는 프로젝트라면, compiler는 추가 runtime package와 호환 코드를 생성하기 위한 target 옵션이 필요합니다. 이 부분은 버전에 따라 다르므로 설정하기 전에 문서를 확인하세요.

Next.js나 Expo 같은 프레임워크에서는 보통 compiler를 활성화하기 위한 별도의 설정 플래그가 있습니다. 플래그 이름은 버전에 따라 변했으므로, 이전 설정을 복사하지 말고 프레임워크의 릴리스 노트를 읽으세요.

5.2. Compiler가 작동하는지 확인하기

React DevTools를 엽니다. Compiler에 의해 최적화된 component는 이름 옆에 “Memo ✨” 같은 레이블이 표시됩니다. 레이블이 없으면 compiler가 component를 건너뛰었다는 뜻이며, 보통 6절의 규칙을 위반했기 때문입니다.

5.3. Lint 활성화하기

// eslint.config.js
import reactHooks from "eslint-plugin-react-hooks";

export default [
  reactHooks.configs.recommended,
];

Lint는 compiler가 건너뛸 또는 잘못 실행될 수 있는 코드를 미리 알려줍니다. compiler 이전에 lint를 활성화하고 모든 경고를 수정해야 합니다.

6. React의 규칙: Compiler가 작동하는 조건

Compiler는 component가 render 중일 때 순수 함수라고 가정합니다. 다음 예시는 이를 위반합니다:

export function CartSummary({ items }: { items: { price: number }[] }) {
  items.sort((a, b) => b.price - a.price); // prop을 직접 수정하고 있습니다!
  const total = items.reduce((sum, i) => sum + i.price, 0);
  return <p>최상위 상품: {items[0]?.price}, 합계: {total}</p>;
}

items.sort는 전달된 배열을 직접 수정합니다. 캐시가 있으면 compiler는 items가 변경되었다는 것을 인식하지 못할 수 있습니다. 참조는 여전히 동일하기 때문입니다. UI가 잘못 표시됩니다. Lint는 다음과 같은 경고를 표시합니다:

error  함수의 반환 값 수정 금지  react-hooks/...

정확한 메시지는 plugin 버전에 따라 다릅니다. 해결책은 복사본을 만드는 것입니다:

const sorted = [...items].sort((a, b) => b.price - a.price);

자주 발생하는 규칙 위반:

  • prop, state 또는 hook이 반환한 값을 수정합니다.
  • render 중에 ref.current를 읽거나 씁니다.
  • 조건부로 hook을 호출하거나 루프 내에서 호출합니다.
  • component 본문에 직접 side effect를 넣습니다. 예를 들어, 모듈 변수에 쓰거나 API를 호출합니다.

이러한 코드를 만나면, compiler는 보통 component를 건너뜁니다. 이는 안전한 동작이지만, 최적화의 이점을 잃게 됩니다.

7. 대규모 프로젝트에서 점진적 전환

7.1. Opt-in 모드

큰 codebase의 경우, 표시된 component만 compile할 수 있습니다:

// vite.config.js (발췌)
plugins: [["babel-plugin-react-compiler", { compilationMode: "annotation" }]],
export function InvoiceTable({ invoices }: { invoices: Invoice[] }) {
  "use memo";
  // ... 이 component가 compile됩니다
}

함수 본문 시작 부분의 "use memo" directive는 compiler에게 이 component를 처리하라고 알립니다. 다른 component는 그대로 유지됩니다.

7.2. Component 제외하기

전체적으로 활성화했지만 하나의 component가 문제를 일으키면, 그 component에 대해서만 compiler를 비활성화할 수 있습니다:

export function LegacyChart(props: ChartProps) {
  "use no memo";
  // ... 라이브러리가 데이터를 수정하는 이전 코드
}

주의: "use no memo"는 임시 해결책입니다. 이유를 기록하고 수정할 작업을 만드세요. 기본값으로 두지 마세요.

7.3. 기존의 useMemo는 어떻게 하나요?

Compiler는 이미 useMemo/useCallback이 있는 코드와도 작동하므로 즉시 삭제할 필요가 없습니다. 대량으로 삭제하면 미묘한 동작이 변경될 수 있습니다. 특히 값이 useEffect의 dependency로 사용되는 경우입니다. 점진적으로 삭제하고 테스트를 작성하세요. 새 component의 경우, 순수 코드를 작성하고 compiler에 맡기세요.

8. Memoization 동작에 대한 테스트 작성

child가 무관한 state 변경 시 re-render되지 않는지 확인할 수 있습니다. 아래 예시는 Vitest와 React Testing Library를 사용하며, compiler가 활성화된 동일한 Vite 설정으로 실행됩니다:

npm install -D vitest @testing-library/react @testing-library/user-event jsdom
// OrdersPage.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { describe, it, expect, vi } from "vitest";

const renderSpy = vi.fn();

function OrderList({ orders }: { orders: string[] }) {
  renderSpy();
  return <ul>{orders.map((o) => <li key={o}>{o}</li>)}</ul>;
}

function OrdersPage() {
  const [clicks, setClicks] = useState(0);
  const orders = ["Alice", "Bob"]; // useMemo를 사용하지 않습니다
  return (
    <>
      <button onClick={() => setClicks((c) => c + 1)}>클릭: {clicks}</button>
      <OrderList orders={orders} />
    </>
  );
}

describe("React Compiler를 사용한 OrdersPage", () => {
  it("카운터만 변경되었을 때 OrderList를 re-render하지 않습니다", async () => {
    render(<OrdersPage />);
    await userEvent.click(screen.getByRole("button"));
    await userEvent.click(screen.getByRole("button"));

    expect(screen.getByRole("button")).toHaveTextContent("클릭: 2");
    expect(renderSpy).toHaveBeenCalledTimes(1);
  });
});

설명:

  • orders는 literal 배열이며 useMemo로 감싸지 않았습니다. Compiler 없으면 매 render마다 새로운 배열이 만들어지고 OrderList가 re-render됩니다.
  • Compiler가 있으면 orders 배열과 <OrderList /> element 모두 캐시되며, 둘 다 clicks에 의존하지 않기 때문입니다.
  • renderSpy는 처음 render에서만 한 번 호출됩니다.

예상 결과:

✓ React Compiler를 사용한 OrdersPage > 카운터만 변경되었을 때 OrderList를 re-render하지 않습니다
Test Files  1 passed (1)

Compiler를 비활성화하면 마지막 assertion이 값 3으로 실패합니다. toHaveTextContent matcher는 setup 파일에서 @testing-library/jest-dom이 필요합니다.

주의: component 본문에서 spy를 호출하는 것은 side effect입니다. 이 방식은 render 횟수를 측정하는 test에서만 허용됩니다. production 코드에는 적용하지 마세요.

9. 사용하면 안 되는 경우

  • React 규칙을 많이 위반하는 codebase: compiler는 대부분의 component를 건너뛰거나, 더 나쁘게는 숨겨져 있던 버그를 드러낼 것입니다. lint에 따라 먼저 수정하세요.
  • Mutation에 의존하는 라이브러리: 일부 state 또는 form 라이브러리는 객체를 제자리에서 변경하고 component가 매 render마다 새 값을 읽기를 기대합니다. 이런 component에 대해서는 "use no memo"가 필요할 수 있습니다.
  • 특수한 build pipeline: Babel을 사용하지 않거나 bundler가 plugin을 지원하지 않으면 통합이 어려워집니다. 통합하기 전에 bundler의 지원을 확인하세요.
  • 작고 상호작용이 적은 애플리케이션: 이점이 빌드 시간 증가와 debug 복잡성 증가보다 작을 수 있습니다.
  • Effect의 참조 안정성이 필요한 경우: compiler는 최적화를 위해 memoize하며 의미론을 보장하지 않습니다. 로직의 정확성이 값이 참조를 유지하는 데 달려 있다면(예: effect가 다시 실행되지 않도록), 의도를 명확히 하기 위해 명시적 useMemo/useCallback을 유지하세요.

또한 debug할 때, 브라우저에서 실행되는 코드는 당신이 작성한 코드와 다릅니다. Source map이 도움이 되지만, 필요할 때 compiler의 playground 도구와 DevTools에 익숙해져야 합니다.

10. 결론

React Compiler는 useMemo와 useCallback을 제거하지 않지만, 이들을 일상적인 도구에서 특수한 경우를 위한 해결책으로 변환합니다. 전제 조건은 코드가 React의 규칙을 준수해야 한다는 것입니다. 따라서 첫 번째 단계는 항상 lint를 활성화하고 규칙 위반을 수정하는 것입니다.

권장되는 경로: lint를 활성화하고, 경고를 수정하고, annotation 모드에서 compiler를 몇 가지 component에 대해 활성화한 후, render 횟수를 측정하는 테스트를 작성하고, 점진적으로 확대하세요. 이렇게 하면 수동으로 작성한 dependency 배열 없이 더 나은 성능을 얻을 수 있습니다.

reactreact-compilerperformancefrontendtech-trends