Back

Next.js 16 마이그레이션 완전 가이드: Turbopack, proxy.ts, Cache Components, 모든 Breaking Change 총정리

CI에서 next^16.0.0으로 올렸더니 빌드가 바로 터졌어요. 에러 로그 400줄, 미들웨어 절반이 깨지고, 난생 처음 보는 proxy.ts가 등장. Next.js 16 업그레이드의 세계에 오신 걸 환영합니다.

Next.js 16은 App Router 이후 가장 큰 판 갈이예요. Webpack이 빠지고 Turbopack만 남았고, middleware.tsproxy.ts로 바뀌었어요. 캐싱은 use cache 하나로 갈아엎었고요. K8s나 Docker에서 돌리고 있다면 메모리 쪽도 까봐야 프로덕션에서 안 터져요.

이 글에서 breaking change를 하나씩 까보고, 왜 바뀌었는지 설명하고, codemod 명령어랑 수동 마이그레이션 경로까지 전부 정리했어요. 작은 사이트든 200개 라우트짜리 앱이든, 이 글 하나면 됩니다.

뭐가 바뀌었고 왜 바뀌었나

마이그레이션 단계에 들어가기 전에, Next.js 16이 뭘 바꿨고 각 변경의 이유가 뭔지 큰 그림부터 봅시다:

┌─────────────────────────────────────────────────────────────┐
│                    Next.js 16 아키텍처                        │
│                                                              │
│  ┌──────────────┐  ┌──────────────┐  ┌─────────────────┐   │
│  │  Turbopack    │  │  proxy.ts    │  │  Cache           │   │
│  │  (유일한      │  │  (middleware │  │  Components      │   │
│  │   번들러)     │  │   대체)      │  │  ('use cache')   │   │
│  └──────┬───────┘  └──────┬───────┘  └────────┬────────┘   │
│         │                  │                    │             │
│  ┌──────┴───────┐  ┌──────┴───────┐  ┌────────┴────────┐   │
│  │  10배 빠른   │  │  명확한      │  │  선언적          │   │
│  │  HMR & 빌드  │  │  네트워크    │  │  캐싱 + 세밀한   │   │
│  │              │  │  경계 분리   │  │  TTL 제어        │   │
│  └──────────────┘  └──────────────┘  └──────────────────┘   │
│                                                              │
│  ┌──────────────┐  ┌──────────────┐                         │
│  │  비동기       │  │  PPR         │                         │
│  │  Request API │  │  (기본값)    │                         │
│  │  (params,    │  │              │                         │
│  │   cookies,   │  │              │                         │
│  │   headers)   │  │              │                         │
│  └──────────────┘  └──────────────┘                         │
└─────────────────────────────────────────────────────────────┘
변경 사항이유
Turbopack이 Webpack 대체Webpack이 모듈 500개만 넘어도 HMR이 버벅댔어요. Turbopack은 모듈 만 개 넘어도 200ms 안에 갱신이 끝나요.
proxy.tsmiddleware.ts 대체미들웨어에 인증이랑 리다이렉트랑 리라이트가 다 섞여 있었잖아요. proxy.ts로 네트워크 관심사만 분리해서 경계가 훨씬 깔끔해졌어요.
비동기 Request APIparams, searchParams, cookies(), headers()가 전부 async가 됐어요. 스트리밍 성능이 좋아지고 RSC 렌더링에서 암묵적 블로킹이 사라져요.
Cache Components (use cache)revalidate, unstable_cache, cache() 뭘 써야 하는지 매번 헷갈렸잖아요. use cache 하나로 통일. 선언적이고 조합 가능해요.
PPR 기본값이제 SSR이냐 SSG냐 안 골라도 돼요. 정적 셸 즉시 서빙 + 동적 부분은 스트리밍으로 알아서 채워줘요.

Step 0: 시작하기 전에

1. 현재 메트릭 기록

코드 건드리기 전에 지금 성능부터 찍어둬야 해요:

# 성능 베이스라인 npx next build 2>&1 | tee build-before.log npx @next/bundle-analyzer # 런타임 메트릭 lighthouse https://your-app.com --output json --output-path baseline.json

2. Node.js 호환성 확인

Next.js 16은 Node.js 20.x 이상이 필요해요. Node 18은 더 이상 지원 안 합니다.

node -v # v20.0.0 이상이어야 함

3. 업그레이드 명령 실행

npx @next/codemod@latest upgrade

자동 마이그레이션의 상당 부분을 처리해줘요. 하지만 다 잡지는 못합니다. 나머지는 이 가이드에서 다룰게요.

Step 1: Turbopack — Webpack은 더 이상 기본이 아니다

