Product Domain
docs/domain/product.md
Product Domain
개요
상품 등록, 옵션 관리, 가격 설정, 재고 연동을 담당하는 핵심 커머스 도메인. 셀러가 상품을 구성하고, 구매자가 옵션을 선택하여 주문하기까지의 전체 상품 데이터 구조를 정의합니다.
서버 패키지: com.makitt.core.domain.product
엔티티 관계도
┌─────────────────────────────────────────────────────────┐
│ Shop │
│ ├── Category (상품 분류) │
│ ├── Tag (상품 태그) │
│ ├── ShopAttribute (상품 속성 정의) │
│ ├── VariantGroup (옵션 템플릿) │
│ │ │
│ └── Product ◄──────────────────────────────────────┐ │
│ ├── ProductVariantGroup (상품별 옵션 그룹) │ │
│ │ ├── Option (옵션 키 + 값 목록) │ │
│ │ └── Combination (옵션 조합 → SU 매핑) │ │
│ │ │ │
│ └── StoreItem (판매 단위) ─────────────────────┘ │
│ └── SellableUnit (출고 단위) │
│ └── BomItem (구성품) │
│ └── SKU (최소 재고 단위, Argo 연동) │
└─────────────────────────────────────────────────────────┘
계층 요약:
| 계층 | 역할 | 예시 |
|---|---|---|
| Product | 상품 정보 (이름, 설명, 이미지, 옵션 구조) | "Velvet Lip Tint" |
| ProductVariantGroup | Product에 내장된 옵션 그룹 | 컬러 (Rose/Coral/Berry) |
| StoreItem | 옵션 조합별 판매 단위 (가격, 활성화) | "Velvet Lip Tint - Rose Pink" ₩22,000 |
| SellableUnit | 출고 구성 (BOM) | su_49812e5be5c9 (SKU-LIP-ROSE × 1) |
| BomItem | 출고 시 포함되는 SKU + 수량 | SKU-LIP-ROSE qty:1 |
| SKU | 최소 재고 관리 단위 (Argo CMS 연동) | SKU-LIP-ROSE barcode:8809... |
1. Product
DynamoDB Entity
소스: makitt-core/.../product/core/entity/Product.java
키빌더: makitt-core/.../common/dynamodb/key/ProductKey.java
키 구조:
| 키 | 패턴 | 예시 |
|---|---|---|
| PK | PRODUCT#{productId} | PRODUCT#c5278d25-409c-4db6-893c-54df8d16d509 |
| SK | METADATA | METADATA |
| GSI1 PK | SHOP#{shopId} | SHOP#8e450dc8-2434-4ab4-aa75-252bbb8ace42 |
| GSI1 SK | PRODUCT#{timestamp}#{productId} | PRODUCT#2026-02-18T05:21:05Z#c5278d25-... |
필드:
| 필드 | DynamoDB Attribute | 타입 | 필수 | 설명 |
|---|---|---|---|---|
| productId | product_id | String | O | UUID |
| shopId | shop_id | String | O | 소속 Shop ID |
| status | status | ProductStatus | O | 상품 상태 |
| name | name | String | O | 상품명 |
| description | description | String | X | 상세 설명 |
| shortDescription | short_description | String | X | 한줄 설명 |
| categoryName | category_name | String | X | 카테고리명 (비정규화) |
| tags | tags | List<String> | X | 태그 목록 |
| brandName | brand_name | String | X | 브랜드명 |
| supplierName | supplier_name | String | X | 공급업체명 |
| featuredPrice | featured_price | Long | X | 대표 가격 (원) |
| featuredCompareAtPrice | featured_compare_at_price | Long | X | 할인 전 가격 |
| attributes | attributes | List<ProductAttribute> | X | Shop 속성 값 |
| seo | seo | ProductSeo | X | SEO 설정 |
| thumbnailImage | thumbnail_image | String | X | 썸네일 (mainImages[0]) |
| mainImages | main_images | List<String> | X | 메인 이미지 URL |
| detailImages | detail_images | List<String> | X | 상세 이미지 URL |
| imageGroups | image_groups | List<ImageGroup> | X | 이미지 그룹 |
| productVariantGroups | product_variant_groups | List<ProductVariantGroup> | X | 옵션 그룹 (내장) |
| createdAt | created_at | Instant | O | 생성일시 |
| updatedAt | updated_at | Instant | O | 수정일시 |
ProductStatus enum:
| 값 | 설명 |
|---|---|
DRAFT | 초안 — 고객에게 미노출 |
ACTIVE | 활성 — 구매 가능 |
INACTIVE | 비활성 — 일시 중단 |
ARCHIVED | 보관 — 스토어에서 숨김 |
Embedded: ProductAttribute
{ "key": "skin_type", "value": "sensitive,all" }
Embedded: ProductSeo
{ "slug": "vitamin-c-serum", "title": "비타민C 세럼", "description": "..." }
Embedded: ImageGroup
{ "groupKey": "color_red", "groupName": "빨강 컬러샷", "sortOrder": 0, "images": ["https://..."] }
OpenSearch Document
인덱스: products
매핑 파일: opensearch/mappings/products-index.json
문서 클래스: ProductDocument.java
Sync: ProductSearchSyncListener — Product 엔티티 변경 시 자동 인덱싱
DynamoDB → OpenSearch 변환 규칙:
| DynamoDB | OpenSearch | 변환 |
|---|---|---|
attributes (List<ProductAttribute>) | attribute_values (keyword[]) | key + ":" + value 형태로 변환 |
seo.slug | seo_slug | 추출 |
seo.title | seo_title | 추출 |
productVariantGroups | product_variant_groups (nested) | 깊은 중첩 변환 |
| — (파생) | variant_group_count | PVG 개수 |
| — (파생) | group_types (keyword[]) | PVG의 groupType 목록 |
| — (파생) | has_offer_variants | OFFER 타입 존재 여부 |
| — (파생) | option_names (keyword[]) | 모든 option.optionName |
| — (파생) | option_values (keyword[]) | 모든 option.values[].valueName |
| — (파생) | search_text | name+desc+brand+tags+... 통합 텍스트 |
status (enum) | status (keyword) | .name() |
created_at (Instant) | created_at (long) | .toEpochMilli() |
인덱싱되지 않는 필드: pk, sk, gsi*, entityType, imageGroups
JSON 예시
DynamoDB (P03 Vitamin C Serum — OFFER 타입 옵션):
{ "PK": "PRODUCT#c5278d25-409c-4db6-893c-54df8d16d509", "SK": "METADATA", "entity_type": "PRODUCT", "product_id": "c5278d25-409c-4db6-893c-54df8d16d509", "shop_id": "8e450dc8-2434-4ab4-aa75-252bbb8ace42", "status": "ACTIVE", "name": "Vitamin C Brightening Serum", "description": "순수 비타민C 15% 함유 브라이트닝 세럼.", "short_description": "비타민C 15% 브라이트닝 세럼 30ml", "category_name": "Serums & Ampoules", "tags": ["Best Seller", "Vegan"], "brand_name": "MAKITT Beauty", "featured_price": 35000, "attributes": [ { "key": "product_type", "value": "serum" }, { "key": "skin_type", "value": "all" }, { "key": "volume_ml", "value": "30" } ], "main_images": ["https://cdn.dev.makitt.shop/products/main/2026/02/79514563.png"], "detail_images": [ "https://cdn.dev.makitt.shop/products/detail/2026/02/c225197f.png", "https://cdn.dev.makitt.shop/products/detail/2026/02/73a50050.png" ], "thumbnail_image": "https://cdn.dev.makitt.shop/products/main/2026/02/79514563.png", "product_variant_groups": [ { "product_variant_group_id": "pvg_serum_of", "product_variant_group_name": "Offer", "sort_order": 0, "group_type": "OFFER", "required": true, "ui_mode": "AUTO", "options": [ { "option_key": "offer", "option_name": "구매 옵션", "option_type": "text", "sort_order": 0, "values": [ { "value_key": "single", "value_name": "단품", "sort_order": 0 }, { "value_key": "1plus1", "value_name": "1+1", "sort_order": 1 }, { "value_key": "fullcare", "value_name": "풀케어 세트", "sort_order": 2 } ] } ], "combinations": [ { "combination_key": "offer:single", "combination_name": "단품", "selections": { "offer": "single" }, "sellable_unit_id": "su_7557e47428c5", "is_enabled": true }, { "combination_key": "offer:1plus1", "combination_name": "1+1", "selections": { "offer": "1plus1" }, "sellable_unit_id": "su_de08acd51cc2", "is_enabled": true }, { "combination_key": "offer:fullcare", "combination_name": "풀케어 세트", "selections": { "offer": "fullcare" }, "sellable_unit_id": "su_e62fa6101127", "is_enabled": true } ] } ], "gsi1_pk": "SHOP#8e450dc8-2434-4ab4-aa75-252bbb8ace42", "gsi1_sk": "PRODUCT#2026-02-18T05:21:05Z#c5278d25-409c-4db6-893c-54df8d16d509", "created_at": "2026-02-18T05:21:05.711721Z", "updated_at": "2026-02-18T08:08:06.123456Z" }
OpenSearch (동일 상품):
{ "product_id": "c5278d25-409c-4db6-893c-54df8d16d509", "shop_id": "8e450dc8-2434-4ab4-aa75-252bbb8ace42", "name": "Vitamin C Brightening Serum", "description": "순수 비타민C 15% 함유 브라이트닝 세럼.", "short_description": "비타민C 15% 브라이트닝 세럼 30ml", "category_name": "Serums & Ampoules", "tags": ["Best Seller", "Vegan"], "brand_name": "MAKITT Beauty", "attribute_values": ["product_type:serum", "skin_type:all", "volume_ml:30"], "thumbnail_image": "https://cdn.dev.makitt.shop/products/main/2026/02/79514563.png", "main_images": ["https://cdn.dev.makitt.shop/products/main/2026/02/79514563.png"], "detail_images": ["https://cdn.dev.makitt.shop/products/detail/2026/02/c225197f.png"], "featured_price": 35000, "product_variant_groups": [ { "product_variant_group_id": "pvg_serum_of", "product_variant_group_name": "Offer", "sort_order": 0, "group_type": "OFFER", "required": true, "ui_mode": "AUTO", "options": [ { "option_key": "offer", "option_name": "구매 옵션", "option_type": "text", "sort_order": 0, "values": [ { "value_key": "single", "value_name": "단품", "sort_order": 0 }, { "value_key": "1plus1", "value_name": "1+1", "sort_order": 1 }, { "value_key": "fullcare", "value_name": "풀케어 세트", "sort_order": 2 } ] } ], "combinations": [ { "combination_key": "offer:single", "combination_name": "단품", "selections": { "offer": "single" }, "sellable_unit_id": "su_7557e47428c5", "is_enabled": true }, { "combination_key": "offer:1plus1", "combination_name": "1+1", "selections": { "offer": "1plus1" }, "sellable_unit_id": "su_de08acd51cc2", "is_enabled": true }, { "combination_key": "offer:fullcare", "combination_name": "풀케어 세트", "selections": { "offer": "fullcare" }, "sellable_unit_id": "su_e62fa6101127", "is_enabled": true } ] } ], "variant_group_count": 1, "group_types": ["OFFER"], "has_offer_variants": true, "option_names": ["구매 옵션"], "option_values": ["단품", "1+1", "풀케어 세트"], "status": "ACTIVE", "search_text": "Vitamin C Brightening Serum 순수 비타민C 15% 함유 ... MAKITT Beauty Best Seller Vegan serum all 30", "created_at": 1771392065711, "updated_at": 1771396886123 }
2. ProductVariantGroup & Combination
ProductVariantGroup
Product에 내장(embedded) 되는 옵션 그룹. 독립 DynamoDB 엔티티가 아닙니다.
소스: product/core/entity/ProductVariantGroup.java
필드:
| 필드 | DynamoDB Attribute | 타입 | 설명 |
|---|---|---|---|
| productVariantGroupId | product_variant_group_id | String | pvg_{식별자} 형식 |
| variantGroupId | variant_group_id | String | 템플릿 VariantGroup 참조 (선택) |
| productVariantGroupName | product_variant_group_name | String | 표시명 |
| sortOrder | sort_order | Integer | 정렬 순서 |
| groupType | group_type | String | ATTRIBUTE 또는 OFFER |
| required | required | Boolean | 필수 선택 여부 |
| uiMode | ui_mode | String | AUTO (조합 1개면 숨김) / SHOW (항상 표시) |
| options | options | List<Option> | 옵션 정의 목록 |
| combinations | combinations | List<Combination> | 조합 → SU 매핑 목록 |
groupType: ATTRIBUTE vs OFFER
| 구분 | ATTRIBUTE | OFFER |
|---|---|---|
| 용도 | 상품의 물리적 속성 (색상, 사이즈) | 판매 구성 (단품, 세트, 1+1) |
| 예시 | 컬러: Rose/Coral/Berry | 구매옵션: 단품/1+1/풀케어 |
| StoreItem 관계 | 각 조합이 별도 StoreItem | 각 구성이 별도 StoreItem |
| 조합 방식 | 다수 ATTRIBUTE PVG → 데카르트 곱 | 각 OFFER PVG 독립 선택 |
| SellableUnit | 각 SU가 단일 SKU (보통) | 각 SU가 번들 BOM 가능 |
Option & OptionValue
Option:
| 필드 | 타입 | 예시 |
|---|---|---|
| optionKey | String | "lip_color", "size", "offer" |
| optionName | String | "컬러", "사이즈", "구매 옵션" |
| optionType | String | "text", "color", "size", "offer" |
| sortOrder | Integer | 0 |
| values | List<OptionValue> | 아래 참조 |
OptionValue:
| 필드 | 타입 | 예시 |
|---|---|---|
| valueKey | String | "rose", "S", "single" |
| valueName | String | "Rose Pink", "Small", "단품" |
| colorHex | String | "#E8909C" (color 타입만) |
| sortOrder | Integer | 0 |
| sellableUnitId | String | OFFER 타입의 직접 SU 연결 (선택) |
Combination
옵션 선택 조합과 SellableUnit 간의 매핑.
소스: product/core/entity/Combination.java
| 필드 | DynamoDB Attribute | 타입 | 설명 |
|---|---|---|---|
| combinationKey | combination_key | String | 정렬된 조합 키 (아래 규칙 참조) |
| combinationName | combination_name | String | 표시명 ("Rose Pink", "빨강/S") |
| selections | selections | Map<String,String> | { "lip_color": "rose" } |
| sellableUnitId | sellable_unit_id | String | 연결된 SellableUnit ID |
| isEnabled | is_enabled | Boolean | 활성화 여부 (PDP에서 판매 가능 판단) |
combinationKey 생성 규칙 (CombinationKeyUtil):
optionKey:valueKey|optionKey:valueKey|...
- 구분자:
:(key-value 사이),|(쌍 사이) - optionKey 기준 사전순(lexical) 정렬
예시:
| selections | combinationKey |
|---|---|
{ "offer": "single" } | offer:single |
{ "lip_color": "rose" } | lip_color:rose |
{ "color": "red", "size": "S" } | color:red|size:S |
JSON 예시 (P06 Lip Tint — ATTRIBUTE 타입)
{ "product_variant_group_id": "pvg_lip5", "product_variant_group_name": "Color", "sort_order": 0, "group_type": "ATTRIBUTE", "required": true, "ui_mode": "AUTO", "options": [ { "option_key": "lip_color", "option_name": "컬러", "option_type": "color", "sort_order": 0, "values": [ { "value_key": "rose", "value_name": "Rose Pink", "color_hex": "#E8909C", "sort_order": 0 }, { "value_key": "coral", "value_name": "Coral", "color_hex": "#FF7F50", "sort_order": 1 }, { "value_key": "berry", "value_name": "Berry", "color_hex": "#8B0A50", "sort_order": 2 }, { "value_key": "nude", "value_name": "Nude", "color_hex": "#C4A882", "sort_order": 3 }, { "value_key": "red", "value_name": "Classic Red", "color_hex": "#CC0000", "sort_order": 4 } ] } ], "combinations": [ { "combination_key": "lip_color:rose", "combination_name": "Rose Pink", "selections": { "lip_color": "rose" }, "sellable_unit_id": "su_49812e5be5c9", "is_enabled": true }, { "combination_key": "lip_color:coral", "combination_name": "Coral", "selections": { "lip_color": "coral" }, "sellable_unit_id": "su_643255da484f", "is_enabled": true }, { "combination_key": "lip_color:berry", "combination_name": "Berry", "selections": { "lip_color": "berry" }, "sellable_unit_id": "su_01e6702972ee", "is_enabled": true }, { "combination_key": "lip_color:nude", "combination_name": "Nude", "selections": { "lip_color": "nude" }, "sellable_unit_id": "su_f23246ded0d0", "is_enabled": true }, { "combination_key": "lip_color:red", "combination_name": "Classic Red", "selections": { "lip_color": "red" }, "sellable_unit_id": "su_96f010ee07f9", "is_enabled": true } ] }
JSON 예시 (P11 Primer — ATTRIBUTE + OFFER 혼합)
하나의 Product에 ATTRIBUTE PVG와 OFFER PVG가 공존하는 경우:
{ "product_variant_groups": [ { "product_variant_group_id": "pvg_primer_shade", "product_variant_group_name": "Shade", "sort_order": 0, "group_type": "ATTRIBUTE", "combinations": [ { "combination_key": "shade:pink", "combination_name": "Pink", "selections": { "shade": "pink" }, "sellable_unit_id": "su_eec1a3df243a", "is_enabled": true }, { "combination_key": "shade:lavender", "combination_name": "Lavender", "selections": { "shade": "lavender" }, "sellable_unit_id": "su_ebacba78d2ad", "is_enabled": true }, { "combination_key": "shade:peach", "combination_name": "Peach", "selections": { "shade": "peach" }, "sellable_unit_id": "su_349bb1a0dac5", "is_enabled": true } ] }, { "product_variant_group_id": "pvg_primer_offer", "product_variant_group_name": "Offer", "sort_order": 1, "group_type": "OFFER", "combinations": [ { "combination_key": "offer:single", "combination_name": "단품", "selections": { "offer": "single" }, "is_enabled": true }, { "combination_key": "offer:fullset", "combination_name": "풀케어 세트", "selections": { "offer": "fullset" }, "is_enabled": true } ] } ] }
이 경우 StoreItem은 3 shades × 2 offers = 6개 생성됩니다.
3. StoreItem
옵션 조합별 판매 단위. Product와 별도의 독립 DynamoDB 엔티티로 저장됩니다.
DynamoDB Entity
소스: product/storeitem/entity/StoreItem.java
키빌더: common/dynamodb/key/StoreItemKey.java
키 구조:
| 키 | 패턴 | 예시 |
|---|---|---|
| PK | STOREITEM#{storeItemId} | STOREITEM#57400bf2-541e-41e7-... |
| SK | METADATA | METADATA |
| GSI1 PK | PRODUCT#{productId} | PRODUCT#c5278d25-... |
| GSI1 SK | STOREITEM#{sortOrder:8pad}#{storeItemId} | STOREITEM#00000000#57400bf2-... |
| GSI2 PK | SHOP#{shopId} | SHOP#8e450dc8-... |
| GSI2 SK | STOREITEM#{createdAt}#{storeItemId} | STOREITEM#2026-02-18T...#57400bf2-... |
| GSI3 PK | PRODUCT#{productId}#STOREITEMKEY | PRODUCT#c5278d25-...#STOREITEMKEY |
| GSI3 SK | {storeItemKey} (canonical key 그대로) | pvg_serum_of-offer:single |
GSI3은 canonical key로 StoreItem을 직접 조회할 때 사용됩니다. 구매자가 옵션을 선택하면 프론트엔드에서 storeItemKey를 구성하여 매칭합니다.
필드:
| 필드 | DynamoDB Attribute | 타입 | 필수 | 설명 |
|---|---|---|---|---|
| storeItemId | store_item_id | String | O | UUID |
| productId | product_id | String | O | 소속 Product ID |
| shopId | shop_id | String | O | 소속 Shop ID |
| storeItemKey | store_item_key | String | O | Canonical Key (아래 상세) |
| productName | product_name | String | X | 비정규화된 상품명 |
| storeItemName | store_item_name | String | O | 표시명 ("Vitamin C Serum 단품") |
| priceConfig | price_config | PriceConfig | O | 가격 설정 (내장) |
| sellableUnitIds | sellable_unit_ids | List<String> | X | 연결된 SellableUnit ID |
| isEnabled | is_enabled | Boolean | O | 판매 가능 여부 (PDP 판단 기준) |
| sortOrder | sort_order | Integer | X | 정렬 순서 |
| argoStoreItemId | argo_store_item_id | Long | X | Argo CMS 연동 ID |
| syncStatus | sync_status | StoreItemSyncStatus | X | 동기화 상태 |
| createdAt | created_at | Instant | O | 생성일시 |
| updatedAt | updated_at | Instant | O | 수정일시 |
PriceConfig (내장):
| 필드 | 타입 | 설명 |
|---|---|---|
| price | Long | 판매가 (필수, >= 0) |
| compareAtPrice | Long | 정가 (할인 표시용) |
| cost | Long | 원가 |
| currency | String | 통화 (기본 "KRW") |
OpenSearch Document
인덱스: store-items
매핑 파일: opensearch/mappings/store-items-index.json
Sync: StoreItemSearchSyncListener — Product에서 brandName 비정규화
DynamoDB → OpenSearch 변환:
| DynamoDB | OpenSearch | 변환 |
|---|---|---|
price_config.price | price | 추출 |
price_config.compare_at_price | compare_at_price | 추출 |
price_config.cost | cost | 추출 |
price_config.currency | currency | 추출 |
| Product.brandName (fetch) | brand_name | Product 엔티티에서 비정규화 |
JSON 예시
DynamoDB (P03의 StoreItem — "단품" 옵션):
{ "PK": "STOREITEM#57400bf2-541e-41e7-8f18-2dcfb989a627", "SK": "METADATA", "entity_type": "STORE_ITEM", "store_item_id": "57400bf2-541e-41e7-8f18-2dcfb989a627", "product_id": "c5278d25-409c-4db6-893c-54df8d16d509", "shop_id": "8e450dc8-2434-4ab4-aa75-252bbb8ace42", "store_item_key": "pvg_serum_of-offer:single", "product_name": "Vitamin C Brightening Serum", "store_item_name": "Vitamin C Serum 단품", "price_config": { "price": 35000, "compare_at_price": null, "cost": null, "currency": "KRW" }, "sellable_unit_ids": ["su_7557e47428c5"], "is_enabled": true, "sort_order": 0, "gsi1_pk": "PRODUCT#c5278d25-409c-4db6-893c-54df8d16d509", "gsi1_sk": "STOREITEM#00000000#57400bf2-541e-41e7-8f18-2dcfb989a627", "gsi2_pk": "SHOP#8e450dc8-2434-4ab4-aa75-252bbb8ace42", "gsi2_sk": "STOREITEM#2026-02-18T05:21:05Z#57400bf2-541e-41e7-8f18-2dcfb989a627", "gsi3_pk": "PRODUCT#c5278d25-409c-4db6-893c-54df8d16d509#STOREITEMKEY", "gsi3_sk": "pvg_serum_of-offer:single", "created_at": 1771392065786, "updated_at": 1771392065786 }
OpenSearch:
{ "store_item_id": "57400bf2-541e-41e7-8f18-2dcfb989a627", "product_id": "c5278d25-409c-4db6-893c-54df8d16d509", "shop_id": "8e450dc8-2434-4ab4-aa75-252bbb8ace42", "store_item_key": "pvg_serum_of-offer:single", "store_item_name": "Vitamin C Serum 단품", "price": 35000, "compare_at_price": null, "cost": null, "currency": "KRW", "is_enabled": true, "sort_order": 0, "product_name": "Vitamin C Brightening Serum", "brand_name": "MAKITT Beauty", "sellable_unit_ids": ["su_7557e47428c5"], "search_text": "Vitamin C Serum 단품 Vitamin C Brightening Serum", "created_at": 1771392065786, "updated_at": 1771392065786 }
4. SellableUnit & BOM
출고 단위. SKU의 구성(BOM)을 정의하여, 하나의 SellableUnit이 여러 SKU를 묶을 수 있습니다.
DynamoDB Entity
소스: product/sellableunit/entity/SellableUnit.java
키빌더: common/dynamodb/key/SellableUnitKey.java
키 구조:
| 키 | 패턴 | 예시 |
|---|---|---|
| PK | SELLABLEUNIT#{sellableUnitId} | SELLABLEUNIT#su_7557e47428c5 |
| SK | METADATA | METADATA |
| GSI1 PK | SHOP#{shopId} | SHOP#8e450dc8-... |
| GSI1 SK | SELLABLEUNIT#{timestamp}#{sellableUnitId} | SELLABLEUNIT#2026-02-18T...#su_755... |
필드:
| 필드 | DynamoDB Attribute | 타입 | 필수 | 설명 |
|---|---|---|---|---|
| sellableUnitId | sellable_unit_id | String | O | su_{12자리hex} 형식 |
| shopId | shop_id | String | O | 소속 Shop |
| organizationId | organization_id | String | O | 소속 Organization |
| canonicalBomKey | canonical_bom_key | String | O | BOM 정규화 키 |
| name | name | String | O | 표시명 |
| description | description | String | X | 설명 |
| type | type | SellableUnitType | O | 유형 |
| enabled | enabled | Boolean | O | 활성화 여부 |
| bom | bom | List<BomItem> | O | 구성품 목록 (1개 이상 필수) |
| createdAt | created_at | Instant | O | 생성일시 |
| updatedAt | updated_at | Instant | O | 수정일시 |
SellableUnitType enum:
| 값 | 설명 | 예시 |
|---|---|---|
SINGLE | 단일 SKU 1개 | 세럼 1개 |
BUNDLE | 여러 SKU 묶음 | 세럼+토너+크림 세트 |
GIFT_INCLUDED | 사은품 포함 | 본품 + 샘플 키트 |
ADDON | 추가 옵션 | 기본 + 리필 파우치 |
UPGRADE | 업그레이드 | 기본 → 프리미엄 |
PROMOTIONAL | 프로모션 | 1+1 |
CONDITIONAL | 조건부 구성 | 3만원 이상 시 증정품 포함 |
BomItem (내장)
| 필드 | DynamoDB Attribute | 타입 | 설명 |
|---|---|---|---|
| skuId | sku_id | String | SKU ID (Argo referenceSkuId UUID) |
| qty | qty | Integer | 수량 (>= 1) |
| role | role | BomItemRole | REQUIRED (필수) / GIFT (사은품) |
canonicalBomKey 규칙
BOM 구성을 고유하게 식별하는 정규화 키:
{skuId}:{qty}|{skuId}:{qty}|...
skuId기준 사전순 정렬- 동일 BOM 구성은 항상 동일한 canonicalBomKey 생성
예시:
| BOM 구성 | canonicalBomKey |
|---|---|
| SKU-A × 1 | SKU-A:1 |
| SKU-A × 1, SKU-B × 2 | SKU-A:1|SKU-B:2 |
| SKU-B × 2, SKU-A × 1 (순서 다름) | SKU-A:1|SKU-B:2 (정렬 후 동일) |
OpenSearch Document
인덱스: sellable-units
Sync: 자동 Sync 리스너 없음 — 수동/벌크 인덱싱 필요
파생 필드:
| OpenSearch 필드 | 변환 |
|---|---|
bom_sku_ids | BOM의 모든 skuId 목록 |
sku_count | 고유 SKU 수 |
total_qty | BOM 전체 수량 합 |
JSON 예시
DynamoDB (P03 단품용 SU — SINGLE 타입):
{ "PK": "SELLABLEUNIT#su_7557e47428c5", "SK": "METADATA", "entity_type": "SELLABLE_UNIT", "sellable_unit_id": "su_7557e47428c5", "shop_id": "8e450dc8-2434-4ab4-aa75-252bbb8ace42", "organization_id": "a68a160d-4678-42e0-876d-0bbea3dd1001", "canonical_bom_key": "SKU-SERUM-VC:1", "name": "Vitamin C Serum 단품", "type": "SINGLE", "enabled": true, "bom": [ { "sku_id": "SKU-SERUM-VC", "qty": 1, "role": "REQUIRED" } ], "gsi1_pk": "SHOP#8e450dc8-2434-4ab4-aa75-252bbb8ace42", "gsi1_sk": "SELLABLEUNIT#2026-02-18T05:15:00Z#su_7557e47428c5", "created_at": "2026-02-18T05:15:00.123Z", "updated_at": "2026-02-18T05:15:00.123Z" }
DynamoDB (P03 풀케어 세트용 SU — BUNDLE 타입):
{ "sellable_unit_id": "su_e62fa6101127", "canonical_bom_key": "SKU-CREAM-CICA:1|SKU-SERUM-VC:1|SKU-TONER-HA:1", "name": "Vitamin C 풀케어 세트", "type": "BUNDLE", "bom": [ { "sku_id": "SKU-SERUM-VC", "qty": 1, "role": "REQUIRED" }, { "sku_id": "SKU-TONER-HA", "qty": 1, "role": "REQUIRED" }, { "sku_id": "SKU-CREAM-CICA", "qty": 1, "role": "REQUIRED" } ] }
OpenSearch:
{ "sellable_unit_id": "su_e62fa6101127", "shop_id": "8e450dc8-2434-4ab4-aa75-252bbb8ace42", "organization_id": "a68a160d-4678-42e0-876d-0bbea3dd1001", "canonical_bom_key": "SKU-CREAM-CICA:1|SKU-SERUM-VC:1|SKU-TONER-HA:1", "name": "Vitamin C 풀케어 세트", "type": "BUNDLE", "enabled": true, "bom": [ { "sku_id": "SKU-SERUM-VC", "qty": 1, "role": "REQUIRED" }, { "sku_id": "SKU-TONER-HA", "qty": 1, "role": "REQUIRED" }, { "sku_id": "SKU-CREAM-CICA", "qty": 1, "role": "REQUIRED" } ], "bom_sku_ids": ["SKU-SERUM-VC", "SKU-TONER-HA", "SKU-CREAM-CICA"], "sku_count": 3, "total_qty": 3, "search_text": "Vitamin C 풀케어 세트 SKU-CREAM-CICA:1|SKU-SERUM-VC:1|SKU-TONER-HA:1 SKU-SERUM-VC SKU-TONER-HA SKU-CREAM-CICA", "created_at": 1771391700123, "updated_at": 1771391700123 }
5. SKU
최소 재고 관리 단위. Argo CMS에서 생성되어 Kafka를 통해 MAKITT으로 동기화됩니다.
DynamoDB Entity
소스: product/sku/entity/SKU.java
키빌더: common/dynamodb/key/SKUKey.java
키 구조:
| 키 | 패턴 | 예시 |
|---|---|---|
| PK | SKU#{skuId} | SKU#a1b2c3d4-... |
| SK | METADATA | METADATA |
| GSI1 PK | ORGANIZATION#{organizationId} | ORGANIZATION#a68a160d-... |
| GSI1 SK | SKU#{timestamp}#{skuId} | SKU#2026-01-15T...#a1b2c3d4-... |
| GSI3 PK | ARGO_SKU#{argoSkuId} | ARGO_SKU#12345 |
| GSI3 SK | METADATA | METADATA |
skuId는 Argo의referenceSkuId(UUID).argoSkuId는 Argo 내부 시퀀스 ID (Long).
주요 필드:
| 필드 | DynamoDB Attribute | 타입 | 설명 |
|---|---|---|---|
| skuId | sku_id | String | = Argo referenceSkuId (UUID) |
| argoSkuId | argo_sku_id | Long | Argo 시퀀스 ID |
| organizationId | organization_id | String | 소속 Organization |
| skuCode | sku_code | String | Argo customerSkuId |
| skuName | sku_name | String | SKU명 |
| barcode | barcode | String | 바코드 |
| brand | brand | String | 브랜드 |
| price | price | Long | 가격 (원) |
| weight | weight | Double | 무게 (kg) |
| dimensions | dimensions | SKUDimensions | 길이/너비/높이 (cm) |
| storageTemperature | storage_temperature | StorageTemperature | ROOM / REFRIGERATED / FROZEN |
| stockStatus | stock_status | StockStatus | IN_STOCK / LOW_STOCK / OUT_OF_STOCK / BACKORDER |
| active | active | Boolean | Argo 활성 상태 |
| enabled | enabled | Boolean | Argo 사용 가능 상태 |
동기화: Kafka 토픽 cms-sku-info-change → MAKITT DynamoDB 저장
OpenSearch Document
인덱스: skus
Sync: 자동 Sync 리스너 없음 — Kafka 소비 시 인덱싱 또는 벌크 처리
JSON 예시
DynamoDB:
{ "PK": "SKU#a1b2c3d4-e5f6-7890-abcd-ef1234567890", "SK": "METADATA", "entity_type": "SKU", "sku_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "argo_sku_id": 12345, "organization_id": "a68a160d-4678-42e0-876d-0bbea3dd1001", "sku_code": "SKU-SERUM-VC-30ML", "sku_name": "비타민C 브라이트닝 세럼 30ml", "barcode": "8809123456789", "brand": "MAKITT Beauty", "price": 18000, "weight": 0.15, "dimensions": { "length": 4.5, "width": 4.5, "height": 12.0 }, "storage_temperature": "ROOM", "stock_status": "IN_STOCK", "active": true, "enabled": true, "gsi1_pk": "ORGANIZATION#a68a160d-4678-42e0-876d-0bbea3dd1001", "gsi1_sk": "SKU#2026-01-15T10:00:00Z#a1b2c3d4-e5f6-7890-abcd-ef1234567890", "gsi3_pk": "ARGO_SKU#12345", "gsi3_sk": "METADATA", "created_at": "2026-01-15T10:00:00Z", "updated_at": "2026-02-10T14:30:00Z" }
OpenSearch:
{ "sku_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "organization_id": "a68a160d-4678-42e0-876d-0bbea3dd1001", "reference_sku_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "sku_code": "SKU-SERUM-VC-30ML", "sku_name": "비타민C 브라이트닝 세럼 30ml", "barcode": "8809123456789", "brand": "MAKITT Beauty", "price": 18000, "weight": 0.15, "length": 4.5, "width": 4.5, "height": 12.0, "storage_temperature": "ROOM", "stock_status": "IN_STOCK", "active": true, "enabled": true, "search_text": "비타민C 브라이트닝 세럼 30ml 8809123456789 MAKITT Beauty SKU-SERUM-VC-30ML", "created_at": 1736935200000, "updated_at": 1739194200000 }
6. Category
상품 분류 체계. Shop 단위로 생성하며, 계층 구조(parent-child)를 지원합니다.
DynamoDB Entity
소스: product/category/entity/Category.java
키빌더: common/dynamodb/key/CategoryKey.java
키 구조:
| 키 | 패턴 | 예시 |
|---|---|---|
| PK | CATEGORY#{categoryId} | CATEGORY#uuid |
| SK | METADATA | METADATA |
| GSI1 PK | SHOP#{shopId} | SHOP#8e450dc8-... |
| GSI1 SK | CATEGORY#{level:3pad}#{position:5pad}#{categoryId} | CATEGORY#000#00001#uuid |
GSI1 SK에 level과 position을 패딩하여 정렬 가능하게 합니다.
필드:
| 필드 | DynamoDB Attribute | 타입 | 필수 | 설명 |
|---|---|---|---|---|
| categoryId | category_id | String | O | UUID |
| shopId | shop_id | String | O | 소속 Shop |
| categoryName | category_name | String | O | 카테고리명 |
| description | description | String | X | 설명 |
| parentCategoryId | parent_category_id | String | X | 부모 카테고리 (null = 루트) |
| path | path | String | X | 전체 경로 ("/electronics/laptops") |
| level | level | Integer | O | 깊이 (0 = 루트) |
| position | position | Integer | O | 같은 부모 내 순서 |
| slug | slug | String | X | URL 슬러그 |
| thumbnailUrl | thumbnail_url | String | X | 썸네일 |
| isActive | is_active | Boolean | O | 활성화 |
JSON 예시
{ "PK": "CATEGORY#f1a2b3c4-d5e6-7890-abcd-ef1234567890", "SK": "METADATA", "entity_type": "CATEGORY", "category_id": "f1a2b3c4-d5e6-7890-abcd-ef1234567890", "shop_id": "8e450dc8-2434-4ab4-aa75-252bbb8ace42", "category_name": "Serums & Ampoules", "description": "세럼, 앰플, 에센스 등 집중 케어 제품", "parent_category_id": "parent-uuid-skincare", "path": "/Skincare/Serums & Ampoules", "level": 1, "position": 2, "slug": "serums-ampoules", "is_active": true, "gsi1_pk": "SHOP#8e450dc8-2434-4ab4-aa75-252bbb8ace42", "gsi1_sk": "CATEGORY#001#00002#f1a2b3c4-d5e6-7890-abcd-ef1234567890", "created_at": "2026-02-18T04:44:54Z", "updated_at": "2026-02-18T04:44:54Z" }
7. Tag
상품에 부착하는 태그. Shop 단위로 관리됩니다.
DynamoDB Entity
소스: product/tag/entity/Tag.java
키빌더: common/dynamodb/key/TagKey.java
키 구조:
| 키 | 패턴 | 예시 |
|---|---|---|
| PK | TAG#{tagId} | TAG#uuid |
| SK | METADATA | METADATA |
| GSI1 PK | SHOP#{shopId} | SHOP#8e450dc8-... |
| GSI1 SK | TAG#{position:5pad}#{tagId} | TAG#00001#uuid |
필드:
| 필드 | DynamoDB Attribute | 타입 | 필수 | 설명 |
|---|---|---|---|---|
| tagId | tag_id | String | O | UUID |
| shopId | shop_id | String | O | 소속 Shop |
| tagName | tag_name | String | O | 태그명 ("Best Seller", "Vegan") |
| slug | slug | String | X | URL 슬러그 |
| description | description | String | X | 설명 |
| color | color | String | X | 색상 Hex ("#FF5733") |
| position | position | Integer | O | 정렬 순서 |
| isActive | is_active | Boolean | O | 활성화 |
JSON 예시
{ "PK": "TAG#a1b2c3d4-tag-uuid", "SK": "METADATA", "entity_type": "TAG", "tag_id": "a1b2c3d4-tag-uuid", "shop_id": "8e450dc8-2434-4ab4-aa75-252bbb8ace42", "tag_name": "Best Seller", "slug": "best-seller", "color": "#FF6B35", "position": 0, "is_active": true, "gsi1_pk": "SHOP#8e450dc8-2434-4ab4-aa75-252bbb8ace42", "gsi1_sk": "TAG#00000#a1b2c3d4-tag-uuid", "created_at": "2026-02-18T04:50:00Z", "updated_at": "2026-02-18T04:50:00Z" }
8. ShopAttribute
Shop이 정의하는 동적 상품 속성. Product의 attributes 필드에서 key-value로 참조됩니다.
DynamoDB Entity
소스: product/shopattribute/entity/ShopAttribute.java
키빌더: common/dynamodb/key/ShopAttributeKey.java
키 구조:
| 키 | 패턴 | 예시 |
|---|---|---|
| PK | SHOPATTRIBUTE#{attributeId} | SHOPATTRIBUTE#uuid |
| SK | METADATA | METADATA |
| GSI1 PK | SHOP#{shopId} | SHOP#8e450dc8-... |
| GSI1 SK | SHOPATTRIBUTE#{sortOrder:5pad}#{attributeId} | SHOPATTRIBUTE#00001#uuid |
필드:
| 필드 | DynamoDB Attribute | 타입 | 필수 | 설명 |
|---|---|---|---|---|
| attributeId | attribute_id | String | O | UUID |
| shopId | shop_id | String | O | 소속 Shop |
| key | key | String | O | 불변 키 ("skin_type", "texture") |
| name | name | String | O | 표시명 ("피부 타입", "텍스처") |
| description | description | String | X | 설명 |
| type | type | AttributeType | O | 속성 타입 |
| values | values | List<AttributeValueDef> | X | 선택지 (SELECT 타입) |
| showInFilter | show_in_filter | Boolean | X | 필터 사이드바 표시 |
| showInProduct | show_in_product | Boolean | X | 상품 상세 표시 |
| sortOrder | sort_order | Integer | X | 정렬 순서 |
| isRequired | is_required | Boolean | X | 상품 등록 시 필수 |
AttributeType enum:
| 값 | 설명 | 예시 |
|---|---|---|
SINGLE_SELECT | 단일 선택 | 피부 타입: [지성/건성/복합성] |
MULTI_SELECT | 다중 선택 | 피부 고민: [여드름, 주름, 색소침착] |
TEXT | 자유 텍스트 | 용량: "30ml" |
NUMBER | 숫자 | 용량(ml): 30 |
BOOLEAN | 참/거짓 | 비건 여부: true |
AttributeValueDef (내장 — SELECT 타입의 선택지):
| 필드 | 타입 | 설명 |
|---|---|---|
| valueId | String | UUID |
| value | String | 값 ("sensitive") |
| label | String | 표시명 ("민감성") |
| colorHex | String | 색상 (선택) |
| sortOrder | Integer | 정렬 |
| isActive | Boolean | 활성화 |
JSON 예시
{ "PK": "SHOPATTRIBUTE#attr-uuid-skin-type", "SK": "METADATA", "entity_type": "SHOP_ATTRIBUTE", "attribute_id": "attr-uuid-skin-type", "shop_id": "8e450dc8-2434-4ab4-aa75-252bbb8ace42", "key": "skin_type", "name": "Skin Type", "description": "대상 피부 타입", "type": "MULTI_SELECT", "values": [ { "value_id": "val-uuid-1", "value": "oily", "label": "Oily", "sort_order": 0, "is_active": true }, { "value_id": "val-uuid-2", "value": "dry", "label": "Dry", "sort_order": 1, "is_active": true }, { "value_id": "val-uuid-3", "value": "sensitive", "label": "Sensitive", "sort_order": 2, "is_active": true } ], "show_in_filter": true, "show_in_product": true, "sort_order": 1, "is_required": false, "gsi1_pk": "SHOP#8e450dc8-2434-4ab4-aa75-252bbb8ace42", "gsi1_sk": "SHOPATTRIBUTE#00001#attr-uuid-skin-type", "created_at": "2026-02-18T04:55:00Z", "updated_at": "2026-02-18T04:55:00Z" }
9. VariantGroup (템플릿)
Shop 수준의 옵션 그룹 템플릿. Product에 적용하면 ProductVariantGroup으로 복사됩니다.
DynamoDB Entity
소스: product/variant/entity/VariantGroup.java
키빌더: common/dynamodb/key/VariantGroupKey.java
키 구조:
| 키 | 패턴 | 예시 |
|---|---|---|
| PK | VARIANTGROUP#{variantGroupId} | VARIANTGROUP#vg_a1b2c3d4e5f6 |
| SK | METADATA | METADATA |
| GSI1 PK | SHOP#{shopId} | SHOP#8e450dc8-... |
| GSI1 SK | VARIANTGROUP#{variantGroupId} | VARIANTGROUP#vg_a1b2c3d4e5f6 |
필드:
| 필드 | DynamoDB Attribute | 타입 | 필수 | 설명 |
|---|---|---|---|---|
| variantGroupId | variant_group_id | String | O | vg_{12자리hex} 형식 |
| shopId | shop_id | String | O | 소속 Shop |
| name | name | String | O | 템플릿명 |
| description | description | String | X | 설명 |
| type | type | VariantGroupType | O | ATTRIBUTE / OFFER |
| required | required | Boolean | O | 필수 선택 여부 |
| uiMode | ui_mode | VariantGroupUIMode | O | AUTO / SHOW |
| attributeJson | attribute | String | X | ATTRIBUTE 설정 JSON (type=ATTRIBUTE) |
| offerJson | offer | String | X | OFFER 설정 JSON (type=OFFER) |
attributeJson/offerJson은 DynamoDB에 JSON 문자열로 저장됩니다. 비즈니스 getter(getAttributeConfig(),getOfferConfig())가 역직렬화합니다.
OpenSearch Document
인덱스: variant-groups
Sync: VariantGroupSearchSyncListener
JSON 예시 (ATTRIBUTE 타입)
{ "PK": "VARIANTGROUP#vg_a1b2c3d4e5f6", "SK": "METADATA", "entity_type": "VARIANT_GROUP", "variant_group_id": "vg_a1b2c3d4e5f6", "shop_id": "8e450dc8-2434-4ab4-aa75-252bbb8ace42", "name": "컬러 옵션", "type": "ATTRIBUTE", "required": true, "ui_mode": "AUTO", "attribute": "{\"options\":[{\"optionId\":\"opt1\",\"optionKey\":\"color\",\"optionName\":\"색상\",\"optionType\":\"COLOR\",\"values\":[{\"valueId\":\"v1\",\"value\":\"Red\",\"displayLabel\":\"빨강\",\"colorHex\":\"#FF0000\",\"sortOrder\":0}],\"sortOrder\":0}]}", "gsi1_pk": "SHOP#8e450dc8-2434-4ab4-aa75-252bbb8ace42", "gsi1_sk": "VARIANTGROUP#vg_a1b2c3d4e5f6", "created_at": "2026-02-01T10:00:00Z", "updated_at": "2026-02-01T10:00:00Z" }
OpenSearch:
{ "variant_group_id": "vg_a1b2c3d4e5f6", "shop_id": "8e450dc8-2434-4ab4-aa75-252bbb8ace42", "name": "컬러 옵션", "type": "ATTRIBUTE", "required": true, "ui_mode": "AUTO", "options": [ { "option_id": "opt1", "option_key": "color", "option_name": "색상", "option_type": "COLOR", "values": ["Red"], "value_count": 1, "sort_order": 0 } ], "option_names": ["색상"], "option_values": ["Red"], "option_count": 1, "offer_variants": [], "offer_variant_names": [], "sellable_unit_ids": [], "offer_variant_count": 0, "search_text": "컬러 옵션 Red", "created_at": 1738400400000, "updated_at": 1738400400000 }
10. StoreItem Canonical Key 규칙
StoreItem의 storeItemKey는 구매자가 선택한 옵션 조합을 고유하게 식별하는 정규화 키입니다. PDP에서 옵션 선택 → StoreItem 매칭의 핵심입니다.
소스:
StoreItemCanonicalKeyUtil.java— storeItemKey 생성/파싱/정규화CombinationKeyUtil.java— combinationKey 생성/파싱/정규화StoreItemValidator.java— storeItemKey 유효성 검증StoreItemCandidateApplication.java— 후보 StoreItem 생성
10.1 CombinationKey (내부 키)
하나의 PVG 내에서 선택된 옵션 조합을 표현합니다.
형식:
optionKey:valueKey|optionKey:valueKey|...
규칙:
- 구분자:
:(key-value),|(쌍 사이) - optionKey 기준 사전순(lexical) 정렬
normalize(): 파싱 후 재정렬하여 정규화
예시:
| 선택 | combinationKey |
|---|---|
| offer=single | offer:single |
| lip_color=rose | lip_color:rose |
| color=red, size=S | color:red|size:S (color < size) |
| size=M, color=blue | color:blue|size:M (정렬됨) |
10.2 StoreItemKey (외부 키)
여러 PVG에 걸친 전체 선택을 하나의 키로 합칩니다.
형식:
{pvgId}-{combinationKey}::{pvgId}-{combinationKey}::...
규칙:
- 세그먼트 구분자:
::(combinationKey 내부에:가 있으므로::사용) - 세그먼트 내부:
{pvgId}-{combinationKey}(-로 연결) - 파싱: 각 세그먼트의 첫 번째
-를 기준으로 pvgId와 combinationKey 분리
세그먼트 정렬 규칙:
pvgId 사전순(lexical) 정렬 — 단일 규칙.
모든 컨텍스트(생성, 조회, 정규화)에서 StoreItemCanonicalKeyUtil.generate()를 통해 동일한 lexical order로 키를 생성합니다. PVG 타입(ATTRIBUTE/OFFER)이나 sortOrder는 키 정렬에 영향을 주지 않습니다.
OFFER required=false 시 none 슬롯:
선택적 OFFER PVG에서 사용자가 선택하지 않은 경우, combinationKey 자리에 "none" 사용:
pvg_gift-none → 사은품 미선택
pvg_gift-gift:sample → 샘플 키트 선택
10.3 유형별 예시
Case 1: 단일 상품 (옵션 없음)
pvg_94e75949-default:default
단일 StoreItem도 PVG가 필요합니다. "Default" PVG를 생성하여 default:default 조합 사용.
Case 2: OFFER 단일 PVG (P03 Serum)
3개 옵션 → 3개 StoreItem:
pvg_serum_of-offer:single → 단품 ₩35,000
pvg_serum_of-offer:1plus1 → 1+1 ₩56,000
pvg_serum_of-offer:fullcare → 풀케어 세트 ₩72,000
Case 3: ATTRIBUTE 단일 PVG (P06 Lip Tint)
5개 컬러 → 5개 StoreItem:
pvg_lip5-lip_color:rose → Rose Pink ₩22,000
pvg_lip5-lip_color:coral → Coral ₩22,000
pvg_lip5-lip_color:berry → Berry ₩22,000
pvg_lip5-lip_color:nude → Nude ₩22,000
pvg_lip5-lip_color:red → Classic Red ₩22,000
Case 4: ATTRIBUTE + OFFER 혼합 (P11 Primer)
3 shades × 2 offers = 6개 StoreItem. 각 StoreItem의 키는 두 PVG의 조합:
pvg_primer_offer-offer:fullset::pvg_primer_shade-shade:lavender
pvg_primer_offer-offer:fullset::pvg_primer_shade-shade:peach
pvg_primer_offer-offer:fullset::pvg_primer_shade-shade:pink
pvg_primer_offer-offer:single::pvg_primer_shade-shade:lavender
pvg_primer_offer-offer:single::pvg_primer_shade-shade:peach
pvg_primer_offer-offer:single::pvg_primer_shade-shade:pink
pvgId 사전순:
pvg_primer_offer<pvg_primer_shade→ OFFER 세그먼트가 먼저 옵니다. PVG 타입과 무관하게 항상 pvgId lexical order로 정렬됩니다.
Case 5: OFFER required=false (선택적 OFFER)
required=false인 OFFER PVG는 선택하지 않을 수 있습니다. 이 경우 none 슬롯이 추가:
pvg_attr_color-color:red::pvg_gift-none → 빨강, 사은품 없음
pvg_attr_color-color:red::pvg_gift-gift:sample_kit → 빨강, 샘플 키트 추가
10.4 Validation 규칙 (StoreItemValidator)
- 최소 1개: StoreItem 목록은 비어있을 수 없음
- 키 유일성: 동일 Product 내 중복 storeItemKey 불가
- 형식 검증:
StoreItemCanonicalKeyUtil.parse(key)가 1개 이상의GroupSelection반환 - PVG 참조: storeItemKey의 모든 pvgId가 Product의 PVG 목록에 존재해야 함
- 이름: 비어있지 않음, 최대 200자
- 가격:
priceConfig.price필수, >= 0
10.5 PDP 판매 가능 판단 흐름
사용자가 옵션 선택 완료
│
▼
각 PVG의 선택된 combinationKey로 storeItemKey 구성
(pvgId-combKey 세그먼트를 :: 로 연결, pvgId 사전순 정렬)
│
▼
storeItems.find(item =>
item.storeItemKey === expectedKey
&& item.isEnabled === true
)
│
찾았나?
/ \
YES NO
│ │
구매가능 "판매하지 않는 옵션입니다"
│ 버튼 비활성화
장바구니/
바로구매
11. Shop API (상점 공개 API)
Shop API는 Builder/PDP에서 소비하는 API입니다. 상품 조회는 OpenSearch에서, resolve는 DynamoDB에서 처리됩니다.
Base URL: http://localhost:10000/shop/{shopId}
소스:
- Controller:
makitt-shop-api/.../shop/controller/ProductController.java - DTO:
makitt-shop-api/.../shop/dto/product/ProductDto.java - Response:
makitt-shop-api/.../shop/dto/product/ProductSearchResponse.java
11.1 상품 목록 API
GET /shop/{shopId}/products/list
기본 페이지네이션 목록. 필터 없이 전체 상품 조회.
Query Parameters:
| 파라미터 | 타입 | 기본값 | 설명 |
|---|---|---|---|
page | integer | 1 | 페이지 번호 (1-indexed) |
size | integer | 20 | 페이지 크기 (최대 100) |
sortBy | string | newest | 정렬: price_asc, price_desc, newest, name |
GET /shop/{shopId}/products/search
고급 검색/필터링 + Facet 지원.
Query Parameters:
| 파라미터 | 타입 | 기본값 | 설명 |
|---|---|---|---|
q | string | - | 전문 검색 쿼리 |
categories | list | - | 카테고리명 필터 (복수 가능) |
brands | list | - | 브랜드명 필터 |
vendors | list | - | 공급업체명 필터 |
tags | list | - | 태그 필터 |
attributes | list | - | 속성 필터 (key:value 형식, 예: skinType:dry) |
minPrice | long | - | 최소 가격 |
maxPrice | long | - | 최대 가격 |
inStock | boolean | - | 재고 여부 필터 |
status | string | - | 상품 상태 필터 |
sortBy | string | newest | 정렬: relevance, price_asc, price_desc, newest, name |
page | integer | 1 | 페이지 번호 (1-indexed) |
size | integer | 20 | 페이지 크기 (최대 100) |
includeFacets | boolean | false | Facet 집계 포함 여부 |
정렬 → OpenSearch 필드 매핑:
| sortBy | OpenSearch 필드 | 순서 |
|---|---|---|
relevance | _score | desc |
price_asc | featured_price | asc |
price_desc | featured_price | desc |
newest | created_at | desc |
name | name.keyword | asc |
목록 응답: ProductSearchResponse
{ "items": [ ProductDto, ... ], "page": 1, "size": 20, "totalItems": 150, "totalPages": 8, "hasNext": true, "hasPrevious": false, "facets": { ... }, "priceStats": { ... } }
Facet 필드 (includeFacets=true 일 때만):
| 응답 필드 | 데이터 출처 |
|---|---|
facets.categories | OpenSearch category_name terms 집계 |
facets.brands | OpenSearch brand_name terms 집계 |
facets.suppliers | OpenSearch supplier_name terms 집계 |
facets.tags | OpenSearch tags terms 집계 |
facets.attributeValues | OpenSearch attribute_values terms 집계 |
facets.status | OpenSearch status terms 집계 |
priceStats | OpenSearch featured_price stats 집계 (min/max/avg/count) |
11.2 상품 상세 API
GET /shop/{shopId}/products/{productId}
Product ID로 단건 조회. OpenSearch products 인덱스에서 조회.
GET /shop/{shopId}/products/by-slug/{slug}
SEO slug로 단건 조회. shop_id == shopId AND seo_slug == slug 매칭.
상세 응답: ProductDto
{ "id": "c5278d25-409c-4db6-893c-54df8d16d509", "shopId": "8e450dc8-2434-4ab4-aa75-252bbb8ace42", "name": "Vitamin C Brightening Serum", "description": "순수 비타민C 15% 함유 브라이트닝 세럼.", "shortDescription": "비타민C 15% 브라이트닝 세럼 30ml", "categoryName": "Serums & Ampoules", "tags": ["Best Seller", "Vegan"], "brandName": "MAKITT Beauty", "supplierName": null, "attributeValues": ["product_type:serum", "skin_type:all", "volume_ml:30"], "thumbnailImage": "https://cdn.dev.makitt.shop/products/main/2026/02/79514563.png", "mainImages": ["https://cdn.dev.makitt.shop/products/main/2026/02/79514563.png"], "detailImages": [ "https://cdn.dev.makitt.shop/products/detail/2026/02/c225197f.png", "https://cdn.dev.makitt.shop/products/detail/2026/02/73a50050.png" ], "featuredPrice": 35000, "featuredCompareAtPrice": null, "seoSlug": null, "seoTitle": null, "seoDescription": null, "productVariantGroups": [ { "productVariantGroupName": "Offer", "sortOrder": 0, "groupType": "OFFER", "required": true, "uiMode": "AUTO", "options": [ { "optionName": "구매 옵션", "optionType": "text", "sortOrder": 0, "values": [ { "valueName": "단품", "colorHex": null, "sortOrder": 0, "valueSelection": "pvg_serum_of-offer:single" }, { "valueName": "1+1", "colorHex": null, "sortOrder": 1, "valueSelection": "pvg_serum_of-offer:1plus1" }, { "valueName": "풀케어 세트", "colorHex": null, "sortOrder": 2, "valueSelection": "pvg_serum_of-offer:fullcare" } ] } ] } ], "status": { "name": "ACTIVE", "description": "ACTIVE" }, "createdAt": 1771392065711, "updatedAt": 1771396886123 }
Shop API 응답에서 제외된 필드
아래 필드들은 OpenSearch 인덱스에는 존재하지만, Shop API ProductDto 응답에는 포함되지 않습니다.
valueSelection 토큰과 Resolve API 도입으로 클라이언트가 내부 키 구조를 알 필요가 없어졌기 때문입니다.
| 필드 | 원래 위치 | 제외 사유 |
|---|---|---|
productVariantGroupId | ProductVariantGroupDto | valueSelection에 내장됨 |
optionKey | OptionDto | valueSelection에 내장됨 |
valueKey | OptionValueDto | valueSelection에 내장됨 |
variantGroupId | ProductVariantGroupDto | 내부 관리용. Shop API 소비자에게 불필요 |
combinations | ProductVariantGroupDto | Resolve API가 서버에서 StoreItem 매핑을 처리 |
variantGroupCount | ProductDto | productVariantGroups.length로 계산 가능 |
groupTypes | ProductDto | productVariantGroups[].groupType에서 추출 가능 |
hasOfferVariants | ProductDto | groupTypes에서 파생 가능 |
optionNames | ProductDto | OpenSearch facet 검색용. API 응답에 불필요 |
optionValues | ProductDto | OpenSearch facet 검색용. API 응답에 불필요 |
참고:
optionNames,optionValues,groupTypes,hasOfferVariants등은 OpenSearch 인덱스에는 유지됩니다 (검색/필터링/facet에 사용).
valueSelection 필드
각 옵션 값에 서버가 생성한 selection 토큰을 포함합니다. 클라이언트는 이 토큰을 opaque string으로 취급하여 resolve/cart API에 그대로 전달합니다.
생성 규칙: {pvgId}-{optionKey}:{valueKey} — StoreItemCanonicalKeyUtil의 세그먼트 형식과 동일
구현 위치: Shop API의 ProductDto.fromDocument() 변환 시 생성. 도메인 모델이나 OpenSearch 인덱스에는 저장하지 않습니다. StoreItemCanonicalKeyUtil의 기존 상수(GROUP_KEY_DELIMITER, SEGMENT_DELIMITER)와 CombinationKeyUtil을 활용하여 생성합니다.
valueSelection = pvgId + "-" + CombinationKeyUtil.generate(optionKey, valueKey)
= pvgId + "-" + optionKey + ":" + valueKey
예시:
| PVG | Option | Value | valueSelection |
|---|---|---|---|
| pvg_lip5 | lip_color | rose | pvg_lip5-lip_color:rose |
| pvg_primer_shade | shade | pink | pvg_primer_shade-shade:pink |
| pvg_primer_offer | offer | single | pvg_primer_offer-offer:single |
| pvg_serum_of | offer | 1plus1 | pvg_serum_of-offer:1plus1 |
ProductDto 필드 목록
| 필드 | 타입 | 데이터 출처 (OS → DynamoDB) |
|---|---|---|
id | String | product_id ← product_id |
shopId | String | shop_id ← shop_id |
name | String | name ← name |
description | String | description ← description |
shortDescription | String | short_description ← short_description |
categoryName | String | category_name ← category_name |
tags | List<String> | tags ← tags |
brandName | String | brand_name ← brand_name |
supplierName | String | supplier_name ← supplier_name |
attributeValues | List<String> | attribute_values ← attributes[]를 key:value로 변환 |
thumbnailImage | String | thumbnail_image ← thumbnail_image |
mainImages | List<String> | main_images ← main_images |
detailImages | List<String> | detail_images ← detail_images |
featuredPrice | Long | featured_price ← featured_price |
featuredCompareAtPrice | Long | featured_compare_at_price ← featured_compare_at_price |
seoSlug | String | seo_slug ← seo.slug |
seoTitle | String | seo_title ← seo.title |
seoDescription | String | seo_description ← seo.description |
productVariantGroups | List<PVGDto> | 아래 참조 |
status | EnumDto | status ← status.name() |
createdAt | Long | created_at ← Instant.toEpochMilli() |
updatedAt | Long | updated_at ← Instant.toEpochMilli() |
ProductVariantGroupDto
| 필드 | 타입 | 설명 |
|---|---|---|
productVariantGroupName | String | 표시명 |
sortOrder | Integer | 정렬 순서 |
groupType | String | ATTRIBUTE 또는 OFFER |
required | Boolean | 필수 선택 여부 |
uiMode | String | AUTO / SHOW |
options | List<OptionDto> | 옵션 목록 |
OptionDto
| 필드 | 타입 | 설명 |
|---|---|---|
optionName | String | 옵션 표시명 (예: "컬러", "구매 옵션") |
optionType | String | text / color / image |
sortOrder | Integer | 정렬 순서 |
values | List<OptionValueDto> | 선택 가능한 값 목록 |
OptionValueDto
| 필드 | 타입 | 설명 |
|---|---|---|
valueName | String | 값 표시명 (예: "Rose Pink", "단품") |
colorHex | String | 컬러 표시용 (optionType=color일 때) |
sortOrder | Integer | 정렬 순서 |
valueSelection | String | resolve/cart 전달용 opaque 토큰. pvgId-optionKey:valueKey 형식 |
11.3 StoreItem Resolve API
사용자의 옵션 선택을 서버에 보내면, 서버가 매칭되는 StoreItem을 반환합니다.
클라이언트는 valueSelection 토큰을 수집하여 배열로 전송하기만 하면 됩니다. 키 조합/정렬 로직이 클라이언트에 필요 없습니다.
GET /shop/{shopId}/products/{productId}/resolve?selections={token}&selections={token}
selections query parameter로 valueSelection 토큰을 전달합니다. 순서는 상관없습니다.
GET /shop/{shopId}/products/{productId}/resolve
?selections=pvg_primer_shade-shade:pink
&selections=pvg_primer_offer-offer:single
Response:
{ "storeItemId": "57400bf2-541e-41e7-8f18-2dcfb989a627", "storeItemName": "Tone-Up Glow Primer Pink / 단품", "price": 28000, "compareAtPrice": null, "currency": "KRW", "isEnabled": true, "sellableUnitIds": ["su_eec1a3df243a"] }
Response 필드:
| 필드 | 타입 | 데이터 출처 | 설명 |
|---|---|---|---|
storeItemId | String | DynamoDB StoreItem.store_item_id | 장바구니 추가 시 사용하는 식별자 |
storeItemName | String | DynamoDB StoreItem.store_item_name | 표시명 |
price | Long | DynamoDB StoreItem.price_config.price | 실제 판매가 |
compareAtPrice | Long | DynamoDB StoreItem.price_config.compare_at_price | 정가 (할인 표시용) |
currency | String | DynamoDB StoreItem.price_config.currency | 통화 |
isEnabled | Boolean | DynamoDB StoreItem.is_enabled | 판매 가능 여부 |
sellableUnitIds | List<String> | DynamoDB StoreItem.sellable_unit_ids | 연결된 SellableUnit |
내부 처리 흐름:
Builder: 사용자가 옵션 선택 → 각 선택의 valueSelection 수집
│
▼
POST /resolve { selections: ["pvg_x-opt:val", "pvg_y-opt:val"] }
│
▼
서버: 각 selection 문자열을 StoreItemCanonicalKeyUtil.parse()로 GroupSelection 리스트 변환
│
▼
서버: StoreItemCanonicalKeyUtil.generate(groupSelections)로 정렬된 storeItemKey 생성
│
▼
서버: DynamoDB GSI3 (PRODUCT#{productId}#STOREITEMKEY, {storeItemKey})로 조회
│
▼
Response: { storeItemId, price, ... }
핵심: 클라이언트가 보낸 selections의 순서는 무관합니다.
generate()가 pvgId lexical order로 정렬하므로 항상 동일한 canonical key가 생성됩니다.
에러 응답:
| 상황 | 에러 코드 | 메시지 |
|---|---|---|
| 매칭 StoreItem 없음 | error.store_item.not_found | 해당 옵션 조합의 상품을 찾을 수 없습니다 |
| selections 비어있음 | error.store_item.invalid_selections | 옵션을 선택해주세요 |
Builder PDP 통합 흐름
1. 페이지 로드
GET /products/{id} → ProductDto
└─ featuredPrice/featuredCompareAtPrice로 기본 가격 표시
└─ productVariantGroups 렌더링 (옵션 UI)
└─ 각 value의 valueSelection은 내부에 보관
2. 옵션 선택 완료 (모든 required PVG 선택 시)
POST /products/{id}/resolve
{ "selections": [선택된 value들의 valueSelection 배열] }
└─ 응답의 price로 실제 가격 갱신
└─ isEnabled=false면 "품절" 표시
3. 장바구니 추가
POST /cart/items { storeItemId, quantity }
└─ resolve 응답의 storeItemId 그대로 전송
required=false PVG 처리:
선택적 PVG (required=false)의 경우, 상품 상세 응답에 "선택안함" 옵션 값이 자동으로 포함됩니다.
이 값의 valueSelection은 {pvgId}-none 형식이며, sortOrder=-1로 맨 앞에 위치합니다.
// 상품 상세 응답 예시 — required=false PVG { "productVariantGroupName": "사은품", "required": false, "options": [{ "optionName": "사은품 선택", "values": [ { "valueName": "선택안함", "sortOrder": -1, "valueSelection": "pvg_gift-none" }, { "valueName": "샘플키트", "sortOrder": 0, "valueSelection": "pvg_gift-gift:sample_kit" } ] }] }
클라이언트는 "선택안함"을 일반 옵션 값과 동일하게 처리하면 됩니다. 추가로, 서버는 Resolve/Cart API에서 누락된 required=false PVG에 대해 {pvgId}-none을 자동 보충합니다 (safety net).
// 사은품 미선택 시 — "선택안함"의 valueSelection 전송 { "selections": ["pvg_attr_color-color:red", "pvg_gift-none"] } // 사은품 선택 시 { "selections": ["pvg_attr_color-color:red", "pvg_gift-gift:sample_kit"] } // 선택적 PVG를 아예 생략해도 서버가 자동 보충 { "selections": ["pvg_attr_color-color:red"] } // → 서버 내부: ["pvg_attr_color-color:red", "pvg_gift-none"]
11.4 Full Product Detail API
별도의 Resolve 호출 없이, 한 번의 API 호출로 상품 정보와 모든 StoreItem(가격/재고/ID)을 함께 반환합니다. Builder PDP에서 옵션 선택 시 클라이언트 사이드에서 즉시 가격을 표시할 수 있습니다.
GET /shop/{shopId}/products/{productId}/full
기존 ProductDto의 모든 필드에 storeItems 맵을 추가한 응답을 반환합니다.
응답 예시
P11 "Ampoule Mask Combo Set" 기준 (PVG 3개 — 앰플 ATTRIBUTE, 마스크 OFFER, 사은품 OFFER required=false):
{ "id": "e91d3996-6623-4e56-b5c0-ccf631cb7154", "shopId": "8e450dc8-2434-4ab4-aa75-252bbb8ace42", "name": "Ampoule Mask Combo Set", "description": "고농축 앰플과 마스크 팩을 함께 구성한 스페셜 세트.", "shortDescription": "앰플 + 마스크 콤보 세트", "categoryName": "Sets & Kits", "tags": ["Best Seller"], "brandName": "MAKITT Beauty", "featuredPrice": 18000, "featuredCompareAtPrice": 25000, "thumbnailImage": "https://cdn.dev.makitt.shop/products/main/2026/02/abcdef01.png", "productVariantGroups": [ { "productVariantGroupName": "앰플 선택", "sortOrder": 0, "groupType": "ATTRIBUTE", "required": true, "uiMode": "SHOW", "options": [ { "optionName": "성분", "optionType": "text", "sortOrder": 0, "values": [ { "valueName": "나이아신아마이드", "sortOrder": 0, "valueSelection": "pvg_ample-ingredient:ing_niacinamide" }, { "valueName": "히알루론산", "sortOrder": 1, "valueSelection": "pvg_ample-ingredient:ing_hyaluronic" } ] }, { "optionName": "용량", "optionType": "text", "sortOrder": 1, "values": [ { "valueName": "10ml", "sortOrder": 0, "valueSelection": "pvg_ample-ml:ml_10" }, { "valueName": "30ml", "sortOrder": 1, "valueSelection": "pvg_ample-ml:ml_30" } ] } ] }, { "productVariantGroupName": "마스크 수량", "sortOrder": 1, "groupType": "OFFER", "required": true, "uiMode": "AUTO", "options": [ { "optionName": "마스크 수량", "optionType": "text", "sortOrder": 0, "values": [ { "valueName": "1매", "sortOrder": 0, "valueSelection": "pvg_mask_offer-mask_qty:mask_1ea" }, { "valueName": "2매 세트", "sortOrder": 1, "valueSelection": "pvg_mask_offer-mask_qty:mask_2ea" }, { "valueName": "4매 세트", "sortOrder": 2, "valueSelection": "pvg_mask_offer-mask_qty:mask_4ea" } ] } ] }, { "productVariantGroupName": "사은품", "sortOrder": 2, "groupType": "OFFER", "required": false, "uiMode": "AUTO", "options": [ { "optionName": "사은품 선택", "optionType": "text", "sortOrder": 0, "values": [ { "valueName": "선택안함", "sortOrder": -1, "valueSelection": "pvg_gift-none" }, { "valueName": "미니 트래블 세트 증정", "sortOrder": 0, "valueSelection": "pvg_gift-gift_option:gift_mini_set" } ] } ] } ], "status": { "name": "ACTIVE", "description": "ACTIVE" }, "storeItems": { "pvg_ample-ingredient:ing_niacinamide|ml:ml_10::pvg_gift-none::pvg_mask_offer-mask_qty:mask_1ea": { "storeItemId": "720f4dbe-8e57-49d6-be96-a17ed477cf8f", "storeItemName": "나이아신아마이드 10ml / 마스크 1매", "price": 18000, "compareAtPrice": 25000, "currency": "KRW", "isEnabled": true, "selections": [ "pvg_ample-ingredient:ing_niacinamide", "pvg_ample-ml:ml_10", "pvg_gift-none", "pvg_mask_offer-mask_qty:mask_1ea" ] }, "pvg_ample-ingredient:ing_niacinamide|ml:ml_10::pvg_gift-none::pvg_mask_offer-mask_qty:mask_2ea": { "storeItemId": "11c56e8e-5528-4eec-925a-690f6e93fde7", "storeItemName": "나이아신아마이드 10ml / 마스크 2매 세트", "price": 34000, "compareAtPrice": 50000, "currency": "KRW", "isEnabled": true, "selections": [ "pvg_ample-ingredient:ing_niacinamide", "pvg_ample-ml:ml_10", "pvg_gift-none", "pvg_mask_offer-mask_qty:mask_2ea" ] }, "pvg_ample-ingredient:ing_niacinamide|ml:ml_10::pvg_gift-gift_option:gift_mini_set::pvg_mask_offer-mask_qty:mask_1ea": { "storeItemId": "55bc822a-31f1-407d-910d-67f355ed39f5", "storeItemName": "나이아신아마이드 10ml / 마스크 1매 / 미니 트래블 세트 증정", "price": 21000, "compareAtPrice": 28000, "currency": "KRW", "isEnabled": true, "selections": [ "pvg_ample-ingredient:ing_niacinamide", "pvg_ample-ml:ml_10", "pvg_gift-gift_option:gift_mini_set", "pvg_mask_offer-mask_qty:mask_1ea" ] }, "pvg_ample-ingredient:ing_niacinamide|ml:ml_30::pvg_gift-gift_option:gift_mini_set::pvg_mask_offer-mask_qty:mask_2ea": { "storeItemId": "494e9b55-629c-4ee2-a32e-87e3fb08e243", "storeItemName": "나이아신아마이드 30ml / 마스크 2매 세트 / 미니 트래블 세트 증정", "price": 42000, "compareAtPrice": 58000, "currency": "KRW", "isEnabled": true, "selections": [ "pvg_ample-ingredient:ing_niacinamide", "pvg_ample-ml:ml_30", "pvg_gift-gift_option:gift_mini_set", "pvg_mask_offer-mask_qty:mask_2ea" ] } } }
참고: 위 예시는 30개 StoreItem 중 4개만 발췌한 것입니다. 실제 응답에는 모든 유효 조합이 포함됩니다.
storeItems 맵 구조
| 항목 | 설명 |
|---|---|
| 맵 키 | storeItemKey (canonical key) — DB에 저장된 그대로 사용 |
| 맵 값 | StoreItemInfo — 해당 옵션 조합의 가격/재고/ID |
StoreItemInfo 필드
| 필드 | 타입 | 설명 |
|---|---|---|
storeItemId | String | StoreItem 고유 ID. 장바구니 추가 시 사용 |
storeItemName | String | 옵션 조합 표시명 (예: "나이아신아마이드 10ml / 마스크 1매") |
price | Long | 판매가 (최소 화폐 단위) |
compareAtPrice | Long | 정가 (할인 표시용, nullable) |
currency | String | 통화 코드 (예: "KRW") |
isEnabled | Boolean | 판매 가능 여부. false면 품절 표시 |
selections | List<String> | 이 StoreItem을 구성하는 개별 valueSelection 토큰 목록 |
selections 필드 생성 규칙
서버가 storeItemKey를 개별 valueSelection 토큰으로 확장합니다:
storeItemKey (canonical key):
pvg_ample-ingredient:ing_niacinamide|ml:ml_10::pvg_gift-none::pvg_mask_offer-mask_qty:mask_1ea
1. "::" 로 세그먼트 분리:
- pvg_ample-ingredient:ing_niacinamide|ml:ml_10
- pvg_gift-none
- pvg_mask_offer-mask_qty:mask_1ea
2. 각 세그먼트에서 pvgId 추출 (첫 번째 "-" 앞):
- pvg_ample → ingredient:ing_niacinamide|ml:ml_10
3. combinationKey를 "|" 로 분리하여 개별 토큰 생성:
- pvg_ample-ingredient:ing_niacinamide
- pvg_ample-ml:ml_10
4. "none" 세그먼트는 그대로 유지:
- pvg_gift-none
결과 selections:
["pvg_ample-ingredient:ing_niacinamide", "pvg_ample-ml:ml_10", "pvg_gift-none", "pvg_mask_offer-mask_qty:mask_1ea"]
Builder PDP 통합 흐름
1. 페이지 로드
GET /products/{id}/full → ProductDto + storeItems
└─ featuredPrice/featuredCompareAtPrice로 기본 가격 표시
└─ productVariantGroups 렌더링 (옵션 UI)
└─ storeItems 맵을 메모리에 보관
2. 옵션 선택 시 (실시간, API 호출 없음)
사용자가 옵션 선택 → 각 선택의 valueSelection 토큰 수집
└─ storeItems를 순회하며 selections가 사용자 선택과 일치하는 항목 찾기
(Set equality: 사용자 선택 토큰 Set == storeItem.selections Set)
└─ 매칭 항목의 price로 가격 갱신
└─ isEnabled=false면 "품절" 표시
3. 장바구니 추가
POST /cart/items { storeItemId, quantity }
└─ 매칭된 storeItem의 storeItemId 사용
기존 방식 대비 차이: 기존
GET /products/{id}+POST /resolve2-call 흐름 대신, 1-call로 모든 정보를 받아 클라이언트 사이드에서 즉시 매칭합니다. Resolve API는 개별 조합 확인이 필요한 경우 여전히 사용 가능합니다.
기존 API와의 관계
| API | 용도 | StoreItem 포함 | 사용 시나리오 |
|---|---|---|---|
GET /products/{id} | 가벼운 상품 정보 | X | 검색 결과 클릭, 상품 카드 등 |
GET /products/{id}/full | PDP 풀 렌더링 | O (전체) | 상품 상세 페이지 (옵션+가격 한번에) |
POST /products/{id}/resolve | 개별 옵션 조합 확인 | O (1건) | 동적 옵션 조합 확인, 외부 연동 |
서버 내부 처리
1. ProductSearchService.getProductById(productId) → ProductDocument
2. StoreItemService.findByProductId(productId) → List<StoreItem> (GSI1 조회)
3. ProductDto.fromDocument(document) → 기존 상품 정보
4. 각 StoreItem에 대해:
├─ storeItemKey → selections 토큰 확장
└─ StoreItemInfo 생성 (price, isEnabled, ...)
5. storeItems 맵 구성 (키: storeItemKey)
6. 응답 반환
11.5 에러 응답
모든 비즈니스 에러는 HTTP 400으로 반환됩니다.
{ "code": "error.product.not_found", "defaultMessage": "Product not found", "message": "상품을 찾을 수 없습니다", "timestamp": "2026-02-18T08:00:00Z", "path": "/shop/8e450dc8-.../products/invalid-id" }
| 상황 | 에러 코드 | 메시지 |
|---|---|---|
| 상품 미존재 | error.product.not_found | 상품을 찾을 수 없습니다 |
| shopId 불일치 | error.product.not_found | 해당 샵에서 상품을 찾을 수 없습니다 |
| slug 미존재 | error.product.not_found | 해당 슬러그의 상품을 찾을 수 없습니다 |
| StoreItem 미매칭 | error.store_item.not_found | 해당 옵션 조합의 상품을 찾을 수 없습니다 |
GSI 인덱스 사용 현황
| GSI | 인덱스명 | 사용 엔티티 |
|---|---|---|
| GSI1 | EntityLookupIndex | Product, StoreItem, SellableUnit, SKU, Category, Tag, ShopAttribute, VariantGroup |
| GSI2 | UniqueLookupIndex | StoreItem |
| GSI3 | SecondaryIdIndex | StoreItem (storeItemKey 조회), SKU (argoSkuId 조회) |
| GSI4 | FilterIndex | (Product 도메인 미사용) |
| GSI5 | TimeSeriesIndex | (Product 도메인 미사용) |
OpenSearch Sync 현황
| 인덱스 | 자동 Sync | 리스너 |
|---|---|---|
products | O | ProductSearchSyncListener |
store-items | O | StoreItemSearchSyncListener (Product brandName 비정규화) |
variant-groups | O | VariantGroupSearchSyncListener |
sellable-units | X | 수동/벌크 인덱싱 |
skus | X | Kafka 소비 시 별도 처리 |