이 블로그를 Next.js 16으로 올리던 첫날, 동적 라우트 페이지가 한꺼번에 빨갛게 깨졌습니다. 에러 메시지는 단순했습니다. "params should be awaited before using its properties." 분명 어제까지는 params.slug를 그냥 꺼내 쓰던 코드였습니다. 변경된 게 없는데 갑자기 await을 요구하니 처음에는 뭐가 잘못된 건가 의심했습니다. 결국 이유는 Next.js 16이 dynamic API를 완전히 비동기로 바꿨고, 이전 버전이 양보해 주던 호환성을 더는 두지 않기로 한 데 있었습니다.

v15에서 도입되고, v16에서 못을 박았다#

공식 업그레이드 가이드는 이 변화를 한 줄로 정리합니다.

Version 15 introduced Async Request APIs as a breaking change, with temporary synchronous compatibility. Starting with Next.js 16, synchronous access is fully removed.

영향을 받는 API는 다섯 군데입니다. cookies, headers, draftMode, 그리고 page와 layout이 받는 params, page가 받는 searchParams. v15는 호환성을 위해 sync 접근을 deprecation 경고와 함께 잠시 허용했습니다. v16은 그 호환 레이어를 제거했습니다. 이전에 deprecation 경고로 뜨던 자리가 이제 런타임 또는 타입 에러가 됩니다.

이게 왜 도입되었는지를 같이 보면 마이그레이션할 때 덜 답답합니다. params와 searchParams를 비동기로 바꾸면 Next.js가 라우트 트리를 더 일찍 streaming할 수 있습니다. 페이지의 정적인 부분은 params 결정이 끝나기 전에도 보낼 수 있고, 동적인 부분만 await 지점에서 끊깁니다. dynamic API를 명시적으로 await하게 만든 건 동기/비동기 경계가 코드에 보이게 하기 위한 변화입니다.

페이지 한 쪽에서 마이그레이션하는 패턴#

가장 자주 만지는 곳은 동적 라우트 페이지입니다. 이 블로그의 포스트 상세 페이지도 같은 패턴으로 정리했습니다.

// app/posts/[slug]/page.tsx
type PostPageProps = {
  params: Promise<{ slug: string }>;
};

export const dynamicParams = false;

export async function generateStaticParams() {
  const posts = await getAllPosts();
  return posts.map((post) => ({ slug: post.slug }));
}

export async function generateMetadata({
  params,
}: PostPageProps): Promise<Metadata> {
  const { slug } = await params;
  // ...
}

export default async function PostPage({ params }: PostPageProps) {
  const { slug } = await params;
  // ...
}

타입 한 줄(params: Promise<{ slug: string }>)과 await params 두 줄이 핵심입니다. generateMetadata와 default export 양쪽에서 같은 props를 받기 때문에, 한쪽만 고치면 빌드 에러가 남습니다. 동적 라우트가 여러 단계로 중첩되어 있다면([category]/[slug]처럼) params 객체 안에 양쪽 키가 모두 들어옵니다. 같은 패턴으로 처리하면 됩니다.

generateStaticParams는 예외입니다. 이 함수는 빌드 시점에 호출되고 정적 라우트 매니페스트를 만드는 용도라, 반환 타입은 여전히 sync입니다. 헷갈리기 쉬운 자리니, "라우트가 결정된 다음에 props로 흘러오는 params만 Promise"라고 묶어 외워 두면 편합니다.

타입을 매번 직접 적기 번거롭다면 npx next typegen으로 PageProps<'/posts/[slug]'>, LayoutProps<...> 같은 전역 헬퍼를 생성할 수 있습니다. 라우트 경로를 한 번만 적으면 nested params 타입까지 자동으로 결정됩니다.

opengraph-image와 sitemap도 같은 변화에 따라간다#

마이그레이션할 때 가장 잘 빠뜨리는 곳이 opengraph-image, twitter-image, icon, apple-icon 같은 이미지 생성 함수입니다. v16에서는 이 함수들이 받는 params도 Promise가 됐습니다. generateImageMetadata로 동적 id를 만들었다면 그 id 인자도 Promise입니다.

