Ver3.5 쇼핑몰 장바구니 시스템 설계와세션 관리 전략 완전 정복 🎯

쇼핑몰 장바구니 시스템 설계와
세션 관리 전략 완전 정복 🎯
실무에서 바로 쓰는 장바구니 아키텍처 & 세션 전략 — 친구처럼 쉽게 풀어드립니다!
야, 솔직히 말해봐. 쇼핑몰 개발할 때 장바구니가 제일 만만해 보이지 않아? 😅
"그냥 상품 담고 빼면 되는 거 아냐?" 싶은데... 막상 구현하다 보면 세션 관리, 비로그인 사용자 처리, 재고 동기화, 결제 연동까지 얽히면서 머리가 터질 것 같은 경험, 다들 한 번쯤 있을 거야.
이 글에서는 그 복잡한 장바구니 시스템을 처음부터 끝까지 제대로 뜯어볼 거야. 설계 철학부터 실제 코드 패턴, 세션 전략까지 — 실무에서 바로 써먹을 수 있는 내용으로 꽉꽉 채웠으니까 끝까지 읽어봐! 🔥
1. 장바구니, 왜 이렇게 복잡한 거야? 🤔
장바구니는 쇼핑몰의 심장이야. 사용자가 상품을 탐색하고 구매를 결정하는 모든 과정이 장바구니를 통해 흘러가거든. 근데 이게 단순히 "상품 목록 저장"이 아니라 상태 관리, 동시성 제어, 보안, 성능이 전부 얽혀있는 복합 시스템이야.
생각해봐. 아래 상황들을 전부 처리해야 해:
2 여러 기기에서 동시에 접속 → 장바구니 동기화 필요
3 담아둔 상품이 품절 → 실시간 재고 반영
4 가격 변동 → 담을 때 가격 vs 결제 시 가격 처리
5 세션 만료 → 장바구니 데이터 보존 전략
6 대용량 트래픽 → 성능 최적화
이 모든 걸 고려하지 않으면 나중에 버그 지옥을 경험하게 돼. 특히 세션 관련 버그는 재현하기도 어렵고 디버깅도 힘들어서 진짜 골치 아파. 그래서 처음 설계할 때 제대로 잡아두는 게 핵심이야! 💪
2. 데이터 모델 설계 — 뼈대부터 탄탄하게 🦴
장바구니 시스템의 첫 번째 관문은 데이터 모델 설계야. 어떤 데이터를 어떻게 저장할지 결정하는 게 이후 모든 로직의 기반이 되거든.
📐 핵심 엔티티 구조
장바구니 시스템에서 필요한 주요 테이블/컬렉션을 살펴보자:
-- 장바구니 테이블 (cart)
CREATE TABLE cart (
cart_id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NULL, -- 로그인 사용자 ID (NULL = 비로그인)
session_key VARCHAR(128) NOT NULL, -- 세션 식별자
created_at DATETIME DEFAULT NOW(),
updated_at DATETIME DEFAULT NOW() ON UPDATE NOW(),
expires_at DATETIME, -- 비로그인 장바구니 만료 시간
status ENUM('active','merged','abandoned','converted') DEFAULT 'active'
);
-- 장바구니 아이템 테이블 (cart_item)
CREATE TABLE cart_item (
item_id BIGINT PRIMARY KEY AUTO_INCREMENT,
cart_id BIGINT NOT NULL,
product_id BIGINT NOT NULL,
variant_id BIGINT NULL, -- 옵션(색상/사이즈 등)
quantity INT NOT NULL DEFAULT 1,
unit_price DECIMAL(12,2) NOT NULL, -- 담을 당시 가격 스냅샷
added_at DATETIME DEFAULT NOW(),
FOREIGN KEY (cart_id) REFERENCES cart(cart_id)
);
🔑 session_key 설계 전략
session_key는 장바구니 시스템의 핵심 식별자야. 이걸 어떻게 생성하고 관리하느냐에 따라 보안성과 성능이 달라져.
// Node.js 예시 - 세션 키 생성
const crypto = require('crypto');
function generateSessionKey() {
// 128비트 랜덤 + 타임스탬프 조합
const random = crypto.randomBytes(16).toString('hex');
const timestamp = Date.now().toString(36);
return `cart_${timestamp}_${random}`;
// 예: cart_lq3k2m_a1b2c3d4e5f6...
}
// UUID v4 방식도 많이 사용
const { v4: uuidv4 } = require('uuid');
const sessionKey = uuidv4(); // 'f47ac10b-58cc-4372-a567-0e02b2c3d479'
📊 variant(옵션) 처리 설계
실제 쇼핑몰에서는 같은 상품이라도 색상, 사이즈, 용량 등 다양한 옵션이 있어. 이걸 variant라고 부르는데, 장바구니에서 이 처리가 꽤 까다로워.
-- 상품 옵션 테이블
CREATE TABLE product_variant (
variant_id BIGINT PRIMARY KEY AUTO_INCREMENT,
product_id BIGINT NOT NULL,
sku VARCHAR(100) UNIQUE NOT NULL, -- 재고 관리 단위
option_color VARCHAR(50),
option_size VARCHAR(50),
stock_qty INT NOT NULL DEFAULT 0,
extra_price DECIMAL(10,2) DEFAULT 0, -- 기본가 대비 추가금액
is_active BOOLEAN DEFAULT TRUE
);
장바구니에 상품을 담을 때는 product_id만이 아니라 variant_id까지 함께 저장해야 해. 그래야 "빨간색 M사이즈 2개"와 "파란색 L사이즈 1개"를 별도 아이템으로 정확히 관리할 수 있거든.
3. 세션 관리 전략 — 이게 진짜 핵심이야 🔐
자, 이제 본론 중의 본론이야. 세션 관리. 장바구니 시스템에서 가장 많은 버그가 발생하는 곳이기도 하고, 가장 신중하게 설계해야 하는 부분이야.
🍪 쿠키 기반 세션 구현 (Express.js)
// Express.js + express-session + Redis 연동
const express = require('express');
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const { createClient } = require('redis');
const redisClient = createClient({ url: 'redis://localhost:6379' });
redisClient.connect();
const app = express();
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET, // 환경변수로 관리!
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production', // HTTPS에서만 전송
httpOnly: true, // JS에서 접근 불가 (XSS 방어)
sameSite: 'strict', // CSRF 방어
maxAge: 1000 * 60 * 60 * 24 * 7 // 7일
}
}));
// 장바구니 세션 초기화 미들웨어
app.use((req, res, next) => {
if (!req.session.cartKey) {
req.session.cartKey = generateSessionKey();
}
next();
});
🎫 JWT 방식 구현
// JWT 기반 인증 + 장바구니 연동
const jwt = require('jsonwebtoken');
// 토큰 생성
function generateTokens(userId) {
const accessToken = jwt.sign(
{ userId, type: 'access' },
process.env.JWT_SECRET,
{ expiresIn: '15m' } // 짧은 만료 시간
);
const refreshToken = jwt.sign(
{ userId, type: 'refresh' },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: '7d' }
);
return { accessToken, refreshToken };
}
// 장바구니 API 미들웨어
async function cartAuthMiddleware(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
if (token) {
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.userId = decoded.userId;
req.isAuthenticated = true;
} catch (err) {
// 토큰 만료 → 비로그인으로 처리
req.isAuthenticated = false;
}
}
// 비로그인이면 쿠키에서 임시 세션 키 사용
if (!req.isAuthenticated) {
req.cartKey = req.cookies.cart_session || generateSessionKey();
res.cookie('cart_session', req.cartKey, {
httpOnly: true,
maxAge: 1000 * 60 * 60 * 24 * 30 // 30일
});
}
next();
}
4. 비로그인 → 로그인 장바구니 병합 로직 🔄
이게 진짜 장바구니 개발의 꽃이야. 비로그인 상태에서 열심히 상품 담아놨는데 로그인하니까 장바구니가 비어있다? 그 순간 사용자 이탈률이 폭발해. 😱
그래서 장바구니 병합(Cart Merge) 로직을 반드시 구현해야 해.
// 장바구니 병합 서비스 (TypeScript)
class CartMergeService {
async mergeOnLogin(
guestCartKey: string, // 비로그인 장바구니 키
userId: number // 로그인한 사용자 ID
): Promise<void> {
// 1. 비로그인 장바구니 아이템 조회
const guestItems = await CartItem.findAll({
where: {
cartKey: guestCartKey,
status: 'active'
}
});
if (guestItems.length === 0) return; // 병합할 게 없으면 종료
// 2. 사용자의 기존 장바구니 조회 (없으면 생성)
let userCart = await Cart.findOne({
where: { userId, status: 'active' }
});
if (!userCart) {
userCart = await Cart.create({ userId, status: 'active' });
}
// 3. 트랜잭션으로 병합 처리
await sequelize.transaction(async (t) => {
for (const guestItem of guestItems) {
// 기존 사용자 장바구니에 같은 상품+옵션이 있는지 확인
const existingItem = await CartItem.findOne({
where: {
cartId: userCart.id,
productId: guestItem.productId,
variantId: guestItem.variantId
},
transaction: t
});
if (existingItem) {
// 중복 상품: 수량 합산 (재고 초과 방지)
const maxQty = await this.getMaxAvailableQty(guestItem.productId, guestItem.variantId);
const mergedQty = Math.min(
existingItem.quantity + guestItem.quantity,
maxQty
);
await existingItem.update(
{ quantity: mergedQty },
{ transaction: t }
);
} else {
// 새 상품: 사용자 장바구니에 추가
await CartItem.create({
cartId: userCart.id,
productId: guestItem.productId,
variantId: guestItem.variantId,
quantity: guestItem.quantity,
unitPrice: guestItem.unitPrice
}, { transaction: t });
}
// 비로그인 아이템 삭제
await guestItem.destroy({ transaction: t });
}
// 비로그인 장바구니 상태 변경
await Cart.update(
{ status: 'merged' },
{ where: { sessionKey: guestCartKey }, transaction: t }
);
});
}
private async getMaxAvailableQty(productId: number, variantId: number): Promise<number> {
const variant = await ProductVariant.findByPk(variantId);
return variant ? variant.stockQty : 0;
}
}
B 로그인 우선: 기존 사용자 장바구니 유지, 비로그인 것은 버림 → 데이터 손실 위험
C 수량 합산 (권장): 같은 상품은 수량 합산, 없는 상품은 추가 → 가장 사용자 친화적
대부분의 대형 쇼핑몰은 C 방식을 사용해. 사용자 입장에서 가장 자연스럽거든!
5. Redis를 활용한 고성능 장바구니 캐싱 ⚡
트래픽이 많은 쇼핑몰에서 장바구니 조회는 매우 빈번하게 발생해. 상품 페이지 볼 때마다 장바구니 아이콘의 숫자가 업데이트되잖아? 이걸 매번 DB에서 읽으면 서버가 버텨내질 못해.
그래서 Redis를 캐시 레이어로 활용하는 거야.
// Redis 기반 장바구니 캐싱 전략
class CartCacheService {
private redis: Redis;
private CACHE_TTL = 3600; // 1시간
// 캐시 키 생성
private getCacheKey(identifier: string): string {
return `cart:${identifier}`;
}
// 장바구니 조회 (캐시 우선)
async getCart(identifier: string): Promise<CartData | null> {
const cacheKey = this.getCacheKey(identifier);
// 1. Redis에서 먼저 조회
const cached = await this.redis.get(cacheKey);
if (cached) {
return JSON.parse(cached); // 캐시 히트!
}
// 2. 캐시 미스 → DB에서 조회
const cartData = await this.fetchFromDB(identifier);
// 3. Redis에 캐싱
if (cartData) {
await this.redis.setex(
cacheKey,
this.CACHE_TTL,
JSON.stringify(cartData)
);
}
return cartData;
}
// 장바구니 수정 시 캐시 무효화
async invalidateCart(identifier: string): Promise<void> {
const cacheKey = this.getCacheKey(identifier);
await this.redis.del(cacheKey);
}
// 장바구니 아이템 수 (빠른 조회용)
async getCartCount(identifier: string): Promise<number> {
const countKey = `cart:count:${identifier}`;
const count = await this.redis.get(countKey);
if (count !== null) return parseInt(count);
// DB에서 집계
const dbCount = await CartItem.count({
where: { cartKey: identifier, status: 'active' }
});
await this.redis.setex(countKey, 300, dbCount.toString()); // 5분 캐시
return dbCount;
}
}
🏗️ Redis Hash를 활용한 구조적 저장
단순 JSON 문자열 대신 Redis Hash를 사용하면 더 효율적으로 장바구니를 관리할 수 있어.
// Redis Hash 방식 - 아이템별 개별 관리
async function addToCartRedis(cartKey: string, item: CartItem) {
const hashKey = `cart:hash:${cartKey}`;
const fieldKey = `${item.productId}:${item.variantId || 'default'}`;
// HSET으로 개별 아이템 저장
await redis.hset(hashKey, fieldKey, JSON.stringify({
productId: item.productId,
variantId: item.variantId,
quantity: item.quantity,
unitPrice: item.unitPrice,
addedAt: new Date().toISOString()
}));
// TTL 갱신
await redis.expire(hashKey, 86400 * 30); // 30일
}
async function getCartFromRedis(cartKey: string) {
const hashKey = `cart:hash:${cartKey}`;
const allItems = await redis.hgetall(hashKey);
if (!allItems) return [];
return Object.values(allItems).map(item => JSON.parse(item));
}
// 수량만 업데이트 (전체 재조회 없이)
async function updateQuantityRedis(
cartKey: string,
productId: number,
variantId: number,
newQty: number
) {
const hashKey = `cart:hash:${cartKey}`;
const fieldKey = `${productId}:${variantId || 'default'}`;
const existing = await redis.hget(hashKey, fieldKey);
if (existing) {
const item = JSON.parse(existing);
item.quantity = newQty;
await redis.hset(hashKey, fieldKey, JSON.stringify(item));
}
}
6. 재고 관리와 동시성 제어 — 레이스 컨디션 잡기 🏁
블랙프라이데이 세일 상상해봐. 수천 명이 동시에 같은 상품을 장바구니에 담고 결제하려 해. 재고는 딱 1개인데 100명이 동시에 "구매 완료" 버튼을 누른다면? 이게 바로 레이스 컨디션(Race Condition) 문제야.
🔒 낙관적 잠금 (Optimistic Locking)
-- 낙관적 잠금을 위한 version 컬럼 추가
ALTER TABLE product_variant ADD COLUMN version INT DEFAULT 0;
-- 재고 차감 시 version 체크
UPDATE product_variant
SET
stock_qty = stock_qty - ?,
version = version + 1
WHERE
variant_id = ?
AND stock_qty >= ? -- 재고 충분한지 확인
AND version = ?; -- 버전이 일치하는지 확인
-- 영향받은 행이 0이면 → 동시 수정 발생 → 재시도 또는 실패 처리
// 낙관적 잠금 구현 (Node.js)
async function decreaseStock(
variantId: number,
quantity: number,
maxRetries: number = 3
): Promise<boolean> {
for (let attempt = 0; attempt < maxRetries; attempt++) {
// 현재 재고와 버전 조회
const variant = await ProductVariant.findByPk(variantId);
if (!variant || variant.stockQty < quantity) {
throw new Error('재고 부족');
}
// 낙관적 잠금으로 업데이트
const [affectedRows] = await ProductVariant.update(
{
stockQty: variant.stockQty - quantity,
version: variant.version + 1
},
{
where: {
variantId,
version: variant.version, // 버전 일치 조건
stockQty: { [Op.gte]: quantity }
}
}
);
if (affectedRows > 0) {
return true; // 성공!
}
// 실패 → 잠시 대기 후 재시도 (지수 백오프)
await sleep(Math.pow(2, attempt) * 100);
}
throw new Error('재고 처리 실패 - 재시도 초과');
}
🔐 Redis를 활용한 분산 잠금
// Redis Redlock을 활용한 분산 잠금
const Redlock = require('redlock');
const redlock = new Redlock([redis], {
retryCount: 3,
retryDelay: 200,
retryJitter: 100
});
async function processCartCheckout(cartId: number, userId: number) {
const lockKey = `lock:cart:${cartId}`;
// 분산 잠금 획득 (5초 TTL)
const lock = await redlock.acquire([lockKey], 5000);
try {
// 잠금 획득 성공 → 안전하게 처리
const cartItems = await getCartItems(cartId);
for (const item of cartItems) {
// 재고 재확인
const available = await checkStock(item.variantId, item.quantity);
if (!available) {
throw new Error(`${item.productName} 재고 부족`);
}
}
// 주문 생성 및 재고 차감
await createOrder(userId, cartItems);
await clearCart(cartId);
} finally {
// 반드시 잠금 해제!
await lock.release();
}
}
비관적 잠금: 충돌이 잦은 경우 (한정판 세일) — 안전성 우수
Redis 분산 잠금: 다중 서버 환경 — 확장성 우수
대부분의 경우 낙관적 잠금 + Redis 분산 잠금 조합이 가장 실용적이야!
7. 장바구니 API 설계 — RESTful하게 깔끔하게 🎨
장바구니 API는 직관적이고 일관성 있게 설계해야 해. 프론트엔드 개발자가 쉽게 이해하고 사용할 수 있어야 하거든.
| 메서드 | 엔드포인트 | 설명 | 인증 |
|---|---|---|---|
| GET | /api/cart |
장바구니 전체 조회 | 선택 |
| POST | /api/cart/items |
상품 추가 | 선택 |
| PATCH | /api/cart/items/:itemId |
수량 변경 | 선택 |
| DELETE | /api/cart/items/:itemId |
아이템 삭제 | 선택 |
| DELETE | /api/cart |
장바구니 전체 비우기 | 선택 |
| POST | /api/cart/merge |
비로그인→로그인 병합 | 필수 |
| GET | /api/cart/count |
아이템 수 조회 (빠른) | 선택 |
| POST | /api/cart/validate |
결제 전 유효성 검증 | 필수 |
📝 API 응답 형식 표준화
// 장바구니 조회 응답 예시
{
"success": true,
"data": {
"cartId": "cart_lq3k2m_a1b2c3d4",
"userId": 12345,
"items": [
{
"itemId": 1001,
"product": {
"id": 501,
"name": "프리미엄 무선 이어폰",
"thumbnail": "https://cdn.example.com/products/501.jpg",
"currentPrice": 89000, // 현재 가격
"isAvailable": true
},
"variant": {
"id": 201,
"color": "블랙",
"size": null,
"stockQty": 15
},
"quantity": 2,
"unitPrice": 89000, // 담을 당시 가격
"subtotal": 178000,
"priceChanged": false, // 가격 변동 여부
"addedAt": "2024-01-15T10:30:00Z"
}
],
"summary": {
"itemCount": 1,
"totalQuantity": 2,
"subtotal": 178000,
"shippingFee": 0,
"discount": 0,
"total": 178000
},
"warnings": [] // 품절, 가격변동 등 경고
}
}
8. 결제 전 유효성 검증 — 마지막 관문 ✅
결제 버튼을 누르기 전에 반드시 장바구니 유효성 검증을 해야 해. 이 단계를 건너뛰면 결제 완료 후에 "사실 재고가 없었어요" 같은 최악의 상황이 발생할 수 있거든.
// 결제 전 장바구니 검증 서비스
class CartValidationService {
async validateBeforeCheckout(cartId: string, userId: number) {
const cart = await this.getCartWithItems(cartId);
const warnings: ValidationWarning[] = [];
const errors: ValidationError[] = [];
for (const item of cart.items) {
// 1. 상품 활성화 상태 확인
const product = await Product.findByPk(item.productId);
if (!product || !product.isActive) {
errors.push({
type: 'PRODUCT_UNAVAILABLE',
itemId: item.id,
message: `${item.productName}은(는) 더 이상 판매하지 않습니다.`
});
continue;
}
// 2. 재고 확인
const variant = await ProductVariant.findByPk(item.variantId);
if (!variant || variant.stockQty < item.quantity) {
if (variant && variant.stockQty > 0) {
// 부분 재고 있음
warnings.push({
type: 'INSUFFICIENT_STOCK',
itemId: item.id,
message: `재고가 ${variant.stockQty}개만 남았습니다.`,
availableQty: variant.stockQty
});
} else {
// 완전 품절
errors.push({
type: 'OUT_OF_STOCK',
itemId: item.id,
message: `${item.productName}이(가) 품절되었습니다.`
});
}
}
// 3. 가격 변동 확인
if (product.currentPrice !== item.unitPrice) {
warnings.push({
type: 'PRICE_CHANGED',
itemId: item.id,
message: `가격이 변경되었습니다.`,
oldPrice: item.unitPrice,
newPrice: product.currentPrice
});
}
// 4. 구매 수량 제한 확인
if (product.maxPurchaseQty && item.quantity > product.maxPurchaseQty) {
errors.push({
type: 'QUANTITY_LIMIT_EXCEEDED',
itemId: item.id,
message: `최대 ${product.maxPurchaseQty}개까지 구매 가능합니다.`
});
}
}
return {
isValid: errors.length === 0,
canProceed: errors.length === 0,
errors,
warnings,
updatedSummary: await this.recalculateSummary(cart)
};
}
}
9. 장바구니 UX 최적화 전략 🎭
기술적인 구현만큼 중요한 게 사용자 경험(UX)이야. 아무리 완벽한 백엔드를 만들어도 UX가 나쁘면 사용자가 이탈해버려.
⚡ 낙관적 UI 업데이트 (Optimistic UI)
"장바구니에 담기" 버튼을 누르면 서버 응답을 기다리지 않고 즉시 UI를 업데이트하는 기법이야. 사용자는 빠른 반응을 느끼고, 만약 서버 오류가 나면 롤백해주면 돼.
// React + Optimistic UI 예시
function useCartOptimistic() {
const [cartItems, setCartItems] = useState([]);
const [pendingItems, setPendingItems] = useState(new Set());
const addToCart = async (product) => {
const tempId = `temp_${Date.now()}`;
// 1. 즉시 UI 업데이트 (낙관적)
setCartItems(prev => [...prev, {
...product,
id: tempId,
isPending: true
}]);
setPendingItems(prev => new Set([...prev, tempId]));
try {
// 2. 서버 요청
const response = await cartAPI.addItem(product);
// 3. 성공: 임시 ID를 실제 ID로 교체
setCartItems(prev => prev.map(item =>
item.id === tempId
? { ...response.data, isPending: false }
: item
));
} catch (error) {
// 4. 실패: 롤백
setCartItems(prev => prev.filter(item => item.id !== tempId));
showToast('장바구니 추가에 실패했습니다. 다시 시도해주세요.');
} finally {
setPendingItems(prev => {
const next = new Set(prev);
next.delete(tempId);
return next;
});
}
};
return { cartItems, addToCart, pendingItems };
}
🔔 실시간 재고 알림
// WebSocket을 활용한 실시간 재고 알림
// 서버 (Node.js + Socket.io)
io.on('connection', (socket) => {
// 장바구니 아이템 구독
socket.on('subscribe:cart', async (cartItems) => {
const productIds = cartItems.map(item => item.productId);
// 각 상품의 재고 변화 구독
productIds.forEach(productId => {
socket.join(`stock:${productId}`);
});
});
});
// 재고 변화 시 알림 발송
async function notifyStockChange(productId, newStock) {
io.to(`stock:${productId}`).emit('stock:updated', {
productId,
stockQty: newStock,
isLow: newStock <= 5, // 5개 이하 = 품귀 임박
isSoldOut: newStock === 0
});
}
// 클라이언트 (React)
useEffect(() => {
const socket = io(process.env.REACT_APP_WS_URL);
socket.emit('subscribe:cart', cartItems);
socket.on('stock:updated', ({ productId, stockQty, isSoldOut }) => {
if (isSoldOut) {
showWarning(`장바구니의 상품이 품절되었습니다!`);
updateCartItemStatus(productId, 'soldout');
} else if (stockQty <= 5) {
showInfo(`잔여 재고: ${stockQty}개`);
}
});
return () => socket.disconnect();
}, [cartItems]);
10. 보안 강화 전략 🛡️
장바구니 시스템은 돈과 직결되기 때문에 보안이 특히 중요해. 주요 공격 벡터와 방어 방법을 알아보자.
🚦 Rate Limiting 구현
// Express Rate Limiter - 장바구니 API 보호
const rateLimit = require('express-rate-limit');
// 장바구니 추가 제한 (분당 30회)
const cartAddLimiter = rateLimit({
windowMs: 60 * 1000, // 1분
max: 30,
keyGenerator: (req) => {
// IP + 세션 조합으로 키 생성
return `${req.ip}:${req.session?.cartKey || 'anonymous'}`;
},
message: {
error: 'TOO_MANY_REQUESTS',
message: '요청이 너무 많습니다. 잠시 후 다시 시도해주세요.'
},
standardHeaders: true,
legacyHeaders: false
});
// 결제 시도 제한 (시간당 10회)
const checkoutLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1시간
max: 10,
skipSuccessfulRequests: true // 성공한 요청은 카운트 제외
});
app.post('/api/cart/items', cartAddLimiter, addToCartHandler);
app.post('/api/checkout', checkoutLimiter, checkoutHandler);
🔑 권한 검증 — 내 장바구니만 접근 가능하게
// 장바구니 소유권 검증 미들웨어
async function verifyCartOwnership(req, res, next) {
const { cartId } = req.params;
const cart = await Cart.findByPk(cartId);
if (!cart) {
return res.status(404).json({ error: '장바구니를 찾을 수 없습니다.' });
}
// 로그인 사용자: userId 일치 확인
if (req.userId && cart.userId !== req.userId) {
return res.status(403).json({ error: '접근 권한이 없습니다.' });
}
// 비로그인 사용자: 세션 키 일치 확인
if (!req.userId && cart.sessionKey !== req.session.cartKey) {
return res.status(403).json({ error: '접근 권한이 없습니다.' });
}
req.cart = cart;
next();
}
// 라우터에 적용
app.patch('/api/cart/items/:itemId',
cartAuthMiddleware,
verifyCartOwnership,
updateCartItemHandler
);
11. 성능 최적화 — 빠른 장바구니가 매출을 올린다 🚀
아마존 연구에 따르면 페이지 로딩이 100ms 느려질 때마다 매출이 1% 감소한다고 해. 장바구니도 마찬가지야. 빠른 장바구니 = 높은 전환율이야.
📊 데이터베이스 인덱스 최적화
-- 장바구니 조회 성능을 위한 인덱스
CREATE INDEX idx_cart_user_status
ON cart(user_id, status);
CREATE INDEX idx_cart_session_status
ON cart(session_key, status);
CREATE INDEX idx_cart_item_cart
ON cart_item(cart_id);
CREATE INDEX idx_cart_item_product
ON cart_item(product_id, variant_id);
-- 만료된 비로그인 장바구니 정리를 위한 인덱스
CREATE INDEX idx_cart_expires
ON cart(expires_at, status);
-- 복합 인덱스로 커버링 쿼리 최적화
CREATE INDEX idx_cart_item_covering
ON cart_item(cart_id, product_id, variant_id, quantity, unit_price);
🧹 장바구니 정리 배치 작업
// 만료된 장바구니 정리 (Cron Job)
const cron = require('node-cron');
// 매일 새벽 3시에 실행
cron.schedule('0 3 * * *', async () => {
console.log('🧹 장바구니 정리 배치 시작...');
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
// 1. 30일 이상 된 비로그인 장바구니 삭제
const expiredCarts = await Cart.findAll({
where: {
userId: null,
status: 'active',
updatedAt: { [Op.lt]: thirtyDaysAgo }
}
});
for (const cart of expiredCarts) {
await CartItem.destroy({ where: { cartId: cart.id } });
await cart.update({ status: 'abandoned' });
}
// 2. 이미 병합된 장바구니 아카이브
await Cart.update(
{ status: 'archived' },
{
where: {
status: 'merged',
updatedAt: { [Op.lt]: thirtyDaysAgo }
}
}
);
console.log(`✅ 정리 완료: ${expiredCarts.length}개 장바구니 처리`);
});
// Redis 캐시 워밍업 (자주 조회되는 장바구니)
async function warmupCartCache() {
const activeUsers = await getActiveUserIds(); // 최근 1시간 활성 사용자
for (const userId of activeUsers) {
const cart = await fetchCartFromDB(userId);
if (cart) {
await redis.setex(`cart:${userId}`, 3600, JSON.stringify(cart));
}
}
}
2 Redis 캐싱으로 DB 부하 분산
3 장바구니 아이템 수 별도 캐싱 (헤더 표시용)
4 불필요한 JOIN 최소화 (필요한 데이터만 조회)
5 만료 데이터 정기 정리 (DB 용량 관리)
6 CDN으로 상품 이미지 캐싱
7 페이지네이션 (장바구니 아이템 많을 경우)
12. 실전 아키텍처 — 규모별 추천 구성 🏗️
모든 쇼핑몰에 동일한 아키텍처를 적용할 필요는 없어. 규모와 예산에 맞는 현실적인 선택이 중요해.
| 규모 | 세션 전략 | 캐시 | DB | 특이사항 |
|---|---|---|---|---|
| 소규모 (MAU 1만 이하) |
쿠키 세션 + 서버 메모리 |
불필요 | MySQL 단일 | 단순 구현 우선, 빠른 출시 |
| 중규모 (MAU 10만 이하) |
쿠키 세션 + Redis |
Redis 캐시 | MySQL + Read Replica |
세션 공유, 캐시 도입 |
| 대규모 (MAU 100만+) |
JWT + Redis 블랙리스트 |
Redis Cluster | MySQL Cluster + NoSQL |
마이크로서비스, 수평 확장 |
🔧 마이크로서비스 환경에서의 장바구니
// 마이크로서비스 환경 - 이벤트 기반 장바구니 동기화
// 이벤트 발행 (Cart Service)
class CartEventPublisher {
async publishCartUpdated(cartId: string, userId: number) {
await messageQueue.publish('cart.updated', {
cartId,
userId,
timestamp: new Date().toISOString(),
action: 'ITEM_ADDED'
});
}
async publishCartCheckedOut(cartId: string, orderId: string) {
await messageQueue.publish('cart.checked_out', {
cartId,
orderId,
timestamp: new Date().toISOString()
});
}
}
// 이벤트 구독 (Inventory Service)
messageQueue.subscribe('cart.checked_out', async (event) => {
const { cartId, orderId } = event;
// 재고 차감 처리
const cartItems = await getCartItems(cartId);
for (const item of cartItems) {
await decreaseInventory(item.variantId, item.quantity);
}
// 재고 차감 완료 이벤트 발행
await messageQueue.publish('inventory.decreased', { orderId });
});
// 이벤트 구독 (Notification Service)
messageQueue.subscribe('cart.updated', async (event) => {
const { userId } = event;
// 장바구니 업데이트 알림 (선택적)
await sendPushNotification(userId, {
title: '장바구니 업데이트',
body: '장바구니에 새 상품이 추가되었습니다.'
});
});
🎯 마무리 — 장바구니 개발의 핵심 원칙
자, 여기까지 장바구니 시스템의 A부터 Z까지 다 훑어봤어! 마지막으로 핵심 원칙들을 정리해볼게.
2. 사용자 데이터 보존 — 비로그인 장바구니도 소중하게 관리
3. 병합 로직 완벽하게 — 로그인 시 데이터 손실 없이 병합
4. 보안은 기본 — HttpOnly, HTTPS, CSRF 방어는 필수
5. 성능 고려 — Redis 캐싱, 인덱스 최적화로 빠른 응답
6. 동시성 제어 — 재고 레이스 컨디션 반드시 처리
7. 점진적 확장 — 처음부터 완벽할 필요 없어, 규모에 맞게 성장
장바구니 시스템은 처음엔 단순해 보이지만 파면 팔수록 깊은 곳에서 복잡한 문제들이 튀어나와. 하지만 이 글에서 다룬 개념들을 잘 이해하고 적용하면 안정적이고 확장 가능한 장바구니를 만들 수 있어! 💪
특히 세션 관리와 병합 로직은 처음에 제대로 설계해두면 나중에 엄청난 시간을 절약할 수 있어. "나중에 고치면 되지"라는 생각은 금물! 🚫
이제 직접 구현해봐. 처음엔 작은 쇼핑몰부터 시작해서 점점 규모를 키워가는 게 최고의 학습 방법이야. 화이팅! 🔥
댓글 작성
이 글에 대한 여러분의 생각을 들려주세요
로그인이 필요합니다
댓글을 작성하려면 먼저 로그인해주세요.
댓글 0
지식인의 숲 - 지적 재산권 보호 고지
지적 재산권 보호 고지
- 저작권 및 소유권: 본 컨텐츠는 재능넷의 독점 AI 기술로 생성되었으며, 대한민국 저작권법 및 국제 저작권 협약에 의해 보호됩니다.
- AI 생성 컨텐츠의 법적 지위: 본 AI 생성 컨텐츠는 재능넷의 지적 창작물로 인정되며, 관련 법규에 따라 저작권 보호를 받습니다.
- 사용 제한: 재능넷의 명시적 서면 동의 없이 본 컨텐츠를 복제, 수정, 배포, 또는 상업적으로 활용하는 행위는 엄격히 금지됩니다.
- 데이터 수집 금지: 본 컨텐츠에 대한 무단 스크래핑, 크롤링, 및 자동화된 데이터 수집은 법적 제재의 대상이 됩니다.
- AI 학습 제한: 재능넷의 AI 생성 컨텐츠를 타 AI 모델 학습에 무단 사용하는 행위는 금지되며, 이는 지적 재산권 침해로 간주됩니다.
아직 댓글이 없습니다.