© 2025 anveloper.dev
GitHub·LinkedIn·Contact

목차

  • 배경
  • 사전 준비: 의존성 정리
  • Phase 1: Next.js 14 → 15
  • Next.js 15의 주요 변경사항
  • 실제 마이그레이션: Async Request API 대응 (173개 파일)
  • Phase 2: Next.js 15 → 16
  • Next.js 16의 주요 변경사항
  • 실제 마이그레이션: NextAuth v5 (206개 파일)
  • middleware.ts → proxy.ts
  • next.config.mjs 변경
  • React 19 타입 호환성
  • SVG Import 변경
  • 무중단 배포 스크립트 제거
  • 정리
  • 핵심 교훈
포스트 목록으로 돌아가기

Next.js 14에서 16까지: 실무 프로젝트 마이그레이션 기록

2026-02-02
Next.js
React
NextAuth
Migration

Next.js 14에서 16까지: 실무 프로젝트 마이그레이션 기록

배경

운영 중인 대규모 프로젝트(900개 이상 파일, 80개 이상 페이지)를 Next.js 14에서 16까지 단계적으로 업그레이드했다. React 18에서 19로, NextAuth v4에서 v5로의 전환도 함께 진행되었기에 단순한 버전 범프와는 거리가 멀었다.

패키지시작 버전중간 버전최종 버전
next14.2.3515.5.1116.1.6
react18.3.118.3.119.2.4
next-auth4.x4.x5.0.0-beta.30

업그레이드는 14→15→16 순서로 진행했다. 한 번에 두 메이저 버전을 건너뛰는 것은 위험하므로, 각 단계에서 빌드가 정상적으로 통과하는 것을 확인한 뒤 다음 단계로 넘어갔다. 이 글에서는 각 단계에서 만난 주요 변경사항과 대응 방법을 정리한다.

사전 준비: 의존성 정리

본격적인 업그레이드 전에 14.2.35 패치까지 먼저 올리면서 의존성을 정리했다. 보안 패치(CVE-2025-66478, CVE-2025-29927 등) 적용과 함께 사용하지 않는 패키지를 제거하고, 누락된 의존성을 추가했다.

// 제거한 패키지
"aws-sdk"           // 사용하지 않는 SDK
"@hookform/devtools" // 개발 도구
"html-react-parser" // 미사용
 
// 추가한 패키지
"@tiptap/core"      // peer dependency 누락
"framer-motion"     // peer dependency 누락
"lodash"            // 암묵적 의존
"node-fetch"        // 암묵적 의존

업그레이드 전에 의존성을 깨끗하게 정리해두면 이후 단계에서 문제의 원인을 좁히기가 수월하다. 특히 peer dependency 경고가 사라지면 실제 호환성 문제와 단순 경고를 구분하기 쉬워진다.

Phase 1: Next.js 14 → 15

Next.js 15는 프레임워크의 방향성이 크게 바뀐 버전이다. 단순 API 변경 외에도 캐싱 철학, 보안 모델, 개발 경험 전반에 걸친 변화가 있었다.

Next.js 15의 주요 변경사항

1. Async Request API (핵심 Breaking Change)

15에서 가장 큰 변경은 params, searchParams, headers(), cookies() 등 요청 관련 API가 모두 Promise로 변경된 것이다.

// Next.js 14 - 동기 접근
export default function Page({ params }: { params: { id: string } }) {
  const { id } = params;
}
 
// Next.js 15 - 비동기 접근
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
}

headers()와 cookies()도 마찬가지다.

// Next.js 14
const referer = headers().get("referer");
 
// Next.js 15
const referer = (await headers()).get("referer");

이 변경은 서버 컴포넌트의 스트리밍 렌더링을 최적화하기 위한 것이다. 요청 데이터에 접근할 때 동기적으로 블로킹하는 대신, 필요한 시점에 비동기로 접근하여 렌더링 파이프라인을 더 효율적으로 만든다.

2. 캐싱 기본값 변경

14까지는 fetch()와 GET Route Handler가 기본적으로 캐시되었다. 15에서는 이 기본값이 뒤집어졌다.

// Next.js 14 - 기본적으로 캐시됨
fetch("https://api.example.com/data");
 
// Next.js 15 - 기본적으로 캐시 안 됨. 명시적으로 지정해야 함
fetch("https://api.example.com/data", { cache: "force-cache" });

