본문으로 건너뛰기

안동민 개발노트

본문 시작
17장 : 실전 프로젝트단계별 구현 가이드

도서 목록·상세·검색

도서 목록과 상세 화면을 구현하고 페이지네이션 및 검색 흐름을 연결합니다.

핵심 기능 구현: 도서 목록 및 상세 페이지

데이터베이스에서 도서 정보를 가져와 화면에 표시하는 기능을 구현합니다.

서버 컴포넌트의 데이터 페칭을 활용합니다.

도서 목록 페이지

도서 데이터를 페칭하여 목록을 표시하는 서버 컴포넌트를 구현합니다.

페이지네이션도 함께 고려합니다.

app/books/page.tsx
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에 둡니다.

app/books/loading.tsx
export default function BooksLoading() {
  return <p className="p-8 text-center">도서 목록을 불러오는 중입니다...</p>;
}
components/BookCard.tsx
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>
  );
}

도서 상세 페이지

동적 라우팅을 사용하여 특정 도서의 상세 정보를 표시합니다.

app/books/[id]/page.tsx
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() }));
// }