diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/curation/config/CurationSpecResourceSyncRunner.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/curation/config/CurationSpecResourceSyncRunner.kt index 3d2f5d4f1..14d516672 100644 --- a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/curation/config/CurationSpecResourceSyncRunner.kt +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/curation/config/CurationSpecResourceSyncRunner.kt @@ -1,5 +1,6 @@ package app.bottlenote.curation.config +import app.bottlenote.curation.service.CurationFeedPayloadRefreshService import app.bottlenote.curation.service.CurationSpecResourceSyncService import org.slf4j.LoggerFactory import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty @@ -15,7 +16,8 @@ import org.springframework.stereotype.Component matchIfMissing = true ) class CurationSpecResourceSyncRunner( - private val curationSpecResourceSyncService: CurationSpecResourceSyncService + private val curationSpecResourceSyncService: CurationSpecResourceSyncService, + private val curationFeedPayloadRefreshService: CurationFeedPayloadRefreshService ) { private val log = LoggerFactory.getLogger(javaClass) @@ -28,5 +30,6 @@ class CurationSpecResourceSyncRunner( result.updatedCount(), result.totalCount() ) + curationFeedPayloadRefreshService.refresh(result.changedSpecIds()) } } diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/curation/AdminSpecBasedCurationIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/curation/AdminSpecBasedCurationIntegrationTest.kt index 7dd0e7981..149a21fc3 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/integration/curation/AdminSpecBasedCurationIntegrationTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/curation/AdminSpecBasedCurationIntegrationTest.kt @@ -13,6 +13,7 @@ import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired import org.springframework.http.MediaType import java.time.LocalDate +import java.util.LinkedHashMap @Tag("admin_integration") @DisplayName("[integration] Admin Spec Based Curation API 통합 테스트") @@ -26,6 +27,13 @@ class AdminSpecBasedCurationIntegrationTest : IntegrationTestSupport() { @Autowired private lateinit var curationExtensionRepository: CurationExtensionRepository + @Autowired + private lateinit var curationRepository: app.bottlenote.curation.domain.CurationRepository + + @Autowired + private lateinit var curationFeedPayloadRegenerationService: + app.bottlenote.curation.service.CurationFeedPayloadRegenerationService + private lateinit var accessToken: String @BeforeEach @@ -332,6 +340,123 @@ class AdminSpecBasedCurationIntegrationTest : IntegrationTestSupport() { .isEqualTo(400) } + @Nested + @DisplayName("스펙 변경에 따른 feed_payload 재생성") + inner class FeedPayloadRegeneration { + + @Test + @DisplayName("responseSpec이 그대로면 재동기화해도 변경 스펙이 없어 재생성하지 않는다") + fun sync_whenResponseSpecUnchanged_reportsNoChangedSpec() { + val result = curationSpecResourceSyncService.sync() + + assertThat(result.updatedCount()).isPositive() + assertThat(result.changedSpecIds()).isEmpty() + assertThat(result.hasChangedSpecs()).isFalse() + } + + @Test + @DisplayName("responseSpec을 바꾸면 그 스펙의 큐레이션만 feed_payload가 재생성되고 NULL 행도 채워진다") + fun regenerate_whenResponseSpecChanged_refreshesOnlyThatSpec() { + val curationId = createCurationViaApi() + val otherSpecId = curationSpecRepository.findByCode("WHISKY_TASTING_EVENT").orElseThrow().id + // backfill 이전 레거시 행을 재현한다. + curationExtensionRepository.findByCurationId(curationId).orElseThrow() + .also { it.updateFeedPayload(null) } + .let { curationExtensionRepository.save(it) } + val otherBefore = tamperOtherSpecCuration(otherSpecId) + + tamperResponseSpec(recommendedSpecId()) + val syncResult = curationSpecResourceSyncService.sync() + curationFeedPayloadRegenerationService.regenerate(syncResult.changedSpecIds()) + + assertThat(syncResult.changedSpecIds()).containsExactly(recommendedSpecId()) + val regenerated = curationExtensionRepository.findByCurationId(curationId).orElseThrow() + assertThat(regenerated.feedPayload).isNotNull() + assertThat(mapper.valueToTree(regenerated.feedPayload)[0].path("alcohol").path("korName").asText()) + .isEqualTo("검증 위스키") + // 원본 payload는 SSOT이므로 그대로여야 한다. + assertThat(mapper.valueToTree(regenerated.payload)[0].has("source")).isTrue() + assertThat(curationExtensionRepository.findByCurationId(otherBefore).orElseThrow().feedPayload) + .isNull() + } + + @Test + @DisplayName("Admin 피드 프리뷰는 feed_payload가 NULL이어도 원본으로 fallback해 같은 결과를 낸다") + fun searchFeed_whenFeedPayloadIsNull_fallsBackToSource() { + val migratedId = createCurationViaApi() + val legacyId = createCurationViaApi() + curationExtensionRepository.findByCurationId(legacyId).orElseThrow() + .also { it.updateFeedPayload(null) } + .let { curationExtensionRepository.save(it) } + + val result = mockMvcTester + .get() + .uri("/v2/curations/feed?code=RECOMMENDED_WHISKY&size=10") + .header("Authorization", "Bearer $accessToken") + .exchange() + + assertThat(result).hasStatusOk() + // fromPage는 data 자체가 배열이다. + val items = dataNode(result) + val migrated = items.first { it.path("id").asLong() == migratedId } + val legacy = items.first { it.path("id").asLong() == legacyId } + assertThat(legacy.path("feedFields")).isEqualTo(migrated.path("feedFields")) + assertThat(migrated.path("feedFields")).isNotEmpty() + } + + private fun createCurationViaApi(): Long { + val result = mockMvcTester + .post() + .uri("/v2/curations") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(createRequest(validPayload()))) + .exchange() + return dataNode(result).path("targetId").asLong() + } + + // 다른 스펙의 큐레이션을 NULL 상태로 만들어 재생성 범위 밖임을 확인할 수 있게 한다. + private fun tamperOtherSpecCuration(otherSpecId: Long): Long { + val curation = curationRepository.save( + app.bottlenote.curation.domain.Curation.builder() + .specId(otherSpecId) + .name("다른 스펙 큐레이션") + .description("재생성 범위 검증") + .coverImageUrl("https://cdn.example.com/cover.jpg") + .exposureStartDate(LocalDate.now().minusDays(1)) + .exposureEndDate(LocalDate.now().plusDays(30)) + .displayOrder(1) + .isActive(true) + .build() + ) + curationExtensionRepository.save( + app.bottlenote.curation.domain.CurationExtension.builder() + .curationId(curation.id) + .specId(otherSpecId) + .payload(mapOf("eventDate" to "2026-06-21")) + .build() + ) + return curation.id + } + + // x-feed를 하나 꺼서 responseSpec 지문이 실제로 달라지게 만든다. + private fun tamperResponseSpec(specId: Long) { + val spec = curationSpecRepository.findById(specId).orElseThrow() + val tampered = LinkedHashMap(spec.responseSpec) + tampered["x-regeneration-test"] = System.nanoTime() + spec.update( + spec.name, + spec.description, + spec.requestSpec, + tampered, + spec.hydratorKey, + spec.version, + true + ) + curationSpecRepository.save(spec) + } + } + private fun assertNotFound(result: org.springframework.test.web.servlet.assertj.MvcTestResult) { assertThat(result).hasStatus(404) .bodyJson() diff --git a/bottlenote-mono/src/main/java/app/bottlenote/curation/domain/CurationExtension.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/domain/CurationExtension.java index 12dd82c42..d8e10b489 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/curation/domain/CurationExtension.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/domain/CurationExtension.java @@ -12,8 +12,11 @@ import lombok.Getter; import lombok.NoArgsConstructor; import org.hibernate.annotations.Comment; +import org.hibernate.annotations.DynamicUpdate; import org.hibernate.annotations.Type; +// 재생성은 feed_payload만 바꾼다. 전체 컬럼 UPDATE면 로드 시점의 stale payload가 어드민 저장분을 덮는다. +@DynamicUpdate @Comment("spec 기반 큐레이션 payload") @Entity(name = "curation_extension") @Table(name = "curation_extension") @@ -47,4 +50,15 @@ public void update(Long specId, Object payload, Object feedPayload) { this.payload = payload; this.feedPayload = feedPayload; } + + // 스펙 변경에 따른 재생성용. 원본 payload는 SSOT이므로 건드리지 않는다. + public void updateFeedPayload(Object feedPayload) { + this.feedPayload = feedPayload; + } + + // 피드 조회 소스. NULL은 backfill 이전 레거시 행이므로 원본으로 되돌아간다. + // 빈 결과는 []/{}로 저장되므로 NULL만 fallback 조건이다. + public Object feedSource() { + return feedPayload != null ? feedPayload : payload; + } } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/curation/domain/CurationExtensionRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/domain/CurationExtensionRepository.java index 87b224684..bb7b2c73f 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/curation/domain/CurationExtensionRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/domain/CurationExtensionRepository.java @@ -12,6 +12,8 @@ public interface CurationExtensionRepository { List findAllByCurationIdIn(Collection curationIds); + List findAllBySpecIdIn(Collection specIds); + CurationExtension save(CurationExtension curationExtension); void deleteByCurationId(Long curationId); diff --git a/bottlenote-mono/src/main/java/app/bottlenote/curation/domain/CurationFeedRegenerationLock.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/domain/CurationFeedRegenerationLock.java new file mode 100644 index 000000000..17eaf430c --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/domain/CurationFeedRegenerationLock.java @@ -0,0 +1,9 @@ +package app.bottlenote.curation.domain; + +// 인스턴스가 여럿이면 기동 시 재생성이 중복 실행된다. JVM 로컬 상태로는 막을 수 없어 공유 저장소로 잠근다. +public interface CurationFeedRegenerationLock { + + boolean tryAcquire(); + + void release(); +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/curation/dto/response/CurationSpecSyncResponse.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/dto/response/CurationSpecSyncResponse.java index a41e3de5a..6b9dfd4ef 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/curation/dto/response/CurationSpecSyncResponse.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/dto/response/CurationSpecSyncResponse.java @@ -1,8 +1,21 @@ package app.bottlenote.curation.dto.response; -public record CurationSpecSyncResponse(int createdCount, int updatedCount) { +import java.util.List; + +// changedSpecIds: responseSpec이 실제로 바뀐 기존 스펙. 신규 생성 스펙은 큐레이션이 없어 제외한다. +public record CurationSpecSyncResponse( + int createdCount, int updatedCount, List changedSpecIds) { + + // null을 빈 목록으로 뭉개면 재생성 누락이 "변경 없음"으로 위장된다. NPE로 즉시 드러나게 둔다. + public CurationSpecSyncResponse { + changedSpecIds = List.copyOf(changedSpecIds); + } public int totalCount() { return createdCount + updatedCount; } + + public boolean hasChangedSpecs() { + return !changedSpecIds.isEmpty(); + } } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/curation/repository/JpaCurationExtensionRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/repository/JpaCurationExtensionRepository.java index 88fb10492..5b3d97284 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/curation/repository/JpaCurationExtensionRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/repository/JpaCurationExtensionRepository.java @@ -16,5 +16,7 @@ public interface JpaCurationExtensionRepository List findAllByCurationIdIn(Collection curationIds); + List findAllBySpecIdIn(Collection specIds); + void deleteByCurationId(Long curationId); } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/curation/repository/RedisCurationFeedRegenerationLock.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/repository/RedisCurationFeedRegenerationLock.java new file mode 100644 index 000000000..c948055ff --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/repository/RedisCurationFeedRegenerationLock.java @@ -0,0 +1,49 @@ +package app.bottlenote.curation.repository; + +import app.bottlenote.curation.domain.CurationFeedRegenerationLock; +import java.time.Duration; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class RedisCurationFeedRegenerationLock implements CurationFeedRegenerationLock { + + private static final String LOCK_KEY = "curation:feed-payload:regeneration:lock"; + // 재생성이 끝나면 해제하므로 TTL은 프로세스가 죽었을 때의 안전장치다. + private static final Duration LOCK_TTL = Duration.ofMinutes(10); + + private final RedisTemplate redisTemplate; + + // 획득할 때마다 다른 토큰을 남긴다. TTL이 만료돼 다른 인스턴스가 락을 잡았을 때 남의 락을 지우지 않기 위해서다. + private final AtomicReference ownedToken = new AtomicReference<>(); + + @Override + public boolean tryAcquire() { + String candidate = UUID.randomUUID().toString(); + boolean acquired = + Boolean.TRUE.equals(redisTemplate.opsForValue().setIfAbsent(LOCK_KEY, candidate, LOCK_TTL)); + if (acquired) { + ownedToken.set(candidate); + } + return acquired; + } + + @Override + public void release() { + String owned = ownedToken.getAndSet(null); + if (owned == null) { + return; + } + if (owned.equals(redisTemplate.opsForValue().get(LOCK_KEY))) { + redisTemplate.delete(LOCK_KEY); + return; + } + log.warn("재생성 락 소유권이 이미 넘어가 해제를 건너뜁니다. 남은 락은 TTL({})로 만료됩니다.", LOCK_TTL); + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/curation/service/AdminSpecBasedCurationService.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/service/AdminSpecBasedCurationService.java index 0cb6cb2ea..9b6ffde52 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/curation/service/AdminSpecBasedCurationService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/service/AdminSpecBasedCurationService.java @@ -243,7 +243,7 @@ private CurationFeedItemResponse toFeedResponse(Curation curation, CurationSpec CurationExtension extension = getExtension(curation.getId()); Object materialized = responseMaterializer.materializeFeed( - curation.getId(), spec.getCode(), spec.getResponseSpec(), extension.getPayload()); + curation.getId(), spec.getCode(), spec.getResponseSpec(), extension.feedSource()); return new CurationFeedItemResponse( curation.getId(), curation.getSpecId(), diff --git a/bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationFeedPayloadRefreshService.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationFeedPayloadRefreshService.java new file mode 100644 index 000000000..2efe9ceab --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationFeedPayloadRefreshService.java @@ -0,0 +1,57 @@ +package app.bottlenote.curation.service; + +import app.bottlenote.curation.domain.CurationFeedRegenerationLock; +import java.util.Collection; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +// 스펙 변경 후 Read Model을 되살리는 절차를 소유한다. 잠금·순서·실패 정책이 여기 모인다. +@Service +@RequiredArgsConstructor +@Slf4j +public class CurationFeedPayloadRefreshService { + + private final CurationFeedPayloadRegenerationService regenerationService; + private final CurationFeedRegenerationLock lock; + + // feed_payload는 파생 데이터고 NULL fallback이 있다. 어떤 실패도 호출자(기동 경로)로 새지 않는다. + // 무효화와 재생성이 각자 커밋되어야 하므로 이 메서드 자체는 트랜잭션을 열지 않는다. + @Transactional(propagation = Propagation.NOT_SUPPORTED) + public void refresh(Collection specIds) { + if (specIds == null || specIds.isEmpty()) { + return; + } + boolean acquired = false; + try { + acquired = lock.tryAcquire(); + if (!acquired) { + log.info("다른 인스턴스가 재생성 중이라 건너뜁니다: specIds={}", specIds); + return; + } + // 무효화를 먼저 커밋한다. 뒤가 실패해도 낡은 값이 아니라 NULL이 남아 원본으로 fallback된다. + regenerationService.invalidate(specIds); + log.info( + "feed_payload 재생성 완료: specIds={}, curations={}", + specIds, + regenerationService.regenerate(specIds)); + } catch (Exception e) { + log.warn("feed_payload 재생성 실패. 조회는 원본 payload로 대체됩니다: specIds={}", specIds, e); + } finally { + releaseQuietly(acquired); + } + } + + private void releaseQuietly(boolean acquired) { + if (!acquired) { + return; + } + try { + lock.release(); + } catch (Exception e) { + log.warn("재생성 락 해제에 실패했습니다. TTL로 만료됩니다.", e); + } + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationFeedPayloadRegenerationService.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationFeedPayloadRegenerationService.java new file mode 100644 index 000000000..c346f36ca --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationFeedPayloadRegenerationService.java @@ -0,0 +1,57 @@ +package app.bottlenote.curation.service; + +import app.bottlenote.curation.domain.CurationExtension; +import app.bottlenote.curation.domain.CurationExtensionRepository; +import app.bottlenote.curation.domain.CurationSpec; +import app.bottlenote.curation.domain.CurationSpecRepository; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +// 스펙의 x-feed가 바뀌면 저장된 feed_payload가 낡는다. 바뀐 스펙의 큐레이션만 다시 만든다. +@Service +@RequiredArgsConstructor +@Slf4j +public class CurationFeedPayloadRegenerationService { + + private final CurationSpecRepository curationSpecRepository; + private final CurationExtensionRepository curationExtensionRepository; + private final CurationFeedProjector feedProjector; + + // 재생성 전에 먼저 비운다. 이후 단계가 실패해도 NULL fallback으로 정확한 응답이 나가기 때문이다. + // sync가 DB 스펙을 이미 덮어써서 다음 기동에는 "변경 없음"으로 보이므로, 낡은 값을 남기면 영구히 굳는다. + @Transactional + public int invalidate(Collection specIds) { + List extensions = + curationExtensionRepository.findAllBySpecIdIn(Set.copyOf(specIds)); + extensions.forEach(extension -> extension.updateFeedPayload(null)); + log.info("큐레이션 feed_payload 무효화: specIds={}, curations={}", specIds, extensions.size()); + return extensions.size(); + } + + @Transactional + public int regenerate(Collection specIds) { + Map specs = + curationSpecRepository.findAllByIdIn(Set.copyOf(specIds)).stream() + .collect(Collectors.toMap(CurationSpec::getId, Function.identity())); + + // feed_payload가 NULL인 레거시 행도 대상이다. 재생성이 backfill을 겸한다. + List extensions = + curationExtensionRepository.findAllBySpecIdIn(specs.keySet()); + extensions.forEach( + extension -> + extension.updateFeedPayload( + feedProjector.extractFeedPayload( + specs.get(extension.getSpecId()).getResponseSpec(), extension.getPayload()))); + log.info( + "큐레이션 feed_payload 재생성 완료: specIds={}, curations={}", specs.keySet(), extensions.size()); + return extensions.size(); + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationSpecResourceSyncService.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationSpecResourceSyncService.java index b51f4a7d5..913549fc1 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationSpecResourceSyncService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationSpecResourceSyncService.java @@ -4,9 +4,11 @@ import app.bottlenote.curation.domain.CurationSpecRepository; import app.bottlenote.curation.dto.response.CurationSpecSyncResponse; import app.bottlenote.curation.service.CurationPayloadValidator.MapBackedSchema; +import app.bottlenote.curation.support.CurationSpecFingerprint; import app.bottlenote.curation.support.CurationSpecResourceReader; import app.bottlenote.curation.support.CurationSpecResourceReader.CurationSpecResourceDocument; import java.util.ArrayList; +import java.util.List; import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.cache.annotation.CacheEvict; @@ -20,6 +22,7 @@ public class CurationSpecResourceSyncService { private final CurationSpecRepository curationSpecRepository; private final CurationSpecResourceReader curationSpecResourceReader; private final CurationPayloadValidator curationPayloadValidator; + private final CurationSpecFingerprint curationSpecFingerprint; @CacheEvict( value = {"local_cache_curation_spec_list", "local_cache_curation_spec_detail"}, @@ -28,20 +31,29 @@ public class CurationSpecResourceSyncService { public CurationSpecSyncResponse sync() { int createdCount = 0; int updatedCount = 0; + List changedSpecIds = new ArrayList<>(); for (CurationSpecResourceDocument specDocument : curationSpecResourceReader.readAll()) { validateSpecDocument(specDocument); Optional existingSpec = curationSpecRepository.findByCode(specDocument.code()); if (existingSpec.isPresent()) { - curationSpecRepository.save(update(existingSpec.get(), specDocument)); + CurationSpec curationSpec = existingSpec.get(); + // 덮어쓰기 전에 비교해야 한다. update()가 responseSpec을 교체하고 나면 이전 값을 잃는다. + boolean responseSpecChanged = + !curationSpecFingerprint.isSame( + curationSpec.getResponseSpec(), specDocument.responseSpec()); + curationSpecRepository.save(update(curationSpec, specDocument)); updatedCount++; + if (responseSpecChanged) { + changedSpecIds.add(curationSpec.getId()); + } } else { curationSpecRepository.save(create(specDocument)); createdCount++; } } - return new CurationSpecSyncResponse(createdCount, updatedCount); + return new CurationSpecSyncResponse(createdCount, updatedCount, changedSpecIds); } private void validateSpecDocument(CurationSpecResourceDocument specDocument) { diff --git a/bottlenote-mono/src/main/java/app/bottlenote/curation/service/ProductSpecBasedCurationService.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/service/ProductSpecBasedCurationService.java index a10d9c540..1b5ab5660 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/curation/service/ProductSpecBasedCurationService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/service/ProductSpecBasedCurationService.java @@ -166,7 +166,7 @@ private ProductSpecBasedCurationFeedItemResponse toFeedResponse( Curation curation, CurationSpec spec, CurationExtension extension) { Object materialized = responseMaterializer.materializeFeed( - curation.getId(), spec.getCode(), spec.getResponseSpec(), extension.getPayload()); + curation.getId(), spec.getCode(), spec.getResponseSpec(), extension.feedSource()); return new ProductSpecBasedCurationFeedItemResponse( curation.getId(), curation.getName(), diff --git a/bottlenote-mono/src/main/java/app/bottlenote/curation/support/CurationSpecFingerprint.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/support/CurationSpecFingerprint.java new file mode 100644 index 000000000..52718ea9f --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/support/CurationSpecFingerprint.java @@ -0,0 +1,83 @@ +package app.bottlenote.curation.support; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import org.springframework.stereotype.Component; + +// 스펙 내용 비교용 지문. 저장 왕복에서 키 순서·공백·수치 표기가 흔들리므로 정규화 후 해시한다. +@Component +public final class CurationSpecFingerprint { + + private static final String ALGORITHM = "SHA-256"; + + private final ObjectMapper objectMapper; + + public CurationSpecFingerprint(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + public String of(Map spec) { + JsonNode canonical = canonicalize(objectMapper.valueToTree(spec)); + return digest(canonical == null ? "" : canonical.toString()); + } + + public boolean isSame(Map left, Map right) { + return of(left).equals(of(right)); + } + + // 객체 키를 재귀 정렬한다. MySQL JSON 컬럼이 키를 자체 정렬해 저장 전후 순서가 달라진다. + private JsonNode canonicalize(JsonNode node) { + if (node == null || node.isNull()) { + return null; + } + if (node.isObject()) { + List names = new ArrayList<>(); + node.fieldNames().forEachRemaining(names::add); + names.sort(String::compareTo); + ObjectNode sorted = objectMapper.createObjectNode(); + for (String name : names) { + JsonNode child = canonicalize(node.get(name)); + sorted.set(name, child == null ? objectMapper.nullNode() : child); + } + return sorted; + } + if (node.isArray()) { + // 배열은 순서가 의미를 가지므로 정렬하지 않는다. + ArrayNode canonical = objectMapper.createArrayNode(); + node.forEach( + child -> { + JsonNode value = canonicalize(child); + canonical.add(value == null ? objectMapper.nullNode() : value); + }); + return canonical; + } + // 1 과 1.0, 1.2 와 1.20 이 왕복 과정에서 갈리므로 실수는 항상 trailing zero를 떨어낸다. + if (node.isFloatingPointNumber()) { + BigDecimal stripped = node.decimalValue().stripTrailingZeros(); + return stripped.scale() <= 0 + ? objectMapper.getNodeFactory().numberNode(stripped.toBigInteger()) + : objectMapper.getNodeFactory().numberNode(stripped); + } + return node; + } + + private String digest(String canonical) { + try { + MessageDigest messageDigest = MessageDigest.getInstance(ALGORITHM); + return HexFormat.of() + .formatHex(messageDigest.digest(canonical.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(ALGORITHM + " 알고리즘을 사용할 수 없습니다.", e); + } + } +} diff --git a/bottlenote-mono/src/main/resources/openapi/curation/program.json b/bottlenote-mono/src/main/resources/openapi/curation/program.json index 12811c61d..78109dd25 100644 --- a/bottlenote-mono/src/main/resources/openapi/curation/program.json +++ b/bottlenote-mono/src/main/resources/openapi/curation/program.json @@ -38,15 +38,25 @@ "description": "행사 대표 장소명", "example": "코엑스", "maxLength": 100, - "x-field-style": "plain-text", + "x-field-style": "address-search", "x-display-name": "장소명" }, + "zipCode": { + "type": "string", + "description": "행사 장소 우편번호", + "example": "06164", + "minLength": 5, + "maxLength": 5, + "pattern": "^\\d{5}$", + "x-field-style": "address-search", + "x-display-name": "우편번호" + }, "address": { "type": "string", "description": "행사 대표 주소", "example": "서울 강남구 영동대로 513", "maxLength": 200, - "x-field-style": "plain-text", + "x-field-style": "address-search", "x-display-name": "장소 및 주소" }, "detailLocation": { diff --git a/bottlenote-mono/src/main/resources/openapi/curation/whisky_tasting_event.json b/bottlenote-mono/src/main/resources/openapi/curation/whisky_tasting_event.json index 7b47da663..4b24a8b1d 100644 --- a/bottlenote-mono/src/main/resources/openapi/curation/whisky_tasting_event.json +++ b/bottlenote-mono/src/main/resources/openapi/curation/whisky_tasting_event.json @@ -34,6 +34,24 @@ "x-field-style": "none", "x-display-name": "시음회 시간" }, + "placeName": { + "type": "string", + "description": "시음회 장소명", + "example": "보틀노트 테이스팅룸", + "maxLength": 100, + "x-field-style": "address-search", + "x-display-name": "장소명" + }, + "zipCode": { + "type": "string", + "description": "장소 우편번호", + "example": "06236", + "minLength": 5, + "maxLength": 5, + "pattern": "^\\d{5}$", + "x-field-style": "address-search", + "x-display-name": "우편번호" + }, "barAddress": { "type": "string", "description": "장소 및 바 주소", @@ -280,6 +298,30 @@ "description": "피드에서 시음회 시작 시간을 표시하기 위해 포함한다." } }, + "placeName": { + "type": "string", + "description": "시음회 장소명", + "example": "보틀노트 테이스팅룸", + "maxLength": 100, + "x-field-style": "address-search", + "x-display-name": "장소명", + "x-feed": { + "enabled": true, + "role": "location", + "order": 25, + "description": "피드에서 시음회 장소명을 표시하기 위해 포함한다." + } + }, + "zipCode": { + "type": "string", + "description": "장소 우편번호", + "example": "06236", + "minLength": 5, + "maxLength": 5, + "pattern": "^\\d{5}$", + "x-field-style": "address-search", + "x-display-name": "우편번호" + }, "barAddress": { "type": "string", "description": "장소 및 바 주소", diff --git a/bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationFeedPayloadRefreshServiceTest.java b/bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationFeedPayloadRefreshServiceTest.java new file mode 100644 index 000000000..504e5752f --- /dev/null +++ b/bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationFeedPayloadRefreshServiceTest.java @@ -0,0 +1,151 @@ +package app.bottlenote.curation.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +import app.bottlenote.curation.domain.CurationFeedRegenerationLock; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +@Tag("unit") +@DisplayName("CurationFeedPayloadRefreshService 단위 테스트") +class CurationFeedPayloadRefreshServiceTest { + + @Test + @DisplayName("변경된 스펙이 없으면 락을 잡지 않는다") + void refresh_whenNoSpecIds_doesNothing() { + FakeLock lock = new FakeLock(true); + RecordingRegeneration regeneration = new RecordingRegeneration(); + + new CurationFeedPayloadRefreshService(regeneration, lock).refresh(List.of()); + new CurationFeedPayloadRefreshService(regeneration, lock).refresh(null); + + assertThat(lock.acquireCount).isZero(); + assertThat(regeneration.regenerated).isEmpty(); + } + + @Test + @DisplayName("무효화를 먼저 커밋한 뒤 재생성하고 락을 해제한다") + void refresh_invalidatesBeforeRegenerating() { + FakeLock lock = new FakeLock(true); + RecordingRegeneration regeneration = new RecordingRegeneration(); + + new CurationFeedPayloadRefreshService(regeneration, lock).refresh(List.of(1L, 3L)); + + assertThat(regeneration.order).containsExactly("invalidate", "regenerate"); + assertThat(regeneration.regenerated).containsExactly(List.of(1L, 3L)); + assertThat(lock.releaseCount).isEqualTo(1); + } + + @Test + @DisplayName("락을 얻지 못하면 재생성을 건너뛰고 해제도 하지 않는다") + void refresh_whenLockNotAcquired_skips() { + FakeLock lock = new FakeLock(false); + RecordingRegeneration regeneration = new RecordingRegeneration(); + + new CurationFeedPayloadRefreshService(regeneration, lock).refresh(List.of(1L)); + + assertThat(regeneration.order).isEmpty(); + assertThat(lock.releaseCount).isZero(); + } + + @Test + @DisplayName("재생성이 실패해도 호출자로 예외가 새지 않고 락은 해제된다") + void refresh_whenRegenerationFails_swallowsAndReleases() { + FakeLock lock = new FakeLock(true); + RecordingRegeneration regeneration = + new RecordingRegeneration() { + @Override + public int regenerate(Collection specIds) { + throw new IllegalStateException("추출 실패"); + } + }; + CurationFeedPayloadRefreshService service = + new CurationFeedPayloadRefreshService(regeneration, lock); + + assertThatCode(() -> service.refresh(List.of(1L))).doesNotThrowAnyException(); + assertThat(lock.releaseCount).isEqualTo(1); + } + + @Test + @DisplayName("락 획득 중 저장소가 죽어도 예외가 새지 않는다") + void refresh_whenAcquireThrows_swallows() { + CurationFeedPayloadRefreshService service = + new CurationFeedPayloadRefreshService(new RecordingRegeneration(), new ExplodingLock(true)); + + assertThatCode(() -> service.refresh(List.of(1L))).doesNotThrowAnyException(); + } + + @Test + @DisplayName("락 해제 중 저장소가 죽어도 예외가 새지 않는다") + void refresh_whenReleaseThrows_swallows() { + CurationFeedPayloadRefreshService service = + new CurationFeedPayloadRefreshService( + new RecordingRegeneration(), new ExplodingLock(false)); + + assertThatCode(() -> service.refresh(List.of(1L))).doesNotThrowAnyException(); + } + + private static class RecordingRegeneration extends CurationFeedPayloadRegenerationService { + final List order = new ArrayList<>(); + final List> regenerated = new ArrayList<>(); + + RecordingRegeneration() { + super(null, null, null); + } + + @Override + public int invalidate(Collection specIds) { + order.add("invalidate"); + return specIds.size(); + } + + @Override + public int regenerate(Collection specIds) { + order.add("regenerate"); + regenerated.add(specIds); + return specIds.size(); + } + } + + private static class FakeLock implements CurationFeedRegenerationLock { + private final boolean acquirable; + int acquireCount; + int releaseCount; + + FakeLock(boolean acquirable) { + this.acquirable = acquirable; + } + + @Override + public boolean tryAcquire() { + acquireCount++; + return acquirable; + } + + @Override + public void release() { + releaseCount++; + } + } + + // 공유 저장소 장애를 흉내낸다. 기동 경로에서 이 예외가 새면 애플리케이션이 뜨지 않는다. + private record ExplodingLock(boolean onAcquire) implements CurationFeedRegenerationLock { + @Override + public boolean tryAcquire() { + if (onAcquire) { + throw new IllegalStateException("Redis 연결 실패"); + } + return true; + } + + @Override + public void release() { + throw new IllegalStateException("Redis 연결 실패"); + } + } +} diff --git a/bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationFeedPayloadRegenerationServiceTest.java b/bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationFeedPayloadRegenerationServiceTest.java new file mode 100644 index 000000000..2cf13629b --- /dev/null +++ b/bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationFeedPayloadRegenerationServiceTest.java @@ -0,0 +1,169 @@ +package app.bottlenote.curation.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import app.bottlenote.curation.domain.CurationExtension; +import app.bottlenote.curation.domain.CurationSpec; +import app.bottlenote.curation.fixture.InMemoryCurationExtensionRepository; +import app.bottlenote.curation.fixture.InMemoryCurationSpecRepository; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +@Tag("unit") +@DisplayName("CurationFeedPayloadRegenerationService 단위 테스트") +class CurationFeedPayloadRegenerationServiceTest { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private InMemoryCurationSpecRepository specRepository; + private InMemoryCurationExtensionRepository extensionRepository; + private CurationFeedPayloadRegenerationService regenerator; + + @BeforeEach + void setUp() { + specRepository = new InMemoryCurationSpecRepository(); + extensionRepository = new InMemoryCurationExtensionRepository(); + regenerator = + new CurationFeedPayloadRegenerationService( + specRepository, extensionRepository, new CurationFeedProjector(OBJECT_MAPPER)); + } + + @Test + @DisplayName("feed_payload가 NULL인 레거시 행도 재생성 대상이라 backfill을 겸한다") + void regenerate_whenFeedPayloadIsNull_fillsIt() { + CurationSpec spec = saveSpec(feedOnTitleSpec()); + saveExtension(1L, spec.getId(), payload("제목", "내부메모"), null); + + int regenerated = regenerator.regenerate(List.of(spec.getId())); + + assertThat(regenerated).isEqualTo(1); + JsonNode feedPayload = feedPayloadOf(1L); + assertThat(feedPayload.path("title").asText()).isEqualTo("제목"); + assertThat(feedPayload.has("internalNote")).isFalse(); + } + + @Test + @DisplayName("스펙의 x-feed가 바뀌면 기존 feed_payload가 새 기준으로 갱신된다") + void regenerate_whenSpecChanged_refreshesExistingFeedPayload() { + CurationSpec spec = saveSpec(feedOnTitleSpec()); + saveExtension(1L, spec.getId(), payload("제목", "내부메모"), Map.of("title", "옛날값")); + spec.update( + spec.getName(), + spec.getDescription(), + spec.getRequestSpec(), + feedOnBothSpec(), + spec.getHydratorKey(), + spec.getVersion(), + true); + + regenerator.regenerate(List.of(spec.getId())); + + JsonNode feedPayload = feedPayloadOf(1L); + assertThat(feedPayload.path("title").asText()).isEqualTo("제목"); + assertThat(feedPayload.path("internalNote").asText()).isEqualTo("내부메모"); + } + + @Test + @DisplayName("대상이 아닌 스펙의 큐레이션은 건드리지 않는다") + void regenerate_whenOtherSpec_leavesUntouched() { + CurationSpec target = saveSpec(feedOnTitleSpec()); + CurationSpec other = saveSpec(feedOnTitleSpec()); + saveExtension(1L, target.getId(), payload("제목", "내부메모"), null); + saveExtension(2L, other.getId(), payload("다른제목", "내부메모"), null); + + regenerator.regenerate(List.of(target.getId())); + + assertThat(extensionRepository.findByCurationId(1L).orElseThrow().getFeedPayload()).isNotNull(); + assertThat(extensionRepository.findByCurationId(2L).orElseThrow().getFeedPayload()).isNull(); + } + + @Test + @DisplayName("원본 payload는 재생성 후에도 그대로다") + void regenerate_keepsSourcePayloadIntact() { + CurationSpec spec = saveSpec(feedOnTitleSpec()); + saveExtension(1L, spec.getId(), payload("제목", "내부메모"), null); + + regenerator.regenerate(List.of(spec.getId())); + + JsonNode payload = + OBJECT_MAPPER.valueToTree( + extensionRepository.findByCurationId(1L).orElseThrow().getPayload()); + assertThat(payload.path("internalNote").asText()).isEqualTo("내부메모"); + } + + @Test + @DisplayName("빈 목록을 받으면 아무것도 하지 않는다") + void regenerate_whenNoSpecIds_doesNothing() { + assertThat(regenerator.regenerate(List.of())).isZero(); + assertThat(regenerator.invalidate(List.of())).isZero(); + } + + @Test + @DisplayName("존재하지 않는 specId는 조용히 건너뛴다") + void regenerate_whenSpecMissing_skips() { + assertThat(regenerator.regenerate(List.of(9999L))).isZero(); + } + + private JsonNode feedPayloadOf(Long curationId) { + return OBJECT_MAPPER.valueToTree( + extensionRepository.findByCurationId(curationId).orElseThrow().getFeedPayload()); + } + + private CurationSpec saveSpec(Map responseSpec) { + return specRepository.save( + CurationSpec.builder() + .code("SPEC_" + System.nanoTime()) + .name("테스트 스펙") + .description("설명") + .requestSpec(Map.of()) + .responseSpec(responseSpec) + .hydratorKey("test") + .version(1) + .isActive(true) + .build()); + } + + private void saveExtension(Long curationId, Long specId, Object payload, Object feedPayload) { + extensionRepository.save( + CurationExtension.builder() + .curationId(curationId) + .specId(specId) + .payload(payload) + .feedPayload(feedPayload) + .build()); + } + + private static Map payload(String title, String internalNote) { + Map payload = new LinkedHashMap<>(); + payload.put("title", title); + payload.put("internalNote", internalNote); + return payload; + } + + private static Map feedOnTitleSpec() { + return Map.of( + "type", + "object", + "properties", + Map.of( + "title", Map.of("type", "string", "x-feed", Map.of("enabled", true)), + "internalNote", Map.of("type", "string"))); + } + + private static Map feedOnBothSpec() { + return Map.of( + "type", + "object", + "properties", + Map.of( + "title", Map.of("type", "string", "x-feed", Map.of("enabled", true)), + "internalNote", Map.of("type", "string", "x-feed", Map.of("enabled", true)))); + } +} diff --git a/bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationFeedSourceEquivalenceTest.java b/bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationFeedSourceEquivalenceTest.java new file mode 100644 index 000000000..81a35cea2 --- /dev/null +++ b/bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationFeedSourceEquivalenceTest.java @@ -0,0 +1,266 @@ +package app.bottlenote.curation.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import app.bottlenote.curation.domain.CurationExtension; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +// 읽기 경로 전환의 안전망. feed_payload를 소스로 써도 피드 응답이 원본 경로와 같아야 한다. +@Tag("unit") +@DisplayName("피드 소스 전환 동등성 단위 테스트") +class CurationFeedSourceEquivalenceTest { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final TypeReference> MAP_TYPE = new TypeReference<>() {}; + + private final CurationFeedProjector projector = new CurationFeedProjector(OBJECT_MAPPER); + + @Test + @DisplayName("RECOMMENDED_WHISKY는 feed_payload로 투영해도 원본 투영과 같다") + void projectPayload_whenRecommendedWhisky_isEquivalent() throws IOException { + assertEquivalent( + "recommended_whisky.json", + List.of( + map( + "source", "BOTTLE_NOTE", + "alcohol", map("alcoholId", 1, "korName", "테스트", "selectedTags", List.of("셰리")), + "comment", "추천 코멘트"))); + } + + @Test + @DisplayName("WHISKY_PAIRING은 feed_payload로 투영해도 원본 투영과 같다") + void projectPayload_whenWhiskyPairing_isEquivalent() throws IOException { + assertEquivalent( + "whisky_pairing.json", + List.of( + map( + "source", + "BOTTLE_NOTE", + "alcohol", + map("alcoholId", 7, "korName", "페어링 위스키"), + "comment", + "페어링 코멘트", + "pairings", + List.of(map("itemName", "티라미수", "pairingNote", "단맛과 어울림"))))); + } + + @Test + @DisplayName("WHISKY_TASTING_EVENT는 feed_payload로 투영해도 원본 투영과 같다") + void projectPayload_whenTastingEvent_isEquivalent() throws IOException { + assertEquivalent( + "whisky_tasting_event.json", + map( + "eventDate", + "2026-06-21", + "eventTime", + "19:00", + "barAddress", + "서울시 강남구 테스트로 1", + "isRecruiting", + true, + "entryFee", + 50000, + "capacity", + 12, + "guideText", + "시음회 안내", + "alcohols", + List.of(map("source", "BOTTLE_NOTE", "alcohol", map("alcoholId", 1))))); + } + + @Test + @DisplayName("PROGRAM은 feed_payload로 투영해도 원본 투영과 같다") + void projectPayload_whenProgram_isEquivalent() throws IOException { + assertEquivalent( + "program.json", + map( + "eventStartDate", "2026-07-24", + "placeName", "코엑스", + "address", "서울 강남구 영동대로 513", + "programTags", List.of("위스키"), + "programs", + List.of( + map( + "name", "오프닝", + "type", "TALK", + "programDate", "2026-07-24", + "startTime", "13:00", + "venue", "A홀")))); + } + + @Test + @DisplayName("feed_payload가 NULL이면 원본 payload를 소스로 쓴다") + void feedSource_whenFeedPayloadIsNull_fallsBackToPayload() { + Object payload = map("title", "제목"); + CurationExtension extension = + CurationExtension.builder().curationId(1L).specId(1L).payload(payload).build(); + + assertThat(extension.feedSource()).isSameAs(payload); + } + + @Test + @DisplayName("feed_payload가 비어 있어도 NULL이 아니면 그것을 소스로 쓴다") + void feedSource_whenFeedPayloadIsEmpty_usesFeedPayload() { + Object emptyFeedPayload = List.of(); + CurationExtension extension = + CurationExtension.builder() + .curationId(1L) + .specId(1L) + .payload(map("title", "제목")) + .feedPayload(emptyFeedPayload) + .build(); + + assertThat(extension.feedSource()).isSameAs(emptyFeedPayload); + } + + @Test + @DisplayName("숨은 입력값은 feed_payload에 있어도 투영 후 응답에 남지 않는다") + void projectPayload_whenHiddenInputExists_isNotExposed() { + Map responseSpec = + map( + "type", + "object", + "properties", + map( + "title", map("type", "string", "x-feed", map("enabled", true)), + "alcoholId", map("type", "integer"), + "stats", + map( + "type", "object", + "x-feed", map("enabled", true), + "x-graphql", + map("query", "alcohols", "argFrom", "$.alcoholId", "writeTo", "stats"), + "properties", map("rating", map("type", "number", "x-graphql", true))))); + Object payload = map("title", "제목", "alcoholId", 42); + + JsonNode feedPayload = + OBJECT_MAPPER.valueToTree(projector.extractFeedPayload(responseSpec, payload)); + JsonNode projected = + OBJECT_MAPPER.valueToTree( + projector.projectPayload( + responseSpec, OBJECT_MAPPER.convertValue(feedPayload, Object.class))); + + assertThat(feedPayload.path("alcoholId").asLong()).isEqualTo(42L); + assertThat(projected.has("alcoholId")).isFalse(); + assertThat(projected.path("title").asText()).isEqualTo("제목"); + } + + @Test + @DisplayName("현행 스펙에는 피드와 교차하는 x-graphql이 없다 — 전환 동등성의 전제다") + void shippedSpecs_haveNoFeedIntersectingGraphQLEntry() throws IOException { + for (String resourceName : + List.of( + "recommended_whisky.json", + "whisky_pairing.json", + "whisky_tasting_event.json", + "program.json")) { + JsonNode rootSchema = + CurationFeedPaths.rootSchema(OBJECT_MAPPER.valueToTree(schema(resourceName))); + Set feedPaths = CurationFeedPaths.collect(rootSchema); + List intersecting = new ArrayList<>(); + collectFeedIntersectingGraphQLPaths(rootSchema, "", feedPaths, intersecting); + + assertThat(intersecting) + .withFailMessage( + """ + %s 에 피드와 교차하는 x-graphql 엔트리가 생겼다: %s + 이 경우 원본 경로는 인자가 비어도 writeTo에 null/[]을 채워 배열 원소를 살리지만, + feed_payload 경로는 추출 단계에서 그 원소를 버려 응답이 갈릴 수 있다. + 읽기 경로 전환의 동등성을 다시 검토해야 한다.""", + resourceName, intersecting) + .isEmpty(); + } + } + + private void collectFeedIntersectingGraphQLPaths( + JsonNode schema, String path, Set feedPaths, List found) { + if (schema == null || !schema.isObject()) { + return; + } + JsonNode meta = schema.get("x-graphql"); + if (meta != null && meta.isObject() && meta.has("query")) { + if (CurationFeedPaths.intersectsFeed(feedPaths, path)) { + found.add(path); + } + return; + } + JsonNode properties = schema.get("properties"); + if (properties != null && properties.isObject()) { + properties + .properties() + .forEach( + entry -> + collectFeedIntersectingGraphQLPaths( + entry.getValue(), + CurationFeedPaths.join(path, entry.getKey()), + feedPaths, + found)); + } + JsonNode items = schema.get("items"); + if (items != null) { + collectFeedIntersectingGraphQLPaths(items, path, feedPaths, found); + } + } + + // 실제 피드 파이프라인(소스 → materializeFeed → projectPayload)을 두 소스로 각각 돌려 비교한다. + private void assertEquivalent(String resourceName, Object payload) throws IOException { + Map responseSpec = schema(resourceName); + CurationResponseMaterializer materializer = materializer(); + + JsonNode fromSource = feedResponseOf(materializer, responseSpec, payload); + Object feedPayload = projector.extractFeedPayload(responseSpec, payload); + JsonNode fromFeedPayload = feedResponseOf(materializer, responseSpec, feedPayload); + + assertThat(fromFeedPayload).isEqualTo(fromSource); + } + + private JsonNode feedResponseOf( + CurationResponseMaterializer materializer, Map responseSpec, Object source) { + Object materialized = materializer.materializeFeed(1L, "TEST", responseSpec, source); + return OBJECT_MAPPER.valueToTree(projector.projectPayload(responseSpec, materialized)); + } + + // 현재 4개 스펙에는 피드와 교차하는 x-graphql 엔트리가 없다. 실행되면 그 전제가 깨진 것이므로 실패시킨다. + private CurationResponseMaterializer materializer() { + return new CurationResponseMaterializer( + OBJECT_MAPPER, + new GraphQLCurationQueryBuilder(), + (curationId, index, query) -> { + throw new AssertionError("피드 경로에서 GraphQL이 실행되면 안 된다: " + query.entryField()); + }, + new CurationPayloadValidator(OBJECT_MAPPER)); + } + + private static Map schema(String resourceName) throws IOException { + JsonNode root = + OBJECT_MAPPER.readTree( + new ClassPathResource("openapi/curation/" + resourceName).getInputStream()); + JsonNode schema = + root.path("components").path("schemas").properties().stream() + .filter(entry -> entry.getKey().endsWith("Response")) + .findFirst() + .map(Map.Entry::getValue) + .orElseThrow(); + return OBJECT_MAPPER.convertValue(schema, MAP_TYPE); + } + + private static Map map(Object... values) { + Map map = new LinkedHashMap<>(); + for (int i = 0; i < values.length; i += 2) { + map.put((String) values[i], values[i + 1]); + } + return map; + } +} diff --git a/bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationPlaceFieldContractTest.java b/bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationPlaceFieldContractTest.java new file mode 100644 index 000000000..d869f356b --- /dev/null +++ b/bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationPlaceFieldContractTest.java @@ -0,0 +1,171 @@ +package app.bottlenote.curation.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import app.bottlenote.curation.service.CurationPayloadValidator.MapBackedSchema; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +// 이슈 #344 장소검색 계약. 기존 데이터 회귀와 피드 노출 여부를 함께 고정한다. +@Tag("unit") +@DisplayName("큐레이션 장소 필드 계약 단위 테스트") +class CurationPlaceFieldContractTest { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final TypeReference> MAP_TYPE = new TypeReference<>() {}; + private static final String TASTING = "whisky_tasting_event.json"; + private static final String PROGRAM = "program.json"; + + private final CurationPayloadValidator validator = new CurationPayloadValidator(OBJECT_MAPPER); + private final CurationFeedProjector projector = new CurationFeedProjector(OBJECT_MAPPER); + + @Test + @DisplayName("장소검색이 필요한 필드는 address-search 렌더링 계약을 쓴다") + void requestSpec_placeSearchFields_useAddressSearchStyle() throws IOException { + JsonNode tasting = OBJECT_MAPPER.valueToTree(schema(TASTING, "Request")).path("properties"); + assertThat(tasting.path("placeName").path("x-field-style").asText()) + .isEqualTo("address-search"); + assertThat(tasting.path("zipCode").path("x-field-style").asText()).isEqualTo("address-search"); + + JsonNode program = OBJECT_MAPPER.valueToTree(schema(PROGRAM, "Request")).path("properties"); + assertThat(program.path("placeName").path("x-field-style").asText()) + .isEqualTo("address-search"); + assertThat(program.path("address").path("x-field-style").asText()).isEqualTo("address-search"); + assertThat(program.path("zipCode").path("x-field-style").asText()).isEqualTo("address-search"); + } + + @Test + @DisplayName("placeName과 zipCode는 optional이라 기존 required 계약을 늘리지 않는다") + void requestSpec_newFields_areOptional() throws IOException { + JsonNode required = OBJECT_MAPPER.valueToTree(schema(TASTING, "Request")).path("required"); + + assertThat(required).hasSize(8); + assertThat(required.toString()).doesNotContain("placeName").doesNotContain("zipCode"); + } + + @Test + @DisplayName("placeName이 없는 기존 시음회 payload도 그대로 검증을 통과한다") + void validate_whenLegacyPayloadHasNoPlaceName_passes() throws IOException { + var errors = + validator.validate(new MapBackedSchema(schema(TASTING, "Request")), legacyPayload()); + + assertThat(errors).isEmpty(); + } + + @Test + @DisplayName("zipCode가 5자가 아니면 검증이 거부한다") + void validate_whenZipCodeLengthIsNotFive_rejects() throws IOException { + Map payload = legacyPayload(); + payload.put("zipCode", "123"); + + var errors = validator.validate(new MapBackedSchema(schema(TASTING, "Request")), payload); + + assertThat(errors).isNotEmpty(); + } + + @Test + @DisplayName("placeName이 responseSpec 검증 대상이 되어도 기존 저장값이 상세 경로에서 통과한다") + void validateResponse_whenLegacyPlaceNameExists_passes() throws IOException { + // responseSpec에 placeName이 새로 들어가면서 상세 경로(materialize)의 검증 대상이 됐다. + Map payload = legacyPayload(); + payload.put("placeName", "도시술"); + + var errors = validator.validate(new MapBackedSchema(schema(TASTING, "Response")), payload); + + assertThat(errors).isEmpty(); + } + + @Test + @DisplayName("placeName이 100자를 넘으면 상세 경로 검증이 거부한다") + void validateResponse_whenPlaceNameTooLong_rejects() throws IOException { + Map payload = legacyPayload(); + payload.put("placeName", "가".repeat(101)); + + var errors = validator.validate(new MapBackedSchema(schema(TASTING, "Response")), payload); + + assertThat(errors).isNotEmpty(); + } + + @Test + @DisplayName("placeName의 피드 정렬 위치는 시간(20)과 주소(30) 사이다") + void responseSpec_placeNameFeedOrder_sitsBetweenTimeAndAddress() throws IOException { + JsonNode properties = OBJECT_MAPPER.valueToTree(schema(TASTING, "Response")).path("properties"); + JsonNode placeName = properties.path("placeName").path("x-feed"); + + assertThat(placeName.path("enabled").asBoolean()).isTrue(); + assertThat(placeName.path("role").asText()).isEqualTo("location"); + assertThat(placeName.path("order").asInt()) + .isGreaterThan(properties.path("eventTime").path("x-feed").path("order").asInt()) + .isLessThan(properties.path("barAddress").path("x-feed").path("order").asInt()); + } + + @Test + @DisplayName("피드에는 placeName이 실리고 zipCode는 실리지 않는다") + void extractFeedPayload_exposesPlaceNameButNotZipCode() throws IOException { + Map payload = legacyPayload(); + payload.put("placeName", "보틀노트 테이스팅룸"); + payload.put("zipCode", "06236"); + + JsonNode feedPayload = + OBJECT_MAPPER.valueToTree( + projector.extractFeedPayload(schema(TASTING, "Response"), payload)); + + assertThat(feedPayload.path("placeName").asText()).isEqualTo("보틀노트 테이스팅룸"); + assertThat(feedPayload.has("zipCode")).isFalse(); + } + + @Test + @DisplayName("네 스펙 문서 모두 스펙 자체 검증을 통과한다") + void validateSpec_allSchemas_areValid() throws IOException { + for (String resource : List.of(TASTING, PROGRAM)) { + for (String suffix : List.of("Request", "Response")) { + assertThat( + validator.validateSpec( + resource + "#" + suffix, new MapBackedSchema(schema(resource, suffix)))) + .isEmpty(); + } + } + } + + private static Map legacyPayload() { + Map payload = new LinkedHashMap<>(); + payload.put("eventDate", "2026-06-21"); + payload.put("eventTime", "19:00"); + payload.put("barAddress", "서울 강남구 테헤란로 123"); + payload.put("detailAddress", "2층"); + payload.put("entryFee", 50000); + payload.put("capacity", 12); + payload.put("guideText", "시음회 안내"); + payload.put( + "alcohols", + List.of( + Map.of( + "source", + "MANUAL", + "alcohol", + Map.of("korName", "테스트 위스키", "selectedTags", List.of("셰리"))))); + return payload; + } + + private static Map schema(String resourceName, String suffix) throws IOException { + JsonNode root = + OBJECT_MAPPER.readTree( + new ClassPathResource("openapi/curation/" + resourceName).getInputStream()); + return OBJECT_MAPPER.convertValue( + root.path("components").path("schemas").properties().stream() + .filter(entry -> entry.getKey().endsWith(suffix)) + .findFirst() + .orElseThrow() + .getValue(), + MAP_TYPE); + } +} diff --git a/bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationSpecResourceSyncServiceTest.java b/bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationSpecResourceSyncServiceTest.java index b7e49936f..4fc5981ea 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationSpecResourceSyncServiceTest.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationSpecResourceSyncServiceTest.java @@ -5,10 +5,15 @@ import app.bottlenote.curation.domain.CurationSpec; import app.bottlenote.curation.dto.response.CurationSpecSyncResponse; import app.bottlenote.curation.fixture.InMemoryCurationSpecRepository; +import app.bottlenote.curation.support.CurationSpecFingerprint; import app.bottlenote.curation.support.CurationSpecResourceReader; import app.bottlenote.curation.support.CurationSpecResourceReader.CurationSpecResourceDocument; import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; @@ -27,7 +32,10 @@ class CurationSpecResourceSyncServiceTest { new CurationSpecResourceReader(new PathMatchingResourcePatternResolver(), objectMapper); CurationSpecResourceSyncService service = new CurationSpecResourceSyncService( - curationSpecRepository, resourceReader, new CurationPayloadValidator(objectMapper)); + curationSpecRepository, + resourceReader, + new CurationPayloadValidator(objectMapper), + new CurationSpecFingerprint(objectMapper)); List specDocuments = resourceReader.readAll(); List specCodes = specDocuments.stream().map(CurationSpecResourceDocument::code).toList(); @@ -59,4 +67,101 @@ class CurationSpecResourceSyncServiceTest { .extracting(CurationSpec::getCode) .containsExactlyElementsOf(specCodes); } + + @Test + @DisplayName("최초 생성 시에는 큐레이션이 없으므로 변경 스펙으로 잡지 않는다") + void sync_최초_생성은_변경으로_보지_않는다() { + CurationSpecSyncResponse result = newService(new InMemoryCurationSpecRepository()).sync(); + + assertThat(result.createdCount()).isPositive(); + assertThat(result.changedSpecIds()).isEmpty(); + assertThat(result.hasChangedSpecs()).isFalse(); + } + + @Test + @DisplayName("responseSpec이 그대로면 재동기화해도 변경 스펙이 없다") + void sync_responseSpec_동일하면_변경_없음() { + InMemoryCurationSpecRepository repository = new InMemoryCurationSpecRepository(); + CurationSpecResourceSyncService service = newService(repository); + service.sync(); + + CurationSpecSyncResponse result = service.sync(); + + assertThat(result.updatedCount()).isPositive(); + assertThat(result.changedSpecIds()).isEmpty(); + } + + @Test + @DisplayName("responseSpec이 바뀐 스펙만 변경 목록에 담긴다") + void sync_responseSpec_바뀐_스펙만_담긴다() { + InMemoryCurationSpecRepository repository = new InMemoryCurationSpecRepository(); + CurationSpecResourceSyncService service = newService(repository); + service.sync(); + CurationSpec target = repository.findByCode("RECOMMENDED_WHISKY").orElseThrow(); + Map tampered = new LinkedHashMap<>(target.getResponseSpec()); + tampered.put("x-tampered", true); + target.update( + target.getName(), + target.getDescription(), + target.getRequestSpec(), + tampered, + target.getHydratorKey(), + target.getVersion(), + true); + + CurationSpecSyncResponse result = service.sync(); + + assertThat(result.changedSpecIds()).containsExactly(target.getId()); + } + + @Test + @DisplayName("키 순서와 수치 표기만 다른 responseSpec은 동일하게 판정한다") + void sync_키순서와_수치표기_차이는_동일하다() { + InMemoryCurationSpecRepository repository = new InMemoryCurationSpecRepository(); + CurationSpecResourceSyncService service = newService(repository); + service.sync(); + CurationSpec target = repository.findByCode("RECOMMENDED_WHISKY").orElseThrow(); + target.update( + target.getName(), + target.getDescription(), + target.getRequestSpec(), + reorderedCopy(target.getResponseSpec()), + target.getHydratorKey(), + target.getVersion(), + true); + + CurationSpecSyncResponse result = service.sync(); + + assertThat(result.changedSpecIds()).isEmpty(); + } + + // 키를 역순으로 다시 담고 정수를 실수 표기로 바꾼다. MySQL·Jackson 왕복에서 생기는 흔들림을 흉내낸다. + private static Map reorderedCopy(Map source) { + List keys = new ArrayList<>(source.keySet()); + Collections.reverse(keys); + Map copy = new LinkedHashMap<>(); + for (String key : keys) { + Object value = source.get(key); + if (value instanceof Map nested) { + @SuppressWarnings("unchecked") + Map casted = (Map) nested; + copy.put(key, reorderedCopy(casted)); + } else if (value instanceof Integer intValue) { + copy.put(key, intValue.doubleValue()); + } else { + copy.put(key, value); + } + } + return copy; + } + + private static CurationSpecResourceSyncService newService( + InMemoryCurationSpecRepository repository) { + ObjectMapper objectMapper = new ObjectMapper(); + return new CurationSpecResourceSyncService( + repository, + new CurationSpecResourceReader(new PathMatchingResourcePatternResolver(), objectMapper), + new CurationPayloadValidator(objectMapper), + new CurationSpecFingerprint(objectMapper)); + } } diff --git a/bottlenote-mono/src/test/java/app/bottlenote/curation/support/CurationSpecFingerprintTest.java b/bottlenote-mono/src/test/java/app/bottlenote/curation/support/CurationSpecFingerprintTest.java new file mode 100644 index 000000000..d912df2e8 --- /dev/null +++ b/bottlenote-mono/src/test/java/app/bottlenote/curation/support/CurationSpecFingerprintTest.java @@ -0,0 +1,106 @@ +package app.bottlenote.curation.support; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.math.BigDecimal; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +@Tag("unit") +@DisplayName("CurationSpecFingerprint 단위 테스트") +class CurationSpecFingerprintTest { + + private final CurationSpecFingerprint fingerprint = + new CurationSpecFingerprint(new ObjectMapper()); + + @Test + @DisplayName("최상위와 중첩 객체의 키 순서가 달라도 같은 지문이다") + void isSame_whenKeyOrderDiffers_treatsAsSame() { + Map left = map("b", map("y", 1, "x", 2), "a", 3); + Map right = map("a", 3, "b", map("x", 2, "y", 1)); + + assertThat(fingerprint.isSame(left, right)).isTrue(); + } + + @Test + @DisplayName("배열 안 객체의 키 순서가 달라도 같은 지문이다") + void isSame_whenKeyOrderDiffersInsideArray_treatsAsSame() { + Map left = map("items", List.of(map("role", "title", "enabled", true))); + Map right = map("items", List.of(map("enabled", true, "role", "title"))); + + assertThat(fingerprint.isSame(left, right)).isTrue(); + } + + @Test + @DisplayName("배열 원소 순서가 다르면 다른 지문이다") + void isSame_whenArrayOrderDiffers_treatsAsDifferent() { + Map left = map("required", List.of("source", "alcohol")); + Map right = map("required", List.of("alcohol", "source")); + + assertThat(fingerprint.isSame(left, right)).isFalse(); + } + + @Test + @DisplayName("정수와 정수로 표현 가능한 실수는 같은 지문이다") + void isSame_whenIntegerAndWholeDecimal_treatsAsSame() { + Map left = map("order", 1); + Map right = map("order", 1.0); + + assertThat(fingerprint.isSame(left, right)).isTrue(); + } + + @Test + @DisplayName("실수의 trailing zero 차이는 같은 지문이다") + void isSame_whenTrailingZeroDiffers_treatsAsSame() { + Map left = map("abv", new BigDecimal("1.20")); + Map right = map("abv", new BigDecimal("1.2")); + + assertThat(fingerprint.isSame(left, right)).isTrue(); + } + + @Test + @DisplayName("null 값과 키 부재는 다른 지문이다") + void isSame_whenNullValueVersusAbsentKey_treatsAsDifferent() { + Map withNull = map("nullable", null); + Map withoutKey = map(); + + assertThat(fingerprint.isSame(withNull, withoutKey)).isFalse(); + } + + @Test + @DisplayName("빈 객체와 빈 배열은 다른 지문이다") + void isSame_whenEmptyObjectVersusEmptyArray_treatsAsDifferent() { + assertThat(fingerprint.isSame(map("properties", map()), map("properties", List.of()))) + .isFalse(); + } + + @Test + @DisplayName("값이 하나라도 바뀌면 다른 지문이다") + void isSame_whenValueChanges_treatsAsDifferent() { + Map left = map("x-feed", map("enabled", true)); + Map right = map("x-feed", map("enabled", false)); + + assertThat(fingerprint.isSame(left, right)).isFalse(); + } + + @Test + @DisplayName("같은 입력에 대해 지문이 항상 같다") + void of_whenSameInput_isStable() { + Map spec = map("a", map("b", List.of(1, map("c", "d")))); + + assertThat(fingerprint.of(spec)).isEqualTo(fingerprint.of(spec)); + } + + private static Map map(Object... values) { + Map map = new LinkedHashMap<>(); + for (int i = 0; i < values.length; i += 2) { + map.put((String) values[i], values[i + 1]); + } + return map; + } +} diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/curation/integration/ProductSpecBasedCurationIntegrationTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/curation/integration/ProductSpecBasedCurationIntegrationTest.java index 94c733f8e..31b0e0078 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/curation/integration/ProductSpecBasedCurationIntegrationTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/curation/integration/ProductSpecBasedCurationIntegrationTest.java @@ -194,6 +194,48 @@ void searchFeed_returnsDetailShapeWithoutSpecAndKeepsXFeedPayloadStructure() thr assertThat(payloadItem.path("comment").asText()).isEqualTo("테스트 코멘트"); } + @Test + @DisplayName("feed_payload가 저장된 큐레이션과 NULL인 레거시 큐레이션의 피드 응답이 같다") + void searchFeed_whenFeedPayloadIsNull_fallsBackToSourceAndMatches() throws Exception { + // given - 같은 payload로 하나는 feed_payload 저장, 하나는 레거시(NULL) + List> payload = List.of(manualItem("동등성 위스키")); + Long migratedId = createCuration("전환 큐레이션", 1, true, payload); + Long legacyId = + createLegacyCuration( + "레거시 큐레이션", + 2, + true, + LocalDate.now().minusDays(1), + LocalDate.now().plusDays(1), + payload); + assertThat( + curationExtensionRepository + .findByCurationId(migratedId) + .orElseThrow() + .getFeedPayload()) + .isNotNull(); + assertThat( + curationExtensionRepository.findByCurationId(legacyId).orElseThrow().getFeedPayload()) + .isNull(); + + // when + MvcTestResult result = + mockMvcTester + .get() + .uri("/api/v2/curations/feed?code=RECOMMENDED_WHISKY&size=10") + .contentType(APPLICATION_JSON) + .exchange(); + + // then + JsonNode items = dataNode(result).path("items"); + JsonNode migrated = itemById(items, migratedId); + JsonNode legacy = itemById(items, legacyId); + assertThat(legacy.path("payload")).isEqualTo(migrated.path("payload")); + assertThat(legacy.path("payload").get(0).path("alcohol").path("korName").asText()) + .isEqualTo("동등성 위스키"); + assertThat(legacy.path("payload").get(0).has("source")).isFalse(); + } + @Test @DisplayName("Product feed는 시음회 상세 라인업처럼 x-feed가 없는 배열 payload를 제외한다") void searchFeed_whenTastingEventAlcoholsHasNoXFeed_excludesAlcoholsPayload() throws Exception { @@ -441,6 +483,15 @@ void getDetail_whenMissingCuration_returnsNotFound() { } } + private static JsonNode itemById(JsonNode items, Long curationId) { + for (JsonNode item : items) { + if (item.path("id").asLong() == curationId) { + return item; + } + } + throw new AssertionError("피드에 큐레이션이 없습니다: " + curationId); + } + private Long createCuration( String name, int displayOrder, boolean active, List> payload) { return createCuration(name, "통합 테스트 큐레이션", displayOrder, active, payload); diff --git a/bottlenote-test-support/src/main/java/app/bottlenote/curation/fixture/CurationFixtureFactory.java b/bottlenote-test-support/src/main/java/app/bottlenote/curation/fixture/CurationFixtureFactory.java index 5b7b7b81c..59b642289 100644 --- a/bottlenote-test-support/src/main/java/app/bottlenote/curation/fixture/CurationFixtureFactory.java +++ b/bottlenote-test-support/src/main/java/app/bottlenote/curation/fixture/CurationFixtureFactory.java @@ -63,6 +63,11 @@ public CurationSpec saveSpec( } public Curation saveCuration(CurationCreateRequest request) { + return saveCuration(request, true); + } + + // withFeedPayload=false는 backfill 이전 레거시 행을 재현한다. 읽기 경로의 NULL fallback 검증용이다. + public Curation saveCuration(CurationCreateRequest request, boolean withFeedPayload) { Curation saved = curationRepository.save( Curation.builder() @@ -82,7 +87,7 @@ public Curation saveCuration(CurationCreateRequest request) { .curationId(saved.getId()) .specId(request.specId()) .payload(request.payload()) - .feedPayload(feedPayloadOf(request)) + .feedPayload(withFeedPayload ? feedPayloadOf(request) : null) .build()); return saved; } diff --git a/bottlenote-test-support/src/main/java/app/bottlenote/curation/fixture/InMemoryCurationExtensionRepository.java b/bottlenote-test-support/src/main/java/app/bottlenote/curation/fixture/InMemoryCurationExtensionRepository.java index 3a09efbe3..21a9841b7 100644 --- a/bottlenote-test-support/src/main/java/app/bottlenote/curation/fixture/InMemoryCurationExtensionRepository.java +++ b/bottlenote-test-support/src/main/java/app/bottlenote/curation/fixture/InMemoryCurationExtensionRepository.java @@ -24,6 +24,13 @@ public List findAllByCurationIdIn(Collection curationId .toList(); } + @Override + public List findAllBySpecIdIn(Collection specIds) { + return database.values().stream() + .filter(extension -> specIds.contains(extension.getSpecId())) + .toList(); + } + @Override public CurationExtension save(CurationExtension curationExtension) { database.put(curationExtension.getCurationId(), curationExtension); diff --git a/plan/complete/curation-feed-read-path.md b/plan/complete/curation-feed-read-path.md new file mode 100644 index 000000000..0fdaac30c --- /dev/null +++ b/plan/complete/curation-feed-read-path.md @@ -0,0 +1,269 @@ +``` +================================================================================ + PROJECT COMPLETION STAMP +================================================================================ +Status: **COMPLETED** +Completion Date: 2026-07-28 + +** Core Achievements ** +- responseSpec 변경 감지 (canonical JSON + SHA-256, 스키마 변경 없음) +- 변경된 스펙의 feed_payload 재생성 (NULL 레거시 포함 backfill 겸용, 공유 락, 무효화 선행) +- 피드 조회 소스를 feed_payload로 전환 (NULL이면 원본 fallback), Product 피드 + Admin 프리뷰 +- 전 단계 PASS: unit 493건 / rule 63건 / integration 268건 / admin integration 209건, 실패 0 + +** Key Components ** +- curation/support/CurationSpecFingerprint.java (canonical JSON 지문) +- curation/service/CurationFeedPayloadRegenerationService.java (무효화·재생성) +- curation/service/CurationFeedPayloadRefreshService.java (잠금·순서·실패 정책) +- curation/domain/CurationExtension.feedSource() (NULL fallback) +- curation/service/CurationFeedSourceEquivalenceTest.java (전환 동등성 + 트립와이어) + +** Deferred Items ** +- x-feed 스펙 보정: 프론트 협의 후 별도 PR (피드 응답 payload는 API 계약) +- 재생성 중 어드민 저장 경합 시 파생값 세대 불일치: @Version 필요, 스키마 변경 범위 밖 +- 재생성 트랜잭션 chunk 처리: 수천 건 규모부터 필요 +================================================================================ +``` + +# Plan: 큐레이션 피드 읽기 경로 전환 및 스펙 변경 시 재생성 + +## Overview + +이슈 bottle-note/workspace#322 (큐레이션 피드 Read Model 분리 ADR)의 **읽기 경로**를 +구현하고, 스펙이 바뀌었을 때 저장된 Read Model이 낡지 않도록 재생성 장치를 넣는다. + +PR #677로 `curation_extension.feed_payload` 쓰기 경로는 확보됐다. 다만 지금은 +아무도 그 값을 읽지 않고, 스펙 JSON의 `x-feed`가 바뀌면 저장된 값이 조용히 +낡는 상태다. 이 둘을 함께 해결한다. + +세 덩어리다. + +1. **변경 감지** — `CurationSpecResourceSyncService.sync()`가 지금은 기존 스펙을 + 비교 없이 매 기동 무조건 `update()`한다. `responseSpec`이 실제로 바뀌었는지 + 판별할 수단을 넣는다. +2. **재생성** — `responseSpec`이 바뀐 스펙에 한해, 그 스펙으로 저장된 큐레이션의 + `feed_payload`를 다시 만든다. `feed_payload`가 NULL인 레거시 행도 대상에 + 포함하므로 backfill을 겸한다. +3. **읽기 경로 전환** — Product 피드와 Admin 피드 프리뷰가 원본 `payload` 대신 + `feed_payload`를 소스로 쓴다. `feed_payload`가 NULL이면 원본에서 파싱한다. + +### Assumptions + +- 신규 PR은 `main` 기준 새 브랜치로 낸다. PR #677 위에 쌓지 않는다. + (#677이 먼저 머지되는 것을 전제한다) +- 스키마 변경이 없다. 변경 감지는 `update()` 직전에 DB의 기존 `responseSpec`을 + 읽어 그 자리에서 해시를 내고 비교하므로 해시 저장용 컬럼이 필요 없다. + 따라서 Flyway 마이그레이션도 없다. +- 변경 감지 기준은 `responseSpec`만이다. `name`, `description`, `requestSpec`, + `hydratorKey`, `version`만 바뀐 경우는 재생성하지 않는다. `feed_payload` + 추출에 영향을 주는 것은 `responseSpec`뿐이기 때문이다. +- 비교는 정규화 후 해시로 한다. 단순 문자열 비교는 쓸 수 없다 — MySQL JSON + 컬럼이 객체 키를 자체 정렬하고, Jackson 직렬화 공백·수치 표기가 경로마다 + 달라 매번 "변경됨"으로 오판한다. 키를 재귀 정렬하고 공백을 제거한 canonical + JSON을 SHA-256으로 비교한다. +- 재생성은 admin-api 기동 시점(`CurationSpecResourceSyncRunner`)에서만 돈다. + product-api에는 러너를 추가하지 않는다. +- 재생성 대상은 변경된 스펙에 속한 **모든** 큐레이션이다. `feed_payload`가 + NULL인 레거시 행도 포함한다. 즉 backfill을 겸한다. +- 다중 인스턴스 동시 기동 시 Redis 분산 락으로 한 인스턴스만 재생성한다. + 락을 얻지 못한 인스턴스는 건너뛰고 정상 기동한다. JVM 로컬 상태는 쓰지 않는다. +- 재생성 중 예외가 나면 경고 로그만 남기고 기동을 계속한다. `feed_payload`는 + 파생 데이터이고 NULL fallback이 있으므로 서비스 전체를 막지 않는다. +- 읽기 경로 전환 대상은 Product 피드와 Admin 피드 프리뷰 **둘 다**다. + 프리뷰의 목적이 사용자가 볼 화면을 미리 확인하는 것이므로 같은 소스를 봐야 한다. +- Product **상세** API는 전환하지 않는다. 상세는 원본 `payload`에 전체 GraphQL + 보강을 적용하는 별도 경로이며 `x-feed`와 무관하다. +- 전환 후에도 파이프라인 형태는 유지한다: `소스 → materializeFeed → + projectPayload`. `projectPayload`를 계속 통과시켜야 `feed_payload`에 섞인 + 숨은 입력값(x-feed가 아닌 argFrom 값)이 응답에 노출되지 않는다. +- `x-feed` 스펙 보정은 이번 범위에서 **제외**한다. 피드 응답 `payload`는 API + 계약이라 좁히면 breaking change이며 프론트 협의가 선행되어야 한다. 이번에 + 3번이 들어가면, 협의 후 스펙 JSON만 고쳐도 재생성이 자동으로 반영한다. + +### Success Criteria + +- `responseSpec`이 바뀌지 않은 채 재기동하면 재생성이 **0건** 수행된다. + (현재는 판별 자체가 불가능하다) +- 스펙 JSON의 `x-feed`를 바꾸고 재기동하면 해당 스펙의 큐레이션만 + `feed_payload`가 갱신되고, 다른 스펙의 행은 그대로다. +- 재생성 후 `feed_payload`가 NULL인 행이 남지 않는다 (해당 스펙 범위 내). +- 피드 응답이 전환 전후로 동일하다. 4개 스펙 각각에 대해 원본 경로 결과와 + `feed_payload` 경로 결과가 같음을 테스트로 보인다. +- `feed_payload`가 NULL인 큐레이션도 피드에 정상 노출된다 (원본 fallback). +- 피드 응답에 숨은 입력값(x-feed가 아닌 argFrom 값)이 노출되지 않는다. +- 재생성이 실패해도 애플리케이션이 정상 기동한다. +- 락을 얻지 못한 인스턴스는 재생성을 건너뛰고 정상 기동한다. + +### Impact Scope + +- **mono**: `CurationSpecResourceSyncService`(변경 감지), + `CurationSpecSyncResponse`(변경된 스펙 식별자 전달), 재생성 서비스 신규, + `ProductSpecBasedCurationService`·`AdminSpecBasedCurationService`(읽기 경로 전환). + 정규화·해시 유틸 신규. +- **admin-api**: `CurationSpecResourceSyncRunner`가 동기화 후 재생성을 호출. +- **product-api**: 코드 변경 없음. 피드 응답 동작만 바뀐다 (계약은 불변). +- **test-support**: `CurationFixtureFactory`가 NULL fallback 케이스를 만들 수 + 있어야 한다 (feed_payload 없는 픽스처). +- **스키마**: 변경 없음. Flyway 마이그레이션 없음. +- **Redis**: 재생성 락 키 추가. 기존 `RedisConfig` 사용. + +## Execution Mode + +- mode: delegated +- scope: plan, implement, test, verify, commit, push, pr +- 구현·리뷰에 codex CLI(`codex exec`, `codex exec review`)를 적극 활용한다. + 특히 작성자와 리뷰어가 분리되도록 Task 구현 후 독립 리뷰를 codex에 맡긴다. +- stop-conditions: 기본 3종 (① 가정 붕괴 시 재개봉 프로토콜, ② /verify 3회 + 실패 시 /debug 보고 후 정지, ③ scope 밖 되돌리기 어려운 행동 전 확인) + +## Tasks + +### Task 1: responseSpec 변경 감지 +- Acceptance: `sync()`가 기존 스펙의 `responseSpec`과 리소스의 `responseSpec`을 + canonical JSON(키 재귀 정렬 + 공백 제거) SHA-256으로 비교해, 실제로 바뀐 + 스펙만 식별한다. 키 순서·공백·수치 표기 차이는 "동일"로 판정한다. + `CurationSpecSyncResponse`가 변경된 스펙 식별자를 함께 반환한다. + 신규 생성 스펙은 변경으로 취급하지 않는다 (큐레이션이 아직 없다). +- Verification: `./gradlew :bottlenote-mono:test --tests '*CurationSpecResourceSync*'` +- Files (advisory): canonical/해시 유틸 신규, `CurationSpecResourceSyncService.java`, + `CurationSpecSyncResponse.java`, 관련 테스트 +- Depends: 없음 +- Size: M +- Status: [x] done + +### Task 2: 스펙 단위 큐레이션 조회와 feed_payload 재생성 +- Acceptance: `CurationExtensionRepository`에 specId 기준 조회가 추가되고, + 주어진 스펙의 모든 큐레이션 `feed_payload`를 `extractFeedPayload`로 다시 + 만들어 저장하는 서비스가 생긴다. `feed_payload`가 NULL인 레거시 행도 + 대상에 포함한다(backfill 겸용). 다른 스펙의 행은 건드리지 않는다. +- Verification: `./gradlew :bottlenote-mono:test --tests '*Regenerat*'` +- Files (advisory): `CurationExtensionRepository.java`, + `JpaCurationExtensionRepository.java`, 재생성 서비스 신규, + `InMemoryCurationExtensionRepository.java`, 관련 테스트 +- Depends: 없음 (Task 1과 병렬 가능 — 서비스는 specId 목록만 입력받는다) +- Size: M +- Status: [x] done + +### Checkpoint: after Tasks 1-2 +- [ ] 컴파일 통과 / 단위 테스트 통과 / ArchUnit 룰 통과 + +### Task 3: 기동 시 재생성 실행과 중복 억제 +- Acceptance: admin-api 러너가 동기화 직후, 변경된 스펙이 있을 때만 재생성을 + 호출한다. Redis 분산 락으로 한 인스턴스만 실행하며, 락을 얻지 못한 + 인스턴스는 건너뛰고 정상 기동한다. 재생성 중 예외가 나면 경고 로그를 남기고 + 기동을 계속한다. +- Verification: `./gradlew :bottlenote-admin-api:test` +- Files (advisory): 락 지원 신규, `CurationSpecResourceSyncRunner.kt`, 관련 테스트 +- Depends: 1, 2 +- Size: M +- Status: [x] done + +### Task 4: 피드 읽기 경로를 feed_payload로 전환 +- Acceptance: Product 피드와 Admin 피드 프리뷰가 `feed_payload`를 소스로 쓰고, + NULL이면 원본 `payload`로 fallback한다. `소스 → materializeFeed → + projectPayload` 파이프라인은 유지해 숨은 입력값이 응답에 노출되지 않는다. + Product 상세 API는 전환하지 않는다. +- Verification: `./gradlew :bottlenote-mono:test --tests '*SpecBasedCuration*'` +- Files (advisory): `ProductSpecBasedCurationService.java`, + `AdminSpecBasedCurationService.java`, `CurationExtension.java`, 관련 테스트 +- Depends: 없음 (1-3과 병렬 가능) +- Size: M +- Status: [x] done + +### Checkpoint: after Tasks 3-4 +- [ ] 컴파일 통과 / 단위 테스트 통과 / ArchUnit 룰 통과 + +### Task 5: Product 피드 전환 통합 검증 +- Acceptance: 4개 스펙 각각에 대해 `원본 payload → materializeFeed → + projectPayload` 결과와 `feed_payload → 동일 파이프라인` 결과가 같음을 + 보인다(두 값을 직접 계산해 비교하며, 전환 전 API를 호출하지 않는다). + `feed_payload`가 NULL인 큐레이션도 피드에 정상 노출되고, 응답에 숨은 + 입력값이 없다. 픽스처가 NULL/비NULL 두 상태를 모두 만들 수 있다. +- Verification: `./gradlew integration_test` +- Files (advisory): `ProductSpecBasedCurationIntegrationTest.java`, + `CurationFixtureFactory.java` +- Depends: 4 +- Size: M +- Status: [x] done + +### Task 6: Admin 프리뷰 전환과 재생성 통합 검증 +- Acceptance: Admin 피드 프리뷰가 `feed_payload` 기준으로 응답하고 NULL이면 + 원본으로 fallback한다. 스펙 `responseSpec`을 바꾼 뒤 동기화를 돌리면 해당 + 스펙의 큐레이션만 `feed_payload`가 갱신되고 NULL 행이 채워지며, 다른 스펙은 + 불변임을 실제 DB로 확인한다. +- Verification: `./gradlew :bottlenote-admin-api:admin_integration_test` +- Files (advisory): `AdminSpecBasedCurationIntegrationTest.kt`, 동기화·재생성 + 통합 테스트 신규 +- Depends: 3, 4 +- Size: M +- Status: [x] done + +## Progress Log + +- /plan 완료: Task 6개(M 5, M 1), 의존 순서 1·2·4 병렬 → 3 → 5·6. + codex(`codex exec --sandbox read-only`)로 분해 검토를 받아 4건 중 3건 반영: + Task 5의 불필요한 Depends 2 제거, "원본 경로 응답" 문구를 직접 계산 비교로 + 구체화(전환 후엔 호출 가능한 원본 경로가 없음), Task 3 크기 S→M. + Task 4·5 분리 지적은 Task 5만 모듈 경계(product-api/admin-api)로 분리하고 + Task 4는 mono 단일 모듈의 동일 메커니즘이라 유지. +- Task 1 완료: `CurationSpecFingerprint` 신규(canonical JSON + SHA-256), + `sync()`가 `update()` 덮어쓰기 전에 비교해 `changedSpecIds`를 반환. + 신규 생성 스펙은 큐레이션이 없어 변경으로 잡지 않는다. + codex 리뷰 3건 전부 반영: ① 실수 trailing zero 미제거로 `1.20`/`1.2` + 오탐 → 항상 `stripTrailingZeros` ② `changedSpecIds` null을 빈 목록으로 + 뭉개 재생성 누락을 "변경 없음"으로 위장 → `List.copyOf`로 NPE 노출 + ③ 정규화 엣지 미검증 → `CurationSpecFingerprintTest` 9건 신규. + 단위 테스트 14건 통과. +- Task 2 완료: `CurationFeedPayloadRegenerationService` 신규, + `CurationExtensionRepository.findAllBySpecIdIn`과 + `CurationExtension.updateFeedPayload` 추가. NULL 레거시 행 포함(backfill 겸용). + codex 리뷰에서 **경합 버그**를 잡아 `@DynamicUpdate` 적용: 전체 컬럼 UPDATE면 + 재생성이 로드한 stale `payload`가 어드민 저장분을 덮어써 SSOT가 유실된다. + 나머지 지적은 설계 판단으로 유지 — `save()` 명시 호출은 더티체킹 의존을 + 피해 포트 계약을 지키려는 것이고, 행 단위 예외의 전체 롤백은 "재생성 안 됨 + + fallback 동작"이라 안전한 쪽이며, `specIds` null 원소는 Task 1과 같은 + 이유로 NPE 노출을 택했다. 단위 테스트 6건 통과. +- Task 4 완료: `CurationExtension.feedSource()`(NULL이면 원본 fallback)를 두 서비스가 + 공유한다. Product 피드·Admin 프리뷰 전환, 상세는 원본 유지. + codex 리뷰가 **동등성 논증의 허점**을 잡았다: 최초 테스트가 `materializeFeed`를 + 건너뛰고 "두 경로 모두 동일 적용"이라고 가정했는데, 인자가 비면 materializer가 + no-op이 아니라 `writeTo`에 null/[]을 써서 배열 원소를 살린다. 원본 경로는 그 + 원소가 남고 feed_payload 경로는 추출 단계에서 버려 응답이 갈릴 수 있다. + → 테스트를 실제 파이프라인(소스 → materializeFeed → projectPayload) 비교로 + 바꾸고, GraphQL 실행 시 실패하는 executor를 물렸다. 인자가 빈 경우는 실행 없이 + 갈리므로 "현행 스펙에 피드 교차 x-graphql 없음"을 명시적 트립와이어로 고정했다. + 현재 4개 스펙 모두 교차 0건이라 동등성이 성립하며, 새 스펙이 생기면 테스트가 + 깨져 재검토를 강제한다. 단위 테스트 8건 통과. +- Task 3 완료: 러너가 동기화 직후 `changedSpecIds`가 있을 때만 재생성한다. + 락은 `CurationFeedRegenerationLock`(도메인 인터페이스) + + `RedisCurationFeedRegenerationLock`(구현)으로 분리했다 — 처음엔 서비스에 + Redis를 직접 물렸으나 admin-api 테스트 클래스패스에 `RedisTemplate`이 없어 + 컴파일이 깨졌고, 레이어 표준 4(기술 세부사항은 구현에 격리)에도 이 형태가 맞다. + 재생성 예외는 경고 로그로 삼키고 락은 finally에서 해제한다. 러너 단위 4건 통과. +- Task 5·6 완료: Product 통합에서 동일 payload를 가진 전환/레거시(NULL) 큐레이션의 + 피드 응답 payload가 같음을 E2E로 확인. Admin 통합에서 프리뷰 fallback, + responseSpec 미변경 시 재생성 0건, 변경 시 해당 스펙만 갱신되고 다른 스펙은 + NULL 유지, 원본 payload 불변을 실제 DB로 확인. 픽스처에 withFeedPayload + 옵션을 추가해 레거시 상태를 만들 수 있게 했다. + Admin 피드 응답은 `fromPage`라 data 자체가 배열인데 items 경로로 읽어 한 번 + 실패했고 파싱을 교정했다. +- codex 최종 리뷰 반영 (Critical 2건, Important 1건): + ① **재생성 실패의 영구 유실** — sync가 DB 스펙을 이미 덮어써서 다음 기동에는 + "변경 없음"으로 보이고, feed_payload가 non-NULL이면 fallback도 안 걸려 낡은 + 응답이 굳는다. → `invalidate()`를 먼저 커밋해 실패 시 NULL이 남게 했다. + 정확성은 항상 유지되고 잃는 것은 성능뿐이다. + ② **Redis 장애가 기동 실패로 전파** — `tryAcquire()`가 try 밖이라 연결 오류가 + ApplicationReadyEvent로 전파돼 "경고 후 기동" 계약을 깼다. → try 안으로 옮기고 + release도 runCatching으로 감쌌다. 락 예외 경로 테스트 2건 추가. + ③ 락 해제가 소유권을 확인하지 않아 TTL 만료 후 남의 락을 지울 수 있었다. + → UUID 토큰 비교 후 삭제. +- 정리: 러너가 락 획득/해제·무효화 순서·실패 정책을 모두 들고 있어 config 클래스가 + 비대해졌다. 오케스트레이션을 `CurationFeedPayloadRefreshService`(mono)로 옮겨 + 러너를 35줄/위임 한 줄로 줄였고, 정책 테스트도 mono로 이동했다. + `regenerate`의 `spec == null` 분기는 `findAllBySpecIdIn(specs.keySet())`로 + 조회하므로 성립할 수 없는 죽은 코드라 제거했고, 중복 가드는 헬퍼로 모았다. + 불필요한 `save()` 호출도 걷어내 더티체킹에 맡겼다. + `refresh()`는 무효화·재생성이 각자 커밋되어야 해서 `NOT_SUPPORTED`로 + "경계 없음"을 명시했다 — ArchUnit 트랜잭션 룰의 취지(경계 명확화)에 맞는 선언이다. +- 알려진 한계: 재생성이 대상 전체를 한 트랜잭션에 적재한다. 현재 운영 28건 + 기준 문제없으나 큐레이션이 수천 건이 되면 chunk 처리가 필요하다. diff --git a/plan/complete/curation-place-search-contract.md b/plan/complete/curation-place-search-contract.md new file mode 100644 index 000000000..c5bb813a2 --- /dev/null +++ b/plan/complete/curation-place-search-contract.md @@ -0,0 +1,171 @@ +``` +================================================================================ + PROJECT COMPLETION STAMP +================================================================================ +Status: **COMPLETED** +Completion Date: 2026-07-28 + +** Core Achievements ** +- 시음회 requestSpec·responseSpec에 placeName·zipCode 추가 (둘 다 optional) +- placeName을 x-feed(order 25, role location)로 노출, zipCode는 피드 제외 +- 프로그램 placeName·address를 address-search 계약으로 전환하고 zipCode 추가 +- 전 단계 PASS: unit 502건 / rule 63건 / integration 268건 / admin integration 209건, 실패 0 + +** Key Components ** +- resources/openapi/curation/whisky_tasting_event.json +- resources/openapi/curation/program.json +- curation/service/CurationPlaceFieldContractTest.java (계약·회귀 9건) + +** Deferred Items ** +- placeName·zipCode의 required 승격: FE 장소검색 연결 후 +- CurationPayloadValidator의 pattern 지원: 이번은 리소스 JSON만 수정하는 범위 +- 미정의 property 차단: FE가 보내는 다른 필드까지 깨질 수 있어 별건 +- FE 연계(barAddress 전용 자동 동기화 제거, 필드별 매핑): FE 작업 +================================================================================ +``` + +# Plan: 큐레이션 시음회·프로그램 장소검색 계약 정렬 + +## Overview + +이슈 bottle-note/workspace#344 구현. Admin 시음회 form이 `requestSpec`에 없는 +`placeName`을 FE 하드코딩으로 주입해 저장하고 있다. 스펙을 SSoT로 되돌리고, +장소검색 계약(`x-field-style: address-search`)을 두 스펙에 정렬한다. + +현재 상태를 실측으로 확인했다. + +- 시음회 `requestSpec`에 `placeName`이 없는데도 개발 DB 14건 중 12건에 저장돼 + 있다. `CurationPayloadValidator`가 미정의 property를 차단하지 않기 때문이다. +- 상세 API는 원본 payload를 그대로 내보내므로 `placeName`이 이미 응답에 나온다 + (`GET /v2/curations/6` → `placeName: "도시술"`). 반면 피드 API와 `feed_payload` + 에는 없다 — `responseSpec`에 없어 `x-feed` 투영에서 빠진다. +- `PROGRAM`은 `placeName`이 `requestSpec`·`responseSpec`에 모두 있고 `x-feed`도 + 켜져 있다. 즉 두 스펙의 계약이 이미 어긋나 있으며, 시음회를 맞추는 것이 + 일관성 회복이다. +- `zipCode`는 두 스펙 어디에도 없고 저장된 payload 15건 전부에 없다. + +### Assumptions + +- 신규 PR은 **PR #680 브랜치(`Whale0928/feed-read-path`) 위에 쌓는다.** #680이 + 먼저 머지된 뒤 리뷰 가능하다. 스펙 변경이 #680의 재생성과 함께 동작하는 것을 + 한 PR에서 보일 수 있다. +- 변경 대상은 스펙 JSON 리소스 두 개(`whisky_tasting_event.json`, + `program.json`)다. 추출·투영·검증 로직은 건드리지 않는다. +- 스키마 변경 없음. Flyway 마이그레이션 없음. +- `placeName`과 `zipCode`는 **둘 다 optional**이다. required로 올리면 + `placeName` 없는 시음회 2건과 `zipCode` 없는 전 15건이 어드민 재저장 시 + `CURATION_PAYLOAD_INVALID`로 실패한다. FE가 장소검색을 연결해 데이터가 채워진 + 뒤 required 승격은 후속 과제로 남긴다. +- 우편번호 필드명은 프로젝트 관례를 따라 `zipCode`다 + (선례: `review/facade/payload/LocationInfo`, DB `zip_code varchar(5)`). + `postalCode`·`zonecode`는 프로젝트에 쓰이지 않는다. +- **[수정됨] `CurationPayloadValidator`는 `pattern`을 검증하지 않는다.** 지원하는 + 제약은 type/required/nullable/enum/maxLength/minLength/maximum/minimum/ + maxItems/minItems뿐이며, 기존 큐레이션 스펙에 `pattern` 사용처도 없었다. + 따라서 `zipCode`에 `minLength: 5`, `maxLength: 5`를 함께 걸어 길이는 서버가 + 강제하고, `pattern: ^\d{5}$`는 숫자 제약을 FE·문서용으로 남긴다. + review 도메인은 Bean Validation `@Pattern`을 쓰지만 큐레이션 payload는 + 자유형 JSON이라 같은 방식을 쓸 수 없다. 검증기에 `pattern` 지원을 추가하는 + 것은 이번 범위(리소스 JSON만 수정) 밖이라 후속 과제로 남긴다. +- `barAddress`("장소 및 바(bar) 주소")와 `detailAddress`("상세 주소")는 이름·의미· + 스타일 모두 현행 유지한다. 이슈 표의 "선택 주소"가 `barAddress`, "상세 위치"가 + `detailAddress`에 대응한다. +- `placeName`은 `responseSpec`에도 추가하고 `x-feed`를 켠다. `PROGRAM`이 이미 + 그렇게 되어 있어 두 스펙이 같은 모양이 된다. +- `zipCode`는 `responseSpec`에 넣되 `x-feed`는 켜지 않는다. 계약에는 드러내 + 상세에서 보이게 하고, 피드 카드에는 노출하지 않는다. +- `PROGRAM`은 `address`와 `placeName` **둘 다** `address-search`로 바꾼다. + 이슈는 `address`만 언급하지만, 한쪽만 바꾸면 시음회와 계약이 어긋난다. +- 검증기의 미정의 property 허용 동작은 이번에 바꾸지 않는다. 막으면 FE가 보내는 + 다른 필드까지 깨질 수 있어 별건이다. +- 배포 시 `responseSpec`이 바뀌므로 #680의 재생성이 두 스펙에 대해 자동 실행된다. + 이는 의도된 동작이며 별도 backfill 작업이 필요 없다. + +### Success Criteria + +- 시음회 `requestSpec`에 `placeName`(string, maxLength 100, `장소명`, + `address-search`)과 `zipCode`(string, length 5 고정, `우편번호`, + `address-search`)가 optional로 존재한다. `required` 배열은 기존 8개에서 + 늘지 않는다. +- `zipCode`에 5자가 아닌 값을 넣으면 검증이 거부한다. +- `PROGRAM` `requestSpec`에 `zipCode`가 optional로 추가되고, `address`와 + `placeName`의 `x-field-style`이 `address-search`다. +- 시음회 `responseSpec`에 `placeName`이 `x-feed.enabled=true`로, `zipCode`가 + `x-feed` 없이 존재한다. +- 시음회 피드 응답 payload에 `placeName`이 포함된다 (현재는 빠져 있다). +- 시음회 피드 응답 payload에 `zipCode`가 포함되지 않는다. +- `placeName`이 없는 기존 시음회 2건도 생성·수정·조회가 모두 정상 동작한다. +- Admin curation-spec 상세 API 응답의 `requestSpec`에 변경이 반영된다. +- 기존 시음회·프로그램 payload가 검증을 통과한다 (회귀 없음). +- Product 상세 응답이 변경 전과 동일하다. + +### Impact Scope + +- **mono**: `resources/openapi/curation/whisky_tasting_event.json`, + `resources/openapi/curation/program.json` — 리소스만 변경. +- **product-api / admin-api / test-support**: 코드 변경 없음. 스펙 리소스 변경에 + 따른 테스트 기대값 조정만 있을 수 있다. +- **스키마**: 변경 없음. +- **배포**: `responseSpec` 변경으로 #680의 재생성이 시음회·프로그램 스펙에 대해 + 자동 실행된다. 개발 DB 기준 시음회 14건 + 프로그램 1건의 `feed_payload`가 갱신된다. +- **API 계약**: 시음회 피드 응답에 `placeName` 필드가 **추가**된다. 가산적 + 변경이라 기존 클라이언트를 깨지 않는다. + +## Execution Mode + +- mode: delegated +- scope: plan, implement, test, verify, commit, push, pr +- base 브랜치는 `Whale0928/feed-read-path`(PR #680)다. PR도 그 브랜치를 대상으로 연다. +- 구현·리뷰에 codex CLI를 활용해 작성자와 리뷰어를 분리한다. +- stop-conditions: 기본 3종 (① 가정 붕괴 시 재개봉 프로토콜, ② /verify 3회 + 실패 시 /debug 보고 후 정지, ③ scope 밖 되돌리기 어려운 행동 전 확인) + +## Tasks + +### Task 1: 시음회 장소 필드 추가 +- Acceptance: `requestSpec`에 `placeName`(string, maxLength 100, `장소명`, + `address-search`)과 `zipCode`(string, length 5 고정 + `pattern ^\d{5}$`, + `우편번호`, `address-search`)가 optional로 존재하고 `required` 배열은 기존 + 8개 그대로다. `zipCode`가 5자가 아니면 검증이 거부한다. + `responseSpec`에 `placeName`이 `x-feed.enabled=true`로, `zipCode`가 `x-feed` + 없이 존재한다. 그 결과 피드 응답 payload에 `placeName`이 포함되고 `zipCode`는 + 포함되지 않으며, `placeName`이 없는 payload도 검증을 통과한다. +- Verification: `./gradlew :bottlenote-mono:test --tests '*Curation*'`, + `./gradlew :bottlenote-product-api:integration_test --tests '*ProductSpecBasedCuration*'` +- Files (advisory): `openapi/curation/whisky_tasting_event.json`, 관련 테스트 +- Depends: 없음 +- Size: S +- Status: [x] done + +### Task 2: 프로그램 장소검색 계약 정렬 +- Acceptance: `requestSpec`에 `zipCode`가 optional로 추가되고, `address`와 + `placeName`의 `x-field-style`이 `address-search`다. 기존 PROGRAM payload가 + 검증을 통과하고 피드·상세 응답 내용은 변하지 않는다(스타일은 렌더링 힌트일 뿐 + 응답 값에 영향이 없다). +- Verification: `./gradlew :bottlenote-mono:test --tests '*Curation*'` +- Files (advisory): `openapi/curation/program.json`, 관련 테스트 +- Depends: 없음 (Task 1과 병렬 가능 — 서로 다른 리소스다) +- Size: S +- Status: [x] done + +## Progress Log + +- /plan 완료: Task 2개(S 2), 서로 독립이라 병렬 가능. 리소스 JSON 하나씩이 + 자연 관절이며, 회귀 확인은 각 Task의 Acceptance에 포함해 별도 Task로 쪼개지 + 않았다. Task가 2개라 Checkpoint는 생략하고 마감의 `/verify full`이 그 역할을 한다. + 기존 테스트 8개 파일이 두 스펙을 참조하지만 payload 키 목록을 전수 단정하는 + 곳은 없어 파급은 제한적으로 예상된다. +- 가정 붕괴/수정 1건: `CurationPayloadValidator`가 `pattern`을 검증하지 않음을 + 구현 중 발견해 정지·보고했다. 사용자 결정으로 `minLength`/`maxLength` 5를 함께 + 걸어 길이는 서버가 강제하고 `pattern`은 FE·문서용으로 남긴다. 검증기 확장은 + 후속 과제. +- Task 1·2 완료: 시음회에 `placeName`(x-feed order 25, role location)·`zipCode`, + 프로그램에 `zipCode` 추가 및 `placeName`·`address`를 `address-search`로 전환. + 리소스 JSON은 재직렬화가 원본과 바이트 일치함을 확인한 뒤 편집해 포맷을 보존했다. + `CurationPlaceFieldContractTest` 9건 신규. + codex 리뷰 3건 중 2건 반영: ① `responseSpec`에 `placeName`이 들어가면서 상세 + 경로(`materialize`)의 검증 대상이 된 점을 테스트가 증명하지 못함 → 기존 저장값 + 통과와 100자 초과 거부를 각각 고정. 개발 DB 실측으로 기존 15건 최대 9자, + 비문자열 0건, 100자 초과 0건이라 회귀 없음을 확인했다. ② `order`·`role` + 미고정 → 시간(20)과 주소(30) 사이라는 관계로 고정. ③ `pattern` 미검증은 + 이미 문서화된 결정이라 유지.