Next.js

Next.js params should be awaited 오류 해결: App Router params 처리 기준

2026.05.19·수정 2026.05.20·약 18분

이 글에서 정리하는 내용

Next.js 15에서 App Router의 paramssearchParams를 기존처럼 바로 읽을 때 발생하는 params should be awaited 오류를 정리합니다. 단순히 await를 아무 위치에 붙이는 문제가 아니라, page, layout, route handler, generateMetadata 중 어디에서 값을 읽는지에 따라 수정 기준이 달라집니다.

오류 문구가 말하는 실제 의미

Next.js App Router params should be awaited 오류가 발생하는 page layout route handler 위치 점검 흐름

Next.js App Router에서 app/blog/[slug]/page.tsx 같은 동적 라우트를 만들면 params.slug를 바로 읽는 코드가 자연스럽게 나옵니다. Next.js 14 이전 예제나 오래된 블로그 글을 참고했다면 더 익숙한 형태입니다. 그런데 Next.js 15 기준에서는 params, searchParams가 비동기 Dynamic API로 다뤄지기 때문에, 일반 객체처럼 바로 접근하면 params should be awaited 계열의 경고나 오류가 나타날 수 있습니다.

이 오류를 처음 보면 폴더 이름부터 의심하게 됩니다. [slug]를 잘못 만들었는지, page.tsx 위치가 틀렸는지, 혹은 빌드 캐시가 꼬인 것처럼 보일 수 있습니다. 물론 라우트 구조가 맞는지도 봐야 하지만, 이 오류의 중심은 파일 위치보다 값을 읽는 시점입니다. params 자체가 Promise 형태로 넘어오는데, 그 안의 slug를 동기 객체 속성처럼 먼저 꺼내려고 해서 문제가 생깁니다.

작업할 때는 에러가 찍힌 파일 하나만 보지 않는 편이 좋습니다. 같은 동적 세그먼트는 page.tsx뿐 아니라 layout.tsx, generateMetadata, route.ts에서도 다시 사용될 수 있습니다. 화면 본문만 고쳤는데 빌드에서 같은 메시지가 남는 경우는 대개 이 주변 파일 중 하나에 params.slugsearchParams.page 같은 코드가 남아 있을 때입니다.

검색 키워드는 단순하게 잡는 것이 빠릅니다. 프로젝트 전체에서 params., searchParams., const {, } = params를 찾아보면 바로 접근한 흔적을 잡기 쉽습니다. 특히 searchParams는 페이지네이션, 검색어, 탭 상태를 처리할 때 자주 쓰이기 때문에 params만 고치고 놓치는 경우가 있습니다.

page.tsx에서 먼저 고쳐야 하는 부분

가장 흔한 위치는 동적 상세 페이지입니다. 예를 들어 /blog/[slug] 구조에서 slug로 게시글 데이터를 조회하거나 제목을 출력하는 경우입니다. 기존 코드가 아래처럼 되어 있다면 Next.js 15 기준에서는 수정 대상입니다.

수정 전 코드

type Props = {
  params: { slug: string }
}

export default function Page({ params }: Props) {
  return <h1>{params.slug}</h1>
}

이 코드는 params를 일반 객체로 보고 있습니다. 그래서 컴포넌트도 동기 함수이고, 타입도 { slug: string }입니다. Next.js 15에서는 페이지 props의 params를 Promise로 보고, 값을 쓰기 전에 먼저 기다린 뒤 구조분해하는 형태로 바꾸는 것이 기준입니다.

수정 후 코드

type Props = {
  params: Promise<{ slug: string }>
}

export default async function Page({ params }: Props) {
  const { slug } = await params

  return <h1>{slug}</h1>
}

여기서 조심할 부분은 await params.slug가 아니라는 점입니다. 기다려야 하는 대상은 slug 속성이 아니라 params 자체입니다. 먼저 const { slug } = await params로 Promise를 풀고, 그다음부터 slug를 문자열 값처럼 사용합니다.

데이터 조회가 붙어 있으면 차이가 더 분명해집니다. slug를 꺼내기 전에 API 호출이나 DB 조회 함수를 먼저 만들 필요는 없습니다. 동적 세그먼트 값을 먼저 풀고, 그 값을 기준으로 조회 함수를 호출하는 순서가 읽기 쉽습니다.

type PageProps = {
  params: Promise<{ slug: string }>
}

async function getPost(slug: string) {
  const res = await fetch(`https://example.com/api/posts/${slug}`)

  if (!res.ok) {
    throw new Error('게시글을 불러오지 못했습니다.')
  }

  return res.json()
}

export default async function Page({ params }: PageProps) {
  const { slug } = await params
  const post = await getPost(slug)

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.description}</p>
    </article>
  )
}

TypeScript 프로젝트라면 타입도 같이 수정해야 합니다. 런타임 코드만 await로 바꾸고 타입을 { slug: string }으로 두면 에디터 오류가 남거나, 이후 수정 과정에서 다시 예전 방식의 코드가 섞일 수 있습니다. 동적 라우트 props는 코드와 타입을 동시에 바꾸는 것이 재발 방지에 더 직접적입니다.

PageProps를 쓰는 방식

export default async function Page(props: PageProps<'/blog/[slug]'>) {
  const { slug } = await props.params
  const query = await props.searchParams

  return <h1>Blog Post: {slug}</h1>
}

PageProps를 사용할 수 있는 환경이라면 라우트 문자열 기준으로 paramssearchParams 타입을 맞출 수 있습니다. 직접 타입을 쓰는 방식도 가능하지만, /shop/[category]/[item]처럼 동적 세그먼트가 여러 개로 늘어나면 라우트 구조와 타입이 따로 움직이기 쉽습니다. PageProps<'/blog/[slug]'>처럼 라우트 리터럴을 기준으로 잡으면 자동완성과 키 검사를 활용하기 좋습니다.

단, PageProps는 타입 생성이 된 뒤 전역 헬퍼로 사용할 수 있다는 점을 같이 봐야 합니다. 새 프로젝트에서 에디터가 바로 인식하지 못한다면 next dev, next build, 또는 타입 생성 과정을 거친 뒤 다시 확인합니다. 이 부분을 모르고 무조건 import하려고 하면 오히려 불필요한 수정으로 이어질 수 있습니다.

generateMetadata와 route handler에서 놓치기 쉬운 부분

상세 페이지에서 제목, 설명, OG 이미지를 동적으로 만들 때는 generateMetadata 안에서도 params를 읽습니다. 이 부분은 화면에 직접 보이지 않아서 누락되기 쉽습니다. 본문 컴포넌트는 정상 렌더링되는데 빌드나 배포 단계에서 다시 오류가 보인다면, 메타데이터 함수에 예전 코드가 남아 있는지 확인해야 합니다.

import type { Metadata } from 'next'

type Props = {
  params: Promise<{ slug: string }>
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params

  return {
    title: `${slug} 글 상세`,
    description: `${slug} 글의 상세 내용을 확인합니다.`,
  }
}

export default async function Page({ params }: Props) {
  const { slug } = await params

  return <article>{slug}</article>
}

여기서 중요한 것은 page 컴포넌트와 generateMetadata가 같은 Props 타입을 공유하더라도, 각 함수 안에서 값을 읽는 위치는 별도로 존재한다는 점입니다. generateMetadata에서 params.slug를 직접 읽고 있었다면 그 함수 안에서도 await params가 필요합니다.

Route Handler에서도 같은 규칙이 적용됩니다. 예를 들어 app/api/posts/[id]/route.ts처럼 API 경로에서 id를 읽는다면, 두 번째 인자로 받는 context의 params를 Promise로 처리합니다.

export async function GET(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params

  return Response.json({ id })
}

Route Handler는 page 컴포넌트와 작성 모양이 조금 다릅니다. 첫 번째 인자는 request이고, 동적 세그먼트 값은 두 번째 인자의 params에 들어옵니다. 그래서 페이지에서 쓰던 타입을 그대로 복사하기보다, handler의 함수 시그니처를 기준으로 수정해야 합니다.

import type { NextRequest } from 'next/server'

export async function GET(_req: NextRequest, ctx: RouteContext<'/users/[id]'>) {
  const { id } = await ctx.params

  return Response.json({ id })
}

RouteContext를 사용할 수 있다면 라우트 리터럴 기준으로 context 타입을 잡을 수 있습니다. PageProps와 마찬가지로 타입 생성 이후 전역 헬퍼로 사용할 수 있으므로, 별도 import를 추가하기 전에 현재 프로젝트에서 타입 생성이 되어 있는지 먼저 확인합니다.

반대로 generateStaticParams는 이름이 비슷해서 헷갈리지만, 페이지에 전달된 params를 읽는 함수가 아닙니다. 빌드 시점에 어떤 동적 경로를 미리 만들지 배열로 반환하는 함수입니다. 따라서 generateStaticParams 내부를 무조건 await params 형태로 바꾸면 문제를 잘못 건드리게 됩니다.

위치확인할 코드수정 기준
page.tsxparams.slug, searchParams.pageawait params, await searchParams 후 사용
layout.tsxparams로 세그먼트 값 접근layout props의 params 타입과 접근 방식 수정
generateMetadata동적 title, description 생성메타데이터 함수 내부에서도 await params
route.tsAPI 경로의 [id], [slug]context의 params를 Promise로 처리
generateStaticParams정적 생성할 경로 배열 반환전달받은 params를 읽는 함수가 아니므로 별도로 판단

Client Component에서는 다른 방식으로 접근하기

'use client'가 붙은 컴포넌트에서는 Server Component의 page props를 다루는 방식과 클라이언트 라우팅 훅을 구분해야 합니다. 자식 Client Component에서 현재 URL의 동적 세그먼트를 읽어야 한다면 next/navigationuseParams를 사용합니다. 쿼리스트링은 useSearchParams로 읽습니다.

'use client'

import { useParams, useSearchParams } from 'next/navigation'

export default function PostToolbar() {
  const params = useParams<{ slug: string }>()
  const searchParams = useSearchParams()
  const tab = searchParams.get('tab') ?? 'summary'

  return (
    <div>
      <span>현재 글: {params.slug}</span>
      <span>선택 탭: {tab}</span>
    </div>
  )
}

이 코드는 await params로 고치는 대상이 아닙니다. useParams는 클라이언트에서 현재 URL의 동적 값을 읽는 훅이고, useSearchParams는 읽기 전용 URLSearchParams 형태로 쿼리스트링을 다룹니다. 서버 page props의 searchParams는 일반 객체 형태의 Promise이고, 클라이언트 훅의 useSearchParams는 메서드 기반 객체라는 차이도 같이 봐야 합니다.

Client Component 페이지가 props로 Promise 형태의 params 또는 searchParams를 직접 받는 구조라면 React의 use()로 풀어 읽는 방식도 있습니다. 다만 일반적인 작업에서는 페이지 자체를 Server Component로 두고, 상호작용이 필요한 작은 영역만 Client Component로 분리하는 구성이 더 흔합니다.

type PageProps = {
  params: Promise<{ slug: string }>
}

export default async function Page({ params }: PageProps) {
  const { slug } = await params

  return <PostToolbar slug={slug} />
}

이렇게 나누면 서버에서는 Next.js 15의 비동기 Dynamic API 규칙을 따르고, 클라이언트 컴포넌트는 이미 정리된 문자열 값을 받아 화면 상호작용에 집중할 수 있습니다. 상세 페이지 안에 댓글, 공유 버튼, 탭, 필터가 붙기 시작하면 이 분리가 유지보수에 더 직접적으로 영향을 줍니다.

