Skip to Content
WebReactNext.js05. 동적 라우트로 만드는 책 상세 페이지

이번 편의 결과물: /books/1처럼 URL로 접근하면 해당 책 상세가 보이고, 없는 id면 404 화면이 뜹니다. · 다루는 개념: [bookId] 동적 세그먼트, 파라미터로 책 조회, notFound()로 404 처리

04편까지 bookshelf-next/books에서 시드 데이터 6권을 목록으로 보여줍니다. 이 편에서는 목록의 각 책을 눌렀을 때 이동할 상세 페이지를 만듭니다. 책마다 URL이 따로 있어야 하므로, 폴더 이름 자체에 변수를 넣는 동적 라우트를 씁니다.

이 편에서 만드는 파일

bookshelf-next/ └── app/ └── books/ ├── page.js ~ (책 제목에 상세 페이지 링크 추가) └── [bookId]/ └── page.js + (동적 세그먼트, 책 상세 렌더)

개념 정리

동적 세그먼트

폴더 이름을 대괄호로 감싸면 그 자리에 오는 값이 전부 하나의 파라미터로 매핑됩니다.

폴더 경로접속 URL파라미터
app/books/[bookId]/page.js/books/1{ bookId: '1' }
app/books/[bookId]/page.js/books/6{ bookId: '6' }

폴더 이름 [bookId]와 컴포넌트에서 꺼내 쓰는 변수 이름 bookId를 맞춰두면 코드를 읽을 때 헷갈리지 않습니다.

params는 Promise

page.js가 받는 params는 즉시 쓸 수 있는 객체가 아니라 Promise입니다. Next.js 15부터 이렇게 바뀌었고, Next.js 16도 같은 방식입니다. 반드시 await로 값을 꺼낸 뒤 사용합니다.

export default async function Page({ params }) { const { bookId } = await params }

컴포넌트를 async function으로 선언하지 않으면 await를 쓸 수 없으므로, 동적 라우트의 page.js는 항상 비동기 함수로 작성합니다.

notFound()로 404 처리

next/navigation이 제공하는 notFound 함수를 호출하면 그 지점에서 렌더링이 멈추고 가장 가까운 404 화면이 대신 나타납니다. 지금은 아직 전용 화면(not-found.js)을 만들지 않았으므로 Next.js가 기본으로 제공하는 404 화면이 뜹니다. 06편에서 직접 만든 화면으로 바꿉니다.

notFound()는 값을 반환하지 않고 예외를 던지는 방식으로 동작하므로, 호출 뒤에 이어지는 코드는 실행되지 않습니다. return notFound()처럼 쓸 필요 없이 호출 한 줄이면 충분합니다.

실습

1. 목록 페이지에 상세 링크 추가

app/books/page.js에서 책 제목을 Link로 감싸 각 책의 상세 페이지로 연결합니다.

// bookshelf-next/app/books/page.js import Link from 'next/link' import books from '@/data/books.seed.json' export default function BooksPage() { return ( <section> <h1>책 목록</h1> <ul className="book-list"> {books.map((book) => ( <li key={book.id}> <Link href={`/books/${book.id}`}>{book.title}</Link> <p>{book.author}</p> </li> ))} </ul> </section> ) }

2. 동적 세그먼트 폴더와 상세 페이지 작성

app/books 아래에 [bookId] 폴더를 만들고 그 안에 page.js를 작성합니다.

// bookshelf-next/app/books/[bookId]/page.js import { notFound } from 'next/navigation' import Link from 'next/link' import books from '@/data/books.seed.json' export default async function BookDetailPage({ params }) { const { bookId } = await params const book = books.find((item) => item.id === Number(bookId)) if (!book) { notFound() } return ( <article> <p> <Link href="/books">목록으로</Link> </p> <h1>{book.title}</h1> <p>{book.author}</p> <dl> <dt>쪽수</dt> <dd>{book.pages}쪽</dd> <dt>상태</dt> <dd>{book.status}</dd> <dt>메모</dt> <dd>{book.memo || '메모 없음'}</dd> </dl> </article> ) }

books.seed.jsonid는 숫자이고 bookId는 항상 문자열이므로, 비교 전에 Number(bookId)로 변환합니다. find가 아무것도 못 찾으면 undefined가 되고, 이 조건을 if (!book)으로 걸러 notFound()를 호출합니다.

3. 실행과 확인

npm run dev
  • /books에서 책 제목을 클릭하면 /books/1처럼 숫자가 붙은 주소로 이동합니다.
  • 상세 페이지에 제목·저자·쪽수·상태·메모가 보이고, “목록으로” 링크로 돌아갈 수 있습니다.
  • 주소창에 직접 /books/999를 입력하면 목록에 없는 id이므로 Next.js 기본 404 화면이 뜹니다.
  • memo가 빈 문자열인 책(리팩터링 2판 등)은 “메모 없음”으로 표시됩니다.

직접 해보기

  1. 상세 페이지 dl 목록에 startedAt, finishedAt 항목을 추가해 읽기 시작·완독 날짜도 보이게 만들어 보세요. 값이 null인 책은 “기록 없음”으로 표시합니다.
  2. bookId에 숫자가 아닌 문자열(/books/abc)을 넣으면 어떤 화면이 뜨는지 확인하고, 왜 그렇게 되는지 Number('abc')의 결과로 설명해 보세요.

정답 보기

<dt>읽기 시작</dt> <dd>{book.startedAt || '기록 없음'}</dd> <dt>완독</dt> <dd>{book.finishedAt || '기록 없음'}</dd>

/books/abcNumber('abc')NaN이 되어 어떤 책의 id와도 같지 않으므로 findundefined를 반환합니다. 결과적으로 존재하지 않는 id와 똑같이 404 화면이 뜹니다.

자주 하는 실수

증상원인고치는 법
params.bookId에서 타입 에러 발생paramsawait 없이 바로 구조분해const { bookId } = await params로 먼저 값을 꺼낸다
폴더를 만들었는데 라우트가 안 잡힘[bookId] 대괄호를 빠뜨리거나 오타폴더 이름이 정확히 대괄호를 포함하는지 확인한다
/books/1에서도 404가 뜸id 비교 시 문자열과 숫자를 그대로 비교Number(bookId)로 변환한 뒤 비교한다

확인 문제

문제 14지선다
app/books/[bookId]/page.js 폴더 구조에서 대괄호가 의미하는 것은
문제 24지선다
동적 라우트의 page.js에서 params를 다루는 방법으로 올바른 것은
문제 34지선다
notFound()를 호출했을 때 일어나는 일은
문제 44지선다
이 편에서 상세 페이지의 book을 찾을 때 Number(bookId)로 변환하는 이유는

참고 자료

Last updated on