본문으로 건너뛰기

안동민 개발노트

본문 시작

단계별 구현 가이드

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

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

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

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

HTML 다이어그램: /docs/next/ch17/ch17-2/1.html

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

가장 먼저 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 블록에서 커스터마이징합니다.


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

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

HTML 다이어그램: /docs/next/ch17/ch17-2/3.html

정의된 스키마를 바탕으로 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 명령으로 초기 데이터를 삽입할 수 있습니다.


이어서 보기