이번 편의 결과물: /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.json의 id는 숫자이고 bookId는 항상 문자열이므로, 비교 전에 Number(bookId)로 변환합니다. find가 아무것도 못 찾으면 undefined가 되고, 이 조건을 if (!book)으로 걸러 notFound()를 호출합니다.
3. 실행과 확인
npm run dev/books에서 책 제목을 클릭하면/books/1처럼 숫자가 붙은 주소로 이동합니다.- 상세 페이지에 제목·저자·쪽수·상태·메모가 보이고, “목록으로” 링크로 돌아갈 수 있습니다.
- 주소창에 직접
/books/999를 입력하면 목록에 없는 id이므로 Next.js 기본 404 화면이 뜹니다. memo가 빈 문자열인 책(리팩터링 2판 등)은 “메모 없음”으로 표시됩니다.
직접 해보기
- 상세 페이지
dl목록에startedAt,finishedAt항목을 추가해 읽기 시작·완독 날짜도 보이게 만들어 보세요. 값이null인 책은 “기록 없음”으로 표시합니다. bookId에 숫자가 아닌 문자열(/books/abc)을 넣으면 어떤 화면이 뜨는지 확인하고, 왜 그렇게 되는지Number('abc')의 결과로 설명해 보세요.
정답 보기
<dt>읽기 시작</dt>
<dd>{book.startedAt || '기록 없음'}</dd>
<dt>완독</dt>
<dd>{book.finishedAt || '기록 없음'}</dd>/books/abc는 Number('abc')가 NaN이 되어 어떤 책의 id와도 같지 않으므로 find가 undefined를 반환합니다. 결과적으로 존재하지 않는 id와 똑같이 404 화면이 뜹니다.
자주 하는 실수
| 증상 | 원인 | 고치는 법 |
|---|---|---|
params.bookId에서 타입 에러 발생 | params를 await 없이 바로 구조분해 | const { bookId } = await params로 먼저 값을 꺼낸다 |
| 폴더를 만들었는데 라우트가 안 잡힘 | [bookId] 대괄호를 빠뜨리거나 오타 | 폴더 이름이 정확히 대괄호를 포함하는지 확인한다 |
/books/1에서도 404가 뜸 | id 비교 시 문자열과 숫자를 그대로 비교 | Number(bookId)로 변환한 뒤 비교한다 |