Skip to content

feat: 큐레이션 피드 읽기 경로 전환 및 스펙 변경 시 재생성 - #680

Merged
Whale0928 merged 12 commits into
mainfrom
Whale0928/feed-read-path
Jul 28, 2026
Merged

feat: 큐레이션 피드 읽기 경로 전환 및 스펙 변경 시 재생성#680
Whale0928 merged 12 commits into
mainfrom
Whale0928/feed-read-path

Conversation

@Whale0928

Copy link
Copy Markdown
Collaborator

배경

  • 이슈: [BE/ADR] 큐레이션 피드 Read Model 분리 workspace#322 (큐레이션 피드 Read Model 분리 ADR)
  • PR #677로 curation_extension.feed_payload 쓰기 경로는 확보됐다. 다만 아무도 그 값을 읽지 않았고, 스펙 JSON의 x-feed가 바뀌면 저장된 값이 조용히 낡는 상태였다
  • 이번 PR은 읽기 경로 전환스펙 변경 시 재생성을 함께 넣는다. ADR에서 이 기능이 처음으로 사용자에게 보이는 동작을 바꾸는 단계다

변경 사항

1. 스펙 변경 감지

  • sync()가 기존 스펙을 비교 없이 매 기동 무조건 덮어써 "실제로 바뀐 스펙"을 가릴 수 없었다. CurationSpecFingerprint(canonical JSON + SHA-256)로 비교해 changedSpecIds를 반환한다
  • 단순 문자열 비교는 쓸 수 없다. MySQL JSON 컬럼이 객체 키를 자체 정렬하고 Jackson 직렬화 표기가 경로마다 달라 매번 "변경됨"으로 오판한다. 키를 재귀 정렬하고 실수의 trailing zero를 떨어낸 뒤 해시한다
  • 비교는 update()가 값을 덮어쓰기 전에 수행한다. 신규 생성 스펙은 큐레이션이 없어 변경으로 잡지 않는다
  • 스키마 변경 없음 — 해시를 저장하지 않고 DB의 기존 값과 즉석 비교한다

2. 변경된 스펙의 feed_payload 재생성

  • CurationFeedPayloadRegenerationService가 해당 스펙의 큐레이션만 다시 만든다. feed_payload가 NULL인 레거시 행도 대상이라 backfill을 겸한다
  • admin-api 러너가 동기화 직후 실행한다. 인스턴스가 여럿일 때 중복 실행되지 않도록 Redis로 잠그고, 락을 얻지 못한 인스턴스는 건너뛰고 정상 기동한다
  • 재생성 전에 무효화를 먼저 커밋한다. sync가 DB 스펙을 이미 덮어쓴 뒤라, 재생성이 실패하면 다음 기동에는 "변경 없음"으로 판정돼 재시도되지 않는다. 낡은 값을 남기면 영구히 굳으므로 NULL을 남겨 원본 fallback으로 정확성을 유지한다
  • 재생성 실패와 Redis 장애 모두 경고 로그만 남기고 기동을 계속한다

3. 피드 조회 소스 전환

  • Product 피드와 Admin 프리뷰가 CurationExtension.feedSource()(NULL이면 원본 fallback)를 쓴다. 빈 결과는 []/{}로 저장되므로 NULL만 fallback 조건이다
  • 파이프라인은 소스 → materializeFeed → projectPayload를 유지한다. projectPayload를 통과시켜야 feed_payload에 섞인 숨은 입력값(x-feed가 아닌 argFrom 값)이 응답에 노출되지 않는다
  • Product 상세 API는 전환하지 않는다. 원본 payload에 전체 GraphQL 보강을 적용하는 별도 경로다

검증

  • 전 단계 PASS, 실패 0: unit 493건(mono 266 / product 206 / observability 21), rule 63건,
    integration 268건, admin integration 209건, admin default 58건, restDocs 136건, Spotless
  • 전환 동등성: 4개 스펙 각각에 대해 실제 파이프라인(materializeFeed 포함)을 원본/feed_payload 두 소스로 각각 돌려 결과가 같음을 확인
  • E2E fallback: 같은 payload를 가진 전환 큐레이션과 레거시(NULL) 큐레이션의 피드 응답 payload가 동일함을 실제 API로 확인
  • 재생성: responseSpec 미변경 시 0건, 변경 시 해당 스펙만 갱신되고 다른 스펙은 NULL 유지, 원본 payload 불변을 실제 DB로 확인
  • 기동 안정성: 재생성 예외, 락 획득 실패, Redis 장애(획득·해제) 각 경로에서 기동이 막히지 않음을 확인

