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.ts는 proxy.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.ts가 middleware.ts 대체 | 미들웨어에 인증이랑 리다이렉트랑 리라이트가 다 섞여 있었잖아요. proxy.ts로 네트워크 관심사만 분리해서 경계가 훨씬 깔끔해졌어요. |
| 비동기 Request API | params, 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.json의 paths를 쓰세요. 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가 두 가지 개념 레이어로 분리됐어요:
proxy.ts— 네트워크 레벨 작업(URL 리라이트, 헤더 주입, 지역 라우팅). 기본적으로 Node.js 런타임에서 실행돼요(Edge가 기본이던 기존 middleware와 다름).- 라우트 핸들러의 서버 로직 — 인증 확인, 세션 검증, 비즈니스 로직은 실제 라우트 핸들러, 레이아웃, 서버 액션에 두는 게 맞아요.
기존 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에는 빌트인 캐시 수명 프리셋이 포함돼 있어요:
| 프리셋 | Stale | Revalidate | Expire |
|---|---|---|---|
'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 }); }
마이그레이션 전략
- 페이지에서
export const revalidate = ...전부 제거 fetch(..., { next: { revalidate } })를use cache+cacheLife로 교체unstable_cache()호출을use cache함수로 교체- 타겟 재검증이 필요한 곳에
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 라우트) | 512MB | 0.5 vCPU |
| 중규모 (50-200 라우트) | 1GB | 1 vCPU |
| 대규모 (200+ 라우트) | 2GB | 2 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.jsonpaths가 Turbopack에서 동작 확인 -
npx next build에러 없이 완료
라우팅
-
middleware.ts→proxy.ts마이그레이션 (네트워크 관심사만) - 인증 로직을 middleware에서 layout/route handler로 이동
-
proxy.tsmatcher 패턴 검토
데이터 페칭
- 모든
params,searchParams가Promise<T>+await - 모든
cookies(),headers()호출에await추가 -
export const revalidate→use cache+cacheLife변환 -
next.config.ts에cacheComponents: true활성화 -
unstable_cache→use 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주 |
시간을 가장 많이 잡아먹는 건:
- 커스텀 Webpack 로더 → Turbopack 대체품 찾기
- 복잡한 미들웨어 로직 → proxy + layout 인증으로 분해
- 캐싱 전략 재설계 → 기존
revalidate패턴을use cache로 매핑
마이그레이션 후 기대할 수 있는 것
깨끗하게 마이그레이션하면 보통 이런 결과를 보고해요:
- 개발 서버 시작: 60-80% 빠름 (Turbopack vs Webpack)
- HMR 갱신: 5-10배 빠름 (200ms 이내 유지)
- 프로덕션 빌드: 20-40% 빠름
- TTFB: PPR로 30-50% 개선 (정적 셸 즉시 서빙)
- 메모리 사용: 비슷하거나 약간 높음 (튜닝 필요)
Turbopack이랑 PPR 성능 향상만으로도 삽질한 보람이 충분해요. proxy.ts랑 use cache로 코드 구조가 깔끔해지는 건 앱이 커질수록 체감되는 덤이고요.
Next.js 16은 고집이 세요. 더 나은 패턴을 밀어붙이지만, 개발자한테 먼저 마이그레이션 비용을 요구해요. codemod로 기계적인 건 처리하고, 아키텍처 이해 — proxy 분리, 선언적 캐싱, PPR — 는 직접 챙겨야 해요. 이 글에서 둘 다 정리했으니까, 업그레이드 ㄱㄱ.
관련 도구 둘러보기
Pockit의 무료 개발자 도구를 사용해 보세요