단계별 구현 가이드
Next.js와 MongoDB로 도서 목록·상세·검색·장바구니·주문 흐름을 구현하고 배포 가능한 북스토어로 연결합니다.
이 절에서는 17장 1절에서 기획하고 설계한 온라인 북스토어 프로젝트를 실제 Next.js 애플리케이션으로 구현하는 과정을 단계별로 상세히 안내합니다.
각 단계에서는 필요한 기술 스택의 설정부터 핵심 기능의 구현까지, Next.js의 주요 개념들을 실전에 적용하는 방법을 익히게 될 것입니다.
아래 다이어그램은 전체 구현 순서를 환경 설정, 데이터 계약, 화면 구현, 구매 흐름, 검색, 배포 검증으로 나누어 보여줍니다.
온라인 북스토어는 기능을 무작정 붙이는 프로젝트가 아니라, 데이터 계약을 먼저 세우고 화면과 서버 동작을 그 위에 올리는 연습이다.
| 순서 | 구현 단위 | 완료 신호 | 다음 단계로 넘기는 산출물 |
|---|---|---|---|
| 1 | Next.js 프로젝트 생성, Tailwind, 기본 UI | 로컬 앱 실행 | app/, components/, lib/ 기본 구조 |
| 2 | Auth.js, auth(), /login 경계 | 미로그인 사용자가 로그인으로 이동 | 문자열 session userId |
| 3 | MongoDB 연결, Mongoose 모델, seed | .env.local을 읽어 도서와 book-placeholder 삽입 | Book, CartItem, Order 모델 |
| 4 | 도서 목록, 상세, 검색, URL query | 목록·상세 이동과 검색어 유지 | BookCard, SearchInput |
| 5 | 장바구니·주문 Server Action, CartItemDto | 예상 실패가 직렬화 결과로 UI에 표시됨 | CartActionResult와 문자열 ID DTO |
| 6 | env, OAuth callback, build, Vercel 배포 | 배포 URL에서 로그인부터 주문까지 통과 | DB·Auth.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 블록에서 커스터마이징합니다.
데이터 모델 정의 및 초기 데이터 삽입
다음 다이어그램은 모델 분리와 초기 데이터 삽입이 화면 구현 전에 어떤 검증 역할을 하는지 정리합니다.
seed는 .env.local의 연결 문자열과 public/book-placeholder.svg까지 준비되어야 서버 밖에서도 같은 데이터 계약을 만든다.
- connect
lib/db.ts는 연결 재사용과 env 누락 오류만 담당한다.
- model
s/Book.ts에 필드, unique, timestamps를 둔다.
- asset
public/book-placeholder.svg를 두어 시드 이미지 경로를 실제 자산과 맞춘다.
- seed
tsx --env-file=.env.local scripts/seed.ts로 연결 문자열을 명시해 실행한다.
- verify
목록에서 삽입 수와 대체 이미지가 함께 보이는지 확인한다.
| 파일 | 책임 | 검토 신호 |
|---|---|---|
| lib/db.ts | MongoDB 연결과 캐시 | MONGODB_URI 누락 시 즉시 실패 |
| models/Book.ts | 도서 필드와 검증 규칙 | isbn unique, price/stock 타입 확인 |
| scripts/seed.ts | .env.local을 읽어 초기 데이터 주입 | 개발용 삭제 범위와 insert 결과 확인 |
| public/book-placeholder.svg | 시드 표지의 로컬 대체 자산 | /book-placeholder.svg 요청이 200으로 응답 |
정의된 스키마를 바탕으로 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 명령으로 초기 데이터를 삽입할 수 있습니다.