코드 정리

리뷰 지적을 방어 코드로 덧붙이다 러너가 비대해져(락 획득·해제, 무효화 순서, 실패 정책 40줄) 오케스트레이션을 CurationFeedPayloadRefreshService로 분리했다. 러너는 위임 한 줄만 남는다. regeneratespec == null 분기는 findAllBySpecIdIn(specs.keySet())로 조회하므로 성립 불가한 죽은 코드라 제거했고, 중복 null 방어와 관리 엔티티에 대한 불필요한 save()도 걷어냈다.

refresh()는 무효화와 재생성이 각자 커밋되어야 해서 @Transactional(NOT_SUPPORTED)로 "경계 없음"을 명시한다. ArchUnit 트랜잭션 룰을 끄는 대신 그 취지(경계 명확화)에 맞게 선언한 것이다.

리뷰 반영

구현은 Claude가, 독립 리뷰는 codex CLI가 맡아 작성자와 리뷰어를 분리했다. 지적 중 실제 결함으로 확인돼 반영한 것들:

  • 재생성 실패의 영구 유실 (Critical) → 무효화 선행
  • Redis 장애가 기동 실패로 전파 (Critical) → tryAcquire를 try 안으로, release를 runCatching으로. 락 예외 경로 테스트 2건 추가
  • 경합 시 SSOT 유실 — 전체 컬럼 UPDATE라 재생성이 로드한 stale payload가 어드민 저장분을 덮어썼다 → @DynamicUpdate
  • 동등성 논증의 허점 — 최초 테스트가 materializeFeed를 건너뛰고 "두 경로 동일"이라 가정했으나, 인자가 비면 materializer가 writeTo에 null/[]을 써서 배열 원소를 살린다 → 테스트를 전체 파이프라인 비교로 교체하고 트립와이어 추가
  • 변경 감지 오탐/은폐 — 실수 trailing zero 미제거, changedSpecIds null을 빈 목록으로 뭉개 재생성 누락을 "변경 없음"으로 위장
  • 락 소유권 미확인 해제 — TTL 만료 후 남의 락을 지울 수 있었다 → UUID 토큰 비교

알려진 한계

  • 동등성의 전제: 현행 4개 스펙에는 피드와 교차하는 x-graphql 엔트리가 0건이라 전환 전후 응답이 동일하다. 교차 엔트리가 생기면 원본 경로는 인자가 비어도 writeTo에 null을 채워 배열 원소를 살리는 반면 feed_payload 경로는 그 원소를 버려 응답이 갈릴 수 있다. 테스트로 고정해뒀으므로 그런 스펙이 추가되면 빌드가 깨져 재검토를 강제한다
  • 재생성 중 어드민 저장 경합: payload는 새 값, feed_payload는 직전 payload 기준으로 남을 수 있다. 완전한 해결은 @Version 컬럼이 필요해 "스키마 변경 없음" 범위를 벗어난다. 기동 시점의 짧은 창이고 어드민이 다시 저장하면 해소된다
  • 재생성 트랜잭션 크기: 대상 전체를 한 트랜잭션에 적재한다. 운영 28건 기준 문제없으나 수천 건이 되면 chunk 처리가 필요하다

제외 범위 (후속 과제)

  • x-feed 스펙 보정 — 개발 DB 실측 결과 feed_payload 축소율이 WHISKY_TASTING_EVENT 88%인 반면 RECOMMENDED_WHISKY·WHISKY_PAIRING은 4%다. 뒤 둘은 alcohol 객체 전체가 x-feed라 거의 줄지 않는다. 피드 응답 payload는 API 계약이라 좁히면 breaking change이며 프론트 협의가 선행되어야 한다. 이번에 재생성이 들어갔으므로, 협의 후 스펙 JSON만 고쳐도 재생성이 자동 반영한다
  • 스펙 버전 이력 관리
  • Admin 피드 프리뷰 N+1, GraphQL 배치 보강

Whale0928 and others added 9 commits July 28, 2026 13:16
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>
Whale0928 and others added 3 commits July 28, 2026 17:22
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>
@Whale0928
Whale0928 merged commit 28976f6 into main Jul 28, 2026
2 checks passed
@Whale0928
Whale0928 deleted the Whale0928/feed-read-path branch July 28, 2026 08:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant