React Compiler가 바꾼 세상: useMemo와 useCallback 없애도 되는 시대

프론트엔드

ReactReact Compiler성능 최적화메모이제이션

이 글은 누구를 위한 것인가

  • useMemouseCallback을 언제 써야 할지 아직도 헷갈리는 React 개발자
  • React Compiler라는 말을 들었지만 실제로 어떻게 동작하는지 감이 안 잡히는 분
  • 기존 프로젝트에 React Compiler를 도입하고 싶은데 어디서부터 시작해야 할지 모르는 실무 개발자
  • 메모이제이션을 과하게 혹은 부족하게 써서 성능 이슈를 겪어본 분

들어가며

React를 좀 써본 사람이라면 한 번쯤 이런 코드 앞에서 멈칫한 경험이 있을 것이다.

const MyComponent = ({ items, onSelect }) => {
  const filteredItems = useMemo(
    () => items.filter(item => item.active),
    [items]
  );

  const handleClick = useCallback(
    (id) => onSelect(id),
    [onSelect]
  );

  return <List items={filteredItems} onItemClick={handleClick} />;
};

"이거 useMemo 빼면 성능 떨어지나? 넣으면 확실히 좋아지나? 아니면 오히려 무거워지나?"

Meta의 React 팀이 내부 코드베이스를 분석했더니 실제로 개발자들이 useMemouseCallback잘못 사용하는 비율이 꽤 높았다. 너무 많이 쓰거나, 의존성 배열을 잘못 작성하거나, 아예 필요 없는 곳에 쓰거나. 그 결과 메모이제이션 자체의 오버헤드가 오히려 성능을 깎아먹는 경우도 생겼다.

그래서 Meta는 2021년부터 "React Forget"이라는 이름으로 컴파일러 프로젝트를 진행했다. 2024년 React Conf에서 공식 발표된 이후 현재는 React Compiler라는 이름으로 안정화됐고, React 19와 함께 본격적인 프로덕션 사용이 시작됐다.

2025년 기준 Meta 내부 Instagram 웹 프로덕션에 먼저 적용한 결과, 불필요한 리렌더링이 평균 30~40% 감소하고 개발자들이 직접 작성하던 메모이제이션 코드의 상당 부분이 제거됐다. 수동으로 관리하던 복잡성이 컴파일러로 이전된 것이다.

이 글에서는 React Compiler가 실제로 어떻게 동작하는지, 어떤 코드가 자동으로 최적화되고 어떤 건 직접 손봐야 하는지, 그리고 기존 프로젝트에 어떻게 점진적으로 도입하는지 실무 관점에서 정리한다.


1. React Compiler란 무엇인가

1.1 컴파일러 이전의 세상: 개발자가 직접 최적화를 책임지던 시대

React의 렌더링 모델은 단순하다. 상태나 props가 바뀌면 컴포넌트가 다시 실행된다. 문제는 자식 컴포넌트도 함께 리렌더링된다는 점이다.

// ParentComponent가 리렌더링되면 ChildComponent도 항상 재실행된다
const ParentComponent = () => {
  const [count, setCount] = useState(0);
  const [name, setName] = useState("철수");

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>카운트: {count}</button>
      {/* count가 바뀔 때마다 name이 안 바뀌어도 ChildComponent 재실행 */}
      <ChildComponent name={name} />
    </div>
  );
};

이걸 막으려면 React.memo, useMemo, useCallback을 써야 했다. 그런데 이게 쉬운 일이 아니다.

수동 메모이제이션의 문제점:

문제설명
의존성 배열 관리빠트리면 stale closure, 과하게 넣으면 의미 없는 메모이제이션
과도한 적용단순한 계산에 useMemo 쓰면 오버헤드가 더 큼
팀원 간 일관성누구는 쓰고 누구는 안 써서 코드 스타일이 뒤섞임
유지보수 부담로직 변경 시 의존성 배열도 함께 업데이트해야 함
잘못된 최적화useCallback으로 감쌌는데 내부에서 쓰는 함수가 매번 새 참조면 의미 없음

1.2 React Compiler의 핵심 아이디어

React Compiler는 빌드 타임에 코드를 정적 분석해서 어떤 값이 언제 바뀌는지 파악하고, 자동으로 메모이제이션 코드를 삽입한다. 개발자가 직접 최적화 규칙을 따를 필요가 없어지는 것이다.

컴파일 전:

function Greeting({ name }) {
  return <h1>안녕, {name}!</h1>;
}

컴파일 후 (내부적으로 변환되는 형태, 실제 코드가 이렇게 바뀌는 건 아님):

function Greeting({ name }) {
  // 컴파일러가 name의 변경 여부를 추적해서 결과를 캐싱
  return _cache(name, () => <h1>안녕, {name}!</h1>);
}

