Cart Domain
docs/domain/cart.md
Cart Domain
개요
장바구니 생성, 상품 추가/수량 변경/삭제, 게스트→회원 전환, 정책 관리를 담당하는 커머스 도메인. 게스트와 회원 모두 장바구니를 사용할 수 있으며, DynamoDB TTL을 통해 만료된 장바구니가 자동 정리됩니다.
서버 패키지: com.makitt.core.domain.cart
엔티티 관계도
┌─────────────────────────────────────────────────────────┐
│ Shop │
│ ├── CartPolicy (장바구니 정책 — Shop과 PK 공유) │
│ │ │
│ └── Cart (장바구니) │
│ ├── owner: Guest 또는 Customer │
│ └── CartItem (장바구니 항목 — Cart와 PK 공유) │
│ ├── StoreItem 참조 │
│ └── CartItemSnapshot (추가 시점 스냅샷) │
└─────────────────────────────────────────────────────────┘
계층 요약:
| 계층 | 역할 | 예시 |
|---|---|---|
| Cart | 장바구니 (소유자, 상태, TTL) | cart_a1b2c3d4e5f6 (ACTIVE) |
| CartItem | 장바구니 내 상품 항목 (수량, 스냅샷) | ci_x1y2z3w4v5u6 qty:2 |
| CartItemSnapshot | 추가 시점의 상품 정보 스냅샷 | "Velvet Lip Tint - Rose Pink" ₩22,000 |
| CartPolicy | Shop별 장바구니 정책 (TTL, 수량 제한, 병합 전략 등) | 게스트 허용, 최대 50개 라인 |
1. Cart
DynamoDB Entity
소스: makitt-core/.../cart/entity/Cart.java
| 필드 | 타입 | 설명 |
|---|---|---|
cartId | String | cart_{12자 UUID} 형식 |
shopId | String | 소속 Shop ID |
ownerType | CartOwnerType | GUEST 또는 CUSTOMER |
ownerId | String | Guest ID 또는 Customer ID |
status | CartStatus | ACTIVE 또는 EXPIRED |
itemCount | Integer | 비정규화된 항목 수 (빠른 조회용) |
ttl | Long | DynamoDB TTL (Unix 초). 자동 만료용 |
createdAt | Instant | 생성 시각 |
updatedAt | Instant | 최종 수정 시각 |
키 패턴
| Key | 패턴 | 예시 |
|---|---|---|
| PK | CART#{cartId} | CART#cart_a1b2c3d4e5f6 |
| SK | METADATA | METADATA |
| GSI1 PK | SHOP#{shopId} | SHOP#8e450dc8-... |
| GSI1 SK | CART#{createdAtEpochMs}#{cartId} | CART#1771392065711#cart_a1b2... |
| GSI2 PK | CART_OWNER#{shopId}#{ownerType}#{ownerId} | CART_OWNER#8e450dc8-...#CUSTOMER#cust_abc123 |
| GSI2 SK | CART#{status}#{cartId} | CART#ACTIVE#cart_a1b2... |
- GSI1 (EntityLookupIndex): Shop별 장바구니 목록 조회
- GSI2 (UniqueLookupIndex): 소유자별 활성 장바구니 조회 (
CART#ACTIVE#prefix로sortBeginsWith)
TTL 기본값
| 소유자 | TTL |
|---|---|
| Guest | 30일 |
| Customer | 90일 |
팩토리 메서드
Cart.createForGuest(shopId, guestId) // 게스트 장바구니 생성 Cart.createForCustomer(shopId, customerId) // 회원 장바구니 생성
비즈니스 메서드
| 메서드 | 설명 |
|---|---|
updateItemCount(newCount) | 항목 수 갱신 (새 Cart 반환) |
expire() | 상태를 EXPIRED로 변경 |
transferToCustomer(customerId) | 게스트 → 회원 전환 |
extendTtl() | TTL 갱신 |
isEmpty() | 항목 수 0 여부 |
isActive() | ACTIVE 상태 여부 |
2. CartItem
DynamoDB Entity
소스: makitt-core/.../cart/entity/CartItem.java
| 필드 | 타입 | 설명 |
|---|---|---|
cartItemId | String | ci_{12자 UUID} 형식 |
cartId | String | 부모 Cart ID |
storeItemKey | String | StoreItem PK (STOREITEM#{storeItemId}) |
storeItemId | String | StoreItem ID |
quantity | Integer | 수량 |
displaySnapshot | CartItemSnapshot | 추가 시점 상품 정보 스냅샷 |
addedAt | Instant | 추가 시각 |
updatedAt | Instant | 최종 수정 시각 |
키 패턴
| Key | 패턴 | 예시 |
|---|---|---|
| PK | CART#{cartId} | CART#cart_a1b2c3d4e5f6 (Cart와 동일) |
| SK | CARTITEM#{cartItemId} | CARTITEM#ci_x1y2z3w4v5u6 |
Item Collection 패턴: Cart와 CartItem이 동일한 PK를 공유하여,
SK begins_with CARTITEM#로 한 장바구니의 모든 항목을 효율적으로 조회합니다.
팩토리 메서드
CartItem.create(cartId, storeItemKey, storeItemId, quantity, displaySnapshot)
비즈니스 메서드
| 메서드 | 설명 |
|---|---|
updateQuantity(newQuantity) | 수량 변경 (새 CartItem 반환) |
updateSnapshot(newSnapshot) | 스냅샷 갱신 |
getLineTotal() | snapshot.price × quantity 계산 |
3. CartItemSnapshot
내장 객체
소스: makitt-core/.../cart/entity/CartItemSnapshot.java
장바구니에 상품을 추가하는 시점의 상품 정보를 스냅샷으로 저장합니다. 이후 상품 정보가 변경되어도 장바구니에는 추가 시점의 정보가 유지됩니다.
| 필드 | 타입 | 설명 |
|---|---|---|
productName | String | 상품명 |
thumbnail | String | 썸네일 이미지 URL |
storeItemName | String | StoreItem 표시명 (옵션 조합명) |
price | BigDecimal | 단가 |
compareAtPrice | BigDecimal | 정가 (할인 표시용, nullable) |
팩토리 메서드
CartItemSnapshot.of(productName, thumbnail, storeItemName, price, compareAtPrice) CartItemSnapshot.empty() // 빈 스냅샷
스냅샷 생성 시점
CartApplication.addItem()
│
├─ StoreItemService → StoreItem 조회 (price, storeItemName)
├─ ProductService → Product 조회 (productName, thumbnail)
│
└─ CartItemSnapshot.of(productName, thumbnail, storeItemName, price, compareAtPrice)
4. CartPolicy
DynamoDB Entity
소스: makitt-core/.../cart/entity/policy/CartPolicy.java
Shop별 장바구니 운영 정책. Shop 엔티티와 PK를 공유합니다 (item collection).
키 패턴
| Key | 패턴 | 예시 |
|---|---|---|
| PK | SHOP#{shopId} | SHOP#8e450dc8-... (Shop과 동일) |
| SK | CART_POLICY | CART_POLICY |
A. 접근 & 유지 정책
| 필드 | 타입 | 기본값 | 설명 |
|---|---|---|---|
guestCartEnabled | Boolean | true | 게스트 장바구니 허용 |
cartTtlDays | Integer | 30 | 장바구니 TTL (1–365일) |
mergeStrategy | MergeStrategy | COMBINE | 게스트→회원 전환 시 병합 전략 |
duplicateHandling | DuplicateHandling | SUM_QUANTITY | 병합 시 중복 항목 처리 |
B. 수량 & 라인 제한
| 필드 | 타입 | 기본값 | 설명 |
|---|---|---|---|
maxItemQuantity | Integer | null (무제한) | 항목당 최대 수량 (1–999) |
maxCartLines | Integer | 50 | 최대 라인 수 (1–999) |
minOrderAmountEnabled | Boolean | false | 최소 주문 금액 적용 여부 |
minOrderAmount | Long | null | 최소 주문 금액 (최소 화폐 단위) |
C. 재고 & 가격 변동 처리
| 필드 | 타입 | 기본값 | 설명 |
|---|---|---|---|
outOfStockHandling | OutOfStockHandling | BLOCK | 품절 상품 추가 처리 |
autoAdjustQuantity | Boolean | true | 재고 부족 시 수량 자동 조정 |
priceChangeHandling | PriceChangeHandling | ALWAYS_UPDATE | 가격 변동 처리 |
showPriceChangeBadge | Boolean | true | 가격 변동 배지 표시 |
D. 결제 규칙
| 필드 | 타입 | 기본값 | 설명 |
|---|---|---|---|
requireLoginForCheckout | Boolean | false | 결제 시 로그인 필수 |
collectGuestEmailBeforeCheckout | Boolean | true | 게스트 결제 전 이메일 수집 |
draftOrderExpiry | DraftOrderExpiry | HOURS_72 | 임시 주문 만료 시간 |
팩토리 메서드
CartPolicy.createDefault(shopId) // 위 기본값으로 정책 생성
5. Enum 정의
CartStatus
| 값 | 설명 |
|---|---|
ACTIVE | 활성 장바구니 — 수정 가능 |
EXPIRED | 만료된 장바구니 — 더 이상 사용 불가 |
CartOwnerType
| 값 | 설명 |
|---|---|
GUEST | 비회원 (게스트 토큰) |
CUSTOMER | 회원 |
MergeStrategy (게스트→회원 전환 시)
| 값 | 설명 |
|---|---|
COMBINE | 양쪽 장바구니 합침 |
LOGIN_USER | 회원 장바구니만 유지 |
GUEST | 게스트 장바구니만 유지 |
DuplicateHandling (병합 시 중복 항목)
| 값 | 설명 |
|---|---|
SUM_QUANTITY | 수량 합산 |
KEEP_HIGHER | 더 큰 수량 유지 |
KEEP_LOGIN_USER | 회원 쪽 수량 유지 |
OutOfStockHandling
| 값 | 설명 |
|---|---|
BLOCK | 품절 상품 추가 차단 |
ALLOW_WITH_NOTICE | 알림 표시 후 허용 |
PriceChangeHandling
| 값 | 설명 |
|---|---|
ALWAYS_UPDATE | 항상 최신 가격으로 갱신 |
CONFIRM_AT_CHECKOUT | 결제 시 가격 변동 확인 |
DraftOrderExpiry
| 값 | 시간 |
|---|---|
HOURS_24 | 24시간 |
HOURS_72 | 72시간 |
DAYS_7 | 7일 (168시간) |
6. 아키텍처 패턴
6.1 Item Collection 패턴
Cart와 CartItem이 동일한 PK (CART#{cartId})를 공유합니다:
PK: CART#cart_abc123
SK: METADATA → Cart 엔티티
SK: CARTITEM#ci_001 → CartItem #1
SK: CARTITEM#ci_002 → CartItem #2
SK: CARTITEM#ci_003 → CartItem #3
- Cart 조회:
PK = CART#{cartId}, SK = METADATA - CartItem 전체 조회:
PK = CART#{cartId}, SK begins_with CARTITEM# - 개별 CartItem 조회:
PK = CART#{cartId}, SK = CARTITEM#{cartItemId}
6.2 트랜잭션 패턴
Cart와 CartItem의 일관성을 위해 DynamoDB TransactWriteItems를 사용합니다:
새 항목 추가 시:
TransactWriteItems:
1. Cart 갱신 (itemCount + 1)
2. CartItem 저장
6.3 중복 항목 처리
같은 StoreItem을 다시 추가하면 새 항목을 만들지 않고 기존 항목의 수량을 증가시킵니다:
addItemToCart(cart, item)
├─ 기존 항목 존재? → 수량 합산 (itemCount 유지)
└─ 새 항목? → CartItem 저장 + itemCount 증가 (트랜잭션)
6.4 Display Snapshot 패턴
장바구니에 상품을 추가할 때, 그 시점의 상품 정보를 스냅샷으로 저장합니다. 이후 상품 가격이 변경되거나 삭제되어도 장바구니에서는 추가 시점의 정보를 표시할 수 있습니다.
6.5 GSI KEYS_ONLY 패턴
Cart의 GSI 조회 (소유자별 활성 장바구니 찾기)는 KEYS_ONLY projection을 사용합니다:
1. GSI2 쿼리 → PK/SK 키만 반환
2. Main Table getItem() → 전체 데이터 조회
7. 레이어별 클래스
Service (makitt-core)
| 클래스 | 주요 메서드 |
|---|---|
CartService | getOrCreateGuestCart, getOrCreateCustomerCart, findById, findActiveCartByOwner, addItemToCart, findCartItems, updateItemQuantity, removeItem, clearCart, deleteCartWithItems, expireCart |
CartPolicyService | getOrCreatePolicy, getPolicy, save, delete |
Repository (makitt-core)
| 클래스 | 설명 |
|---|---|
CartRepository | Cart/CartItem CRUD. 트랜잭션 쓰기 (saveCartWithItem, updateCartAndSaveItem), GSI2 소유자 조회, SK prefix 아이템 목록 조회 |
CartPolicyRepository | CartPolicy CRUD (SHOP#{shopId}, CART_POLICY) |
Application (makitt-application)
| 클래스 | 의존성 | 주요 메서드 |
|---|---|---|
CartApplication | CartService, StoreItemService, ProductService | getOrCreateCart, getCart, getActiveCartByOwner, addItem, updateItemQuantity, removeItem, clearCart, deleteCart |
CartPolicyApplication | CartPolicyService | getCartPolicy, updateCartPolicy |
Value Objects (makitt-core)
| VO | 설명 |
|---|---|
CartResponseVo | Cart + items + subtotal 응답. fromEntity(Cart), fromEntity(Cart, List<CartItem>) |
CartItemResponseVo | CartItem + lineTotal 응답. fromEntity(CartItem) |
CartItemSnapshotVo | 스냅샷 VO. fromEntity(CartItemSnapshot) |
CartPolicyResponseVo | 정책 VO (enum → EnumDto 변환). fromEntity(CartPolicy) |
8. Shop API
8.1 엔드포인트
모든 엔드포인트는 인증이 필요합니다 (ShopContext).
| Method | Path | 설명 | 인증 |
|---|---|---|---|
| GET | /shop/{shopId}/cart/summary | 장바구니 요약 (헤더 배지용) | 필수 |
| GET | /shop/{shopId}/cart | 장바구니 상세 (전체 항목 포함) | 필수 |
| POST | /shop/{shopId}/cart/items | 장바구니에 상품 추가 | 필수 |
소스: makitt-shop-api/.../controller/CartController.java
8.2 장바구니 요약 API
GET /shop/{shopId}/cart/summary
헤더의 장바구니 아이콘 배지 등에 사용. 장바구니가 없으면 자동 생성합니다.
Response: CartSummaryResponse
{ "cartId": "cart_a1b2c3d4e5f6", "itemCount": 3 }
8.3 장바구니 상세 API
GET /shop/{shopId}/cart
장바구니의 전체 항목을 포함한 상세 정보. 장바구니가 없으면 자동 생성합니다.
Response: CartDetailResponse
{ "cartId": "cart_a1b2c3d4e5f6", "shopId": "8e450dc8-2434-4ab4-aa75-252bbb8ace42", "ownerType": { "name": "CUSTOMER", "description": "Registered customer" }, "status": { "name": "ACTIVE", "description": "Active cart - can be modified" }, "itemCount": 2, "items": [ { "cartItemId": "ci_x1y2z3w4v5u6", "cartId": "cart_a1b2c3d4e5f6", "storeItemId": "720f4dbe-8e57-49d6-be96-a17ed477cf8f", "quantity": 2, "productName": "Vitamin C Brightening Serum", "thumbnail": "https://cdn.dev.makitt.shop/products/main/2026/02/79514563.png", "storeItemName": "단품", "price": "35000", "compareAtPrice": null, "lineTotal": "70000", "addedAt": 1771392065711, "updatedAt": 1771392065711 } ], "subtotal": "70000", "createdAt": 1771392065711, "updatedAt": 1771396886123 }
Response 필드:
| 필드 | 타입 | 설명 |
|---|---|---|
cartId | String | 장바구니 ID |
shopId | String | Shop ID |
ownerType | EnumDto | GUEST 또는 CUSTOMER |
status | EnumDto | ACTIVE 또는 EXPIRED |
itemCount | Integer | 항목 수 (라인 수) |
items | List<CartItemDto> | 항목 목록 |
subtotal | String (decimal) | 합계 금액 |
createdAt | Long (epoch-ms) | 생성 시각 |
updatedAt | Long (epoch-ms) | 최종 수정 시각 |
CartItemDto 필드:
| 필드 | 타입 | 설명 |
|---|---|---|
cartItemId | String | 항목 ID |
cartId | String | 장바구니 ID |
storeItemId | String | StoreItem ID |
quantity | Integer | 수량 |
productName | String | 상품명 (스냅샷) |
thumbnail | String | 썸네일 URL (스냅샷, nullable) |
storeItemName | String | 옵션 조합명 (스냅샷) |
price | String (decimal) | 단가 (스냅샷) |
compareAtPrice | String (decimal) | 정가 (스냅샷, nullable) |
lineTotal | String (decimal) | price × quantity |
addedAt | Long (epoch-ms) | 추가 시각 |
updatedAt | Long (epoch-ms) | 최종 수정 시각 |
8.4 장바구니 추가 API
POST /shop/{shopId}/cart/items
장바구니에 상품을 추가합니다. 두 가지 방식을 지원합니다.
Request: AddToCartRequest
{ "productId": "e91d3996-6623-4e56-b5c0-ccf631cb7154", "selections": [ "pvg_ample-ingredient:ing_niacinamide", "pvg_ample-ml:ml_10", "pvg_mask_offer-mask_qty:mask_1ea" ], "quantity": 1 }
| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
productId | String | O | 상품 ID |
selections | List<String> | △ | valueSelection 토큰 배열 (권장) |
variantSelections | Map<String, String> | △ | [Deprecated] variantGroupId → combinationKey 맵 |
quantity | Integer | X | 수량 (기본값: 1) |
selections와variantSelections중 하나를 전달합니다.selections가 있으면 우선 사용됩니다.
내부 처리 흐름:
1. ShopContext에서 인증 정보 추출 (customerId 또는 guestId)
2. 활성 장바구니 조회 또는 자동 생성
3. StoreItem 해석:
├─ selections 있음 → StoreItemResolver.resolveFromSelections()
│ ├─ 같은 pvgId의 selection 합산 (optionKey:valueKey|optionKey:valueKey)
│ ├─ required=false PVG 누락 시 "pvgId-none" 자동 보충
│ └─ canonical key 생성 → DynamoDB GSI3 조회
└─ variantSelections 있음 → StoreItemResolver.resolve() (레거시)
4. CartItemSnapshot 생성 (StoreItem + Product에서 정보 수집)
5. 장바구니에 추가:
├─ 동일 StoreItem 기존 항목 존재 → 수량 합산
└─ 새 항목 → CartItem 생성 + itemCount 증가 (트랜잭션)
Response: AddToCartResponse
{ "item": { "cartItemId": "ci_x1y2z3w4v5u6", "cartId": "cart_a1b2c3d4e5f6", "storeItemId": "720f4dbe-8e57-49d6-be96-a17ed477cf8f", "quantity": 1, "productName": "Ampoule Mask Combo Set", "thumbnail": "https://cdn.dev.makitt.shop/products/main/2026/02/abcdef01.png", "storeItemName": "나이아신아마이드 10ml / 마스크 1매", "price": "25000", "compareAtPrice": null, "lineTotal": "25000", "addedAt": 1771396886123, "updatedAt": 1771396886123 }, "cartId": "cart_a1b2c3d4e5f6", "cartItemCount": 3 }
8.5 에러 응답
모든 비즈니스 에러는 HTTP 400으로 반환됩니다.
| 상황 | 에러 코드 | 메시지 |
|---|---|---|
| 인증 필요 | error.auth.not_authenticated | 장바구니 기능을 사용하려면 로그인이 필요합니다 |
| 장바구니 없음 | error.cart.not.found | 장바구니를 찾을 수 없습니다 |
| 장바구니 만료 | error.cart.expired | 만료된 장바구니입니다 |
| 항목 없음 | error.cart.item.not.found | 장바구니 항목을 찾을 수 없습니다 |
| StoreItem 미매칭 | error.product.store.item.not.found | 선택한 옵션 조합에 해당하는 상품을 찾을 수 없습니다 |
| 잘못된 selection 형식 | error.product.store.item.invalid.selections | 잘못된 선택 형식입니다 |
9. Domain Manifest
소스: makitt-shop-api/.../manifest/descriptor/CartDomainDescriptor.java
Builder가 API 연동 시 참조하는 리소스 정의입니다.
| 리소스 | 타입 | Path | 설명 |
|---|---|---|---|
cartSummary | Query GET | /cart/summary | 장바구니 요약 |
cartDetail | Query GET | /cart | 장바구니 상세 |
addToCart | Action POST | /cart/items | 상품 추가 |
GSI 인덱스 사용 현황
| GSI | 인덱스명 | 용도 |
|---|---|---|
| GSI1 | EntityLookupIndex | Shop별 장바구니 목록 |
| GSI2 | UniqueLookupIndex | 소유자별 활성 장바구니 조회 |