개인정보처리방침© 2026 DEV BAK - 기술블로그. All rights reserved.
DEV BAK - 기술블로그
frontend

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를 출력합니다.

tsx
// 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>;
}
tsx
// After: 컴파일러가 알아서 처리 — 코드는 이렇게만 쓰면 됩니다
function ProductCard({ price, discount }) {
  const finalPrice = calculatePrice(price, discount);
  const handleBuy = () => addToCart(finalPrice);
 
  return <button onClick={handleBuy}>{finalPrice}원</button>;
}

겉으로 보이는 코드는 이렇게 깔끔해지지만, 컴파일러가 생성한 실제 JavaScript 결과물은 다릅니다. 블랙박스에 그냥 넘기는 게 아니라, 컴파일러가 입력값 변화를 직접 추적하는 코드를 삽입합니다.

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 이후에는 훅을 호출할 수 없습니다. 저도 처음엔 이 제약 때문에 어쩔 수 없이 최적화를 포기하거나 로직을 억지로 분리했던 기억이 있습니다.

tsx
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 한 줄을 추가하는 것으로 시작했습니다.

typescript
// next.config.ts
const nextConfig = {
  reactCompiler: true, // 이게 전부입니다
};
 
export default nextConfig;

가장 먼저 손댄 건 분석 대시보드 컴포넌트였습니다. props, state, 연산, 이벤트 핸들러마다 useMemo와 useCallback이 붙어 있었고, 컴포넌트 전체의 절반 이상이 최적화 보일러플레이트였습니다.

tsx
// 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을 제거했습니다.

tsx
// 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: 외부 라이브러리가 참조 동일성을 요구하는 경우

tsx
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 의존성 배열에서 과다 실행을 막아야 할 때

tsx
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에서는 컴파일러의 혜택이 제한됩니다.

실무에서 가장 흔한 실수

  1. 컴파일러를 켜자마자 useMemo를 일괄 삭제하는 것 — 컴파일러와 기존 코드는 공존이 가능합니다. 먼저 테스트를 통과시키고, 별도 PR에서 순차적으로 제거하는 흐름이 안전합니다.
  2. Deopt된 컴포넌트를 확인하지 않고 넘어가는 것 — React DevTools에서 컴파일러가 어떤 컴포넌트를 최적화했는지, 어디서 포기했는지 시각적으로 확인할 수 있습니다. 이 과정을 건너뛰면 의도치 않은 성능 저하를 놓칩니다.
  3. 외부 라이브러리 참조 코드에서도 useMemo를 지우는 것 — 컴파일러는 외부 라이브러리 내부 동작을 알 수 없습니다. 참조 안정성이 외부 계약에 필요한 경우, 명시적인 useMemo가 여전히 필요합니다.

마치며

React Compiler는 useMemo를 쓰지 말라는 게 아니라, 개발자가 메모이제이션 "배치 판단"에 쏟던 인지 비용을 컴파일러에게 넘기는 패러다임 전환입니다. 판단 자체가 사라지는 게 아니라, 그 판단을 더 잘하는 도구가 생겼다는 뜻입니다.

지금 바로 시작할 수 있는 3단계입니다.

  1. 컴파일러를 먼저 활성화합니다. Next.js 16이라면 next.config.ts에 reactCompiler: true를 추가하고, Vite라면 vite-plugin-react-compiler를 설치하면 됩니다. 기존 useMemo는 아직 건드리지 않아도 됩니다. 기존 코드와 공존 가능합니다.
  2. React DevTools로 deopt 컴포넌트를 확인합니다. 컴파일러가 어떤 컴포넌트를 최적화했는지, 어디서 포기했는지 시각적으로 확인할 수 있습니다. 빠진 컴포넌트가 있다면 원인을 파악하는 것이 다음 단계입니다.
  3. 신규 코드부터는 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
#ReactCompiler#useMemo#useCallback#메모이제이션#NextJS#React#성능최적화#RSC#정적분석#useEffect
공유하기

목차

핵심 개념컴파일러가 "수동 힌트"를 대신하는 방식useMemo가 구조적으로 배치할 수 없었던 자리실전 적용예시 1: Next.js 대시보드에서 useMemo 코드 400줄 삭제예시 2: 외부 라이브러리와 useEffect — 여전히 남겨야 하는 두 패턴장단점 분석장점단점 및 주의사항실무에서 가장 흔한 실수마치며참고 자료

