HcsAsset
docs/domain/hcsAsset.md
HcsAsset
개요
HcsAsset(Headless Component System Asset)은 Builder에서 사전 정의된 컴포넌트 구조를 재사용 가능한 에셋으로 관리하는 도메인. 사용자는 직접 만든 컴포넌트 트리를 HcsAsset으로 저장하고, 다른 페이지나 프로젝트에서 쉽게 재사용할 수 있다.
서버 패키지: com.makitt.core.domain.hcsasset
핵심 기능:
- Builder에서 만든 커스텀 컴포넌트 구조 저장 및 재사용
- 비즈니스 의미를 담는 shopComponentType 라벨 시스템
- OpenSearch 기반 전문 검색 (name, description, tags)
- 필터링 (componentType, shopComponentType, createdBy, tags)
- 사용 횟수 추적 (usageCount)
- 생성자(createdBy) 기반 접근 제어
DynamoDB Entity
HcsAsset
Single Table Design 기반의 HcsAsset 엔티티. 사용자(User)별로 관리되며, OpenSearch와 이벤트 기반 동기화된다.
키 구조:
| 키 | 패턴 | 예시 |
|---|---|---|
| PK | HCSASSET#{hcsAssetId} | HCSASSET#550e8400-e29b-41d4-a716-446655440000 |
| SK | METADATA | METADATA |
필드:
| 필드 | DynamoDB Attribute | 타입 | 필수 | 설명 |
|---|---|---|---|---|
| pk | PK | String | O | Partition Key |
| sk | SK | String | O | Sort Key (METADATA 고정) |
| entityType | entity_type | String | O | "HCS_ASSET" 고정 |
| hcsAssetId | hcs_asset_id | String | O | HcsAsset 고유 ID (UUID) |
| name | name | String | O | 에셋 이름 |
| description | description | String | X | 에셋 설명 |
| treeNodeJson | tree_node_json | String | O | 컴포넌트 트리 JSON |
| thumbnailUrl | thumbnail_url | String | X | 썸네일 이미지 URL |
| componentType | component_type | String | O | 컴포넌트 타입 (예: "SECTION", "COMPONENT") |
| shopComponentType | shop_component_type | String | X | 비즈니스 의미 라벨 (예: ProductCard, Header) |
| createdBy | created_by | String | O | 생성자 User ID |
| usageCount | usage_count | Long | O | 사용 횟수 (기본값 0) |
| tags | tags | List<String> | X | 태그 목록 (검색/필터용) |
| gsi1Pk | gsi1_pk | String | O | GSI1 Partition Key |
| gsi1Sk | gsi1_sk | String | O | GSI1 Sort Key |
| createdAt | created_at | Instant | O | 생성 시각 |
| updatedAt | updated_at | Instant | O | 수정 시각 |
Factory Methods:
HcsAsset.create(name, description, treeNodeJson, thumbnailUrl, componentType, shopComponentType, createdBy, tags)— 새로운 HcsAsset 생성 (UUID 및 타임스탬프 자동 생성)
Business Methods:
incrementUsageCount()— 사용 횟수 증가 + updatedAt 갱신update(name, description, treeNodeJson, thumbnailUrl, componentType, shopComponentType, tags)— 에셋 메타데이터 업데이트 (nullable 필드)
GSI 키 구조
| GSI | 용도 | PK 패턴 | SK 패턴 |
|---|---|---|---|
| GSI1 (EntityLookupIndex) | User별 생성 시간순 정렬 조회 | USER#{userId} | HCSASSET#{createdAtEpochMs}#{hcsAssetId} |
GSI1 설명:
- 사용자가 만든 모든 HcsAsset을 생성 시간순으로 조회
- KEYS_ONLY Projection → 2단계 조회 (GSI 쿼리 → 메인 테이블 Fetch)
- Reverse Scan으로 최신순 정렬
Key Builder (com.makitt.core.common.dynamodb.key.HcsAssetKey):
HcsAssetKey.pk(hcsAssetId) → "HCSASSET#{hcsAssetId}" HcsAssetKey.sk() → "METADATA" HcsAssetKey.gsi1Pk(userId) → "USER#{userId}" HcsAssetKey.gsi1Sk(createdAtEpochMs, hcsAssetId) → "HCSASSET#{createdAtEpochMs}#{hcsAssetId}" HcsAssetKey.gsi1SkPrefix() → "HCSASSET#" HcsAssetKey.extractHcsAssetId(pk) → "550e8400..." (from "HCSASSET#550e8400...")
OpenSearch Document
HcsAssetDocument
HcsAsset의 검색 및 필터링을 위한 OpenSearch 인덱스.
Index 이름: hcs-assets
필드 매핑:
| 필드 | JSON Property | 타입 | 설명 |
|---|---|---|---|
| hcsAssetId | hcs_asset_id | Keyword | HcsAsset ID (Document ID) |
| name | name | Text | 에셋 이름 (전문 검색, 가중치 2x) |
| description | description | Text | 에셋 설명 (전문 검색) |
| treeNodeJson | tree_node_json | String | 컴포넌트 트리 JSON (저장만, 인덱싱 안 함) |
| thumbnailUrl | thumbnail_url | Keyword | 썸네일 URL |
| componentType | component_type | Keyword | 컴포넌트 타입 (필터용) |
| shopComponentType | shop_component_type | Keyword | 비즈니스 라벨 (필터용) |
| createdBy | created_by | Keyword | 생성자 User ID (필터용) |
| usageCount | usage_count | Long | 사용 횟수 (정렬용) |
| tags | tags | Keyword[] | 태그 목록 (필터용) |
| createdAt | created_at | Long | 생성 시각 (epoch ms, 정렬용) |
| updatedAt | updated_at | Long | 수정 시각 (epoch ms) |
Factory Method:
HcsAssetDocument.fromEntity(hcsAsset)— DynamoDB Entity → OpenSearch Document 변환 (Instant → epoch ms)
검색 전략:
name,description: 전문 검색 (name에 2x 가중치)tags: Keyword로 필터 (AND 조건 / OR 조건 양쪽 사용)componentType,shopComponentType,createdBy: Keyword 필터- 기본 정렬:
created_at DESC(최신순) - 태그 검색:
usage_count DESC(인기순) - 페이지네이션:
from = page * size
Value Objects
CreateHcsAssetRequestVo
| 필드 | 타입 | 설명 |
|---|---|---|
| name | String | 에셋 이름 |
| description | String | 설명 |
| treeNodeJson | String | 트리 JSON |
| thumbnailUrl | String | 썸네일 URL |
| componentType | String | 컴포넌트 타입 |
| shopComponentType | String | 비즈니스 라벨 |
| tags | List<String> | 태그 |
| createdBy | String | 생성자 userId |
UpdateHcsAssetRequestVo
| 필드 | 타입 | 설명 |
|---|---|---|
| name | String | (nullable) |
| description | String | (nullable) |
| treeNodeJson | String | (nullable) |
| thumbnailUrl | String | (nullable) |
| componentType | String | (nullable) |
| shopComponentType | String | (nullable) |
| tags | List<String> | (nullable) |
SearchHcsAssetRequestVo
| 필드 | 타입 | 기본값 | 설명 |
|---|---|---|---|
| keyword | String | null | 검색어 (name, description) |
| componentType | String | null | 필터 |
| shopComponentType | String | null | 필터 |
| createdBy | String | null | 필터 (userId) |
| tags | List<String> | null | 필터 (AND 조건) |
| page | Integer | 0 | 0-indexed 페이지 |
| size | Integer | 20 | 페이지 크기 |
HcsAssetResponseVo
| 필드 | 타입 | 설명 |
|---|---|---|
| hcsAssetId | String | 고유 ID |
| name | String | 이름 |
| description | String | 설명 |
| treeNodeJson | String | 트리 JSON |
| thumbnailUrl | String | 썸네일 |
| componentType | String | 컴포넌트 타입 |
| shopComponentType | String | 비즈니스 라벨 |
| createdBy | String | 생성자 |
| usageCount | Long | 사용 횟수 |
| tags | List<String> | 태그 |
| createdAt | Instant | 생성 시각 |
| updatedAt | Instant | 수정 시각 |
Conversion: HcsAssetResponseVo.fromEntity(HcsAsset) — 정적 팩토리 메서드
Repository
HcsAssetRepository (DynamoDB)
public interface HcsAssetRepository { void save(HcsAsset hcsAsset); Optional<HcsAsset> findById(String hcsAssetId); List<HcsAsset> findByUserId(String userId, int limit); List<HcsAsset> scanAll(); void delete(HcsAsset hcsAsset); }
구현 상세:
save(): PutItem (생성/수정 동일)findById(): GetItem (PK=HCSASSET#{hcsAssetId}, SK=METADATA)findByUserId(): GSI1 Query (KEYS_ONLY) → Main Table BatchGetItem. Reverse scan (최신순)scanAll(): Scan + entityType 필터 (개발/관리용, 운영 주의)delete(): DeleteItem
HcsAssetSearchRepository (OpenSearch)
public interface HcsAssetSearchRepository { String index(HcsAssetDocument document); Optional<HcsAssetDocument> findById(String hcsAssetId); void update(HcsAssetDocument document); void delete(String hcsAssetId); List<HcsAssetDocument> search(String userId, String keyword, String componentType, String shopComponentType, List<String> tags, int page, int size); List<HcsAssetDocument> findByUserId(String userId, int size); List<HcsAssetDocument> findByTagsAny(List<String> tags, int size); void bulkIndex(List<HcsAssetDocument> documents); long deleteAllDocuments(); }
Search 로직:
search(): BoolQuery — filter(createdBy, componentType, shopComponentType, tags AND 조건) + must(keyword → name^2, description)findByTagsAny(): BoolQuery — should(tags OR 조건), minimum_should_match=1, 정렬: usage_count DESC
Service
HcsAssetService
public interface HcsAssetService { void createHcsAsset(HcsAsset hcsAsset); void updateHcsAsset(HcsAsset hcsAsset); Optional<HcsAsset> findHcsAsset(String hcsAssetId); HcsAsset getHcsAsset(String hcsAssetId); // throws HCS_ASSET_NOT_FOUND List<HcsAsset> findByUserId(String userId, int limit); List<HcsAsset> getAllHcsAssets(); void deleteHcsAsset(HcsAsset hcsAsset); void incrementUsageCount(String hcsAssetId); }
이벤트 발행: 생성/수정/삭제 시 EntityChangedEvent 발행 → OpenSearch 자동 동기화
HcsAssetSearchService (OpenSearch)
public interface HcsAssetSearchService { String indexHcsAsset(HcsAsset hcsAsset); String indexHcsAssetDocument(HcsAssetDocument document); Optional<HcsAssetDocument> getHcsAssetById(String hcsAssetId); void updateHcsAsset(HcsAsset hcsAsset); void updateHcsAssetDocument(HcsAssetDocument document); void deleteHcsAsset(String hcsAssetId); List<HcsAssetDocument> searchHcsAssets(String userId, String keyword, String componentType, String shopComponentType, List<String> tags, int page, int size); List<HcsAssetDocument> findByUserId(String userId, int size); List<HcsAssetDocument> findByTagsAny(List<String> tags, int size); void bulkIndexHcsAssets(List<HcsAsset> hcsAssets); void bulkIndexHcsAssetDocuments(List<HcsAssetDocument> documents); }
Application
HcsAssetApplication
@Service public class HcsAssetApplication { HcsAssetResponseVo createHcsAsset(CreateHcsAssetRequestVo requestVo); HcsAssetResponseVo getHcsAsset(String hcsAssetId, String userId); List<HcsAssetResponseVo> listHcsAssets(String userId, int limit); List<HcsAssetResponseVo> searchHcsAssets(SearchHcsAssetRequestVo requestVo); HcsAssetResponseVo updateHcsAsset(String hcsAssetId, UpdateHcsAssetRequestVo requestVo, String userId); void deleteHcsAsset(String hcsAssetId, String userId); void incrementUsageCount(String hcsAssetId, String userId); }
접근 제어: 모든 수정/삭제/조회 메서드에서 createdBy == userId 확인. 불일치 시 MakittBizException(HCS_ASSET_ACCESS_DENIED) 발생.
API
HcsAssetController
Base Path: /api/v1/hcs-assets
인증: 모든 엔드포인트에 SecurityContext 필요 (SecurityContextUtil.getCurrentUserId())
| 메서드 | 엔드포인트 | 상태 코드 | 설명 |
|---|---|---|---|
| POST | /api/v1/hcs-assets | 201 Created | HcsAsset 생성 |
| GET | /api/v1/hcs-assets | 200 OK | 내 HcsAsset 목록 조회 (limit 파라미터) |
| GET | /api/v1/hcs-assets/{hcsAssetId} | 200 OK | HcsAsset 상세 조회 |
| POST | /api/v1/hcs-assets/search | 200 OK | OpenSearch 검색/필터 |
| PUT | /api/v1/hcs-assets/{hcsAssetId} | 200 OK | HcsAsset 수정 |
| DELETE | /api/v1/hcs-assets/{hcsAssetId} | 204 No Content | HcsAsset 삭제 |
| POST | /api/v1/hcs-assets/{hcsAssetId}/increment-usage | 204 No Content | 사용 횟수 증가 |
Request/Response DTOs
CreateHcsAssetRequest:
| 필드 | 타입 | 검증 | 예시 |
|---|---|---|---|
| name | String | @NotBlank | "Product Card with Rating" |
| description | String | 선택 | |
| treeNodeJson | String | @NotBlank | "{\"id\":\"...\",\"type\":\"container\",...}" |
| thumbnailUrl | String | 선택 | "https://example.com/thumbnail.png" |
| componentType | String | @NotBlank | "COMPONENT" |
| shopComponentType | String | 선택 | "ProductCard" |
| tags | List<String> | 선택 | ["product", "card", "rating"] |
Conversion: CreateHcsAssetRequest.toVo(request, createdBy) — userId를 createdBy로 설정
UpdateHcsAssetRequest:
| 필드 | 타입 | 설명 |
|---|---|---|
| name | String | 선택 |
| description | String | 선택 |
| treeNodeJson | String | 선택 |
| thumbnailUrl | String | 선택 |
| componentType | String | 선택 |
| shopComponentType | String | 선택 |
| tags | List<String> | 선택 |
SearchHcsAssetRequest:
| 필드 | 타입 | 기본값 | 예시 |
|---|---|---|---|
| keyword | String | null | "product" |
| componentType | String | null | "COMPONENT" |
| shopComponentType | String | null | "ProductCard" |
| page | Integer | 0 | |
| size | Integer | 20 |
Conversion: SearchHcsAssetRequest.toVo(request, createdBy) — userId를 createdBy 필터로 설정
HcsAssetResponse:
| 필드 | 타입 | 예시 |
|---|---|---|
| hcsAssetId | String | "550e8400-e29b-41d4-a716-446655440000" |
| name | String | "Product Card with Rating" |
| description | String | |
| treeNodeJson | String | |
| thumbnailUrl | String | |
| componentType | String | "COMPONENT" |
| shopComponentType | String | "ProductCard" |
| createdBy | String | "user123" |
| usageCount | Long | 42 |
| tags | List<String> | |
| createdAt | Instant | |
| updatedAt | Instant |
에러 응답:
401 Unauthorized: 인증 실패403 Forbidden: 접근 권한 없음 (createdBy != userId)404 Not Found: HcsAsset 없음
OpenSearch 동기화
HcsAssetSearchSyncListener
AbstractSearchSyncListener<HcsAsset, HcsAssetDocument> 상속. EntityChangedEvent 리스닝으로 DynamoDB → OpenSearch 자동 동기화.
조건부 활성화: @ConditionalOnProperty(name = "search.sync.enabled", havingValue = "true", matchIfMissing = true)
메서드:
toDocument(HcsAsset)→HcsAssetDocumentindexDocument(HcsAssetDocument)→ 인덱싱updateDocument(HcsAssetDocument)→ 업데이트deleteDocument(String id)→ 삭제
동기화 흐름:
HcsAssetService.createHcsAsset(entity)
→ repository.save(entity)
→ eventPublisher.publishEvent(EntityChangedEvent.created(entity))
→ HcsAssetSearchSyncListener
→ HcsAssetSearchService.indexHcsAsset(entity)
→ HcsAssetSearchRepository.index(document)
Error Codes
| 코드 | 키 | 설명 |
|---|---|---|
| HCS_ASSET_NOT_FOUND | error.hcs.asset.not.found | HCS 에셋을 찾을 수 없습니다 |
| HCS_ASSET_ACCESS_DENIED | error.hcs.asset.access.denied | 접근할 권한이 없습니다 |
서버 아키텍처
HcsAssetController (makitt-api)
|
v (Request DTO → VO)
HcsAssetApplication (makitt-application)
|
v (VO → Entity)
HcsAssetService (makitt-core/service)
|
+---> HcsAssetRepository (DynamoDB)
|
+---> EntityChangedEvent → HcsAssetSearchSyncListener
| |
| v
+---> HcsAssetSearchService → HcsAssetSearchRepository (OpenSearch)
|
v
HcsAsset (makitt-core/entity)
패키지 구조:
makitt-core/
com.makitt.core.domain.hcsasset/
entity/
HcsAsset.java # DynamoDB Entity
vo/
CreateHcsAssetRequestVo.java
UpdateHcsAssetRequestVo.java
SearchHcsAssetRequestVo.java
HcsAssetResponseVo.java
service/
HcsAssetService.java # 인터페이스
HcsAssetServiceImpl.java # 비즈니스 로직
repository/
HcsAssetRepository.java # DynamoDB Repository
HcsAssetRepositoryImpl.java
com.makitt.core.common.dynamodb.key/
HcsAssetKey.java # Key Builder
KeyPrefix.java # HCS_ASSET = "HCSASSET#"
com.makitt.core.opensearch/
document/
HcsAssetDocument.java # OpenSearch Document
repository/
HcsAssetSearchRepository.java
HcsAssetSearchRepositoryImpl.java
service/
HcsAssetSearchService.java
HcsAssetSearchServiceImpl.java
listener/
HcsAssetSearchSyncListener.java # 이벤트 기반 동기화
makitt-application/
com.makitt.application.hcsasset/
HcsAssetApplication.java
makitt-api/
com.makitt.api.controller.hcsasset/
HcsAssetController.java
dto/
CreateHcsAssetRequest.java
UpdateHcsAssetRequest.java
SearchHcsAssetRequest.java
HcsAssetResponse.java
접근 제어
| 작업 | 권한 |
|---|---|
| 생성 | 인증된 사용자 |
| 조회 | 생성자 본인만 |
| 목록 조회 | 본인이 생성한 에셋만 |
| 검색 | 본인이 생성한 에셋만 (createdBy 필터 자동 적용) |
| 수정 | 생성자 본인만 |
| 삭제 | 생성자 본인만 |
| 사용 횟수 증가 | 생성자 본인만 |
검증 패턴:
if (!entity.getCreatedBy().equals(currentUserId)) { throw new MakittBizException(ErrorCode.HCS_ASSET_ACCESS_DENIED, ...); }
관련 설정
HcsConfig
@Configuration @ConfigurationProperties(prefix = "hcs") public class HcsConfig { private SystemDefaults systemDefaults; public static class SystemDefaults { private String tokensKey; // S3 object key for default tokens private String presetsKey; // S3 object key for default presets private String nodeDefaultsKey; // S3 object key for default node-defaults } }
참조
- Epic: HcsAsset 시스템
- Domain: Shop
- Domain: User