안녕하세요. J4J입니다.
이번 포스팅은 Next.js 16에서 stable로 전환된 React Compiler를 활성화하는 방법과, 기존 useMemo/useCallback/memo로 작성했던 코드를 걷어내는 방법에 대해 알아보는 시간을 가져보려고 합니다.
React Compiler란 무엇인가
React로 리스트나 자식 컴포넌트를 다루다 보면 부모 컴포넌트가 리렌더링될 때마다 관련 없는 자식 컴포넌트까지 함께 리렌더링되는 상황을 자주 마주치게 됩니다.
이 문제를 해결하기 위해 그동안은 useMemo로 계산 결과를 캐싱하고, useCallback으로 함수 참조를 고정하고, memo로 컴포넌트 자체를 감싸는 방식을 직접 작성해야 했습니다.
저의 경우 useCallback의 의존성 배열에 값을 하나 빠뜨려서 예전 상태를 참조하는 stale closure 버그를 겪은 적이 있는데, 이런 실수는 코드가 늘어날수록 반복되기 쉬웠습니다.
그러면 React Compiler는 무엇일까요?
React Compiler는 컴포넌트와 훅 코드를 빌드 시점에 정적으로 분석해서 필요한 곳에 자동으로 메모이제이션을 삽입해 주는 Babel 기반 컴파일러입니다.
즉 useMemo, useCallback, memo를 직접 작성하지 않아도 컴파일러가 코드를 분석해서 동일한 효과를 자동으로 적용해 줍니다.
React Compiler는 2025년 10월 1.0 버전으로 정식 출시되었고, Next.js 16에서는 이를 연동하는 설정이 experimental에서 stable로 승격되었습니다.
다음으로는 Next.js 16 프로젝트에서 실제로 React Compiler를 활성화하는 방법을 살펴보겠습니다.
Next.js 16에서 React Compiler 활성화하기
reactCompiler 설정은 Next.js 16에서 stable로 승격되었을 뿐 기본값은 여전히 false이며, 별도로 설정을 켜지 않으면 React Compiler는 전혀 동작하지 않습니다.
활성화를 위해서는 먼저 babel-plugin-react-compiler 패키지 설치가 필요합니다.
// terminal
$ npm install babel-plugin-react-compiler
reactCompiler는 next.config.ts에서 React Compiler 연동 여부를 켜고 끄는 설정이며, 이 값을 true로 지정하면 Next.js가 빌드 과정에서 babel-plugin-react-compiler를 함께 구동합니다.
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
reactCompiler: true,
}
export default nextConfig
그러므로 기존 프로젝트에 React Compiler를 도입할 때는 패키지 설치와 next.config.ts 설정 두 가지를 함께 진행하는 것이 좋습니다.
추가적으로 Turbopack을 사용하는 프로젝트에서도 별도 예외 처리가 필요한지 궁금하실 수 있습니다.
Turbopack은 webpack 플러그인 API 자체를 지원하지 않지만, Next.js가 React Compiler만을 위해 별도로 구축한 SWC 기반 통합 로직이 관련 파일에만 선택적으로 babel-plugin-react-compiler 트랜스폼을 적용하기 때문에, next dev와 next build 양쪽 모두에서 --webpack 플래그 없이 reactCompiler 설정을 그대로 사용할 수 있습니다.
Turbopack 자체의 전환 배경과 webpack 커스텀 설정 마이그레이션은 Turbopack, Next.js 16 기본 번들러 전환과 webpack 커스텀 설정 마이그레이션을 참고해 주시길 바랍니다.
Turbopack, Next.js 16 기본 번들러 전환과 webpack 커스텀 설정 마이그레이션
이번 포스팅은 Next.js 16에서 Turbopack이 기본 번들러로 자리잡은 배경과, 기존 webpack 커스텀 설정을 Turbopack 설정으로 옮기는 방법에 대해 알아보는 시간을 가져보려고 합니다.
jforj.tistory.com
다음으로는 이렇게 활성화한 뒤 기존 useMemo/useCallback/memo 코드를 실제로 제거하는 방법을 살펴보겠습니다.
기존 useMemo/useCallback/memo 제거하기
count 버튼을 누를 때마다 리렌더링되는 부모 컴포넌트와, count와 무관하게 선택 버튼만 제공하는 자식 컴포넌트로 예시를 살펴보겠습니다.
// app/memo-demo/page.tsx
// ❌ useCallback과 memo로 자식 리렌더링을 직접 방지하는 기존 방식
'use client'
import { useState, useCallback, memo } from 'react'
function ExpensiveChildBase({ onSelect }: { onSelect: (id: number) => void }) {
console.log('ExpensiveChild 렌더링')
return (
<button
className="cursor-pointer rounded border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50"
onClick={() => onSelect(1)}
>
선택
</button>
)
}
const ExpensiveChild = memo(ExpensiveChildBase)
export default function MemoDemoPage() {
const [count, setCount] = useState(0)
const handleSelect = useCallback((id: number) => {
console.log('선택된 id:', id)
}, [])
return (
<div className="flex flex-col gap-4 p-6">
<button
className="cursor-pointer rounded border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50"
onClick={() => setCount(count + 1)}
>
count: {count}
</button>
<ExpensiveChild onSelect={handleSelect} />
</div>
)
}
이렇게 작성하면 count 버튼을 눌러 부모가 리렌더링되어도 ExpensiveChild는 useCallback으로 고정된 동일한 onSelect 참조를 받기 때문에 memo에 의해 리렌더링이 건너뛰어지고, 콘솔에 "ExpensiveChild 렌더링" 로그가 다시 찍히지 않습니다.
다만 이 결과를 얻으려면 useCallback의 의존성 배열을 정확하게 관리해야 하며, 값을 하나라도 빠뜨리면 onSelect가 예전 count 값을 참조하는 stale closure 버그로 이어질 수 있습니다.
React Compiler를 활성화하면 동일한 결과를 useCallback과 memo 없이도 얻을 수 있습니다.
// app/memo-demo/page.tsx
// ✅ React Compiler가 메모이제이션을 자동으로 적용하는 방식
'use client'
import { useState } from 'react'
function ExpensiveChild({ onSelect }: { onSelect: (id: number) => void }) {
console.log('ExpensiveChild 렌더링')
return (
<button
className="cursor-pointer rounded border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50"
onClick={() => onSelect(1)}
>
선택
</button>
)
}
export default function MemoDemoPage() {
const [count, setCount] = useState(0)
const handleSelect = (id: number) => {
console.log('선택된 id:', id)
}
return (
<div className="flex flex-col gap-4 p-6">
<button
className="cursor-pointer rounded border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50"
onClick={() => setCount(count + 1)}
>
count: {count}
</button>
<ExpensiveChild onSelect={handleSelect} />
</div>
)
}
그러므로 React Compiler를 활성화한 프로젝트에서는 useCallback의 의존성 배열이나 memo 래핑을 직접 관리하지 않는 것이 좋습니다.
체감할 수 있는 것 중 하나는, 컴포넌트가 늘어날수록 이런 수동 메모이제이션 코드를 걷어낸 만큼 컴포넌트 본연의 로직만 남아 코드가 훨씬 읽기 편해진다는 점입니다.
다음으로는 이 최적화가 실제로 적용되었는지 확인하는 방법을 살펴보겠습니다.
React Compiler 적용 여부 확인하기
React Compiler가 실제로 컴포넌트를 최적화했는지는 React DevTools에서 확인할 수 있습니다.
개발 모드에서 React DevTools의 Components 탭을 열어보면, 컴파일러가 최적화를 적용한 컴포넌트 이름 옆에 별 모양 아이콘이 붙은 "Memo" 배지가 표시됩니다.
다만 이 배지가 보이지 않는다고 바로 오류로 판단하지 않는 것이 좋은데, 이미 최적화할 여지가 없는 단순한 컴포넌트이거나 아래에서 다룰 Rules of React 위반으로 컴파일러가 해당 컴포넌트를 건너뛴 경우에도 배지가 나타나지 않기 때문입니다.
Rules of React 위반은 런타임에서 바로 드러나지 않는 경우가 많기 때문에 ESLint로 미리 잡아내는 것이 좋습니다.
eslint-plugin-react-hooks는 React Compiler가 전제하는 규칙(Rules of React) 위반을 정적으로 잡아내는 ESLint 플러그인이며, 최신 버전의 recommended-latest 프리셋에 React Compiler 관련 규칙이 통합되어 있습니다.
// terminal
$ npm install eslint-plugin-react-hooks@latest
설치한 플러그인은 eslint.config.mjs에 다음과 같이 등록합니다.
// eslint.config.mjs
import reactHooks from 'eslint-plugin-react-hooks'
export default [
reactHooks.configs.flat['recommended-latest'],
]
그러므로 React Compiler를 도입한다면 eslint-plugin-react-hooks도 최신 버전으로 함께 갱신해서 recommended-latest 프리셋을 적용하는 것이 좋습니다.
다음으로는 이 ESLint 규칙이 실제로 잡아내는 대표적인 위반 사례와, 컴파일러 자체의 제약사항을 살펴보겠습니다.
React Compiler가 최적화하지 못하는 경우와 제약사항
React Compiler는 컴포넌트를 순수 함수로 간주하고 분석하기 때문에, 렌더링 도중 props를 직접 변경하는 코드는 최적화 대상에서 제외됩니다.
// app/product-card/page.tsx, ❌ 렌더링 중 props로 받은 객체를 직접 mutate하는 경우
function ProductCard({ product }: { product: { url: string } }) {
product.url = new URL(product.url, 'https://example.com').toString()
return <a href={product.url}>상세보기</a>
}
이렇게 작성하면 product.url을 직접 변경하는 순간 컴포넌트가 순수 함수가 아니게 되어, 리렌더링마다 결과가 달라질 수 있다고 판단한 컴파일러가 해당 컴포넌트의 메모이제이션을 건너뜁니다.
// app/product-card/page.tsx, ✅ 원본을 변경하지 않고 새 값을 만들어 사용하는 방식
function ProductCard({ product }: { product: { url: string } }) {
const absoluteUrl = new URL(product.url, 'https://example.com').toString()
return <a href={absoluteUrl}>상세보기</a>
}
그러므로 props나 인자로 받은 값은 직접 변경하지 않고, 필요한 값을 새로 만들어 반환하는 방식으로 작성하는 것이 좋습니다.
추가적으로 컴파일러 자체를 특정 컴포넌트에서만 끄고 싶은 경우도 있습니다.
'use no memo'는 함수 본문 최상단에 작성해 해당 컴포넌트나 훅을 React Compiler의 최적화 대상에서 제외하는 directive이며, 다른 import나 문장보다 앞선 첫 줄에 정확히 위치해야 인식됩니다.
// app/legacy-widget/page.tsx, 'use no memo'로 특정 컴포넌트를 최적화 대상에서 제외하는 예시
function LegacyWidget() {
'use no memo'
// 컴파일러가 분석하기 어려운 레거시 로직
return <div>Legacy Widget</div>
}
그러므로 마이그레이션 도중 컴파일러가 의도대로 최적화하지 못하는 특정 컴포넌트가 있다면, 전체 설정을 끄는 대신 'use no memo'로 해당 컴포넌트만 예외 처리하는 것이 좋습니다.
마지막으로 트레이드오프도 함께 알아두는 것이 좋습니다.
React Compiler 1.0 공식 발표에 따르면 초기 로드와 페이지 이동에서 최대 12%, 특정 인터랙션에서는 2.5배 이상의 성능 향상이 있었다고 하며, 메모리 사용량 증가 없이 이런 개선이 이루어졌다고 합니다.
다만 이 수치는 실제 사례 기준이며 프로젝트마다 다를 수 있고, Babel 기반 컴파일 단계가 추가되는 만큼 빌드 시간은 다소 늘어날 수 있다는 점은 감안하는 것이 좋습니다.
이상으로 Next.js 16에서 React Compiler를 활성화하고 기존 메모이제이션 코드를 정리하는 방법에 대해 간단하게 알아보는 시간이었습니다.
읽어주셔서 감사합니다.
'SPA > Next' 카테고리의 다른 글
| Turbopack, Next.js 16 기본 번들러 전환과 webpack 커스텀 설정 마이그레이션 (0) | 2026.08.01 |
|---|---|
| Next.js 16 proxy.ts, middleware.ts를 대체하는 네트워크 경계 재정의 (1) | 2026.07.27 |
| Next.js 16 Cache Components: use cache부터 updateTag까지 캐시 무효화 총정리 (0) | 2026.07.26 |
| React Server Action, API Route 없이 서버 함수를 호출하는 방법 (0) | 2026.07.05 |
| [Next] Next13 이후로 MSW 사용하기 (3) - Storybook에서 사용하기 (1) | 2024.01.09 |
댓글