// app/posts/[slug]/opengraph-image.tsx
export async function generateImageMetadata({ params }) {
  // generateImageMetadata 자체는 sync params 유지
  const { slug } = params;
  return [{ id: "default" }];
}

export default async function Image({ params, id }) {
  const { slug } = await params; // 여기서는 await 필요
  const imageId = await id;       // generateImageMetadata가 있으면 id도 Promise
  // ...
}

사이트맵도 마찬가지입니다. generateSitemaps로 sitemap을 분할 생성한 경우, 자식 sitemap 함수가 받는 id가 Promise로 바뀌었습니다. 하나만 운영하는 사이트맵에는 영향이 없지만, OpenAPI나 카탈로그를 분할 색인하는 사이트라면 마이그레이션 체크리스트에 넣어야 합니다.

이쪽 변화는 페이지 본문보다 눈에 덜 띕니다. 빌드는 통과해도 OG 이미지 URL이 빈 slug로 굳어지거나, sitemap 분할 인덱스가 잘못 생성될 수 있습니다. opengraph-image 파일이 있는 라우트는 마이그레이션 직후 빌드 산출물의 opengraph-image 경로에 실제 slug가 박혀 있는지 한 번 눈으로 확인하는 게 안전합니다.

정적 export에서는 searchParams 자체가 함정이 된다#

이 사이트는 output: "export"로 빌드되는 정적 사이트입니다. 정적 export 환경에서는 searchParams가 Promise가 된 것보다 더 큰 문제가 있습니다. searchParams는 본질적으로 request-time API입니다. 빌드 시점에 알 수 없는 값이라, 이 API를 server component에서 await하는 순간 Next.js는 해당 페이지를 dynamic rendering으로 옮깁니다.

문제는 정적 export에는 dynamic rendering이 존재하지 않는다는 점입니다. 결과적으로 output: "export"와 server component의 searchParams await은 빌드 타임에 충돌합니다. v16 마이그레이션 와중에 server page에서 searchParams를 무심코 await하면, 빌드 자체가 막힙니다.

해결은 두 가지 갈래입니다. 하나는 검색·필터처럼 정말로 query string에 의존하는 UI를 client component로 분리해서 React의 use(searchParams) 훅으로 읽는 방식입니다. 클라이언트 측에서 동적으로 처리하니 정적 export와 호환됩니다.

"use client";
import { use } from "react";

type PageProps = {
  searchParams: Promise<{ q?: string }>;
};

export default function SearchUI({ searchParams }: PageProps) {
  const { q } = use(searchParams);
  // 클라이언트에서 동적 렌더링
}

다른 하나는 빌드 시 결정 가능한 값을 라우트 세그먼트로 끌어올리는 방식입니다. ?category=tech 대신 /posts/category/tech로 만들고 dynamicParams = false로 굳히면 정적 export 동적 라우트 글에서 다룬 흐름 그대로 정적 산출물에 떨어집니다. SEO 관점에서도 query string보다 path segment가 색인 친화적이라 일거양득입니다.

한 줄 차이지만 모든 동적 라우트가 영향을 받는다#

await 한 단어가 추가되는 변화로 보이지만, 이 마이그레이션은 dynamic API를 사용하는 모든 라우트를 건드립니다. codemod(npx @next/codemod async-request-api)로 일괄 변환할 수 있긴 한데, 변환 후 한 번씩은 손으로 훑어 보는 쪽을 권합니다. 특히 generateMetadata와 default export가 한 파일 안에 있는 경우, codemod가 한쪽만 손대고 끝나는 사례가 종종 보입니다.

새로 작성하는 코드라면 처음부터 params: Promise<{ ... }> 타입과 await params 패턴으로 쓰는 게 가장 안전합니다. v15 호환성을 의식하지 않고 await를 기본으로 두면, 이후 어떤 기능을 끼워 넣더라도 같은 모양으로 확장할 수 있습니다. 정적 export 운영 사이트라면 searchParams 의존을 server page에서 끄집어내는 결정도 같이 굳혀 두는 게 좋습니다.