도서 목록·상세·검색
도서 목록과 상세 화면을 구현하고 페이지네이션 및 검색 흐름을 연결합니다.
핵심 기능 구현: 도서 목록 및 상세 페이지
데이터베이스에서 도서 정보를 가져와 화면에 표시하는 기능을 구현합니다.
서버 컴포넌트의 데이터 페칭을 활용합니다. 이 페이지는 목록·상세를 구현하고, 제목·저자 검색은 이어지는 주문 및 검색에서 목록 코드를 교체해 추가합니다.
도서 목록 페이지
도서 데이터를 페칭하여 목록을 표시하는 서버 컴포넌트를 구현합니다.
페이지네이션도 함께 고려합니다. 정해진 순서로 페이지를 나누기 위해 _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>
);
}페이지가 데이터베이스 조회를 기다리는 동안의 UI는 같은 라우트 세그먼트의 loading.tsx에 둡니다.
export default function BooksLoading() {
return <p className="p-8 text-center">도서 목록을 불러오는 중입니다...</p>;
}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>
);
}도서 상세 페이지
동적 라우팅을 사용하여 특정 도서의 상세 정보를 표시합니다.
목록·상세의 서버 조회와 응답
상세 페이지가 잘못된 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 | 조회 결과가 페이지 종료 형태를 정합니다. |
페이지 번호를 직접 보정하기
동시 도서 변경이 없는 가상의 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권 구축 결과를 뜻하지 않습니다.
상세 요청은 ID 형식을 먼저 검사하고, 통과한 경우에만 조회 결과를 확인합니다. 형식 오류와 존재하지 않는 유효 ID는 모두 notFound 경로지만, 전자는 DB 조회 전에 끝난다는 차이가 있습니다. DB 연결 오류를 없는 도서로 바꾸는 catch는 원 코드에 없습니다.
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() }));
// }