단계별 구현 가이드
Next.js와 MongoDB로 로그인·도서·장바구니·가상 주문을 연결하고, 제공 코드와 직접 완성할 UI·배포 조건을 구분합니다.
이 글부터는 17장 1절에서 기획하고 설계한 온라인 북스토어 프로젝트를 실제 Next.js 애플리케이션으로 구현하는 과정을 단계별로 안내합니다. 단계마다 필요한 기술 스택의 설정부터 핵심 기능의 구현까지, Next.js의 주요 개념을 실전에 적용하는 방법을 익힙니다.
첫 학습 목표는 GitHub 로그인, 도서 목록과 상세, 자기 장바구니, 가상 주문으로 이어지는 하나의 흐름입니다. 홈 화면과 로그인 상태에 따른 메뉴, 로그아웃은 직접 완성할 UI로 남겨 둡니다. 이 구현 가이드는 이 글의 초기 설정과 데이터 모델에 이어 도서 목록과 상세, 장바구니, 주문과 검색, 전역 레이아웃과 배포 순서로 진행되며, 마지막 글인 전역 레이아웃 및 배포에서 제공한 코드, 직접 할 과제, 실제 환경에서 확인할 결과를 구분합니다.
프로젝트 초기 설정 및 기본 환경 구축
먼저 Next.js 프로젝트를 만들고, 데이터베이스 연결과 스타일링 프레임워크 등을 설정해 개발 환경을 준비합니다. 순서는 프로젝트 생성과 패키지 설치, MongoDB 연결, Auth.js 연결, 공통 UI 준비입니다. 장바구니와 주문은 로그인한 사용자와 데이터베이스가 모두 있어야 동작하므로, 화면을 만들기 전에 이 두 연결을 먼저 끝내 둡니다. 연결 문자열과 인증 비밀 값은 모두 .env.local 한 파일에 모읍니다.
Next.js 프로젝트 생성
Next.js 16을 기준으로 App Router를 쓰는 새 프로젝트를 만듭니다. create-next-app@16처럼 주 버전을 붙이면, 나중에 새 주 버전이 나와도 이 글과 같은 Next.js 16 생성기로 프로젝트가 만들어집니다. 명령을 실행하면 몇 가지 설정을 묻는 질문이 이어지고, 답을 마치면 your-bookstore-app 폴더에 프로젝트가 생깁니다.
npx create-next-app@16 your-bookstore-app
cd your-bookstore-app설치된 Next.js, Auth.js, Mongoose의 버전은 잠금 파일로 기록하고, 각 패키지가 지원하는 Node.js 버전을 씁니다. 생성기의 질문 문구는 버전에 따라 달라질 수 있으며, 사용자 지정 설정을 고른 뒤 TypeScript는 Yes, linter는 ESLint, React Compiler는 No, Tailwind CSS는 Yes를 고릅니다. src/ 디렉터리는 이 글의 경로 예시에 맞춰 No로 두고, App Router는 Yes, import alias(@/*) 사용자 지정은 No로 둡니다. 16.4부터 나오는 Cache Components 질문은 기본 선택이 Yes이지만, 이 장의 코드는 이 기능을 켜지 않은 구성을 기준으로 하므로 No를 고릅니다. 이미 Yes로 만들었다면 next.config.ts에 들어간 cacheComponents: true와 partialPrefetching: true 줄을 지웁니다.
데이터베이스, 인증, TypeScript 시드 실행에 필요한 패키지를 설치합니다.
npm install mongoose next-auth@beta use-debounce
npm install -D tsxmongoose는 MongoDB 문서를 스키마와 모델로 다루는 ODM이고, next-auth@beta는 10장에서 쓴 Auth.js v5입니다. use-debounce는 뒤의 검색 입력에서 키를 누를 때마다 요청하지 않도록 호출을 늦추는 라이브러리입니다. tsx는 TypeScript 파일을 따로 컴파일하지 않고 바로 실행하는 도구로, 시드 스크립트를 실행할 때만 필요하므로 -D로 개발 의존성에 넣습니다.
데이터베이스 연결 설정
lib/db.ts 파일을 만들고 MongoDB 연결 로직을 넣습니다. 개발 서버는 파일을 고칠 때마다 모듈을 다시 불러오므로, 연결을 모듈 변수에만 두면 저장할 때마다 새 연결이 쌓일 수 있습니다. 그래서 아래 코드는 연결과 연결 중인 Promise를 globalThis에 캐시하고, connectToDatabase()를 여러 번 불러도 이미 있는 연결이나 진행 중인 연결을 돌려줍니다. 연결에 실패하면 catch에서 Promise 캐시를 비워, 다음 호출이 다시 연결을 시도할 수 있게 합니다.
import mongoose, { type Mongoose } from 'mongoose';
if (!process.env.MONGODB_URI) {
throw new Error('Please define the MONGODB_URI environment variable inside .env.local');
}
const MONGODB_URI: string = process.env.MONGODB_URI;
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;맨 위에서는 process.env.MONGODB_URI를 직접 검사한 뒤 그 값을 string 타입의 상수에 담습니다. 모듈 최상위의 if로 좁힌 타입은 connectToDatabase 같은 함수 선언 안까지 이어지지 않으므로, 검사하기 전의 string | undefined 상수를 함수 안에서 mongoose.connect에 넘기면 타입 검사에서 오류가 나기 때문입니다.
이 캐시는 지금의 JavaScript 프로세스 안에서 연결과, 연결 중인 Promise를 함께 씁니다. 따로 떨어진 배포 인스턴스까지 하나의 연결을 함께 쓰는 것은 아닙니다.
프로젝트 루트에 .env.local 파일을 만들고 MongoDB Atlas에서 발급받은 연결 문자열을 넣습니다. 뒤의 주문 예제는 여러 문서에 걸친 트랜잭션을 쓰므로 replica set이나 sharded cluster가 필요하며, standalone 서버는 쓸 수 없습니다. MongoDB Atlas의 클러스터는 replica set으로 만들어지므로 그대로 쓸 수 있습니다.
# .env.local
MONGODB_URI="mongodb+srv://<username>:<password>@<cluster-url>/<database-name>?retryWrites=true&w=majority"<username>처럼 꺾쇠로 감싼 부분은 Atlas에서 확인한 실제 값으로 바꾸고, 꺾쇠 기호는 남기지 않습니다. 비밀번호에 @나 : 같은 문자가 들어 있으면 퍼센트 인코딩해야 연결 문자열이 올바르게 해석됩니다.
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"중앙 인증 모듈 auth.ts를 만듭니다. 10장과 같은 구조이지만, 이번에는 장바구니와 주문의 소유자를 구분할 ID가 필요하므로 jwt와 session 콜백을 더합니다. jwt 콜백의 account는 로그인 직후에만 들어오므로, 그때 공급자 이름과 공급자 계정 ID로 만든 문자열을 token.sub에 저장합니다. session 콜백은 이 값을 session.user.id로 옮겨, 서버 코드가 세션에서 소유자 ID를 읽을 수 있게 합니다.
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 모델은 이후의 확장 범위입니다.
세션의 user에 id가 있다고 TypeScript에 알리도록 타입을 확장합니다.
import type { DefaultSession } from 'next-auth';
declare module 'next-auth' {
interface Session {
user: {
id: string;
} & DefaultSession['user'];
}
}이 선언 파일은 tsconfig.json의 include 범위 안에 있어야 TypeScript가 읽으므로, 프로젝트의 모든 .ts 파일을 포함하는 기본 설정을 바꿨다면 types 폴더도 넣어 줍니다. 다음으로 Auth.js의 핸들러를 catch-all 라우트에 연결합니다. 10장과 같은 파일이며, /api/auth 아래의 로그인, 콜백, 로그아웃 요청을 모두 이 파일이 받아 auth.ts의 handlers로 넘깁니다.
import { handlers } from '@/auth';
export const { GET, POST } = handlers;뒤의 예제에서 공통으로 쓰는 getSession()은 auth()를 얇게 감싼 함수입니다. 지금은 auth()의 결과를 그대로 돌려줄 뿐이지만, 페이지와 서버 액션이 이 함수를 거치게 해 두면 나중에 세션을 읽는 방식이 바뀌어도 이 파일만 고치면 됩니다. 루트의 auth.ts(@/auth)와 lib/auth.ts(@/lib/auth)는 이름이 비슷하므로 import 경로를 헷갈리지 않도록 주의합니다.
import { auth } from '@/auth';
export async function getSession() {
return auth();
}로그인이 필요한 페이지가 이동할 /login도 함께 만듭니다. next 쿼리는 내부 경로일 때만 로그인 뒤의 목적지로 쓰고, 그 밖에는 /books로 보냅니다.
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>
);
}startsWith('/')만 검사하면 //evil.example처럼 슬래시 두 개로 시작하는 값도 통과하는데, 브라우저는 이런 주소를 다른 도메인으로 해석합니다. 그래서 //와 /\로 시작하는 값을 따로 막아, 로그인 뒤에 외부 사이트로 보내는 열린 리다이렉트(open redirect)를 피합니다. 버튼의 서버 액션은 이렇게 고른 redirectTo를 signIn에 넘깁니다.
기본 UI 컴포넌트 및 Tailwind CSS 설정
components/ui 디렉터리를 만들고, 이 프로젝트에서 반복해서 쓸 Button.tsx를 정의합니다. ButtonProps가 React.ButtonHTMLAttributes<HTMLButtonElement>를 확장하므로 type, disabled, onClick 같은 기본 <button> 속성을 그대로 받을 수 있고, 나머지 속성은 ...props로 모아 실제 <button>에 넘깁니다. variant는 미리 정한 세 가지 색 조합 가운데 하나를 고르며, 바깥에서 넘긴 className은 기본 클래스 뒤에 덧붙습니다.
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 transition-colors duration-200';
const variants = {
primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500/75',
secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300 focus:ring-gray-400/75',
danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500/75',
};
return (
<button className={`${baseClasses} ${variants[variant]} ${className}`} {...props}>
{children}
</button>
);
}포커스 링의 투명도는 focus:ring-blue-500/75처럼 색 뒤에 붙였습니다. Tailwind CSS 4에서는 ring-opacity-75 같은 별도의 투명도 유틸리티가 없어졌기 때문입니다. Tailwind CSS 4의 디자인 토큰은 globals.css의 @theme 블록에서 바꿉니다.
데이터 모델 정의 및 초기 데이터 삽입
정의한 스키마를 바탕으로 Mongoose 모델을 만들고, 초기 테스트 데이터를 데이터베이스에 넣습니다. 모델은 Book, CartItem, Order 세 가지이며, 장바구니와 주문 모델에는 앞 글에서 정한 소유자 userId와 구매 시점의 가격이 들어갑니다. 모델을 만든 뒤에는 시드 스크립트로 도서 두 권을 넣어, 목록과 상세 화면을 만들 때 바로 확인할 데이터를 준비합니다.
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;마지막 두 줄의 mongoose.models.Book ?? mongoose.model(...)은 이미 등록된 모델이 있으면 그것을 다시 씁니다. 개발 서버가 모듈을 다시 불러올 때 같은 이름의 모델을 두 번 등록하면 Mongoose가 오류를 내기 때문이며, 뒤의 두 모델도 같은 방식을 씁니다. 장바구니 모델은 사용자와 도서의 조합이 하나만 있도록 복합 고유 인덱스를 둡니다. 이 인덱스가 만들어져 있으면 같은 조합의 두 번째 문서는 중복 키 오류로 저장되지 않으므로, 같은 책을 다시 담는 동작은 기존 문서의 수량을 바꾸는 방식으로 처리해야 합니다.
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;userId의 index: true는 한 사용자의 장바구니를 모아 읽는 조회를 위한 단일 필드 인덱스입니다. 주문 모델은 각 항목에 구매 시점의 가격을 함께 저장합니다. items는 하위 문서의 배열이라서 주문 하나에 여러 도서가 들어가고, status의 enum은 세 값 밖의 상태를 저장하려 하면 검증 오류를 냅니다.
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의 검색 인덱스가 없으므로, 필요한 조회 패턴에 맞춰 따로 정합니다.
Next · Model reference and snapshot
도서 참조와 주문 시점의 가격
세션에서 검증한 문자열 userId가 장바구니·주문의 소유자를 구분합니다. 두 모델의 bookId는 Book을 참조하지만, 장바구니의 현재 가격과 주문에 기록한 구매 가격은 서로 다른 값입니다.
| 모델 | 저장하는 핵심 값 | 화면·변경 해석 |
|---|---|---|
| Book | 도서 _id · 현재 price · stock | 목록·상세와 장바구니가 읽는 도서 값 |
| CartItem | userId · Book을 가리키는 bookId · quantity | populate한 도서의 현재 가격으로 장바구니 금액 계산 |
| Order | userId · items의 bookId/quantity/priceAtPurchase · totalPrice | 주문 액션이 읽은 도서 가격과 수량으로 구매 시점 금액 저장 |
Book
- 저장하는 핵심 값
- 도서 _id · 현재 price · stock
- 화면·변경 해석
- 목록·상세와 장바구니가 읽는 도서 값
CartItem
- 저장하는 핵심 값
- userId · Book을 가리키는 bookId · quantity
- 화면·변경 해석
- populate한 도서의 현재 가격으로 장바구니 금액 계산
Order
- 저장하는 핵심 값
- userId · items의 bookId/quantity/priceAtPurchase · totalPrice
- 화면·변경 해석
- 주문 액션이 읽은 도서 가격과 수량으로 구매 시점 금액 저장
ref는 도서 삭제를 막지 않습니다. Book 전체를 지우는 시드를 기존 장바구니·주문이 있는 DB에 다시 실행하면 참조가 끊어질 수 있습니다. userId에 대응하는 영속 User 모델은 이 실습에 없습니다.
위 표는 세 모델이 도서를 어떻게 참조하고 가격을 어떻게 기록하는지 비교합니다. Book은 도서의 _id, 현재 price, stock을 저장하며, 목록과 상세, 장바구니가 이 값을 읽습니다. CartItem은 userId, Book을 가리키는 bookId, quantity를 저장하고, populate한 도서의 현재 가격으로 장바구니 금액을 계산합니다. Order는 userId, 항목마다의 bookId, quantity, priceAtPurchase, 그리고 totalPrice를 저장하며, 주문 액션이 읽은 도서 가격과 수량으로 구매 시점의 금액을 남깁니다. 세션에서 검증한 문자열 userId가 장바구니와 주문의 소유자를 구분합니다.
표의 아래 설명처럼 ref는 도서 삭제를 막지 않으므로, Book 전체를 지우는 시드를 기존 장바구니와 주문이 있는 DB에 다시 실행하면 참조가 끊어질 수 있습니다. userId에 대응하는 영속적인 User 모델은 이 실습에 없습니다.
초기 데이터 삽입 스크립트
scripts/seed.ts 같은 스크립트로 초기 도서 데이터를 넣습니다. 다음 스크립트는 Book 전체를 지우는 독립 실습 DB용입니다. 기존 장바구니와 주문이 있는 DB에서 다시 실행하면 bookId 참조가 끊어지고, 삭제와 삽입도 하나의 트랜잭션이 아니므로, 실제로 쓰고 있는 DB의 데이터를 갱신하는 도구로 실행하지 않습니다.
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;
});스크립트는 연결한 뒤 기존 도서를 모두 지우고 두 권을 넣으며, 성공하든 실패하든 finally에서 연결을 닫습니다. 열린 연결이 남아 있으면 Node.js 프로세스가 끝나지 않기 때문입니다. 실패했을 때는 process.exitCode = 1로 명령이 실패했다고 알리므로, 스크립트를 자동화에 넣어도 실패를 감지할 수 있습니다. 시드가 참조하는 표지 대체 이미지는 새 프로젝트의 public 디렉터리에 직접 둡니다. imageUrl의 /book-placeholder.svg는 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로 초기 데이터를 넣을 수 있습니다.
정리
- 프로젝트는
create-next-app@16으로 App Router, TypeScript, Tailwind CSS를 고르고,mongoose,next-auth@beta,use-debounce,tsx를 설치합니다. lib/db.ts는 한 프로세스 안에서 MongoDB 연결과 연결 중인 Promise를 캐시하며, 주문 트랜잭션에는 replica set이 필요합니다.- Auth.js는 서버가 검증한 공급자와 계정 ID로 문자열 사용자 ID를 만들고, 장바구니와 주문은 이 ID로 소유자를 구분합니다.
- CartItem은 사용자와 도서의 복합 고유 인덱스를 두고, Order 항목은 구매 시점의 가격을 저장하며,
ref는 외래 키가 아닙니다. - 시드는 Book 전체를 지우는 독립 실습 DB용이므로, 실제 데이터가 있는 DB에서 실행하지 않습니다.
연습 문제
1. 같은 책을 두 번 담기
CartItem에는 { userId: 1, bookId: 1 } 복합 고유 인덱스가 있습니다. 같은 사용자가 같은 책을 담는 요청을 두 번 보냈는데, 서버 코드가 매번 CartItem.create()로 새 문서를 만든다면 두 번째 요청은 어떻게 될까요?
해설 보기
고유 인덱스가 실제로 만들어져 있다면, 두 번째 create()는 같은 userId와 bookId 조합이 이미 있으므로 중복 키 오류로 실패합니다. 그래서 같은 책을 다시 담을 때는 새 문서를 만들기보다 기존 항목의 수량을 늘리는 방식으로 처리해야 하며, 다음 글들의 장바구니 액션도 이렇게 구현합니다. 인덱스가 만들어지지 않은 상태라면 중복 문서가 생길 수 있으므로, 인덱스가 있는지도 확인합니다.
2. 다른 사람의 주문을 조회하는 요청
로그인한 사용자 A가 주문 목록 화면에서 URL의 쿼리를 바꿔 ?userId=github:999처럼 다른 사용자 ID로 주문을 조회하려고 합니다. 이 프로젝트의 설계에서는 주문을 어떤 ID로 조회해야 할까요?
해설 보기
주문은 언제나 서버 세션에서 얻은 session.user.id로만 조회해야 합니다. URL 쿼리나 요청 본문의 userId는 사용자가 바꿀 수 있는 입력이므로 신원으로 쓰지 않습니다. 이 프로젝트의 auth.ts는 서버가 검증한 provider와 providerAccountId로 ID를 만들므로, 서버 코드에서 그 값으로 조회 조건을 제한하면 다른 사람의 주문이 섞이지 않습니다.
3. 시드를 다시 실행하기
장바구니에 책을 담아 둔 상태에서 npm run seed를 다시 실행했습니다. 장바구니 화면에는 어떤 문제가 생길 수 있을까요?
해설 보기
시드는 Book.deleteMany({})로 모든 도서를 지운 뒤 새로 넣으므로, 새로 만든 도서는 이전과 다른 _id를 갖습니다. 장바구니 항목의 bookId는 이전 _id를 가리키므로, populate하면 도서를 찾지 못해 null이 됩니다. 이 상태를 처리하지 않으면 장바구니 화면에서 도서 정보를 읽다가 오류가 날 수 있습니다. 시드는 독립된 실습 DB에서만 쓰고, 다시 실행해야 한다면 장바구니와 주문도 함께 정리합니다.
Mongoose의 스키마와 인덱스 옵션은 Mongoose 스키마 문서에서, MongoDB 트랜잭션의 조건은 MongoDB 트랜잭션 문서에서 확인할 수 있습니다.
다음 글 도서 목록·상세·검색에서는 서버 컴포넌트로 도서 목록과 상세 페이지를 만들고 페이지네이션을 구현합니다.