운영 중인 프로젝트에서는 대부분 동적 데이터를 다루고 있어 이 변경의 영향은 크지 않았지만, 정적 데이터를 캐시하던 코드가 있다면 주의가 필요하다.

3. Turbopack 안정화

next dev --turbo가 안정화되었다. Webpack 대비 개발 서버 시작 속도가 대폭 개선되며, HMR도 빨라졌다. 다만 일부 Webpack 플러그인(@svgr/webpack 등)과의 호환성 문제가 있어 16 단계에서 별도 대응이 필요했다.

4. next/form 컴포넌트

클라이언트 사이드 네비게이션을 지원하는 <Form> 컴포넌트가 추가되었다. 기존 HTML <form>을 교체하면 폼 제출 시 전체 페이지 리로드 없이 네비게이션이 가능하다.

5. next/after API

응답을 보낸 후 백그라운드 작업을 실행할 수 있는 after() API가 추가되었다. 로깅, 분석 이벤트 전송 등에 유용하다.

6. Server Actions 보안 강화

Server Actions의 엔드포인트가 더 이상 공개 HTTP 엔드포인트로 노출되지 않는다. 사용하지 않는 Server Action은 클라이언트 번들에서 자동으로 제거된다.

실제 마이그레이션: Async Request API 대응 (173개 파일)

프로젝트에서 173개 파일이 Async Request API 변경의 영향을 받았다. 다행히 Next.js에서 제공하는 codemod가 잘 동작했다.

npx @next/codemod@latest async-request-api .

codemod는 params와 searchParams의 타입을 Promise로 변경하고, await를 추가하는 작업을 자동으로 처리했다. 변환 패턴을 살펴보면:

// codemod 적용 전
const AdminCodePage = async ({ searchParams }: { searchParams: SearchParams }) => {
  const { codeList } = await dplServer.code.getCodeListAll(searchParams);
};
 
// codemod 적용 후
const AdminCodePage = async (props: { searchParams: Promise<SearchParams> }) => {
  const searchParams = await props.searchParams;
  const { codeList } = await dplServer.code.getCodeListAll(searchParams);
};

중첩 구조분해가 있는 경우에도 깔끔하게 처리된다.

// codemod 적용 전
const Page = async ({ searchParams, params: { type } }: { searchParams: SearchParams; params: { type: string } }) => {
  /* ... */
};
 
// codemod 적용 후
const Page = async (props: { searchParams: Promise<SearchParams>; params: Promise<{ type: string }> }) => {
  const params = await props.params;
  const { type } = params;
  const searchParams = await props.searchParams;
  // ...
};

한 가지 주의할 점이 있었다. 업그레이드 전에 14 규격에 맞지 않게 searchParams를 await 하고 있던 코드가 있었다. 14에서는 searchParams가 동기 객체이므로 await가 불필요하고 오히려 타입 에러를 유발했다. 업그레이드 전 기존 코드가 현재 버전의 규격에 맞는지 먼저 확인해야 codemod가 제대로 동작한다.

// 14에서 잘못된 코드 (await 불필요)
const params = await searchParams;
 
// 14에서 올바른 코드 (동기 접근)
const sd = searchParams.st ? new Date(String(searchParams.st)) : new Date();

codemod 적용 후 빌드를 확인하고, 15 버전에서의 안정성을 검증한 뒤 다음 단계로 넘어갔다.

Phase 2: Next.js 15 → 16

이 단계에서 React 19와 NextAuth v5 전환이 함께 이루어졌다. Next.js 16은 15에서 시작된 변화를 완성하면서 동시에 상당한 양의 Breaking Change를 포함하고 있다.

Next.js 16의 주요 변경사항

1. middleware.ts → proxy.ts (파일명 변경)

16에서 middleware 파일명이 proxy로 변경되었다. 네트워크 경계와 라우팅 역할을 명확히 하기 위한 리네이밍이다. 함수명도 middleware에서 proxy로 바뀌었다.

// Next.js 15 - middleware.ts
export const middleware = (req: NextRequest) => {
  /* ... */
};
 
// Next.js 16 - proxy.ts
export function proxy(req: NextRequest) {
  /* ... */
}

설정 플래그도 함께 변경되었다. skipMiddlewareUrlNormalize은 skipProxyUrlNormalize로.

proxy에서는 edge 런타임이 지원되지 않는다. proxy의 런타임은 nodejs이며 변경할 수 없다. Edge 런타임을 계속 사용해야 한다면 middleware를 그대로 유지해야 한다.

