17장 : 실전 프로젝트단계별 구현 가이드
도서 목록·상세·검색
도서 목록과 상세 화면을 구현하고 페이지네이션 및 검색 흐름을 연결합니다.
핵심 기능 구현: 도서 목록 및 상세 페이지
데이터베이스에서 도서 정보를 가져와 화면에 표시하는 기능을 구현합니다.
서버 컴포넌트의 데이터 페칭을 활용합니다.
도서 목록 페이지
도서 데이터를 페칭하여 목록을 표시하는 서버 컴포넌트를 구현합니다.
페이지네이션도 함께 고려합니다.
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 parsedPage = Number.parseInt(pageValue ?? '1', 10);
const page = Number.isFinite(parsedPage) ? Math.max(1, parsedPage) : 1;
const limit = 12; // 페이지당 도서 수
const skip = (page - 1) * limit;
await connectToDatabase();
const totalBooks = await Book.countDocuments();
const books: IBook[] = await Book.find({})
.skip(skip)
.limit(limit)
.lean(); // Mongoose Document를 일반 JS 객체로 변환하여 성능 향상
const totalPages = Math.ceil(totalBooks / limit);
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() }));
// }