React 코드를 작성하면서 가장 귀찮은 일 중 하나가 메모이제이션이다. useMemo로 계산 결과를 캐시하고, useCallback으로 함수 참조를 유지하고, React.memo로 불필요한 리렌더링을 막고. 잘 쓰면 성능이 좋아지지만, 잘못 쓰면 아무 효과 없이 코드만 복잡해진다. 의존성 배열을 빠뜨리면 버그가 난다.
React Compiler는 이 문제를 근본적으로 해결하려는 시도다.
React Compiler가 뭔가
컴파일 타임에 React 컴포넌트를 분석해서, 필요한 곳에 자동으로 메모이제이션을 삽입하는 도구다. 빌드 시점에 Babel 플러그인으로 동작한다.
이 코드를:
function ProductList({ products, onSelect }) {
const sorted = [...products].sort((a, b) => a.price - b.price);
return (
<ul>
{sorted.map((product) => (
<ProductItem
key={product.id}
product={product}
onSelect={onSelect}
/>
))}
</ul>
);
}컴파일러가 이렇게 변환한다 (개념적으로):
function ProductList({ products, onSelect }) {
const sorted = useMemo(
() => products.sort((a, b) => a.price - b.price),
[products]
);
return useMemo(
() => (
<ul>
{sorted.map((product) => (
<ProductItem
key={product.id}
product={product}
onSelect={onSelect}
/>
))}
</ul>
),
[sorted, onSelect]
);
}개발자가 useMemo를 직접 쓸 필요가 없다. 컴파일러가 "어떤 값이 변했을 때 어떤 부분을 다시 계산해야 하는지"를 정적 분석으로 파악하고 알아서 최적화한다.
설치와 설정
npm install babel-plugin-react-compilerNext.js에서는 next.config.js에 한 줄이면 된다:
module.exports = {
experimental: {
reactCompiler: true,
},
};Vite라면 Babel 플러그인으로 추가한다:
// vite.config.ts
export default defineConfig({
plugins: [
react({
babel: {
plugins: ['babel-plugin-react-compiler'],
},
}),
],
});실제로 달라지는 것
기존 프로젝트에 React Compiler를 적용한 뒤 React DevTools의 Profiler로 렌더링을 비교해봤다.
1. 불필요한 리렌더링 감소
부모 컴포넌트의 state가 바뀔 때, 관련 없는 자식 컴포넌트까지 리렌더링되던 것이 사라졌다. 이전에는 React.memo로 감싸야 했던 컴포넌트들이 컴파일러에 의해 자동으로 스킵된다.
2. 콜백 함수 안정성
useCallback 없이 선언한 이벤트 핸들러도 참조가 유지된다. 자식 컴포넌트에 콜백을 내려줄 때 매번 새 함수가 생성되어 리렌더링을 유발하던 문제가 해결됐다.
3. 파생 데이터 캐싱
useMemo 없이 계산한 값도 입력이 바뀌지 않으면 이전 결과를 재사용한다. 필터링, 정렬, 집계 같은 연산에 일일이 useMemo를 감싸지 않아도 된다.
컴파일러가 잘 못하는 것
만능은 아니다. 컴파일러는 React의 규칙(Rules of React)을 따르는 코드에서만 제대로 동작한다.
// 이런 코드는 컴파일러가 최적화하기 어렵다
function BadComponent({ items }) {
// 렌더링 중 외부 변수를 수정 (부수 효과)
globalCounter++;
// 매 렌더링마다 다른 결과를 반환 (비순수)
const id = Math.random();
return <div>{id}</div>;
}순수하지 않은 컴포넌트, 렌더링 중 부수 효과가 있는 코드는 컴파일러가 건너뛴다. 에러가 나는 건 아니고, 최적화가 적용되지 않을 뿐이다.
React DevTools에서 컴파일러가 최적화한 컴포넌트에는 "Memo" 배지가 붙는다. 배지가 없는 컴포넌트를 확인해서 코드를 수정하면 점진적으로 커버리지를 올릴 수 있다.
기존 useMemo/useCallback은 어떻게 하나
당장 지울 필요는 없다. 컴파일러가 적용되면 기존 useMemo/useCallback은 사실상 중복이 되지만, 있어도 문제가 되지는 않는다. 컴파일러가 더 효율적인 메모이제이션을 만들어내면 기존 것을 대체한다.
새로 작성하는 코드에서는 useMemo/useCallback을 쓰지 않아도 된다. 기존 코드에서는 리팩토링할 때 자연스럽게 제거하면 된다. 급하게 마이그레이션할 이유는 없다.
// Before: 메모이제이션을 직접 관리
function SearchResults({ query, items }) {
const filtered = useMemo(
() => items.filter((item) => item.name.includes(query)),
[items, query]
);
const handleClick = useCallback(
(id: string) => {
selectItem(id);
},
[selectItem]
);
return filtered.map((item) => (
<Item key={item.id} item={item} onClick={handleClick} />
));
}
// After: 그냥 쓰면 된다
function SearchResults({ query, items }) {
const filtered = items.filter((item) => item.name.includes(query));
const handleClick = (id: string) => {
selectItem(id);
};
return filtered.map((item) => (
<Item key={item.id} item={item} onClick={handleClick} />
));
}코드가 눈에 띄게 깔끔해진다. 의존성 배열 관리에서 해방된다.
React Compiler는 "성능 최적화를 프레임워크의 책임으로 가져가겠다"는 React 팀의 방향성을 보여준다. 개발자는 비즈니스 로직에 집중하고, 렌더링 최적화는 컴파일러가 알아서 한다. 이 방향이 맞다고 생각한다. 메모이제이션은 원래 사람이 관리할 게 아니었다.
