Skip to content

feat(spec-002): Outbox Republisher Worker (At-Least-Once 보장 1/2)#2

Merged
simhani1 merged 11 commits into
devfrom
002-outbox-republisher
May 22, 2026
Merged

feat(spec-002): Outbox Republisher Worker (At-Least-Once 보장 1/2)#2
simhani1 merged 11 commits into
devfrom
002-outbox-republisher

Conversation

@simhani1
Copy link
Copy Markdown
Owner

Issue Number

  • (없음 — SDD 워크플로우 기반 Spec 002)

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-Check
  • specs/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.md v1.0.0

핵심 설계

  • 폴러: outbox/republisher 신규 패키지. OutboxRepublisher(로직, 항상 빈) + OutboxRepublisherScheduler(@Scheduled 트리거, @ConditionalOnProperty)로 분리 — test 프로필은 트리거만 비활성화하고 통합 테스트가 로직 빈을 직접 호출.
  • 동시성: OutboxRepository에 native query FOR UPDATE SKIP LOCKED — 다중 폴러 인스턴스 간 이중 처리 0.
  • 컷오프: createdAt 기준 시간 컷오프(기본 5분, 설정값). poison message가 폴러 사이클을 영구 점유하지 않음.
  • 설정 외부화: OutboxRepublisherProperties(enabled/polling-interval-ms/cutoff-minutes/batch-size).

Constitution Check

  • I. Testcontainers 통합 테스트 (NON-NEGOTIABLE) — SKIP LOCKED는 실제 MySQL 필수
  • II. 외부 의존 어댑터 격리 — 기존 VerificationMessagePublisher 재사용
  • III. QueryDSL Projections — N/A (엔티티 조회)
  • IV. Outbox 강제 (NON-NEGOTIABLE) — 본 스펙이 Outbox 신뢰성 강화
  • V. 민감정보 로그 금지 (NON-NEGOTIABLE) — verifySecretLogScan clean
  • VI. 듀얼 AI 리뷰 첨부 — 아래 섹션
  • VII. 인수 기준 자동 테스트화 — SC-001~005 ↔ 테스트 매핑

위반 0건.

Testcontainers hotfix 포함 (커밋 21f6195)

본 PR에는 Spec 001에서 도입한 Testcontainers 베이스의 잠재 결함 수정이 포함된다. 전체 테스트 실행 시 HikariPool ... No operations allowed after connection closed 타임아웃으로 다수 통합 테스트가 실패하던 문제:

  • 원인: @Testcontainers+@Container 라이프사이클이 테스트 클래스 종료 시 컨테이너를 stop → Spring TestContext 컨텍스트 캐시가 죽은 컨테이너를 참조. (단독 실행은 컨텍스트 1개라 미발현)
  • 수정: 싱글톤 컨테이너 패턴 — static 블록에서 1회 start, JVM 생존 내내 단일 인스턴스 유지.
  • 부수: PlanetIntegrationTestExecutorService 누수 3곳 try-with-resources화, PlanetrushApplicationTests Testcontainers 미사용 수정.

AI Review (Constitution VI)

Claude Code 리뷰

리뷰 요약 / 자체 회고

본 PR은 Claude Code 세션으로 작성. 작성·디버깅 과정의 주요 발견:

  • status ORDINAL 매핑: OutboxEvent.status@Enumerated 미지정 → native query를 ordinal 비교로 구현.
  • 폴러 2클래스 분리: 단일 클래스(@Scheduled+@Transactional)면 test 프로필에서 빈이 통째 사라져 통합 테스트가 호출 불가 → 로직/트리거 분리.
  • timezone 버그: 컷오프 테스트에서 java.sql.Timestamp(JVM TZ)와 @CreationTimestamp(UTC) 9시간 어긋남 → MySQL UTC_TIMESTAMP()로 수정.
  • analyze U1/M1 해소: SC-005 검증 방식 재정의, @EnableConfigurationProperties 위치 고정.

Codex 리뷰

리뷰 결과 / 채택·기각

TODO — 머지 전 codex review --base dev 실행 후 결과 첨부.

Verification (SC ↔ 테스트)

SC 테스트 결과
SC-001 카오스 재발행 OutboxRepublisherIntegrationTest (재발행·일시장애 재시도)
SC-002 동시성 이중 처리 0 OutboxRepublisherConcurrencyTest @RepeatedTest(10)
SC-003 컷오프 제외 OutboxRepublisherIntegrationTest (stale 제외)
SC-004 폴러 비활성 OutboxRepublisherProfileTest
SC-005 per-cycle 완전성 OutboxRepublisherIntegrationTest

전체 테스트: 166 tests, 0 failures (27초) · verifySecretLogScan clean.

알려진 한계 (의도된 범위)

  • 폴러 통합 테스트는 VerificationMessagePublisher@MockBean으로 대체 — 실제 Redis Stream XADD end-to-end 검증은 미포함. mock이 실제 publisher의 "발행 성공 시 published() 전환" 계약을 흉내. 실제 Stream 검증은 Spec 003(Idempotent Stream Consumer)에서 Consumer와 함께 자연히 커버됨.
  • retryCount/FAILED/지수 백오프/Mattermost 알림 — 단순 버전 설계로 제외 (컨슈머 멱등성 전제).

변경 통계

  • 10 commits (4d3470a ~ 506c435)
  • 신규: OutboxRepublisher / OutboxRepublisherScheduler / OutboxRepublisherProperties / VerificationOutboxPayload + 테스트 3종
  • 수정: OutboxRepository(SKIP LOCKED 쿼리) / application*.yml / IntegrationTest(싱글톤) / PlanetIntegrationTest / PlanetrushApplicationTests

🤖 Generated with Claude Code

simhani1 and others added 10 commits May 18, 2026 00:29
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>
@simhani1
Copy link
Copy Markdown
Owner Author

Codex 리뷰 결과 (Constitution VI)

codex review --base dev (codex-cli 0.130.0) 실행 결과 — 이슈 0건.

The implementation and tests are consistent with the intended outbox republisher behavior, and the test suite passes successfully. I did not identify any discrete correctness issues introduced by this patch.

판정: 채택 — 수정 사항 없음. 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>
@simhani1 simhani1 merged commit 10f7c26 into dev May 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant