MAKITTDocs

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"
ProductVariantGroupProduct에 내장된 옵션 그룹컬러 (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

키 구조:

패턴예시
PKPRODUCT#{productId}PRODUCT#c5278d25-409c-4db6-893c-54df8d16d509
SKMETADATAMETADATA
GSI1 PKSHOP#{shopId}SHOP#8e450dc8-2434-4ab4-aa75-252bbb8ace42
GSI1 SKPRODUCT#{timestamp}#{productId}PRODUCT#2026-02-18T05:21:05Z#c5278d25-...

필드:

필드DynamoDB Attribute타입필수설명
productIdproduct_idStringOUUID
shopIdshop_idStringO소속 Shop ID
statusstatusProductStatusO상품 상태
namenameStringO상품명
descriptiondescriptionStringX상세 설명
shortDescriptionshort_descriptionStringX한줄 설명
categoryNamecategory_nameStringX카테고리명 (비정규화)
tagstagsList<String>X태그 목록
brandNamebrand_nameStringX브랜드명
supplierNamesupplier_nameStringX공급업체명
featuredPricefeatured_priceLongX대표 가격 (원)
featuredCompareAtPricefeatured_compare_at_priceLongX할인 전 가격
attributesattributesList<ProductAttribute>XShop 속성 값
seoseoProductSeoXSEO 설정
thumbnailImagethumbnail_imageStringX썸네일 (mainImages[0])
mainImagesmain_imagesList<String>X메인 이미지 URL
detailImagesdetail_imagesList<String>X상세 이미지 URL
imageGroupsimage_groupsList<ImageGroup>X이미지 그룹
productVariantGroupsproduct_variant_groupsList<ProductVariantGroup>X옵션 그룹 (내장)
createdAtcreated_atInstantO생성일시
updatedAtupdated_atInstantO수정일시

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 변환 규칙:

DynamoDBOpenSearch변환
attributes (List<ProductAttribute>)attribute_values (keyword[])key + ":" + value 형태로 변환
seo.slugseo_slug추출
seo.titleseo_title추출
productVariantGroupsproduct_variant_groups (nested)깊은 중첩 변환
— (파생)variant_group_countPVG 개수
— (파생)group_types (keyword[])PVG의 groupType 목록
— (파생)has_offer_variantsOFFER 타입 존재 여부
— (파생)option_names (keyword[])모든 option.optionName
— (파생)option_values (keyword[])모든 option.values[].valueName
— (파생)search_textname+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타입설명
productVariantGroupIdproduct_variant_group_idStringpvg_{식별자} 형식
variantGroupIdvariant_group_idString템플릿 VariantGroup 참조 (선택)
productVariantGroupNameproduct_variant_group_nameString표시명
sortOrdersort_orderInteger정렬 순서
groupTypegroup_typeStringATTRIBUTE 또는 OFFER
requiredrequiredBoolean필수 선택 여부
uiModeui_modeStringAUTO (조합 1개면 숨김) / SHOW (항상 표시)
optionsoptionsList<Option>옵션 정의 목록
combinationscombinationsList<Combination>조합 → SU 매핑 목록

groupType: ATTRIBUTE vs OFFER

구분ATTRIBUTEOFFER
용도상품의 물리적 속성 (색상, 사이즈)판매 구성 (단품, 세트, 1+1)
예시컬러: Rose/Coral/Berry구매옵션: 단품/1+1/풀케어
StoreItem 관계각 조합이 별도 StoreItem각 구성이 별도 StoreItem
조합 방식다수 ATTRIBUTE PVG → 데카르트 곱각 OFFER PVG 독립 선택
SellableUnit각 SU가 단일 SKU (보통)각 SU가 번들 BOM 가능

Option & OptionValue

Option:

필드타입예시
optionKeyString"lip_color", "size", "offer"
optionNameString"컬러", "사이즈", "구매 옵션"
optionTypeString"text", "color", "size", "offer"
sortOrderInteger0
valuesList<OptionValue>아래 참조

OptionValue:

필드타입예시
valueKeyString"rose", "S", "single"
valueNameString"Rose Pink", "Small", "단품"
colorHexString"#E8909C" (color 타입만)
sortOrderInteger0
sellableUnitIdStringOFFER 타입의 직접 SU 연결 (선택)

Combination

옵션 선택 조합과 SellableUnit 간의 매핑.

소스: product/core/entity/Combination.java

필드DynamoDB Attribute타입설명
combinationKeycombination_keyString정렬된 조합 키 (아래 규칙 참조)
combinationNamecombination_nameString표시명 ("Rose Pink", "빨강/S")
selectionsselectionsMap<String,String>{ "lip_color": "rose" }
sellableUnitIdsellable_unit_idString연결된 SellableUnit ID
isEnabledis_enabledBoolean활성화 여부 (PDP에서 판매 가능 판단)

combinationKey 생성 규칙 (CombinationKeyUtil):

optionKey:valueKey|optionKey:valueKey|...
  • 구분자: : (key-value 사이), | (쌍 사이)
  • optionKey 기준 사전순(lexical) 정렬

예시:

selectionscombinationKey
{ "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

키 구조:

패턴예시
PKSTOREITEM#{storeItemId}STOREITEM#57400bf2-541e-41e7-...
SKMETADATAMETADATA
GSI1 PKPRODUCT#{productId}PRODUCT#c5278d25-...
GSI1 SKSTOREITEM#{sortOrder:8pad}#{storeItemId}STOREITEM#00000000#57400bf2-...
GSI2 PKSHOP#{shopId}SHOP#8e450dc8-...
GSI2 SKSTOREITEM#{createdAt}#{storeItemId}STOREITEM#2026-02-18T...#57400bf2-...
GSI3 PKPRODUCT#{productId}#STOREITEMKEYPRODUCT#c5278d25-...#STOREITEMKEY
GSI3 SK{storeItemKey} (canonical key 그대로)pvg_serum_of-offer:single

GSI3은 canonical key로 StoreItem을 직접 조회할 때 사용됩니다. 구매자가 옵션을 선택하면 프론트엔드에서 storeItemKey를 구성하여 매칭합니다.

필드:

필드DynamoDB Attribute타입필수설명
storeItemIdstore_item_idStringOUUID
productIdproduct_idStringO소속 Product ID
shopIdshop_idStringO소속 Shop ID
storeItemKeystore_item_keyStringOCanonical Key (아래 상세)
productNameproduct_nameStringX비정규화된 상품명
storeItemNamestore_item_nameStringO표시명 ("Vitamin C Serum 단품")
priceConfigprice_configPriceConfigO가격 설정 (내장)
sellableUnitIdssellable_unit_idsList<String>X연결된 SellableUnit ID
isEnabledis_enabledBooleanO판매 가능 여부 (PDP 판단 기준)
sortOrdersort_orderIntegerX정렬 순서
argoStoreItemIdargo_store_item_idLongXArgo CMS 연동 ID
syncStatussync_statusStoreItemSyncStatusX동기화 상태
createdAtcreated_atInstantO생성일시
updatedAtupdated_atInstantO수정일시

PriceConfig (내장):

필드타입설명
priceLong판매가 (필수, >= 0)
compareAtPriceLong정가 (할인 표시용)
costLong원가
currencyString통화 (기본 "KRW")

OpenSearch Document

인덱스: store-items 매핑 파일: opensearch/mappings/store-items-index.json Sync: StoreItemSearchSyncListener — Product에서 brandName 비정규화

DynamoDB → OpenSearch 변환:

DynamoDBOpenSearch변환
price_config.priceprice추출
price_config.compare_at_pricecompare_at_price추출
price_config.costcost추출
price_config.currencycurrency추출
Product.brandName (fetch)brand_nameProduct 엔티티에서 비정규화

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

키 구조:

패턴예시
PKSELLABLEUNIT#{sellableUnitId}SELLABLEUNIT#su_7557e47428c5
SKMETADATAMETADATA
GSI1 PKSHOP#{shopId}SHOP#8e450dc8-...
GSI1 SKSELLABLEUNIT#{timestamp}#{sellableUnitId}SELLABLEUNIT#2026-02-18T...#su_755...

필드:

필드DynamoDB Attribute타입필수설명
sellableUnitIdsellable_unit_idStringOsu_{12자리hex} 형식
shopIdshop_idStringO소속 Shop
organizationIdorganization_idStringO소속 Organization
canonicalBomKeycanonical_bom_keyStringOBOM 정규화 키
namenameStringO표시명
descriptiondescriptionStringX설명
typetypeSellableUnitTypeO유형
enabledenabledBooleanO활성화 여부
bombomList<BomItem>O구성품 목록 (1개 이상 필수)
createdAtcreated_atInstantO생성일시
updatedAtupdated_atInstantO수정일시

SellableUnitType enum:

설명예시
SINGLE단일 SKU 1개세럼 1개
BUNDLE여러 SKU 묶음세럼+토너+크림 세트
GIFT_INCLUDED사은품 포함본품 + 샘플 키트
ADDON추가 옵션기본 + 리필 파우치
UPGRADE업그레이드기본 → 프리미엄
PROMOTIONAL프로모션1+1
CONDITIONAL조건부 구성3만원 이상 시 증정품 포함

BomItem (내장)

필드DynamoDB Attribute타입설명
skuIdsku_idStringSKU ID (Argo referenceSkuId UUID)
qtyqtyInteger수량 (>= 1)
roleroleBomItemRoleREQUIRED (필수) / GIFT (사은품)

canonicalBomKey 규칙

BOM 구성을 고유하게 식별하는 정규화 키:

{skuId}:{qty}|{skuId}:{qty}|...
  • skuId 기준 사전순 정렬
  • 동일 BOM 구성은 항상 동일한 canonicalBomKey 생성

예시:

BOM 구성canonicalBomKey
SKU-A × 1SKU-A:1
SKU-A × 1, SKU-B × 2SKU-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_idsBOM의 모든 skuId 목록
sku_count고유 SKU 수
total_qtyBOM 전체 수량 합

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

키 구조:

패턴예시
PKSKU#{skuId}SKU#a1b2c3d4-...
SKMETADATAMETADATA
GSI1 PKORGANIZATION#{organizationId}ORGANIZATION#a68a160d-...
GSI1 SKSKU#{timestamp}#{skuId}SKU#2026-01-15T...#a1b2c3d4-...
GSI3 PKARGO_SKU#{argoSkuId}ARGO_SKU#12345
GSI3 SKMETADATAMETADATA

skuId는 Argo의 referenceSkuId (UUID). argoSkuId는 Argo 내부 시퀀스 ID (Long).

주요 필드:

필드DynamoDB Attribute타입설명
skuIdsku_idString= Argo referenceSkuId (UUID)
argoSkuIdargo_sku_idLongArgo 시퀀스 ID
organizationIdorganization_idString소속 Organization
skuCodesku_codeStringArgo customerSkuId
skuNamesku_nameStringSKU명
barcodebarcodeString바코드
brandbrandString브랜드
pricepriceLong가격 (원)
weightweightDouble무게 (kg)
dimensionsdimensionsSKUDimensions길이/너비/높이 (cm)
storageTemperaturestorage_temperatureStorageTemperatureROOM / REFRIGERATED / FROZEN
stockStatusstock_statusStockStatusIN_STOCK / LOW_STOCK / OUT_OF_STOCK / BACKORDER
activeactiveBooleanArgo 활성 상태
enabledenabledBooleanArgo 사용 가능 상태

동기화: 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

키 구조:

패턴예시
PKCATEGORY#{categoryId}CATEGORY#uuid
SKMETADATAMETADATA
GSI1 PKSHOP#{shopId}SHOP#8e450dc8-...
GSI1 SKCATEGORY#{level:3pad}#{position:5pad}#{categoryId}CATEGORY#000#00001#uuid

GSI1 SK에 level과 position을 패딩하여 정렬 가능하게 합니다.

필드:

필드DynamoDB Attribute타입필수설명
categoryIdcategory_idStringOUUID
shopIdshop_idStringO소속 Shop
categoryNamecategory_nameStringO카테고리명
descriptiondescriptionStringX설명
parentCategoryIdparent_category_idStringX부모 카테고리 (null = 루트)
pathpathStringX전체 경로 ("/electronics/laptops")
levellevelIntegerO깊이 (0 = 루트)
positionpositionIntegerO같은 부모 내 순서
slugslugStringXURL 슬러그
thumbnailUrlthumbnail_urlStringX썸네일
isActiveis_activeBooleanO활성화

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

키 구조:

패턴예시
PKTAG#{tagId}TAG#uuid
SKMETADATAMETADATA
GSI1 PKSHOP#{shopId}SHOP#8e450dc8-...
GSI1 SKTAG#{position:5pad}#{tagId}TAG#00001#uuid

필드:

필드DynamoDB Attribute타입필수설명
tagIdtag_idStringOUUID
shopIdshop_idStringO소속 Shop
tagNametag_nameStringO태그명 ("Best Seller", "Vegan")
slugslugStringXURL 슬러그
descriptiondescriptionStringX설명
colorcolorStringX색상 Hex ("#FF5733")
positionpositionIntegerO정렬 순서
isActiveis_activeBooleanO활성화

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

키 구조:

패턴예시
PKSHOPATTRIBUTE#{attributeId}SHOPATTRIBUTE#uuid
SKMETADATAMETADATA
GSI1 PKSHOP#{shopId}SHOP#8e450dc8-...
GSI1 SKSHOPATTRIBUTE#{sortOrder:5pad}#{attributeId}SHOPATTRIBUTE#00001#uuid

필드:

필드DynamoDB Attribute타입필수설명
attributeIdattribute_idStringOUUID
shopIdshop_idStringO소속 Shop
keykeyStringO불변 키 ("skin_type", "texture")
namenameStringO표시명 ("피부 타입", "텍스처")
descriptiondescriptionStringX설명
typetypeAttributeTypeO속성 타입
valuesvaluesList<AttributeValueDef>X선택지 (SELECT 타입)
showInFiltershow_in_filterBooleanX필터 사이드바 표시
showInProductshow_in_productBooleanX상품 상세 표시
sortOrdersort_orderIntegerX정렬 순서
isRequiredis_requiredBooleanX상품 등록 시 필수

AttributeType enum:

설명예시
SINGLE_SELECT단일 선택피부 타입: [지성/건성/복합성]
MULTI_SELECT다중 선택피부 고민: [여드름, 주름, 색소침착]
TEXT자유 텍스트용량: "30ml"
NUMBER숫자용량(ml): 30
BOOLEAN참/거짓비건 여부: true

AttributeValueDef (내장 — SELECT 타입의 선택지):

필드타입설명
valueIdStringUUID
valueString값 ("sensitive")
labelString표시명 ("민감성")
colorHexString색상 (선택)
sortOrderInteger정렬
isActiveBoolean활성화

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

키 구조:

패턴예시
PKVARIANTGROUP#{variantGroupId}VARIANTGROUP#vg_a1b2c3d4e5f6
SKMETADATAMETADATA
GSI1 PKSHOP#{shopId}SHOP#8e450dc8-...
GSI1 SKVARIANTGROUP#{variantGroupId}VARIANTGROUP#vg_a1b2c3d4e5f6

필드:

필드DynamoDB Attribute타입필수설명
variantGroupIdvariant_group_idStringOvg_{12자리hex} 형식
shopIdshop_idStringO소속 Shop
namenameStringO템플릿명
descriptiondescriptionStringX설명
typetypeVariantGroupTypeOATTRIBUTE / OFFER
requiredrequiredBooleanO필수 선택 여부
uiModeui_modeVariantGroupUIModeOAUTO / SHOW
attributeJsonattributeStringXATTRIBUTE 설정 JSON (type=ATTRIBUTE)
offerJsonofferStringXOFFER 설정 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=singleoffer:single
lip_color=roselip_color:rose
color=red, size=Scolor:red|size:S (color < size)
size=M, color=bluecolor: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=falsenone 슬롯:

선택적 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. 최소 1개: StoreItem 목록은 비어있을 수 없음
  2. 키 유일성: 동일 Product 내 중복 storeItemKey 불가
  3. 형식 검증: StoreItemCanonicalKeyUtil.parse(key)가 1개 이상의 GroupSelection 반환
  4. PVG 참조: storeItemKey의 모든 pvgId가 Product의 PVG 목록에 존재해야 함
  5. 이름: 비어있지 않음, 최대 200자
  6. 가격: 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:

파라미터타입기본값설명
pageinteger1페이지 번호 (1-indexed)
sizeinteger20페이지 크기 (최대 100)
sortBystringnewest정렬: price_asc, price_desc, newest, name

GET /shop/{shopId}/products/search

고급 검색/필터링 + Facet 지원.

Query Parameters:

파라미터타입기본값설명
qstring-전문 검색 쿼리
categorieslist-카테고리명 필터 (복수 가능)
brandslist-브랜드명 필터
vendorslist-공급업체명 필터
tagslist-태그 필터
attributeslist-속성 필터 (key:value 형식, 예: skinType:dry)
minPricelong-최소 가격
maxPricelong-최대 가격
inStockboolean-재고 여부 필터
statusstring-상품 상태 필터
sortBystringnewest정렬: relevance, price_asc, price_desc, newest, name
pageinteger1페이지 번호 (1-indexed)
sizeinteger20페이지 크기 (최대 100)
includeFacetsbooleanfalseFacet 집계 포함 여부

정렬 → OpenSearch 필드 매핑:

sortByOpenSearch 필드순서
relevance_scoredesc
price_ascfeatured_priceasc
price_descfeatured_pricedesc
newestcreated_atdesc
namename.keywordasc

목록 응답: ProductSearchResponse

{ "items": [ ProductDto, ... ], "page": 1, "size": 20, "totalItems": 150, "totalPages": 8, "hasNext": true, "hasPrevious": false, "facets": { ... }, "priceStats": { ... } }

Facet 필드 (includeFacets=true 일 때만):

응답 필드데이터 출처
facets.categoriesOpenSearch category_name terms 집계
facets.brandsOpenSearch brand_name terms 집계
facets.suppliersOpenSearch supplier_name terms 집계
facets.tagsOpenSearch tags terms 집계
facets.attributeValuesOpenSearch attribute_values terms 집계
facets.statusOpenSearch status terms 집계
priceStatsOpenSearch 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 도입으로 클라이언트가 내부 키 구조를 알 필요가 없어졌기 때문입니다.

필드원래 위치제외 사유
productVariantGroupIdProductVariantGroupDtovalueSelection에 내장됨
optionKeyOptionDtovalueSelection에 내장됨
valueKeyOptionValueDtovalueSelection에 내장됨
variantGroupIdProductVariantGroupDto내부 관리용. Shop API 소비자에게 불필요
combinationsProductVariantGroupDtoResolve API가 서버에서 StoreItem 매핑을 처리
variantGroupCountProductDtoproductVariantGroups.length로 계산 가능
groupTypesProductDtoproductVariantGroups[].groupType에서 추출 가능
hasOfferVariantsProductDtogroupTypes에서 파생 가능
optionNamesProductDtoOpenSearch facet 검색용. API 응답에 불필요
optionValuesProductDtoOpenSearch 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

예시:

PVGOptionValuevalueSelection
pvg_lip5lip_colorrosepvg_lip5-lip_color:rose
pvg_primer_shadeshadepinkpvg_primer_shade-shade:pink
pvg_primer_offeroffersinglepvg_primer_offer-offer:single
pvg_serum_ofoffer1plus1pvg_serum_of-offer:1plus1

ProductDto 필드 목록

필드타입데이터 출처 (OS → DynamoDB)
idStringproduct_idproduct_id
shopIdStringshop_idshop_id
nameStringnamename
descriptionStringdescriptiondescription
shortDescriptionStringshort_descriptionshort_description
categoryNameStringcategory_namecategory_name
tagsList<String>tagstags
brandNameStringbrand_namebrand_name
supplierNameStringsupplier_namesupplier_name
attributeValuesList<String>attribute_valuesattributes[]key:value로 변환
thumbnailImageStringthumbnail_imagethumbnail_image
mainImagesList<String>main_imagesmain_images
detailImagesList<String>detail_imagesdetail_images
featuredPriceLongfeatured_pricefeatured_price
featuredCompareAtPriceLongfeatured_compare_at_pricefeatured_compare_at_price
seoSlugStringseo_slugseo.slug
seoTitleStringseo_titleseo.title
seoDescriptionStringseo_descriptionseo.description
productVariantGroupsList<PVGDto>아래 참조
statusEnumDtostatusstatus.name()
createdAtLongcreated_atInstant.toEpochMilli()
updatedAtLongupdated_atInstant.toEpochMilli()

ProductVariantGroupDto

필드타입설명
productVariantGroupNameString표시명
sortOrderInteger정렬 순서
groupTypeStringATTRIBUTE 또는 OFFER
requiredBoolean필수 선택 여부
uiModeStringAUTO / SHOW
optionsList<OptionDto>옵션 목록

OptionDto

필드타입설명
optionNameString옵션 표시명 (예: "컬러", "구매 옵션")
optionTypeStringtext / color / image
sortOrderInteger정렬 순서
valuesList<OptionValueDto>선택 가능한 값 목록

OptionValueDto

필드타입설명
valueNameString값 표시명 (예: "Rose Pink", "단품")
colorHexString컬러 표시용 (optionType=color일 때)
sortOrderInteger정렬 순서
valueSelectionStringresolve/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 필드:

필드타입데이터 출처설명
storeItemIdStringDynamoDB StoreItem.store_item_id장바구니 추가 시 사용하는 식별자
storeItemNameStringDynamoDB StoreItem.store_item_name표시명
priceLongDynamoDB StoreItem.price_config.price실제 판매가
compareAtPriceLongDynamoDB StoreItem.price_config.compare_at_price정가 (할인 표시용)
currencyStringDynamoDB StoreItem.price_config.currency통화
isEnabledBooleanDynamoDB StoreItem.is_enabled판매 가능 여부
sellableUnitIdsList<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 필드

필드타입설명
storeItemIdStringStoreItem 고유 ID. 장바구니 추가 시 사용
storeItemNameString옵션 조합 표시명 (예: "나이아신아마이드 10ml / 마스크 1매")
priceLong판매가 (최소 화폐 단위)
compareAtPriceLong정가 (할인 표시용, nullable)
currencyString통화 코드 (예: "KRW")
isEnabledBoolean판매 가능 여부. false면 품절 표시
selectionsList<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 /resolve 2-call 흐름 대신, 1-call로 모든 정보를 받아 클라이언트 사이드에서 즉시 매칭합니다. Resolve API는 개별 조합 확인이 필요한 경우 여전히 사용 가능합니다.

기존 API와의 관계

API용도StoreItem 포함사용 시나리오
GET /products/{id}가벼운 상품 정보X검색 결과 클릭, 상품 카드 등
GET /products/{id}/fullPDP 풀 렌더링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인덱스명사용 엔티티
GSI1EntityLookupIndexProduct, StoreItem, SellableUnit, SKU, Category, Tag, ShopAttribute, VariantGroup
GSI2UniqueLookupIndexStoreItem
GSI3SecondaryIdIndexStoreItem (storeItemKey 조회), SKU (argoSkuId 조회)
GSI4FilterIndex(Product 도메인 미사용)
GSI5TimeSeriesIndex(Product 도메인 미사용)

OpenSearch Sync 현황

인덱스자동 Sync리스너
productsOProductSearchSyncListener
store-itemsOStoreItemSearchSyncListener (Product brandName 비정규화)
variant-groupsOVariantGroupSearchSyncListener
sellable-unitsX수동/벌크 인덱싱
skusXKafka 소비 시 별도 처리