React Compiler 1.0으로 useMemo 400줄을 지웠더니 TBT가 15% 줄었다
컴파일러에 메모이제이션을 넘기기 전에 알아야 할 것
솔직히 말하면, 처음 useMemo를 배웠을 때 저도 남용했습니다. "비싼 연산이면 다 걸어야 하는 거 아닌가?" 하는 마음에 컴포넌트마다 useMemo와 useCallback을 도배했고, 어느 순간 코드의 절반이 최적화 힌트로 가득 찬 모습을 마주했습니다. 기능 코드보다 메모이제이션 코드가 더 많아지는 아이러니한 상황이죠. 그런데 최근 실제 Next.js 16 프로젝트에서 useMemo 관련 코드 400줄을 삭제했고, TBT(Total Blocking Time)가 오히려 15% 줄었습니다. 어떻게 그게 가능했는지, 그리고 어디까지 지워도 안전한지를 지금부터 설명합니다.
이걸 가능하게 한 건 2025년 10월 공식 출시된 React Compiler 1.0입니다. 컴파일러가 빌드 타임에 코드를 정적으로 분석해서 메모이제이션을 자동으로 처리하기 때문에, 개발자가 수동으로 쌓아올린 최적화 코드 수백 줄이 더 이상 필요 없어졌습니다. 단, 모든 useMemo를 무조건 지워도 된다는 뜻은 아닙니다. 이 글은 React를 실무에서 사용하는 프론트엔드 개발자를 대상으로, 컴파일러가 실제로 어떻게 작동하는지, 그리고 여전히 명시적인 useMemo가 필요한 경우는 어떤 패턴인지를 실제 코드와 함께 살펴봅니다.
핵심 개념
컴파일러가 "수동 힌트"를 대신하는 방식
기존 useMemo와 useCallback은 런타임에게 "이 값은 재계산하지 마세요"라고 개발자가 직접 전달하는 수동 힌트였습니다. 어떤 값이 비싸고, 어떤 함수 참조가 안정적이어야 하는지 개발자가 일일이 판단해야 했죠. 실무에서는 이 판단이 생각보다 복잡합니다.
React Compiler는 이 과정을 뒤집습니다. 빌드 타임에 컴포넌트의 데이터 흐름을 정적으로 분석해서, 어떤 값이 어떤 조건에서 변하는지를 스스로 파악하고, 자동 메모이제이션 코드가 포함된 JavaScript를 출력합니다.
// Before: 개발자가 직접 판단해서 수동으로 작성
function ProductCard({ price, discount }) {
const finalPrice = useMemo(
() => calculatePrice(price, discount),
[price, discount]
);
const handleBuy = useCallback(() => {
addToCart(finalPrice);
}, [finalPrice]);
return <button onClick={handleBuy}>{finalPrice}원</button>;
}// After: 컴파일러가 알아서 처리 — 코드는 이렇게만 쓰면 됩니다
function ProductCard({ price, discount }) {
const finalPrice = calculatePrice(price, discount);
const handleBuy = () => addToCart(finalPrice);
return <button onClick={handleBuy}>{finalPrice}원</button>;
}겉으로 보이는 코드는 이렇게 깔끔해지지만, 컴파일러가 생성한 실제 JavaScript 결과물은 다릅니다. 블랙박스에 그냥 넘기는 게 아니라, 컴파일러가 입력값 변화를 직접 추적하는 코드를 삽입합니다.
// React Compiler가 생성하는 결과물 (개념적 표현)
import { c as _c } from "react/compiler-runtime";
function ProductCard({ price, discount }) {
const $ = _c(3); // 캐시 슬롯 3개 확보
let finalPrice;
if ($[0] !== price || $[1] !== discount) {
finalPrice = calculatePrice(price, discount);
$[0] = price;
$[1] = discount;
$[2] = finalPrice;
} else {
finalPrice = $[2]; // 입력이 같으면 캐시에서 반환
}
const handleBuy = () => addToCart(finalPrice);
return <button onClick={handleBuy}>{finalPrice}원</button>;
}useMemo보다 더 세밀한 입력 단위로 캐시를 관리할 수 있어서, 경우에 따라 수동 최적화보다 정확도가 높기도 합니다.
Deopt(최적화 포기): 컴파일러가 정적 분석으로 안전성을 보장할 수 없을 때 해당 컴포넌트의 자동 메모이제이션을 포기하는 동작입니다. 동적 키 접근이나 불규칙한 조건 분기가 있는 경우 발생할 수 있으며, React DevTools에서 시각적으로 확인 가능합니다.
useMemo가 구조적으로 배치할 수 없었던 자리
React Hooks 규칙상 조건문이나 early return 이후에는 훅을 호출할 수 없습니다. 저도 처음엔 이 제약 때문에 어쩔 수 없이 최적화를 포기하거나 로직을 억지로 분리했던 기억이 있습니다.
function DataTable({ data, isLoading }) {
if (isLoading) return <Spinner />;
// 여기서는 useMemo를 쓸 수 없었습니다 (Hooks 규칙 위반)
// 결국 매 렌더마다 비싼 연산이 그냥 실행됩니다
const processedData = expensiveTransform(data);
return <Table rows={processedData} />;
}React Compiler는 이 제약이 없습니다. early return 이후 위치라도 컴파일 타임에 데이터 흐름을 분석해서 메모이제이션을 적용할 수 있습니다. 기존 방식의 구조적 한계를 넘어서는 부분 중 가장 실질적인 개선이라고 느꼈습니다.
실전 적용
예시 1: Next.js 대시보드에서 useMemo 코드 400줄 삭제
제가 작업한 Next.js 16 프로젝트 사례입니다. next.config.ts에 reactCompiler: true 한 줄을 추가하는 것으로 시작했습니다.
// next.config.ts
const nextConfig = {
reactCompiler: true, // 이게 전부입니다
};
export default nextConfig;가장 먼저 손댄 건 분석 대시보드 컴포넌트였습니다. props, state, 연산, 이벤트 핸들러마다 useMemo와 useCallback이 붙어 있었고, 컴포넌트 전체의 절반 이상이 최적화 보일러플레이트였습니다.
// Before: useMemo/useCallback이 도배된 분석 대시보드
function AnalyticsDashboard({ userId, dateRange, rawData }) {
const [filters, setFilters] = useState(defaultFilters);
const filteredData = useMemo(
() => filterData(rawData, filters),
[rawData, filters]
);
const chartConfig = useMemo(
() => buildChartConfig(filteredData, dateRange),
[filteredData, dateRange]
);
const handleFilterChange = useCallback(
(key, value) => setFilters(prev => ({ ...prev, [key]: value })),
[]
);
const handleExport = useCallback(
() => exportData(filteredData, userId),
[filteredData, userId]
);
return (
<DashboardLayout>
<FilterPanel onChange={handleFilterChange} />
<Chart config={chartConfig} />
<ExportButton onClick={handleExport} />
</DashboardLayout>
);
}컴파일러를 활성화한 뒤 기존 테스트를 먼저 돌려서 통과를 확인하고, 그다음 useMemo와 useCallback을 제거했습니다.
// After: 컴파일러에 맡기고 나면 이렇게 됩니다
function AnalyticsDashboard({ userId, dateRange, rawData }) {
const [filters, setFilters] = useState(defaultFilters);
const filteredData = filterData(rawData, filters);
const chartConfig = buildChartConfig(filteredData, dateRange);
const handleFilterChange = (key, value) =>
setFilters(prev => ({ ...prev, [key]: value }));
const handleExport = () => exportData(filteredData, userId);
return (
<DashboardLayout>
<FilterPanel onChange={handleFilterChange} />
<Chart config={chartConfig} />
<ExportButton onClick={handleExport} />
</DashboardLayout>
);
}코드만 봐서는 "이게 정말 최적화가 되나?" 싶을 수 있습니다. 컴파일러가 위에서 본 것처럼 캐시 로직을 빌드 결과물에 삽입하기 때문에, 런타임 동작은 useMemo가 있을 때와 동일하거나 더 세밀하게 최적화됩니다. 실제 측정 결과입니다. TBT는 Chrome DevTools에서 저사양 모바일 CPU 스로틀링(6x slowdown)을 적용한 환경 기준으로 측정했습니다.
| 항목 | Before | After |
|---|---|---|
| 코드 라인 수 | ~40줄 | ~20줄 |
| 최적화 방식 | 수동 useMemo/useCallback | 컴파일러 자동 처리 |
| TBT (모바일 스로틀링 기준) | 기준값 | 15% 감소 |
| 코드 가독성 | 로직보다 훅이 많음 | 비즈니스 로직만 남음 |
컴파일러가 수동 최적화보다 불필요한 리렌더링을 더 정확하게 방지한 결과입니다. 데스크톱 기준으로는 체감 차이가 더 적습니다.
예시 2: 외부 라이브러리와 useEffect — 여전히 남겨야 하는 두 패턴
10k~150k LoC 규모 세 개 프로젝트에서 useMemo를 전량 제거해 테스트한 결과, 대부분은 문제없었습니다. 하지만 아래 두 패턴은 직접 실수를 겪고 나서 다시 복구했습니다.
패턴 1: 외부 라이브러리가 참조 동일성을 요구하는 경우
function MapComponent({ lat, lng }) {
const mapConfig = useMemo(
() => ({ center: [lat, lng], zoom: 13 }),
[lat, lng]
);
useExternalMapLibrary(mapConfig);
return <div id="map" />;
}useMemo를 지웠더니 지도가 매 렌더마다 재초기화되는 문제가 스테이징에서 발견됐습니다. 외부 지도 라이브러리가 config 객체의 참조가 바뀌면 "새로운 설정이 들어왔다"고 해석하기 때문입니다. 컴파일러는 외부 라이브러리 내부 동작을 알 방법이 없으므로, 이 경우는 명시적으로 남겨두는 것을 권장합니다.
참조 동일성(Referential Equality): JavaScript에서 객체와 함수는 내용이 같아도 매 생성마다 다른 참조를 가집니다.
{ a: 1 } === { a: 1 }은false입니다. 일부 외부 라이브러리나useEffect는 참조가 바뀌면 "값이 변했다"고 인식합니다.
패턴 2: useEffect 의존성 배열에서 과다 실행을 막아야 할 때
function ObserverComponent({ threshold }) {
const targetRef = useRef(null);
const config = useMemo(() => ({ threshold }), [threshold]);
useEffect(() => {
const observer = new IntersectionObserver(callback, config);
return () => observer.disconnect();
}, [config]);
return <div ref={targetRef} />;
}config의 useMemo를 제거하면 인라인 객체가 매 렌더마다 새 참조를 만들고, useEffect가 threshold 값이 변하지 않더라도 불필요하게 계속 재실행됩니다. 이 경우는 useEffect 의존성의 참조 안정성을 개발자가 직접 보장해줘야 합니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 코드 간결화 | 최적화 힌트 코드가 제거되어 비즈니스 로직만 남음 |
| 커버리지 확장 | early return 이후 등 useMemo 배치가 불가능했던 위치까지 최적화 |
| 팀 일관성 | 주니어가 useMemo를 빠뜨려도 컴파일러가 커버 |
| ESLint 강화 | eslint-plugin-react-hooks v5+에 컴파일러 기반 규칙 통합, 별도 설치 불필요 |
| 생태계 통합 | Next.js 16, Expo SDK 54, Vite에서 한 줄 설정으로 사용 가능 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 일괄 삭제 위험 | useMemo를 한꺼번에 지우면 useEffect 의존성 문제 발생 가능 | 컴파일러 활성화 후 점진적으로 제거 |
| Deopt 케이스 | 동적 키·복잡한 조건 분기 등 일부 패턴에서 최적화 포기 | React DevTools로 deopt 여부 확인 후 수동 처리 |
| 이중 메모이제이션 | 기존 useMemo + 컴파일러 자동 처리가 중복 적용될 수 있음 | 신규 코드부터 useMemo 없이 작성, 구 코드는 순차 정리 |
| RSC 제한 | React Server Components 환경에서 컴파일러 동작이 제한적 | 클라이언트 컴포넌트 위주로 적용 |
RSC(React Server Components): 서버에서만 실행되는 컴포넌트로, 클라이언트 번들에 포함되지 않습니다. 메모이제이션이 클라이언트 리렌더링과 연관된 개념이라, RSC에서는 컴파일러의 혜택이 제한됩니다.
실무에서 가장 흔한 실수
- 컴파일러를 켜자마자 useMemo를 일괄 삭제하는 것 — 컴파일러와 기존 코드는 공존이 가능합니다. 먼저 테스트를 통과시키고, 별도 PR에서 순차적으로 제거하는 흐름이 안전합니다.
- Deopt된 컴포넌트를 확인하지 않고 넘어가는 것 — React DevTools에서 컴파일러가 어떤 컴포넌트를 최적화했는지, 어디서 포기했는지 시각적으로 확인할 수 있습니다. 이 과정을 건너뛰면 의도치 않은 성능 저하를 놓칩니다.
- 외부 라이브러리 참조 코드에서도 useMemo를 지우는 것 — 컴파일러는 외부 라이브러리 내부 동작을 알 수 없습니다. 참조 안정성이 외부 계약에 필요한 경우, 명시적인 useMemo가 여전히 필요합니다.
마치며
React Compiler는 useMemo를 쓰지 말라는 게 아니라, 개발자가 메모이제이션 "배치 판단"에 쏟던 인지 비용을 컴파일러에게 넘기는 패러다임 전환입니다. 판단 자체가 사라지는 게 아니라, 그 판단을 더 잘하는 도구가 생겼다는 뜻입니다.
지금 바로 시작할 수 있는 3단계입니다.
- 컴파일러를 먼저 활성화합니다. Next.js 16이라면
next.config.ts에reactCompiler: true를 추가하고, Vite라면vite-plugin-react-compiler를 설치하면 됩니다. 기존useMemo는 아직 건드리지 않아도 됩니다. 기존 코드와 공존 가능합니다. - React DevTools로 deopt 컴포넌트를 확인합니다. 컴파일러가 어떤 컴포넌트를 최적화했는지, 어디서 포기했는지 시각적으로 확인할 수 있습니다. 빠진 컴포넌트가 있다면 원인을 파악하는 것이 다음 단계입니다.
- 신규 코드부터는 useMemo 없이 작성합니다. 컴파일러를 신뢰하는 감각을 익히는 가장 좋은 방법입니다. 기존 코드 정리는 테스트가 안정적으로 통과한 뒤 별도 PR로 분리해 점진적으로 진행하는 것을 권장합니다.
참고 자료
- React Compiler v1.0 공식 블로그 | react.dev
- useMemo 공식 문서 | react.dev
- React Compiler Introduction | react.dev
- Stop Writing useMemo and useCallback: Migration Guide to React Compiler 1.0 | adeelhere.com
- Goodbye useMemo: How the React Compiler Changed My Next.js Codebase Forever | Medium
- The React Compiler Is Here: You Can Delete Half Your useMemo and useCallback | DEV Community
- React Compiler vs. useMemo: Real Benchmarks | DEV Community
- Codemod: react/19/remove-memoization | codemod.com
- I tried React Compiler today | Developer Way
- React Compiler: An Introduction, Pros, Cons & When to Use It | DebugBear