이 글은 누구를 위한 것인가
- Next.js 14 또는 그 이전 버전에서 15로 업그레이드하려는 팀
- React 19의 새 기능(Actions, use, 새 hooks)을 실무에 적용하고 싶은 개발자
- 마이그레이션 중 발생하는 Breaking Change와 대응법이 필요한 엔지니어
들어가며
Next.js 15와 React 19는 각각 중요한 변화를 담고 있다. React 19는 Server Actions를 공식 안정화했고, 새로운 use() hook과 폼 액션 패턴을 도입했다. Next.js 15는 App Router를 더욱 성숙시키고 캐싱 기본값을 변경했다.
이 글은 실제 중간 규모 Next.js 앱을 14에서 15로 마이그레이션한 경험을 바탕으로, 무엇이 바뀌고 어떻게 대응하는지를 실용적으로 다룬다.
이 글은 bluefoxdev.kr의 Next.js 버전별 변경사항 정리 를 참고하고, 실전 마이그레이션 관점에서 확장하여 작성했습니다.
1. 마이그레이션 전 준비
1.1 의존성 업그레이드
# 패키지 업그레이드
npm install next@15 react@19 react-dom@19
# 타입 패키지 업그레이드
npm install -D @types/react@19 @types/react-dom@19
# React 19 호환 확인이 필요한 주요 라이브러리
npx @next/codemod@latest upgrade
1.2 주요 Breaking Changes 목록
Next.js 15 Breaking Changes:
1. fetch 캐싱 기본값: no-store (이전: force-cache)
2. 동적 API가 비동기로 변경 (params, searchParams, cookies, headers)
3. React 19 필수 (React 18 지원 중단)
4. Turbopack이 기본 번들러
React 19 Breaking Changes:
1. ReactDOM.render() 완전 제거 (React 18에서 deprecated)
2. 일부 레거시 컨텍스트 API 제거
3. ref 콜백 정리 함수 지원
4. forwardRef 더 이상 필요 없음 (ref를 prop으로 전달 가능)
2. Next.js 15 핵심 변경
2.1 비동기 동적 API
// ❌ Next.js 14: 동기 접근
export default function Page({ params }: { params: { id: string } }) {
const id = params.id; // 동기
return <div>{id}</div>;
}
// ✅ Next.js 15: 비동기 접근 필수
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params; // 비동기
return <div>{id}</div>;
}
// searchParams도 동일
export default async function SearchPage({
searchParams,
}: {
searchParams: Promise<{ q?: string }>
}) {
const { q } = await searchParams;
return <div>검색: {q}</div>;
}
// cookies, headers도 비동기
import { cookies, headers } from 'next/headers';
// ❌ 이전
export async function getUser() {
const cookieStore = cookies();
const token = cookieStore.get('token');
}
// ✅ Next.js 15
export async function getUser() {
const cookieStore = await cookies();
const token = cookieStore.get('token');
}
2.2 fetch 캐싱 기본값 변경
// ❌ Next.js 14 기본: force-cache (모든 fetch가 캐시됨)
// Next.js 15 기본: no-store (캐시 없음)
// 명시적으로 캐시 설정 권장
async function getProducts() {
const res = await fetch('/api/products', {
// 15에서는 명시하지 않으면 no-store
next: { revalidate: 3600 } // 1시간 캐시
});
return res.json();
}
// 정적 데이터 (변경 없음)
async function getConfig() {
const res = await fetch('/api/config', {
cache: 'force-cache' // 명시적 설정
});
return res.json();
}
2.3 codemod 자동 변환
# Next.js 공식 codemod로 자동 마이그레이션
npx @next/codemod@latest async-server-component-cookies .
# 사용 가능한 codemod 목록
npx @next/codemod@latest --list
3. React 19 새 기능
3.1 Server Actions + useActionState
// app/actions.ts
'use server';
import { z } from 'zod';
const schema = z.object({
email: z.string().email(),
message: z.string().min(10),
});
type State = {
success?: boolean;
error?: string;
fieldErrors?: { email?: string; message?: string };
};
export async function submitContact(
prevState: State,
formData: FormData
): Promise<State> {
const result = schema.safeParse({
email: formData.get('email'),
message: formData.get('message'),
});
if (!result.success) {
return {
error: '입력값을 확인해주세요',
fieldErrors: result.error.flatten().fieldErrors,
};
}
await sendEmail(result.data);
return { success: true };
}
// app/contact/page.tsx (React 19 useActionState)
'use client';
import { useActionState } from 'react';
import { submitContact } from '../actions';
export default function ContactForm() {
const [state, action, isPending] = useActionState(submitContact, {});
return (
<form action={action}>
<input name="email" type="email" placeholder="이메일" />
{state.fieldErrors?.email && (
<p className="text-red-500">{state.fieldErrors.email}</p>
)}
<textarea name="message" placeholder="메시지" />
{state.fieldErrors?.message && (
<p className="text-red-500">{state.fieldErrors.message}</p>
)}
<button type="submit" disabled={isPending}>
{isPending ? '전송 중...' : '전송'}
</button>
{state.success && <p className="text-green-500">메시지가 전송되었습니다!</p>}
{state.error && <p className="text-red-500">{state.error}</p>}
</form>
);
}
3.2 use() hook - 비동기 처리
// React 19의 use()는 Promise와 Context를 처리
import { use, Suspense } from 'react';
// Promise 언래핑
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise); // Suspense와 함께 사용
return <div>{user.name}</div>;
}
// 부모에서 Promise 전달
function UserPage() {
const userPromise = fetchUser(userId); // fetch 바로 시작
return (
<Suspense fallback={<Skeleton />}>
<UserProfile userPromise={userPromise} />
</Suspense>
);
}
3.3 forwardRef 없이 ref 전달
// ❌ React 18: forwardRef 필요
const Input = forwardRef<HTMLInputElement, InputProps>((props, ref) => (
<input ref={ref} {...props} />
));
// ✅ React 19: ref를 prop으로 그냥 전달
function Input({ ref, ...props }: InputProps & { ref?: React.Ref<HTMLInputElement> }) {
return <input ref={ref} {...props} />;
}
// 사용
const inputRef = useRef<HTMLInputElement>(null);
<Input ref={inputRef} placeholder="입력" />
4. React Compiler 도입
# React Compiler 설치 (선택적)
npm install -D babel-plugin-react-compiler
# next.config.js
const nextConfig = {
experimental: {
reactCompiler: true,
},
};
// React Compiler가 자동으로 memoization 최적화
// useMemo, useCallback, memo 대부분 불필요해짐
// ❌ 이전: 수동 최적화
const expensiveValue = useMemo(() => compute(data), [data]);
const handleClick = useCallback(() => doSomething(id), [id]);
// ✅ React Compiler 있을 때: 그냥 작성
const expensiveValue = compute(data); // 컴파일러가 최적화
const handleClick = () => doSomething(id); // 컴파일러가 최적화
5. 마이그레이션 체크리스트
[ ] next, react, react-dom 15/19로 업그레이드
[ ] npx @next/codemod@latest upgrade 실행
[ ] params/searchParams async 처리 확인
[ ] cookies/headers async 처리 확인
[ ] fetch 캐시 전략 명시적 선언
[ ] ReactDOM.render() → createRoot() 확인 (이미 18 기반이면 완료)
[ ] 서드파티 라이브러리 React 19 호환성 확인
[ ] TypeScript 타입 에러 해결
[ ] 테스트 통과 확인
[ ] 빌드 + 런타임 에러 없음 확인
마무리
Next.js 14→15 마이그레이션에서 가장 많은 시간을 잡아먹는 것은 비동기 params/searchParams와 fetch 캐시 기본값 변경이다. codemod로 상당 부분 자동화할 수 있지만, 비즈니스 로직에 따라 캐시 전략을 다시 검토해야 한다.
React 19의 useActionState는 서버 액션과 폼 처리를 크게 단순화한다. 새 기능부터 적용해보고 점진적으로 기존 코드도 마이그레이션하는 전략을 추천한다.