2. next lint 명령어 제거

next lint 명령어가 완전히 제거되었다. ESLint 또는 Biome을 직접 사용해야 한다. next build 과정에서도 더 이상 린트를 실행하지 않는다.

// next.config.mjs - eslint 설정 제거 필요
const nextConfig = {
  // ❌ 더 이상 지원되지 않음
  // eslint: { ignoreDuringBuilds: true },
};

ESLint Flat Config 형식이 기본이 되었으며, eslint-config-next가 이를 지원한다.

3. React 19 필수

16부터 React 19가 필수다. React 19.2에는 View Transitions, useEffectEvent, Activity 등 새 기능이 포함되어 있다.

4. React Compiler 지원 안정화

React Compiler 지원이 experimental에서 안정 옵션으로 승격되었다. 기본으로 활성화되지는 않는다.

// next.config.ts
const nextConfig: NextConfig = {
  reactCompiler: true, // experimental 접두사 제거됨
};

5. 캐싱 API 변경

  • cacheLife, cacheTag가 안정화 (unstable_ 접두사 제거)
  • revalidateTag에 cacheLife 프로파일을 두 번째 인자로 전달 가능
  • 새로운 updateTag API: Server Action 내에서 read-your-writes 시맨틱 제공
  • 새로운 refresh API: Server Action에서 클라이언트 라우터 갱신

6. next/image 변경사항

  • 로컬 이미지의 쿼리스트링 사용 시 images.localPatterns.search 설정 필요
  • minimumCacheTTL 기본값 60초 → 4시간(14400초)으로 변경
  • imageSizes 기본값에서 16px 제거
  • qualities 기본값이 모든 품질 허용 → [75]만 허용으로 변경
  • 로컬 IP 최적화 기본 차단 (dangerouslyAllowLocalIP: true로 허용)
  • 최대 리다이렉트 무제한 → 3회로 제한

7. 라우팅 및 네비게이션 최적화

  • 공유 레이아웃 중복 제거: 여러 URL이 공유하는 레이아웃을 한 번만 다운로드
  • 증분 프리패칭: 캐시에 없는 부분만 프리패치

코드 수정 없이 자동 적용되지만, 개별 프리패치 요청이 늘어날 수 있다.

8. Parallel Routes default.js 필수화

모든 parallel route 슬롯에 default.js 파일이 필수가 되었다.

// app/@modal/default.tsx
import { notFound } from "next/navigation";
 
export default function Default() {
  notFound();
}

9. scroll-behavior 오버라이드 변경

이전에는 Next.js가 SPA 라우트 전환 시 scroll-behavior: smooth를 임시로 auto로 오버라이드했다. 16부터는 더 이상 오버라이드하지 않는다. 이전 동작을 원하면 <html data-scroll-behavior="smooth">를 추가해야 한다.

10. 제거된 기능

  • AMP 지원 완전 제거
  • next lint 제거
  • Runtime Configuration (serverRuntimeConfig, publicRuntimeConfig) 제거 → 환경 변수 사용
  • next/legacy/image deprecated
  • images.domains deprecated → images.remotePatterns 사용
  • PPR 실험적 플래그 제거 → cacheComponents로 대체

실제 마이그레이션: NextAuth v5 (206개 파일)

16 업그레이드에서 가장 영향 범위가 컸다. 206개 파일이 변경 대상이었다.

파일 구조 변경

v4에서는 API route 내부에 인증 설정을 두었지만, v5에서는 lib/로 분리하여 중앙화하는 구조로 바뀌었다.

# v4
app/api/auth/[...nextauth]/auth-option.ts  → 삭제
app/api/auth/[...nextauth]/route.ts
 
# v5
lib/auth.ts              # auth(), handlers 등 export
lib/auth.config.ts       # NextAuth 설정 (providers, callbacks)
app/api/auth/[...nextauth]/route.ts

auth.ts / auth.config.ts 구성

v5에서는 설정과 인스턴스 생성을 분리하는 패턴을 권장한다.

// lib/auth.config.ts
import type { NextAuthConfig } from "next-auth";
import Credentials from "next-auth/providers/credentials";
 