제일 티나는 변경. 이제 dev든 build든 Turbopack이 기본이에요. next.config.ts에서 webpack 키는 deprecated 처리됐지만, 당장 호환 안 되는 로더가 있으면 next dev --webpack이나 next build --webpack으로 돌릴 순 있어요. 임시 탈출구니까 결국엔 떼는 게 목표예요.

뭐가 깨지나

next.config.ts에 이런 게 있다면 다 실패해요:

// ❌ Next.js 16에서 더 이상 동작 안 함 module.exports = { webpack: (config) => { config.resolve.alias['@'] = path.resolve(__dirname, 'src'); config.module.rules.push({ test: /\.svg$/, use: ['@svgr/webpack'], }); return config; }, };

마이그레이션 방법

별칭(alias) 설정: tsconfig.jsonpaths를 쓰세요. Turbopack이 네이티브로 인식해요:

// tsconfig.json { "compilerOptions": { "paths": { "@/*": ["./src/*"] } } }

SVG 임포트: @svgr/webpack 대신 @svgr/turbopack 사용:

npm install @svgr/turbopack
// next.config.ts import type { NextConfig } from 'next'; const nextConfig: NextConfig = { turbopack: { rules: { '*.svg': { loaders: ['@svgr/turbopack'], as: '*.js', }, }, }, }; export default nextConfig;

다른 커스텀 로더들: Turbopack 호환 버전이 있는지 확인하세요. CSS 모듈, SASS, 이미지 최적화 같은 인기 로더는 Turbopack에 빌트인이에요. 나머지는 turbopack.rules 설정으로 해결할 수 있어요.

검증

# 개발 서버 npx next dev # 에러 없이 시작되면 Turbopack 정상 동작 # 프로덕션 빌드 npx next build

빌드 중에 이전에 없던 Module not found 에러가 보인다면 십중팔구 로더 호환성 이슈예요. Turbopack 호환성 테이블에서 사용 중인 로더를 확인하세요.

Step 2: middleware.ts → proxy.ts

가장 혼란을 유발하는 변경이에요. 기존 middleware.ts가 두 가지 개념 레이어로 분리됐어요:

  1. proxy.ts — 네트워크 레벨 작업(URL 리라이트, 헤더 주입, 지역 라우팅). 기본적으로 Node.js 런타임에서 실행돼요(Edge가 기본이던 기존 middleware와 다름).
  2. 라우트 핸들러의 서버 로직 — 인증 확인, 세션 검증, 비즈니스 로직은 실제 라우트 핸들러, 레이아웃, 서버 액션에 두는 게 맞아요.

기존 middleware.ts는 이랬죠

// ❌ 기존: middleware.ts (Next.js 15) import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; export function middleware(request: NextRequest) { // 인증 체크 const token = request.cookies.get('session-token'); if (!token && request.nextUrl.pathname.startsWith('/dashboard')) { return NextResponse.redirect(new URL('/login', request.url)); } // 로케일 감지 const locale = request.headers.get('Accept-Language')?.split(',')[0] || 'en'; const response = NextResponse.next(); response.headers.set('x-locale', locale); // A/B 테스트 라우팅 const bucket = Math.random() > 0.5 ? 'a' : 'b'; response.headers.set('x-ab-bucket', bucket); return response; } export const config = { matcher: ['/((?!api|_next/static|favicon.ico).*)'], };

새로운 proxy.ts

// ✅ 새 방식: proxy.ts (Next.js 16) import type { NextRequest } from 'next/server'; export function proxy(request: NextRequest) { const url = request.nextUrl.clone(); // 로케일 감지 (네트워크 레벨 관심사) const locale = request.headers.get('Accept-Language')?.split(',')[0] || 'en'; // A/B 테스트 라우팅 (네트워크 레벨 관심사) const bucket = Math.random() > 0.5 ? 'a' : 'b'; return { headers: { 'x-locale': locale, 'x-ab-bucket': bucket, }, }; } export const config = { matcher: ['/((?!api|_next/static|favicon.ico).*)'], };

인증은 어디로 가나?

인증 체크는 그걸 보호하는 라우트의 레이아웃이나 라우트 핸들러로 옮기는 게 맞아요:

// app/dashboard/layout.tsx import { redirect } from 'next/navigation'; import { getSession } from '@/lib/auth'; export default async function DashboardLayout({ children, }: { children: React.ReactNode; }) { const session = await getSession(); if (!session) { redirect('/login'); } return <>{children}</>; }

