본문으로 건너뛰기

안동민 개발노트

본문 시작

단계별 구현 가이드

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

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

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

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

구현은 환경 설정에서 배포 검증까지 한 방향으로 쌓는다

온라인 북스토어는 기능을 무작정 붙이는 프로젝트가 아니라, 데이터 계약을 먼저 세우고 화면과 서버 동작을 그 위에 올리는 연습이다.

순서구현 단위완료 신호다음 단계로 넘기는 산출물
1Next.js 프로젝트 생성, Tailwind, 기본 UI로컬 앱 실행app/, components/, lib/ 기본 구조
2Auth.js, auth(), /login 경계미로그인 사용자가 로그인으로 이동문자열 session userId
3MongoDB 연결, Mongoose 모델, seed.env.local을 읽어 도서와 book-placeholder 삽입Book, CartItem, Order 모델
4도서 목록, 상세, 검색, URL query목록·상세 이동과 검색어 유지BookCard, SearchInput
5장바구니·주문 Server Action, CartItemDto예상 실패가 직렬화 결과로 UI에 표시됨CartActionResult와 문자열 ID DTO
6env, 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 연결 로직을 추가합니다.

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


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

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

모델과 seed는 화면을 만들기 전에 데이터 계약을 테스트하게 한다

seed는 .env.local의 연결 문자열과 public/book-placeholder.svg까지 준비되어야 서버 밖에서도 같은 데이터 계약을 만든다.

  1. connect

    lib/db.ts는 연결 재사용과 env 누락 오류만 담당한다.

  2. model

    s/Book.ts에 필드, unique, timestamps를 둔다.

  3. asset

    public/book-placeholder.svg를 두어 시드 이미지 경로를 실제 자산과 맞춘다.

  4. seed

    tsx --env-file=.env.local scripts/seed.ts로 연결 문자열을 명시해 실행한다.

  5. verify

    목록에서 삽입 수와 대체 이미지가 함께 보이는지 확인한다.

파일책임검토 신호
lib/db.tsMongoDB 연결과 캐시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를 그대로 사용합니다.

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 명령으로 초기 데이터를 삽입할 수 있습니다.


이어서 보기