단계별 구현 가이드
이 절에서는 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
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
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
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
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
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
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
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 (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 (서버 컴포넌트)
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 (클라이언트 컴포넌트)
"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 (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 (주문 확인 페이지 - 서버 컴포넌트)
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 (주문 성공 페이지)
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 (기존 코드에 검색 기능 추가)
// ... 상단 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 (클라이언트 컴포넌트)
"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 (기본 생성된 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
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 배포
- GitHub, GitLab, 또는 Bitbucket에 프로젝트 리포지토리를 생성하고 코드를 푸시합니다.
- Vercel 계정에 로그인하고 "Add New Project" 를 통해 해당 Git 리포지토리를 임포트합니다.
- Vercel이 Next.js 프로젝트임을 자동으로 감지하고 설정을 제안합니다.
- 환경 변수 설정: Vercel 대시보드 프로젝트 설정에서 "Environment Variables" 섹션으로 이동하여
MONGODB_URI
환경 변수를 추가합니다. 값은 MongoDB Atlas에서 제공하는 프로덕션 연결 문자열을 사용합니다. - 배포: 설정 확인 후 "Deploy" 버튼을 클릭하면 Vercel이 자동으로 빌드하고 배포합니다.
이 단계별 가이드는 "온라인 북스토어" 프로젝트의 핵심 기능을 구현하는 데 필요한 주요 과정과 코드 예시를 제공합니다. 실제 프로젝트에서는 여기에 더 많은 UI 개선, 에러 핸들링, 사용자 인증, 테스트 코드 작성 등의 작업이 추가될 것입니다. 이 가이드를 시작점으로 삼아 Next.js의 모든 잠재력을 탐험하며 자신만의 멋진 애플리케이션을 완성해 보세요!