feat(spec-002): Outbox Republisher Worker (At-Least-Once 보장 1/2)#2
Merged
Conversation
At-Least-Once 보장의 publisher 측 절반 — PENDING OutboxEvent 자동 재발행 워커. US1(카오스 복구)/US2(SKIP LOCKED 동시성) P1, US3(영구 실패 격리 + Mattermost 알림) P2. FR 9개, SC 6개, Out of Scope로 Spec 003/005 명시 분리. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…odel 브레인스토밍 결과 컨슈머 멱등성(Spec 003) 전제 하에 "정확히 1번"이 아닌 "최소 1번"만 보장하면 충분하다는 결론. 재시도 제어 계층을 시간 컷오프로 대체. 변경: - US3: "영구 실패 격리 + Mattermost 알림" → "오래된 PENDING을 자동 재시도에서 제외" (createdAt 컷오프) - OutboxEvent 스키마 변경 0 (retryCount/nextRetryAt/lastError 제거) - OutboxStatus.FAILED 제거 — 무한 재시도 방지는 시간 컷오프가 담당 - 지수 백오프 제거 — 폴러가 PENDING을 매 사이클 잡는 것이 자연 재시도 - FR-005: 컷오프(기본 5분)·폴링 주기·배치 크기를 application.yml로 외부화 - 컷오프 5분 근거 명문화 (배포 시간 초과 + 비즈니스 허용 지연 이내) 유지: US1(누락분 재발행) / US2(SKIP LOCKED 다중 인스턴스 안전) / 카오스·동시성 테스트 / graceful shutdown Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…epublisher) 단순 버전 spec.md 기준 /speckit-plan 산출물. - plan.md: Technical Context (신규 의존성 0) + Constitution Check (전 게이트 ✅, NON-NEGOTIABLE 3종 위반 0) + Project Structure (outbox/republisher 신규 패키지) - research.md: R-001~006 * R-001 native query FOR UPDATE SKIP LOCKED (JPA @lock 대비) * R-002 사이클당 트랜잭션 경계 (락 보유 ~수백ms) * R-003 payload JSON → MessageCommand 역직렬화 * R-004 graceful shutdown (ThreadPoolTaskScheduler await-termination) * R-005 @ConditionalOnProperty 프로필별 활성화 * R-006 @ConfigurationProperties 설정 외부화 (컷오프·주기·배치) - quickstart.md: 폴러 운영·컷오프 튜닝·트러블슈팅 가이드 - data-model.md / contracts/ 미생성 — 스키마·외부 계약 변경 0 (plan에 명시) - CLAUDE.md: 활성 spec 링크에서 data-model/contracts 제거 - git-config.yml: after_specify auto-commit 활성화 (사용자 설정) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
단순 버전 spec/plan 기준 /speckit-tasks 재생성. full 버전 38 task → 14 task. - Phase 1 Setup (T001~T003): properties / application.yml / scheduler shutdown - Phase 2 Foundational (T004~T005): SKIP LOCKED native query / payload 변환 - Phase 3 US1 (T006~T008): 폴러 골격 → republishPending() → 카오스 테스트 - Phase 4 US2 (T009): 동시성 RepeatedTest - Phase 5 US3 (T010): 컷오프 제외 검증 - Phase 6 Polish (T011~T014): 프로필 비활성 / 발행 지연 / 최종 검증 / plan 갱신 retryCount/FAILED/백오프/Mattermost task 전부 제거. SC↔Task 매핑 포함. /speckit-analyze 결과: CRITICAL 0, HIGH 1(U1 SC-005 검증 방식), MEDIUM 1(M1). U1/M1은 implement 중 해소 예정. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… payload Spec 002 Phase 1·2 (T001~T005). - T001 OutboxRepublisherProperties: @ConfigurationProperties record (enabled/pollingIntervalMs/cutoffMinutes/batchSize). @DefaultValue + compact constructor 양수 검증 (신규 의존성 0 — validation starter 미사용) - T002 application.yml에 outbox.republisher.* 기본값, application-test.yml에 enabled=false (FR-006) - T003 application.yml에 spring.task.scheduling.shutdown.await-termination (graceful shutdown, research R-004) - T004 OutboxRepository.findRepublishableForUpdateSkipLocked — native query FOR UPDATE SKIP LOCKED - T005 VerificationOutboxPayload: payload JSON 역직렬화 record + toMessageCommand() 변환 구현 중 발견: OutboxEvent.status가 @Enumerated 미지정 → ORDINAL 매핑(DB 정수 저장). native query는 status를 ordinal(int)로 비교 — research R-001의 status='PENDING' 예시를 ordinal 파라미터 방식으로 정정해 구현. Verification: ./gradlew compileJava 통과. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Spec 002 Phase 3 (T006~T008) + Phase 5 (T010). - T006 OutboxRepublisher: @transactional republishPending() 로직 빈. @EnableConfigurationProperties로 properties 등록 (analyze M1 해소). - T007 OutboxRepublisherScheduler: @ConditionalOnProperty + @scheduled 트리거. 로직 빈과 스케줄 트리거 분리 — test 프로필은 트리거만 비활성화하고 통합 테스트는 OutboxRepublisher를 @transactional 프록시째 주입받아 직접 호출(SKIP LOCKED 락 동작까지 결정론 검증). - T008 OutboxRepublisherIntegrationTest: SC-001 카오스(재발행/일시장애 재시도) + SC-003 컷오프 제외. VerificationMessagePublisher @MockBean. - T010 컷오프 검증 테스트 동봉. 구현 중 발견·수정: - 폴러 단일 클래스(@scheduled+@transactional)는 test 프로필에서 빈이 통째 사라져 통합 테스트가 호출 불가 → 로직/트리거 2클래스로 분리. - 컷오프 테스트의 timezone 버그: java.sql.Timestamp는 JDBC 전송 시 JVM TZ(KST)로 해석되어 @CreationTimestamp의 UTC 저장과 9시간 어긋남 → MySQL UTC_TIMESTAMP() 기반 native update로 수정. Verification: ./gradlew test --tests "*OutboxRepublisherIntegrationTest" 3 tests, 0 failures. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Spec 002 Phase 4 (T009). - OutboxRepublisherConcurrencyTest: PENDING 100건에 폴러 2개 스레드 동시 실행. batch-size=20(@TestPropertySource)으로 낮춰 한 사이클 독식을 막고 두 폴러가 여러 사이클에 걸쳐 실제 경합하게 함. - eventId별 발행 횟수를 ConcurrentHashMap으로 집계 → 모든 outbox가 정확히 1회만 발행됨을 검증 (이중 처리 0). - @RepeatedTest(10) 경합 안정성 반복 확인. Verification: 10 tests, 0 failures. SC-002 충족. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
전체 테스트 실행 시 `HikariPool ... Connection is not available` / `No operations allowed after connection closed` 타임아웃으로 다수 통합 테스트가 실패하던 문제를 수정한다. (Spec 001에서 도입한 Testcontainers 베이스의 잠재 결함 — 단독 실행은 통과하나 전체 실행에서만 발현.) 근본 원인: - IntegrationTest가 @testcontainers + @container static 패턴이라 테스트 클래스 종료 시 컨테이너를 stop. 그러나 Spring TestContext는 컨텍스트를 캐시하므로, 다음 클래스가 캐시된 컨텍스트를 재사용하면 그 HikariCP 풀이 이미 죽은 컨테이너를 가리켜 커넥션 오류 발생. 단독 실행은 컨텍스트가 하나뿐이라 재현되지 않음. 수정: - IntegrationTest: 싱글톤 컨테이너 패턴으로 전환. @Testcontainers/@container 제거, static 초기화 블록에서 1회 start → 컨테이너가 JVM 생존 내내 단일 인스턴스로 유지되어 캐시된 어떤 컨텍스트도 살아있는 컨테이너를 참조. - PlanetrushApplicationTests: @SpringBootTest 직접 선언 → IntegrationTest 상속으로 전환. 기존엔 Testcontainers를 못 써 localhost:3306 폴백으로 컨텍스트 로딩 실패. - PlanetIntegrationTest: ExecutorService 3곳을 try-with-resources로 교체 (Java 21 AutoCloseable). @RepeatedTest(100)에서 매 반복 스레드 풀 누수. Verification: ./gradlew test --tests "com.planetrush.planetrush.*" 166 tests, 0 failures (이전: 다수 실패 + 30초 타임아웃 반복). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Spec 002 Phase 6 (T011~T014) 마무리. - T011 OutboxRepublisherProfileTest: SC-004 — test 프로필에서 OutboxRepublisherScheduler(자동 폴링 트리거) 빈 부재 검증, OutboxRepublisher 로직 빈은 존재 확인. - T012 OutboxRepublisherIntegrationTest: SC-005 — 폴러 사이클 1회가 조회된 PENDING 배치를 모두 그 사이클 내에 발행하는 per-cycle 완전성 검증. - T013 전체 검증: ./gradlew test 166 tests 0 failures, verifySecretLogScan clean. - T014 plan.md Post-Implementation Constitution Re-Check 섹션 추가 (구현 발견사항: status ORDINAL 매핑, 폴러 2클래스 분리, timezone 버그 등). spec.md SC-005 정정 (analyze U1 해소): "평균 발행 지연 측정" → "폴러 사이클 1회 배치 완전성" — test 프로필은 폴러 자동 실행이 비활성이라 주기 기반 측정이 불가하므로 per-cycle 완전성으로 갈음. Acceptance: SC-001~005 전부 자동 테스트 통과. Constitution VII 충족. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ents OutboxRepublisher / OutboxRepublisherScheduler가 수동으로 전체 생성자를 선언하고 있어 프로젝트 컨벤션(기존 @Component/@service는 모두 @requiredargsconstructor 사용)과 어긋났다. - OutboxRepublisher: 수동 4-arg 생성자 → @requiredargsconstructor - OutboxRepublisherScheduler: 수동 1-arg 생성자 → @requiredargsconstructor src/main 전체 스캔 결과 그 외 수동 생성자는 전부 대체 불가로 확인: - 예외 클래스(~22개): RuntimeException 위임 생성자 오버로드 - 도메인 엔티티(7개): 빌더/비즈니스 생성자 (기본값 설정 로직 포함) Verification: ./gradlew test --tests "*OutboxRepublisher*" 16 tests, 0 failures. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Owner
Author
Codex 리뷰 결과 (Constitution VI)
판정: 채택 — 수정 사항 없음. Codex가 dev 대비 10개 커밋 전체를 검토했고 correctness 이슈를 발견하지 못함. 듀얼 리뷰(Claude Code self-review + Codex) 모두 통과. → 머지 준비 완료. |
5초 폴링은 정상 시 대부분 빈 쿼리라 DB 부하만 유발. 폴링 주기는 독립 기준이 아니라 컷오프에 종속된 파생값 — 컷오프(주 기준, 5분) ÷ 목표 재시도 횟수(4~5회) ≈ 1분으로 역산. 근거: - 폴링 ≪ 컷오프 여야 함. 폴링이 컷오프와 같거나 크면 경계에서 재시도 기회가 0~1회로 붕괴. - 1분 폴링 → 5분 컷오프 안에 재시도 4~5회 확보 (일시 장애 복구 안전망). - 부수 효과: 발행 실패분의 사용자 추가 대기가 최대 1분으로 제한됨 (정상 흐름은 AFTER_COMMIT 즉시 발행이라 폴링 주기와 무관). 변경: - application.yml: polling-interval-ms 5000 → 60000 - OutboxRepublisherProperties: @DefaultValue 5000 → 60000 - OutboxRepublisherScheduler: @scheduled 폴백 5000 → 60000 - spec.md / research.md: 폴링 주기 갱신 + "컷오프=주 기준, 폴링=파생값" 설계 원칙 명문화 Verification: ./gradlew compileJava compileTestJava 통과. 폴링 주기는 런타임 설정값이라 test 프로필(폴러 비활성)·단위 테스트에 영향 없음. Co-Authored-By: Claude Opus 4.7 (1M context) <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.
Issue Number
Summary
PENDING 상태로 남은
OutboxEvent를 주기적으로 재발행하는 폴러를 도입해 At-Least-Once 메시지 발행을 보장한다.기존 구멍:
VerificationRedisStreamPublisher발행이 실패하면catch가 로그만 찍고 outbox는PENDING으로 영영 남음 +@TransactionalEventListener(AFTER_COMMIT)실행 전 앱이 죽어도 마찬가지 — 누구도 재발행하지 않음.본 스펙은 컨슈머 멱등성(Spec 003)이 중복을 무해화한다는 전제로 "최소 1번"만 보장하면 충분하다는 결론에 따라 단순 버전으로 설계: 재시도 카운터·지수 백오프·
FAILED상태를 도입하지 않고, 무한 재시도는createdAt시간 컷오프로 차단.OutboxEvent/OutboxStatus스키마 변경 0.Spec Reference
specs/002-outbox-republisher/spec.md— User Story 3개 (US1·US2 P1, US3 P2)specs/002-outbox-republisher/plan.md— Constitution Check + Post-Implementation Re-Checkspecs/002-outbox-republisher/research.md— R-001~006 (SKIP LOCKED native query / 트랜잭션 경계 / payload 역직렬화 / graceful shutdown / 프로필 토글 / 설정 외부화)specs/002-outbox-republisher/tasks.md— T001~T014 전부 완료.specify/memory/constitution.mdv1.0.0핵심 설계
outbox/republisher신규 패키지.OutboxRepublisher(로직, 항상 빈) +OutboxRepublisherScheduler(@Scheduled트리거,@ConditionalOnProperty)로 분리 —test프로필은 트리거만 비활성화하고 통합 테스트가 로직 빈을 직접 호출.OutboxRepository에 native queryFOR UPDATE SKIP LOCKED— 다중 폴러 인스턴스 간 이중 처리 0.createdAt기준 시간 컷오프(기본 5분, 설정값). poison message가 폴러 사이클을 영구 점유하지 않음.OutboxRepublisherProperties(enabled/polling-interval-ms/cutoff-minutes/batch-size).Constitution Check
VerificationMessagePublisher재사용verifySecretLogScanclean위반 0건.
Testcontainers hotfix 포함 (커밋
21f6195)본 PR에는 Spec 001에서 도입한 Testcontainers 베이스의 잠재 결함 수정이 포함된다. 전체 테스트 실행 시
HikariPool ... No operations allowed after connection closed타임아웃으로 다수 통합 테스트가 실패하던 문제:@Testcontainers+@Container라이프사이클이 테스트 클래스 종료 시 컨테이너를 stop → Spring TestContext 컨텍스트 캐시가 죽은 컨테이너를 참조. (단독 실행은 컨텍스트 1개라 미발현)PlanetIntegrationTest의ExecutorService누수 3곳 try-with-resources화,PlanetrushApplicationTestsTestcontainers 미사용 수정.AI Review (Constitution VI)
Claude Code 리뷰
리뷰 요약 / 자체 회고
본 PR은 Claude Code 세션으로 작성. 작성·디버깅 과정의 주요 발견:
OutboxEvent.status가@Enumerated미지정 → native query를 ordinal 비교로 구현.@Scheduled+@Transactional)면test프로필에서 빈이 통째 사라져 통합 테스트가 호출 불가 → 로직/트리거 분리.java.sql.Timestamp(JVM TZ)와@CreationTimestamp(UTC) 9시간 어긋남 → MySQLUTC_TIMESTAMP()로 수정.@EnableConfigurationProperties위치 고정.Codex 리뷰
리뷰 결과 / 채택·기각
TODO — 머지 전
codex review --base dev실행 후 결과 첨부.Verification (SC ↔ 테스트)
OutboxRepublisherIntegrationTest(재발행·일시장애 재시도)OutboxRepublisherConcurrencyTest@RepeatedTest(10)OutboxRepublisherIntegrationTest(stale 제외)OutboxRepublisherProfileTestOutboxRepublisherIntegrationTest전체 테스트: 166 tests, 0 failures (27초) ·
verifySecretLogScanclean.알려진 한계 (의도된 범위)
VerificationMessagePublisher를@MockBean으로 대체 — 실제 Redis StreamXADDend-to-end 검증은 미포함. mock이 실제 publisher의 "발행 성공 시published()전환" 계약을 흉내. 실제 Stream 검증은 Spec 003(Idempotent Stream Consumer)에서 Consumer와 함께 자연히 커버됨.retryCount/FAILED/지수 백오프/Mattermost 알림 — 단순 버전 설계로 제외 (컨슈머 멱등성 전제).변경 통계
4d3470a~506c435)OutboxRepublisher/OutboxRepublisherScheduler/OutboxRepublisherProperties/VerificationOutboxPayload+ 테스트 3종OutboxRepository(SKIP LOCKED 쿼리) /application*.yml/IntegrationTest(싱글톤) /PlanetIntegrationTest/PlanetrushApplicationTests🤖 Generated with Claude Code