중요한 건 이 변환이 항상 안전하게 이루어져야 한다는 점이다. 컴파일러는 React의 규칙(Rules of React) 을 지키는 코드에 대해서만 최적화를 적용한다. 규칙을 어기는 코드는 최적화를 건너뛴다.


2. 내부 동작 방식: 컴파일러는 어떻게 코드를 분석하는가

2.1 정적 분석의 원리

React Compiler는 Babel 플러그인 형태로 동작한다. 빌드 과정에서 AST(Abstract Syntax Tree)를 분석해서 각 변수와 표현식의 불변성(immutability) 을 추론한다.

구체적으로는 다음을 분석한다:

  1. 값의 출처: props에서 왔는가? 상태에서 왔는가? 상수인가?
  2. 변경 가능성: 해당 값이 렌더링 사이에 변할 수 있는가?
  3. 참조 안정성: 같은 렌더링에서 같은 참조를 반환하는가?
function ProductCard({ product, onAddToCart }) {
  // product.price는 product props에 의존 → product 바뀌면 재계산
  const discountedPrice = product.price * 0.9;

  // formatCurrency는 순수 함수 → 결과가 항상 동일하면 캐싱 가능
  const formatted = formatCurrency(discountedPrice);

  // onAddToCart는 props → 참조 안정성을 컴파일러가 추적
  const handleClick = () => onAddToCart(product.id);

  return (
    <div>
      <span>{formatted}</span>
      <button onClick={handleClick}>장바구니 담기</button>
    </div>
  );
}

컴파일러는 이 코드를 보고 "product가 바뀌지 않으면 discountedPrice, formatted, handleClick 모두 재계산할 필요가 없다"고 추론하고 자동으로 캐싱 로직을 삽입한다.

2.2 컴파일러가 생성하는 코드 엿보기

실제 컴파일된 결과를 React Compiler Playground에서 확인할 수 있다. 내부적으로는 _c 배열을 사용한 캐싱 구조가 생성된다.

// 원본 코드
function Counter({ initialCount }) {
  const [count, setCount] = useState(initialCount);
  const doubled = count * 2;
  return <div>{doubled}</div>;
}

// 컴파일러가 변환한 코드 (단순화된 예시)
function Counter({ initialCount }) {
  const $ = _cache(2);
  const [count, setCount] = useState(initialCount);
  let doubled;
  if ($[0] !== count) {
    doubled = count * 2;
    $[0] = count;
    $[1] = doubled;
  } else {
    doubled = $[1];
  }
  return <div>{doubled}</div>;
}

useMemo(() => count * 2, [count])와 본질적으로 같지만, 개발자가 직접 작성하지 않아도 된다.


3. 어떤 코드가 자동 최적화되는가

3.1 자동 최적화 가능한 케이스

컴파일러가 잘 처리하는 패턴들이다.

순수한 계산 (Pure Computation)

// 항상 자동 최적화됨
function PriceDisplay({ price, taxRate }) {
  const totalPrice = price * (1 + taxRate);
  const formatted = `${totalPrice.toLocaleString('ko-KR')}원`;
  return <span>{formatted}</span>;
}

이벤트 핸들러

// onDelete가 안정적이면 handleDelete도 자동으로 안정적 참조 유지
function TodoItem({ todo, onDelete }) {
  const handleDelete = () => onDelete(todo.id);
  return <button onClick={handleDelete}>삭제</button>;
}

조건부 렌더링

// isLoading이나 data가 바뀌지 않으면 전체 JSX 캐싱
function DataView({ isLoading, data }) {
  if (isLoading) return <Spinner />;
  return <Table data={data} />;
}

React.memo 대체

// 부모가 리렌더링되어도 props가 같으면 자식은 재실행되지 않음
// React.memo 없이도 동일한 효과
function ExpensiveChild({ items }) {
  return <ul>{items.map(item => <li key={item.id}>{item.name}</li>)}</ul>;
}

3.2 컴파일러가 최적화하지 못하는 케이스

컴파일러는 React의 규칙을 엄격하게 따른다. 규칙을 어기는 코드는 최적화를 건너뛴다.

useEffect 내부의 복잡한 로직

// 컴파일러가 useEffect 내부를 최적화하는 데 한계가 있음
useEffect(() => {
  // 외부 라이브러리와 상호작용하는 코드
  const chart = new ChartLibrary(ref.current);
  chart.render(data);
  return () => chart.destroy();
}, [data]);

외부 변수(모듈 스코프) 참조

// 모듈 스코프의 변경 가능한 변수 참조 → 컴파일러가 변경 추적 불가
let globalCounter = 0;