export const authConfig: NextAuthConfig = {
  providers: [
    Credentials({
      id: "seller",
      credentials: {
        email: { type: "text" as const },
        password: { type: "password" as const },
      },
      // v5: authorize의 두 번째 인자가 Request 객체
      authorize: async (credentials, request) => {
        const headers = Object.fromEntries(request.headers.entries());
        return login(credentials, headers);
      },
    }),
  ],
  callbacks: {
    /* ... */
  },
};
 
// lib/auth.ts
import NextAuth from "next-auth";
import { authConfig } from "./auth.config";
 
export const { handlers, auth, signIn, signOut } = NextAuth(authConfig);

v4의 authorize(credentials, req) 시그니처가 authorize(credentials, request) 로 변경되었다. req는 Pick<RequestInternal, "body" | "query" | "headers" | "method"> 형태였지만, v5의 request는 표준 Web Request 객체다. 헤더 접근 방식이 달라진다.

// v4: req.headers는 Record<string, string>
const headers = (req.headers || {}) as Record<string, string>;
 
// v5: request.headers는 Headers 객체
const headers = Object.fromEntries(request.headers.entries());

세션 조회 방식 변경

getServerSession(authOptions)를 호출하던 모든 곳을 auth()로 교체해야 한다. 함수명이 짧아진 것은 좋지만, 206개 파일을 수정하는 것은 단순 반복 작업이었다.

// v4
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/auth-option";
const session = await getServerSession(authOptions);
 
// v5
import { auth } from "@/lib/auth";
const session = await auth();

IDE의 전체 검색과 일괄 치환에 크게 의존했다. 파일마다 import 2줄을 삭제하고 1줄을 추가하는 패턴이 반복되었다.

// v4 (삭제 대상)
- import { authOptions } from "@/app/api/auth/[...nextauth]/auth-option";
- import { getServerSession } from "next-auth";
 
// v5 (추가)
+ import { auth } from "@/lib/auth";

route.ts 변경

route handler도 간결해졌다.

// v4
import NextAuth from "next-auth";
import { authOptions } from "./auth-option";
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
 
// v5
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;

codemod가 남긴 주석 경고도 깔끔하게 정리되었다.

// codemod가 남긴 경고 (정리 대상)
export {
  /* @next-codemod-error `handler` export is re-exported. ... */
  handler as GET /* @next-codemod-error ... */,
  handler as POST,
};
 
// v5로 교체 후
export const { GET, POST } = handlers;

v5 타입 시스템 변경

v4에서 사용하던 AuthOptions, SessionOptions 등의 타입이 NextAuthConfig로 통합되었다. 커스텀 타입(JWT 확장, Session 확장)은 as any 캐스팅이 필요한 부분이 생겼는데, 이는 v5의 타입 시스템이 아직 beta 단계이기 때문이다.

// v4 - 타입이 느슨하여 직접 접근 가능
token.user = user;
token.expires = now + MAX_INACTIVE_TIME * 1000;
 
// v5 - 엄격한 타입으로 캐스팅 필요
(token as any).user = user;
(token as any).expires = now + MAX_INACTIVE_TIME * 1000;

middleware.ts → proxy.ts

Next.js 16에서 middleware 파일명이 proxy.ts로 변경되었다. 파일명을 바꾸고 함수명도 변경하면 된다. 모르면 한참 헤맬 수 있는 변경이다.

// middleware.ts (삭제)
export const middleware = (req: NextRequest) => {
  // ...
};
 
// proxy.ts (새로 생성)
export function proxy(req: NextRequest) {
  // 동일한 로직
}

next.config.mjs 변경

ESLint 관련 설정이 제거되었다. eslint.ignoreDuringBuilds 옵션을 사용하고 있었다면 삭제해야 한다. 빌드 스크립트에서 SKIP_LINT 환경 변수를 사용하던 패턴도 더 이상 필요 없다.

// 이전
const nextConfig = {
  typescript: { ignoreBuildErrors: process.env.SKIP_TYPE_CHECK === "true" },
  eslint: { ignoreDuringBuilds: process.env.SKIP_LINT === "true" }, // ← 제거
};
 
// 현재
const nextConfig = {
  typescript: { ignoreBuildErrors: process.env.SKIP_TYPE_CHECK === "true" },
  // eslint 설정 제거
};

React 19 타입 호환성

useRef 타입 변경

React 19에서 useRef의 타입 시그니처가 변경되었다. 9개 파일에서 수정이 필요했다.

// React 18
const ref = useRef<HTMLDivElement>(null);
const swiperRef = useRef<SwiperType>();
 