솔직히 이게 맞는 구조예요. 인증 로직이 보호할 라우트 바로 옆에 있으니까 "어디서 막히는 거지?" 삽질할 일이 없어요. 글로벌 미들웨어에 숨겨둔 것보다 백만 배 나아요.

Codemod

npx @next/codemod@latest middleware-to-proxy

단순한 헤더/리라이트 패턴은 처리해주지만, 인증 로직은 자동으로 옮겨주지 않아요. 결과물을 꼭 직접 확인하세요.

Step 3: 비동기 Request API

모든 동적 요청 API가 이제 완전히 비동기예요. 파일 수 기준으로 영향이 제일 큰 변경이에요. params, search params, cookies, headers에 접근하는 모든 페이지, 레이아웃, 라우트 핸들러를 수정해야 해요.

Before (Next.js 15)

// ❌ 동기 접근 더 이상 안 됨 export default function Page({ params, searchParams, }: { params: { slug: string }; searchParams: { q?: string }; }) { const title = params.slug; const query = searchParams.q; return <div>{title} - {query}</div>; }

After (Next.js 16)

// ✅ 모든 Request API가 비동기 export default async function Page({ params, searchParams, }: { params: Promise<{ slug: string }>; searchParams: Promise<{ q?: string }>; }) { const { slug } = await params; const { q } = await searchParams; return <div>{slug} - {q}</div>; }

cookies()와 headers()

// ❌ Before import { cookies, headers } from 'next/headers'; export default function Page() { const cookieStore = cookies(); const headerList = headers(); // ... } // ✅ After import { cookies, headers } from 'next/headers'; export default async function Page() { const cookieStore = await cookies(); const headerList = await headers(); // ... }

Codemod

npx @next/codemod@latest async-request-apis

이 codemod는 성공률이 높아요(~90%). 함수에 async 붙이고, await 감싸고, 타입 시그니처까지 업데이트해줘요. 결과물은 꼭 확인하세요. params를 다른 유틸 함수에 패스스루하는 경우에 가끔 놓치거든요.

Step 4: Cache Components와 use cache 디렉티브

개념적으로 제일 큰 변화예요. 캐싱 세팅할 때 revalidate 쓸까, unstable_cache 쓸까, fetch 옵션 쓸까 매번 고민했잖아요. 그 난장판이 use cache 하나로 정리됐어요.

예전 방식 (헷갈림)

// ❌ Next.js 15: 여러 캐싱 메커니즘이 겹쳐서 혼란 export const revalidate = 3600; // 페이지 레벨 재검증 async function getData() { const res = await fetch('https://api.example.com/data', { next: { revalidate: 60, tags: ['data'] }, }); return res.json(); } // 거기에: unstable_cache, cache(), generateStaticParams까지...

새로운 방식 (선언적)

먼저 config에서 Cache Components를 활성화해야 해요:

// next.config.ts const nextConfig: NextConfig = { cacheComponents: true, // 'use cache' 사용에 필수 };

그다음 디렉티브를 사용하면 돼요:

// ✅ Next.js 16: 단일 'use cache' 디렉티브 import { cacheLife, cacheTag } from 'next/cache'; // 함수 레벨 캐싱 async function getData() { 'use cache'; cacheLife('hours'); cacheTag('data'); const res = await fetch('https://api.example.com/data'); return res.json(); } // 컴포넌트 레벨 캐싱 async function ExpensiveWidget() { 'use cache'; cacheLife('days'); cacheTag('widget'); const data = await getData(); return <div>{data.title}</div>; } // 페이지 레벨 캐싱 export default async function Page() { 'use cache'; cacheLife('minutes'); return ( <main> <ExpensiveWidget /> <DynamicContent /> </main> ); }

Cache Life 프리셋

Next.js 16에는 빌트인 캐시 수명 프리셋이 포함돼 있어요:

프리셋StaleRevalidateExpire
'seconds'0초1초60초
'minutes'5분1분1시간
'hours'5분1시간24시간
'days'5분1일14일
'weeks'5분1주30일
'max'5분30일365일

커스텀 프로필도 정의 가능해요:

// next.config.ts const nextConfig: NextConfig = { cacheLife: { product: { stale: 300, // 5분 revalidate: 3600, // 1시간 expire: 86400, // 1일 }, }, };
// 이렇게 사용: async function getProduct(id: string) { 'use cache'; cacheLife('product'); cacheTag(`product-${id}`); return db.products.findById(id); }

재검증 (Revalidation)

태그 기반 재검증은 같은 방식이지만, 어떤 단위에서든 태그를 달 수 있어서 훨씬 세밀해졌어요:

