icon
17장 : 실전 프로젝트

단계별 구현 가이드

이 절에서는 17장 1절에서 기획하고 설계한 "온라인 북스토어" 프로젝트를 실제 Next.js 애플리케이션으로 구현하는 과정을 단계별로 상세히 안내합니다. 각 단계에서는 필요한 기술 스택의 설정부터 핵심 기능의 구현까지, Next.js의 주요 개념들을 실전에 적용하는 방법을 익히게 될 것입니다.


프로젝트 초기 설정 및 기본 환경 구축

가장 먼저 Next.js 프로젝트를 생성하고, 데이터베이스 연결, 스타일링 프레임워크 등을 설정하여 개발 환경을 준비합니다.

Next.js 프로젝트 생성

Next.js 14를 기준으로 App Router를 사용하는 새로운 프로젝트를 생성합니다.

npx create-next-app@latest your-bookstore-app

프롬프트가 나타나면 다음과 같이 선택합니다:

  • Would you like to use TypeScript? Yes
  • Would you like to use ESLint? Yes
  • Would you like to use Tailwind CSS? Yes
  • Would you like to use src/ directory? No (선택 사항, app/ 디렉토리 사용)
  • Would you like to use App Router? (recommended) Yes
  • Would you like to customize the default import alias? No

데이터베이스 연결 설정

lib/db.ts 파일을 생성하고 MongoDB 연결 로직을 추가합니다.

lib/db.ts
// lib/db.ts
import mongoose, { Mongoose } from 'mongoose';

const MONGODB_URI = process.env.MONGODB_URI;

if (!MONGODB_URI) {
  throw new Error('Please define the MONGODB_URI environment variable inside .env.local');
}

// 캐싱된 MongoDB 연결을 저장할 전역 변수
// 개발 환경에서 핫 리로딩 시 불필요한 재연결 방지
let cached: Mongoose | null = null;

async function connectToDatabase(): Promise<Mongoose> {
  if (cached) {
    console.log('Using cached database connection');
    return cached;
  }

  try {
    console.log('Connecting to new database connection');
    const conn = await mongoose.connect(MONGODB_URI, {
      bufferCommands: false, // Node.js 드라이버의 기본 버퍼링을 비활성화
    });
    cached = conn;
    return conn;
  } catch (error) {
    console.error('Failed to connect to database:', error);
    throw error;
  }
}

export default connectToDatabase;

프로젝트 루트에 .env.local 파일을 생성하고 MongoDB Atlas에서 발급받은 연결 문자열을 추가합니다.

# .env.local
MONGODB_URI="mongodb+srv://<username>:<password>@<cluster-url>/<database-name>?retryWrites=true&w=majority"

기본 UI 컴포넌트 및 Tailwind CSS 설정

components/ui 디렉토리를 생성하고 Button.tsx, Input.tsx 등 기본적으로 자주 사용될 UI 컴포넌트들을 정의합니다.

components/ui/Button.tsx
// components/ui/Button.tsx
import React from 'react';

interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  children: React.ReactNode;
  variant?: 'primary' | 'secondary' | 'danger';
}

export default function Button({ children, variant = 'primary', className = '', ...props }: ButtonProps) {
  const baseClasses = 'px-4 py-2 rounded-md font-semibold focus:outline-none focus:ring-2 focus:ring-opacity-75 transition-colors duration-200';
  const variants = {
    primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500',
    secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300 focus:ring-gray-400',
    danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
  };

  return (
    <button className={`${baseClasses} ${variants[variant]} ${className}`} {...props}>
      {children}
    </button>
  );
}

tailwind.config.ts 파일에서 필요한 설정을 커스터마이징합니다.


데이터 모델 정의 및 초기 데이터 삽입

정의된 스키마를 바탕으로 Mongoose 모델을 생성하고, 초기 테스트 데이터를 데이터베이스에 삽입합니다.

Mongoose 모델 정의