function BadComponent() {
  globalCounter++; // 이런 코드가 있으면 최적화 건너뜀
  return <div>{globalCounter}</div>;
}

Rules of React 위반 코드

// 조건문 안의 훅 → React 규칙 위반 → 컴파일러 최적화 대상에서 제외
function BrokenComponent({ condition }) {
  if (condition) {
    const [state, setState] = useState(0); // 오류! 최적화 안 됨
  }
  return <div />;
}

외부 변경 가능한 객체

// 외부에서 가져온 변경 가능한 객체
function ComponentWithMutableRef({ ref }) {
  // ref.current는 언제든 바뀔 수 있어서 컴파일러가 추적 불가
  const value = ref.current.someValue;
  return <div>{value}</div>;
}

정리하면:

상황자동 최적화 여부
순수한 계산가능
props 기반 이벤트 핸들러가능
조건부 JSX 렌더링가능
리스트 렌더링가능
useEffect 내부 로직제한적
외부 변경 가능한 변수 참조불가
React 규칙 위반 코드불가 (건너뜀)
동적 키를 사용하는 훅불가

4. eslint-plugin-react-compiler로 미리 진단하기

React Compiler를 도입하기 전에 기존 코드가 얼마나 컴파일러 친화적인지 확인하는 것이 좋다. eslint-plugin-react-compiler가 이 역할을 한다.

4.1 설치

npm install -D eslint-plugin-react-compiler

4.2 ESLint 설정

// eslint.config.js (flat config)
import reactCompiler from 'eslint-plugin-react-compiler';

export default [
  {
    plugins: {
      'react-compiler': reactCompiler,
    },
    rules: {
      'react-compiler/react-compiler': 'error',
    },
  },
];

4.3 주요 경고 유형

// ❌ react-compiler/react-compiler: Mutating a value returned from a hook
function BadList() {
  const items = useItems();
  items.push({ id: 99 }); // 경고! 훅에서 반환된 값 변경 금지
  return <ul>{items.map(...)}</ul>;
}

// ✅ 올바른 방법: 새 배열로 교체
function GoodList() {
  const items = useItems();
  const allItems = [...items, { id: 99 }];
  return <ul>{allItems.map(...)}</ul>;
}
// ❌ 조건부 훅 호출
function ConditionalHook({ enabled }) {
  if (enabled) {
    const data = useSomeData(); // 경고!
  }
}

// ✅ 훅은 항상 최상위에서
function ConditionalHook({ enabled }) {
  const data = useSomeData();
  if (!enabled) return null;
  return <div>{data}</div>;
}

ESLint 플러그인을 CI에 통합하면 컴파일러 도입 전에 문제 코드를 미리 정리할 수 있다.


5. 프로젝트에 React Compiler 도입하기

5.1 설치

npm install -D babel-plugin-react-compiler
# 또는 React 19의 경우 내장되어 있음

5.2 Babel 설정

// babel.config.js
module.exports = {
  plugins: [
    ['babel-plugin-react-compiler', {
      // 도입 초기에는 compilationMode: 'annotation'으로 시작
      compilationMode: 'infer', // 기본값: 자동으로 최적화 여부 판단
    }],
  ],
};

5.3 Next.js에서 설정

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    reactCompiler: true,
  },
};

module.exports = nextConfig;

5.4 점진적 도입 전략

전체 프로젝트에 한 번에 적용하면 예상치 못한 동작이 생길 수 있다. 3단계 전략을 추천한다.

1단계: 분석 (ESLint만 적용)

# 먼저 경고만 확인, 빌드는 그대로
npx eslint --rule 'react-compiler/react-compiler: warn' src/

2단계: opt-in 방식으로 파일 단위 적용

// babel.config.js
{
  plugins: [
    ['babel-plugin-react-compiler', {
      compilationMode: 'annotation', // 명시적으로 표시한 파일만 최적화
    }],
  ],
}
// 최적화할 파일 상단에 추가
'use memo'; // 이 파일 전체에 컴파일러 최적화 적용

function MyComponent() {
  // ...
}

3단계: 전체 적용 후 문제 파일은 opt-out

// 문제가 있는 컴포넌트는 명시적으로 제외
function LegacyComponent() {
  'use no memo'; // 이 컴포넌트는 컴파일러 최적화에서 제외
  // 기존 방식대로 동작
}

6. 실제 프로덕션 성능 변화

6.1 Meta 인스타그램 사례

React Compiler를 Instagram 웹에 적용한 결과(2024년 React Conf 발표 내용):

지표변화
불필요한 리렌더링평균 ~35% 감소
Interaction to Next Paint (INP)일부 인터랙션 10~15% 개선
코드베이스의 useMemo 제거약 40% 감소

