단계별 구현 가이드
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 연결 로직을 추가합니다.
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에 다음 값을 추가합니다.
AUTH_SECRET="충분히 긴 무작위 문자열"
AUTH_GITHUB_ID="GitHub OAuth App Client ID"
AUTH_GITHUB_SECRET="GitHub OAuth App Client Secret"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;
},
},
});import type { DefaultSession } from 'next-auth';
declare module 'next-auth' {
interface Session {
user: {
id: string;
} & DefaultSession['user'];
}
}import { handlers } from '@/auth';
export const { GET, POST } = handlers;기존 예제에서 공통으로 사용하는 getSession()은 auth()를 얇게 감싼 함수입니다.
import { auth } from '@/auth';
export async function getSession() {
return auth();
}로그인이 필요한 페이지가 이동할 /login도 함께 만듭니다.
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를 정의합니다.
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를 그대로 사용합니다.
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 경로와 맞춥니다.
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;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와 같은 스크립트를 생성하여 초기 도서 데이터를 데이터베이스에 삽입합니다.
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 디렉토리에 직접 둡니다.
<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 항목을 보여 주기 위한 최소 예시입니다.
{
"scripts": {
"seed": "tsx --env-file=.env.local scripts/seed.ts"
}
}seed는 Next.js 서버 밖에서 실행되므로 --env-file=.env.local로 연결 문자열을 명시적으로 불러옵니다.
이제 npm run seed 명령으로 초기 데이터를 삽입할 수 있습니다.