전자상거래
규칙
- 장바구니 상태:
localStorage 지속성을 가진 Zustand 스토어 — 페이지 새로고침 및 탭 닫기 시에도 유지됨
- 제품 변형: 공유 부모 제품을 가진 별도 SKU로 모델링 — 각 변형에는 자체 가격, 재고, 이미지가 있음
- 체크아웃: Stripe Checkout Sessions (서버 측) — 클라이언트에서 가격을 전송하지 않음
- 재고: 체크아웃 세션 생성 전 서버 측에서 재고 확인 — 데이터베이스 트랜잭션으로 경쟁 조건 보호
- 주문 생애주기:
pending → paid → shipped → delivered — 클라이언트 동작이 아닌 웹훅 이벤트를 통해 업데이트
- 제품 목록: 서버 측 필터링/정렬/페이지네이션 — 공유 가능한 필터링된 뷰를 위한 URL 검색 매개변수
- 가격: 센트(정수)로 저장 —
Intl.NumberFormat 및 통화 코드로 표시
- 장바구니 작업: 추가, 제거, 수량 업데이트, 비우기 — 모든 장바구니 작업 시 재고 검증
패턴
import { create } from "zustand";
import { persist } from "zustand/middleware";
interface CartItem { productId: string; variantId: string; quantity: number; price: number; }
interface CartStore {
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (variantId: string) => void;
updateQuantity: (variantId: string, quantity: number) => void;
clear: () => void;
total: () => number;
}
export const useCart = create<CartStore>()(
persist(
(set, get) => ({
items: [],
addItem: (item) => set((state) => {
const existing = state.items.find((i) => i.variantId === item.variantId);
if (existing) {
return { items: state.items.map((i) =>
i.variantId === item.variantId ? { ...i, quantity: i.quantity + item.quantity } : i
)};
}
return { items: [...state.items, item] };
}),
removeItem: (variantId) => set((s) => ({ items: s.items.filter((i) => i.variantId !== variantId) })),
updateQuantity: (variantId, quantity) => set((s) => ({
items: quantity <= 0
? s.items.filter((i) => i.variantId !== variantId)
: s.items.map((i) => (i.variantId === variantId ? { ...i, quantity } : i)),
})),
clear: () => set({ items: [] }),
total: () => get().items.reduce((sum, i) => sum + i.price * i.quantity, 0),
}),
{ name: "cart-storage" }
)
);
피해야 할 것
- 클라이언트에서 가격을 보내 체크아웃 생성 — 항상 데이터베이스에서 서버 측 가격을 확인
- 장바구니를 React 상태에만 저장 — 지속성이 있는 Zustand 또는 localStorage를 사용하여 새로고침 시 장바구니 유지
- "장바구니에 추가" 시 재고 차감 — 체크아웃 시에만 재고 예약, 세션 만료 시 해제
- 클라이언트에서 주문 상태 업데이트 —
checkout.session.completed에 대해 Stripe 웹훅 사용
- 돈에 부동 소수점 사용 — 정수(센트)로 저장, 표시용으로만 포맷