본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
17장 : 실전 프로젝트

단계별 구현 가이드

Next.js와 MongoDB로 도서 목록·상세·검색·장바구니·주문 흐름을 구현하고 배포 가능한 북스토어로 연결합니다.

이 절에서는 17장 1절에서 기획하고 설계한 온라인 북스토어 프로젝트를 실제 Next.js 애플리케이션으로 구현하는 과정을 단계별로 상세히 안내합니다.

각 단계에서는 필요한 기술 스택의 설정부터 핵심 기능의 구현까지, Next.js의 주요 개념들을 실전에 적용하는 방법을 익히게 될 것입니다.

아래 다이어그램은 전체 구현 순서를 환경 설정, 데이터 계약, 화면 구현, 구매 흐름, 검색, 배포 검증으로 나누어 보여줍니다.


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

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

Next.js 프로젝트 생성

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

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

첫 질문에서 No, customize settings를 고른 뒤 다음과 같이 선택합니다.

  • Would you like to use TypeScript? Yes
  • Which linter would you like to use? ESLint
  • Would you like to use React Compiler? No
  • Would you like to use Tailwind CSS? Yes
  • Would you like your code inside a src/ directory? No (이 절의 경로 예시와 맞춤)
  • Would you like to use App Router? (recommended) Yes
  • Would you like to customize the import alias (@/* by default)? No

데이터베이스, 인증, TypeScript 시드 실행에 필요한 패키지를 설치합니다.

npm install mongoose next-auth@beta use-debounce
npm install -D tsx

데이터베이스 연결 설정

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

lib/db.ts
import mongoose, { type 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');
}

type MongooseCache = {
  connection: Mongoose | null;
  promise: Promise<Mongoose> | null;
};

const globalForMongoose = globalThis as typeof globalThis & {
  mongooseCache?: MongooseCache;
};

const cached = globalForMongoose.mongooseCache ?? {
  connection: null,
  promise: null,
};

globalForMongoose.mongooseCache = cached;

async function connectToDatabase(): Promise<Mongoose> {
  if (cached.connection) {
    return cached.connection;
  }

  if (!cached.promise) {
    cached.promise = mongoose.connect(MONGODB_URI, {
      bufferCommands: false, // Node.js 드라이버의 기본 버퍼링을 비활성화
    }).catch((error) => {
      cached.promise = null;
      throw error;
    });
  }

  cached.connection = await cached.promise;
  return cached.connection;
}

export default connectToDatabase;

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

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

Auth.js 세션 연결

장바구니와 주문은 사용자별 데이터이므로 10장에서 만든 Auth.js 설정을 이 프로젝트에도 연결합니다.

GitHub OAuth 앱의 콜백 URL은 개발 환경에서 http://localhost:3000/api/auth/callback/github로 지정하고 .env.local에 다음 값을 추가합니다.

.env.local
AUTH_SECRET="충분히 긴 무작위 문자열"
AUTH_GITHUB_ID="GitHub OAuth App Client ID"
AUTH_GITHUB_SECRET="GitHub OAuth App Client Secret"
auth.ts
import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';

export const { auth, handlers, signIn, signOut } = NextAuth({
  providers: [GitHub],
  callbacks: {
    session({ session, token }) {
      if (session.user && token.sub) {
        session.user.id = token.sub;
      }
      return session;
    },
  },
});
types/next-auth.d.ts
import type { DefaultSession } from 'next-auth';

declare module 'next-auth' {
  interface Session {
    user: {
      id: string;
    } & DefaultSession['user'];
  }
}
app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/auth';

export const { GET, POST } = handlers;

기존 예제에서 공통으로 사용하는 getSession()auth()를 얇게 감싼 함수입니다.

lib/auth.ts
import { auth } from '@/auth';

export async function getSession() {
  return auth();
}

로그인이 필요한 페이지가 이동할 /login도 함께 만듭니다.

app/login/page.tsx
import { signIn } from '@/auth';

interface LoginPageProps {
  searchParams: Promise<{ next?: string | string[] }>;
}

export default async function LoginPage({ searchParams }: LoginPageProps) {
  const params = await searchParams;
  const nextValue = Array.isArray(params.next) ? params.next[0] : params.next;
  const redirectTo = nextValue?.startsWith('/')
    && !nextValue.startsWith('//')
    && !nextValue.startsWith('/\\')
    ? nextValue
    : '/books';

  return (
    <main className="mx-auto max-w-md p-8 text-center">
      <h1 className="text-2xl font-bold">로그인</h1>
      <form
        action={async () => {
          'use server';
          await signIn('github', { redirectTo });
        }}
      >
        <button className="mt-6 rounded bg-black px-4 py-2 text-white" type="submit">
          GitHub로 로그인
        </button>
      </form>
    </main>
  );
}

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

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 CSS 4의 디자인 토큰은 globals.css@theme 블록에서 커스터마이징합니다.


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

다음 다이어그램은 모델 분리와 초기 데이터 삽입이 화면 구현 전에 어떤 검증 역할을 하는지 정리합니다.

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

Mongoose 모델 정의

models 디렉토리를 생성하고 각 엔티티에 대한 스키마와 모델을 정의합니다. (예: models/Book.ts, models/CartItem.ts, models/Order.ts).

17장 1절의 데이터 모델링 섹션에서 제시한 models/Book.ts를 그대로 사용합니다.

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

export interface IBook {
  _id: mongoose.Types.ObjectId;
  title: string;
  author: string;
  description: string;
  price: number;
  imageUrl: string;
  isbn: string;
  publishedDate: Date;
  genre: string[];
  stock: number;
}

const BookSchema = new Schema<IBook>({
  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 as Model<IBook> | undefined)
  ?? mongoose.model<IBook>('Book', BookSchema);

export default Book;

장바구니와 주문 모델도 각각 파일로 분리하여 뒤의 import 경로와 맞춥니다.

models/CartItem.ts
import mongoose, { Schema, type Model } from 'mongoose';

export interface ICartItem {
  userId: string;
  bookId: mongoose.Types.ObjectId;
  quantity: number;
  addedAt: Date;
}

const CartItemSchema = new Schema<ICartItem>({
  userId: { type: String, required: true, index: true },
  bookId: { type: Schema.Types.ObjectId, ref: 'Book', required: true },
  quantity: { type: Number, required: true, min: 1, max: 99 },
  addedAt: { type: Date, default: Date.now },
});

CartItemSchema.index({ userId: 1, bookId: 1 }, { unique: true });

const CartItem = (mongoose.models.CartItem as Model<ICartItem> | undefined)
  ?? mongoose.model<ICartItem>('CartItem', CartItemSchema);

export default CartItem;
models/Order.ts
import mongoose, { Schema, type Model } from 'mongoose';

interface OrderItem {
  bookId: mongoose.Types.ObjectId;
  quantity: number;
  priceAtPurchase: number;
}

export interface IOrder {
  userId: string;
  items: OrderItem[];
  totalPrice: number;
  orderDate: Date;
  status: 'pending' | 'completed' | 'cancelled';
}

const OrderSchema = new Schema<IOrder>({
  userId: { type: String, required: true, index: true },
  items: [{
    bookId: { type: Schema.Types.ObjectId, ref: 'Book', required: true },
    quantity: { type: Number, required: true, min: 1, max: 99 },
    priceAtPurchase: { type: Number, required: true, min: 0 },
  }],
  totalPrice: { type: Number, required: true, min: 0 },
  orderDate: { type: Date, default: Date.now },
  status: {
    type: String,
    enum: ['pending', 'completed', 'cancelled'],
    default: 'pending',
  },
});

const Order = (mongoose.models.Order as Model<IOrder> | undefined)
  ?? mongoose.model<IOrder>('Order', OrderSchema);

export default Order;

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

초기 데이터 삽입 스크립트

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

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

const booksToSeed = [
  {
    title: 'Next.js 완벽 가이드',
    author: '김넥스트',
    description: 'Next.js의 모든 것을 담은 가이드입니다.',
    price: 35000,
    imageUrl: '/book-placeholder.svg',
    isbn: '978-89-6618-000-1',
    genre: ['프로그래밍', '웹 개발'],
    stock: 100,
  },
  {
    title: 'React 마스터',
    author: '이리액트',
    description: 'React의 핵심 개념과 고급 패턴을 익힐 수 있습니다.',
    price: 32000,
    imageUrl: '/book-placeholder.svg',
    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.`);
  } finally {
    await mongoose.disconnect();
    console.log('Database connection closed.');
  }
}

seedDatabase().catch((error) => {
  console.error('Error seeding database:', error);
  process.exitCode = 1;
});

시드에서 참조하는 표지 대체 이미지는 새 프로젝트의 public 디렉토리에 직접 둡니다.

public/book-placeholder.svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 440">
  <rect width="320" height="440" rx="20" fill="#e2e8f0" />
  <path d="M84 100h152v240H84z" fill="#fff" stroke="#64748b" stroke-width="8" />
  <path d="M112 148h96M112 184h96M112 220h64" stroke="#64748b" stroke-width="10" stroke-linecap="round" />
</svg>

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

아래는 기존 scripts 객체에 합칠 seed 항목을 보여 주기 위한 최소 예시입니다.

package.json
{
  "scripts": {
    "seed": "tsx --env-file=.env.local scripts/seed.ts"
  }
}

seed는 Next.js 서버 밖에서 실행되므로 --env-file=.env.local로 연결 문자열을 명시적으로 불러옵니다.

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


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

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

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

도서 목록 페이지

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

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

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() }));
// }

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

다음 다이어그램은 장바구니 버튼 클릭 이후 서버 액션에서 세션, 재고, 수량, 캐시 재검증이 어떤 순서로 처리되는지 보여줍니다.

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

장바구니 관리

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

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

type CartActionResult =
  | { success: true; message: string }
  | { success: false; message: string };

async function getRequiredUserId() {
  const session = await getSession();
  if (!session?.user?.id) {
    redirect('/login?next=/cart');
  }
  return session.user.id;
}

export async function addToCart(bookId: string, quantity: number = 1): Promise<CartActionResult> {
  const userId = await getRequiredUserId();

  if (!mongoose.isValidObjectId(bookId)) {
    return { success: false, message: '올바르지 않은 도서입니다.' };
  }
  if (!Number.isInteger(quantity) || quantity < 1 || quantity > 99) {
    return { success: false, message: '수량은 1부터 99 사이의 정수여야 합니다.' };
  }

  await connectToDatabase();

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

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

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

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

export async function updateCartItemQuantity(itemId: string, newQuantity: number): Promise<CartActionResult> {
  const userId = await getRequiredUserId();

  if (!mongoose.isValidObjectId(itemId)) {
    return { success: false, message: '올바르지 않은 장바구니 항목입니다.' };
  }
  if (!Number.isInteger(newQuantity) || newQuantity < 1 || newQuantity > 99) {
    return { success: false, message: '수량은 1부터 99 사이의 정수여야 합니다.' };
  }

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

  if (!cartItem) {
    return { success: false, message: '장바구니 항목을 찾을 수 없습니다.' };
  }

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

  cartItem.quantity = newQuantity;
  await cartItem.save();
  revalidatePath('/cart');
  return { success: true, message: '수량을 변경했습니다.' };
}

export async function removeCartItem(itemId: string): Promise<CartActionResult> {
  const userId = await getRequiredUserId();

  if (!mongoose.isValidObjectId(itemId)) {
    return { success: false, message: '올바르지 않은 장바구니 항목입니다.' };
  }
  await connectToDatabase();
  const result = await CartItem.deleteOne({ _id: itemId, userId });
  if (result.deletedCount === 0) {
    return { success: false, message: '삭제할 장바구니 항목을 찾을 수 없습니다.' };
  }
  revalidatePath('/cart');
  return { success: true, message: '장바구니에서 삭제했습니다.' };
}
components/AddToCartButton.tsx (클라이언트 컴포넌트)
"use client";

import { 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<{
    tone: 'success' | 'error';
    message: string;
  } | null>(null);

  const handleAddToCart = () => {
    setFeedback(null);
    startTransition(async () => {
      try {
        const result = await addToCart(bookId, 1);
        setFeedback({
          tone: result.success ? 'success' : 'error',
          message: result.message,
        });
      } catch {
        setFeedback({ tone: 'error', message: '잠시 후 다시 시도해 주세요.' });
      }
    });
  };

  return (
    <div>
      <Button onClick={handleAddToCart} disabled={isPending}>
        {isPending ? '추가 중...' : '장바구니에 추가'}
      </Button>
      {feedback && (
        <p
          aria-live="polite"
          className={`mt-2 text-sm ${feedback.tone === 'success' ? 'text-green-700' : 'text-red-700'}`}
        >
          {feedback.message}
        </p>
      )}
    </div>
  );
}

장바구니 페이지

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

서버의 Mongoose 문서를 Client Component에 그대로 넘기지 않고 문자열 ID와 원시 값만 가진 DTO로 변환합니다.

types/cart.ts
export interface CartItemDto {
  id: string;
  quantity: number;
  book: {
    id: string;
    title: string;
    author: string;
    price: number;
    imageUrl: string;
    stock: number;
  };
}
app/cart/page.tsx (서버 컴포넌트)
import connectToDatabase from '@/lib/db';
import CartItemModel from '@/models/CartItem';
import type { IBook } from '@/models/Book';
import type { CartItemDto } from '@/types/cart';
import CartItemCard from '@/components/CartItemCard';
import Link from 'next/link';
import { getSession } from '@/lib/auth';
import { redirect } from 'next/navigation';

export default async function CartPage() {
  const session = await getSession();
  if (!session?.user?.id) {
    redirect('/login?next=/cart');
  }
  const userId = session.user.id;

  await connectToDatabase();

  const cartDocuments = await CartItemModel.find({ userId })
    .populate<{ bookId: IBook }>('bookId')
    .lean();

  const cartItems: CartItemDto[] = cartDocuments.map((item) => ({
    id: String(item._id),
    quantity: item.quantity,
    book: {
      id: String(item.bookId._id),
      title: item.bookId.title,
      author: item.bookId.author,
      price: item.bookId.price,
      imageUrl: item.bookId.imageUrl,
      stock: item.bookId.stock,
    },
  }));

  const total = cartItems.reduce((sum, item) => sum + item.book.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"
            className="inline-flex rounded-md bg-blue-600 px-4 py-2 font-semibold text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
          >
            도서 보러가기
          </Link>
        </div>
      ) : (
        <div className="bg-white p-6 rounded-lg shadow-lg">
          <div className="space-y-6">
            {cartItems.map((item) => (
              <CartItemCard key={item.id} item={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"
              className="inline-flex rounded-md bg-blue-600 px-4 py-2 font-semibold text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
            >
              주문하기
            </Link>
          </div>
        </div>
      )}
    </main>
  );
}
components/CartItemCard.tsx (클라이언트 컴포넌트)
"use client";

import { type ChangeEvent, 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 type { CartItemDto } from '@/types/cart';

interface CartItemCardProps {
  item: CartItemDto;
}

export default function CartItemCard({ item }: CartItemCardProps) {
  const [quantity, setQuantity] = useState(item.quantity);
  const [isPending, startTransition] = useTransition();
  const [feedback, setFeedback] = useState<string | null>(null);

  const book = item.book;

  const handleQuantityChange = (e: ChangeEvent<HTMLSelectElement>) => {
    const newQuantity = Number.parseInt(e.target.value, 10);
    const previousQuantity = quantity;
    setQuantity(newQuantity);
    setFeedback(null);
    startTransition(async () => {
      try {
        const result = await updateCartItemQuantity(item.id, newQuantity);
        if (!result.success) {
          setQuantity(previousQuantity);
          setFeedback(result.message);
        }
      } catch {
        setQuantity(previousQuantity);
        setFeedback('수량을 바꾸지 못했습니다. 잠시 후 다시 시도해 주세요.');
      }
    });
  };

  const handleRemoveItem = () => {
    setFeedback(null);
    startTransition(async () => {
      try {
        const result = await removeCartItem(item.id);
        if (!result.success) setFeedback(result.message);
      } catch {
        setFeedback('항목을 삭제하지 못했습니다. 잠시 후 다시 시도해 주세요.');
      }
    });
  };

  return (
    <div className="flex items-center space-x-4 p-4 border rounded-md bg-gray-50">
      <Link href={`/books/${book.id}`}>
        <Image
          src={book.imageUrl}
          alt={book.title}
          width={80}
          height={100}
          className="rounded-md"
        />
      </Link>
      <div className="grow">
        <Link href={`/books/${book.id}`}>
          <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>
      {feedback && <p aria-live="polite" className="text-sm text-red-700">{feedback}</p>}
    </div>
  );
}

주문 페이지 및 주문 처리

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

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 mongoose from 'mongoose';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { getSession } from '@/lib/auth';

type OrderActionResult = { success: false; message: string };

class OrderRejectedError extends Error {}

async function getRequiredUserId() {
  const session = await getSession();
  if (!session?.user?.id) {
    redirect('/login?next=/order');
  }
  return session.user.id;
}

export async function placeOrder(): Promise<OrderActionResult> {
  const userId = await getRequiredUserId();

  await connectToDatabase();

  let totalPrice = 0;
  const orderItems: Array<{
    bookId: mongoose.Types.ObjectId;
    quantity: number;
    priceAtPurchase: number;
  }> = [];
  const session = await mongoose.startSession();
  session.startTransaction();

  try {
    // 주문 대상도 같은 트랜잭션 안에서 읽어 일관된 스냅샷을 사용합니다.
    const cartItems = await CartItem.find({ userId }).session(session);

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

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

      if (!book || book.stock < item.quantity) {
        throw new OrderRejectedError(`책 "${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();

  } catch (error) {
    await session.abortTransaction();
    if (error instanceof OrderRejectedError) {
      return { success: false, message: error.message };
    }
    console.error('주문 처리 중 오류 발생:', error);
    throw new Error('주문을 처리하지 못했습니다.');
  } finally {
    await session.endSession();
  }

  revalidatePath('/cart'); // 장바구니 페이지 캐시 갱신
  revalidatePath('/order-success'); // 주문 성공 페이지 캐시 갱신
  // 필요한 경우 도서 상세 페이지도 재고 갱신을 위해 revalidatePath('/books/[id]', 'page');
  redirect('/order-success'); // redirect는 예외를 던지므로 try/catch 밖에서 호출합니다.
}

성공 경로는 값을 반환하지 않고 redirect()로 끝납니다.

따라서 OrderActionResult에는 화면에서 복구할 수 있는 실패만 두고, 예상하지 못한 오류는 예외로 전달하여 클라이언트의 catch에서 처리합니다.

components/PlaceOrderButton.tsx
'use client';

import { useState, useTransition } from 'react';
import { placeOrder } from '@/actions/order';
import Button from '@/components/ui/Button';

export default function PlaceOrderButton() {
  const [isPending, startTransition] = useTransition();
  const [errorMessage, setErrorMessage] = useState<string | null>(null);

  const handleOrder = () => {
    setErrorMessage(null);
    startTransition(async () => {
      try {
        const result = await placeOrder();
        setErrorMessage(result.message);
      } catch {
        setErrorMessage('주문을 처리하지 못했습니다. 잠시 후 다시 시도해 주세요.');
      }
    });
  };

  return (
    <div>
      <Button type="button" variant="primary" disabled={isPending} onClick={handleOrder}>
        {isPending ? '주문 처리 중...' : '주문 완료하기'}
      </Button>
      {errorMessage && <p aria-live="polite" className="mt-2 text-sm text-red-700">{errorMessage}</p>}
    </div>
  );
}
app/order/page.tsx (주문 확인 페이지 - 서버 컴포넌트)
import connectToDatabase from '@/lib/db';
import CartItemModel from '@/models/CartItem';
import type { IBook } from '@/models/Book';
import PlaceOrderButton from '@/components/PlaceOrderButton';
import { getSession } from '@/lib/auth';
import { redirect } from 'next/navigation';
import Image from 'next/image';
import Link from 'next/link';

export default async function OrderPage() {
  const session = await getSession();
  if (!session?.user?.id) {
    redirect('/login?next=/order');
  }
  const userId = session.user.id;

  await connectToDatabase();

  const cartItems = await CartItemModel.find({ userId })
    .populate<{ bookId: IBook }>('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"
          className="mt-6 inline-flex rounded-md bg-blue-600 px-4 py-2 font-semibold text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
        >
          도서 보러가기
        </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>
          <PlaceOrderButton />
        </div>
      </div>
    </main>
  );
}
app/order-success/page.tsx (주문 성공 페이지)
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="/"
          className="inline-flex rounded-md bg-blue-600 px-4 py-2 font-semibold text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
        >
          홈으로
        </Link>
        <Link
          href="/books"
          className="inline-flex rounded-md bg-gray-200 px-4 py-2 font-semibold text-gray-800 hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2"
        >
          다른 책 둘러보기
        </Link>
      </div>
    </main>
  );
}

검색 기능 구현

아래 다이어그램은 검색어가 클라이언트 상태에 머물지 않고 URL, 서버 쿼리, 페이지네이션에 함께 반영되어야 하는 이유를 보여줍니다.

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

app/books/page.tsx (기존 코드에 검색 기능 추가)
import BookCard from '@/components/BookCard';
import SearchInput from '@/components/SearchInput';
import connectToDatabase from '@/lib/db';
import Book, { type IBook } from '@/models/Book';

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 page = Math.max(1, Number.parseInt(pageValue ?? '1', 10) || 1);
  const limit = 12;
  const skip = (page - 1) * limit;
  const queryValue = Array.isArray(resolvedSearchParams.query)
    ? resolvedSearchParams.query[0]
    : resolvedSearchParams.query;
  const query = (queryValue ?? '').trim().slice(0, 80);
  const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');

  await connectToDatabase();

  const searchCondition = escapedQuery
    ? {
        $or: [
          { title: { $regex: escapedQuery, $options: 'i' } },
          { author: { $regex: escapedQuery, $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>
      <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">
        {/* 검색어는 URL에 안전하게 인코딩하여 다음 페이지에서도 유지합니다. */}
        {Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
          <a
            key={p}
            href={`/books?page=${p}${query ? `&query=${encodeURIComponent(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 (클라이언트 컴포넌트)
"use client";

import { type ChangeEvent, useEffect, useState } from 'react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useDebouncedCallback } from 'use-debounce';

interface SearchInputProps {
  initialQuery?: string;
}

export default function SearchInput({ initialQuery = '' }: SearchInputProps) {
  const router = useRouter();
  const pathname = usePathname();
  const searchParams = useSearchParams();
  const [inputValue, setInputValue] = useState(initialQuery);

  const updateQuery = useDebouncedCallback(
    (value: string, currentParams: string) => {
      const params = new URLSearchParams(currentParams);
      const normalizedValue = value.trim().slice(0, 80);

      if (normalizedValue) {
        params.set('query', normalizedValue);
        params.set('page', '1');
      } else {
        params.delete('query');
        params.delete('page');
      }

      const nextParams = params.toString();
      const nextUrl = nextParams ? `${pathname}?${nextParams}` : pathname;
      const currentUrl = currentParams ? `${pathname}?${currentParams}` : pathname;

      if (nextUrl !== currentUrl) {
        router.replace(nextUrl);
      }
    },
    500,
  );

  useEffect(() => {
    // 뒤로가기처럼 URL이 외부에서 바뀌면 예약된 쓰기를 취소하고 입력값을 동기화합니다.
    updateQuery.cancel();
    const currentQuery = searchParams.get('query') || '';
    setInputValue(currentQuery);
  }, [searchParams, updateQuery]);

  const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
    const value = e.target.value;
    setInputValue(value);
    updateQuery(value, searchParams.toString());
  };

  return (
    <>
      <label htmlFor="book-search" className="sr-only">
        도서 검색
      </label>
      <input
        id="book-search"
        type="search"
        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 (기본 생성된 layout.tsx에 추가)
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import type { ReactNode } from 'react';
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: ReactNode;
}) {
  return (
    <html lang="ko">
      <body className={`${inter.className} flex flex-col min-h-screen bg-gray-100`}>
        <Header />
        <div className="grow">
          {children}
        </div>
        <Footer />
      </body>
    </html>
  );
}
components/Header.tsx
import Link from 'next/link';

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">
          <span aria-hidden="true" className="text-2xl">📚</span>
          <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>
  );
}
components/Footer.tsx
export default function Footer() {
  return (
    <footer className="border-t bg-white py-6 text-center text-sm text-gray-600">
      <p>© {new Date().getFullYear()} My Bookstore</p>
    </footer>
  );
}

배포 및 테스트

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

배포 전에는 기능 동작뿐 아니라 환경 변수, 데이터 연결, 빌드 결과, 배포 후 확인까지 한 번의 체크리스트로 묶어야 누락을 줄일 수 있습니다.

로컬 테스트

npm run dev

브라우저에서 http://localhost:3000에 접속하여 모든 페이지와 기능이 올바르게 동작하는지 확인합니다.

특히 장바구니 추가, 수량 변경, 삭제, 주문하기 등의 상호작용 기능을 꼼꼼히 테스트합니다.

Vercel 배포

GitHub, GitLab, 또는 Bitbucket에 프로젝트 리포지토리를 생성하고 코드를 푸시합니다.

Vercel 계정에 로그인하고 Add New Project를 통해 해당 Git 리포지토리를 임포트합니다.

Vercel이 Next.js 프로젝트임을 자동으로 감지하고 설정을 제안합니다.

환경 변수 설정: Vercel 대시보드 프로젝트 설정에서 Environment Variables 섹션으로 이동하여 MONGODB_URI, AUTH_SECRET, AUTH_GITHUB_ID, AUTH_GITHUB_SECRET을 Production 환경에 추가합니다.

데이터베이스 값은 MongoDB Atlas의 프로덕션 연결 문자열을 사용하고, AUTH_SECRET에는 충분히 긴 무작위 문자열을 사용합니다.

OAuth 운영 주소 등록: GitHub OAuth 앱에 https://배포도메인/api/auth/callback/github를 Authorization callback URL로 등록합니다.

배포: 설정 확인 후 Deploy 버튼을 클릭하면 Vercel이 자동으로 빌드하고 배포합니다.

이 단계별 가이드는 온라인 북스토어 프로젝트의 핵심 기능을 구현하는 데 필요한 주요 과정과 코드 예시를 제공합니다.

실제 프로젝트에서는 계정 모델과 역할, 사용자용 오류 UI, 관측·복구 절차를 요구사항에 맞춰 확장해야 합니다.

아래 다이어그램은 긴 코드 예시를 기능별 계약과 완료 기준으로 다시 묶어, 구현이 끝났는지 확인하는 기준을 정리합니다.

아래 다이어그램은 Mongoose 모델, 초기 데이터 삽입, 도서 목록, 도서 상세 페이지가 이어지는 구현 흐름을 요구사항, 구현 단위, 검토 신호가 이어지는 작업 흐름으로 정리합니다.