안녕하세요. J4J입니다.
이번 포스팅은 Next.js 16에서 완전히 제거된 Async Request APIs의 동기 접근이 실제로 어떤 문제를 일으키는지, 그리고 이를 안전하게 마이그레이션하는 방법을 살펴보는 시간을 가져보려고 합니다.
Next.js 16 params, searchParams 동기 접근이 조용히 실패하는 이유
Next.js 15에서 params.id처럼 동기로 접근하던 코드를 16으로 올린 뒤에도 그대로 두었는데, 화면에 값이 비어버리는 경우를 마주치게 됩니다.
params, searchParams는 각각 라우트 세그먼트의 동적 값과 URL 쿼리스트링을 담아 컴포넌트에 전달하는 Promise 객체이며, Next.js 15에서 이미 Promise로 바뀌었지만 당시에는 마이그레이션 유예를 위한 임시 동기 호환 프로퍼티가 함께 제공되었습니다.
공식 문서는 이 변경 이력을 다음과 같이 설명합니다.
"Version 15 introduced Async Request APIs as a breaking change, with temporary synchronous compatibility. Starting with Next.js 16, synchronous access is fully removed."
즉 16부터는 그 임시 호환 프로퍼티 자체가 사라져, params는 순수한 Promise 객체 하나만 남게 되었습니다.
여기서 흔히 오해하기 쉬운 지점이 하나 있습니다.
params를 손수 동기 타입으로 지정해서 사용하면 next build 시점에 타입 에러로 걸릴 것이라고 예상하기 쉽지만, 실제로는 그렇지 않습니다.
// ❌ params를 손수 동기 타입으로 지정 — 컴파일러가 막아주지 않음
export default function SyncTestPage({ params }: { params: { id: string } }) {
return <p>상품 ID: {params.id}</p>
}
이렇게 작성하면 next build는 타입 에러 없이 그대로 통과하고, 실제로 페이지에 접속해도 200 응답과 함께 화면이 정상으로 보이지만 "상품 ID: " 뒤에 값만 조용히 비어버립니다.
params는 런타임에 실제 값이 아니라 Promise로 전달되기 때문에, params.id는 Promise에 존재하지 않는 프로퍼티에 접근한 것과 같아 undefined가 될 뿐 아무 에러도 던지지 않습니다.
개발 서버(next dev)에서는 Next.js가 이 Promise를 한 번 더 감싸 프로퍼티 접근을 감지하므로, 그나마 콘솔에 다음과 같은 경고가 남습니다.
Error: Route "/sync-test/[id]" used `params.id`. `params` is a Promise and must be
unwrapped with `await` or `React.use()` before accessing its properties.
Learn more: https://nextjs.org/docs/messages/sync-dynamic-apis
다만 이 경고는 콘솔에 찍히는 안내일 뿐이라 요청 자체는 여전히 200으로 끝나며, 프로덕션 모드(next start)에서는 이 경고조차 남지 않고 값만 비어 나옵니다.
// ✅ app/sync-test/[id]/page.tsx
import ClientPart from './ClientPart'
export default async function SyncTestPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
return (
<div className="flex flex-col gap-4 p-6">
<p>Server Component에서 확인한 상품 ID: {id}</p>
<ClientPart params={params} />
</div>
)
}
그러므로 params, searchParams를 다룰 때는 Promise 타입으로 선언한 뒤 await로 풀어서 사용하는 것이 좋습니다.
Client Component는 컴포넌트 자체를 async 함수로 선언할 수 없으므로, 대신 React의 use로 같은 Promise를 풀어냅니다.
// ✅ app/sync-test/[id]/ClientPart.tsx
'use client'
import { use } from 'react'
export default function ClientPart({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params)
return <p>Client Component에서 확인한 상품 ID: {id}</p>
}
이렇게 작성하면 Server Component는 params를 await로 풀어내고, 같은 Promise를 props로 내려받은 Client Component는 use로 풀어내면서 두 컴포넌트 모두 상품 ID를 정상적으로 표시합니다.
React 19에 새롭게 추가된 use API의 기본 사용법은 React 19 use API, Promise와 Context를 조건부로 읽는 방법을 참고해 주시길 바랍니다.
React 19 use API, Promise와 Context를 조건부로 읽는 방법
이번 포스팅은 React 19에 새롭게 추가된 use API를 이용하여 Promise와 Context를 다루는 방법에 대해 알아보는 시간을 가져보려고 합니다.
jforj.tistory.com
저의 경우 처음 이 동작을 확인했을 때, 타입을 잘못 적으면 당연히 빌드가 막아줄 것이라고 예상했다가 실제로는 값만 조용히 사라진다는 사실을 알고 나서 훨씬 더 주의 깊게 params 타입을 다루게 되었습니다.
이렇게 손으로 잘못 타이핑해도 아무 에러 없이 넘어가는 문제를 근본적으로 막는 방법은 뒤에서 PageProps와 함께 다시 살펴보고, 먼저 같은 16에서 함께 바뀐 cookies, headers, draftMode의 동작부터 확인해 보겠습니다.
Next.js 16에서 cookies, headers, draftMode도 예외 없이 비동기로
cookies, headers, draftMode는 next/headers에서 제공하는 요청 컨텍스트(쿠키, 요청 헤더, Draft Mode 상태)에 접근하는 Server 전용 함수이며, params와 마찬가지로 16부터 반환값이 완전한 Promise 하나로 통일되었습니다.
다만 params와 달리 이 세 함수는 동기로 잘못 사용하면 코드를 실행해보기도 전에 타입 에러로 걸립니다.
// ❌ cookies()를 await 없이 바로 사용
import { cookies } from 'next/headers'
export default async function CookieDemoPage() {
const cookieStore = cookies()
const theme = cookieStore.get('theme') // Property 'get' does not exist on type 'Promise<ReadonlyRequestCookies>'.
return <p>테마: {theme?.value}</p>
}
이렇게 작성하면 cookies가 반환하는 Promise<ReadonlyRequestCookies>에는 애초에 get 메서드가 없으므로, params처럼 값이 조용히 비는 대신 tsc가 즉시 타입 에러로 잡아냅니다.
params는 사용자가 직접 지정한 타입을 컴파일러가 그대로 신뢰하는 반면, cookies는 반환 타입이 라이브러리에 고정되어 있어 사용자가 임의로 타입을 덮어쓸 여지가 없기 때문입니다.
// ✅ app/cookie-demo/page.tsx
import { cookies, draftMode, headers } from 'next/headers'
import ToggleButtons from './ToggleButtons'
export default async function CookieDemoPage() {
const cookieStore = await cookies()
const headerList = await headers()
const { isEnabled } = await draftMode()
const theme = cookieStore.get('theme')?.value ?? '설정 안 됨'
const userAgent = headerList.get('user-agent') ?? '알 수 없음'
return (
<div className="flex flex-col gap-4 p-6">
<p>theme 쿠키: {theme}</p>
<p>User-Agent 헤더: {userAgent}</p>
<p>Draft Mode: {isEnabled ? '켜짐' : '꺼짐'}</p>
<ToggleButtons />
</div>
)
}
그러므로 cookies, headers, draftMode는 항상 await로 먼저 풀어낸 뒤 그 결과의 메서드나 값을 사용하는 것이 좋습니다.
여기서 한 가지 더 유의할 점은, cookies가 반환하는 값 중 set과 delete는 어디서나 호출할 수 있는 것이 아니라는 사실입니다.
Server Component가 렌더링을 시작한 뒤에는 이미 응답 스트리밍이 시작되어 있어 Set-Cookie 헤더를 추가로 보낼 수 없으므로, cookies의 set과 delete는 Server Action이나 Route Handler 안에서만 호출할 수 있습니다.
draftMode의 enable, disable도 같은 이유로 Server Action이나 Route Handler 안에서만 호출할 수 있습니다.
Server Action의 기본 문법과 동작 방식은 React Server Action, API Route 없이 서버 함수를 호출하는 방법을 참고해 주시길 바랍니다.
React Server Action, API Route 없이 서버 함수를 호출하는 방법
Server Action을 사용하면 별도 API Route 없이 클라이언트에서 서버 함수를 직접 호출할 수 있습니다. React 19에서 정식 도입된 이 기능이 보일러플레이트를 줄이고 API 키 같은 민감한 데이터를 서버에서만 안전하게 다루는 방법을 설명합니다.
jforj.tistory.com
쿠키 설정과 Draft Mode 전환을 각각 버튼으로 직접 실행해 보겠습니다.
// app/cookie-demo/actions.ts
'use server'
import { cookies, draftMode } from 'next/headers'
export async function setThemeCookie() {
const cookieStore = await cookies()
cookieStore.set('theme', 'dark')
}
export async function toggleDraftMode() {
const draft = await draftMode()
if (draft.isEnabled) {
draft.disable()
} else {
draft.enable()
}
}
// app/cookie-demo/ToggleButtons.tsx
'use client'
import { useRouter } from 'next/navigation'
import { setThemeCookie, toggleDraftMode } from './actions'
export default function ToggleButtons() {
const router = useRouter()
return (
<div className="flex gap-2">
<button
className="cursor-pointer rounded border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50"
onClick={async () => {
await setThemeCookie()
router.refresh()
}}
>
테마 쿠키 설정
</button>
<button
className="cursor-pointer rounded border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50"
onClick={async () => {
await toggleDraftMode()
router.refresh()
}}
>
Draft Mode 전환
</button>
</div>
)
}
테마 쿠키 설정 버튼을 누르면 CookieDemoPage가 다시 렌더링되며 theme 쿠키 값이 "설정 안 됨"에서 "dark"로 바뀌고, Draft Mode 전환 버튼을 누를 때마다 "켜짐"과 "꺼짐"이 서로 바뀌는 것을 확인할 수 있습니다.
참고로 searchParams는 page.tsx에만 전달되고 layout.tsx에는 전달되지 않는다는 점도, params와 함께 자주 헷갈리는 부분이라 짚어둘 필요가 있습니다.
그러면 앞서 미뤄둔 질문으로 돌아가, params를 손수 잘못 타이핑해도 조용히 넘어가는 문제를 실제로 막아주는 방법을 살펴보겠습니다.
PageProps, LayoutProps, RouteContext 타입 헬퍼
next typegen은 app 디렉터리의 라우트 구조를 분석해 각 라우트 전용 props 타입을 자동으로 생성해 주는 Next.js 내장 타입 생성기이며, next dev·next build·next typegen 중 하나를 실행하면 프로젝트 루트의 .next/types(또는 dev 모드에서는 .next/dev/types)에 결과가 생성됩니다.
이렇게 생성된 타입 중 PageProps, LayoutProps, RouteContext 세 헬퍼는 전역으로 선언되어 있어 별도 import 없이 바로 사용할 수 있습니다.
앞서 살펴본 params.id 예시를 PageProps로 바꿔서 작성해 보겠습니다.
// ❌ PageProps를 사용하면서도 여전히 동기로 접근
export default function TypedTestPage(props: PageProps<'/typed-test/[id]'>) {
return <p>상품 ID: {props.params.id}</p> // Property 'id' does not exist on type 'Promise<{ id: string; }>'.
}
이렇게 작성하면 이번에는 앞선 예시와 달리 next build 시점에 실제로 타입 에러가 발생하며 빌드가 실패합니다.
PageProps<'/typed-test/[id]'>가 만들어내는 params 타입이 정확히 Promise<{ id: string }>로 고정되어 있어, params.id처럼 Promise에 없는 프로퍼티에 접근하면 컴파일러가 이를 그대로 잡아내기 때문입니다.
// ✅ app/typed-test/[id]/page.tsx
export default async function TypedTestPage(props: PageProps<'/typed-test/[id]'>) {
const { id } = await props.params
return <p>상품 ID: {id}</p>
}
그러므로 page.tsx의 params 타입을 손으로 다시 적지 않고 PageProps, LayoutProps, RouteContext 같은 next typegen 헬퍼를 사용하는 것이 좋으며, 이렇게 하면 마이그레이션이 빠진 지점을 next build 시점에 미리 발견할 수 있습니다.
layout.tsx에서도 같은 방식으로 LayoutProps를 사용할 수 있습니다.
// app/typed-test/layout.tsx
export default function TypedTestLayout(props: LayoutProps<'/typed-test'>) {
return <section className="flex flex-col gap-4 p-6">{props.children}</section>
}
Route Handler에서는 PageProps 대신 RouteContext를 사용합니다.
// app/api/typed-test/[id]/route.ts
import { NextResponse } from 'next/server'
export async function GET(request: Request, context: RouteContext<'/api/typed-test/[id]'>) {
const { id } = await context.params
return NextResponse.json({ id })
}
다만 RouteContext는 프로젝트 안에 Route Handler(route.ts)가 하나도 없으면 생성되지 않거나 유효한 라우트를 찾지 못하므로, Route Handler가 없는 프로젝트에서 RouteContext를 먼저 참조하면 타입 에러가 발생합니다.
이 타입들은 next dev, next build, next typegen 중 하나가 최소 한 번 실행되어야 생성되기 때문에, CI 환경에서 tsc --noEmit만 단독으로 실행하면 타입을 찾지 못해 실패합니다.
그러므로 CI 스크립트에는 next typegen && tsc --noEmit처럼 typegen을 먼저 실행하는 순서를 넣어두는 것이 좋습니다.
다음으로는 페이지, 레이아웃, Route Handler뿐 아니라 opengraph-image, sitemap 같은 파일 컨벤션에서는 이 비동기화가 어떻게 적용되는지 살펴보겠습니다.
opengraph-image, sitemap의 함수별 params 비동기화 차이
opengraph-image.tsx처럼 파일 컨벤션으로 동작하는 메타데이터 라우트에도 이번 변경이 적용되지만, 한 파일 안에서 함수마다 적용 여부가 갈리는 지점이 있어 주의가 필요합니다.
generateImageMetadata의 params는 여전히 동기 객체이지만, 같은 파일의 기본 export 함수가 받는 params와 id는 각각 Promise로 전달됩니다.
// app/og-demo/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
export function generateImageMetadata({ params }: { params: { slug: string } }) {
return [
{ id: 'default', alt: `${params.slug} 미리보기`, size: { width: 600, height: 315 }, contentType: 'image/png' },
]
}
export default async function Image({ params, id }: { params: Promise<{ slug: string }>; id: Promise<string> }) {
const { slug } = await params
const imageId = await id
return new ImageResponse(
<div style={{ fontSize: 48, display: 'flex' }}>
{slug} / {imageId}
</div>,
)
}
이렇게 작성하면 generateImageMetadata는 params.slug로 곧바로 접근할 수 있지만, 바로 아래 있는 기본 export 함수는 같은 이름의 params라도 await 없이는 slug 값을 꺼낼 수 없습니다.
동적 세그먼트가 없는 경로에 opengraph-image를 두는 경우에는 조금 다른데, 이때는 params가 Promise도 아니고 아예 undefined로 전달됩니다.
그러므로 opengraph-image, icon 같은 메타데이터 파일을 작성할 때는 같은 파일 안이라도 함수별로 params의 타입이 다를 수 있다는 점을 먼저 확인하고 시작하는 것이 좋습니다.
sitemap.ts는 또 다른 지점에서 헷갈리기 쉽습니다.
여러 개의 sitemap으로 나누어 생성하는 generateSitemaps 패턴에서 Promise로 바뀐 것은 params가 아니라 id이며, generateSitemaps가 반환한 숫자 id도 실제로는 Promise<string>으로 전달되어 문자열 상태로 받게 됩니다.
// app/sitemap-demo/sitemap.ts
import type { MetadataRoute } from 'next'
export function generateSitemaps() {
return [{ id: 0 }, { id: 1 }]
}
export default async function sitemap({ id }: { id: Promise<string> }): Promise<MetadataRoute.Sitemap> {
const page = Number(await id)
return Array.from({ length: 5 }, (_, index) => ({
url: `https://example.com/products/${page * 5 + index}`,
lastModified: new Date(),
}))
}
이렇게 작성하면 generateSitemaps가 돌려준 숫자 0, 1이 sitemap 함수 안에서는 Promise<string>으로 넘어오므로, Number(await id)로 다시 숫자로 변환해야 페이지 번호로 사용할 수 있습니다.
manifest.ts는 이번 변경 대상에 포함되지 않으므로, sitemap과 같은 파일 컨벤션이라고 해서 manifest.ts에도 동일한 변경이 있을 것이라고 넘겨짚지 않는 것이 좋습니다.
지금까지 살펴본 것처럼 코드베이스 전체에서 이런 지점을 하나하나 손으로 찾아 고치기는 쉽지 않으므로, 다음으로는 이 작업을 자동화하는 코드모드를 살펴보겠습니다.
코드 전체를 한 번에 옮기는 next-async-request-api 코드모드
Next.js 팀은 params, searchParams, cookies, headers 등을 자동으로 비동기 형태로 바꿔주는 코드모드를 별도로 제공합니다.
// terminal
$ npx @next/codemod@canary next-async-request-api .
이 코드모드는 공식 코드모드 목록에서 Next.js 15.0 항목으로 분류되어 있어, Next.js 16 업그레이드 코드모드(upgrade)를 실행해도 함께 실행되지 않으므로 이 명령어는 별도로 실행하는 것이 좋습니다.
이 코드모드를 실행하면 동기 함수를 async로 바꾸고 접근 지점에 await를 자동으로 추가해 줍니다.
// 코드모드 실행 전
export default function Page({ params }: { params: { id: string } }) {
return <p>{params.id}</p>
}
// 코드모드 실행 후
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
return <p>{id}</p>
}
다만 일반 동기 함수 내부나 모듈 최상단처럼 async로 바꿀 수 없는 위치에서 값을 사용하는 경우까지는 자동으로 변환하지 못합니다.
이런 지점에는 코드모드가 대신 @next-codemod-error 주석을 남기는데, 이 주석이 남아 있는 동안에는 next dev와 next build 양쪽에서 모두 에러가 발생하므로, 직접 코드를 수정한 뒤 주석을 지우거나 @next-codemod-ignore로 바꾸는 것이 좋습니다.
Next.js 15 시절 이 코드모드를 이미 한 번 실행해 본 프로젝트라면 특히 더 주의할 부분이 있습니다.
당시 코드모드는 자동 변환이 어려운 지점에 UnsafeUnwrappedCookies, UnsafeUnwrappedHeaders, UnsafeUnwrappedDraftMode 같은 임시 타입 캐스팅을 남겨두는 경우가 있었는데, 이 타입들은 Next.js 16에서 완전히 제거되었습니다.
// ❌ Next.js 15 코드모드가 남긴 임시 캐스팅, 16에서는 존재하지 않는 타입
import { cookies, type UnsafeUnwrappedCookies } from 'next/headers'
const cookieStore = cookies() as unknown as UnsafeUnwrappedCookies
const theme = cookieStore.get('theme')
이렇게 작성된 코드가 남아 있으면 Next.js 16에서 다음과 같은 타입 에러로 빌드가 실패합니다.
Module '"next/headers.js"' has no exported member 'UnsafeUnwrappedCookies'.
// ✅ UnsafeUnwrappedCookies 캐스팅을 await로 직접 대체
import { cookies } from 'next/headers'
const cookieStore = await cookies()
const theme = cookieStore.get('theme')
그러므로 16으로 업그레이드하기 전에 코드베이스에서 UnsafeUnwrapped라는 이름을 미리 검색해, 남아 있는 캐스팅을 모두 await 방식으로 직접 고쳐두는 것이 좋습니다.
지금까지 살펴본 내용을 정리하면 다음과 같습니다.
- page.tsx의 params, searchParams를 손수 동기 타입으로 지정하면 next build는 통과하지만 값이 조용히 비어버림 — PageProps, LayoutProps, RouteContext 같은 next typegen 헬퍼를 사용해야 이 누락을 빌드 시점에 잡아낼 수 있음
- cookies, headers, draftMode는 반환 타입이 고정되어 있어 동기로 잘못 사용하면 즉시 타입 에러로 드러남
- cookies의 set/delete, draftMode의 enable/disable은 Server Action이나 Route Handler 안에서만 호출 가능
- opengraph-image의 generateImageMetadata는 동기, 기본 export 함수는 비동기 — 같은 파일 안에서도 함수별로 다름
- sitemap의 generateSitemaps는 동기, id는 Promise<string>으로 전달 — manifest.ts는 이번 변경 대상 아님
- next-async-request-api 코드모드는 16 업그레이드 코드모드에 포함되지 않으므로 별도 실행 필요, UnsafeUnwrapped* 캐스팅은 16에서 제거되어 수동으로 고치는 것이 필요함
개인적으로 가장 인상 깊었던 부분은 params 쪽이 cookies 쪽보다 오히려 더 위험하다는 사실이었습니다.
타입 에러로 바로 드러나는 실수는 그 자리에서 고치면 그만이지만, 빌드도 통과하고 화면도 200으로 뜨는데 값만 조용히 비어 있는 문제는 배포된 뒤에야 발견되기 쉽기 때문입니다.
그러므로 Next.js 16으로 업그레이드하는 프로젝트라면 코드모드 실행과 별개로, params와 searchParams를 손수 타이핑한 곳이 남아 있지 않은지 PageProps, LayoutProps, RouteContext로 바꿔가며 한 번 더 점검해 보는 것이 좋습니다.
이상으로 Next.js 16에서 완전히 제거된 Async Request APIs와 안전한 마이그레이션 방법에 대해 간단하게 알아보는 시간이었습니다.
읽어주셔서 감사합니다.
'SPA > Next' 카테고리의 다른 글
| Next.js 16, next lint 대신 ESLint CLI와 Biome 사이의 현실적인 선택 (0) | 2026.08.17 |
|---|---|
| Next.js 16 next/image 기본값 변경, quality 75부터 SSRF 방어까지 점검 가이드 (0) | 2026.08.12 |
| Next.js 16 App Router에서 Activity, useEffectEvent, View Transitions 통합하기 (0) | 2026.08.11 |
| Next.js 16, Layout Deduplication과 Incremental Prefetching 동작 원리 (0) | 2026.08.06 |
| React Compiler와 Next.js 16: useMemo/useCallback이 사라지는 이유 (0) | 2026.08.02 |
댓글