// React 19 - null 유니온 명시 필요
const ref = useRef<HTMLDivElement | null>(null);
const swiperRef = useRef<SwiperType>(null);

RefObject 타입 정의도 변경되었다. 컴포넌트 props에서 RefObject<T>를 사용하고 있었다면 RefObject<T | null>로 바꿔야 한다.

// React 18
type Props = { tableRef: React.RefObject<HTMLTableElement> };
type Props = { iframeRef: React.RefObject<HTMLIFrameElement> };
 
// React 19
type Props = { tableRef: React.RefObject<HTMLTableElement | null> };
type Props = { iframeRef: React.RefObject<HTMLIFrameElement | null> };

빌드 시 타입 에러로 잡히기 때문에 놓칠 일은 없지만, 수정해야 할 곳이 적지 않았다.

SVG Import 변경

@svgr/webpack이 Turbopack과 호환되지 않는 문제가 있었다. SVG 파일을 TSX 컴포넌트로 직접 변환하는 방식으로 우회했다. 20개 SVG 파일을 변환하고, 9개 파일에서 import를 수정했다.

// 이전: .svg 파일 직접 import
import AlarmIcon from "@/assets/icons/svg/alarm.svg";
 
// 현재: .tsx 컴포넌트로 변환
import AlarmIcon from "@/assets/icons/svg/alarm";

SVG를 TSX로 변환하는 패턴은 단순하다.

// assets/icons/svg/alarm.tsx
import { SVGProps } from "react";
 
const Alarm = (props: SVGProps<SVGSVGElement>) => {
  return (
    <svg {...props} width="24" height="24" viewBox="0 0 24 24" fill="none">
      <path fillRule="evenodd" clipRule="evenodd" d="M12 24C18..." fill="#FACF81" />
    </svg>
  );
};
 
export default Alarm;

번거롭지만 Turbopack의 빌드 속도를 고려하면 합리적인 선택이었다.

무중단 배포 스크립트 제거

Next.js 16에서는 next dev와 next build가 별도의 출력 디렉토리를 사용한다(next dev는 .next/dev로 출력). 이로 인해 기존에 사용하던 무중단 배포 스크립트(.next를 .next-tmp로 빌드 후 교체하는 방식)가 불필요해졌다.

// build-switch.js (삭제)
// .next-tmp로 빌드 후 .next로 교체하는 스크립트
// Next.js 16의 isolatedDevBuild로 대체됨

정리

14에서 16까지, 두 번의 메이저 업그레이드에서 수정된 파일 수를 정리하면 다음과 같다.

단계변경 항목영향 파일 수
사전의존성 정리 및 보안 패치2개
14 → 15Async Request API (codemod)173개
15 → 16NextAuth v5 전환206개
15 → 16getServerSession → auth()206개
15 → 16React 19 타입 호환성 (useRef)9개
15 → 16SVG Import → TSX 변환29개
15 → 16middleware → proxy1개
15 → 16next.config.mjs (eslint 제거)1개

핵심 교훈

  • 한 단계씩 올려야 한다. 메이저 버전을 여러 단계 건너뛸 때는 반드시 각 단계에서 빌드를 확인한 뒤 다음으로 넘어가야 문제의 원인을 좁힐 수 있다.
  • 의존성 정리를 선행해야 한다. 업그레이드 전에 의존성을 깨끗하게 정리하면, 이후 발생하는 오류가 버전 변경 때문인지 기존 문제인지 구분이 명확해진다.
  • codemod를 적극 활용해야 한다. Async Request API의 173개 파일 수정은 codemod 없이는 현실적으로 힘들었을 것이다. Next.js 16에서는 middleware → proxy 변환 codemod도 제공한다.
  • 릴리즈 노트를 꼼꼼히 읽어야 한다. 파일명 변경(middleware.ts → proxy.ts)처럼 사소하지만 치명적인 변경은 릴리즈 노트를 읽어야만 발견할 수 있다.
  • 기존 코드가 현재 버전 규격에 맞는지 먼저 확인해야 한다. 14에서 이미 잘못 작성된 코드(동기 API를 await 하는 등)가 있으면 codemod가 오작동할 수 있다.
  • 타입 에러를 믿어야 한다. React 19의 useRef 타입 변경처럼, 빌드 타임에 잡히는 문제는 하나씩 따라가면 된다. 오히려 런타임에서만 발견되는 동작 변경(캐싱 기본값 등)이 더 위험하다.