추천 포스트

ESLint vs Biome vs Oxlint — 2026년 프론트엔드 린팅 도구의 속도·생태계·마이그레이션 비용 비교
frontend

ESLint vs Biome vs Oxlint — 2026년 프론트엔드 린팅 도구의 속도·생태계·마이그레이션 비용 비교

린터(linter)는 코드를 실행하기 전에 잠재적 버그, 나쁜 패턴, 스타일 불일치를 잡아주는 도구입니다. CI 파이프라인에서 린팅이 없으면 리뷰에서 걸렸을 실수들이 프로덕션까지 가는 경험, 한 번쯤 해보셨을 겁니다. 솔직히 저도 "ESLint면 충분하지"라고 생각하며 꽤 오랫동안...

2026년 06월 26일읽는 데 22분
RSC 없이 DB 직접 호출부터 스트리밍까지 — TanStack Start와 Next.js, 풀스택 경계가 어디서 갈리는가
frontend

RSC 없이 DB 직접 호출부터 스트리밍까지 — TanStack Start와 Next.js, 풀스택 경계가 어디서 갈리는가

Next.js 13 App Router가 나왔을 때 솔직히 많이 흔들렸습니다. 를 붙였다 떼었다 하면서 "나만 이상한 건가" 싶은 불안감, 서버 컴포넌트 안에서 를 쓰려다 런타임 에러를 맞닥뜨리는 경험. React와 Next.js를 어느 정도 써온 분이라면 한 번쯤 공감할 겁니다. 그 ...

2026년 06월 26일읽는 데 27분
Vite 8 + Rolldown: 이중 번들러 통합으로 프로덕션 빌드 시간을 87% 단축한 원리
frontend

Vite 8 + Rolldown: 이중 번들러 통합으로 프로덕션 빌드 시간을 87% 단축한 원리

(Vite 8 Rolldown migration | build speed) 46초짜리 빌드가 6초로 줄었다는 말을 처음 들었을 때 솔직히 반신반의했습니다. 벤치마크 숫자는 늘 현실보다 좋으니까요. 그런데 Linear, GitLab, Ramp 같은 실제 프로덕션 코드베이스에서 속속 ...

2026년 06월 26일읽는 데 17분
CSS Anchor Positioning으로 Popper.js 제거하기 — 브라우저가 직접 처리하는 플로팅 UI
frontend

CSS Anchor Positioning으로 Popper.js 제거하기 — 브라우저가 직접 처리하는 플로팅 UI

프론트엔드를 하다 보면 어느 순간 이런 생각을 하게 됩니다. "버튼 아래에 드롭다운 하나 붙이는 게 왜 이렇게 복잡하지?" 몇 년 전 팀 내 디자인 시스템을 처음 만들 때, Floating UI 셋업과 뷰포트 플립 로직 디버깅에 하루를 통째로 날린 적이 있었습니다. 지금 돌아보면 그 복...

2026년 06월 26일읽는 데 21분
EAA 발효 후 프론트엔드 접근성 점검 체크리스트 15가지 (유럽 접근성법 WCAG 2.1 AA 기준)
frontend

EAA 발효 후 프론트엔드 접근성 점검 체크리스트 15가지 (유럽 접근성법 WCAG 2.1 AA 기준)

2025년 6월 28일, 슬랙에서 흘러온 메시지를 보다가 멈췄습니다. "EAA 발효됐는데, 우리 서비스 괜찮아?" 처음엔 "EU 법이니까 유럽에서 직접 사업하는 회사 얘기겠지" 싶었는데, 역외 적용 조항을 읽고 나서 생각이 달라졌습니다. EU 고객에게 서비스를 제공하는 기업이라면 본사가...

2026년 06월 26일읽는 데 26분
엣지 MFE 오케스트레이션으로 TTFB를 줄이는 Cloudflare Workers 구현법
frontend

엣지 MFE 오케스트레이션으로 TTFB를 줄이는 Cloudflare Workers 구현법

솔직히 처음 마이크로 프론트엔드(MFE) 이야기를 들었을 때, "팀마다 독립 배포가 된다고? 좋아 보이는데, 그럼 사용자 브라우저에서 각자 번들을 다 받아야 하는 거 아냐?" 하는 생각부터 들었습니다. 클라이언트 사이드 컴포지션의 고질적인 문제, 그러니까 여러 MFE의 JS 번들이 순차...

2026년 06월 23일읽는 데 23분