Next.js v14 → v15 마이그레이션 작업기
1. 도입부 (Why This Matters)
메이저 버전 업그레이드는 보통 하나씩 한다. Next.js 먼저 올리고, 안정화되면 React, 그다음 Node. 교과서적이고 안전하다. 그런데 우리는 Next.js 14→15, React 18→19, Node.js 20→24, ESLint 8→9를 단일 PR로 동시에 올렸다. 88개 파일이 한 번에 바뀌었고, 프로덕션 에러율은 0%를 유지했다.
이게 무모해 보인다면 정확한 직관이다. 다만 이 네 축은 서로 강하게 결합돼 있어서 따로 떼면 오히려 "중간 상태"가 더 위험해진다. React 19 타입은 Next.js 15가 요구하고, Next.js 15의 비동기 API는 Node 런타임과 맞물리고, ESLint 9는 그 위에서 새 코드를 검증한다. 절반만 올린 브랜치를 며칠씩 들고 있는 것 자체가 리스크였다.
이 글은 "동시에 올려도 된다"는 주장이 아니라, 무엇을 한 번에 묶고 무엇을 회피·고정(pin)했는지에 대한 결정 기록이다. 읽고 나면 본인 프로젝트의 메이저 업그레이드에서 "이건 묶고, 이건 핀으로 우회한다"를 스스로 판단할 수 있게 되는 게 목표다.
읽는 시간: 약 8분.
⚠️ 버전 고지: 이 글의 마이그레이션은 2026년 1월 Next.js 15.5 기준으로 수행됐다. 작성 시점(2026-06) 기준 최신 stable은 Next.js 16.2이며, 16부터는 Turbopack이 기본 번들러이고 React Compiler가 1.0 stable로 포함된다. 본문 마지막에서 이 방향성을 따로 다룬다.
2. 핵심 개념 (What & Why)
왜 "동시"가 오히려 안전할 수 있나
메이저 업그레이드를 순차로 하면 각 단계마다 "반쪽짜리 호환 상태"가 만들어진다. 예를 들어 React만 19로 올리고 Next.js를 14에 두면, Next.js 14는 React 19의 비동기 params를 전제하지 않으므로 타입과 런타임이 어긋난 채로 며칠을 버텨야 한다. 이 중간 상태는 정식 호환 매트릭스에 존재하지 않는 조합이라, 버그가 나도 "이게 React 탓인지 Next 탓인지" 분리가 안 된다.
반대로 강하게 결합된 축들을 한 번에 올리면, 검증해야 할 조합이 "확정된 최종 상태 하나"로 줄어든다. 핵심은 결합도가 높은 것은 묶고, 결합도가 낮거나 리스크가 큰 것은 외부로 밀어내는 것이다. 우리는 다음을 외부로 밀어냈다.
- 디자인 시스템 호환 → 사내 디자인 시스템 패키지를 React 19 호환 canary 버전으로 선반영
- 서드파티 타입 충돌 →
overrides로 타입을 강제 주입 - React Compiler 도입 → 이번 PR에서 제외, 정책만 설계 (부록 참조)
- Turbopack
next build→ 이번 PR에서는 dev에만 적용, build 전환은 보류 (5장 판단표 참조)
즉 "한 PR로 동시에"는 모든 걸 한꺼번에 한다는 뜻이 아니라, 코어는 원자적으로 묶고 주변부 비호환은 회피 전략으로 격리한다는 뜻이다.
유사 개념과의 차이
흔히 말하는 "빅뱅 마이그레이션"과 다르다. 빅뱅은 "전부 한 번에"지만, 여기서는 코어 4축만 원자적이고 나머지는 의도적으로 분리했다. 점진적 마이그레이션(strangler fig)과도 다르다. 점진적 방식은 결합도 높은 프레임워크 코어에는 적용하기 어렵기 때문이다.
3. 동작 원리 (How It Works)
결합 구조와 작업 분해