models 디렉토리를 생성하고 각 엔티티에 대한 스키마와 모델을 정의합니다. (예: models/Book.ts, models/CartItem.ts, models/Order.ts). 17장 1절의 데이터 모델링 섹션에서 제시된 lib/db.ts 파일의 IBook, BookSchema, Book 정의를 분리하여 사용합니다.

models/Book.ts
// models/Book.ts
import mongoose, { Schema, Document } from 'mongoose';

export interface IBook extends Document {
  title: string;
  author: string;
  description: string;
  price: number;
  imageUrl: string;
  isbn: string;
  publishedDate: Date;
  genre: string[];
  stock: number;
}

const BookSchema: Schema = new Schema({
  title: { type: String, required: true },
  author: { type: String, required: true },
  description: { type: String, required: true },
  price: { type: Number, required: true },
  imageUrl: { type: String, required: true },
  isbn: { type: String, required: true, unique: true },
  publishedDate: { type: Date, default: Date.now },
  genre: [{ type: String }],
  stock: { type: Number, default: 0 },
}, { timestamps: true }); // createdAt, updatedAt 자동 추가

const Book = mongoose.models.Book || mongoose.model<IBook>('Book', BookSchema);

export default Book;

lib/db.ts 파일은 connectToDatabase 함수만 남기고, 모델 임포트를 models 디렉토리에서 가져오도록 수정합니다.

초기 데이터 삽입 스크립트

scripts/seed.ts와 같은 스크립트를 생성하여 초기 도서 데이터를 데이터베이스에 삽입합니다.

scripts/seed.ts
// scripts/seed.ts
import connectToDatabase from '../lib/db';
import Book from '../models/Book';

const booksToSeed = [
  {
    title: 'Next.js 완벽 가이드',
    author: '김넥스트',
    description: 'Next.js의 모든 것을 담은 가이드입니다.',
    price: 35000,
    imageUrl: 'https://via.placeholder.com/150/0000FF/FFFFFF?text=NextJS',
    isbn: '978-89-6618-000-1',
    genre: ['프로그래밍', '웹 개발'],
    stock: 100,
  },
  {
    title: 'React 마스터',
    author: '이리액트',
    description: 'React의 핵심 개념과 고급 패턴을 익힐 수 있습니다.',
    price: 32000,
    imageUrl: 'https://via.placeholder.com/150/FF0000/FFFFFF?text=React',
    isbn: '978-89-6618-000-2',
    genre: ['프로그래밍', '프론트엔드'],
    stock: 80,
  },
  // 추가 도서 데이터...
];

async function seedDatabase() {
  await connectToDatabase();
  console.log('Database connected.');

  try {
    await Book.deleteMany({}); // 기존 데이터 삭제 (개발용)
    console.log('Existing books cleared.');

    await Book.insertMany(booksToSeed);
    console.log(`${booksToSeed.length} books inserted successfully.`);
  } catch (error) {
    console.error('Error seeding database:', error);
  } finally {
    mongoose.connection.close();
    console.log('Database connection closed.');
  }
}

seedDatabase();

package.json에 스크립트를 추가하여 쉽게 실행할 수 있도록 합니다.

// package.json
"scripts": {
  "seed": "ts-node scripts/seed.ts",
  // ...
}

이제 npm run seed 명령으로 초기 데이터를 삽입할 수 있습니다.


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

데이터베이스에서 도서 정보를 가져와 화면에 표시하는 기능을 구현합니다. 서버 컴포넌트의 데이터 페칭을 활용합니다.

도서 목록 페이지

도서 데이터를 페칭하여 목록을 표시하는 서버 컴포넌트를 구현합니다. 페이지네이션도 함께 고려합니다.

app/books/page.tsx
// app/books/page.tsx
import connectToDatabase from '@/lib/db';
import Book, { IBook } from '@/models/Book';
import BookCard from '@/components/BookCard';
import { Suspense } from 'react';
import Loading from './loading'; // 로딩 UI

interface BooksPageProps {
  searchParams: { [key: string]: string | string[] | undefined };
}

