React Compiler — 자동 메모이제이션과 useMemo 없는 미래
useMemo,useCallback,React.memo— 이것들을 언제 써야 하고 언제 쓰지 말아야 하는지, 왜 개발자가 직접 판단해야 할까요? 컴파일러가 알아서 해주면 안 되는 걸까요?
React를 쓰다 보면 성능 최적화라는 이름 아래 useMemo와 useCallback을 습관적으로 감싸게 됩니다. 그런데 정작 이게 효과가 있는 건지, 오히려 메모리만 낭비하는 건지 확신이 없을 때가 많습니다. React Compiler는 바로 이 고민을 빌드 타임에 자동으로 해결해주는 도구입니다.
React Compiler란
React Compiler는 빌드 타임에 React 컴포넌트를 분석하여 자동으로 메모이제이션 코드를 삽입하는 컴파일러 입니다.
핵심을 한마디로 정리하면 이렇습니다.
개발자가
useMemo,useCallback,React.memo를 직접 작성하지 않아도, 컴파일러가 필요한 곳에 알아서 최적화 코드를 넣어준다.
// 개발자가 작성하는 코드 (최적화 신경 X)
function ProductList({ products, category }) {
const filtered = products.filter(p => p.category === category);
return (
<ul>
{filtered.map(p => (
<ProductItem key={p.id} product={p} />
))}
</ul>
);
}
// React Compiler가 변환한 결과 (개념적 표현)
function ProductList({ products, category }) {
// 컴파일러가 자동으로 메모이제이션 삽입
const filtered = useMemo(
() => products.filter(p => p.category === category),
[products, category]
);
return (
<ul>
{filtered.map(p => (
<ProductItem key={p.id} product={p} />
))}
</ul>
);
}
실제 변환 결과는 위보다 더 세밀하지만, 개념적으로 이렇게 이해하면 됩니다. 개발자는 그냥 읽기 좋은 코드를 쓰고, 최적화는 컴파일러에게 맡기는 구조입니다.
왜 필요한가 — 수동 메모이제이션의 문제점
기존 방식에는 세 가지 근본적인 문제가 있습니다.
1. 누락 — 정작 필요한 곳에 안 쓴다
function Dashboard({ data }) {
// 매 렌더링마다 재계산 — 비용이 큰데 useMemo를 빼먹음
const chartData = processHeavyData(data);
const summary = calculateSummary(data);
return (
<>
<Chart data={chartData} />
<Summary info={summary} />
</>
);
}
processHeavyData가 무거운 연산인데 useMemo를 안 감쌌습니다. 코드 리뷰에서 잡히면 다행이지만, 잡히지 않으면 성능 문제로 이어집니다.
2. 과다 사용 — 필요 없는 곳에도 쓴다
function UserCard({ name, age }) {
// 단순 문자열 연결에 useMemo? 오히려 오버헤드
const displayName = useMemo(() => `${name} (${age})`, [name, age]);
// 빈 함수에 useCallback?
const handleClick = useCallback(() => {
console.log('clicked');
}, []);
return <div onClick={handleClick}>{displayName}</div>;
}
단순한 연산에 useMemo를 쓰면, 메모이제이션 자체의 비교 비용이 연산 비용보다 클 수 있습니다. 오히려 역효과입니다.
3. DX 저하 — 코드가 장황해진다
// 메모이제이션 없는 깔끔한 코드
function SearchResults({ query, items }) {
const results = items.filter(item =>
item.name.includes(query)
);
const count = results.length;
const handleSelect = (id) => onSelect(id);
return <ResultList results={results} count={count} onSelect={handleSelect} />;
}
// 메모이제이션을 추가한 코드 — 비즈니스 로직이 묻힌다
function SearchResults({ query, items, onSelect }) {
const results = useMemo(
() => items.filter(item => item.name.includes(query)),
[items, query]
);
const count = useMemo(() => results.length, [results]);
const handleSelect = useCallback((id) => onSelect(id), [onSelect]);
return <ResultList results={results} count={count} onSelect={handleSelect} />;
}
공부하다 보니 이게 진짜 불편한 부분이었습니다. 비즈니스 로직보다 최적화 코드가 더 많아지는 상황이 자주 발생합니다.
동작 원리 — Babel 플러그인으로 빌드 타임 변환
React Compiler는 Babel 플러그인으로 동작합니다. 빌드 과정에서 컴포넌트의 코드를 정적 분석하고, 최적화된 버전으로 변환합니다.
분석 과정
소스 코드 → AST 파싱 → 데이터 흐름 분석 → 의존성 그래프 구축 → 메모이제이션 삽입 → 변환된 코드
컴파일러가 하는 일을 단계별로 보면 이렇습니다.
- **변수 추적 **: 어떤 값이 어떤 값에 의존하는지 파악
- ** 변경 감지 **: props나 state가 바뀔 때 어떤 값들이 영향받는지 계산
- ** 세분화된 메모이제이션 **: 필요한 부분만 정확하게 캐싱
// 원본 코드
function Profile({ user }) {
const fullName = `${user.firstName} ${user.lastName}`;
const age = calculateAge(user.birthDate);
return (
<div>
<h1>{fullName}</h1>
<p>{age}세</p>
<Avatar url={user.avatarUrl} />
</div>
);
}
컴파일러는 이 코드를 분석하여 다음을 파악합니다.
fullName은user.firstName,user.lastName에만 의존age는user.birthDate에만 의존Avatar는user.avatarUrl에만 의존
그래서 user.avatarUrl만 바뀌었을 때 fullName과 age의 재계산을 건너뛸 수 있습니다. 이런 세밀한 최적화를 개발자가 직접 하려면 정말 번거롭습니다.
JSX 엘리먼트도 메모이제이션 대상
function App({ items, theme }) {
return (
<div className={theme}>
<Header /> {/* theme이 바뀌어도 Header는 리렌더링 불필요 */}
<ItemList items={items} />
<Footer /> {/* 마찬가지 */}
</div>
);
}
컴파일러는 JSX 엘리먼트 자체도 메모이제이션합니다. <Header />의 props가 변하지 않았다면 이전 렌더링 결과를 재사용합니다. 기존에는 이걸 위해 React.memo를 써야 했습니다.
Rules of React — 컴파일러가 전제하는 규칙
컴파일러가 안전하게 최적화하려면 코드가 특정 규칙을 지켜야 합니다. 이를 Rules of React 라고 부릅니다.
규칙 1: 컴포넌트는 순수 함수여야 한다
// 순수 함수 — 같은 props면 같은 결과
function Greeting({ name }) {
return <h1>안녕하세요, {name}님!</h1>;
}
// 비순수 — 렌더링 중 외부 변수 변경 (위험!)
let count = 0;
function Counter() {
count++; // 렌더링마다 외부 상태 변경
return <span>{count}</span>;
}
순수하지 않은 컴포넌트를 메모이제이션하면, 실행을 건너뛸 때 side effect도 건너뛰게 되어 버그가 발생합니다.
규칙 2: 렌더링 중 side effect 금지
// 잘못된 예 — 렌더링 중 DOM 접근
function BadComponent({ value }) {
document.title = `값: ${value}`; // 렌더링 중 side effect
return <div>{value}</div>;
}
// 올바른 예 — useEffect에서 side effect 수행
function GoodComponent({ value }) {
useEffect(() => {
document.title = `값: ${value}`; // side effect는 여기서
}, [value]);
return <div>{value}</div>;
}
규칙 3: props와 state는 불변으로 다룬다
// 잘못된 예 — props 직접 변경
function SortedList({ items }) {
items.sort((a, b) => a.name.localeCompare(b.name)); // 원본 변경!
return <ul>{items.map(i => <li key={i.id}>{i.name}</li>)}</ul>;
}
// 올바른 예 — 새 배열 생성
function SortedList({ items }) {
const sorted = [...items].sort((a, b) => a.name.localeCompare(b.name));
return <ul>{sorted.map(i => <li key={i.id}>{i.name}</li>)}</ul>;
}
이 규칙들은 사실 React Compiler와 상관없이 원래 지켜야 하는 것들입니다. 다만 수동 메모이제이션 시절에는 위반해도 대충 돌아가는 경우가 있었는데, 컴파일러가 적극적으로 렌더링을 건너뛰기 시작하면 바로 버그로 드러납니다.
기존 코드에 적용하기
설치
# npm
npm install -D babel-plugin-react-compiler
npm install -D eslint-plugin-react-compiler
# 또는 yarn
yarn add -D babel-plugin-react-compiler
yarn add -D eslint-plugin-react-compiler
Babel 설정
// babel.config.js
module.exports = {
plugins: [
['babel-plugin-react-compiler', {
// 옵션 설정
}],
],
};
Next.js에서 설정
// next.config.js
const nextConfig = {
experimental: {
reactCompiler: true,
},
};
module.exports = nextConfig;
Next.js를 쓰고 있다면 설정이 정말 간단합니다. experimental.reactCompiler를 켜는 것만으로 적용됩니다.
점진적 적용 (opt-in 모드)
기존 프로젝트에 한꺼번에 적용하기 부담스러우면, 특정 디렉토리나 파일만 대상으로 설정할 수 있습니다.
// babel.config.js — 특정 디렉토리만 컴파일
module.exports = {
plugins: [
['babel-plugin-react-compiler', {
sources: (filename) => {
return filename.includes('src/components');
},
}],
],
};
이렇게 하면 src/components 내부의 파일만 컴파일러 최적화가 적용됩니다. 안정성을 확인한 뒤 점진적으로 범위를 넓혀나가는 전략입니다.
'use no memo' 지시어
특정 컴포넌트나 훅을 컴파일러 최적화에서 제외하고 싶을 때 사용합니다.
// 이 컴포넌트는 컴파일러 최적화 제외
function LegacyChart({ data }) {
'use no memo';
// 외부 라이브러리를 사용하여 DOM을 직접 조작하는 등
// Rules of React를 위반하는 코드가 있는 경우
useEffect(() => {
chartLibrary.render(containerRef.current, data);
}, [data]);
return <div ref={containerRef} />;
}
// 커스텀 훅에도 적용 가능
function useExternalSync(source) {
'use no memo';
// 외부 스토어와 동기화하는 복잡한 로직
// ...
}
이 지시어는 주로 다음 상황에서 사용합니다.
- 외부 라이브러리가 DOM을 직접 조작하는 컴포넌트
- 레거시 코드에서 Rules of React를 위반하지만 당장 리팩토링이 어려운 경우
- 컴파일러 적용 후 특정 컴포넌트에서 이상 동작이 발생한 경우
'use client'나 'use server'와 비슷한 형태의 지시어라 익숙하게 느껴질 겁니다. React 팀이 이런 방식으로 도구 간 일관성을 유지하고 있습니다.
useMemo/useCallback은 정말 사라지나?
결론부터 말하면 **당장 사라지지는 않습니다 **. 하지만 점차 쓸 필요가 없어지는 방향으로 가고 있습니다.
호환성
// 기존에 이렇게 쓰던 코드가 있다면
function SearchPage({ query }) {
const debouncedQuery = useMemo(() => debounce(query), [query]);
const handleSearch = useCallback(() => {
search(debouncedQuery);
}, [debouncedQuery]);
return <SearchBar onSearch={handleSearch} />;
}
React Compiler를 적용해도 이 코드는 ** 정상적으로 동작합니다 **. 컴파일러는 기존 useMemo/useCallback을 제거하지 않고, 추가적인 최적화를 삽입합니다. 두 최적화가 공존하는 구조입니다.
점진적 전환 전략
Phase 1: 컴파일러 적용 + 기존 useMemo/useCallback 유지
→ 동작 검증
Phase 2: 새로 작성하는 코드에서 useMemo/useCallback 생략
→ 컴파일러가 알아서 처리
Phase 3: 기존 코드에서 불필요한 useMemo/useCallback 점진적 제거
→ 코드 가독성 향상
한 가지 주의할 점은, useMemo가 ** 의미적으로 중요한 역할 **을 하는 경우입니다.
// 이런 경우는 useMemo를 유지하는 게 좋을 수 있다
function ExpensiveChart({ rawData }) {
// "이 계산이 비싸다"는 의도를 명시적으로 표현
const processedData = useMemo(() => {
return heavyDataProcessing(rawData); // 수백ms 걸리는 연산
}, [rawData]);
return <Chart data={processedData} />;
}
컴파일러가 자동으로 메모이제이션하겠지만, useMemo가 코드를 읽는 다른 개발자에게 "이건 비싼 연산이다"라는 신호를 줄 수 있습니다. 팀의 컨벤션에 따라 결정하면 됩니다.
eslint-plugin-react-compiler로 규칙 위반 감지
컴파일러를 도입하기 전에, 코드가 Rules of React를 잘 지키고 있는지 확인할 수 있습니다.
설정
// .eslintrc.js
module.exports = {
plugins: ['react-compiler'],
rules: {
'react-compiler/react-compiler': 'error',
},
};
감지되는 패턴들
// ESLint 경고: 렌더링 중 외부 변수 변경
let globalCount = 0;
function Counter() {
globalCount++; // ← react-compiler/react-compiler 규칙 위반
return <span>{globalCount}</span>;
}
// ESLint 경고: props 직접 변경
function List({ items }) {
items.push(newItem); // ← 규칙 위반
return <ul>{items.map(i => <li key={i.id}>{i.name}</li>)}</ul>;
}
// ESLint 경고: 조건부 훅 호출
function Form({ showName }) {
if (showName) {
const [name, setName] = useState(''); // ← 규칙 위반
}
return <div />;
}
컴파일러를 도입하기 전에 이 ESLint 플러그인부터 적용하는 것을 추천합니다. 기존 코드에서 위반 사항을 미리 파악하고 수정할 수 있어서 도입 리스크를 크게 줄일 수 있습니다.
React Compiler가 바꾸는 개발 패러다임
React Compiler의 등장은 단순한 "편의 기능 추가"가 아닙니다. React 개발의 패러다임 자체를 바꾸는 변화입니다.
Before: "최적화를 생각하면서" 코드 작성
// 매번 고민해야 했던 것들
function UserDashboard({ user, posts, settings }) {
// Q1: 이 연산은 비싼가? useMemo로 감싸야 하나?
const recentPosts = useMemo(
() => posts.filter(p => isRecent(p)).slice(0, 5),
[posts]
);
// Q2: 자식에게 전달하니까 useCallback?
const handlePostClick = useCallback((postId) => {
navigate(`/posts/${postId}`);
}, [navigate]);
// Q3: 이 컴포넌트는 React.memo?
return (
<div>
<UserInfo user={user} />
<PostList posts={recentPosts} onClick={handlePostClick} />
<Settings config={settings} />
</div>
);
}
After: "로직에만 집중하는" 코드 작성
// 컴파일러가 있으면 그냥 이렇게 쓴다
function UserDashboard({ user, posts, settings }) {
const recentPosts = posts.filter(p => isRecent(p)).slice(0, 5);
const handlePostClick = (postId) => {
navigate(`/posts/${postId}`);
};
return (
<div>
<UserInfo user={user} />
<PostList posts={recentPosts} onClick={handlePostClick} />
<Settings config={settings} />
</div>
);
}
코드의 의도가 훨씬 명확합니다. useMemo를 써야 하나 말아야 하나 고민하는 시간을 비즈니스 로직에 쏟을 수 있습니다.
주니어 개발자에게 특히 좋은 이유
공부하다 보니 이 부분이 확 와닿았습니다.
- ** 진입 장벽 감소 **: "언제 useMemo를 써야 하나"라는 판단이 불필요
- ** 실수 여지 감소 **: 의존성 배열 빠뜨려서 생기는 stale closure 버그가 사라짐
- ** 코드 리뷰 간소화 **: "여기 useMemo 빼먹었네요" 같은 리뷰 포인트가 줄어듦
다만 Rules of React(순수 함수, 불변성)를 이해하는 것은 여전히 중요합니다. 컴파일러가 메모이제이션을 자동화해줘도, 코드 자체가 규칙을 위반하면 최적화가 의미 없어지니까요.
정리
| 구분 | 수동 메모이제이션 | React Compiler |
|---|---|---|
| 최적화 시점 | 개발자가 직접 판단 | 빌드 타임 자동 |
| 최적화 범위 | 개발자가 감싼 곳만 | 전체 컴포넌트 |
| 의존성 관리 | 배열 직접 작성 | 컴파일러가 분석 |
| 실수 가능성 | 높음 (누락/과다) | 낮음 |
| 코드 가독성 | 보일러플레이트 많음 | 비즈니스 로직에 집중 |
| 전제 조건 | 특별히 없음 | Rules of React 준수 |
React Compiler는 "개발자가 최적화를 신경 쓰지 않아도 되는 React"를 만들겠다는 React 팀의 오래된 비전입니다. useMemo와 useCallback을 잘 쓰는 것이 실력이던 시대에서, 깔끔하고 순수한 코드를 작성하는 것이 곧 최적화인 시대로 전환되고 있습니다.