수정 후 다시 확인할 체크포인트

Next.js App Router params should be awaited 오류 수정 후 빌드와 타입을 확인하는 검증 흐름

코드를 고친 뒤에는 개발 서버 화면만 확인하지 말고 빌드까지 확인해야 합니다. 개발 중에는 경고처럼 보이던 문제가 빌드, 배포, cacheComponents 설정과 만나면서 오류로 드러날 수 있습니다. 최소한 npm run build를 실행해 같은 메시지가 남는지 확인합니다.

프로젝트 전체 검색도 다시 하는 것이 좋습니다. params.slug처럼 점 접근을 쓰는 코드, const { slug } = params처럼 await 없이 구조분해하는 코드, searchParams.page처럼 쿼리 값을 바로 읽는 코드가 남아 있는지 확인합니다. 오래된 예제를 복사한 파일이나, 동적 라우트마다 반복해서 만든 메타데이터 함수에서 누락이 자주 나옵니다.

Next.js 15 업그레이드 과정에서 codemod를 사용했다면 @next-codemod-error 문자열도 찾아볼 필요가 있습니다. 자동 변환이 애매한 파일에는 이런 흔적이 남을 수 있고, 그 부분은 사람이 직접 구조를 보고 수정해야 합니다. 자동 변환을 통과했다고 해서 모든 동적 API 접근이 정리됐다고 보기는 어렵습니다.

  • page.tsx, layout.tsx, route.ts, generateMetadata를 함께 검색합니다.
  • await params.slug가 아니라 const { slug } = await params 형태인지 확인합니다.
  • searchParams도 같은 기준으로 Promise 처리했는지 확인합니다.
  • Client Component의 useParams, useSearchParams까지 무리하게 바꾸지 않았는지 확인합니다.
  • PageProps, RouteContext를 쓴다면 타입 생성이 되어 있는지 확인합니다.
  • 수정 후 npm run build로 빌드 단계의 경고와 오류를 확인합니다.

참고 자료 기준

이 글은 Next.js 15 기준의 Dynamic APIs 비동기 처리, page props, generateMetadata segment props, Route Handler context, useParams, useSearchParams 설명을 기준으로 정리했습니다. 버전 차이가 중요한 주제이므로 Next.js 14 이하 프로젝트나 Pages Router 중심 프로젝트에서는 같은 수정 방식을 그대로 적용하기 전에 현재 버전과 라우터 구조를 먼저 확인해야 합니다.

정리

params should be awaited 오류는 동적 라우트 자체가 잘못되었다는 뜻이라기보다, Next.js 15에서 Dynamic API를 비동기 값으로 다루도록 바뀐 영향으로 보는 것이 정확합니다. paramssearchParams를 일반 객체처럼 바로 읽던 코드는 await 이후에 구조분해하는 방식으로 고쳐야 합니다.

다음에 같은 오류를 줄이려면 수정 순서를 정해두는 편이 낫습니다. 먼저 오류가 난 파일을 고치고, 이어서 같은 라우트의 generateMetadata, layout.tsx, route.ts를 확인합니다. 마지막으로 프로젝트 전체 검색과 빌드 확인을 거치면, 화면에서는 괜찮아 보이는데 배포에서 다시 막히는 상황을 줄일 수 있습니다.

실제 수정 기준은 한 줄로 줄일 수 있습니다. 서버에서 page props나 route context로 넘어온 params는 먼저 기다린 뒤 사용하고, 클라이언트에서 현재 URL 값을 읽어야 할 때는 useParamsuseSearchParams를 따로 봅니다. 이 둘을 구분하면 Next.js App Router에서 동적 라우트 오류를 추적하는 시간이 확실히 줄어듭니다.

이 글이 마음에 드세요?

RSS 피드를 구독하세요!

댓글 남기기