안동민 개발노트

안동민 개발노트

프로젝트 기획 및 설계단계별 구현 가이드도서 목록·상세·검색장바구니 구현주문 및 검색전역 레이아웃 및 배포코드 리뷰 및 최적화추가 기능 확장 제안
본문 시작
  1. 홈
  2. 문서
  3. Next.js
  4. 17장 : 실전 프로젝트
  5. 단계별 구현 가이드
  1. Next.js
  2. 단계별 구현 가이드

단계별 구현 가이드

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

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

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


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

가장 먼저 Next.js 프로젝트를 생성하고, 데이터베이스 연결, 스타일링 프레임워크 등을 설정하여 개발 환경을 준비합니다.

Next.js 프로젝트 생성

Next.js 16을 기준으로 App Router를 사용하는 새로운 프로젝트를 생성합니다.

npx create-next-app@16 your-bookstore-app
cd your-bookstore-app

설치된 Next.js·Auth.js·Mongoose 버전은 잠금 파일로 기록하고 각 패키지가 지원하는 Node.js 버전을 사용합니다. 생성기의 질문 문구는 버전에 따라 달라질 수 있으며, 사용자 지정 설정에서 다음 조건을 선택합니다.

  • 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, // Mongoose의 모델 연산 버퍼링을 비활성화
    }).catch((error) => {
      cached.promise = null;
      throw error;
    });
  }

  cached.connection = await cached.promise;
  return cached.connection;
}

export default connectToDatabase;

이 캐시는 현재 JavaScript 프로세스 안에서 연결과 연결 중인 Promise를 공유합니다. 별도 배포 인스턴스까지 하나의 연결을 공유하는 것은 아닙니다.

프로젝트 루트에 .env.local 파일을 생성하고 MongoDB Atlas에서 발급받은 연결 문자열을 추가합니다. 뒤의 주문 예제에는 다중 문서 트랜잭션을 지원하는 replica set 또는 sharded cluster가 필요하며 standalone 서버는 사용할 수 없습니다.

# .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: {
    jwt({ token, account }) {
      if (account) {
        token.sub = `${account.provider}:${account.providerAccountId}`;
      }
      return token;
    },
    session({ session, token }) {
      if (session.user && token.sub) {
        session.user.id = token.sub;
      }
      return session;
    },
  },
});

이 실습은 GitHub 공급자 한 개를 사용하며, 서버가 검증한 provider와 providerAccountId를 JWT에 저장해 같은 계정의 재로그인에서도 문자열 소유자 ID를 유지합니다. 이메일이나 클라이언트 입력을 ID로 사용하지 않습니다. 여러 공급자를 한 사용자로 묶는 계정 연결과 영속 User 모델은 이후 확장 범위입니다.

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


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

정의된 스키마를 바탕으로 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, 모델은 models에서 가져옵니다. ref는 populate할 모델을 지정하며 도서 삭제를 막는 외래 키가 아닙니다. unique는 MongoDB 인덱스 선언이고, 수량의 min·max만으로 정수 검증까지 수행하지는 않습니다. 현재 스키마에는 Order의 orderDate 인덱스나 Book의 검색 인덱스가 없으므로 필요한 조회 패턴에 맞춰 별도로 결정합니다.

초기 데이터 삽입 스크립트

scripts/seed.ts와 같은 스크립트로 초기 도서 데이터를 넣습니다. 아래 스크립트는 Book 전체를 삭제하는 독립 실습 DB용입니다. 기존 장바구니·주문이 있는 DB에서 다시 실행하면 bookId 참조가 끊어지며, 삭제와 삽입도 하나의 트랜잭션이 아닙니다. 정상 사용 중인 DB의 데이터 갱신 도구로 실행하지 않습니다.

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


이어서 보기

  • 도서 목록·상세·검색
  • 장바구니 구현
  • 주문 및 검색
  • 전역 레이아웃 및 배포

프로젝트 기획 및 설계

이전 페이지

도서 목록·상세·검색

다음 페이지

이 페이지의 목차

프로젝트 초기 설정 및 기본 환경 구축Next.js 프로젝트 생성데이터베이스 연결 설정Auth.js 세션 연결기본 UI 컴포넌트 및 Tailwind CSS 설정데이터 모델 정의 및 초기 데이터 삽입Mongoose 모델 정의초기 데이터 삽입 스크립트이어서 보기