croissant_code
이커머스에서 장바구니와 주문은 왜 Cart / Cart Item 구조로 설계할까 본문
이커머스 시스템을 설계할 때 가장 먼저 등장하는 기능 중 하나가 장바구니(Cart)이다.
사용자는 상품을 바로 구매하지 않고 장바구니에 담아두었다가 나중에 주문을 진행한다.
하지만 데이터베이스를 설계할 때 많은 개발자가 처음에 이런 질문을 하게 된다.
장바구니 테이블을 Cart 하나만 만들면 안 될까?
결론부터 말하면 실무에서는 거의 항상 Cart / Cart_Item 구조를 사용한다.
그 이유를 예시와 함께 자세히 살펴보자.
1. 이커머스에서 장바구니의 의미
장바구니는 단순히 상품을 담는 기능처럼 보이지만 실제로는 다음과 같은 역할을 가진다.
사용자가 구매하기 전 상품들을 임시로 저장하는 공간
예를 들어 사용자가 다음과 같이 상품을 담을 수 있다.
Java 책 1권
Python 책 2권
Clean Code 1권
이 상태는 아직 주문이 아니다.
그냥 구매 후보 목록이다.
즉 장바구니는 다음 특징을 가진다.
- 언제든지 상품 추가 가능
- 수량 변경 가능
- 상품 삭제 가능
- 주문하면 장바구니 비워짐
따라서 장바구니는 변경이 될 수 있는 임시 데이터이다.
2. Cart 테이블 하나만 사용할 경우
처음 설계를 하는 개발자는 다음과 같은 구조를 생각할 수 있다.
Cart 테이블
| id | user_id | product_id | quantity |
| 1 | 5 | 10 | 1 |
| 2 | 5 | 11 | 2 |
| 3 | 5 | 12 | 1 |
이 구조는 다음을 의미한다.
유저 5번의 장바구니
상품10 → 1개
상품11 → 2개
상품12 → 1개
처음 보기에는 문제가 없어 보인다.
하지만 사실 이 구조에는 큰 문제가 있다.
3. Cart만 사용했을 때의 문제
A. 장바구니 개념이 사라진다
위 구조에서는 사실상 다음과 같은 의미가 된다.
Cart = 장바구니
가 아니라
Cart = 장바구니 상품
즉 Cart 자체가 아니라 Cart_Item 역할을 하고 있다.
장바구니 자체의 정보는 존재하지 않는다.
B. 장바구니 확장이 어려워진다
실제 서비스에서는 장바구니에 다음과 같은 정보가 들어갈 수 있다.
장바구니 생성 시간
마지막 수정 시간
선택된 상품 목록
쿠폰 적용 여부
etc..
예를 들어
| id | user_id | created_at |
| 1 | 5 | 2024-03-01 |
이런 장바구니 자체 정보를 저장하려면 Cart 테이블이 필요하다.
C. 데이터 정규화 문제가 발생한다
Cart만 사용할 경우 다음처럼 된다.
| id | user_id | product_id |
| 1 | 5 | 10 |
| 2 | 5 | 11 |
| 3 | 5 | 12 |
여기서 user_id는 계속 반복된다.
즉 데이터 중복이 발생한다.
정규화 관점에서도 좋지 않은 구조다.
4. 그래서 Cart / Cart_Item 구조를 사용한다
실무에서는 다음과 같이 설계한다.
Cart 테이블
장바구니 자체
| id | user_id |
| 1 | 5 |
Cart_Item 테이블
장바구니 안의 상품
| id | cart_id | product_id | quantity |
| 1 | 1 | 10 | 1 |
| 2 | 1 | 11 | 2 |
| 3 | 1 | 12 | 1 |
이 구조는 다음 의미를 가진다.
Cart
├ 상품10 → 1개
├ 상품11 → 2개
└ 상품12 → 1개
관계는 다음과 같다.
Cart (1)
│
│
Cart_Item (N)
즉
장바구니 1개
상품 여러 개
이 구조는 Header / Item 패턴이라고 부르며
이커머스 뿐 아니라 ERP, 회계, 물류 시스템에서도 매우 널리 사용된다.
5. Snapshot 구조가 필요한 이유 (Order에서)
이제 Snapshot 개념을 이해해보자.
Snapshot은 다음 의미다.
주문 당시의 상품 정보를 저장하는 것
예를 들어 상품 테이블이 있다고 하자.
Product
| id | name | price |
| 10 | Java Book | 15000 |
하지만 시간이 지나면 상품 정보는 바뀔 수 있다.
| id | name | price |
| 10 | Java Book (2nd Edition) | 20000 |
이때 문제가 생긴다.
예전에 주문한 기록이 다음과 같다고 하자.
Java Book
15000원
하지만 상품 가격이 변경되면
Java Book
20000원
이 되어버린다.
즉 과거 주문 기록이 깨진다.
그래서 Order 시스템에서는 snapshot 구조를 사용한다.
6. Order / Order_Item 구조
주문 시스템은 다음과 같이 설계한다.
Order
| id | user_id | total_price |
Order_Item
| order_id | product_name | product_price | quantity |
| 1001 | Java Book | 15000 | 1 |
여기서
product_name
product_price
는 주문 당시 정보(snapshot)이다.
즉 상품이 변경되어도 주문 기록은 변하지 않는다.
7. 그런데 Cart에서는 Snapshot이 필요 없다
Order와 달리 장바구니는 아직 주문이 발생하지 않은 상태이다.
따라서 상품 정보는 항상 현재 상품 정보를 보여주는 것이 맞다.
예를 들어 장바구니 조회는 보통 다음처럼 한다.
cart_item
JOIN product
그래서 결과는 다음과 같다.
상품명
현재 가격
상품 이미지
즉 장바구니는 상품 테이블을 기준으로 조회한다.
Snapshot을 저장할 필요가 없다.
7. 파생 데이터는 Cart 테이블에 저장하지 않는 이유
장바구니 설계를 할 때 종종 등장하는 질문이 있다.
장바구니에 total_price 컬럼을 넣어야 할까?
결론부터 말하면 대부분의 경우 Cart 테이블에는 total_price를 저장하지 않는다.
그 이유는 total_price가 파생 데이터(derived data)이기 때문이다.
A. 파생 데이터란 무엇인가
파생 데이터는 다른 데이터로부터 계산으로 만들어지는 값을 의미한다.
장바구니의 총 금액은 다음과 같이 계산된다.
총 금액 = 상품 가격 × 수량의 합
예를 들어 다음과 같은 장바구니가 있다고 하자.
CART_ITEM
상품가격수량
| Java Book | 15000 | 2 |
| Python Book | 20000 | 1 |
총 금액 계산
15000 × 2 + 20000 × 1
= 50000
즉
total_price = 계산값
이 값은 저장된 데이터가 아니라 계산 결과이다.
B. 파생 데이터를 저장하면 생기는 문제
만약 Cart 테이블에 total_price를 저장한다고 가정해보자.
CART
| id | user_id | total_price |
| 1 | 5 | 50000 |
CART_ITEM
| product | quantity |
| Java Book | 2 |
| Python Book | 1 |
이 상태에서 사용자가 상품을 하나 삭제했다고 가정하자.
Python Book 삭제
CART_ITEM
| product | quantity |
| Java Book | 2 |
이 경우 실제 장바구니 금액은
15000 × 2 = 30000
하지만 만약 Cart의 total_price를 업데이트하지 않았다면
CART
| id | total_price |
| 1 | 50000 |
이렇게 데이터 불일치(inconsistency) 문제가 발생한다.
즉
Cart 데이터
Cart_Item 데이터
두 테이블이 서로 다른 값을 가지게 되는 문제가 생긴다.
C. 그래서 실무에서는 조회할 때 계산한다
실제 서비스에서는 장바구니 조회 시 다음과 같이 계산한다.
SELECT
SUM(p.product_price * ci.quantity) AS total_price
FROM ecommerce_cart_item ci
JOIN ecommerce_products p
ON ci.product_code = p.product_code
WHERE ci.cart_id = 1;
또는 상품별 금액을 먼저 계산한다.
SELECT
p.product_name,
p.product_price,
ci.quantity,
(p.product_price * ci.quantity) AS item_price
FROM ecommerce_cart_item ci
JOIN ecommerce_products p
ON ci.product_code = p.product_code
WHERE ci.cart_id = 1;
그리고
SUM(item_price)
로 총 금액을 계산한다.
이 방식의 장점은 다음과 같다.
데이터 불일치 문제 없음
항상 최신 가격 반영
데이터 중복 없음
D. 장바구니와 주문의 차이
여기서 중요한 차이가 있다.
장바구니 (Cart)
임시 데이터
그래서
계산해서 사용
한다.
주문 (Order)
영구 기록
이기 때문에 snapshot 데이터를 반드시 저장한다.
ORDER
| id | total_price |
| 1001 | 50000 |
ORDER_ITEM
| product_name | product_price | quantity |
| Java Book | 15000 | 2 |
| Python Book | 20000 | 1 |
주문은 과거 기록을 보존해야 하기 때문에 다음을 저장한다.
product_name
product_price
total_price
하지만 장바구니는
현재 상태
만 의미하기 때문에 파생 데이터 저장이 필요 없다.
E. 최종 정리
장바구니 설계 원칙
Cart
Cart_Item
구조를 사용한다.
그리고
total_price
같은 파생 데이터는 저장하지 않는다.
이유
데이터 불일치 방지
데이터 중복 방지
항상 최신 데이터 반영
반면 주문(Order)은
snapshot 데이터 저장
이 필요하다.
한 줄 핵심 정리
Cart = 계산 데이터
Order = 기록 데이터
8. 정리
이커머스 시스템에서 장바구니는 다음 구조를 가진다.
Cart
└ Cart_Item
이 구조를 사용하는 이유는
- 한 장바구니에 여러 상품이 들어가기 때문
- 장바구니 자체 정보가 필요하기 때문
- 데이터 정규화를 유지하기 위해서
그리고 snapshot 구조는 다음에서만 사용된다.
Order
└ Order_Item
이유는
주문 당시 상품 정보를 보존해야 하기 때문
반면 장바구니는
임시 데이터
이기 때문에 snapshot이 필요하지 않다.
한 줄 정리
Cart → 임시 상품 목록
Order → 실제 거래 기록
그래서
Cart_Item에는 snapshot이 없고
Order_Item에는 snapshot이 존재한다.
'SW' 카테고리의 다른 글
| 테스트 코드 종류 (0) | 2026.03.13 |
|---|---|
| 테스트코드와 커버리지의 의미 (1) | 2026.03.13 |
| 원격 SSH 접근 환경 만들기 (1) | 2025.11.25 |
| 리액트 올인원-4-React.js (0) | 2025.10.16 |
| 리액트 올인원-3-Node.js (0) | 2025.10.15 |