도서 목록·상세·검색
도서 목록과 상세 화면을 구현하고 페이지네이션 및 검색 흐름을 연결합니다.
앞 글에서 프로젝트를 만들고 데이터 모델과 시드를 준비했습니다. 이 글에서는 데이터베이스에서 도서 정보를 가져와 화면에 보여 주는 목록과 상세 페이지를 서버 컴포넌트로 구현하고, 페이지네이션을 더합니다. 제목과 저자 검색은 이어지는 주문 및 검색에서 목록 코드를 바꾸어 추가합니다.
핵심 기능 구현: 도서 목록 및 상세 페이지
데이터베이스의 도서를 서버 컴포넌트에서 직접 조회해 보여 줍니다. 두 페이지 모두 별도의 API를 거치지 않고 connectToDatabase()와 Mongoose 모델을 바로 부릅니다. 이 조회 코드는 서버에서만 실행되므로 연결 문자열이 브라우저 번들에 들어가지 않고, 브라우저는 렌더링된 결과만 받습니다. 목록은 페이지 번호에 따라 12권씩 나누어 보여 주고, 상세는 URL의 도서 ID로 한 권을 찾습니다.
도서 목록 페이지
도서 데이터를 가져와 목록을 보여 주는 서버 컴포넌트를 구현하며, 페이지네이션도 함께 고려합니다. 정해진 순서로 페이지를 나누려고 _id로 정렬하고, 페이지 입력은 양의 정수로 제한합니다. 도서가 동시에 추가되거나 삭제되면 offset 방식의 페이지 구성은 바뀔 수 있습니다.
import connectToDatabase from '@/lib/db';
import Book, { type IBook } from '@/models/Book';
import BookCard from '@/components/BookCard';
interface BooksPageProps {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}
export default async function BooksPage({ searchParams }: BooksPageProps) {
const resolvedSearchParams = await searchParams;
const pageValue = Array.isArray(resolvedSearchParams.page)
? resolvedSearchParams.page[0]
: resolvedSearchParams.page;
const requestedPage = /^\d+$/.test(pageValue ?? '') ? Number(pageValue) : 1;
const limit = 12; // 페이지당 도서 수
await connectToDatabase();
const totalBooks = await Book.countDocuments();
const totalPages = Math.ceil(totalBooks / limit);
const page = Number.isSafeInteger(requestedPage)
? Math.min(Math.max(1, requestedPage), Math.max(1, totalPages))
: 1;
const skip = (page - 1) * limit;
const books: IBook[] = await Book.find({})
.sort({ _id: 1 })
.skip(skip)
.limit(limit)
.lean(); // Mongoose Document의 hydration을 생략해 일반 객체로 받음
return (
<main className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-8 text-center">모든 도서</h1>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
{books.map((book) => (
<BookCard key={book._id.toString()} book={book} />
))}
</div>
{/* 페이지네이션 컴포넌트 */}
<div className="flex justify-center mt-8 space-x-2">
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
<a
key={p}
href={`/books?page=${p}`}
className={`px-4 py-2 border rounded-md ${
p === page ? 'bg-blue-600 text-white' : 'bg-white text-blue-600 hover:bg-blue-100'
}`}
>
{p}
</a>
))}
</div>
</main>
);
}목록 페이지는 page 쿼리를 숫자일 때만 받아들이고, 전체 건수로 구한 totalPages 범위 안으로 맞춥니다. 예를 들어 도서가 시드의 두 권뿐이면 totalPages는 1이므로 ?page=5로 요청해도 1페이지를 보여 주고, ?page=abc처럼 숫자가 아니면 처음부터 1로 처리합니다. .lean()은 Mongoose 문서 대신 일반 객체를 돌려주므로, 값을 보여 주기만 하는 이 페이지에서는 문서 변환 비용을 아낄 수 있습니다.
페이지가 데이터베이스 조회를 기다리는 동안 보여 줄 UI는 같은 라우트 세그먼트의 loading.tsx에 둡니다. Next.js는 이 파일을 page.tsx를 감싸는 Suspense의 대체 화면으로 쓰므로, 페이지 코드 안에 로딩 상태를 따로 만들 필요가 없습니다.
export default function BooksLoading() {
return <p className="p-8 text-center">도서 목록을 불러오는 중입니다...</p>;
}목록의 각 도서는 BookCard 컴포넌트로 보여 주며, 카드를 누르면 상세 페이지로 이동합니다. 카드 전체를 Link로 감싸 누를 수 있는 영역을 넓히고, 상세 페이지 주소에는 ObjectId를 toString()으로 바꾼 문자열을 씁니다.
import Image from 'next/image';
import Link from 'next/link';
import type { IBook } from '@/models/Book';
interface BookCardProps {
book: IBook;
}
export default function BookCard({ book }: BookCardProps) {
return (
<Link href={`/books/${book._id.toString()}`} className="block">
<div className="bg-white rounded-lg shadow-md hover:shadow-xl transition-shadow duration-300 overflow-hidden">
<Image
src={book.imageUrl}
alt={book.title}
width={200}
height={250}
className="w-full h-auto object-cover"
/>
<div className="p-4">
<h3 className="text-lg font-semibold text-gray-800 mb-1 line-clamp-2">{book.title}</h3>
<p className="text-sm text-gray-600 mb-2">{book.author}</p>
<p className="text-xl font-bold text-blue-600">₩{book.price.toLocaleString()}</p>
</div>
</div>
</Link>
);
}표지의 width와 height는 원본 비율(200 대 250)을 알려 주는 값입니다. 실제 표시 너비는 w-full과 h-auto 클래스가 카드 너비에 맞춰 정하므로, 두 값은 고정 크기가 아니라 이미지가 늦게 도착해도 레이아웃이 밀리지 않도록 공간을 잡는 데 쓰입니다. 가격의 toLocaleString()은 인자 없이 서버에서 실행되므로, 천 단위 구분 기호의 모양은 서버 런타임의 기본 로캘을 따릅니다.
도서 상세 페이지
동적 라우팅으로 특정 도서의 상세 정보를 보여 줍니다. 상세 페이지는 먼저 ID의 형식을 검사하고, 유효할 때만 데이터베이스를 조회합니다.
import mongoose from 'mongoose';
import connectToDatabase from '@/lib/db';
import Book, { type IBook } from '@/models/Book';
import Image from 'next/image';
import { notFound } from 'next/navigation';
import AddToCartButton from '@/components/AddToCartButton'; // 장바구니 추가 버튼 (클라이언트 컴포넌트)
interface BookDetailPageProps {
params: Promise<{ id: string }>;
}
export default async function BookDetailPage({ params }: BookDetailPageProps) {
const { id } = await params;
if (!mongoose.isValidObjectId(id)) {
notFound();
}
await connectToDatabase();
const book: IBook | null = await Book.findById(id).lean();
if (!book) {
notFound(); // 도서가 없으면 404 페이지 표시
}
return (
<main className="container mx-auto px-4 py-8">
<div className="flex flex-col md:flex-row gap-8 bg-white p-8 rounded-lg shadow-lg">
<div className="shrink-0">
<Image
src={book.imageUrl}
alt={book.title}
width={300}
height={400}
className="rounded-lg shadow-md"
/>
</div>
<div className="grow">
<h1 className="text-4xl font-bold text-gray-800 mb-3">{book.title}</h1>
<p className="text-xl text-gray-600 mb-4">By {book.author}</p>
<div className="text-2xl font-bold text-blue-600 mb-6">₩{book.price.toLocaleString()}</div>
<p className="text-gray-700 leading-relaxed mb-6">{book.description}</p>
<div className="mb-4">
<span className="font-semibold text-gray-700">ISBN:</span> {book.isbn}
</div>
<div className="mb-4">
<span className="font-semibold text-gray-700">장르:</span> {book.genre.join(', ')}
</div>
<div className="mb-6">
<span className="font-semibold text-gray-700">재고:</span> {book.stock} 권
</div>
<AddToCartButton bookId={book._id.toString()} /> {/* 클라이언트 컴포넌트 사용 */}
</div>
</div>
</main>
);
}
// SSG를 위한 generateStaticParams (선택 사항, 대량의 책은 SSR이 효율적)
// export async function generateStaticParams() {
// await connectToDatabase();
// const books = await Book.find({}, { _id: 1 }).lean();
// return books.map((book) => ({ id: book._id.toString() }));
// }AddToCartButton은 장바구니에 도서를 담는 클라이언트 컴포넌트로, 다음 글에서 만듭니다. 그 전까지는 이 import 때문에 빌드가 실패하므로, 다음 글의 컴포넌트를 함께 준비하거나 잠시 그 줄을 빼 두고 확인합니다.
목록·상세의 서버 조회와 응답
상세 페이지가 잘못된 ID를 DB 전에 notFound 처리하고, 유효 ID는 연결·findById 뒤 없는 결과와 표시할 상품을 구분합니다. DB 예외를 notFound로 처리한다고 덧붙이지 않는 실제 요청 추적이 가능합니다.
Next · Catalog response cases
목록·상세의 서버 조회와 응답
목록은 URL의 page를 정규화해 최대 12권씩 조회합니다. 상세는 ID 형식과 조회 결과를 차례로 확인하고, 유효하지 않거나 찾지 못한 도서는 notFound로 처리합니다.
| 요청 경우 | 서버 조회 | 응답·표시 |
|---|---|---|
| 목록 /books | 건수 조회 → page 보정 → _id 정렬·skip·limit(12) 조회 | 조회한 목록과 페이지 링크; 대기 UI는 loading.tsx |
| 상세 · 잘못된 ID | isValidObjectId 검사 실패; 도서 조회 전 종료 | notFound() → 404 처리 |
| 상세 · 없는 도서 | 유효한 ID로 findById; 결과 없음 | notFound() → 404 처리 |
| 상세 · 찾은 도서 | 유효한 ID로 findById().lean() | 도서 값 표시; 장바구니 버튼에는 문자열 bookId 전달 |
목록 /books
- 서버 조회
- 건수 조회 → page 보정 → _id 정렬·skip·limit(12) 조회
- 응답·표시
- 조회한 목록과 페이지 링크; 대기 UI는 loading.tsx
상세 · 잘못된 ID
- 서버 조회
- isValidObjectId 검사 실패; 도서 조회 전 종료
- 응답·표시
- notFound() → 404 처리
상세 · 없는 도서
- 서버 조회
- 유효한 ID로 findById; 결과 없음
- 응답·표시
- notFound() → 404 처리
상세 · 찾은 도서
- 서버 조회
- 유효한 ID로 findById().lean()
- 응답·표시
- 도서 값 표시; 장바구니 버튼에는 문자열 bookId 전달
목록의 건수와 목록 조회는 별도 작업입니다. 고유한 _id 정렬도 동시 추가·삭제 중 offset 페이지를 같은 스냅샷으로 고정하지 않습니다. 주석 처리된 generateStaticParams는 실행되는 설정이 아닙니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 원 상세 페이지는 ID 형식 검사에 실패하면 조회 전에 notFound로 종료한다. 유효하면 DB 연결·findById 후 결과가 없으면 notFound로 종료하고, 있으면 도서 UI를 반환한다. DB 연결·조회 예외를 notFound로 바꾸는 catch는 없으며 이 그림은 정상 완료와 도서 없음의 두 종료를 보여 준다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 5 | 원 상세 페이지는 ID 형식 검사에 실패하면 조회 전에 notFound로 종료한다. 유효하면 DB 연결·findById 후 결과가 없으면 notFound로 종료하고, 있으면 도서 UI를 반환한다. DB 연결·조회 예외를 notFound로 바꾸는 catch는 없으며 이 그림은 정상 완료와 도서 없음의 두 종료를 보여 준다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| ID 검사 상세 페이지가 ID 형식을 먼저 확인합니다. | 유효하지 않음: notFound · DB 조회: 그 전에 없음 | 잘못된 ID가 데이터 접근 전에 종료됩니다. |
| 연결과 findById 유효 ID면 DB 연결 뒤 findById를 실행합니다. | ID: 유효 · 조회: findById · DB 예외 catch: notFound 변환 없음 | 상품 결과를 기다립니다. |
| 결과에 따라 출력 없는 결과는 notFound이고 있는 도서는 UI를 반환합니다. | 결과 없음: notFound · 결과 있음: 도서 UI | 조회 결과가 페이지 종료 형태를 정합니다. |
목록·상세의 서버 조회와 응답
상세 페이지가 잘못된 ID를 DB 전에 notFound 처리하고, 유효 ID는 연결·findById 뒤 없는 결과와 표시할 상품을 구분합니다. DB 예외를 notFound로 처리한다고 덧붙이지 않는 실제 요청 추적이 가능합니다.
Next · Catalog response cases
목록·상세의 서버 조회와 응답
목록은 URL의 page를 정규화해 최대 12권씩 조회합니다. 상세는 ID 형식과 조회 결과를 차례로 확인하고, 유효하지 않거나 찾지 못한 도서는 notFound로 처리합니다.
| 요청 경우 | 서버 조회 | 응답·표시 |
|---|---|---|
| 목록 /books | 건수 조회 → page 보정 → _id 정렬·skip·limit(12) 조회 | 조회한 목록과 페이지 링크; 대기 UI는 loading.tsx |
| 상세 · 잘못된 ID | isValidObjectId 검사 실패; 도서 조회 전 종료 | notFound() → 404 처리 |
| 상세 · 없는 도서 | 유효한 ID로 findById; 결과 없음 | notFound() → 404 처리 |
| 상세 · 찾은 도서 | 유효한 ID로 findById().lean() | 도서 값 표시; 장바구니 버튼에는 문자열 bookId 전달 |
목록 /books
- 서버 조회
- 건수 조회 → page 보정 → _id 정렬·skip·limit(12) 조회
- 응답·표시
- 조회한 목록과 페이지 링크; 대기 UI는 loading.tsx
상세 · 잘못된 ID
- 서버 조회
- isValidObjectId 검사 실패; 도서 조회 전 종료
- 응답·표시
- notFound() → 404 처리
상세 · 없는 도서
- 서버 조회
- 유효한 ID로 findById; 결과 없음
- 응답·표시
- notFound() → 404 처리
상세 · 찾은 도서
- 서버 조회
- 유효한 ID로 findById().lean()
- 응답·표시
- 도서 값 표시; 장바구니 버튼에는 문자열 bookId 전달
목록의 건수와 목록 조회는 별도 작업입니다. 고유한 _id 정렬도 동시 추가·삭제 중 offset 페이지를 같은 스냅샷으로 고정하지 않습니다. 주석 처리된 generateStaticParams는 실행되는 설정이 아닙니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 원 상세 페이지는 ID 형식 검사에 실패하면 조회 전에 notFound로 종료한다. 유효하면 DB 연결·findById 후 결과가 없으면 notFound로 종료하고, 있으면 도서 UI를 반환한다. DB 연결·조회 예외를 notFound로 바꾸는 catch는 없으며 이 그림은 정상 완료와 도서 없음의 두 종료를 보여 준다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 5 | 원 상세 페이지는 ID 형식 검사에 실패하면 조회 전에 notFound로 종료한다. 유효하면 DB 연결·findById 후 결과가 없으면 notFound로 종료하고, 있으면 도서 UI를 반환한다. DB 연결·조회 예외를 notFound로 바꾸는 catch는 없으며 이 그림은 정상 완료와 도서 없음의 두 종료를 보여 준다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| ID 검사 상세 페이지가 ID 형식을 먼저 확인합니다. | 유효하지 않음: notFound · DB 조회: 그 전에 없음 | 잘못된 ID가 데이터 접근 전에 종료됩니다. |
| 연결과 findById 유효 ID면 DB 연결 뒤 findById를 실행합니다. | ID: 유효 · 조회: findById · DB 예외 catch: notFound 변환 없음 | 상품 결과를 기다립니다. |
| 결과에 따라 출력 없는 결과는 notFound이고 있는 도서는 UI를 반환합니다. | 결과 없음: notFound · 결과 있음: 도서 UI | 조회 결과가 페이지 종료 형태를 정합니다. |
위 그림은 목록과 상세 페이지가 서버에서 무엇을 조회하고 어떻게 응답하는지 보여 줍니다. 위쪽 흐름은 상세 페이지의 처리입니다. params의 ID가 유효한 형식이 아니면 조회하기 전에 notFound로 끝나고, 유효하면 DB에 연결해 findById를 실행한 뒤 결과가 없으면 notFound, 있으면 도서 UI를 돌려줍니다. 그림의 단계도 ID 검사, 연결과 findById, 결과에 따른 출력 순서입니다. DB 연결이나 조회의 예외를 notFound로 바꾸는 catch는 없으므로, 그림은 정상 완료와 도서 없음의 두 가지 끝만 보여 줍니다.
아래쪽 표는 요청별로 정리합니다. 목록 /books는 건수를 조회해 page를 보정한 뒤 _id 정렬, skip, limit(12)로 목록을 조회하고, 조회한 목록과 페이지 링크를 보여 주며 대기 UI는 loading.tsx가 맡습니다. 상세 페이지는 잘못된 ID이면 isValidObjectId 검사에서 도서를 조회하기 전에 끝나 notFound()로 404 처리되고, 유효한 ID로 findById했는데 결과가 없어도 같은 처리를 합니다. 도서를 찾으면 findById().lean()의 값을 보여 주고, 장바구니 버튼에는 문자열 bookId를 넘깁니다.
표의 아래 설명처럼 목록의 건수 조회와 목록 조회는 서로 다른 작업이며, 고유한 _id로 정렬해도 동시에 추가나 삭제가 일어나는 동안 offset 페이지가 같은 상태로 고정되지는 않습니다. 코드 끝에 주석으로 둔 generateStaticParams는 실행되는 설정이 아닙니다.
정리
- 도서 목록은 서버 컴포넌트에서
searchParams의page를 양의 정수로 정규화하고, 전체 페이지 수 안으로 보정해skip과limit으로 조회합니다. _id정렬로 페이지 순서를 정하지만, 동시에 도서가 바뀌면 offset 페이지의 구성은 달라질 수 있습니다.- 데이터를 기다리는 동안의 UI는 같은 세그먼트의
loading.tsx에 둡니다. - 상세 페이지는
params를await한 뒤 ID 형식을 먼저 검사하고, 형식 오류와 없는 도서를 모두notFound()로 처리합니다. lean()은 Mongoose 문서 대신 일반 객체를 돌려주며, 클라이언트 컴포넌트에는_id를 문자열로 바꾸어 넘깁니다.
연습 문제
1. 페이지 번호를 직접 보정하기
동시에 도서가 바뀌지 않는 가상의 50권 데이터에서 page에 999를 요청하면, 전체 페이지 수, 보정된 page, skip은 각각 얼마일까요? 두 권짜리 시드에서 같은 요청을 했을 때와, page에 1.5를 넣었을 때도 계산해 보세요.
해설 보기
50권이면 ceil(50 / 12) = 5페이지이며, 999는 5로 제한되어 skip은 (5 - 1) × 12 = 48입니다. 마지막 페이지에는 2권이 남습니다. 두 권짜리 시드는 1페이지뿐이므로 page는 1, skip은 0입니다. 1.5는 숫자 문자만 허용하는 정규식에 맞지 않으므로 1로 처리됩니다. 이는 가상 조건의 계산이며, 실제로 50권을 구축한 결과를 뜻하지 않습니다.
2. 형식 오류와 없는 도서
/books/abc와, 형식은 유효하지만 존재하지 않는 ID인 /books/000000000000000000000000을 각각 요청했습니다. 두 요청은 같은 화면을 보여 줄까요? DB에는 각각 어떤 작업이 일어날까요?
해설 보기
두 요청 모두 notFound() 경로로 이어지므로 같은 not-found 화면을 보여 줍니다. 차이는 그 전의 작업입니다. abc는 isValidObjectId 검사에서 실패해 DB에 연결하거나 조회하기 전에 끝나고, 유효한 형식의 ID는 DB에 연결해 findById를 실행한 뒤 결과가 없어서 끝납니다. DB 연결 오류는 이 두 경로와 달리 notFound로 바뀌지 않고 그대로 오류로 전달됩니다.
3. 페이지 링크를 <a>로 둔 이유
목록의 페이지네이션은 <a href>로 페이지를 이동합니다. 이 링크를 next/link의 <Link>로 바꾸면 무엇이 달라질까요?
해설 보기
<a href>는 링크를 누를 때마다 브라우저가 문서 전체를 새로 요청합니다. <Link>로 바꾸면 Next.js의 클라이언트 탐색이 쓰여, 공통 레이아웃을 다시 그리지 않고 바뀐 부분의 서버 컴포넌트 결과만 받아 화면을 갱신하므로 이동이 더 빠르고 부드럽습니다. 화면에 보이는 링크는 프리페치(prefetch)될 수도 있습니다. 두 방식 모두 서버 컴포넌트가 searchParams를 읽어 목록을 조회한다는 점은 같습니다.
Mongoose의 조회 메서드는 Mongoose 쿼리 문서에서, notFound의 동작은 Next.js 공식 문서의 notFound 레퍼런스에서 확인할 수 있습니다.
다음 글 장바구니 구현에서는 서버 액션으로 로그인한 사용자의 장바구니에 도서를 담고, 수량을 바꾸고, 지우는 기능을 구현합니다.