#1이 모든 작업의 선행 조건이다. 일단 코어 버전을 올리면 #2~#6은 서로 독립적으로 병렬 가능하고, #7(CI/CD)은 #5(설정)에, #8(디자인 시스템)은 #3(타입)에만 의존한다. 실제로는 단일 PR로 통합 배포했지만, 내부적으로는 이 의존 그래프대로 진행해야 충돌 지점을 예측할 수 있다.
어디서 무엇이 깨지는가 — 레벨별 분해
| 레벨 | 깨지는 지점 | 원인 |
|---|---|---|
| 런타임(서버) | cookies(), headers()가 동기 → 비동기 |
Next.js 15가 동적 API를 Promise로 전환 (요청 단위 캐싱·스트리밍 최적화 목적) |
| 라우팅 | params / searchParams가 Promise |
동일 — 동적 데이터의 지연 평가 |
| 미들웨어 | NextRequest.headers가 읽기 전용 |
요청 헤더 불변성 강화 |
| 타입(컴파일) | RefObject<T> → RefObject<T | null> |
React 19의 ref 제네릭 변경 |
| 의존성 | 서드파티의 @types/react 18 고정 |
일부 라이브러리가 React 19 타입 미반영 |
핵심은 런타임 깨짐(비동기 API)과 컴파일 깨짐(타입)이 서로 다른 종류의 문제라는 점이다. 비동기 API는 await를 빠뜨리면 런타임에 조용히 틀린 값을 반환할 수 있어 더 위험하고, 타입 충돌은 빌드에서 즉시 멈추므로 오히려 안전하다. 그래서 비동기 API 전환을 가장 신중하게 다뤘다.
4. 실무 적용 (Practical Examples)
아래 코드는 실제 마이그레이션 변경분을 일반화한 예시다(핵심 패턴만 표시).
4-1. 비동기 Dynamic API — cookies() / headers()
// lib/auth/auth-server.ts
import { cookies, headers } from 'next/headers';
// ❌ Before (Next.js 14) — 동기 함수
export function isAuthenticated(): boolean {
const cookieStore = cookies();
const authToken = cookieStore.get('SESSION_TOKEN')?.value;
return authToken !== undefined;
}
// ✅ After (Next.js 15) — async + await
export async function isAuthenticated(): Promise<boolean> {
const cookieStore = await cookies(); // cookies()가 Promise 반환
const authToken = cookieStore.get('SESSION_TOKEN')?.value;
return authToken !== undefined;
}
export async function getNextUrl(): Promise<string> {
const headerList = await headers(); // headers()도 동일
const xPathname = headerList.get('x-next-url');
return xPathname ?? `${process.env.APP_DOMAIN}/`;
}
이 변경의 진짜 함정은 함수 시그니처가 Promise로 바뀌는 순간 이 함수를 호출하는 모든 곳이 연쇄적으로 async가 되어야 한다는 것이다. 미들웨어를 보자.
// middleware.ts
// ❌ Before — 동기 호출
const authMiddleware = (req: NextRequest) => {
if (!isAuthenticated()) {
/* 리다이렉트 */
}
return intlMiddleware(req);
};
// ✅ After — async 전파 + 읽기 전용 헤더 우회
const authMiddleware = async (req: NextRequest) => {
if (!(await isAuthenticated())) {
/* 리다이렉트 */
}
return intlMiddleware(req);
};
export default async function middleware(req: NextRequest) {
const { pathname, search } = req.nextUrl;
const nextUrl = createRedirectUrl(pathname, search);
// ⚠️ NextRequest.headers는 읽기 전용 → 직접 .set() 불가
// 새 Headers를 만들어 복사 후 수정해야 한다
const requestHeaders = new Headers(req.headers);
requestHeaders.set('x-next-url', nextUrl);
// ... 분기 ...
return await authMiddleware(req); // 호출부도 await
}
🔍 실행 결과: await를 빠뜨리면 isAuthenticated()가 Promise<boolean> 객체를 반환하고, 객체는 항상 truthy라서 if (!isAuthenticated())가 언제나 false가 된다. 즉 미인증 사용자가 보호 페이지를 통과한다. 빌드는 통과하지만 인증이 뚫리는 사일런트 버그라, 이 부분은 라우트별로 수동 점검했다.
4-2. searchParams / params → Promise
서버 컴포넌트와 클라이언트 컴포넌트의 처리 방식이 다르다. 이게 React 19의 use() Hook이 빛을 보는 지점이다.
// ✅ 서버 컴포넌트 — await로 푼다
interface PageProps {
searchParams: Promise<{ documentId: string }>;
}
export async function generateMetadata({ searchParams }: PageProps): Promise<Metadata> {
const { documentId } = await searchParams; // await
// ...
}
export default async function Page({ searchParams }: PageProps) {
const { documentId } = await searchParams; // await
// ...
}
// ✅ 클라이언트 컴포넌트 — use() Hook으로 푼다 (async 불가하므로)
'use client';
import { use, useState } from 'react';
interface PageProps {
searchParams: Promise<{ initIndex: number; size: 'sm' | 'md' | 'lg' }>;
}
export default function Page(props: PageProps) {
const searchParams = use(props.searchParams); // ← Promise를 use()로 언랩
const { initIndex, size } = searchParams;
const [data, setData] = useState<Record<string, unknown>>();
// ...
}
❌ Anti-Pattern: 클라이언트 컴포넌트를 억지로 async function으로 만들려는 시도. 클라이언트 컴포넌트는 async가 될 수 없다. Promise prop은 반드시 use()로 풀어야 한다. 이 구분(서버=await / 클라이언트=use())을 명확히 두지 않으면 페이지마다 헤매게 된다. 참고로 Next.js 기본 ESLint 설정의 @next/next/no-async-client-component 규칙이 이 실수를 잡아주므로, 뒤에서 다룰 Flat Config 전환은 이런 마이그레이션 사고의 안전장치이기도 하다. 영향 범위는 7개 라우트 페이지 + 1개 레이아웃이었다.
4-3. React 19 타입 — RefObject<T | null>
// ❌ Before (React 18)
positionRef: React.RefObject<HTMLDivElement>;
// ✅ After (React 19) — 제네릭에 | null 명시
positionRef: React.RefObject<HTMLDivElement | null>;
React 19는 useRef의 초기값과 제네릭 정합을 강화하면서 RefObject<T>를 RefObject<T | null>로 바꿨다. 15개 이상 컴포넌트에서 ref 타입 선언을 일괄 수정했다. 이건 컴파일 에러로 전부 잡히므로 tsc --noEmit을 가이드 삼아 기계적으로 처리할 수 있다 — 위험하지 않은 종류의 깨짐이다.
4-4. 서드파티 타입 충돌 회피 — overrides
가장 실용적인 트릭. 일부 서드파티 라이브러리가 내부적으로 @types/react@18을 끌고 오면서 React 19 타입과 충돌했다. 라이브러리 업데이트를 기다리는 대신 타입을 강제로 통일했다.
# pnpm-workspace.yaml (pnpm 11+ 기준)
overrides:
'@types/react': ^19
'@types/react-dom': ^19
# 특정 패키지가 오래된 React 타입을 끌고 올 때만 좁혀서 강제
'some-legacy-lib>@types/react': ^19
💡 버전 위치 참고: pnpm 11부터는
overrides를pnpm-workspace.yaml에서 읽으며,package.json의pnpm필드는 더 이상 읽지 않는다. pnpm 10 이하 프로젝트라면 기존처럼package.json의"pnpm": { "overrides": { ... } }에 둔다.
루트에 박은 버전을 자식 의존성 전체로 전파하고 싶을 때는, 버전 문자열을 다시 쓰는 대신 루트 의존성을 참조하는 $ 표기를 쓸 수 있다.
// (pnpm 10 이하) package.json — $ 참조로 루트 버전 계승
{
"pnpm": {
"overrides": {
"@types/react": "$@types/react",
"@types/react-dom": "$@types/react-dom"
}
}
}
"$@types/react"는 "루트 dependencies/devDependencies에 선언된 @types/react 버전을 그대로 자식 트리에 계승하라"는 의미다. 버전 숫자를 한 곳(루트)에서만 관리하게 되므로, 숫자를 양쪽에 중복으로 적고 어긋나는 실수를 막는다.
이렇게 하면 의존성 트리 전체에서 @types/react가 단일 버전으로 평탄화된다. 사내 디자인 시스템은 한발 더 나아가 React 19 호환을 미리 반영한 canary 버전을 핀으로 고정했다. "정식 릴리스를 기다린다"가 아니라 "호환 버전을 선반영하고 핀으로 박는다"가 핵심 회피 전략이었다.
// canary 의존성은 범위(^)가 아니라 exact pin으로
{
"dependencies": {
"@internal/design-system": "0.1.7-canary.2"
}
}
canary 같은 prerelease는 semver 범위 매칭에서 쉽게 누락되거나 예상치 못한 버전으로 점프할 수 있으므로, 정확한 버전 핀이 더 예측 가능하다.
4-5. ESLint 9 Flat Config
.eslintrc.json은 ESLint 9에서 지원이 끊긴다. 다만 next/core-web-vitals 같은 기존 공유 설정은 아직 레거시 포맷이라, FlatCompat 브릿지로 감싸 점진 전환했다.
// eslint.config.mjs
import { dirname } from 'path';
import { fileURLToPath } from 'url';
import { FlatCompat } from '@eslint/eslintrc';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({ baseDirectory: __dirname });
const eslintConfig = [
// 레거시 공유 설정을 FlatCompat로 브릿지
...compat.extends('next/core-web-vitals', 'next/typescript', 'prettier'),
{
rules: {
'@typescript-eslint/no-explicit-any': 'warn',
'react/react-in-jsx-scope': 'off', // React 19: 자동 JSX 런타임
'prefer-const': 'error',
eqeqeq: ['error', 'smart'],
},
},
{ ignores: ['.next/**', 'out/**', 'build/**', 'next-env.d.ts'] },
];
export default eslintConfig;
⚠️ 함정: ESLint 9 Flat Config 전환은 설정 파일 이름만 바꾸는 일이 아니다. 아직 v9 rule API를 따르지 않은 플러그인에서는
context.getScope is not a function같은 오류가 날 수 있다. 이런 경우FlatCompat같은 호환 레이어로 감싸거나 구성을 재배치해야 한다.
4-6. Turbopack SVGR 호환 (dev 한정)
이번 PR에서 Turbopack은 dev에만 적용했고 next build는 webpack 경로를 유지했다(전환 보류 이유는 5장 판단표 참조). Turbopack은 webpack 로더 설정을 그대로 읽지 않으므로, SVG를 컴포넌트로 쓰던 SVGR 설정을 turbopack.rules로 별도 선언하고, webpack 빌드 경로도 폴백으로 함께 유지했다.
// next.config.mjs
const nextConfig = {
turbopack: {
rules: {
'*.svg': { loaders: ['@svgr/webpack'], as: '*.js' },
},
},
// next build는 아직 webpack 경로 → 동일 로더를 중복 선언해 폴백 유지
webpack: (config) => {
config.module.rules.push({ test: /\.svg$/i, use: ['@svgr/webpack'] });
return config;
},
};
5. 장단점 및 고려사항
무엇을 묶고 무엇을 canary로 미룰까 — 판단 기준표
이번 사례의 결정을 일반화하면, 새 프로젝트에서도 그대로 쓸 수 있는 판단표가 된다. 기준은 단순하다. 타입·런타임·린트 경계가 서로 맞물리는 것은 한 번에 묶고, 지원 단계가 beta/experimental이거나 운영 환경 편차가 큰 것은 canary로 분리한다.
| 판단 항목 | 한 번에 묶기 좋은 경우 | canary로 미루기 좋은 경우 | 이번 사례의 권고 |
|---|---|---|---|
| Next 15 + React 19 | App Router, 타입 정렬, async API 정리가 함께 움직일 때 | Pages Router 비중이 크고 React 18 유지가 전략적으로 필요할 때 | 묶기 |
@types/react 정렬 |
서드파티가 React 18 타입을 끌고 와 충돌할 때 | 충돌 범위가 좁고 임시 허용이 가능할 때 | 묶기 |
| ESLint 9 + Flat Config | Next 15.5 이상, next lint 의존 탈피가 필요할 때 |
플러그인 호환성이 크게 불안정할 때 | 대체로 묶기 |
| Turbopack dev | 로컬 DX 개선이 목적일 때 | custom webpack 진입점에 강하게 의존할 때 | 묶기 |
Turbopack next build |
parity 테스트가 충분하고 알려진 차이를 감당할 때 | CSS ordering·번들 최적화 격차·custom webpack 리스크가 클 때 | canary 권장 (이번 PR 보류) |
| Node 24 + Alpine 베이스 | native addon이 단순하고 musl 검증이 끝났을 때 | glibc 의존·보안 릴리스 즉시 반영·멀티아키 민감도가 높을 때 | Node 24는 묶고 Alpine 전환은 주의 |
핵심 분기점은 Turbopack이다. dev는 안정 단계라 묶었지만, next build --turbopack은 CSS ordering 차이와 일부 번들 최적화 격차가 알려져 있어 이번 PR에서는 의도적으로 보류했다. "Turbopack을 채택했다"가 아니라 "dev에만 적용했다"가 정확한 서술이다.
트레이드오프 요약
| 장점 | 단점 / 비용 | 예방책 |
|---|---|---|
| ✓ 검증 대상이 "최종 상태 하나"로 수렴 — 반쪽 호환 상태 제거 | ✗ 단일 PR이 88개 파일로 비대 → 리뷰 부담 | 의존 그래프 순서대로 커밋을 쪼개 리뷰 동선 확보 |
| ✓ 결합도 높은 축을 원자적으로 묶어 디버깅 시 변인 분리 | ✗ 문제 발생 시 롤백 단위가 큼 | 배포 전 스테이징에서 핵심 라우트 E2E 통과 확인 |
| ✓ canary 핀 + overrides로 서드파티 대기 시간 0 | ✗ canary 의존은 임시 부채 | 정식 stable 배포 시점에 핀 해제 캘린더 등록 |
| ✓ 정량 성과 명확 (아래) | ✗ 비동기 API 사일런트 버그는 자동 검출 불가 | 인증·권한 분기는 라우트별 수동 점검 필수 |
정량 성과
| 지표 | Before | After | 변화 |
|---|---|---|---|
| Dev 서버 시작 | — | — | 개선 (Turbopack, Vercel 공식 벤치마크: 대형 앱 기준 최대 76.7% 빠름) |
| Dev HMR (Fast Refresh) | — | — | 개선 (Vercel 공식 벤치마크: 최대 96.3% 빠름) |
| 배포 빌드 시간 | 평균 4분 | 평균 2분 20초 | 약 42%↓ (자체 CI 실측) |
| 프로덕션 에러율 | 0% | 0% | 무장애 유지 (자체 관측) |
빌드 42% 단축의 내역(자체 attribution): Next.js 15 빌드 최적화 ~20초 + Node.js 24 V8 개선 ~30초 + GitHub Actions .next/cache 캐시(actions/cache@v4) ~50초.
⚠️ 수치 고지: 76.7%·96.3%는 Vercel이 대형 Next.js 앱에서 측정한 공식 참고 수치로, 프로젝트 규모·환경에 따라 체감은 크게 달라진다(본 프로젝트의 독립 실측치가 아니다). 빌드 42%는 자체 CI 환경 실측이며, 내역 분해는 내부 attribution 가설이다. "에러율 0%"는 어떤 관측 도구·기간·분모 기준인지 함께 밝혀야 엄밀하다(여기서는 배포 후 안정화 관측 창 기준).
실무 도입 체크리스트
- 비동기 API(
cookies/headers/params/searchParams) 호출부를 전수 조사했는가 —await누락은 빌드를 통과하는 사일런트 버그다. - 인증·권한처럼 boolean을 반환하던 동기 함수가 async로 바뀌었다면, 그 호출부의 조건문을 직접 점검했는가.
- 서드파티 타입 충돌은 라이브러리 업데이트를 기다리지 말고
overrides로 평탄화했는가. - CI/로컬/Docker의 Node 버전을
.nvmrc단일 소스로 일원화했는가. - 빌드 캐시(
.next/cache)를 CI에 붙였는가 — 가장 가성비 높은 빌드 단축 수단이다. - Turbopack을 dev에만 적용했는지 build까지 켰는지 명확히 구분해 기록했는가.
6. 결론 및 다음 단계
세 줄 요약:
- 메이저 동시 업그레이드의 성패는 "전부 묶기"가 아니라 결합도 높은 코어는 원자적으로 묶고, 비호환 주변부는 canary 핀·
overrides로 격리하는 분리 설계에 있다. - 가장 위험한 깨짐은 빌드를 멈추는 타입 에러가 아니라, 빌드를 통과하는 비동기 API의
await누락이다. 인증 같은 곳은 수동 점검이 필수다. - 정량 측정(빌드 42%↓, 에러율 0%)을 붙이고, 공식 참고 수치와 내부 실측을 분리해 표기하면 "무장애"가 주장이 아니라 결과가 된다.
첫 단계 제안: 본인 코드베이스에서 cookies() / headers() / searchParams 사용처를 먼저 grep으로 전수 조사하라. 영향 범위를 알면 묶을지 나눌지가 보인다.
심화 학습 키워드: React Compiler, PPR(Partial Prerendering), use() Hook, Turbopack, pnpm overrides.
부록 — React Compiler를 열어둔 useMemo/useCallback 정책
이번 PR에서 React Compiler는 의도적으로 제외했다. 대신 "나중에 켜도 손해 보지 않는" 코드 정책을 설계했다.
React Compiler는 컴포넌트를 자동 메모이제이션하므로, 켜지는 순간 수동 useMemo/useCallback 상당수가 잉여가 된다. 그렇다고 기존 메모이제이션을 미리 대량으로 지우는 건 위험하다 — 컴파일러를 켜기 전까지는 그 메모이제이션이 실제로 동작 중이고, 수백 개를 한꺼번에 지웠다가 재생성 버그나 렌더 루프를 쫓는 비용이 더 크다. 그래서 기존 구문은 보존하되, 신규 코드에서는 성능을 위한 선제적 메모이제이션을 금지하고, useMemo/useCallback은 (a) 참조 동일성이 의미상 필요한 경우(의존성 배열, key, context value), (b) 실측으로 병목이 확인된 경우에만 쓰도록 했다. 이렇게 하면 컴파일러를 켰을 때 제거할 부채가 자연히 줄어든다.
방향성: 작성 시점(2026-06) 기준 React Compiler는 1.0 stable이고 Next.js 16부터 빌드에 내장됐지만, 여전히 기본 비활성이다. 도입 시에는 compiler를 켜기 전에 ESLint compiler linter(
preserve-manual-memoization등)를 먼저 도입해 기존 메모이제이션을 보존하면서 점진 전환하는 경로가 권장된다. 위 정책은 Next 16 전환 시 그대로 활용된다.