feat: 큐레이션 피드 읽기 경로 전환 및 스펙 변경 시 재생성 - #680
Merged
Merged
Conversation
sync()가 기존 스펙을 비교 없이 매 기동 무조건 덮어써 실제로 바뀐 스펙을 가릴 수 없었다. canonical JSON(키 재귀 정렬 + 실수 trailing zero 제거) SHA-256 지문으로 비교해 changedSpecIds를 반환한다. MySQL JSON 컬럼이 키를 자체 정렬하고 Jackson 직렬화 표기가 경로마다 달라 단순 비교는 매번 변경으로 오판한다. 비교는 update()가 값을 덮어쓰기 전에 수행한다. 신규 생성 스펙은 큐레이션이 없으므로 변경으로 잡지 않는다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
responseSpec이 바뀐 스펙의 큐레이션을 조회해 feed_payload를 다시 만든다. feed_payload가 NULL인 레거시 행도 대상이므로 backfill을 겸한다. CurationExtension에 @DynamicUpdate를 붙였다. 전체 컬럼 UPDATE면 재생성이 로드한 stale payload가 그 사이 어드민이 저장한 값을 덮어써 SSOT가 유실된다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@service 클래스는 Service로 끝나야 한다는 룰(서비스_클래스_명명_규칙_검증)을 위반해 check_rule_test가 실패했다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
admin-api 러너가 동기화 직후 responseSpec이 바뀐 스펙에 한해 재생성한다. 인스턴스가 여럿일 때 중복 실행되지 않도록 공유 저장소로 잠그며, 락을 얻지 못한 인스턴스는 건너뛰고 정상 기동한다. 재생성 실패는 경고 로그로 삼킨다. feed_payload는 파생 데이터이고 조회는 원본 payload로 fallback되므로 서비스 기동을 막을 이유가 없다. 락은 도메인 인터페이스와 Redis 구현으로 분리했다. 기술 세부사항을 구현 안에 격리하고, 테스트가 Redis 없이 fake로 대체할 수 있게 한다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
sync가 DB 스펙을 이미 덮어쓴 뒤라 재생성이 실패하면 다음 기동에서 "변경 없음"으로 판정돼 재시도되지 않았다. feed_payload가 non-NULL이면 fallback도 걸리지 않아 낡은 응답이 계속 나간다. 재생성 전에 무효화를 먼저 커밋한다. 이후 단계가 실패해도 NULL이 남아 원본 payload로 fallback되므로 정확성은 항상 유지되고 잃는 것은 성능뿐이다. Redis 장애가 기동을 막던 경로도 함께 고쳤다. tryAcquire가 try 밖에 있어 연결 오류가 ApplicationReadyEvent로 전파됐다. 락 해제는 UUID 토큰으로 소유권을 확인해 TTL 만료 후 남의 락을 지우지 않게 했다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
러너가 락 획득/해제, 무효화 순서, 실패 정책을 모두 들고 있어 config 클래스가 비대해졌다. CurationFeedPayloadRefreshService로 옮겨 러너는 위임 한 줄만 남긴다. 정책 테스트도 mono로 이동했다. regenerate의 spec == null 분기는 findAllBySpecIdIn(specs.keySet())으로 조회하므로 성립할 수 없는 죽은 코드라 제거했다. 중복 가드는 헬퍼로 모으고, 관리 엔티티에 대한 불필요한 save() 호출은 더티체킹에 맡겼다. refresh()는 무효화와 재생성이 각자 커밋되어야 하므로 NOT_SUPPORTED로 트랜잭션 경계 없음을 명시한다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
refresh()가 이미 null과 빈 목록을 막는데 하위 서비스가 다시 nullable 계약을 유지해 헬퍼 두 개가 남아 있었다. invalidate만 빈 조회를 피하고 regenerate는 피하지 않는 비대칭도 함께 정리했다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8 tasks
This was referenced Jul 28, 2026
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 <noreply@anthropic.com>
placeName과 address의 x-field-style을 plain-text에서 address-search로 바꾸고 zipCode를 optional로 추가한다. 이슈는 address만 언급하지만 한쪽만 바꾸면 시음회와 계약이 어긋난다. x-field-style은 렌더링 힌트라 응답 값에는 영향이 없다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
배경
curation_extension.feed_payload쓰기 경로는 확보됐다. 다만 아무도 그 값을 읽지 않았고, 스펙 JSON의x-feed가 바뀌면 저장된 값이 조용히 낡는 상태였다변경 사항
1. 스펙 변경 감지
sync()가 기존 스펙을 비교 없이 매 기동 무조건 덮어써 "실제로 바뀐 스펙"을 가릴 수 없었다.CurationSpecFingerprint(canonical JSON + SHA-256)로 비교해changedSpecIds를 반환한다update()가 값을 덮어쓰기 전에 수행한다. 신규 생성 스펙은 큐레이션이 없어 변경으로 잡지 않는다2. 변경된 스펙의 feed_payload 재생성
CurationFeedPayloadRegenerationService가 해당 스펙의 큐레이션만 다시 만든다.feed_payload가 NULL인 레거시 행도 대상이라 backfill을 겸한다3. 피드 조회 소스 전환
CurationExtension.feedSource()(NULL이면 원본 fallback)를 쓴다. 빈 결과는[]/{}로 저장되므로 NULL만 fallback 조건이다소스 → materializeFeed → projectPayload를 유지한다.projectPayload를 통과시켜야feed_payload에 섞인 숨은 입력값(x-feed가 아닌 argFrom 값)이 응답에 노출되지 않는다검증
integration 268건, admin integration 209건, admin default 58건, restDocs 136건, Spotless
materializeFeed포함)을 원본/feed_payload두 소스로 각각 돌려 결과가 같음을 확인payload가 동일함을 실제 API로 확인responseSpec미변경 시 0건, 변경 시 해당 스펙만 갱신되고 다른 스펙은 NULL 유지, 원본 payload 불변을 실제 DB로 확인코드 정리
리뷰 지적을 방어 코드로 덧붙이다 러너가 비대해져(락 획득·해제, 무효화 순서, 실패 정책 40줄) 오케스트레이션을
CurationFeedPayloadRefreshService로 분리했다. 러너는 위임 한 줄만 남는다.regenerate의spec == null분기는findAllBySpecIdIn(specs.keySet())로 조회하므로 성립 불가한 죽은 코드라 제거했고, 중복 null 방어와 관리 엔티티에 대한 불필요한save()도 걷어냈다.refresh()는 무효화와 재생성이 각자 커밋되어야 해서@Transactional(NOT_SUPPORTED)로 "경계 없음"을 명시한다. ArchUnit 트랜잭션 룰을 끄는 대신 그 취지(경계 명확화)에 맞게 선언한 것이다.리뷰 반영
구현은 Claude가, 독립 리뷰는 codex CLI가 맡아 작성자와 리뷰어를 분리했다. 지적 중 실제 결함으로 확인돼 반영한 것들:
tryAcquire를 try 안으로,release를 runCatching으로. 락 예외 경로 테스트 2건 추가payload가 어드민 저장분을 덮어썼다 →@DynamicUpdatematerializeFeed를 건너뛰고 "두 경로 동일"이라 가정했으나, 인자가 비면 materializer가writeTo에 null/[]을 써서 배열 원소를 살린다 → 테스트를 전체 파이프라인 비교로 교체하고 트립와이어 추가changedSpecIdsnull을 빈 목록으로 뭉개 재생성 누락을 "변경 없음"으로 위장알려진 한계
x-graphql엔트리가 0건이라 전환 전후 응답이 동일하다. 교차 엔트리가 생기면 원본 경로는 인자가 비어도writeTo에 null을 채워 배열 원소를 살리는 반면feed_payload경로는 그 원소를 버려 응답이 갈릴 수 있다. 테스트로 고정해뒀으므로 그런 스펙이 추가되면 빌드가 깨져 재검토를 강제한다@Version컬럼이 필요해 "스키마 변경 없음" 범위를 벗어난다. 기동 시점의 짧은 창이고 어드민이 다시 저장하면 해소된다제외 범위 (후속 과제)
x-feed스펙 보정 — 개발 DB 실측 결과feed_payload축소율이 WHISKY_TASTING_EVENT 88%인 반면 RECOMMENDED_WHISKY·WHISKY_PAIRING은 4%다. 뒤 둘은alcohol객체 전체가x-feed라 거의 줄지 않는다. 피드 응답payload는 API 계약이라 좁히면 breaking change이며 프론트 협의가 선행되어야 한다. 이번에 재생성이 들어갔으므로, 협의 후 스펙 JSON만 고쳐도 재생성이 자동 반영한다