From 1523ec19e0145c8ee48de2038336cf1135c523b7 Mon Sep 17 00:00:00 2001 From: Whale0928 Date: Tue, 28 Jul 2026 13:16:11 +0900 Subject: [PATCH 01/12] =?UTF-8?q?feat:=20=ED=81=90=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EC=8A=A4=ED=8E=99=20=EB=8F=99=EA=B8=B0=ED=99=94?= =?UTF-8?q?=EC=97=90=20responseSpec=20=EB=B3=80=EA=B2=BD=20=EA=B0=90?= =?UTF-8?q?=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sync()가 기존 스펙을 비교 없이 매 기동 무조건 덮어써 실제로 바뀐 스펙을 가릴 수 없었다. canonical JSON(키 재귀 정렬 + 실수 trailing zero 제거) SHA-256 지문으로 비교해 changedSpecIds를 반환한다. MySQL JSON 컬럼이 키를 자체 정렬하고 Jackson 직렬화 표기가 경로마다 달라 단순 비교는 매번 변경으로 오판한다. 비교는 update()가 값을 덮어쓰기 전에 수행한다. 신규 생성 스펙은 큐레이션이 없으므로 변경으로 잡지 않는다. Co-Authored-By: Claude Opus 5 --- .../response/CurationSpecSyncResponse.java | 15 +- .../CurationSpecResourceSyncService.java | 16 +- .../support/CurationSpecFingerprint.java | 83 ++++++++ .../CurationSpecResourceSyncServiceTest.java | 107 +++++++++- .../support/CurationSpecFingerprintTest.java | 106 ++++++++++ plan/curation-feed-read-path.md | 189 ++++++++++++++++++ 6 files changed, 512 insertions(+), 4 deletions(-) create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/curation/support/CurationSpecFingerprint.java create mode 100644 bottlenote-mono/src/test/java/app/bottlenote/curation/support/CurationSpecFingerprintTest.java create mode 100644 plan/curation-feed-read-path.md 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/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/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/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/plan/curation-feed-read-path.md b/plan/curation-feed-read-path.md new file mode 100644 index 000000000..f0641b486 --- /dev/null +++ b/plan/curation-feed-read-path.md @@ -0,0 +1,189 @@ +# 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: [ ] not 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: [ ] not 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: [ ] not 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: [ ] not 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: [ ] not 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건 통과. From 9a25600333c259e40a45b346678a0765e8c3c1dd Mon Sep 17 00:00:00 2001 From: Whale0928 Date: Tue, 28 Jul 2026 13:22:34 +0900 Subject: [PATCH 02/12] =?UTF-8?q?feat:=20=EC=8A=A4=ED=8E=99=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EC=8B=9C=20=ED=81=90=EB=A0=88=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?feed=5Fpayload=20=EC=9E=AC=EC=83=9D=EC=84=B1=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit responseSpec이 바뀐 스펙의 큐레이션을 조회해 feed_payload를 다시 만든다. feed_payload가 NULL인 레거시 행도 대상이므로 backfill을 겸한다. CurationExtension에 @DynamicUpdate를 붙였다. 전체 컬럼 UPDATE면 재생성이 로드한 stale payload가 그 사이 어드민이 저장한 값을 덮어써 SSOT가 유실된다. Co-Authored-By: Claude Opus 5 --- .../curation/domain/CurationExtension.java | 8 + .../domain/CurationExtensionRepository.java | 2 + .../JpaCurationExtensionRepository.java | 2 + .../CurationFeedPayloadRegenerator.java | 54 ++++++ .../CurationFeedPayloadRegeneratorTest.java | 169 ++++++++++++++++++ .../InMemoryCurationExtensionRepository.java | 7 + plan/curation-feed-read-path.md | 13 +- 7 files changed, 254 insertions(+), 1 deletion(-) create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationFeedPayloadRegenerator.java create mode 100644 bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationFeedPayloadRegeneratorTest.java 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..9c59cff7c 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,9 @@ 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; + } } 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/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/service/CurationFeedPayloadRegenerator.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationFeedPayloadRegenerator.java new file mode 100644 index 000000000..1e18543eb --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationFeedPayloadRegenerator.java @@ -0,0 +1,54 @@ +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 CurationFeedPayloadRegenerator { + + private final CurationSpecRepository curationSpecRepository; + private final CurationExtensionRepository curationExtensionRepository; + private final CurationFeedProjector feedProjector; + + @Transactional + public int regenerate(Collection specIds) { + if (specIds == null || specIds.isEmpty()) { + return 0; + } + Map specs = + curationSpecRepository.findAllByIdIn(Set.copyOf(specIds)).stream() + .collect(Collectors.toMap(CurationSpec::getId, Function.identity())); + + List extensions = + curationExtensionRepository.findAllBySpecIdIn(specs.keySet()); + int regenerated = 0; + for (CurationExtension extension : extensions) { + CurationSpec spec = specs.get(extension.getSpecId()); + if (spec == null) { + continue; + } + // feed_payload가 NULL인 레거시 행도 대상이다. 재생성이 backfill을 겸한다. + extension.updateFeedPayload( + feedProjector.extractFeedPayload(spec.getResponseSpec(), extension.getPayload())); + curationExtensionRepository.save(extension); + regenerated++; + } + log.info("큐레이션 feed_payload 재생성 완료: specIds={}, curations={}", specs.keySet(), regenerated); + return regenerated; + } +} diff --git a/bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationFeedPayloadRegeneratorTest.java b/bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationFeedPayloadRegeneratorTest.java new file mode 100644 index 000000000..047c9f23e --- /dev/null +++ b/bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationFeedPayloadRegeneratorTest.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("CurationFeedPayloadRegenerator 단위 테스트") +class CurationFeedPayloadRegeneratorTest { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private InMemoryCurationSpecRepository specRepository; + private InMemoryCurationExtensionRepository extensionRepository; + private CurationFeedPayloadRegenerator regenerator; + + @BeforeEach + void setUp() { + specRepository = new InMemoryCurationSpecRepository(); + extensionRepository = new InMemoryCurationExtensionRepository(); + regenerator = + new CurationFeedPayloadRegenerator( + 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("빈 목록이나 null을 받으면 아무것도 하지 않는다") + void regenerate_whenNoSpecIds_doesNothing() { + assertThat(regenerator.regenerate(List.of())).isZero(); + assertThat(regenerator.regenerate(null)).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-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/curation-feed-read-path.md b/plan/curation-feed-read-path.md index f0641b486..f0ffbc024 100644 --- a/plan/curation-feed-read-path.md +++ b/plan/curation-feed-read-path.md @@ -115,7 +115,7 @@ PR #677로 `curation_extension.feed_payload` 쓰기 경로는 확보됐다. 다 `InMemoryCurationExtensionRepository.java`, 관련 테스트 - Depends: 없음 (Task 1과 병렬 가능 — 서비스는 specId 목록만 입력받는다) - Size: M -- Status: [ ] not done +- Status: [x] done ### Checkpoint: after Tasks 1-2 - [ ] 컴파일 통과 / 단위 테스트 통과 / ArchUnit 룰 통과 @@ -187,3 +187,14 @@ PR #677로 `curation_extension.feed_payload` 쓰기 경로는 확보됐다. 다 뭉개 재생성 누락을 "변경 없음"으로 위장 → `List.copyOf`로 NPE 노출 ③ 정규화 엣지 미검증 → `CurationSpecFingerprintTest` 9건 신규. 단위 테스트 14건 통과. +- Task 2 완료: `CurationFeedPayloadRegenerator` 신규, + `CurationExtensionRepository.findAllBySpecIdIn`과 + `CurationExtension.updateFeedPayload` 추가. NULL 레거시 행 포함(backfill 겸용). + codex 리뷰에서 **경합 버그**를 잡아 `@DynamicUpdate` 적용: 전체 컬럼 UPDATE면 + 재생성이 로드한 stale `payload`가 어드민 저장분을 덮어써 SSOT가 유실된다. + 나머지 지적은 설계 판단으로 유지 — `save()` 명시 호출은 더티체킹 의존을 + 피해 포트 계약을 지키려는 것이고, 행 단위 예외의 전체 롤백은 "재생성 안 됨 + + fallback 동작"이라 안전한 쪽이며, `specIds` null 원소는 Task 1과 같은 + 이유로 NPE 노출을 택했다. 단위 테스트 6건 통과. +- 알려진 한계: 재생성이 대상 전체를 한 트랜잭션에 적재한다. 현재 운영 28건 + 기준 문제없으나 큐레이션이 수천 건이 되면 chunk 처리가 필요하다. From c075e08f7fc64a14b7a757bc6bc39f3ba0926332 Mon Sep 17 00:00:00 2001 From: Whale0928 Date: Tue, 28 Jul 2026 13:25:40 +0900 Subject: [PATCH 03/12] =?UTF-8?q?fix:=20=EC=9E=AC=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=9D=B4=EB=A6=84=EC=9D=84=20Arc?= =?UTF-8?q?hUnit=20=EB=AA=85=EB=AA=85=20=EA=B7=9C=EC=B9=99=EC=97=90=20?= =?UTF-8?q?=EB=A7=9E=EC=B6=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @Service 클래스는 Service로 끝나야 한다는 룰(서비스_클래스_명명_규칙_검증)을 위반해 check_rule_test가 실패했다. Co-Authored-By: Claude Opus 5 --- ...r.java => CurationFeedPayloadRegenerationService.java} | 2 +- ...va => CurationFeedPayloadRegenerationServiceTest.java} | 8 ++++---- plan/curation-feed-read-path.md | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) rename bottlenote-mono/src/main/java/app/bottlenote/curation/service/{CurationFeedPayloadRegenerator.java => CurationFeedPayloadRegenerationService.java} (97%) rename bottlenote-mono/src/test/java/app/bottlenote/curation/service/{CurationFeedPayloadRegeneratorTest.java => CurationFeedPayloadRegenerationServiceTest.java} (96%) diff --git a/bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationFeedPayloadRegenerator.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationFeedPayloadRegenerationService.java similarity index 97% rename from bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationFeedPayloadRegenerator.java rename to bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationFeedPayloadRegenerationService.java index 1e18543eb..1d57627d9 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationFeedPayloadRegenerator.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationFeedPayloadRegenerationService.java @@ -19,7 +19,7 @@ @Service @RequiredArgsConstructor @Slf4j -public class CurationFeedPayloadRegenerator { +public class CurationFeedPayloadRegenerationService { private final CurationSpecRepository curationSpecRepository; private final CurationExtensionRepository curationExtensionRepository; diff --git a/bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationFeedPayloadRegeneratorTest.java b/bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationFeedPayloadRegenerationServiceTest.java similarity index 96% rename from bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationFeedPayloadRegeneratorTest.java rename to bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationFeedPayloadRegenerationServiceTest.java index 047c9f23e..8472e4143 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationFeedPayloadRegeneratorTest.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationFeedPayloadRegenerationServiceTest.java @@ -17,21 +17,21 @@ import org.junit.jupiter.api.Test; @Tag("unit") -@DisplayName("CurationFeedPayloadRegenerator 단위 테스트") -class CurationFeedPayloadRegeneratorTest { +@DisplayName("CurationFeedPayloadRegenerationService 단위 테스트") +class CurationFeedPayloadRegenerationServiceTest { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); private InMemoryCurationSpecRepository specRepository; private InMemoryCurationExtensionRepository extensionRepository; - private CurationFeedPayloadRegenerator regenerator; + private CurationFeedPayloadRegenerationService regenerator; @BeforeEach void setUp() { specRepository = new InMemoryCurationSpecRepository(); extensionRepository = new InMemoryCurationExtensionRepository(); regenerator = - new CurationFeedPayloadRegenerator( + new CurationFeedPayloadRegenerationService( specRepository, extensionRepository, new CurationFeedProjector(OBJECT_MAPPER)); } diff --git a/plan/curation-feed-read-path.md b/plan/curation-feed-read-path.md index f0ffbc024..7d6ed0104 100644 --- a/plan/curation-feed-read-path.md +++ b/plan/curation-feed-read-path.md @@ -187,7 +187,7 @@ PR #677로 `curation_extension.feed_payload` 쓰기 경로는 확보됐다. 다 뭉개 재생성 누락을 "변경 없음"으로 위장 → `List.copyOf`로 NPE 노출 ③ 정규화 엣지 미검증 → `CurationSpecFingerprintTest` 9건 신규. 단위 테스트 14건 통과. -- Task 2 완료: `CurationFeedPayloadRegenerator` 신규, +- Task 2 완료: `CurationFeedPayloadRegenerationService` 신규, `CurationExtensionRepository.findAllBySpecIdIn`과 `CurationExtension.updateFeedPayload` 추가. NULL 레거시 행 포함(backfill 겸용). codex 리뷰에서 **경합 버그**를 잡아 `@DynamicUpdate` 적용: 전체 컬럼 UPDATE면 From 203b73dd3ed7bbd360889f1b521fe1098b591228 Mon Sep 17 00:00:00 2001 From: Whale0928 Date: Tue, 28 Jul 2026 13:32:38 +0900 Subject: [PATCH 04/12] =?UTF-8?q?feat:=20=ED=94=BC=EB=93=9C=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=86=8C=EC=8A=A4=EB=A5=BC=20feed=5Fpayload?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Product 피드와 Admin 프리뷰가 feed_payload를 소스로 쓰고, NULL이면 원본 payload로 fallback한다. 빈 결과는 []/{}로 저장되므로 NULL만 fallback 조건이다. 상세 API는 원본 payload를 그대로 쓴다. 파이프라인은 소스 -> materializeFeed -> projectPayload를 유지한다. projectPayload를 통과시켜야 feed_payload에 섞인 숨은 입력값이 응답에 노출되지 않는다. 동등성 테스트는 파이프라인 전체를 두 소스로 각각 돌려 비교한다. 인자가 빈 GraphQL 엔트리는 실행 없이 writeTo에 null을 써서 두 경로가 갈릴 수 있으므로, 현행 스펙에 피드 교차 x-graphql이 없음을 트립와이어로 고정했다. Co-Authored-By: Claude Opus 5 --- .../curation/domain/CurationExtension.java | 6 + .../AdminSpecBasedCurationService.java | 2 +- .../ProductSpecBasedCurationService.java | 2 +- .../CurationFeedSourceEquivalenceTest.java | 266 ++++++++++++++++++ plan/curation-feed-read-path.md | 13 +- 5 files changed, 286 insertions(+), 3 deletions(-) create mode 100644 bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationFeedSourceEquivalenceTest.java 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 9c59cff7c..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 @@ -55,4 +55,10 @@ public void update(Long specId, Object payload, Object feedPayload) { 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/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/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/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/plan/curation-feed-read-path.md b/plan/curation-feed-read-path.md index 7d6ed0104..adcf04c8e 100644 --- a/plan/curation-feed-read-path.md +++ b/plan/curation-feed-read-path.md @@ -141,7 +141,7 @@ PR #677로 `curation_extension.feed_payload` 쓰기 경로는 확보됐다. 다 `AdminSpecBasedCurationService.java`, `CurationExtension.java`, 관련 테스트 - Depends: 없음 (1-3과 병렬 가능) - Size: M -- Status: [ ] not done +- Status: [x] done ### Checkpoint: after Tasks 3-4 - [ ] 컴파일 통과 / 단위 테스트 통과 / ArchUnit 룰 통과 @@ -196,5 +196,16 @@ PR #677로 `curation_extension.feed_payload` 쓰기 경로는 확보됐다. 다 피해 포트 계약을 지키려는 것이고, 행 단위 예외의 전체 롤백은 "재생성 안 됨 + 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건 통과. - 알려진 한계: 재생성이 대상 전체를 한 트랜잭션에 적재한다. 현재 운영 28건 기준 문제없으나 큐레이션이 수천 건이 되면 chunk 처리가 필요하다. From 605f2a646e358ef4e25446b8b74a23d0d737fbaa Mon Sep 17 00:00:00 2001 From: Whale0928 Date: Tue, 28 Jul 2026 13:37:23 +0900 Subject: [PATCH 05/12] =?UTF-8?q?feat:=20=EA=B8=B0=EB=8F=99=20=EC=8B=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=EB=90=9C=20=EC=8A=A4=ED=8E=99=EC=9D=98=20fee?= =?UTF-8?q?d=5Fpayload=20=EC=9E=AC=EC=83=9D=EC=84=B1=20=EC=8B=A4=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit admin-api 러너가 동기화 직후 responseSpec이 바뀐 스펙에 한해 재생성한다. 인스턴스가 여럿일 때 중복 실행되지 않도록 공유 저장소로 잠그며, 락을 얻지 못한 인스턴스는 건너뛰고 정상 기동한다. 재생성 실패는 경고 로그로 삼킨다. feed_payload는 파생 데이터이고 조회는 원본 payload로 fallback되므로 서비스 기동을 막을 이유가 없다. 락은 도메인 인터페이스와 Redis 구현으로 분리했다. 기술 세부사항을 구현 안에 격리하고, 테스트가 Redis 없이 fake로 대체할 수 있게 한다. Co-Authored-By: Claude Opus 5 --- .../config/CurationSpecResourceSyncRunner.kt | 34 ++++++- .../CurationSpecResourceSyncRunnerTest.kt | 95 +++++++++++++++++++ .../domain/CurationFeedRegenerationLock.java | 9 ++ .../RedisCurationFeedRegenerationLock.java | 28 ++++++ plan/curation-feed-read-path.md | 8 +- 5 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 bottlenote-admin-api/src/test/kotlin/app/unit/curation/CurationSpecResourceSyncRunnerTest.kt create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/curation/domain/CurationFeedRegenerationLock.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/curation/repository/RedisCurationFeedRegenerationLock.java 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..30aace1e4 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,7 @@ package app.bottlenote.curation.config +import app.bottlenote.curation.domain.CurationFeedRegenerationLock +import app.bottlenote.curation.service.CurationFeedPayloadRegenerationService import app.bottlenote.curation.service.CurationSpecResourceSyncService import org.slf4j.LoggerFactory import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty @@ -15,7 +17,9 @@ import org.springframework.stereotype.Component matchIfMissing = true ) class CurationSpecResourceSyncRunner( - private val curationSpecResourceSyncService: CurationSpecResourceSyncService + private val curationSpecResourceSyncService: CurationSpecResourceSyncService, + private val curationFeedPayloadRegenerationService: CurationFeedPayloadRegenerationService, + private val curationFeedRegenerationLock: CurationFeedRegenerationLock ) { private val log = LoggerFactory.getLogger(javaClass) @@ -28,5 +32,33 @@ class CurationSpecResourceSyncRunner( result.updatedCount(), result.totalCount() ) + regenerateFeedPayloads(result.changedSpecIds()) + } + + // feed_payload는 파생 데이터고 NULL fallback이 있다. 재생성 실패로 서비스 기동을 막지 않는다. + private fun regenerateFeedPayloads(changedSpecIds: List) { + if (changedSpecIds.isEmpty()) { + return + } + if (!curationFeedRegenerationLock.tryAcquire()) { + log.info("다른 인스턴스가 재생성 중이라 건너뜁니다: specIds={}", changedSpecIds) + return + } + try { + val regenerated = curationFeedPayloadRegenerationService.regenerate(changedSpecIds) + log.info( + "responseSpec 변경으로 feed_payload 재생성: specIds={}, curations={}", + changedSpecIds, + regenerated + ) + } catch (e: Exception) { + log.warn( + "feed_payload 재생성에 실패했습니다. 조회는 원본 payload로 대체됩니다: specIds={}", + changedSpecIds, + e + ) + } finally { + curationFeedRegenerationLock.release() + } } } diff --git a/bottlenote-admin-api/src/test/kotlin/app/unit/curation/CurationSpecResourceSyncRunnerTest.kt b/bottlenote-admin-api/src/test/kotlin/app/unit/curation/CurationSpecResourceSyncRunnerTest.kt new file mode 100644 index 000000000..a308fb248 --- /dev/null +++ b/bottlenote-admin-api/src/test/kotlin/app/unit/curation/CurationSpecResourceSyncRunnerTest.kt @@ -0,0 +1,95 @@ +package app.unit.curation + +import app.bottlenote.curation.config.CurationSpecResourceSyncRunner +import app.bottlenote.curation.domain.CurationFeedRegenerationLock +import app.bottlenote.curation.dto.response.CurationSpecSyncResponse +import app.bottlenote.curation.service.CurationFeedPayloadRegenerationService +import app.bottlenote.curation.service.CurationSpecResourceSyncService +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatCode +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Tag +import org.junit.jupiter.api.Test + +@Tag("unit") +@DisplayName("CurationSpecResourceSyncRunner 단위 테스트") +class CurationSpecResourceSyncRunnerTest { + + // 재생성 호출 인자를 기록하는 Fake. 실제 추출은 mono 단위 테스트가 검증한다. + private class RecordingRegenerationService( + private val onRegenerate: (Collection) -> Int = { it.size } + ) : CurationFeedPayloadRegenerationService(null, null, null) { + val calls = mutableListOf>() + + override fun regenerate(specIds: Collection?): Int { + calls.add(specIds ?: emptyList()) + return onRegenerate(specIds ?: emptyList()) + } + } + + private class FakeLock(private val acquirable: Boolean) : CurationFeedRegenerationLock { + var acquireCount = 0 + var releaseCount = 0 + + override fun tryAcquire(): Boolean { + acquireCount++ + return acquirable + } + + override fun release() { + releaseCount++ + } + } + + private fun syncServiceReturning(changedSpecIds: List) = object : CurationSpecResourceSyncService(null, null, null, null) { + override fun sync() = CurationSpecSyncResponse(0, 4, changedSpecIds) + } + + @Test + @DisplayName("변경된 스펙이 없으면 락을 잡지 않고 재생성도 하지 않는다") + fun sync_whenNoChangedSpec_skipsRegeneration() { + val regeneration = RecordingRegenerationService() + val lock = FakeLock(true) + + CurationSpecResourceSyncRunner(syncServiceReturning(emptyList()), regeneration, lock).sync() + + assertThat(regeneration.calls).isEmpty() + assertThat(lock.acquireCount).isZero() + } + + @Test + @DisplayName("변경된 스펙이 있으면 그 스펙만 재생성하고 락을 해제한다") + fun sync_whenSpecChanged_regeneratesAndReleasesLock() { + val regeneration = RecordingRegenerationService() + val lock = FakeLock(true) + + CurationSpecResourceSyncRunner(syncServiceReturning(listOf(1L, 3L)), regeneration, lock).sync() + + assertThat(regeneration.calls).containsExactly(listOf(1L, 3L)) + assertThat(lock.releaseCount).isEqualTo(1) + } + + @Test + @DisplayName("락을 얻지 못하면 재생성을 건너뛰고 해제도 하지 않는다") + fun sync_whenLockNotAcquired_skipsRegeneration() { + val regeneration = RecordingRegenerationService() + val lock = FakeLock(false) + + CurationSpecResourceSyncRunner(syncServiceReturning(listOf(1L)), regeneration, lock).sync() + + assertThat(regeneration.calls).isEmpty() + assertThat(lock.releaseCount).isZero() + } + + @Test + @DisplayName("재생성이 실패해도 기동을 막지 않고 락은 해제한다") + fun sync_whenRegenerationFails_doesNotBlockStartup() { + val regeneration = RecordingRegenerationService { throw IllegalStateException("추출 실패") } + val lock = FakeLock(true) + val runner = + CurationSpecResourceSyncRunner(syncServiceReturning(listOf(1L)), regeneration, lock) + + assertThatCode { runner.sync() }.doesNotThrowAnyException() + assertThat(lock.releaseCount).isEqualTo(1) + } +} 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/repository/RedisCurationFeedRegenerationLock.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/repository/RedisCurationFeedRegenerationLock.java new file mode 100644 index 000000000..4478e1a65 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/repository/RedisCurationFeedRegenerationLock.java @@ -0,0 +1,28 @@ +package app.bottlenote.curation.repository; + +import app.bottlenote.curation.domain.CurationFeedRegenerationLock; +import java.time.Duration; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +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; + + @Override + public boolean tryAcquire() { + return Boolean.TRUE.equals(redisTemplate.opsForValue().setIfAbsent(LOCK_KEY, "1", LOCK_TTL)); + } + + @Override + public void release() { + redisTemplate.delete(LOCK_KEY); + } +} diff --git a/plan/curation-feed-read-path.md b/plan/curation-feed-read-path.md index adcf04c8e..05a9266fa 100644 --- a/plan/curation-feed-read-path.md +++ b/plan/curation-feed-read-path.md @@ -129,7 +129,7 @@ PR #677로 `curation_extension.feed_payload` 쓰기 경로는 확보됐다. 다 - Files (advisory): 락 지원 신규, `CurationSpecResourceSyncRunner.kt`, 관련 테스트 - Depends: 1, 2 - Size: M -- Status: [ ] not done +- Status: [x] done ### Task 4: 피드 읽기 경로를 feed_payload로 전환 - Acceptance: Product 피드와 Admin 피드 프리뷰가 `feed_payload`를 소스로 쓰고, @@ -207,5 +207,11 @@ PR #677로 `curation_extension.feed_payload` 쓰기 경로는 확보됐다. 다 갈리므로 "현행 스펙에 피드 교차 x-graphql 없음"을 명시적 트립와이어로 고정했다. 현재 4개 스펙 모두 교차 0건이라 동등성이 성립하며, 새 스펙이 생기면 테스트가 깨져 재검토를 강제한다. 단위 테스트 8건 통과. +- Task 3 완료: 러너가 동기화 직후 `changedSpecIds`가 있을 때만 재생성한다. + 락은 `CurationFeedRegenerationLock`(도메인 인터페이스) + + `RedisCurationFeedRegenerationLock`(구현)으로 분리했다 — 처음엔 서비스에 + Redis를 직접 물렸으나 admin-api 테스트 클래스패스에 `RedisTemplate`이 없어 + 컴파일이 깨졌고, 레이어 표준 4(기술 세부사항은 구현에 격리)에도 이 형태가 맞다. + 재생성 예외는 경고 로그로 삼키고 락은 finally에서 해제한다. 러너 단위 4건 통과. - 알려진 한계: 재생성이 대상 전체를 한 트랜잭션에 적재한다. 현재 운영 28건 기준 문제없으나 큐레이션이 수천 건이 되면 chunk 처리가 필요하다. From db2f0970488e4c3e55f3136784b80d9a69b416d5 Mon Sep 17 00:00:00 2001 From: Whale0928 Date: Tue, 28 Jul 2026 14:04:15 +0900 Subject: [PATCH 06/12] =?UTF-8?q?fix:=20=EC=9E=AC=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=EA=B0=80=20=EB=82=A1=EC=9D=80=20feed=5Fpaylo?= =?UTF-8?q?ad=EB=A5=BC=20=EC=98=81=EA=B5=AC=ED=9E=88=20=EA=B5=B3=ED=9E=88?= =?UTF-8?q?=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sync가 DB 스펙을 이미 덮어쓴 뒤라 재생성이 실패하면 다음 기동에서 "변경 없음"으로 판정돼 재시도되지 않았다. feed_payload가 non-NULL이면 fallback도 걸리지 않아 낡은 응답이 계속 나간다. 재생성 전에 무효화를 먼저 커밋한다. 이후 단계가 실패해도 NULL이 남아 원본 payload로 fallback되므로 정확성은 항상 유지되고 잃는 것은 성능뿐이다. Redis 장애가 기동을 막던 경로도 함께 고쳤다. tryAcquire가 try 밖에 있어 연결 오류가 ApplicationReadyEvent로 전파됐다. 락 해제는 UUID 토큰으로 소유권을 확인해 TTL 만료 후 남의 락을 지우지 않게 했다. Co-Authored-By: Claude Opus 5 --- .../config/CurationSpecResourceSyncRunner.kt | 19 ++- .../AdminSpecBasedCurationIntegrationTest.kt | 125 ++++++++++++++++++ .../CurationSpecResourceSyncRunnerTest.kt | 47 +++++++ .../RedisCurationFeedRegenerationLock.java | 25 +++- ...urationFeedPayloadRegenerationService.java | 17 +++ ...oductSpecBasedCurationIntegrationTest.java | 51 +++++++ .../fixture/CurationFixtureFactory.java | 7 +- plan/curation-feed-read-path.md | 21 ++- 8 files changed, 301 insertions(+), 11 deletions(-) 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 30aace1e4..865c319ce 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 @@ -35,16 +35,20 @@ class CurationSpecResourceSyncRunner( regenerateFeedPayloads(result.changedSpecIds()) } - // feed_payload는 파생 데이터고 NULL fallback이 있다. 재생성 실패로 서비스 기동을 막지 않는다. + // feed_payload는 파생 데이터고 NULL fallback이 있다. 재생성 실패나 Redis 장애로 기동을 막지 않는다. private fun regenerateFeedPayloads(changedSpecIds: List) { if (changedSpecIds.isEmpty()) { return } - if (!curationFeedRegenerationLock.tryAcquire()) { - log.info("다른 인스턴스가 재생성 중이라 건너뜁니다: specIds={}", changedSpecIds) - return - } + var acquired = false try { + acquired = curationFeedRegenerationLock.tryAcquire() + if (!acquired) { + log.info("다른 인스턴스가 재생성 중이라 건너뜁니다: specIds={}", changedSpecIds) + return + } + // 무효화를 먼저 커밋한다. 뒤 단계가 실패해도 낡은 값이 아니라 NULL이 남아 원본으로 fallback된다. + curationFeedPayloadRegenerationService.invalidate(changedSpecIds) val regenerated = curationFeedPayloadRegenerationService.regenerate(changedSpecIds) log.info( "responseSpec 변경으로 feed_payload 재생성: specIds={}, curations={}", @@ -58,7 +62,10 @@ class CurationSpecResourceSyncRunner( e ) } finally { - curationFeedRegenerationLock.release() + if (acquired) { + runCatching { curationFeedRegenerationLock.release() } + .onFailure { log.warn("재생성 락 해제에 실패했습니다. TTL로 만료됩니다.", it) } + } } } } 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-admin-api/src/test/kotlin/app/unit/curation/CurationSpecResourceSyncRunnerTest.kt b/bottlenote-admin-api/src/test/kotlin/app/unit/curation/CurationSpecResourceSyncRunnerTest.kt index a308fb248..1fdc8d438 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/unit/curation/CurationSpecResourceSyncRunnerTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/unit/curation/CurationSpecResourceSyncRunnerTest.kt @@ -20,6 +20,12 @@ class CurationSpecResourceSyncRunnerTest { private val onRegenerate: (Collection) -> Int = { it.size } ) : CurationFeedPayloadRegenerationService(null, null, null) { val calls = mutableListOf>() + val invalidated = mutableListOf>() + + override fun invalidate(specIds: Collection?): Int { + invalidated.add(specIds ?: emptyList()) + return (specIds ?: emptyList()).size + } override fun regenerate(specIds: Collection?): Int { calls.add(specIds ?: emptyList()) @@ -27,6 +33,11 @@ class CurationSpecResourceSyncRunnerTest { } } + private class ExplodingLock(private val onAcquire: Boolean) : CurationFeedRegenerationLock { + override fun tryAcquire(): Boolean = if (onAcquire) throw IllegalStateException("Redis 연결 실패") else true + override fun release(): Unit = throw IllegalStateException("Redis 연결 실패") + } + private class FakeLock(private val acquirable: Boolean) : CurationFeedRegenerationLock { var acquireCount = 0 var releaseCount = 0 @@ -81,6 +92,42 @@ class CurationSpecResourceSyncRunnerTest { assertThat(lock.releaseCount).isZero() } + @Test + @DisplayName("무효화를 먼저 수행한 뒤 재생성한다") + fun sync_invalidatesBeforeRegenerating() { + val regeneration = RecordingRegenerationService() + val lock = FakeLock(true) + + CurationSpecResourceSyncRunner(syncServiceReturning(listOf(1L)), regeneration, lock).sync() + + assertThat(regeneration.invalidated).containsExactly(listOf(1L)) + assertThat(regeneration.calls).containsExactly(listOf(1L)) + } + + @Test + @DisplayName("락 획득 중 Redis가 죽어도 기동을 막지 않는다") + fun sync_whenLockAcquireThrows_doesNotBlockStartup() { + val runner = CurationSpecResourceSyncRunner( + syncServiceReturning(listOf(1L)), + RecordingRegenerationService(), + ExplodingLock(onAcquire = true) + ) + + assertThatCode { runner.sync() }.doesNotThrowAnyException() + } + + @Test + @DisplayName("락 해제 중 Redis가 죽어도 기동을 막지 않는다") + fun sync_whenLockReleaseThrows_doesNotBlockStartup() { + val runner = CurationSpecResourceSyncRunner( + syncServiceReturning(listOf(1L)), + RecordingRegenerationService(), + ExplodingLock(onAcquire = false) + ) + + assertThatCode { runner.sync() }.doesNotThrowAnyException() + } + @Test @DisplayName("재생성이 실패해도 기동을 막지 않고 락은 해제한다") fun sync_whenRegenerationFails_doesNotBlockStartup() { 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 index 4478e1a65..c948055ff 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/curation/repository/RedisCurationFeedRegenerationLock.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/repository/RedisCurationFeedRegenerationLock.java @@ -2,12 +2,16 @@ 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"; @@ -16,13 +20,30 @@ public class RedisCurationFeedRegenerationLock implements CurationFeedRegenerati private final RedisTemplate redisTemplate; + // 획득할 때마다 다른 토큰을 남긴다. TTL이 만료돼 다른 인스턴스가 락을 잡았을 때 남의 락을 지우지 않기 위해서다. + private final AtomicReference ownedToken = new AtomicReference<>(); + @Override public boolean tryAcquire() { - return Boolean.TRUE.equals(redisTemplate.opsForValue().setIfAbsent(LOCK_KEY, "1", LOCK_TTL)); + 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() { - redisTemplate.delete(LOCK_KEY); + 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/CurationFeedPayloadRegenerationService.java b/bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationFeedPayloadRegenerationService.java index 1d57627d9..dbfe31a21 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationFeedPayloadRegenerationService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationFeedPayloadRegenerationService.java @@ -25,6 +25,23 @@ public class CurationFeedPayloadRegenerationService { private final CurationExtensionRepository curationExtensionRepository; private final CurationFeedProjector feedProjector; + // 재생성 전에 먼저 비운다. 이후 단계가 실패해도 NULL fallback으로 정확한 응답이 나가기 때문이다. + // sync가 DB 스펙을 이미 덮어써서 다음 기동에는 "변경 없음"으로 보이므로, 낡은 값을 남기면 영구히 굳는다. + @Transactional + public int invalidate(Collection specIds) { + if (specIds == null || specIds.isEmpty()) { + return 0; + } + List extensions = + curationExtensionRepository.findAllBySpecIdIn(Set.copyOf(specIds)); + for (CurationExtension extension : extensions) { + extension.updateFeedPayload(null); + curationExtensionRepository.save(extension); + } + log.info("큐레이션 feed_payload 무효화: specIds={}, curations={}", specIds, extensions.size()); + return extensions.size(); + } + @Transactional public int regenerate(Collection specIds) { if (specIds == null || specIds.isEmpty()) { 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/plan/curation-feed-read-path.md b/plan/curation-feed-read-path.md index 05a9266fa..60c1fd808 100644 --- a/plan/curation-feed-read-path.md +++ b/plan/curation-feed-read-path.md @@ -157,7 +157,7 @@ PR #677로 `curation_extension.feed_payload` 쓰기 경로는 확보됐다. 다 `CurationFixtureFactory.java` - Depends: 4 - Size: M -- Status: [ ] not done +- Status: [x] done ### Task 6: Admin 프리뷰 전환과 재생성 통합 검증 - Acceptance: Admin 피드 프리뷰가 `feed_payload` 기준으로 응답하고 NULL이면 @@ -169,7 +169,7 @@ PR #677로 `curation_extension.feed_payload` 쓰기 경로는 확보됐다. 다 통합 테스트 신규 - Depends: 3, 4 - Size: M -- Status: [ ] not done +- Status: [x] done ## Progress Log @@ -213,5 +213,22 @@ PR #677로 `curation_extension.feed_payload` 쓰기 경로는 확보됐다. 다 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 토큰 비교 후 삭제. - 알려진 한계: 재생성이 대상 전체를 한 트랜잭션에 적재한다. 현재 운영 28건 기준 문제없으나 큐레이션이 수천 건이 되면 chunk 처리가 필요하다. From ebd49252d523d2a3139ffe29040d8d8a6cdfe0b8 Mon Sep 17 00:00:00 2001 From: Whale0928 Date: Tue, 28 Jul 2026 15:29:44 +0900 Subject: [PATCH 07/12] =?UTF-8?q?refactor:=20=EC=9E=AC=EC=83=9D=EC=84=B1?= =?UTF-8?q?=20=EC=98=A4=EC=BC=80=EC=8A=A4=ED=8A=B8=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=85=98=EC=9D=84=20=EB=9F=AC=EB=84=88=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=84=9C=EB=B9=84=EC=8A=A4?= =?UTF-8?q?=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 러너가 락 획득/해제, 무효화 순서, 실패 정책을 모두 들고 있어 config 클래스가 비대해졌다. CurationFeedPayloadRefreshService로 옮겨 러너는 위임 한 줄만 남긴다. 정책 테스트도 mono로 이동했다. regenerate의 spec == null 분기는 findAllBySpecIdIn(specs.keySet())으로 조회하므로 성립할 수 없는 죽은 코드라 제거했다. 중복 가드는 헬퍼로 모으고, 관리 엔티티에 대한 불필요한 save() 호출은 더티체킹에 맡겼다. refresh()는 무효화와 재생성이 각자 커밋되어야 하므로 NOT_SUPPORTED로 트랜잭션 경계 없음을 명시한다. Co-Authored-By: Claude Opus 5 --- .../config/CurationSpecResourceSyncRunner.kt | 42 +---- .../CurationSpecResourceSyncRunnerTest.kt | 142 ---------------- .../CurationFeedPayloadRefreshService.java | 57 +++++++ ...urationFeedPayloadRegenerationService.java | 48 +++--- ...CurationFeedPayloadRefreshServiceTest.java | 151 ++++++++++++++++++ plan/curation-feed-read-path.md | 8 + 6 files changed, 240 insertions(+), 208 deletions(-) delete mode 100644 bottlenote-admin-api/src/test/kotlin/app/unit/curation/CurationSpecResourceSyncRunnerTest.kt create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationFeedPayloadRefreshService.java create mode 100644 bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationFeedPayloadRefreshServiceTest.java 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 865c319ce..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,7 +1,6 @@ package app.bottlenote.curation.config -import app.bottlenote.curation.domain.CurationFeedRegenerationLock -import app.bottlenote.curation.service.CurationFeedPayloadRegenerationService +import app.bottlenote.curation.service.CurationFeedPayloadRefreshService import app.bottlenote.curation.service.CurationSpecResourceSyncService import org.slf4j.LoggerFactory import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty @@ -18,8 +17,7 @@ import org.springframework.stereotype.Component ) class CurationSpecResourceSyncRunner( private val curationSpecResourceSyncService: CurationSpecResourceSyncService, - private val curationFeedPayloadRegenerationService: CurationFeedPayloadRegenerationService, - private val curationFeedRegenerationLock: CurationFeedRegenerationLock + private val curationFeedPayloadRefreshService: CurationFeedPayloadRefreshService ) { private val log = LoggerFactory.getLogger(javaClass) @@ -32,40 +30,6 @@ class CurationSpecResourceSyncRunner( result.updatedCount(), result.totalCount() ) - regenerateFeedPayloads(result.changedSpecIds()) - } - - // feed_payload는 파생 데이터고 NULL fallback이 있다. 재생성 실패나 Redis 장애로 기동을 막지 않는다. - private fun regenerateFeedPayloads(changedSpecIds: List) { - if (changedSpecIds.isEmpty()) { - return - } - var acquired = false - try { - acquired = curationFeedRegenerationLock.tryAcquire() - if (!acquired) { - log.info("다른 인스턴스가 재생성 중이라 건너뜁니다: specIds={}", changedSpecIds) - return - } - // 무효화를 먼저 커밋한다. 뒤 단계가 실패해도 낡은 값이 아니라 NULL이 남아 원본으로 fallback된다. - curationFeedPayloadRegenerationService.invalidate(changedSpecIds) - val regenerated = curationFeedPayloadRegenerationService.regenerate(changedSpecIds) - log.info( - "responseSpec 변경으로 feed_payload 재생성: specIds={}, curations={}", - changedSpecIds, - regenerated - ) - } catch (e: Exception) { - log.warn( - "feed_payload 재생성에 실패했습니다. 조회는 원본 payload로 대체됩니다: specIds={}", - changedSpecIds, - e - ) - } finally { - if (acquired) { - runCatching { curationFeedRegenerationLock.release() } - .onFailure { log.warn("재생성 락 해제에 실패했습니다. TTL로 만료됩니다.", it) } - } - } + curationFeedPayloadRefreshService.refresh(result.changedSpecIds()) } } diff --git a/bottlenote-admin-api/src/test/kotlin/app/unit/curation/CurationSpecResourceSyncRunnerTest.kt b/bottlenote-admin-api/src/test/kotlin/app/unit/curation/CurationSpecResourceSyncRunnerTest.kt deleted file mode 100644 index 1fdc8d438..000000000 --- a/bottlenote-admin-api/src/test/kotlin/app/unit/curation/CurationSpecResourceSyncRunnerTest.kt +++ /dev/null @@ -1,142 +0,0 @@ -package app.unit.curation - -import app.bottlenote.curation.config.CurationSpecResourceSyncRunner -import app.bottlenote.curation.domain.CurationFeedRegenerationLock -import app.bottlenote.curation.dto.response.CurationSpecSyncResponse -import app.bottlenote.curation.service.CurationFeedPayloadRegenerationService -import app.bottlenote.curation.service.CurationSpecResourceSyncService -import org.assertj.core.api.Assertions.assertThat -import org.assertj.core.api.Assertions.assertThatCode -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Tag -import org.junit.jupiter.api.Test - -@Tag("unit") -@DisplayName("CurationSpecResourceSyncRunner 단위 테스트") -class CurationSpecResourceSyncRunnerTest { - - // 재생성 호출 인자를 기록하는 Fake. 실제 추출은 mono 단위 테스트가 검증한다. - private class RecordingRegenerationService( - private val onRegenerate: (Collection) -> Int = { it.size } - ) : CurationFeedPayloadRegenerationService(null, null, null) { - val calls = mutableListOf>() - val invalidated = mutableListOf>() - - override fun invalidate(specIds: Collection?): Int { - invalidated.add(specIds ?: emptyList()) - return (specIds ?: emptyList()).size - } - - override fun regenerate(specIds: Collection?): Int { - calls.add(specIds ?: emptyList()) - return onRegenerate(specIds ?: emptyList()) - } - } - - private class ExplodingLock(private val onAcquire: Boolean) : CurationFeedRegenerationLock { - override fun tryAcquire(): Boolean = if (onAcquire) throw IllegalStateException("Redis 연결 실패") else true - override fun release(): Unit = throw IllegalStateException("Redis 연결 실패") - } - - private class FakeLock(private val acquirable: Boolean) : CurationFeedRegenerationLock { - var acquireCount = 0 - var releaseCount = 0 - - override fun tryAcquire(): Boolean { - acquireCount++ - return acquirable - } - - override fun release() { - releaseCount++ - } - } - - private fun syncServiceReturning(changedSpecIds: List) = object : CurationSpecResourceSyncService(null, null, null, null) { - override fun sync() = CurationSpecSyncResponse(0, 4, changedSpecIds) - } - - @Test - @DisplayName("변경된 스펙이 없으면 락을 잡지 않고 재생성도 하지 않는다") - fun sync_whenNoChangedSpec_skipsRegeneration() { - val regeneration = RecordingRegenerationService() - val lock = FakeLock(true) - - CurationSpecResourceSyncRunner(syncServiceReturning(emptyList()), regeneration, lock).sync() - - assertThat(regeneration.calls).isEmpty() - assertThat(lock.acquireCount).isZero() - } - - @Test - @DisplayName("변경된 스펙이 있으면 그 스펙만 재생성하고 락을 해제한다") - fun sync_whenSpecChanged_regeneratesAndReleasesLock() { - val regeneration = RecordingRegenerationService() - val lock = FakeLock(true) - - CurationSpecResourceSyncRunner(syncServiceReturning(listOf(1L, 3L)), regeneration, lock).sync() - - assertThat(regeneration.calls).containsExactly(listOf(1L, 3L)) - assertThat(lock.releaseCount).isEqualTo(1) - } - - @Test - @DisplayName("락을 얻지 못하면 재생성을 건너뛰고 해제도 하지 않는다") - fun sync_whenLockNotAcquired_skipsRegeneration() { - val regeneration = RecordingRegenerationService() - val lock = FakeLock(false) - - CurationSpecResourceSyncRunner(syncServiceReturning(listOf(1L)), regeneration, lock).sync() - - assertThat(regeneration.calls).isEmpty() - assertThat(lock.releaseCount).isZero() - } - - @Test - @DisplayName("무효화를 먼저 수행한 뒤 재생성한다") - fun sync_invalidatesBeforeRegenerating() { - val regeneration = RecordingRegenerationService() - val lock = FakeLock(true) - - CurationSpecResourceSyncRunner(syncServiceReturning(listOf(1L)), regeneration, lock).sync() - - assertThat(regeneration.invalidated).containsExactly(listOf(1L)) - assertThat(regeneration.calls).containsExactly(listOf(1L)) - } - - @Test - @DisplayName("락 획득 중 Redis가 죽어도 기동을 막지 않는다") - fun sync_whenLockAcquireThrows_doesNotBlockStartup() { - val runner = CurationSpecResourceSyncRunner( - syncServiceReturning(listOf(1L)), - RecordingRegenerationService(), - ExplodingLock(onAcquire = true) - ) - - assertThatCode { runner.sync() }.doesNotThrowAnyException() - } - - @Test - @DisplayName("락 해제 중 Redis가 죽어도 기동을 막지 않는다") - fun sync_whenLockReleaseThrows_doesNotBlockStartup() { - val runner = CurationSpecResourceSyncRunner( - syncServiceReturning(listOf(1L)), - RecordingRegenerationService(), - ExplodingLock(onAcquire = false) - ) - - assertThatCode { runner.sync() }.doesNotThrowAnyException() - } - - @Test - @DisplayName("재생성이 실패해도 기동을 막지 않고 락은 해제한다") - fun sync_whenRegenerationFails_doesNotBlockStartup() { - val regeneration = RecordingRegenerationService { throw IllegalStateException("추출 실패") } - val lock = FakeLock(true) - val runner = - CurationSpecResourceSyncRunner(syncServiceReturning(listOf(1L)), regeneration, lock) - - assertThatCode { runner.sync() }.doesNotThrowAnyException() - assertThat(lock.releaseCount).isEqualTo(1) - } -} 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 index dbfe31a21..5fb00ee2b 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationFeedPayloadRegenerationService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationFeedPayloadRegenerationService.java @@ -29,43 +29,37 @@ public class CurationFeedPayloadRegenerationService { // sync가 DB 스펙을 이미 덮어써서 다음 기동에는 "변경 없음"으로 보이므로, 낡은 값을 남기면 영구히 굳는다. @Transactional public int invalidate(Collection specIds) { - if (specIds == null || specIds.isEmpty()) { - return 0; - } - List extensions = - curationExtensionRepository.findAllBySpecIdIn(Set.copyOf(specIds)); - for (CurationExtension extension : extensions) { - extension.updateFeedPayload(null); - curationExtensionRepository.save(extension); - } + List extensions = extensionsOf(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) { - if (specIds == null || specIds.isEmpty()) { - return 0; - } Map specs = - curationSpecRepository.findAllByIdIn(Set.copyOf(specIds)).stream() + curationSpecRepository.findAllByIdIn(idsOf(specIds)).stream() .collect(Collectors.toMap(CurationSpec::getId, Function.identity())); + // feed_payload가 NULL인 레거시 행도 대상이다. 재생성이 backfill을 겸한다. List extensions = curationExtensionRepository.findAllBySpecIdIn(specs.keySet()); - int regenerated = 0; - for (CurationExtension extension : extensions) { - CurationSpec spec = specs.get(extension.getSpecId()); - if (spec == null) { - continue; - } - // feed_payload가 NULL인 레거시 행도 대상이다. 재생성이 backfill을 겸한다. - extension.updateFeedPayload( - feedProjector.extractFeedPayload(spec.getResponseSpec(), extension.getPayload())); - curationExtensionRepository.save(extension); - regenerated++; - } - log.info("큐레이션 feed_payload 재생성 완료: specIds={}, curations={}", specs.keySet(), regenerated); - return regenerated; + 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(); + } + + private List extensionsOf(Collection specIds) { + Set ids = idsOf(specIds); + return ids.isEmpty() ? List.of() : curationExtensionRepository.findAllBySpecIdIn(ids); + } + + private Set idsOf(Collection specIds) { + return specIds == null ? Set.of() : Set.copyOf(specIds); } } 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/plan/curation-feed-read-path.md b/plan/curation-feed-read-path.md index 60c1fd808..2db093f92 100644 --- a/plan/curation-feed-read-path.md +++ b/plan/curation-feed-read-path.md @@ -230,5 +230,13 @@ PR #677로 `curation_extension.feed_payload` 쓰기 경로는 확보됐다. 다 release도 runCatching으로 감쌌다. 락 예외 경로 테스트 2건 추가. ③ 락 해제가 소유권을 확인하지 않아 TTL 만료 후 남의 락을 지울 수 있었다. → UUID 토큰 비교 후 삭제. +- 정리: 러너가 락 획득/해제·무효화 순서·실패 정책을 모두 들고 있어 config 클래스가 + 비대해졌다. 오케스트레이션을 `CurationFeedPayloadRefreshService`(mono)로 옮겨 + 러너를 35줄/위임 한 줄로 줄였고, 정책 테스트도 mono로 이동했다. + `regenerate`의 `spec == null` 분기는 `findAllBySpecIdIn(specs.keySet())`로 + 조회하므로 성립할 수 없는 죽은 코드라 제거했고, 중복 가드는 헬퍼로 모았다. + 불필요한 `save()` 호출도 걷어내 더티체킹에 맡겼다. + `refresh()`는 무효화·재생성이 각자 커밋되어야 해서 `NOT_SUPPORTED`로 + "경계 없음"을 명시했다 — ArchUnit 트랜잭션 룰의 취지(경계 명확화)에 맞는 선언이다. - 알려진 한계: 재생성이 대상 전체를 한 트랜잭션에 적재한다. 현재 운영 28건 기준 문제없으나 큐레이션이 수천 건이 되면 chunk 처리가 필요하다. From 164b9a990393d4ec727c46238be297357fec10cc Mon Sep 17 00:00:00 2001 From: Whale0928 Date: Tue, 28 Jul 2026 15:34:46 +0900 Subject: [PATCH 08/12] =?UTF-8?q?refactor:=20=EC=9E=AC=EC=83=9D=EC=84=B1?= =?UTF-8?q?=20=EC=84=9C=EB=B9=84=EC=8A=A4=EC=9D=98=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?null=20=EB=B0=A9=EC=96=B4=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refresh()가 이미 null과 빈 목록을 막는데 하위 서비스가 다시 nullable 계약을 유지해 헬퍼 두 개가 남아 있었다. invalidate만 빈 조회를 피하고 regenerate는 피하지 않는 비대칭도 함께 정리했다. Co-Authored-By: Claude Opus 5 --- .../CurationFeedPayloadRegenerationService.java | 14 +++----------- ...CurationFeedPayloadRegenerationServiceTest.java | 4 ++-- 2 files changed, 5 insertions(+), 13 deletions(-) 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 index 5fb00ee2b..c346f36ca 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationFeedPayloadRegenerationService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/curation/service/CurationFeedPayloadRegenerationService.java @@ -29,7 +29,8 @@ public class CurationFeedPayloadRegenerationService { // sync가 DB 스펙을 이미 덮어써서 다음 기동에는 "변경 없음"으로 보이므로, 낡은 값을 남기면 영구히 굳는다. @Transactional public int invalidate(Collection specIds) { - List extensions = extensionsOf(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(); @@ -38,7 +39,7 @@ public int invalidate(Collection specIds) { @Transactional public int regenerate(Collection specIds) { Map specs = - curationSpecRepository.findAllByIdIn(idsOf(specIds)).stream() + curationSpecRepository.findAllByIdIn(Set.copyOf(specIds)).stream() .collect(Collectors.toMap(CurationSpec::getId, Function.identity())); // feed_payload가 NULL인 레거시 행도 대상이다. 재생성이 backfill을 겸한다. @@ -53,13 +54,4 @@ public int regenerate(Collection specIds) { "큐레이션 feed_payload 재생성 완료: specIds={}, curations={}", specs.keySet(), extensions.size()); return extensions.size(); } - - private List extensionsOf(Collection specIds) { - Set ids = idsOf(specIds); - return ids.isEmpty() ? List.of() : curationExtensionRepository.findAllBySpecIdIn(ids); - } - - private Set idsOf(Collection specIds) { - return specIds == null ? Set.of() : Set.copyOf(specIds); - } } 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 index 8472e4143..2cf13629b 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationFeedPayloadRegenerationServiceTest.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationFeedPayloadRegenerationServiceTest.java @@ -99,10 +99,10 @@ void regenerate_keepsSourcePayloadIntact() { } @Test - @DisplayName("빈 목록이나 null을 받으면 아무것도 하지 않는다") + @DisplayName("빈 목록을 받으면 아무것도 하지 않는다") void regenerate_whenNoSpecIds_doesNothing() { assertThat(regenerator.regenerate(List.of())).isZero(); - assertThat(regenerator.regenerate(null)).isZero(); + assertThat(regenerator.invalidate(List.of())).isZero(); } @Test From b56f2c58d33effe5d606558ec4bb4fe474259805 Mon Sep 17 00:00:00 2001 From: Whale0928 Date: Tue, 28 Jul 2026 15:45:26 +0900 Subject: [PATCH 09/12] =?UTF-8?q?docs:=20curation-feed-read-path=20plan=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20=EC=8A=A4=ED=83=AC=ED=94=84=20=EB=B0=8F=20?= =?UTF-8?q?complete=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- .../{ => complete}/curation-feed-read-path.md | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) rename plan/{ => complete}/curation-feed-read-path.md (91%) diff --git a/plan/curation-feed-read-path.md b/plan/complete/curation-feed-read-path.md similarity index 91% rename from plan/curation-feed-read-path.md rename to plan/complete/curation-feed-read-path.md index 2db093f92..0fdaac30c 100644 --- a/plan/curation-feed-read-path.md +++ b/plan/complete/curation-feed-read-path.md @@ -1,3 +1,30 @@ +``` +================================================================================ + 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 From ab389df8d782edab4ff7b59a3934c481e94adde2 Mon Sep 17 00:00:00 2001 From: Whale0928 Date: Tue, 28 Jul 2026 16:46:42 +0900 Subject: [PATCH 10/12] =?UTF-8?q?feat:=20=EC=8B=9C=EC=9D=8C=ED=9A=8C=20?= =?UTF-8?q?=EC=8A=A4=ED=8E=99=EC=97=90=20=EC=9E=A5=EC=86=8C=EB=AA=85=C2=B7?= =?UTF-8?q?=EC=9A=B0=ED=8E=B8=EB=B2=88=ED=98=B8=20=ED=95=84=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin 시음회 form이 requestSpec에 없는 placeName을 FE 하드코딩으로 주입해 저장하고 있었다(개발 DB 14건 중 12건). 스펙을 SSoT로 되돌린다. placeName과 zipCode를 requestSpec·responseSpec에 추가하고 둘 다 optional로 둔다. required로 올리면 placeName 없는 기존 2건과 zipCode 없는 전 건이 어드민 재저장 시 검증 실패한다. placeName은 x-feed를 켜 피드에 노출한다. order는 시간(20)과 주소(30) 사이인 25로 끼워 기존 order 값을 건드리지 않는다. zipCode는 피드에 싣지 않는다. CurationPayloadValidator가 pattern을 검증하지 않으므로 zipCode에 minLength/maxLength 5를 함께 건다. 길이는 서버가 강제하고 pattern은 FE·문서용으로 남긴다. Co-Authored-By: Claude Opus 5 --- .../curation/whisky_tasting_event.json | 42 +++++ .../CurationPlaceFieldContractTest.java | 171 ++++++++++++++++++ plan/curation-place-search-contract.md | 145 +++++++++++++++ 3 files changed, 358 insertions(+) create mode 100644 bottlenote-mono/src/test/java/app/bottlenote/curation/service/CurationPlaceFieldContractTest.java create mode 100644 plan/curation-place-search-contract.md 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/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/plan/curation-place-search-contract.md b/plan/curation-place-search-contract.md new file mode 100644 index 000000000..97389ce3c --- /dev/null +++ b/plan/curation-place-search-contract.md @@ -0,0 +1,145 @@ +# 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` 미검증은 + 이미 문서화된 결정이라 유지. From 76c8b09eb8eb0a83a76f56d51da1fff5af0be398 Mon Sep 17 00:00:00 2001 From: Whale0928 Date: Tue, 28 Jul 2026 16:46:43 +0900 Subject: [PATCH 11/12] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EB=9E=A8=20=EC=8A=A4=ED=8E=99=EC=9D=84=20=EC=9E=A5=EC=86=8C?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EA=B3=84=EC=95=BD=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=95=EB=A0=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit placeName과 address의 x-field-style을 plain-text에서 address-search로 바꾸고 zipCode를 optional로 추가한다. 이슈는 address만 언급하지만 한쪽만 바꾸면 시음회와 계약이 어긋난다. x-field-style은 렌더링 힌트라 응답 값에는 영향이 없다. Co-Authored-By: Claude Opus 5 --- .../main/resources/openapi/curation/program.json | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) 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": { From de9396f224f99405e3e5506e9639115c3fc1421d Mon Sep 17 00:00:00 2001 From: Whale0928 Date: Tue, 28 Jul 2026 16:59:06 +0900 Subject: [PATCH 12/12] =?UTF-8?q?docs:=20curation-place-search-contract=20?= =?UTF-8?q?plan=20=EC=99=84=EB=A3=8C=20=EC=8A=A4=ED=83=AC=ED=94=84=20?= =?UTF-8?q?=EB=B0=8F=20complete=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- .../curation-place-search-contract.md | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) rename plan/{ => complete}/curation-place-search-contract.md (88%) diff --git a/plan/curation-place-search-contract.md b/plan/complete/curation-place-search-contract.md similarity index 88% rename from plan/curation-place-search-contract.md rename to plan/complete/curation-place-search-contract.md index 97389ce3c..c5bb813a2 100644 --- a/plan/curation-place-search-contract.md +++ b/plan/complete/curation-place-search-contract.md @@ -1,3 +1,29 @@ +``` +================================================================================ + 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