export default async function BooksPage({ searchParams }: BooksPageProps) {
  const page = parseInt(searchParams.page as string) || 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>
      <Suspense fallback={<Loading />}>
        <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>
      </Suspense>
      {/* 페이지네이션 컴포넌트 (추후 구현) */}
      <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>
  );
}
components/BookCard.tsx
// components/BookCard.tsx
import Image from 'next/image';
import Link from 'next/link';
import { 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
// app/books/[id]/page.tsx
import connectToDatabase from '@/lib/db';
import Book, { IBook } from '@/models/Book';
import Image from 'next/image';
import { notFound } from 'next/navigation';
import AddToCartButton from '@/components/AddToCartButton'; // 장바구니 추가 버튼 (클라이언트 컴포넌트)

interface BookDetailPageProps {
  params: { id: string };
}

export default async function BookDetailPage({ params }: BookDetailPageProps) {
  await connectToDatabase();
  const book: IBook | null = await Book.findById(params.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="flex-shrink-0">
          <Image
            src={book.imageUrl}
            alt={book.title}
            width={300}
            height={400}
            className="rounded-lg shadow-md"
          />
        </div>
        <div className="flex-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() }));
// }

핵심 기능 구현: 장바구니 및 주문

장바구니와 주문 기능은 사용자 상호작용이 많으므로 클라이언트 컴포넌트와 Server Actions를 혼합하여 구현합니다.

장바구니 관리

  • Server Actions: 장바구니에 항목을 추가/삭제/수량 변경하는 서버 액션 정의. 데이터베이스 업데이트 및 캐시 재검증 수행.
  • 클라이언트 컴포넌트: useTransition 등을 사용하여 Server Action의 로딩 상태를 처리하고, 장바구니 UI를 업데이트.
actions/cart.ts
// actions/cart.ts (Server Actions)
'use server';

import connectToDatabase from '@/lib/db';
import CartItem from '@/models/CartItem';
import Book from '@/models/Book';
import { revalidatePath } from 'next/cache';
import { getSession } from '@/lib/auth'; // 사용자 세션 가져오는 함수 (추후 구현)
import { redirect } from 'next/navigation';

export async function addToCart(bookId: string, quantity: number = 1) {
  // 실제 프로젝트에서는 사용자 인증 로직 필요
  // const session = await getSession();
  // if (!session?.user) {
  //   redirect('/login'); // 로그인 페이지로 리다이렉트
  // }
  // const userId = session.user.id;

  // 임시 userId (인증 기능 없을 시)
  const userId = '60d5ec49f1c7d2001c8c4a0e'; // 실제 MongoDB ObjectId 형식의 임시 ID

  await connectToDatabase();

  const book = await Book.findById(bookId);
  if (!book || book.stock < quantity) {
    throw new Error('재고가 부족하거나 책을 찾을 수 없습니다.');
  }

  let cartItem = await CartItem.findOne({ userId, bookId });

  if (cartItem) {
    cartItem.quantity += quantity;
    if (cartItem.quantity > book.stock) {
      throw new Error('장바구니에 담을 수 있는 최대 수량을 초과했습니다.');
    }
    await cartItem.save();
  } else {
    await CartItem.create({ userId, bookId, quantity });
  }

  // 장바구니 페이지의 데이터를 최신 상태로 재검증
  revalidatePath('/cart');
  revalidatePath('/books/[id]', 'page'); // 도서 상세 페이지 재고 정보 갱신
  return { success: true };
}

export async function updateCartItemQuantity(itemId: string, newQuantity: number) {
  // ... 인증 로직 ...
  const userId = '60d5ec49f1c7d2001c8c4a0e'; // 임시 userId

  await connectToDatabase();
  const cartItem = await CartItem.findOne({ _id: itemId, userId });

  if (!cartItem) {
    throw new Error('장바구니 항목을 찾을 수 없습니다.');
  }

  const book = await Book.findById(cartItem.bookId);
  if (!book || book.stock < newQuantity) {
    throw new Error('재고가 부족하거나 책을 찾을 수 없습니다.');
  }

  cartItem.quantity = newQuantity;
  await cartItem.save();
  revalidatePath('/cart');
  return { success: true };
}

export async function removeCartItem(itemId: string) {
  // ... 인증 로직 ...
  const userId = '60d5ec49f1c7d2001c8c4a0e'; // 임시 userId

  await connectToDatabase();
  await CartItem.deleteOne({ _id: itemId, userId });
  revalidatePath('/cart');
  return { success: true };
}
// components/AddToCartButton.tsx (클라이언트 컴포넌트)
"use client";

import React, { useState, useTransition } from 'react';
import Button from './ui/Button';
import { addToCart } from '@/actions/cart'; // Server Action 임포트

interface AddToCartButtonProps {
  bookId: string;
}

export default function AddToCartButton({ bookId }: AddToCartButtonProps) {
  const [isPending, startTransition] = useTransition();
  const [feedback, setFeedback] = useState<string | null>(null);

  const handleAddToCart = async () => {
    setFeedback(null);
    startTransition(async () => {
      try {
        const result = await addToCart(bookId, 1);
        if (result.success) {
          setFeedback('장바구니에 추가되었습니다!');
        }
      } catch (error: any) {
        setFeedback(`오류: ${error.message}`);
      }
    });
  };

  return (
    <div>
      <Button onClick={handleAddToCart} disabled={isPending}>
        {isPending ? '추가 중...' : '장바구니에 추가'}
      </Button>
      {feedback && <p className="mt-2 text-sm text-green-600">{feedback}</p>}
    </div>
  );
}

장바구니 페이지

장바구니 항목을 표시하고, 수량 변경 및 삭제 기능을 제공합니다.

app/cart/page.tsx
// app/cart/page.tsx (서버 컴포넌트)
import connectToDatabase from '@/lib/db';
import CartItemModel, { ICartItem } from '@/models/CartItem';
import Book, { IBook } from '@/models/Book';
import CartItemCard from '@/components/CartItemCard'; // 클라이언트 컴포넌트
import Button from '@/components/ui/Button';
import Link from 'next/link';

export default async function CartPage() {
  // 실제 프로젝트에서는 사용자 인증 로직으로 userId 가져오기
  const userId = '60d5ec49f1c7d2001c8c4a0e'; // 임시 userId

  await connectToDatabase();

  const cartItems: ICartItem[] = await CartItemModel.find({ userId })
    .populate('bookId') // 'bookId' 필드를 Book 문서로 채움
    .lean();

  const total = cartItems.reduce((sum, item) => sum + (item.bookId as IBook).price * item.quantity, 0);

  return (
    <main className="container mx-auto px-4 py-8">
      <h1 className="text-3xl font-bold mb-8 text-center">장바구니</h1>

      {cartItems.length === 0 ? (
        <div className="text-center p-8 border rounded-lg bg-white shadow-sm">
          <p className="text-lg text-gray-600 mb-4">장바구니가 비어있습니다.</p>
          <Link href="/books">
            <Button>도서 보러가기</Button>
          </Link>
        </div>
      ) : (
        <div className="bg-white p-6 rounded-lg shadow-lg">
          <div className="space-y-6">
            {cartItems.map((item) => (
              <CartItemCard key={item._id.toString()} item={JSON.parse(JSON.stringify(item))} />
            ))}
          </div>

          <div className="mt-8 pt-6 border-t-2 border-gray-200 flex justify-end items-center">
            <span className="text-2xl font-bold text-gray-800 mr-4">총액: ₩{total.toLocaleString()}</span>
            <Link href="/order">
              <Button variant="primary">주문하기</Button>
            </Link>
          </div>
        </div>
      )}
    </main>
  );
}
components/CartItemCard.tsx
// components/CartItemCard.tsx (클라이언트 컴포넌트)
"use client";

import React, { useState, useTransition } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { updateCartItemQuantity, removeCartItem } from '@/actions/cart'; // Server Action 임포트
import Button from './ui/Button';
import { ICartItem, IBook } from '@/models/Book'; // 모델 인터페이스 재사용 (직렬화 필요)

interface CartItemCardProps {
  item: ICartItem & { bookId: IBook }; // populate된 bookId
}

export default function CartItemCard({ item }: CartItemCardProps) {
  const [quantity, setQuantity] = useState(item.quantity);
  const [isPending, startTransition] = useTransition();

  const book = item.bookId; // populate된 도서 정보

  const handleQuantityChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
    const newQuantity = parseInt(e.target.value);
    setQuantity(newQuantity);
    startTransition(async () => {
      try {
        await updateCartItemQuantity(item._id.toString(), newQuantity);
        // 성공 메시지 또는 UI 업데이트 (선택 사항)
      } catch (error) {
        console.error('수량 변경 실패:', error);
        // 오류 처리 및 이전 수량으로 롤백 (복잡하므로 여기서는 생략)
      }
    });
  };

  const handleRemoveItem = async () => {
    startTransition(async () => {
      try {
        await removeCartItem(item._id.toString());
        // 성공 메시지 또는 UI 업데이트 (선택 사항)
      } catch (error) {
        console.error('항목 삭제 실패:', error);
      }
    });
  };

  return (
    <div className="flex items-center space-x-4 p-4 border rounded-md bg-gray-50">
      <Link href={`/books/${book._id.toString()}`}>
        <Image
          src={book.imageUrl}
          alt={book.title}
          width={80}
          height={100}
          className="rounded-md"
        />
      </Link>
      <div className="flex-grow">
        <Link href={`/books/${book._id.toString()}`}>
          <h3 className="text-lg font-semibold text-gray-800 hover:text-blue-600 transition-colors">
            {book.title}
          </h3>
        </Link>
        <p className="text-sm text-gray-600">{book.author}</p>
        <p className="text-md font-bold text-blue-600">{book.price.toLocaleString()}</p>
      </div>
      <div className="flex items-center space-x-4">
        <label htmlFor={`quantity-${item._id}`} className="sr-only">수량</label>
        <select
          id={`quantity-${item._id}`}
          value={quantity}
          onChange={handleQuantityChange}
          disabled={isPending}
          className="p-2 border rounded-md"
        >
          {Array.from({ length: book.stock > 10 ? 10 : book.stock }, (_, i) => i + 1).map((q) => (
            <option key={q} value={q}>{q}</option>
          ))}
        </select>
        <Button onClick={handleRemoveItem} disabled={isPending} variant="danger">
          삭제
        </Button>
      </div>
    </div>
  );
}

주문 페이지 및 주문 처리

장바구니 내용을 기반으로 주문을 생성하고 데이터베이스에 저장하는 Server Action을 구현합니다.

actions/order.ts
// actions/order.ts (Server Actions)
'use server';

import connectToDatabase from '@/lib/db';
import Order from '@/models/Order';
import CartItem from '@/models/CartItem';
import Book from '@/models/Book';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { getSession } from '@/lib/auth'; // 사용자 세션 (추후 구현)

export async function placeOrder() {
  // 실제 프로젝트에서는 사용자 인증 로직 필요
  // const session = await getSession();
  // if (!session?.user) {
  //   redirect('/login');
  // }
  // const userId = session.user.id;

  const userId = '60d5ec49f1c7d2001c8c4a0e'; // 임시 userId

  await connectToDatabase();

  // 사용자의 장바구니 항목 가져오기
  const cartItems = await CartItem.find({ userId }).populate('bookId');

  if (cartItems.length === 0) {
    throw new Error('장바구니가 비어있어 주문할 수 없습니다.');
  }

  let totalPrice = 0;
  const orderItems = [];
  const session = await mongoose.startSession();
  session.startTransaction();

  try {
    for (const item of cartItems) {
      const book = await Book.findById(item.bookId).session(session);

      if (!book || book.stock < item.quantity) {
        throw new Error(`책 "${book?.title || '알 수 없음'}"의 재고가 부족합니다.`);
      }

      // 재고 감소
      book.stock -= item.quantity;
      await book.save({ session });

      totalPrice += book.price * item.quantity;
      orderItems.push({
        bookId: book._id,
        quantity: item.quantity,
        priceAtPurchase: book.price,
      });
    }

    // 주문 생성
    await Order.create([{ userId, items: orderItems, totalPrice, status: 'completed' }], { session });

    // 장바구니 비우기
    await CartItem.deleteMany({ userId }).session(session);

    await session.commitTransaction();

    revalidatePath('/cart'); // 장바구니 페이지 캐시 갱신
    revalidatePath('/order-success'); // 주문 성공 페이지 캐시 갱신 (추후 구현)
    // 필요한 경우 도서 상세 페이지도 재고 갱신을 위해 revalidatePath('/books/[id]', 'page');
    
    redirect('/order-success'); // 주문 성공 페이지로 리다이렉트 (추후 구현)

  } catch (error) {
    await session.abortTransaction();
    console.error('주문 처리 중 오류 발생:', error);
    throw error;
  } finally {
    session.endSession();
  }
}
app/order/page.tsx
// app/order/page.tsx (주문 확인 페이지 - 서버 컴포넌트)
import connectToDatabase from '@/lib/db';
import CartItemModel, { ICartItem } from '@/models/CartItem';
import { IBook } from '@/models/Book';
import Button from '@/components/ui/Button';
import { placeOrder } from '@/actions/order'; // Server Action 임포트

export default async function OrderPage() {
  const userId = '60d5ec49f1c7d2001c8c4a0e'; // 임시 userId

  await connectToDatabase();

  const cartItems: ICartItem[] = await CartItemModel.find({ userId })
    .populate('bookId')
    .lean();

  const total = cartItems.reduce((sum, item) => sum + (item.bookId as IBook).price * item.quantity, 0);

  if (cartItems.length === 0) {
    return (
      <main className="container mx-auto px-4 py-8 text-center">
        <h1 className="text-3xl font-bold mb-4">주문할 상품이 없습니다.</h1>
        <p className="text-lg text-gray-600">장바구니에 상품을 추가해주세요.</p>
        <Link href="/books">
          <Button className="mt-6">도서 보러가기</Button>
        </Link>
      </main>
    );
  }

  return (
    <main className="container mx-auto px-4 py-8">
      <h1 className="text-3xl font-bold mb-8 text-center">주문 확인</h1>
      <div className="bg-white p-6 rounded-lg shadow-lg">
        <h2 className="text-2xl font-semibold mb-4">주문 상품</h2>
        <ul className="divide-y divide-gray-200">
          {cartItems.map((item) => (
            <li key={item._id.toString()} className="py-4 flex justify-between items-center">
              <div className="flex items-center space-x-4">
                <Image src={(item.bookId as IBook).imageUrl} alt={(item.bookId as IBook).title} width={60} height={80} className="rounded-md" />
                <div>
                  <h3 className="font-medium text-gray-900">{(item.bookId as IBook).title}</h3>
                  <p className="text-sm text-gray-600">수량: {item.quantity}</p>
                </div>
              </div>
              <span className="font-bold text-gray-900">{((item.bookId as IBook).price * item.quantity).toLocaleString()}</span>
            </li>
          ))}
        </ul>
        <div className="mt-8 pt-6 border-t-2 border-gray-200 flex justify-end items-center">
          <span className="text-2xl font-bold text-gray-800 mr-4">최종 결제 금액: ₩{total.toLocaleString()}</span>
          <form action={placeOrder}>
            <Button type="submit" variant="primary">주문 완료하기</Button>
          </form>
        </div>
      </div>
    </main>
  );
}
app/order-success/page.tsx
// app/order-success/page.tsx (주문 성공 페이지)
import Button from '@/components/ui/Button';
import Link from 'next/link';

export default function OrderSuccessPage() {
  return (
    <main className="container mx-auto px-4 py-16 text-center">
      <h1 className="text-4xl font-bold text-green-600 mb-6">🎉 주문이 성공적으로 완료되었습니다! 🎉</h1>
      <p className="text-lg text-gray-700 mb-8">주문해주셔서 감사합니다. 빠른 시일 내에 배송될 예정입니다.</p>
      <div className="flex justify-center space-x-4">
        <Link href="/">
          <Button variant="primary">홈으로</Button>
        </Link>
        <Link href="/books">
          <Button variant="secondary">다른 책 둘러보기</Button>
        </Link>
      </div>
    </main>
  );
}

검색 기능 구현

도서 목록 페이지에 검색 기능을 추가하여 특정 도서를 찾을 수 있도록 합니다.

app/books/page.tsx
// app/books/page.tsx (기존 코드에 검색 기능 추가)
// ... 상단 import 부분은 그대로 유지 ...
import SearchInput from '@/components/SearchInput'; // 검색 입력 컴포넌트

interface BooksPageProps {
  searchParams: { [key: string]: string | string[] | undefined };
}

export default async function BooksPage({ searchParams }: BooksPageProps) {
  const page = parseInt(searchParams.page as string) || 1;
  const limit = 12;
  const skip = (page - 1) * limit;
  const query = (searchParams.query as string) || '';

  await connectToDatabase();

  const searchCondition = query
    ? {
        $or: [
          { title: { $regex: query, $options: 'i' } }, // 제목으로 검색 (대소문자 구분 없음)
          { author: { $regex: query, $options: 'i' } }, // 저자로 검색
        ],
      }
    : {};

  const totalBooks = await Book.countDocuments(searchCondition);
  const books: IBook[] = await Book.find(searchCondition)
    .skip(skip)
    .limit(limit)
    .lean();

  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="mb-8 max-w-md mx-auto">
        <SearchInput initialQuery={query} />
      </div>
      <Suspense fallback={<Loading />}>
        <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>
      </Suspense>
      {/* 페이지네이션 컴포넌트 */}
      <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}${query ? `&query=${query}` : ''}`} {/* 검색 쿼리 유지 */}
            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>
  );
}
components/SearchInput.tsx
// components/SearchInput.tsx (클라이언트 컴포넌트)
"use client";

import { useState, useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useDebounce } from 'use-debounce'; // npm install use-debounce

interface SearchInputProps {
  initialQuery?: string;
}

export default function SearchInput({ initialQuery = '' }: SearchInputProps) {
  const router = useRouter();
  const searchParams = useSearchParams();
  const [inputValue, setInputValue] = useState(initialQuery);
  const [debouncedValue] = useDebounce(inputValue, 500); // 0.5초 디바운스

  useEffect(() => {
    // URL의 쿼리 파라미터가 변경되면 input 값도 업데이트
    const currentQuery = searchParams.get('query') || '';
    if (currentQuery !== inputValue) {
      setInputValue(currentQuery);
    }
  }, [searchParams]); // inputValue 제거

  useEffect(() => {
    // 디바운스된 값이 변경될 때만 URL 업데이트
    const newSearchParams = new URLSearchParams(searchParams.toString());
    if (debouncedValue) {
      newSearchParams.set('query', debouncedValue);
      newSearchParams.set('page', '1'); // 검색 시 1페이지로 리셋
    } else {
      newSearchParams.delete('query');
      newSearchParams.delete('page');
    }
    router.push(`/books?${newSearchParams.toString()}`);
  }, [debouncedValue, router, searchParams]); // searchParams 의존성 추가

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setInputValue(e.target.value);
  };

  return (
    <input
      type="text"
      value={inputValue}
      onChange={handleChange}
      placeholder="도서명 또는 저자 검색..."
      className="w-full p-3 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
    />
  );
}

전역 레이아웃 및 내비게이션

헤더, 푸터 등 모든 페이지에 공통으로 적용될 레이아웃을 구성하고, 내비게이션 바를 추가합니다.

app/layout.tsx
// app/layout.tsx (기본 생성된 layout.tsx에 추가)
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import Header from '@/components/Header'; // Header 컴포넌트
import Footer from '@/components/Footer'; // Footer 컴포넌트

const inter = Inter({ subsets: ['latin'] });

export const metadata: Metadata = {
  title: '나만의 온라인 북스토어',
  description: 'Next.js로 만든 간단한 온라인 북스토어 프로젝트',
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ko">
      <body className={`${inter.className} flex flex-col min-h-screen bg-gray-100`}>
        <Header />
        <div className="flex-grow">
          {children}
        </div>
        <Footer />
      </body>
    </html>
  );
}
components/Header.tsx
// components/Header.tsx
import Link from 'next/link';
import Image from 'next/image';

export default function Header() {
  return (
    <header className="bg-white shadow-md py-4">
      <div className="container mx-auto px-4 flex justify-between items-center">
        <Link href="/" className="flex items-center space-x-2">
          {/* 로고 이미지 또는 텍스트 */}
          <Image src="/book-icon.png" alt="Bookstore Logo" width={32} height={32} />
          <span className="text-2xl font-bold text-gray-800">My Bookstore</span>
        </Link>
        <nav>
          <ul className="flex space-x-6">
            <li>
              <Link href="/books" className="text-gray-600 hover:text-blue-600 transition-colors">
                모든 책
              </Link>
            </li>
            <li>
              <Link href="/cart" className="text-gray-600 hover:text-blue-600 transition-colors">
                장바구니
              </Link>
            </li>
            {/* 로그인/회원가입 링크 (추후 구현 시) */}
            {/* <li>
              <Link href="/login" className="text-gray-600 hover:text-blue-600 transition-colors">
                로그인
              </Link>
            </li> */}
          </ul>
        </nav>
      </div>
    </header>
  );
}
// public/book-icon.png (예시 로고 이미지)
// 실제 프로젝트에서는 적절한 아이콘을 사용하세요.

배포 및 테스트

로컬에서 모든 기능이 정상적으로 작동하는지 확인한 후, Vercel에 배포합니다.

로컬 테스트

npm run dev

브라우저에서 http://localhost:3000에 접속하여 모든 페이지와 기능이 올바르게 동작하는지 확인합니다. 특히 장바구니 추가, 수량 변경, 삭제, 주문하기 등의 상호작용 기능을 꼼꼼히 테스트합니다.

Vercel 배포

  1. GitHub, GitLab, 또는 Bitbucket에 프로젝트 리포지토리를 생성하고 코드를 푸시합니다.
  2. Vercel 계정에 로그인하고 "Add New Project" 를 통해 해당 Git 리포지토리를 임포트합니다.
  3. Vercel이 Next.js 프로젝트임을 자동으로 감지하고 설정을 제안합니다.
  4. 환경 변수 설정: Vercel 대시보드 프로젝트 설정에서 "Environment Variables" 섹션으로 이동하여 MONGODB_URI 환경 변수를 추가합니다. 값은 MongoDB Atlas에서 제공하는 프로덕션 연결 문자열을 사용합니다.
  5. 배포: 설정 확인 후 "Deploy" 버튼을 클릭하면 Vercel이 자동으로 빌드하고 배포합니다.

이 단계별 가이드는 "온라인 북스토어" 프로젝트의 핵심 기능을 구현하는 데 필요한 주요 과정과 코드 예시를 제공합니다. 실제 프로젝트에서는 여기에 더 많은 UI 개선, 에러 핸들링, 사용자 인증, 테스트 코드 작성 등의 작업이 추가될 것입니다. 이 가이드를 시작점으로 삼아 Next.js의 모든 잠재력을 탐험하며 자신만의 멋진 애플리케이션을 완성해 보세요!