6.2 커뮤니티 사례 및 벤치마크

다양한 커뮤니티 팀들의 도입 사례를 종합하면:

  • 단순 CRUD 앱: 리렌더링 횟수 20~30% 감소, 체감 성능 차이는 미미
  • 데이터 집약적 대시보드: 복잡한 필터링/정렬 UI에서 30~50% 개선 보고
  • 애니메이션 많은 UI: 프레임 드랍 감소, 특히 저사양 기기에서 효과 뚜렷
  • 대규모 목록 렌더링: 가상화와 조합 시 효과 극대화

6.3 React DevTools로 확인하기

React DevTools 최신 버전에서는 컴파일러가 메모이즈한 컴포넌트에 "Memo ✨" 표시가 나타난다. 실제로 어떤 컴포넌트가 최적화됐는지 확인할 수 있다.

# DevTools에서 컴파일러 효과 확인 방법
# 1. Chrome DevTools → React 탭
# 2. Profiler로 렌더링 기록
# 3. "Why did this render?" 확인
# 4. 컴파일러 적용 전후 비교

7. 컴파일러 도입 후 코드 스타일 가이드라인

React Compiler를 쓴다고 기존 코드를 모두 뜯어고칠 필요는 없다. 하지만 새 코드를 작성할 때는 몇 가지를 염두에 두면 좋다.

7.1 더 이상 기계적으로 쓰지 않아도 되는 것들

// Before: 방어적으로 useMemo 남발
function SearchResults({ query, data }) {
  const filtered = useMemo(
    () => data.filter(item => item.name.includes(query)),
    [data, query]
  );
  const sorted = useMemo(
    () => [...filtered].sort((a, b) => a.name.localeCompare(b.name)),
    [filtered]
  );
  return <List items={sorted} />;
}

// After: 컴파일러가 알아서 처리
function SearchResults({ query, data }) {
  const filtered = data.filter(item => item.name.includes(query));
  const sorted = [...filtered].sort((a, b) => a.name.localeCompare(b.name));
  return <List items={sorted} />;
}

7.2 여전히 직접 최적화가 필요한 케이스

// 외부 라이브러리 초기화 - 여전히 useRef나 useMemo 필요
function MapComponent({ center }) {
  const mapInstance = useRef(null);

  useEffect(() => {
    if (!mapInstance.current) {
      mapInstance.current = new MapLibrary(divRef.current);
    }
    mapInstance.current.setCenter(center);
  }, [center]);

  return <div ref={divRef} />;
}

// 비용이 매우 큰 계산 - 명시적 메모이제이션 고려
function HeavyComputation({ dataset }) {
  // 수백만 건의 데이터 처리 같은 경우는 여전히 명시적 useMemo 권장
  const result = useMemo(
    () => runExpensiveAlgorithm(dataset),
    [dataset]
  );
  return <Chart data={result} />;
}

7.3 컴파일러와 함께하는 코드 리뷰 체크리스트

□ React 규칙(훅 순서, 최상위 호출 등)을 지키고 있는가?
□ 컴포넌트 내에서 외부 mutable 변수를 직접 수정하지 않는가?
□ props나 state를 직접 변경(mutation)하지 않는가?
□ eslint-plugin-react-compiler 경고가 없는가?
□ 정말 비싼 계산(O(n²) 이상)에만 명시적 useMemo를 쓰는가?

맺으며

React Compiler는 "마법"처럼 보이지만 사실 매우 보수적으로 동작한다. 확실히 안전한 경우에만 최적화를 적용하고, 의심스러우면 건너뛴다. 그래서 기존 코드를 망가뜨릴 가능성이 낮다.

실무에서 도입한다면 이 순서를 권장한다:

  1. 먼저 eslint-plugin-react-compiler 설치해서 기존 코드의 문제 파악
  2. React 규칙 위반 코드부터 수정 (어차피 고쳐야 할 코드들)
  3. 새 프로젝트나 신규 기능부터 컴파일러 적용 (리스크 최소화)
  4. React DevTools로 효과 확인 후 점차 확대

useMemo와 useCallback을 "전부 지워도 된다"기보다는, 컴파일러가 처리할 수 있는 케이스는 컴파일러에게 맡기고, 정말 특수한 케이스만 직접 관리한다는 마인드셋이 맞다.

개발자가 성능 최적화에 쏟던 인지 부하를 컴파일러에게 넘기고, 실제 비즈니스 로직에 집중할 수 있는 환경이 조금씩 갖춰지고 있다. 지금 React를 쓰고 있다면, React Compiler를 먼저 ESLint 플러그인으로만 설치해서 기존 코드가 얼마나 컴파일러 친화적인지 살펴보는 것부터 시작해보자.