도서 목록·상세·검색
도서 목록과 상세 화면을 구현하고 페이지네이션 및 검색 흐름을 연결합니다.
핵심 기능 구현: 도서 목록 및 상세 페이지
데이터베이스에서 도서 정보를 가져와 화면에 표시하는 기능을 구현합니다.
서버 컴포넌트의 데이터 페칭을 활용합니다. 이 페이지는 목록·상세를 구현하고, 제목·저자 검색은 이어지는 주문 및 검색에서 목록 코드를 교체해 추가합니다.
도서 목록 페이지
도서 데이터를 페칭하여 목록을 표시하는 서버 컴포넌트를 구현합니다.
페이지네이션도 함께 고려합니다. 정해진 순서로 페이지를 나누기 위해 _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>
);
}도서 상세 페이지
동적 라우팅을 사용하여 특정 도서의 상세 정보를 표시합니다.
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() }));
// }