// app/api/revalidate/route.ts import { revalidateTag } from 'next/cache'; export async function POST(request: Request) { const { tag } = await request.json(); revalidateTag(tag); return Response.json({ revalidated: true }); }

마이그레이션 전략

  1. 페이지에서 export const revalidate = ... 전부 제거
  2. fetch(..., { next: { revalidate } })use cache + cacheLife로 교체
  3. unstable_cache() 호출을 use cache 함수로 교체
  4. 타겟 재검증이 필요한 곳에 cacheTag() 추가

Step 5: PPR(Partial Prerendering) 기본값

PPR이 이제 기본 렌더링 전략이에요. 모든 페이지가 자동으로 즉시 서브되는 정적 셸을 갖고, 동적 콘텐츠는 Suspense 경계를 통해 스트리밍돼요.

실제로 어떻게 동작하나

// Next.js 16에서 이 페이지는 자동으로 PPR 사용 export default async function ProductPage({ params, }: { params: Promise<{ id: string }>; }) { const { id } = await params; return ( <main> {/* 정적 셸 — CDN에서 서빙 */} <Header /> <ProductInfo id={id} /> {/* 동적 콘텐츠 — 스트리밍 */} <Suspense fallback={<PriceSkeleton />}> <DynamicPrice id={id} /> </Suspense> <Suspense fallback={<ReviewsSkeleton />}> <UserReviews id={id} /> </Suspense> </main> ); }

핵심은 이거예요. use cache 붙인 컴포넌트 → 정적 셸. 쿠키나 헤더 같은 동적 데이터 쓰는 컴포넌트 → 스트리밍으로 나중에 채워지는 구멍. 이 구분만 이해하면 PPR은 끝이에요.

PPR을 끄고 싶다면

// next.config.ts const nextConfig: NextConfig = { experimental: { ppr: false, // PPR 전역 비활성화 }, };

라우트별 비활성화도 가능:

// app/legacy-page/page.tsx export const dynamic = 'force-dynamic'; // 이 페이지는 PPR 안 씀

Step 6: 컨테이너 메모리 최적화

여기서 은근 당해요. Next.js 16 RSC 렌더링이 v15보다 메모리를 확 더 먹거든요. 컨테이너에서 메모리 빡빡하게 잡아놓으면 OOM으로 파드가 죽어요.

문제

Kubernetes나 Docker에서 메모리 제한(예: 파드당 512MB)으로 운영할 때, Next.js 15에서는 안 나던 OOM kill이 발생할 수 있어요. 원인은 Turbopack의 인메모리 모듈 그래프와 RSC 렌더링 엔진이 더 많은 중간 상태를 유지하기 때문이에요.

해결 방법

// next.config.ts const nextConfig: NextConfig = { // Turbopack 메모리 사용량 제한 turbopack: { memoryLimit: 256 * 1024 * 1024, // 256MB }, // 프로덕션용 증분 캐시 핸들러 experimental: { incrementalCacheHandlerPath: './cache-handler.mjs', }, };
// cache-handler.mjs import { CacheHandler } from '@next/cache-handler-redis'; export default class CustomCacheHandler extends CacheHandler { constructor(options) { super({ ...options, redis: { url: process.env.REDIS_URL, }, // 프로세스 메모리에 캐시 엔트리 유지하지 않음 inMemoryCacheEnabled: false, }); } }

컨테이너 리소스 권장 사항

앱 규모권장 메모리권장 CPU
소규모 (<50 라우트)512MB0.5 vCPU
중규모 (50-200 라우트)1GB1 vCPU
대규모 (200+ 라우트)2GB2 vCPU

모니터링:

# 빌드 중 메모리 사용량 모니터링 docker stats --format "table {{.Name}}\t{{.MemUsage}}\t{{.MemPerc}}" # 런타임 Node.js 메모리 프로파일링 NODE_OPTIONS="--max-old-space-size=1024 --heapsnapshot-near-heap-limit=3" npm start

Step 7: next.config.ts 변경 사항

여러 설정 옵션이 이름 변경되거나 구조가 바뀌었어요:

// next.config.ts — Next.js 16 전체 설정 import type { NextConfig } from 'next'; const nextConfig: NextConfig = { // ✅ Turbopack 설정 (webpack 설정 대체) turbopack: { rules: { '*.svg': { loaders: ['@svgr/turbopack'], as: '*.js', }, }, resolveAlias: { // tsconfig paths로 부족할 때 커스텀 별칭 'legacy-lib': './src/lib/legacy-adapter', }, }, // ✅ Cache life 프로필 cacheLife: { product: { stale: 300, revalidate: 3600, expire: 86400 }, blog: { stale: 60, revalidate: 900, expire: 86400 }, }, // ✅ 이미지 최적화 (거의 변경 없음) images: { remotePatterns: [ { protocol: 'https', hostname: '**.example.com' }, ], }, // ✅ 리다이렉트/리라이트 (같은 API) async redirects() { return [ { source: '/old-path', destination: '/new-path', permanent: true }, ]; }, }; export default nextConfig;

제거된 옵션들

next.config.ts 옵션들은 더 이상 존재하지 않아요:

// ❌ Next.js 16에서 전부 제거됨 { webpack: () => {}, // turbopack.rules 사용 swcMinify: true, // 항상 켜짐 (Turbopack) experimental: { appDir: true, // v14부터 항상 켜짐 serverActions: true, // v15부터 항상 켜짐 typedRoutes: true, // 항상 켜짐 }, }

마이그레이션 체크리스트 총정리

codemod 실행 후 이 체크리스트를 쭉 확인하세요:

인프라

  • Node.js >= 20.x 설치됨
  • next^16.0.0으로 업그레이드
  • react, react-dom^19.2.0으로 업그레이드
  • 모든 @next/* 패키지 버전 맞춤
  • 컨테이너 메모리 제한 검토 (512MB 미만이면 증가)

번들러

  • next.config.ts에서 webpack 설정 전부 제거
  • 커스텀 로더를 turbopack.rules로 마이그레이션
  • tsconfig.json paths가 Turbopack에서 동작 확인
  • npx next build 에러 없이 완료

라우팅

  • middleware.tsproxy.ts 마이그레이션 (네트워크 관심사만)
  • 인증 로직을 middleware에서 layout/route handler로 이동
  • proxy.ts matcher 패턴 검토

데이터 페칭

  • 모든 params, searchParamsPromise<T> + await
  • 모든 cookies(), headers() 호출에 await 추가
  • export const revalidateuse cache + cacheLife 변환
  • next.config.tscacheComponents: true 활성화
  • unstable_cacheuse cache 함수로 교체
  • 타겟 재검증 위해 cacheTag() 추가

렌더링

  • PPR 동작을 Suspense 경계로 확인
  • 동적 콘텐츠용 스켈레톤 컴포넌트 추가
  • PPR 원치 않는 곳에서 dynamic = 'force-dynamic' 테스트

프로덕션

  • 빌드 시간 벤치마크 (Turbopack으로 개선 확인)
  • 프로덕션 트래픽 패턴으로 부하 테스트
  • 컨테이너 메모리 사용량 24시간 모니터링
  • 프로덕션 캐시 히트율 확인

실전 마이그레이션 타임라인

다양한 규모의 팀에서 프로덕션 마이그레이션한 경험치 기반:

앱 규모Codemod 커버리지수동 작업총 소요 시간
소규모 (<50 라우트)~85%1-2일3-4일
중규모 (50-200 라우트)~75%3-5일1-2주
대규모 (200+ 라우트)~60%1-2주3-4주

시간을 가장 많이 잡아먹는 건:

  1. 커스텀 Webpack 로더 → Turbopack 대체품 찾기
  2. 복잡한 미들웨어 로직 → proxy + layout 인증으로 분해
  3. 캐싱 전략 재설계 → 기존 revalidate 패턴을 use cache로 매핑

마이그레이션 후 기대할 수 있는 것

깨끗하게 마이그레이션하면 보통 이런 결과를 보고해요:

  • 개발 서버 시작: 60-80% 빠름 (Turbopack vs Webpack)
  • HMR 갱신: 5-10배 빠름 (200ms 이내 유지)
  • 프로덕션 빌드: 20-40% 빠름
  • TTFB: PPR로 30-50% 개선 (정적 셸 즉시 서빙)
  • 메모리 사용: 비슷하거나 약간 높음 (튜닝 필요)

Turbopack이랑 PPR 성능 향상만으로도 삽질한 보람이 충분해요. proxy.tsuse cache로 코드 구조가 깔끔해지는 건 앱이 커질수록 체감되는 덤이고요.

Next.js 16은 고집이 세요. 더 나은 패턴을 밀어붙이지만, 개발자한테 먼저 마이그레이션 비용을 요구해요. codemod로 기계적인 건 처리하고, 아키텍처 이해 — proxy 분리, 선언적 캐싱, PPR — 는 직접 챙겨야 해요. 이 글에서 둘 다 정리했으니까, 업그레이드 ㄱㄱ.

Next.jsNext.js 16마이그레이션TurbopackReactTypeScript웹 개발프론트엔드proxycache componentsPPR

관련 도구 둘러보기

Pockit의 무료 개발자 도구를 사용해 보세요