From 4d3470ab2f39638021b8831a083b4fea4565dbc5 Mon Sep 17 00:00:00 2001 From: simhani1 Date: Mon, 18 May 2026 00:29:48 +0900 Subject: [PATCH 01/11] docs(spec-002): add outbox-republisher specification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .specify/feature.json | 2 +- .../checklists/requirements.md | 48 +++++++ specs/002-outbox-republisher/spec.md | 126 ++++++++++++++++++ 3 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 specs/002-outbox-republisher/checklists/requirements.md create mode 100644 specs/002-outbox-republisher/spec.md diff --git a/.specify/feature.json b/.specify/feature.json index 312d900..e6aa0d6 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1,3 +1,3 @@ { - "feature_directory": "specs/001-secure-logging-test-foundation" + "feature_directory": "specs/002-outbox-republisher" } diff --git a/specs/002-outbox-republisher/checklists/requirements.md b/specs/002-outbox-republisher/checklists/requirements.md new file mode 100644 index 0000000..94f33bd --- /dev/null +++ b/specs/002-outbox-republisher/checklists/requirements.md @@ -0,0 +1,48 @@ +# Specification Quality Checklist: Outbox Republisher Worker + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-05-18 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) + - 참고: 본 스펙은 brownfield 인프라 도입이라 기존 도메인 식별자(`OutboxEvent`, `OutboxStatus`, `OutboxRepository`, `VerificationExternalMessageListener`)와 Spring 컨벤션(`@ConditionalOnProperty`, `@PreDestroy`, `ddl-auto`)을 명시한다. 새로 도입할 폴러 클래스/스케줄러 추상화, 백오프 계산기 구현 등 HOW은 plan 단계로 위임한다. +- [x] Focused on user value and business needs (운영자/SRE 관점 — "메시지 유실 없음", "이중 처리 없음", "영구 실패 가시화") +- [x] Written for non-technical stakeholders (인수 시나리오가 자연어 Given/When/Then으로 표현되어 비개발자도 검증 의도 이해 가능) +- [x] All mandatory sections completed (User Scenarios, Requirements, Success Criteria, Assumptions) + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous + - FR-001~FR-009 각 항목이 SC-001~SC-006와 N:M 매핑 명확(FR-001/002 → SC-001/002, FR-003 → SC-003, FR-004 → SC-004, FR-009 → SC-005, FR-008 → US1 시나리오 3). +- [x] Success criteria are measurable + - SC-001~SC-005는 테스트 통과/mock 호출 횟수/빈 부재 등 이진 판정. SC-006은 시간 임계치(≤ 6초). +- [x] Success criteria are technology-agnostic + - 참고: SC-002가 `RepeatedTest 10회`, SC-005가 `assertThat ... doesNotHaveBean`을 언급하지만 이는 "검증 형식"의 명확화이며 도메인 기준(동시성 100건 무중복 / test 프로필 폴러 비활성)은 기술 중립. +- [x] All acceptance scenarios are defined (US1: 3, US2: 2, US3: 1 시나리오 — 총 6개) +- [x] Edge cases are identified (6개: graceful shutdown, 사이클 초과 발행, 백오프 누적, DLQ 범위 밖, 메시지 순서, 시계 왜곡) +- [x] Scope is clearly bounded (Out of Scope 섹션 5개 항목 — Spec 003/005, observability, jitter, retention 명시 제외) +- [x] Dependencies and assumptions identified (Assumptions 섹션 7개 — Spec 001 베이스, Spec 003/005 분리, ddl-auto 의존, MattermostNotifier 의존, batch size 기본값) + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria + - FR↔US/SC 매핑 표(요약): + - FR-001/FR-002 → US1 시나리오 1·2, US2 시나리오 1·2, SC-001, SC-002 + - FR-003 → SC-003 (백오프 단조 증가) + - FR-004 → US3 시나리오 1, SC-004 + - FR-005/FR-006/FR-007 → 상기 모든 시나리오의 데이터 전제 + - FR-008 → US1 시나리오 3 (graceful shutdown) + - FR-009 → SC-005 (test 프로필 빈 부재) +- [x] User scenarios cover primary flows + - 정상 재시도(US1-1), 카오스 복구(US1-2), graceful shutdown(US1-3), 동시 폴러(US2-1·2), 영구 실패 격리(US3-1). +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification (폴러 구현 방식·스케줄러 종류·트랜잭션 경계 설계는 plan에서 결정) + +## Notes + +- 본 스펙은 Spec 001 안전망 위에서 At-Least-Once 보장의 **publisher 측 절반**만 다룬다. Consumer 측 멱등성/DLQ/Consumer Group은 Spec 003에서 일괄. +- 폴링 쿼리에 한해 `SELECT ... FOR UPDATE SKIP LOCKED` 의미론을 명시한 이유: 동시성 안전성이 도메인 요구사항(US2)이며 동등 효과의 대체 메커니즘(advisory lock 등)을 plan 단계에서 선택할 여지를 남기되 의미론은 고정. +- 모든 체크리스트 항목 통과. `/speckit-clarify`는 선택 사항이며, 본 스펙은 모호점이 없어 바로 `/speckit-plan` 진입 가능. diff --git a/specs/002-outbox-republisher/spec.md b/specs/002-outbox-republisher/spec.md new file mode 100644 index 0000000..7919450 --- /dev/null +++ b/specs/002-outbox-republisher/spec.md @@ -0,0 +1,126 @@ +# Feature Specification: Outbox Republisher Worker (At-Least-Once 보장 1/2) + +**Feature Branch**: `002-outbox-republisher` + +**Created**: 2026-05-18 + +**Status**: Draft + +**Input**: User description: "planetrush-api의 Outbox 인프라를 At-Least-Once 보장 완성으로 끌어올리는 첫 절반. PENDING 상태 OutboxEvent를 자동 재발행하는 워커를 도입한다. 멱등 컨슈머는 Spec 003에서." + +## 배경 *(context)* + +- Spec 001로 Testcontainers 인프라 + 시크릿 마스킹은 안전망 마련됨. +- 현재 `OutboxEvent` + AFTER_COMMIT 리스너(`VerificationExternalMessageListener`)는 존재하나, **발행 직전 앱이 다운되면 outbox는 영원히 PENDING으로 남는다** (재발행 메커니즘 부재). +- 발행 실패 시 자동 재시도 없음, 다중 인스턴스 동시 기동에서 락 미사용으로 이중 처리 위험. +- `OutboxEvent`에 `retryCount`/`nextRetryAt`/`lastError` 필드 부재, `OutboxStatus` enum에 `FAILED` 부재, `OutboxRepository`는 빈 `JpaRepository`. +- 본 스펙은 At-Least-Once 보장의 **publisher 측 절반**만 다룬다. Consumer 측 멱등성/DLQ/Consumer Group은 Spec 003 범위. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - 발행 직전 앱이 다운돼도 메시지는 결국 도달한다 (Priority: P1) + +운영 환경에서 `OutboxEvent`가 DB에 커밋된 직후, 외부 메시지 발행이 일어나기 전에 인스턴스가 비정상 종료되어도 메시지는 결국 컨슈머에 도달해야 한다. 재기동된 인스턴스의 폴러 워커가 PENDING 상태로 남은 outbox를 발견해 자동으로 재발행한다. + +**Why this priority**: At-Least-Once 메시지 발행의 핵심. 분산 시스템 신뢰성의 토대이며, 이 워커가 없으면 outbox 패턴 도입 자체의 가치가 사라진다. 본 스펙의 MVP. + +**Independent Test**: 카오스 시나리오 — outbox 저장 후 발행 직전 강제 종료 → 재기동 → 폴러가 PENDING을 발견해 재발행 → 컨슈머에 메시지 도달. 다른 사용자 스토리(US2/US3)와 독립적으로 검증 가능. + +**Acceptance Scenarios**: + +1. **Given** `OutboxEvent`가 PENDING 상태로 DB에 저장되고 Publisher에 IOException이 강제 주입된 상황, **When** 폴러가 다음 사이클에 해당 PENDING을 발견하고 일시 장애 해소 후 재시도, **Then** 재발행이 시도되고 성공 시 상태가 `PUBLISHED`로 전환된다. +2. **Given** `OutboxEvent` 저장 직후 앱이 강제 종료된 상태(SIGKILL 시뮬레이션), **When** 새 앱 인스턴스가 부팅되고 폴러가 가동, **Then** 해당 PENDING outbox가 재발행되어 컨슈머에 정확히 1번 이상 도달한다. +3. **Given** 폴러 사이클 도중 정상 종료 신호 수신, **When** 진행 중인 발행 작업이 존재, **Then** 폴러는 in-flight 작업이 완료될 때까지 대기한 뒤 종료한다(graceful shutdown). + +--- + +### User Story 2 - 폴러 인스턴스 N개 동시 기동에서 동일 outbox 이중 처리 0건 (Priority: P1) + +운영 다중 인스턴스(HA) 환경에서 동일 outbox를 두 폴러가 동시에 잡으면 메시지 중복 발행이 발생한다. 행 단위 락으로 정확히 한 워커만 각 outbox를 처리하도록 보장한다. + +**Why this priority**: HA 운영에서는 거의 항상 인스턴스 2개 이상이 동시 가동된다. 이중 처리는 외부 시스템(Mattermost 등)에 중복 메시지를 발송해 운영자 신뢰를 손상시킨다. US1과 동등한 P1 — At-Least-Once를 망치지 않으려면 "최소 1번"을 보장하면서 동시에 "동일 워커 사이클 내 중복은 0"이어야 한다. + +**Independent Test**: 동일 outbox 100건 PENDING 상태에서 폴러 워커 2개 동시 실행 → 각 outbox가 정확히 1회만 published()로 전환되는지 검증. US1의 재발행 로직이 없어도 동시성 락 메커니즘 자체는 단독으로 검증 가능. + +**Acceptance Scenarios**: + +1. **Given** `OutboxEvent` 100건이 PENDING 상태, **When** 폴러 스레드 2개가 동시에 같은 사이클을 돌림, **Then** 각 outbox의 발행 호출은 정확히 1번이며 `PUBLISHED` 카운트는 100건이다. +2. **Given** 폴러 A가 outbox X를 잠근 상태(트랜잭션 중), **When** 폴러 B가 같은 사이클을 돌림, **Then** 폴러 B는 X를 건너뛰고 다른 outbox만 처리한다(잠금 대기 없음). + +--- + +### User Story 3 - 영구 실패 outbox는 자동 격리 + Mattermost 알림 (Priority: P2) + +특정 outbox가 발행 시 항상 실패(예: 외부 시스템 영구 장애, 데이터 손상)할 때 무한 재시도로 폴러 사이클을 잡아먹는 것을 방지하고, 운영자가 수동 조치할 수 있도록 알림을 발송한다. + +**Why this priority**: P2인 이유 — 운영 효율성과 가시성 개선이지만, US1/US2가 없으면 무의미. P1 두 개가 At-Least-Once 핵심 보장이고, 본 스토리는 그 위에 얹는 격리/관측 계층. + +**Independent Test**: 발행이 항상 실패하는 outbox → `maxRetryCount`(기본 5) 초과 → `status=FAILED` + Mattermost 알림 1회 호출 검증. US1의 재시도 인프라가 있어야 의미가 있지만, 단위 테스트 수준에서는 mock으로 단독 검증 가능. + +**Acceptance Scenarios**: + +1. **Given** Publisher가 항상 RuntimeException을 던지는 `OutboxEvent` 1건, **When** 폴러가 `maxRetryCount`만큼 재시도, **Then** 상태가 `FAILED`로 전환되고 `lastError`가 기록되며 Mattermost 알림이 정확히 1회 발송되고 이후 사이클에서 더 이상 잡히지 않는다. + +--- + +### Edge Cases + +- **앱 종료 중 폴러 사이클**: 진행 중 작업은 graceful shutdown 패턴으로 완료 후 종료. 강제 종료 시 해당 outbox는 다음 인스턴스에서 재발견되어 재시도(US1 시나리오). +- **사이클 간격보다 발행이 오래 걸림**(예: 5초 사이클 + 6초 발행): 행 단위 락이 살아있는 동안 다음 사이클은 해당 outbox를 건너뜀(US2와 동일 메커니즘). +- **백오프 누적**: 첫 재시도 1s → 2s → 4s → 8s → 16s (단순 지수). Jitter는 본 스펙 범위 밖. +- **DLQ 자동 이동**: 본 스펙 범위 밖. Spec 003에서 Consumer Group + DLQ 일괄 처리. +- **메시지 순서**: 본 스펙은 순서 보장을 약속하지 않는다(At-Least-Once만 보장). 재발행으로 인해 순서가 뒤바뀔 수 있다. +- **시계 왜곡(clock skew)**: 다중 인스턴스 간 시계 차이로 `nextRetryAt` 비교 결과가 흔들릴 수 있으나, 락이 동시 처리를 막으므로 영향 미미. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: 시스템은 PENDING 상태이며 `nextRetryAt`이 도래한 `OutboxEvent`를 주기적(기본 5초 간격)으로 조회한다. +- **FR-002**: 폴링 쿼리는 행 단위 락 + 건너뛰기 의미론(`SELECT ... FOR UPDATE SKIP LOCKED` 또는 동등 효과의 비관적 락)으로 동시 폴러 인스턴스 간 안전을 보장한다. 잠긴 행은 즉시 건너뛰며 대기하지 않는다. +- **FR-003**: 발행 성공 시 상태를 `PUBLISHED`로 전환한다. 실패 시 `retryCount`를 1 증가시키고 `nextRetryAt`을 `now + 2^retryCount` 초로 설정한다. +- **FR-004**: `retryCount`가 `maxRetryCount`(기본 5)를 초과하면 상태를 `FAILED`로 전환하고 Mattermost 알림을 정확히 1회 발송한다. 이후 사이클은 해당 outbox를 더 이상 선택하지 않는다. +- **FR-005**: `OutboxEvent`에 `retryCount`(정수, default 0), `nextRetryAt`(nullable 시각), `lastError`(nullable 문자열) 필드를 추가한다. 마이그레이션은 `ddl-auto=update`에 의존(정식 마이그레이션 도입은 Spec 005). +- **FR-006**: `OutboxStatus` enum에 `FAILED` 값을 추가한다. +- **FR-007**: `OutboxRepository`에 잠금 기반 폴링 쿼리 메서드를 추가한다(Native query 또는 JPA `@Lock` + `Pageable` 조합). +- **FR-008**: 폴러는 graceful shutdown을 지원한다 — 종료 신호 수신 시 in-flight 작업 완료 후 종료(`@PreDestroy` + 짧은 대기 패턴). +- **FR-009**: 폴러는 `dev`/`prod` 프로필에서 활성화되고 `test` 프로필에서는 비활성화된다(통합 테스트 충돌 방지). `@ConditionalOnProperty(name="outbox.republisher.enabled", havingValue="true")` 기반. + +### Key Entities + +- **OutboxEvent**: 외부 시스템으로 발행해야 할 도메인 이벤트의 영속 표현. 본 스펙에서 다음 필드가 신설/변경된다. + - `status`: `PENDING`/`PUBLISHED`/`FAILED`(신규) + - `retryCount`(신규): 누적 재시도 횟수, 기본 0 + - `nextRetryAt`(신규): 다음 재시도 가능 시각, nullable(최초 저장 시 null 또는 즉시 가능) + - `lastError`(신규): 직전 실패의 사유 문자열, nullable +- **OutboxStatus**: outbox 라이프사이클 상태. `FAILED` 추가. +- **Mattermost 알림 채널**: 운영자에게 영구 실패를 통지하는 외부 통지 채널. 본 스펙은 "1회 발송" 계약만 정의(메시지 포맷/대상 채널 설정은 기존 인프라 재사용). + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: 카오스 통합 테스트가 통과한다 — Publisher Mock에 일시 장애를 주입하면 폴러가 자동 재시도하여 메시지가 결국 컨슈머에 도달함을 확인. +- **SC-002**: 동시성 통합 테스트가 통과한다 — outbox 100건 PENDING 상태에서 폴러 2개 동시 실행 시 각 outbox가 정확히 1회만 발행됨을 `RepeatedTest 10회` 모두 만족. +- **SC-003**: 백오프 단위 테스트가 통과한다 — `retryCount` 0~5에서 `nextRetryAt` 간격이 1s, 2s, 4s, 8s, 16s, 32s로 단조 증가. +- **SC-004**: 영구 실패 단위 테스트가 통과한다 — `maxRetryCount` 초과 시 상태가 `FAILED`로 전환되고 Mattermost mock이 정확히 1회 호출되며 후속 사이클에서 재선택되지 않음. +- **SC-005**: 폴러 비활성 검증 — `test` 프로필 부팅 시 폴러 빈이 컨텍스트에 부재함(`assertThat(...).doesNotHaveBean(...)` 동등). +- **SC-006**: 정상 경로 평균 발행 지연이 폴링 주기 + 1초 이내(예: 기본 5초 주기 → 평균 ≤ 6초)로 측정된다. + +## Assumptions + +- 재시도 백오프는 단순 지수(`2^retryCount` 초)로 충분하다고 가정. 무작위 jitter는 본 스펙 범위 밖(트래픽 폭주가 큰 규모가 아니므로 도입 효익 낮음). +- DLQ 자동 이동/관리, Consumer Group 구성, eventId 기반 컨슈머 멱등성은 Spec 003에서 일괄 다룬다. +- 메트릭(예: `outbox_pending_count`, `outbox_failed_count` 등 Prometheus 노출)은 별도 observability 스펙에서 일괄 도입한다. +- 신규 컬럼은 `ddl-auto=update`로 자동 추가된다고 가정(정식 마이그레이션 도구 도입은 Spec 005). +- Spec 001에서 마련한 `IntegrationTest` 베이스(Testcontainers MySQL/Redis)를 그대로 상속하여 새 테스트를 작성한다. +- Mattermost 알림은 기존 인프라(`MattermostNotifier` 또는 동등 구성요소)가 존재하여 본 스펙은 호출 1회 계약만 보장한다고 가정. 부재 시 본 스펙에서 최소 추상화(인터페이스 + no-op 구현)를 도입한다. +- 단일 폴러 사이클 1회 처리량 상한(batch size)은 합리적 기본값(예: 100)으로 두고 구성 가능하게 한다. + +## Out of Scope *(non-mandatory)* + +- 컨슈머 측 멱등성, `eventId` 기반 dedup, Consumer Group 운영, DLQ 자동 이동 (→ Spec 003). +- Prometheus/Grafana 메트릭 노출 및 대시보드 (→ 별도 observability 스펙). +- Flyway/Liquibase 정식 마이그레이션 (→ Spec 005). +- Jitter 백오프, 우선순위 큐, 메시지 순서 보장. +- Outbox 보존 정책(TTL 기반 삭제, 아카이브). From e663caf3faaf39c388a83427625e655260d5c7a4 Mon Sep 17 00:00:00 2001 From: simhani1 Date: Fri, 22 May 2026 20:11:22 +0900 Subject: [PATCH 02/11] =?UTF-8?q?docs(spec-002):=20simplify=20outbox-repub?= =?UTF-8?q?lisher=20spec=20=E2=80=94=20drop=20retry-count=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 브레인스토밍 결과 컨슈머 멱등성(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) --- specs/002-outbox-republisher/spec.md | 115 ++++++++++++++------------- 1 file changed, 58 insertions(+), 57 deletions(-) diff --git a/specs/002-outbox-republisher/spec.md b/specs/002-outbox-republisher/spec.md index 7919450..8fd5fe1 100644 --- a/specs/002-outbox-republisher/spec.md +++ b/specs/002-outbox-republisher/spec.md @@ -10,117 +10,118 @@ ## 배경 *(context)* -- Spec 001로 Testcontainers 인프라 + 시크릿 마스킹은 안전망 마련됨. -- 현재 `OutboxEvent` + AFTER_COMMIT 리스너(`VerificationExternalMessageListener`)는 존재하나, **발행 직전 앱이 다운되면 outbox는 영원히 PENDING으로 남는다** (재발행 메커니즘 부재). -- 발행 실패 시 자동 재시도 없음, 다중 인스턴스 동시 기동에서 락 미사용으로 이중 처리 위험. -- `OutboxEvent`에 `retryCount`/`nextRetryAt`/`lastError` 필드 부재, `OutboxStatus` enum에 `FAILED` 부재, `OutboxRepository`는 빈 `JpaRepository`. -- 본 스펙은 At-Least-Once 보장의 **publisher 측 절반**만 다룬다. Consumer 측 멱등성/DLQ/Consumer Group은 Spec 003 범위. +- Spec 001로 Testcontainers 인프라 + 시크릿 마스킹 안전망 마련됨. +- 현재 `OutboxEvent` + AFTER_COMMIT 리스너(`VerificationExternalMessageListener`) → `VerificationRedisStreamPublisher`가 Redis Stream에 발행하고 성공 시 `published()`로 전환한다. +- **구멍**: `VerificationRedisStreamPublisher`의 발행이 실패하면 `catch (RuntimeException)`이 로그만 찍고 끝나 `OutboxEvent`는 `PENDING`으로 남는다. 또한 발행이 `@TransactionalEventListener(AFTER_COMMIT)`에서 일어나므로 그 리스너 실행 전 앱이 죽으면 영영 `PENDING`. **누구도 재발행하지 않는다.** +- 본 스펙은 At-Least-Once 보장의 **publisher 측 절반**만 다룬다. 컨슈머 측 멱등성/중복 제거/Consumer Group/DLQ는 Spec 003 범위이며, **본 스펙은 "컨슈머가 중복을 무해화한다"를 전제로 설계한다.** + +### 설계 단순화 결정 (브레인스토밍 산출) + +컨슈머 멱등성이 중복을 보장하므로 본 스펙은 "정확히 1번"이 아니라 **"최소 1번"만 보장**하면 된다. 그 결과: + +- **재시도 횟수 제어 불필요**: 폴러가 `PENDING`을 매 사이클 잡는 것 자체가 자연 재시도. `retryCount`/`nextRetryAt`/지수 백오프 **도입하지 않음**. +- **`FAILED` 상태 불필요**: 무한 재시도 방지는 횟수가 아니라 **시간 컷오프**로 한다. `OutboxStatus`에 `FAILED` 추가하지 않음. +- **스키마 변경 0**: `OutboxEvent`에 새 필드를 추가하지 않는다. 컷오프 판정은 기존 `createdAt` 필드만으로 한다. ## User Scenarios & Testing *(mandatory)* ### User Story 1 - 발행 직전 앱이 다운돼도 메시지는 결국 도달한다 (Priority: P1) -운영 환경에서 `OutboxEvent`가 DB에 커밋된 직후, 외부 메시지 발행이 일어나기 전에 인스턴스가 비정상 종료되어도 메시지는 결국 컨슈머에 도달해야 한다. 재기동된 인스턴스의 폴러 워커가 PENDING 상태로 남은 outbox를 발견해 자동으로 재발행한다. +운영 환경에서 `OutboxEvent`가 DB에 커밋된 직후 외부 메시지 발행이 일어나기 전에 인스턴스가 비정상 종료되거나 발행 자체가 실패해도, 메시지는 결국 컨슈머에 도달해야 한다. 폴러 워커가 `PENDING`으로 남은 outbox를 주기적으로 발견해 자동 재발행한다. -**Why this priority**: At-Least-Once 메시지 발행의 핵심. 분산 시스템 신뢰성의 토대이며, 이 워커가 없으면 outbox 패턴 도입 자체의 가치가 사라진다. 본 스펙의 MVP. +**Why this priority**: At-Least-Once 메시지 발행의 핵심. 이 워커가 없으면 Outbox 패턴 도입 가치가 사라진다. 본 스펙의 MVP. -**Independent Test**: 카오스 시나리오 — outbox 저장 후 발행 직전 강제 종료 → 재기동 → 폴러가 PENDING을 발견해 재발행 → 컨슈머에 메시지 도달. 다른 사용자 스토리(US2/US3)와 독립적으로 검증 가능. +**Independent Test**: 카오스 시나리오 — Publisher에 일시 장애를 주입해 `OutboxEvent`를 `PENDING`으로 만든 뒤, 장애 해소 후 폴러 사이클이 돌면 재발행되어 `PUBLISHED`로 전환됨을 확인. US2/US3와 독립 검증 가능. **Acceptance Scenarios**: -1. **Given** `OutboxEvent`가 PENDING 상태로 DB에 저장되고 Publisher에 IOException이 강제 주입된 상황, **When** 폴러가 다음 사이클에 해당 PENDING을 발견하고 일시 장애 해소 후 재시도, **Then** 재발행이 시도되고 성공 시 상태가 `PUBLISHED`로 전환된다. -2. **Given** `OutboxEvent` 저장 직후 앱이 강제 종료된 상태(SIGKILL 시뮬레이션), **When** 새 앱 인스턴스가 부팅되고 폴러가 가동, **Then** 해당 PENDING outbox가 재발행되어 컨슈머에 정확히 1번 이상 도달한다. -3. **Given** 폴러 사이클 도중 정상 종료 신호 수신, **When** 진행 중인 발행 작업이 존재, **Then** 폴러는 in-flight 작업이 완료될 때까지 대기한 뒤 종료한다(graceful shutdown). +1. **Given** `OutboxEvent`가 `PENDING` 상태로 DB에 있고 Publisher의 일시 장애가 해소된 상황, **When** 폴러가 다음 사이클을 돌림, **Then** 재발행이 시도되고 성공 시 상태가 `PUBLISHED`로 전환된다. +2. **Given** Publisher 발행이 1차 실패하여 `OutboxEvent`가 `PENDING`으로 남음, **When** 폴러가 여러 사이클에 걸쳐 재시도, **Then** 장애 해소 후 사이클에서 발행에 성공하고 `PUBLISHED`로 전환된다(별도 재시도 카운트 없이 "성공할 때까지"). +3. **Given** 폴러 사이클 도중 정상 종료(graceful shutdown) 신호 수신, **When** 진행 중인 발행 작업이 존재, **Then** 폴러는 in-flight 작업 완료까지 대기한 뒤 종료한다. --- ### User Story 2 - 폴러 인스턴스 N개 동시 기동에서 동일 outbox 이중 처리 0건 (Priority: P1) -운영 다중 인스턴스(HA) 환경에서 동일 outbox를 두 폴러가 동시에 잡으면 메시지 중복 발행이 발생한다. 행 단위 락으로 정확히 한 워커만 각 outbox를 처리하도록 보장한다. +운영 다중 인스턴스(HA) 환경에서 동일 outbox를 두 폴러가 동시에 잡으면 불필요한 중복 발행이 발생한다. 컨슈머가 중복을 무해화하더라도 브로커·컨슈머 부하가 인스턴스 수만큼 증폭되므로, 행 단위 락으로 한 워커만 각 outbox를 처리하도록 한다. -**Why this priority**: HA 운영에서는 거의 항상 인스턴스 2개 이상이 동시 가동된다. 이중 처리는 외부 시스템(Mattermost 등)에 중복 메시지를 발송해 운영자 신뢰를 손상시킨다. US1과 동등한 P1 — At-Least-Once를 망치지 않으려면 "최소 1번"을 보장하면서 동시에 "동일 워커 사이클 내 중복은 0"이어야 한다. +**Why this priority**: HA 운영에서는 거의 항상 인스턴스 2개 이상이 동시 가동된다. 락이 없으면 매 사이클 동일 PENDING이 N번 발행되어 부하가 N배가 된다. At-Least-Once를 유지하면서 "동일 사이클 내 중복은 0"이어야 한다. -**Independent Test**: 동일 outbox 100건 PENDING 상태에서 폴러 워커 2개 동시 실행 → 각 outbox가 정확히 1회만 published()로 전환되는지 검증. US1의 재발행 로직이 없어도 동시성 락 메커니즘 자체는 단독으로 검증 가능. +**Independent Test**: 동일 `OutboxEvent` 100건이 `PENDING`인 상태에서 폴러 워커 2개를 동시 실행 → 각 outbox가 정확히 1회만 `PUBLISHED`로 전환되는지 검증. **Acceptance Scenarios**: -1. **Given** `OutboxEvent` 100건이 PENDING 상태, **When** 폴러 스레드 2개가 동시에 같은 사이클을 돌림, **Then** 각 outbox의 발행 호출은 정확히 1번이며 `PUBLISHED` 카운트는 100건이다. -2. **Given** 폴러 A가 outbox X를 잠근 상태(트랜잭션 중), **When** 폴러 B가 같은 사이클을 돌림, **Then** 폴러 B는 X를 건너뛰고 다른 outbox만 처리한다(잠금 대기 없음). +1. **Given** `OutboxEvent` 100건이 `PENDING` 상태, **When** 폴러 스레드 2개가 동시에 같은 사이클을 돌림, **Then** 각 outbox의 발행 호출은 정확히 1번이며 `PUBLISHED` 카운트는 100건이다. +2. **Given** 폴러 A가 outbox X를 행 단위 락으로 잠근 상태, **When** 폴러 B가 같은 사이클을 돌림, **Then** 폴러 B는 X를 잠금 대기 없이 즉시 건너뛰고 다른 outbox만 처리한다. --- -### User Story 3 - 영구 실패 outbox는 자동 격리 + Mattermost 알림 (Priority: P2) +### User Story 3 - 오래된 PENDING은 자동 재시도 루프에서 제외된다 (Priority: P2) -특정 outbox가 발행 시 항상 실패(예: 외부 시스템 영구 장애, 데이터 손상)할 때 무한 재시도로 폴러 사이클을 잡아먹는 것을 방지하고, 운영자가 수동 조치할 수 있도록 알림을 발송한다. +발행이 영구적으로 실패하는 outbox(데이터 손상, 외부 시스템 영구 장애)가 폴러 사이클을 영원히 점유하지 않도록, 일정 시간(컷오프)을 넘긴 `PENDING`은 자동 재발행 대상에서 제외한다. -**Why this priority**: P2인 이유 — 운영 효율성과 가시성 개선이지만, US1/US2가 없으면 무의미. P1 두 개가 At-Least-Once 핵심 보장이고, 본 스토리는 그 위에 얹는 격리/관측 계층. +**Why this priority**: P2인 이유 — poison message 방어는 운영 건전성 개선이지만 US1/US2가 없으면 무의미. P1 둘이 At-Least-Once 핵심이고 본 스토리는 그 위에 얹는 안정화 계층. -**Independent Test**: 발행이 항상 실패하는 outbox → `maxRetryCount`(기본 5) 초과 → `status=FAILED` + Mattermost 알림 1회 호출 검증. US1의 재시도 인프라가 있어야 의미가 있지만, 단위 테스트 수준에서는 mock으로 단독 검증 가능. +**Independent Test**: `createdAt`이 컷오프보다 오래된 `PENDING` outbox는 폴러의 폴링 쿼리 결과에 포함되지 않음을 검증. **Acceptance Scenarios**: -1. **Given** Publisher가 항상 RuntimeException을 던지는 `OutboxEvent` 1건, **When** 폴러가 `maxRetryCount`만큼 재시도, **Then** 상태가 `FAILED`로 전환되고 `lastError`가 기록되며 Mattermost 알림이 정확히 1회 발송되고 이후 사이클에서 더 이상 잡히지 않는다. +1. **Given** `createdAt`이 컷오프(기본 5분)보다 오래된 `PENDING` outbox 1건과 컷오프 이내 `PENDING` outbox 1건, **When** 폴러가 사이클을 돌림, **Then** 컷오프 이내 건만 재발행 시도되고 오래된 건은 선택되지 않는다(DB에는 `PENDING`으로 잔존 — 삭제 아님, 수동 조치 영역으로 이관). +2. **Given** 컷오프 설정값을 변경, **When** 앱을 재기동, **Then** 변경된 컷오프 기준으로 폴링 대상이 결정된다. --- ### Edge Cases -- **앱 종료 중 폴러 사이클**: 진행 중 작업은 graceful shutdown 패턴으로 완료 후 종료. 강제 종료 시 해당 outbox는 다음 인스턴스에서 재발견되어 재시도(US1 시나리오). +- **앱 종료 중 폴러 사이클**: 진행 중 작업은 graceful shutdown으로 완료 후 종료. 강제 종료 시 해당 outbox는 다음 인스턴스 폴러가 재발견해 재시도(US1). - **사이클 간격보다 발행이 오래 걸림**(예: 5초 사이클 + 6초 발행): 행 단위 락이 살아있는 동안 다음 사이클은 해당 outbox를 건너뜀(US2와 동일 메커니즘). -- **백오프 누적**: 첫 재시도 1s → 2s → 4s → 8s → 16s (단순 지수). Jitter는 본 스펙 범위 밖. -- **DLQ 자동 이동**: 본 스펙 범위 밖. Spec 003에서 Consumer Group + DLQ 일괄 처리. -- **메시지 순서**: 본 스펙은 순서 보장을 약속하지 않는다(At-Least-Once만 보장). 재발행으로 인해 순서가 뒤바뀔 수 있다. -- **시계 왜곡(clock skew)**: 다중 인스턴스 간 시계 차이로 `nextRetryAt` 비교 결과가 흔들릴 수 있으나, 락이 동시 처리를 막으므로 영향 미미. +- **컷오프 경계의 메시지**: `createdAt`이 정확히 컷오프 직전인 outbox가 폴링 도중 컷오프를 넘기면 다음 사이클부터 제외 — 자연스러운 동작, 별도 처리 불필요. +- **컷오프 초과 후 영구 잔존**: 컷오프를 넘긴 `PENDING`은 자동 복구 대상에서 빠지며 DB에 남는다. 운영자 가시성(알림/메트릭)은 본 스펙 범위 밖(후속 observability 스펙). +- **메시지 순서**: 본 스펙은 순서를 약속하지 않는다(At-Least-Once만 보장). 재발행으로 순서가 뒤바뀔 수 있으며 컨슈머가 이를 감내한다. ## Requirements *(mandatory)* ### Functional Requirements -- **FR-001**: 시스템은 PENDING 상태이며 `nextRetryAt`이 도래한 `OutboxEvent`를 주기적(기본 5초 간격)으로 조회한다. -- **FR-002**: 폴링 쿼리는 행 단위 락 + 건너뛰기 의미론(`SELECT ... FOR UPDATE SKIP LOCKED` 또는 동등 효과의 비관적 락)으로 동시 폴러 인스턴스 간 안전을 보장한다. 잠긴 행은 즉시 건너뛰며 대기하지 않는다. -- **FR-003**: 발행 성공 시 상태를 `PUBLISHED`로 전환한다. 실패 시 `retryCount`를 1 증가시키고 `nextRetryAt`을 `now + 2^retryCount` 초로 설정한다. -- **FR-004**: `retryCount`가 `maxRetryCount`(기본 5)를 초과하면 상태를 `FAILED`로 전환하고 Mattermost 알림을 정확히 1회 발송한다. 이후 사이클은 해당 outbox를 더 이상 선택하지 않는다. -- **FR-005**: `OutboxEvent`에 `retryCount`(정수, default 0), `nextRetryAt`(nullable 시각), `lastError`(nullable 문자열) 필드를 추가한다. 마이그레이션은 `ddl-auto=update`에 의존(정식 마이그레이션 도입은 Spec 005). -- **FR-006**: `OutboxStatus` enum에 `FAILED` 값을 추가한다. -- **FR-007**: `OutboxRepository`에 잠금 기반 폴링 쿼리 메서드를 추가한다(Native query 또는 JPA `@Lock` + `Pageable` 조합). -- **FR-008**: 폴러는 graceful shutdown을 지원한다 — 종료 신호 수신 시 in-flight 작업 완료 후 종료(`@PreDestroy` + 짧은 대기 패턴). -- **FR-009**: 폴러는 `dev`/`prod` 프로필에서 활성화되고 `test` 프로필에서는 비활성화된다(통합 테스트 충돌 방지). `@ConditionalOnProperty(name="outbox.republisher.enabled", havingValue="true")` 기반. +- **FR-001**: 시스템은 `status = PENDING`이며 `createdAt`이 컷오프 이내인 `OutboxEvent`를 주기적(기본 5초 간격)으로 조회한다. +- **FR-002**: 폴링 쿼리는 행 단위 락 + 건너뛰기 의미론(`SELECT ... FOR UPDATE SKIP LOCKED` 또는 동등 효과)으로 동시 폴러 인스턴스 간 안전을 보장한다. 잠긴 행은 대기 없이 즉시 건너뛴다. +- **FR-003**: 폴러는 조회된 각 `OutboxEvent`를 기존 발행 경로(`VerificationMessagePublisher`)로 재발행한다. 성공 시 상태가 `PUBLISHED`로 전환되고, 실패 시 상태는 `PENDING`으로 남아 다음 사이클에 자연 재시도된다(별도 재시도 카운터·백오프 없음). +- **FR-004**: `createdAt`이 컷오프를 초과한 `PENDING` outbox는 폴링 대상에서 제외된다. 해당 레코드는 삭제되지 않고 DB에 `PENDING`으로 잔존한다. +- **FR-005**: 컷오프 시간, 폴링 주기, 배치 크기는 `application.yml` 설정값으로 외부화되며 코드 수정 없이 변경 가능하다. 기본값 — 컷오프 5분, 폴링 주기 5초, 배치 크기 100. +- **FR-006**: 폴러는 `dev`/`prod` 프로필에서 활성화되고 `test` 프로필에서는 비활성화된다. `@ConditionalOnProperty(name = "outbox.republisher.enabled", havingValue = "true")` 기반이며 `test` 프로필은 해당 속성을 `false`로 둔다(통합 테스트는 폴러를 직접 호출해 검증). +- **FR-007**: 폴러는 graceful shutdown을 지원한다 — 종료 신호 수신 시 in-flight 발행 작업 완료 후 종료한다. +- **FR-008**: 본 스펙은 `OutboxEvent` 엔티티에 새 필드를 추가하지 않으며 `OutboxStatus` enum도 변경하지 않는다. 컷오프 판정은 기존 `createdAt` 필드만으로 수행한다. ### Key Entities -- **OutboxEvent**: 외부 시스템으로 발행해야 할 도메인 이벤트의 영속 표현. 본 스펙에서 다음 필드가 신설/변경된다. - - `status`: `PENDING`/`PUBLISHED`/`FAILED`(신규) - - `retryCount`(신규): 누적 재시도 횟수, 기본 0 - - `nextRetryAt`(신규): 다음 재시도 가능 시각, nullable(최초 저장 시 null 또는 즉시 가능) - - `lastError`(신규): 직전 실패의 사유 문자열, nullable -- **OutboxStatus**: outbox 라이프사이클 상태. `FAILED` 추가. -- **Mattermost 알림 채널**: 운영자에게 영구 실패를 통지하는 외부 통지 채널. 본 스펙은 "1회 발송" 계약만 정의(메시지 포맷/대상 채널 설정은 기존 인프라 재사용). +- **OutboxEvent**: 외부 시스템으로 발행해야 할 도메인 이벤트의 영속 표현. **본 스펙에서 스키마 변경 없음.** 사용하는 기존 필드 — `id`(eventId), `status`(`PENDING`/`PUBLISHED`), `payload`, `createdAt`(컷오프 판정 기준). +- **OutboxStatus**: outbox 라이프사이클 상태. 본 스펙은 기존 `PENDING`/`PUBLISHED`만 사용하며 enum을 변경하지 않는다. ## Success Criteria *(mandatory)* ### Measurable Outcomes -- **SC-001**: 카오스 통합 테스트가 통과한다 — Publisher Mock에 일시 장애를 주입하면 폴러가 자동 재시도하여 메시지가 결국 컨슈머에 도달함을 확인. -- **SC-002**: 동시성 통합 테스트가 통과한다 — outbox 100건 PENDING 상태에서 폴러 2개 동시 실행 시 각 outbox가 정확히 1회만 발행됨을 `RepeatedTest 10회` 모두 만족. -- **SC-003**: 백오프 단위 테스트가 통과한다 — `retryCount` 0~5에서 `nextRetryAt` 간격이 1s, 2s, 4s, 8s, 16s, 32s로 단조 증가. -- **SC-004**: 영구 실패 단위 테스트가 통과한다 — `maxRetryCount` 초과 시 상태가 `FAILED`로 전환되고 Mattermost mock이 정확히 1회 호출되며 후속 사이클에서 재선택되지 않음. -- **SC-005**: 폴러 비활성 검증 — `test` 프로필 부팅 시 폴러 빈이 컨텍스트에 부재함(`assertThat(...).doesNotHaveBean(...)` 동등). -- **SC-006**: 정상 경로 평균 발행 지연이 폴링 주기 + 1초 이내(예: 기본 5초 주기 → 평균 ≤ 6초)로 측정된다. +- **SC-001**: 카오스 통합 테스트가 통과한다 — Publisher에 일시 장애를 주입하면 `OutboxEvent`가 `PENDING`으로 남고, 장애 해소 후 폴러 사이클이 재발행하여 `PUBLISHED`로 전환됨을 확인. +- **SC-002**: 동시성 통합 테스트가 통과한다 — `OutboxEvent` 100건 `PENDING` 상태에서 폴러 2개 동시 실행 시 각 outbox가 정확히 1회만 발행됨을 `RepeatedTest 10회` 모두 만족. +- **SC-003**: 컷오프 통합 테스트가 통과한다 — `createdAt`이 컷오프 초과인 `PENDING`은 폴링 결과에서 제외되고, 컷오프 이내인 `PENDING`만 재발행됨을 확인. +- **SC-004**: 폴러 비활성 검증 — `test` 프로필 부팅 시 폴러 빈이 컨텍스트에 부재함. +- **SC-005**: 정상 경로 평균 발행 지연이 폴링 주기 + 1초 이내(기본 5초 주기 → 평균 ≤ 6초)임을 통합 테스트로 측정. ## Assumptions -- 재시도 백오프는 단순 지수(`2^retryCount` 초)로 충분하다고 가정. 무작위 jitter는 본 스펙 범위 밖(트래픽 폭주가 큰 규모가 아니므로 도입 효익 낮음). -- DLQ 자동 이동/관리, Consumer Group 구성, eventId 기반 컨슈머 멱등성은 Spec 003에서 일괄 다룬다. -- 메트릭(예: `outbox_pending_count`, `outbox_failed_count` 등 Prometheus 노출)은 별도 observability 스펙에서 일괄 도입한다. -- 신규 컬럼은 `ddl-auto=update`로 자동 추가된다고 가정(정식 마이그레이션 도구 도입은 Spec 005). +- **컨슈머 멱등성 전제**: 컨슈머(Spec 003)가 동일 `eventId` 중복 메시지를 무해화한다. 따라서 본 스펙은 "정확히 1번"이 아닌 "최소 1번"만 보장하면 충분하다. +- **컷오프 값 근거**: 기본 5분은 (a) 정상적 일시 장애의 최대 회복 시간 — 배포 롤링 업데이트 약 2~3분, Redis/네트워크 순단 1분 미만 — 보다 길고, (b) 챌린지 인증 검증의 비즈니스 허용 지연(당일 인증이면 충분, 분 단위 지연 무해) 이내이다. 5분 ÷ 5초 폴링 = 약 60회 재시도 기회. 운영 데이터로 조정 가능하도록 설정값으로 둔다. +- 신규 컬럼이 없으므로 DB 마이그레이션이 불필요하다. (정식 마이그레이션 도구 Flyway는 Spec 005.) - Spec 001에서 마련한 `IntegrationTest` 베이스(Testcontainers MySQL/Redis)를 그대로 상속하여 새 테스트를 작성한다. -- Mattermost 알림은 기존 인프라(`MattermostNotifier` 또는 동등 구성요소)가 존재하여 본 스펙은 호출 1회 계약만 보장한다고 가정. 부재 시 본 스펙에서 최소 추상화(인터페이스 + no-op 구현)를 도입한다. -- 단일 폴러 사이클 1회 처리량 상한(batch size)은 합리적 기본값(예: 100)으로 두고 구성 가능하게 한다. +- 운영은 다중 인스턴스(HA)를 가정한다. 단일 인스턴스라면 `SELECT ... FOR UPDATE SKIP LOCKED`는 불필요하지만, 유지해도 무해하며 HA 전환 시 안전망이 된다. +- 단일 폴러 사이클의 처리량 상한(batch size)은 기본 100으로 두고 설정 가능하게 한다. ## Out of Scope *(non-mandatory)* +- 재시도 카운트(`retryCount`), 지수 백오프, `nextRetryAt`, `lastError` 필드 — 컨슈머 멱등성 전제로 불필요. +- `OutboxStatus.FAILED` 상태 — 무한 재시도 방지는 시간 컷오프가 담당. +- 영구 실패 outbox에 대한 Mattermost 알림 — 컷오프 초과분의 운영자 가시성(알림/메트릭/대시보드)은 후속 observability 스펙. - 컨슈머 측 멱등성, `eventId` 기반 dedup, Consumer Group 운영, DLQ 자동 이동 (→ Spec 003). -- Prometheus/Grafana 메트릭 노출 및 대시보드 (→ 별도 observability 스펙). +- Prometheus/Grafana 메트릭 노출 (→ 별도 observability 스펙). - Flyway/Liquibase 정식 마이그레이션 (→ Spec 005). -- Jitter 백오프, 우선순위 큐, 메시지 순서 보장. -- Outbox 보존 정책(TTL 기반 삭제, 아카이브). +- Jitter 백오프, 우선순위 큐, 메시지 순서 보장, Outbox 보존(TTL) 정책. From db07452482f9e82e36169c3944ab2f0d2fdcd839 Mon Sep 17 00:00:00 2001 From: simhani1 Date: Fri, 22 May 2026 20:16:07 +0900 Subject: [PATCH 03/11] docs(spec-002): add plan + research + quickstart (simplified outbox republisher) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 단순 버전 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) --- .specify/extensions/git/git-config.yml | 2 +- CLAUDE.md | 10 +- specs/002-outbox-republisher/plan.md | 126 ++++++++++++++++ specs/002-outbox-republisher/quickstart.md | 117 +++++++++++++++ specs/002-outbox-republisher/research.md | 160 +++++++++++++++++++++ 5 files changed, 410 insertions(+), 5 deletions(-) create mode 100644 specs/002-outbox-republisher/plan.md create mode 100644 specs/002-outbox-republisher/quickstart.md create mode 100644 specs/002-outbox-republisher/research.md diff --git a/.specify/extensions/git/git-config.yml b/.specify/extensions/git/git-config.yml index 8c414ba..cbcf5af 100644 --- a/.specify/extensions/git/git-config.yml +++ b/.specify/extensions/git/git-config.yml @@ -37,7 +37,7 @@ auto_commit: enabled: false message: "[Spec Kit] Add project constitution" after_specify: - enabled: false + enabled: true message: "[Spec Kit] Add specification" after_clarify: enabled: false diff --git a/CLAUDE.md b/CLAUDE.md index 4b8a072..d585743 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,9 +2,11 @@ For additional context about technologies to be used, project structure, shell commands, and other important information, read the current plan: -- Active spec: `specs/001-secure-logging-test-foundation/` -- Plan: `specs/001-secure-logging-test-foundation/plan.md` -- Research: `specs/001-secure-logging-test-foundation/research.md` -- Quickstart: `specs/001-secure-logging-test-foundation/quickstart.md` +- Active spec: `specs/002-outbox-republisher/` +- Plan: `specs/002-outbox-republisher/plan.md` +- Research: `specs/002-outbox-republisher/research.md` +- Quickstart: `specs/002-outbox-republisher/quickstart.md` + (data-model·contracts 없음 — 스키마/외부 계약 변경 0) - Constitution: `.specify/memory/constitution.md` +- Prior spec (foundation): `specs/001-secure-logging-test-foundation/` diff --git a/specs/002-outbox-republisher/plan.md b/specs/002-outbox-republisher/plan.md new file mode 100644 index 0000000..4bbe583 --- /dev/null +++ b/specs/002-outbox-republisher/plan.md @@ -0,0 +1,126 @@ +# Implementation Plan: Outbox Republisher Worker (At-Least-Once 보장 1/2) + +**Branch**: `002-outbox-republisher` | **Date**: 2026-05-18 | **Spec**: [spec.md](spec.md) + +**Input**: Feature specification from `/specs/002-outbox-republisher/spec.md` + +## Summary + +`PENDING` 상태로 남은 `OutboxEvent`를 주기적으로 재발행하는 스케줄러 폴러를 도입한다. + +현재 `VerificationRedisStreamPublisher.publish()`의 발행이 실패하면 `catch (RuntimeException)`이 로그만 찍어 `OutboxEvent`가 `PENDING`으로 영영 남고, `@TransactionalEventListener(AFTER_COMMIT)` 실행 전 앱이 죽어도 마찬가지다. 본 스펙은 이 누락분을 자동 복구하는 폴러를 추가해 **At-Least-Once 발행을 보장**한다. + +컨슈머 멱등성(Spec 003)이 중복을 무해화한다는 전제로 "최소 1번"만 보장하면 충분하므로 — 재시도 카운터·지수 백오프·`FAILED` 상태를 **도입하지 않는다**. 무한 재시도는 `createdAt` 기반 시간 컷오프로 차단한다. **`OutboxEvent`/`OutboxStatus` 스키마 변경 0.** + +## Technical Context + +**Language/Version**: Java 21, Spring Boot 3.2.7 (변경 없음) + +**Primary Dependencies**: 신규 의존성 **없음**. 사용 요소 전부 기존 보유 — +- Spring `@Scheduled` (스케줄링은 `PlanetrushApplication`에 `@EnableScheduling` 이미 활성) +- Spring Data JPA (`OutboxRepository`) +- Jackson `ObjectMapper` (`OutboxEvent.payload` JSON → `MessageCommand` 역직렬화) +- 기존 `VerificationMessagePublisher` / `VerificationRedisStreamPublisher` 재사용 + +**Storage**: MySQL `outbox_event` 테이블 (기존), Redis Stream (기존 발행 대상). 스키마 변경 없음. + +**Testing**: JUnit 5 + Testcontainers. Spec 001의 `IntegrationTest` 베이스(MySQL 8.0.36 + Redis 7-alpine) 상속. + +**Target Platform**: Spring Boot 단일 모듈 웹 서비스, 다중 인스턴스(HA) 운영 가정. + +**Project Type**: 단일 모듈. 폴러는 `outbox/republisher` 신규 패키지. + +**Performance Goals**: +- 정상 경로 평균 발행 지연 ≤ 폴링 주기 + 1초 (기본 5초 주기 → ≤ 6초, SC-005). +- 폴러 1사이클 처리량 상한 = 배치 크기(기본 100), 설정 가능. + +**Constraints**: +- `OutboxEvent`/`OutboxStatus` 스키마 변경 금지 (FR-008). +- 폴러는 `test` 프로필에서 비활성 (FR-006) — 통합 테스트는 폴러 컴포넌트를 직접 호출해 검증. +- 다중 폴러 인스턴스 간 동일 outbox 이중 처리 0 (FR-002). + +**Scale/Scope**: 신규 파일 ~3개(폴러 컴포넌트, 설정 properties, 락 쿼리), 수정 ~4개(`OutboxRepository`, `application*.yml`). 통합 테스트 ~3개 + 단위 테스트 ~2개. + +## Constitution Check + +*GATE: Phase 0 진입 전 통과 필수. Phase 1 후 재검토.* + +| 원칙 | 적용 | 본 스펙에서의 의미 | 판정 | +|---|---|---|---| +| **I. Testcontainers (NON-NEGOTIABLE)** | 적용 | 카오스(SC-001)·동시성(SC-002)·컷오프(SC-003) 통합 테스트가 `IntegrationTest` 베이스 상속. SKIP LOCKED는 실제 MySQL 필수라 H2 불가 — Testcontainers 당위성 자체. | ✅ Pass | +| **II. 외부 의존 어댑터 격리** | 적용 | Redis 발행은 기존 `VerificationRedisStreamPublisher`(infra/publisher 어댑터) 재사용. 폴러는 도메인이 아닌 `outbox/republisher` 레이어. 도메인 코드가 외부 클라이언트 직접 호출 0. | ✅ Pass | +| **III. QueryDSL Projections** | N/A | 폴링은 `OutboxEvent` 엔티티 조회(DTO 매핑 아님). 락 쿼리는 native 또는 `@Lock` — 본 원칙은 Entity→DTO 매핑 규칙이라 해당 없음. | ✅ Pass (해당 없음) | +| **IV. Outbox 강제 (NON-NEGOTIABLE)** | 적용 | 본 스펙이 Outbox 인프라 자체를 강화. 발행은 전부 OutboxEvent 경유. 위반 불가능. | ✅ Pass | +| **V. 시크릿 로그 금지 (NON-NEGOTIABLE)** | 적용 | 폴러 신규 로그에 시크릿 키워드 미사용. `payload`(이미지 URL)는 시크릿 아님. Spec 001 마스킹 컨버터가 2차 방어. | ✅ Pass | +| **VI. 듀얼 AI 리뷰** | 적용 | PR 단계에서 Claude + Codex 리뷰 첨부. | ✅ Pass (PR 단계) | +| **VII. 인수 기준 자동 테스트** | 적용 | SC-001~005 전부 자동 테스트로 검증. tasks.md에 SC↔Test 매핑. | ✅ Pass | + +**결과**: 전 게이트 통과. NON-NEGOTIABLE 3종 위반 0. Phase 0 진입 가능. + +**라이브러리 도입 규칙**: 신규 의존성 0건 — 본 항목 해당 없음. + +## Project Structure + +### Documentation (this feature) + +```text +specs/002-outbox-republisher/ +├── spec.md # 작성 완료 (단순 버전) +├── plan.md # 본 파일 +├── research.md # Phase 0 산출물 (R-001~) +├── quickstart.md # Phase 1 산출물 (폴러 운영·튜닝 가이드) +├── checklists/requirements.md # /speckit-specify 산출 +└── tasks.md # /speckit-tasks 단계 생성 + +# data-model.md — 스키마 변경 0 → 아래 plan에 "변경 없음" 명시 후 생략 +# contracts/ — 신규 외부 API/계약 0 (내부 컴포넌트만) → 생략 +``` + +### Source Code (repository root) + +```text +src/main/java/com/planetrush/planetrush/ +├── outbox/ +│ ├── domain/OutboxEvent.java # 변경 없음 (스키마 동결) +│ ├── repository/OutboxRepository.java # 수정: SKIP LOCKED 폴링 쿼리 메서드 추가 +│ └── republisher/ # 신규 패키지 +│ ├── OutboxRepublisher.java # @Scheduled 폴러 컴포넌트 +│ └── OutboxRepublisherProperties.java # @ConfigurationProperties (컷오프·주기·배치) +└── infra/publisher/ + └── VerificationRedisStreamPublisher.java # 재사용 (변경 없음 — 폴러가 publish() 호출) + +src/main/resources/ +├── application.yml # 수정: outbox.republisher.* 기본값 +├── application-dev.yml / application-prod.yml # 수정: outbox.republisher.enabled=true +└── application-test.yml # 수정: outbox.republisher.enabled=false + +src/test/java/com/planetrush/planetrush/outbox/republisher/ +├── OutboxRepublisherIntegrationTest.java # 카오스(SC-001) + 컷오프(SC-003) +├── OutboxRepublisherConcurrencyTest.java # 동시성 RepeatedTest (SC-002) +└── OutboxRepublisherProfileTest.java # 폴러 비활성 검증 (SC-004) +``` + +**Structure Decision**: 폴러는 `outbox/republisher` 신규 하위 패키지에 둔다 — Outbox 도메인의 일부이되 `domain`/`repository`와 분리해 "재발행 메커니즘"임을 명확히. `scheduler/ScheduledTasks`에 메서드를 추가하지 않는 이유: `ScheduledTasks`는 행성·멤버 도메인 배치이고 outbox 폴러는 인프라 신뢰성 관심사 — 응집도 분리. + +## Constitution Alignment (Governance §1) + +- **원칙 IV(NON-NEGOTIABLE)**: 본 스펙은 Outbox 패턴의 신뢰성을 강화하는 작업으로 원칙 IV의 정신을 직접 구현. +- **원칙 I(NON-NEGOTIABLE)**: SKIP LOCKED 동시성 검증은 실제 MySQL이 필수 — Testcontainers의 존재 이유 그 자체. H2로는 검증 불가. +- **원칙 II**: 폴러는 발행을 직접 하지 않고 기존 `VerificationMessagePublisher` 어댑터에 위임. +- **원칙 III·V·VI·VII**: 위 Constitution Check 표 참조. + +## Complexity Tracking + +> 위반 없음. 표 비움. + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| (해당 없음) | — | — | + +## Post-Design Constitution Re-Check + +Phase 0·1 산출물(research.md, quickstart.md) 작성 후 갱신: + +- 신규 위반 없음. `data-model.md`/`contracts/` 생략은 스키마·외부 계약 변경 0에 따른 의도된 N/A이며 본 plan Structure에 명시. ✅ +- research.md의 락 쿼리 결정(native `FOR UPDATE SKIP LOCKED`)이 원칙 III와 무관함 재확인 — 엔티티 조회이지 DTO 매핑 아님. ✅ +- 전 게이트 재통과. **Phase 2(`/speckit-tasks`) 진입 가능.** diff --git a/specs/002-outbox-republisher/quickstart.md b/specs/002-outbox-republisher/quickstart.md new file mode 100644 index 0000000..20f02fb --- /dev/null +++ b/specs/002-outbox-republisher/quickstart.md @@ -0,0 +1,117 @@ +# Quickstart: Outbox Republisher Worker + +**대상**: 폴러를 운영·튜닝하거나 outbox 재발행 동작을 검증하려는 개발자 +**전제**: Spec 002 머지 완료 (`OutboxRepublisher` 가용) + +--- + +## 1. 폴러가 하는 일 (30초 요약) + +``` +5초마다: + status=PENDING 이고 createdAt이 컷오프(5분) 이내인 OutboxEvent 조회 + (SELECT ... FOR UPDATE SKIP LOCKED — 다중 인스턴스 안전) + │ + ▼ 각 건마다 + payload(JSON) → MessageCommand 역직렬화 → Redis Stream 재발행 + ├─ 성공 → status=PUBLISHED + └─ 실패 → PENDING 유지 → 다음 사이클 자연 재시도 +``` + +발행 직전 앱이 죽거나 `VerificationRedisStreamPublisher` 발행이 실패해 `PENDING`으로 남은 outbox를 자동 복구한다. **At-Least-Once 발행 보장.** + +--- + +## 2. 설정 (`application.yml`) + +```yaml +outbox: + republisher: + enabled: true # 폴러 활성 여부 (test 프로필은 false) + polling-interval-ms: 5000 # 폴링 주기 (밀리초) + cutoff-minutes: 5 # createdAt 컷오프 (분) — 초과 PENDING은 폴링 제외 + batch-size: 100 # 사이클당 처리 상한 +``` + +모두 코드 수정 없이 조정 가능. 프로필별 오버라이드: +- `application-test.yml`: `enabled: false` (통합 테스트는 폴러를 직접 호출) +- `application-dev.yml`/`application-prod.yml`: 공통 `application.yml` 값 상속 + +--- + +## 3. 컷오프 튜닝 가이드 + +`cutoff-minutes`는 "이 시간을 넘긴 PENDING은 자동 복구를 포기한다"는 경계다. + +``` +[하한] [컷오프] [상한] +정상적 일시 장애 < 이 값 < 비즈니스 허용 지연 +의 최대 회복 시간 +(배포 ~3분) (기본 5분) (인증 검증 분 단위 지연 무해) +``` + +- **너무 짧으면**: 배포 롤링 업데이트 중 쌓인 PENDING이 배포 끝나기 전에 버려짐 (false positive). +- **너무 길면**: poison message가 오래 폴러 사이클을 점유. +- **권장 튜닝법**: 운영 후 "정상 발행된 outbox의 `createdAt → PUBLISHED` 소요 시간" p99를 측정해 그보다 충분히 크게 설정. 메트릭은 후속 observability 스펙. + +--- + +## 4. 동작 확인 + +### 로컬에서 폴러 강제 트리거 + +`test` 프로필에서는 폴러가 비활성이므로, 통합 테스트는 `OutboxRepublisher`의 메서드를 직접 호출한다: + +```java +class SomeTest extends IntegrationTest { + @Autowired OutboxRepublisher republisher; + + @Test + void pendingIsRepublished() { + // given: PENDING outbox 저장 + // when: + republisher.republishPending(); // 폴러 사이클 1회 직접 실행 + // then: status == PUBLISHED 확인 + } +} +``` + +### 운영 중 stuck outbox 조회 + +컷오프를 넘겨 자동 복구에서 제외된 PENDING은 DB에 남는다. 직접 조회: + +```sql +SELECT id, event_type, created_at +FROM outbox_event +WHERE status = 'PENDING' AND created_at <= NOW() - INTERVAL 5 MINUTE; +``` + +--- + +## 5. 트러블슈팅 + +| 증상 | 원인 / 조치 | +|---|---| +| 폴러가 안 도는 것 같음 | `outbox.republisher.enabled` 확인. `test` 프로필이면 의도된 비활성 | +| PENDING이 계속 쌓임 | Redis Stream 발행 자체가 실패 중. `VerificationRedisStreamPublisher` 로그 확인 (`[Redis Stream] publish failed`) | +| 컷오프 넘긴 PENDING이 안 줄어듦 | 정상 — 컷오프 초과분은 자동 복구 대상 아님. 위 SQL로 조회 후 수동 조치 | +| 다중 인스턴스에서 중복 발행 | `FOR UPDATE SKIP LOCKED` 미동작 의심. MySQL 8.0+ 인지 확인. 단 컨슈머 멱등성(Spec 003)이 무해화 | +| 폴러 사이클이 느림 | `batch-size`가 과도하게 큰지 확인. 락 보유 시간 ∝ 배치 크기 | + +--- + +## 6. 후속 스펙과의 관계 + +| 스펙 | 관계 | +|---|---| +| **Spec 003** (Idempotent Stream Consumer) | 폴러의 재발행으로 발생 가능한 **중복 메시지를 컨슈머 측에서 무해화**. 본 스펙(At-Least-Once) + Spec 003(멱등성) = Exactly-Once-Effect | +| **Spec 005** (Flyway) | 본 스펙은 스키마 변경이 없어 무관. `ddl-auto` 전환은 Spec 005 | +| observability 스펙 | 컷오프 초과 stuck outbox 카운트, 발행 지연 분포 등 메트릭 노출 | + +--- + +## 7. 알려진 한계 (의도된 단순화) + +- **재시도 횟수 추적 없음**: `retryCount`/`FAILED` 상태 미도입. 무한 재시도는 시간 컷오프로만 차단. → 컨슈머 멱등성 전제이므로 "정확히 1번"이 불필요. +- **컷오프 초과 알림 없음**: stuck outbox를 운영자에게 자동 통지하지 않음. 위 SQL 수동 조회 또는 후속 observability 스펙. +- **메시지 순서 미보장**: 재발행으로 순서가 뒤바뀔 수 있음. 컨슈머가 감내. diff --git a/specs/002-outbox-republisher/research.md b/specs/002-outbox-republisher/research.md new file mode 100644 index 0000000..a3e32f6 --- /dev/null +++ b/specs/002-outbox-republisher/research.md @@ -0,0 +1,160 @@ +# Phase 0 Research: Outbox Republisher Worker + +**Feature**: Outbox Republisher Worker (At-Least-Once 보장 1/2) +**Branch**: `002-outbox-republisher` +**Date**: 2026-05-18 + +plan.md Technical Context의 미해결 결정을 푼다. 신규 의존성이 없으므로 라이선스 점검은 생략한다. + +--- + +## R-001 · SKIP LOCKED 폴링 쿼리 구현 방식 + +**Decision**: `OutboxRepository`에 **native query**로 `SELECT ... FOR UPDATE SKIP LOCKED`를 작성한다. + +```java +@Query(value = """ + SELECT * FROM outbox_event + WHERE status = 'PENDING' AND created_at > :cutoff + ORDER BY created_at + LIMIT :batchSize + FOR UPDATE SKIP LOCKED + """, nativeQuery = true) +List findRepublishableForUpdateSkipLocked( + @Param("cutoff") Instant cutoff, @Param("batchSize") int batchSize); +``` + +**Rationale**: + +- MySQL 8.0+는 `FOR UPDATE SKIP LOCKED`를 정식 지원. 잠긴 행을 **대기 없이 즉시 건너뛴다** — FR-002의 "잠금 대기 없음" 요구에 정확히 부합. +- JPA `@Lock(PESSIMISTIC_WRITE)`만으로는 일반 `FOR UPDATE`까지만 — 잠긴 행에서 **대기**가 발생해 다중 폴러가 직렬화됨. SKIP LOCKED 의미론이 안 나온다. +- native query 결과도 Spring Data JPA가 컬럼→필드 매핑으로 **managed 엔티티**를 반환하므로, 같은 트랜잭션 내에서 `published()` 호출 시 dirty checking으로 자동 반영된다. + +**Alternatives considered**: + +| 대안 | 채택 X 사유 | +|---|---| +| `@Lock` + `@QueryHints(jakarta.persistence.lock.timeout = -2)` | Hibernate 6의 `-2`(SKIP_LOCKED 매직값) 의존 — 가독성 낮고 버전 의존적. native query가 의도를 직접 표현 | +| 애플리케이션 레벨 분산 락(Redisson 등) | 신규 의존성 + 별도 인프라. DB가 이미 SKIP LOCKED를 제공하는데 과함 | +| 락 없이 폴링 + 컨슈머 멱등성에 전적 의존 | 동작은 하나 다중 인스턴스에서 매 사이클 N배 중복 발행 → 브로커·컨슈머 부하 N배 (US2 위반) | + +--- + +## R-002 · 폴러 트랜잭션 경계 + +**Decision**: 폴러 사이클 1회 = 트랜잭션 1개. `@Scheduled` 메서드에 `@Transactional`을 두고, 그 안에서 [SKIP LOCKED 조회 → 배치 각 건 발행 → 성공 시 `published()`]를 수행한다. 트랜잭션이 커밋될 때까지 조회된 행의 락이 유지되어 다른 폴러 인스턴스는 해당 행을 건너뛴다. + +**Rationale**: + +- SKIP LOCKED의 락은 **트랜잭션 생존 기간 동안만** 유효. 조회 후 트랜잭션을 닫으면 락이 풀려 다른 폴러가 끼어든다. 따라서 조회~발행~상태전환을 한 트랜잭션으로 묶어야 한다. +- 발행은 Redis Stream `XADD` — 보통 1건당 수 ms. 배치 100건이면 사이클당 수백 ms 수준이라 락 보유 시간이 짧다. 단순함 우선. +- 기존 `VerificationRedisStreamPublisher.publish()`가 `@Transactional`(전파 REQUIRED)이라 폴러 트랜잭션에 자연히 참여한다. `publish()` 내부 `catch (RuntimeException)`이 예외를 삼키므로 한 건 실패가 폴러 트랜잭션 전체를 롤백시키지 않는다 — 실패 건은 `PENDING`으로 남아 다음 사이클 재시도(FR-003). + +**Alternatives considered**: + +- **건별 트랜잭션** (각 outbox를 독립 트랜잭션): 락 보유 시간이 더 짧고 격리도 좋지만, 폴러가 `REQUIRES_NEW`로 건별 트랜잭션을 열어야 해 구조 복잡도↑. 발행이 빠른 본 도메인에선 사이클당 트랜잭션으로 충분 — 단순함 우선. 향후 발행 지연이 커지면 건별로 전환 가능(quickstart에 명시). + +**Caveat**: 배치 크기를 과도하게 키우면(예: 10000) 락 보유 시간이 길어진다. 기본 100을 권장하며 설정 가능(R-006). + +--- + +## R-003 · `OutboxEvent.payload`(JSON) → `MessageCommand` 역직렬화 + +**Decision**: 폴러가 `ObjectMapper`로 `payload`를 역직렬화해 `MessageCommand`를 조립한다. 필드 매핑: + +| MessageCommand | 출처 | +|---|---| +| `eventId` | `OutboxEvent.id` | +| `standardImg` | `payload.standardImgUrl` | +| `targetImg` | `payload.verificationImgUrl` | +| `memberId` | `payload.memberId` | +| `planetId` | `payload.planetId` | + +`payload`는 `VerificationExternalEventRecorder.buildPayload()`가 만든 `{standardImgUrl, verificationImgUrl, memberId, planetId}` JSON이다. + +**Rationale**: + +- `OutboxEvent`는 `payload`를 불투명 문자열로 들고 있고, 발행 경로(`VerificationMessagePublisher.publish`)는 `MessageCommand`를 받는다. 폴러가 둘을 잇는 변환을 담당. +- 역직렬화 대상은 작은 전용 record(`VerificationOutboxPayload`)로 두어 타입 안전성 확보. `Map` 직접 파싱은 키 오타 위험. + +**Alternatives considered**: + +- `payload` JSON에 처음부터 `MessageCommand`와 동일 구조를 저장: 기존 `buildPayload()` 변경 필요 + 기존 outbox 레코드와 불일치. 변환 레이어가 더 안전. + +**Caveat**: `EventType`이 `VERIFICATION_REQUEST` 외로 늘어나면 폴러가 타입별 분기를 해야 한다. 현재 단일 타입이므로 본 스펙은 `VERIFICATION_REQUEST`만 처리하고, 다른 타입은 로그 경고 후 건너뛴다(향후 확장점). + +--- + +## R-004 · Graceful Shutdown + +**Decision**: Spring 기본 `ThreadPoolTaskScheduler`의 종료 대기를 활성화한다. + +```yaml +spring: + task: + scheduling: + shutdown: + await-termination: true + await-termination-period: 20s +``` + +**Rationale**: + +- `@Scheduled` 메서드는 `ThreadPoolTaskScheduler`에서 실행된다. `await-termination`을 켜면 앱 종료 시 in-flight 폴러 사이클이 완료될 때까지 대기 → FR-007 충족. +- 설령 강제 종료로 사이클이 중단되어도, 트랜잭션이 롤백되어 해당 outbox는 `PENDING`으로 남고 다음 인스턴스가 재처리한다(At-Least-Once라 안전). graceful shutdown은 "정상 종료 시 깔끔함"을 위한 것이지 정합성의 필수 조건은 아니다. + +**Alternatives considered**: + +- 폴러에 `@PreDestroy` + 수동 플래그: `ThreadPoolTaskScheduler`의 표준 종료 처리로 충분하므로 불필요한 복잡도. + +--- + +## R-005 · 폴러 활성/비활성 (프로필별) + +**Decision**: 폴러 컴포넌트에 `@ConditionalOnProperty(name = "outbox.republisher.enabled", havingValue = "true")`를 붙인다. + +- `application.yml` (공통 기본): `outbox.republisher.enabled: true` +- `application-test.yml`: `outbox.republisher.enabled: false` + +**Rationale**: + +- `test` 프로필에서 폴러가 자동으로 돌면 통합 테스트의 "발행 직전 상태"를 폴러가 가로채 검증이 불안정해진다. 비활성화 후 테스트가 폴러 컴포넌트의 메서드를 **직접 호출**해 결정론적으로 검증한다(FR-006). +- `dev`/`prod`는 공통 `application.yml`의 `true`를 그대로 상속하므로 별도 명시 불필요. + +**Alternatives considered**: + +- `@Profile("!test")`: 동작하지만 운영 중 폴러를 끄려면 재배포 필요. `@ConditionalOnProperty`는 설정만으로 토글 가능해 운영 유연성이 높다. + +--- + +## R-006 · 설정 외부화 (`@ConfigurationProperties`) + +**Decision**: `OutboxRepublisherProperties`를 `@ConfigurationProperties(prefix = "outbox.republisher")` record로 둔다. + +| 키 | 타입 | 기본값 | 의미 | +|---|---|---|---| +| `enabled` | boolean | `true` | 폴러 활성 여부 | +| `polling-interval-ms` | long | `5000` | 폴링 주기(밀리초) | +| `cutoff-minutes` | long | `5` | `createdAt` 컷오프(분) — 초과 PENDING은 폴링 제외 | +| `batch-size` | int | `100` | 사이클당 처리 상한 | + +`@Scheduled(fixedDelayString = "${outbox.republisher.polling-interval-ms:5000}")`로 주기를 주입한다. + +**Rationale**: + +- FR-005 — 컷오프·주기·배치를 코드 수정 없이 조정 가능해야 한다. `@ConfigurationProperties`가 타입 안전 + IDE 자동완성 + 검증(`@Validated`) 지원. +- 컷오프 5분 근거는 spec.md Assumptions에 명문화됨(배포 시간 ~3분 초과 + 비즈니스 허용 지연 이내 + 폴링 60회 기회). 운영 데이터로 조정. + +**Alternatives considered**: + +- 개별 `@Value` 주입: 키가 4개로 늘면 흩어져 응집도 저하. record properties로 묶는 게 명료. + +--- + +## 결론 + +모든 미해결 결정이 해소되었다. Phase 1로 이동한다. + +- `data-model.md`: **생성하지 않음** — `OutboxEvent`/`OutboxStatus` 스키마 변경이 0이다(FR-008). plan.md Structure에 명시. +- `contracts/`: **생성하지 않음** — 본 스펙은 신규 외부 API/계약을 추가하지 않으며 내부 컴포넌트(`OutboxRepublisher`)만 도입한다. 내부 협력 인터페이스는 기존 `VerificationMessagePublisher`를 재사용. +- `quickstart.md`: 폴러 운영·튜닝·트러블슈팅 가이드를 작성한다. From 5178d6cdc86d7a3c31f6ff21e2a0721eb3b179f3 Mon Sep 17 00:00:00 2001 From: simhani1 Date: Fri, 22 May 2026 20:19:23 +0900 Subject: [PATCH 04/11] docs(spec-002): regenerate tasks for simplified outbox republisher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 단순 버전 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) --- specs/002-outbox-republisher/tasks.md | 218 ++++++++++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 specs/002-outbox-republisher/tasks.md diff --git a/specs/002-outbox-republisher/tasks.md b/specs/002-outbox-republisher/tasks.md new file mode 100644 index 0000000..d99c462 --- /dev/null +++ b/specs/002-outbox-republisher/tasks.md @@ -0,0 +1,218 @@ +--- + +description: "Task list for Spec 002 — Outbox Republisher Worker (simplified)" +--- + +# Tasks: Outbox Republisher Worker (At-Least-Once 보장 1/2) + +**Input**: Design documents from `/specs/002-outbox-republisher/` + +**Prerequisites**: plan.md (✅), spec.md (✅, 단순 버전), research.md (✅), quickstart.md (✅) + +**Tests**: 인수 기준(SC-001~005)이 자동 테스트로 검증되어야 한다(Constitution 원칙 VII). 통합 테스트는 Spec 001의 `IntegrationTest`(Testcontainers) 베이스를 상속한다. + +**Organization**: Tasks are grouped by user story. 단순 버전 특성상 폴러 핵심 구현은 Foundational + US1에 모이고, US2·US3는 그 위에 동작을 검증하는 테스트 중심 phase다. + +## Format: `[ID] [P?] [Story?] Description with file path` + +- **[P]**: 다른 파일, 선행 미완 의존 없음 → 병렬 가능 +- **[Story]**: US1/US2/US3 — Phase 3+ 에만 부착 + +## Path Conventions + +단일 Spring Boot 모듈: +- Production: `src/main/java/com/planetrush/planetrush/...` +- Resources: `src/main/resources/...` +- Tests: `src/test/java/com/planetrush/planetrush/...` + +## 설계 전제 (plan/research 반영) + +- 신규 의존성 0. `OutboxEvent`/`OutboxStatus` 스키마 변경 0. +- 폴러는 `outbox/republisher` 신규 패키지. 발행은 기존 `VerificationMessagePublisher` 재사용. +- 락: `OutboxRepository` native query `FOR UPDATE SKIP LOCKED` (research R-001). +- 트랜잭션: 폴러 사이클당 1개 (research R-002). +- 컷오프/주기/배치: `OutboxRepublisherProperties` 설정 외부화 (research R-006). + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: 설정 외부화 + 스케줄러 종료 정책. + +- [ ] T001 [P] Create `src/main/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisherProperties.java` — `@ConfigurationProperties(prefix = "outbox.republisher")` record. 필드: `enabled`(boolean, default true), `pollingIntervalMs`(long, 5000), `cutoffMinutes`(long, 5), `batchSize`(int, 100). `@Validated` + 양수 제약. +- [ ] T002 [P] Add `outbox.republisher.*` 기본값 to `src/main/resources/application.yml` (`enabled: true`, `polling-interval-ms: 5000`, `cutoff-minutes: 5`, `batch-size: 100`) and override `outbox.republisher.enabled: false` in `src/main/resources/application-test.yml`. +- [ ] T003 [P] Add `spring.task.scheduling.shutdown.await-termination: true` + `await-termination-period: 20s` to `src/main/resources/application.yml` (research R-004, graceful shutdown). + +**Checkpoint**: 설정 바인딩 가능. `@ConfigurationProperties` 스캔 등록(`@ConfigurationPropertiesScan` 또는 `@EnableConfigurationProperties`)은 T006에서 폴러와 함께 처리. + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: 폴러가 의존하는 쿼리·변환 유틸. 모든 User Story의 전제. + +**⚠️ CRITICAL**: 이 Phase가 끝나야 US1/US2/US3 진입 가능. + +- [ ] T004 Add SKIP LOCKED 폴링 쿼리 to `src/main/java/com/planetrush/planetrush/outbox/repository/OutboxRepository.java` — native query 메서드 `findRepublishableForUpdateSkipLocked(Instant cutoff, int batchSize)`: `SELECT * FROM outbox_event WHERE status='PENDING' AND created_at > :cutoff ORDER BY created_at LIMIT :batchSize FOR UPDATE SKIP LOCKED` (research R-001). +- [ ] T005 [P] Create `src/main/java/com/planetrush/planetrush/outbox/republisher/VerificationOutboxPayload.java` — `OutboxEvent.payload` JSON 역직렬화용 record(`standardImgUrl`, `verificationImgUrl`, `memberId`, `planetId`) + `OutboxEvent` → `MessageCommand` 변환 로직(매핑: eventId=id, standardImg=standardImgUrl, targetImg=verificationImgUrl). research R-003. + +**Checkpoint**: 폴링 쿼리 + payload 변환 준비 완료. 폴러 컴포넌트 작성 가능. + +--- + +## Phase 3: User Story 1 - 발행 누락분 자동 재발행 (Priority: P1) 🎯 MVP + +**Goal**: `PENDING`으로 남은 `OutboxEvent`를 폴러가 주기적으로 발견해 재발행한다. + +**Independent Test**: Publisher에 일시 장애를 주입해 `OutboxEvent`를 `PENDING`으로 만든 뒤, 장애 해소 후 폴러 사이클을 직접 호출하면 재발행되어 `PUBLISHED`로 전환됨. + +### Implementation for User Story 1 + +- [ ] T006 [US1] Create `src/main/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisher.java` 골격 — `@Component`, `@ConditionalOnProperty(name="outbox.republisher.enabled", havingValue="true")`, 생성자 주입(`OutboxRepository`, `VerificationMessagePublisher`, `ObjectMapper`, `OutboxRepublisherProperties`). `@EnableConfigurationProperties(OutboxRepublisherProperties.class)`는 본 클래스 또는 별도 config에 부착. +- [ ] T007 [US1] Implement `republishPending()` in `OutboxRepublisher.java` — `@Scheduled(fixedDelayString="${outbox.republisher.polling-interval-ms:5000}")` + `@Transactional`. 로직: cutoff 계산(`now - cutoffMinutes`) → `findRepublishableForUpdateSkipLocked` 조회 → 각 건 `VerificationOutboxPayload` 역직렬화 → `MessageCommand` 변환 → `messagePublisher.publish()` 호출. `publish()` 성공 시 내부에서 `published()` 전환됨(기존 동작 재사용). research R-002 트랜잭션 경계. +- [ ] T008 [US1] Add `src/test/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisherIntegrationTest.java` — `extends IntegrationTest`. 카오스 시나리오(SC-001): `PENDING` OutboxEvent 저장 → `republishPending()` 직접 호출 → `status == PUBLISHED` 검증. 발행 1차 실패 후 재시도 케이스 포함(Mock publisher 또는 Redis 일시 장애 시뮬레이션). + +**Checkpoint**: SC-001 통과. 폴러가 누락분을 재발행. US1 MVP 완성 — 단독으로 At-Least-Once 핵심 가치 제공. + +--- + +## Phase 4: User Story 2 - 다중 인스턴스 이중 처리 0건 (Priority: P1) + +**Goal**: 폴러 N개 동시 실행에서 동일 outbox가 정확히 1회만 발행된다. + +**Independent Test**: `PENDING` 100건 + 폴러 2개 동시 실행 → 각 outbox 발행 1회 + `PUBLISHED` 100건. + +### Implementation for User Story 2 + +> 동시성 보장 메커니즘(`FOR UPDATE SKIP LOCKED`)은 T004에서 이미 구현됨. 본 Phase는 그 동작을 검증한다. + +- [ ] T009 [US2] Add `src/test/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisherConcurrencyTest.java` — `extends IntegrationTest`. SC-002: `PENDING` OutboxEvent 100건 저장 → 폴러 사이클을 스레드 2개로 동시 실행(`ExecutorService` 또는 병렬 호출) → 각 outbox의 발행 횟수 정확히 1, `PUBLISHED` 카운트 100 검증. `@RepeatedTest(10)`으로 안정성 확인. + +**Checkpoint**: SC-002 통과. 다중 인스턴스 안전 검증. + +--- + +## Phase 5: User Story 3 - 오래된 PENDING 컷오프 제외 (Priority: P2) + +**Goal**: `createdAt`이 컷오프 초과한 `PENDING`은 자동 재발행 대상에서 빠진다. + +**Independent Test**: 컷오프 초과 `PENDING`과 컷오프 이내 `PENDING`을 섞어두고 폴러 실행 → 이내 건만 재발행. + +### Implementation for User Story 3 + +> 컷오프 제외 메커니즘(`created_at > :cutoff`)은 T004 쿼리 + T001 properties에 이미 포함. 본 Phase는 그 동작을 검증한다. + +- [ ] T010 [US3] Add 컷오프 검증 테스트 to `src/test/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisherIntegrationTest.java` — SC-003: `createdAt`을 컷오프보다 오래되게 설정한 `PENDING` 1건 + 컷오프 이내 `PENDING` 1건 저장 → `republishPending()` 호출 → 이내 건만 `PUBLISHED`, 오래된 건은 `PENDING` 잔존 검증. `createdAt`은 `@CreationTimestamp`라 테스트에서 native update로 과거 시각 주입. + +**Checkpoint**: SC-003 통과. poison message가 폴러 사이클을 영구 점유하지 않음. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: 잔여 인수 기준 + 머지 준비. + +- [ ] T011 [P] Add `src/test/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisherProfileTest.java` — SC-004: `test` 프로필 부팅 시 `OutboxRepublisher` 빈이 컨텍스트에 부재함 검증(`assertThatThrownBy(() -> context.getBean(OutboxRepublisher.class))` 또는 `ObjectProvider` 부재 확인). +- [ ] T012 [US1] Add 발행 지연 측정 to `OutboxRepublisherIntegrationTest.java` — SC-005: `PENDING` 저장 시각 ~ `PUBLISHED` 전환 시각 차이가 폴링 주기 + 1초 이내임을 측정(테스트에서는 폴러 직접 호출이므로 "주기 내 1회 호출로 발행됨"을 확인하는 형태로 검증). +- [ ] T013 Run full local validation: + - `./gradlew test --tests "*OutboxRepublisher*"` 전 통과 + - `./gradlew verifySecretLogScan` clean (Spec 001 게이트) + - `./gradlew check` 통과 +- [ ] T014 Update `specs/002-outbox-republisher/plan.md` "Post-Design Constitution Re-Check" 섹션을 구현 완료 결과로 갱신 + 발견 사항 기록. + +**Checkpoint**: 전 SC 통과. PR 머지 준비 완료. + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Phase 1 (Setup)** → **Phase 2 (Foundational)** → **Phase 3·4·5 (User Stories)** → **Phase 6 (Polish)** +- US1(Phase 3)이 폴러 본체를 구현. US2(Phase 4)·US3(Phase 5)는 US1 완료 후 그 위에서 동작을 검증 — 사실상 US1 의존. + +### Task-Level Dependencies + +| Task | 차단 | 비고 | +|---|---|---| +| T001·T002·T003 | — | 서로 병렬 | +| T004 | — | Phase 1과 병렬 가능(다른 파일) | +| T005 | — | T004와 병렬 | +| T006 | T001, T004, T005 | 폴러 골격은 properties·repo·payload 필요 | +| T007 | T006 | 폴러 핵심 로직 | +| T008 | T007 | 카오스 통합 테스트 | +| T009 | T007 | 동시성 테스트 (폴러 동작 필요) | +| T010 | T007, T008 | 컷오프 검증 (T008 파일에 추가) | +| T011 | T006 | 프로필 비활성 검증 | +| T012 | T008 | T008 파일에 추가 | +| T013 | T008~T012 | 최종 검증 | +| T014 | T013 | 문서 갱신 | + +### Within Each User Story + +- US1: T006(골격) → T007(로직) → T008(검증). 구현 후 검증. +- US2: T009 — 단독(폴러 동작에 의존). +- US3: T010 — T008 파일에 케이스 추가. + +--- + +## Parallel Execution Examples + +### Phase 1+2 병렬 + +``` +T001 [P] OutboxRepublisherProperties +T002 [P] application.yml 설정 +T003 [P] scheduler shutdown 설정 +T004 OutboxRepository SKIP LOCKED 쿼리 ┐ Phase 1과 병렬 +T005 [P] VerificationOutboxPayload 변환 ┘ +``` + +### Phase 6 병렬 + +``` +T011 [P] 프로필 비활성 테스트 +T012 발행 지연 측정 (T008 파일) +``` + +### 이도류 배치 제안 + +- **Claude (메인)**: T004~T008 (폴러 본체 + 카오스 테스트 — 설계 판단 집중) +- **Codex (워크트리)**: T001~T003 (설정 보일러플레이트) + T009·T011 (검증 테스트) + +--- + +## Implementation Strategy + +### MVP First + +**US1(Phase 1·2·3)만으로 At-Least-Once 핵심이 완성**된다. 시간 압박 시 US1까지 머지하고 US2·US3 검증 테스트는 후속 커밋으로 분리 가능(권장은 단일 PR). + +### Incremental Delivery (단일 PR 내부 커밋 단위) + +- commit 1: Phase 1·2 (설정 + 쿼리 + 변환) +- commit 2: Phase 3 US1 (폴러 본체 + 카오스 테스트) +- commit 3: Phase 4·5 US2·US3 (동시성·컷오프 검증) +- commit 4: Phase 6 (polish + 최종 검증) + +--- + +## Acceptance Criteria ↔ Task Mapping (Constitution VII) + +| SC | 검증 Task | 검증 방식 | +|---|---|---| +| SC-001 (카오스 재발행) | T007·T008 | 일시 장애 주입 → 폴러 재발행 → PUBLISHED | +| SC-002 (동시성 이중 처리 0) | T004·T009 | SKIP LOCKED + 폴러 2개 RepeatedTest | +| SC-003 (컷오프 제외) | T004·T010 | 오래된 PENDING이 폴링에서 제외 | +| SC-004 (폴러 비활성) | T011 | test 프로필에서 빈 부재 | +| SC-005 (발행 지연) | T012 | PENDING→PUBLISHED 지연 측정 | + +--- + +## Notes + +- [P] = 다른 파일, 의존 없음 +- 단순 버전: `retryCount`/`FAILED`/백오프/Mattermost 관련 task 전부 없음 (full 버전 38 task → 14 task) +- TDD를 강제하지는 않으나, T008 카오스 테스트를 T007 직후 빠르게 작성해 회귀 안전망 확보 권장 +- 매 Phase 완료 시 git commit (squash-merge 가정) +- 시크릿 키워드를 포함한 신규 로그 금지 (Spec 001 `verifySecretLogScan` 게이트가 차단) From 755d9c82fa59d898b228d223ec3a9f1590e54505 Mon Sep 17 00:00:00 2001 From: simhani1 Date: Fri, 22 May 2026 20:22:14 +0900 Subject: [PATCH 05/11] =?UTF-8?q?feat(spec-002):=20setup=20+=20foundationa?= =?UTF-8?q?l=20=E2=80=94=20properties,=20SKIP=20LOCKED=20query,=20payload?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- specs/002-outbox-republisher/tasks.md | 10 +++--- .../outbox/repository/OutboxRepository.java | 33 +++++++++++++++++ .../OutboxRepublisherProperties.java | 36 +++++++++++++++++++ .../VerificationOutboxPayload.java | 36 +++++++++++++++++++ src/main/resources/application-test.yml | 4 +++ src/main/resources/application.yml | 14 +++++++- 6 files changed, 127 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisherProperties.java create mode 100644 src/main/java/com/planetrush/planetrush/outbox/republisher/VerificationOutboxPayload.java diff --git a/specs/002-outbox-republisher/tasks.md b/specs/002-outbox-republisher/tasks.md index d99c462..d6a624c 100644 --- a/specs/002-outbox-republisher/tasks.md +++ b/specs/002-outbox-republisher/tasks.md @@ -39,9 +39,9 @@ description: "Task list for Spec 002 — Outbox Republisher Worker (simplified)" **Purpose**: 설정 외부화 + 스케줄러 종료 정책. -- [ ] T001 [P] Create `src/main/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisherProperties.java` — `@ConfigurationProperties(prefix = "outbox.republisher")` record. 필드: `enabled`(boolean, default true), `pollingIntervalMs`(long, 5000), `cutoffMinutes`(long, 5), `batchSize`(int, 100). `@Validated` + 양수 제약. -- [ ] T002 [P] Add `outbox.republisher.*` 기본값 to `src/main/resources/application.yml` (`enabled: true`, `polling-interval-ms: 5000`, `cutoff-minutes: 5`, `batch-size: 100`) and override `outbox.republisher.enabled: false` in `src/main/resources/application-test.yml`. -- [ ] T003 [P] Add `spring.task.scheduling.shutdown.await-termination: true` + `await-termination-period: 20s` to `src/main/resources/application.yml` (research R-004, graceful shutdown). +- [X] T001 [P] Create `src/main/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisherProperties.java` — `@ConfigurationProperties(prefix = "outbox.republisher")` record. 필드: `enabled`(boolean, default true), `pollingIntervalMs`(long, 5000), `cutoffMinutes`(long, 5), `batchSize`(int, 100). `@Validated` + 양수 제약. +- [X] T002 [P] Add `outbox.republisher.*` 기본값 to `src/main/resources/application.yml` (`enabled: true`, `polling-interval-ms: 5000`, `cutoff-minutes: 5`, `batch-size: 100`) and override `outbox.republisher.enabled: false` in `src/main/resources/application-test.yml`. +- [X] T003 [P] Add `spring.task.scheduling.shutdown.await-termination: true` + `await-termination-period: 20s` to `src/main/resources/application.yml` (research R-004, graceful shutdown). **Checkpoint**: 설정 바인딩 가능. `@ConfigurationProperties` 스캔 등록(`@ConfigurationPropertiesScan` 또는 `@EnableConfigurationProperties`)은 T006에서 폴러와 함께 처리. @@ -53,8 +53,8 @@ description: "Task list for Spec 002 — Outbox Republisher Worker (simplified)" **⚠️ CRITICAL**: 이 Phase가 끝나야 US1/US2/US3 진입 가능. -- [ ] T004 Add SKIP LOCKED 폴링 쿼리 to `src/main/java/com/planetrush/planetrush/outbox/repository/OutboxRepository.java` — native query 메서드 `findRepublishableForUpdateSkipLocked(Instant cutoff, int batchSize)`: `SELECT * FROM outbox_event WHERE status='PENDING' AND created_at > :cutoff ORDER BY created_at LIMIT :batchSize FOR UPDATE SKIP LOCKED` (research R-001). -- [ ] T005 [P] Create `src/main/java/com/planetrush/planetrush/outbox/republisher/VerificationOutboxPayload.java` — `OutboxEvent.payload` JSON 역직렬화용 record(`standardImgUrl`, `verificationImgUrl`, `memberId`, `planetId`) + `OutboxEvent` → `MessageCommand` 변환 로직(매핑: eventId=id, standardImg=standardImgUrl, targetImg=verificationImgUrl). research R-003. +- [X] T004 Add SKIP LOCKED 폴링 쿼리 to `src/main/java/com/planetrush/planetrush/outbox/repository/OutboxRepository.java` — native query 메서드 `findRepublishableForUpdateSkipLocked(Instant cutoff, int batchSize)`: `SELECT * FROM outbox_event WHERE status='PENDING' AND created_at > :cutoff ORDER BY created_at LIMIT :batchSize FOR UPDATE SKIP LOCKED` (research R-001). +- [X] T005 [P] Create `src/main/java/com/planetrush/planetrush/outbox/republisher/VerificationOutboxPayload.java` — `OutboxEvent.payload` JSON 역직렬화용 record(`standardImgUrl`, `verificationImgUrl`, `memberId`, `planetId`) + `OutboxEvent` → `MessageCommand` 변환 로직(매핑: eventId=id, standardImg=standardImgUrl, targetImg=verificationImgUrl). research R-003. **Checkpoint**: 폴링 쿼리 + payload 변환 준비 완료. 폴러 컴포넌트 작성 가능. diff --git a/src/main/java/com/planetrush/planetrush/outbox/repository/OutboxRepository.java b/src/main/java/com/planetrush/planetrush/outbox/repository/OutboxRepository.java index 73f67af..ed4ba2e 100644 --- a/src/main/java/com/planetrush/planetrush/outbox/repository/OutboxRepository.java +++ b/src/main/java/com/planetrush/planetrush/outbox/repository/OutboxRepository.java @@ -1,8 +1,41 @@ package com.planetrush.planetrush.outbox.repository; +import java.time.Instant; +import java.util.List; + import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import com.planetrush.planetrush.outbox.domain.OutboxEvent; public interface OutboxRepository extends JpaRepository { + + /** + * 재발행 대상 OutboxEvent를 행 단위 락(FOR UPDATE SKIP LOCKED)으로 조회한다. + * + *

Spec 002 — FR-001/FR-002/FR-004, research R-001. 잠긴 행은 대기 없이 + * 즉시 건너뛰어 다중 폴러 인스턴스 간 동일 outbox 이중 처리를 방지한다. + * {@code created_at > :cutoff} 조건으로 컷오프를 초과한 오래된 PENDING은 + * 폴링 대상에서 제외된다. + * + *

주의: {@code OutboxEvent.status}가 {@code @Enumerated} 미지정으로 + * ORDINAL 매핑되어 DB에 정수로 저장된다. 따라서 {@code status} 파라미터는 + * {@code OutboxStatus.PENDING.ordinal()} 값을 전달해야 한다. + * + * @param status OutboxStatus의 ORDINAL 값 (PENDING) + * @param cutoff 이 시각보다 이후에 생성된 outbox만 조회 + * @param batchSize 사이클당 조회 상한 + */ + @Query(value = """ + SELECT * FROM outbox_event + WHERE status = :status AND created_at > :cutoff + ORDER BY created_at + LIMIT :batchSize + FOR UPDATE SKIP LOCKED + """, nativeQuery = true) + List findRepublishableForUpdateSkipLocked( + @Param("status") int status, + @Param("cutoff") Instant cutoff, + @Param("batchSize") int batchSize); } diff --git a/src/main/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisherProperties.java b/src/main/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisherProperties.java new file mode 100644 index 0000000..6753e01 --- /dev/null +++ b/src/main/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisherProperties.java @@ -0,0 +1,36 @@ +package com.planetrush.planetrush.outbox.republisher; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.DefaultValue; + +/** + * Outbox 재발행 폴러 설정. + * + *

Spec 002 — FR-005. 컷오프·폴링 주기·배치 크기를 코드 수정 없이 조정할 수 + * 있도록 외부화한다. `application.yml`의 {@code outbox.republisher.*}에 바인딩된다. + * + * @param enabled 폴러 활성 여부 (test 프로필은 false) + * @param pollingIntervalMs 폴링 주기(밀리초) + * @param cutoffMinutes createdAt 컷오프(분) — 초과한 PENDING은 폴링 제외 + * @param batchSize 사이클당 처리 상한 + */ +@ConfigurationProperties(prefix = "outbox.republisher") +public record OutboxRepublisherProperties( + @DefaultValue("true") boolean enabled, + @DefaultValue("5000") long pollingIntervalMs, + @DefaultValue("5") long cutoffMinutes, + @DefaultValue("100") int batchSize +) { + + public OutboxRepublisherProperties { + if (pollingIntervalMs <= 0) { + throw new IllegalArgumentException("outbox.republisher.polling-interval-ms must be positive"); + } + if (cutoffMinutes <= 0) { + throw new IllegalArgumentException("outbox.republisher.cutoff-minutes must be positive"); + } + if (batchSize <= 0) { + throw new IllegalArgumentException("outbox.republisher.batch-size must be positive"); + } + } +} diff --git a/src/main/java/com/planetrush/planetrush/outbox/republisher/VerificationOutboxPayload.java b/src/main/java/com/planetrush/planetrush/outbox/republisher/VerificationOutboxPayload.java new file mode 100644 index 0000000..ef52c84 --- /dev/null +++ b/src/main/java/com/planetrush/planetrush/outbox/republisher/VerificationOutboxPayload.java @@ -0,0 +1,36 @@ +package com.planetrush.planetrush.outbox.republisher; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.planetrush.planetrush.outbox.domain.OutboxEvent; +import com.planetrush.planetrush.verification.service.dto.MessageCommand; + +/** + * {@link OutboxEvent#getPayload()}(JSON)의 역직렬화 대상. + * + *

Spec 002 — research R-003. `VerificationExternalEventRecorder.buildPayload()`가 + * 생성한 {@code {standardImgUrl, verificationImgUrl, memberId, planetId}} 구조와 + * 일치한다. 폴러는 이 record로 payload를 역직렬화한 뒤 {@link #toMessageCommand}로 + * 발행용 {@link MessageCommand}를 조립한다. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record VerificationOutboxPayload( + String standardImgUrl, + String verificationImgUrl, + Long memberId, + Long planetId +) { + + /** + * OutboxEvent와 역직렬화된 payload로 발행용 MessageCommand를 조립한다. + * {@code eventId}는 OutboxEvent의 id, 이미지 URL은 payload에서 가져온다. + */ + public MessageCommand toMessageCommand(OutboxEvent event) { + return new MessageCommand( + event.getId(), + verificationImgUrl, + standardImgUrl, + memberId, + planetId + ); + } +} diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index 1a99e4c..75a2597 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -82,3 +82,7 @@ verification: type: redis-stream # http -> RestClient를 사용한 api 호출 redis: stream-key: verification:stream + +outbox: + republisher: + enabled: false # Spec 002 FR-006 — 통합 테스트는 폴러를 직접 호출해 결정론적으로 검증 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 29c2945..7d39161 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,4 +1,9 @@ spring: + task: + scheduling: + shutdown: + await-termination: true # Spec 002 — 폴러 graceful shutdown (research R-004) + await-termination-period: 20s servlet: multipart: max-file-size: 50MB @@ -81,4 +86,11 @@ verification: publisher: type: redis-stream # http -> RestClient를 사용한 api 호출 redis: - stream-key: verification:stream \ No newline at end of file + stream-key: verification:stream + +outbox: + republisher: + enabled: true # Spec 002 — PENDING outbox 자동 재발행 폴러 (test 프로필은 false) + polling-interval-ms: 5000 # 폴링 주기 + cutoff-minutes: 5 # createdAt 컷오프 — 초과한 PENDING은 폴링 제외 + batch-size: 100 # 사이클당 처리 상한 \ No newline at end of file From b69cacafde40ac4092bc8ae47ac9a53a3f09c512 Mon Sep 17 00:00:00 2001 From: simhani1 Date: Fri, 22 May 2026 20:28:57 +0900 Subject: [PATCH 06/11] feat(spec-002): US1 outbox republisher poller + chaos/cutoff tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- specs/002-outbox-republisher/tasks.md | 8 +- .../outbox/republisher/OutboxRepublisher.java | 97 ++++++++++++ .../OutboxRepublisherScheduler.java | 28 ++++ .../OutboxRepublisherIntegrationTest.java | 147 ++++++++++++++++++ 4 files changed, 276 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisher.java create mode 100644 src/main/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisherScheduler.java create mode 100644 src/test/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisherIntegrationTest.java diff --git a/specs/002-outbox-republisher/tasks.md b/specs/002-outbox-republisher/tasks.md index d6a624c..5c3cbfa 100644 --- a/specs/002-outbox-republisher/tasks.md +++ b/specs/002-outbox-republisher/tasks.md @@ -68,9 +68,9 @@ description: "Task list for Spec 002 — Outbox Republisher Worker (simplified)" ### Implementation for User Story 1 -- [ ] T006 [US1] Create `src/main/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisher.java` 골격 — `@Component`, `@ConditionalOnProperty(name="outbox.republisher.enabled", havingValue="true")`, 생성자 주입(`OutboxRepository`, `VerificationMessagePublisher`, `ObjectMapper`, `OutboxRepublisherProperties`). `@EnableConfigurationProperties(OutboxRepublisherProperties.class)`는 본 클래스 또는 별도 config에 부착. -- [ ] T007 [US1] Implement `republishPending()` in `OutboxRepublisher.java` — `@Scheduled(fixedDelayString="${outbox.republisher.polling-interval-ms:5000}")` + `@Transactional`. 로직: cutoff 계산(`now - cutoffMinutes`) → `findRepublishableForUpdateSkipLocked` 조회 → 각 건 `VerificationOutboxPayload` 역직렬화 → `MessageCommand` 변환 → `messagePublisher.publish()` 호출. `publish()` 성공 시 내부에서 `published()` 전환됨(기존 동작 재사용). research R-002 트랜잭션 경계. -- [ ] T008 [US1] Add `src/test/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisherIntegrationTest.java` — `extends IntegrationTest`. 카오스 시나리오(SC-001): `PENDING` OutboxEvent 저장 → `republishPending()` 직접 호출 → `status == PUBLISHED` 검증. 발행 1차 실패 후 재시도 케이스 포함(Mock publisher 또는 Redis 일시 장애 시뮬레이션). +- [X] T006 [US1] Create `src/main/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisher.java` 골격 — `@Component`, `@ConditionalOnProperty(name="outbox.republisher.enabled", havingValue="true")`, 생성자 주입(`OutboxRepository`, `VerificationMessagePublisher`, `ObjectMapper`, `OutboxRepublisherProperties`). `@EnableConfigurationProperties(OutboxRepublisherProperties.class)`는 본 클래스 또는 별도 config에 부착. +- [X] T007 [US1] Implement `republishPending()` in `OutboxRepublisher.java` — `@Scheduled(fixedDelayString="${outbox.republisher.polling-interval-ms:5000}")` + `@Transactional`. 로직: cutoff 계산(`now - cutoffMinutes`) → `findRepublishableForUpdateSkipLocked` 조회 → 각 건 `VerificationOutboxPayload` 역직렬화 → `MessageCommand` 변환 → `messagePublisher.publish()` 호출. `publish()` 성공 시 내부에서 `published()` 전환됨(기존 동작 재사용). research R-002 트랜잭션 경계. +- [X] T008 [US1] Add `src/test/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisherIntegrationTest.java` — `extends IntegrationTest`. 카오스 시나리오(SC-001): `PENDING` OutboxEvent 저장 → `republishPending()` 직접 호출 → `status == PUBLISHED` 검증. 발행 1차 실패 후 재시도 케이스 포함(Mock publisher 또는 Redis 일시 장애 시뮬레이션). **Checkpoint**: SC-001 통과. 폴러가 누락분을 재발행. US1 MVP 완성 — 단독으로 At-Least-Once 핵심 가치 제공. @@ -102,7 +102,7 @@ description: "Task list for Spec 002 — Outbox Republisher Worker (simplified)" > 컷오프 제외 메커니즘(`created_at > :cutoff`)은 T004 쿼리 + T001 properties에 이미 포함. 본 Phase는 그 동작을 검증한다. -- [ ] T010 [US3] Add 컷오프 검증 테스트 to `src/test/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisherIntegrationTest.java` — SC-003: `createdAt`을 컷오프보다 오래되게 설정한 `PENDING` 1건 + 컷오프 이내 `PENDING` 1건 저장 → `republishPending()` 호출 → 이내 건만 `PUBLISHED`, 오래된 건은 `PENDING` 잔존 검증. `createdAt`은 `@CreationTimestamp`라 테스트에서 native update로 과거 시각 주입. +- [X] T010 [US3] Add 컷오프 검증 테스트 to `src/test/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisherIntegrationTest.java` — SC-003: `createdAt`을 컷오프보다 오래되게 설정한 `PENDING` 1건 + 컷오프 이내 `PENDING` 1건 저장 → `republishPending()` 호출 → 이내 건만 `PUBLISHED`, 오래된 건은 `PENDING` 잔존 검증. `createdAt`은 `@CreationTimestamp`라 테스트에서 native update로 과거 시각 주입. **Checkpoint**: SC-003 통과. poison message가 폴러 사이클을 영구 점유하지 않음. diff --git a/src/main/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisher.java b/src/main/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisher.java new file mode 100644 index 0000000..ee2df8c --- /dev/null +++ b/src/main/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisher.java @@ -0,0 +1,97 @@ +package com.planetrush.planetrush.outbox.republisher; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.planetrush.planetrush.outbox.domain.OutboxEvent; +import com.planetrush.planetrush.outbox.domain.OutboxStatus; +import com.planetrush.planetrush.outbox.repository.OutboxRepository; +import com.planetrush.planetrush.verification.event.publisher.VerificationMessagePublisher; +import com.planetrush.planetrush.verification.service.dto.MessageCommand; + +import lombok.extern.slf4j.Slf4j; + +/** + * PENDING 상태로 남은 {@link OutboxEvent}를 재발행하는 폴러 로직. + * + *

Spec 002 — At-Least-Once 발행 보장(US1). 발행 직전 앱이 죽거나 + * `VerificationRedisStreamPublisher` 발행이 실패해 PENDING으로 남은 outbox를 + * 자동 복구한다. + * + *

본 클래스는 스케줄 트리거를 포함하지 않는다 — {@link OutboxRepublisherScheduler}가 + * `@Scheduled`로 {@link #republishPending()}을 호출한다. 로직 빈과 트리거를 분리한 + * 이유: `test` 프로필은 스케줄 트리거를 비활성화(FR-006)하되, 통합 테스트는 본 + * 빈을 주입받아 `@Transactional` 프록시가 적용된 채로 직접 호출해야 SKIP LOCKED + * 락 동작까지 결정론적으로 검증할 수 있기 때문이다. + */ +@Slf4j +@Component +@EnableConfigurationProperties(OutboxRepublisherProperties.class) +public class OutboxRepublisher { + + private final OutboxRepository outboxRepository; + private final VerificationMessagePublisher messagePublisher; + private final ObjectMapper objectMapper; + private final OutboxRepublisherProperties properties; + + public OutboxRepublisher( + OutboxRepository outboxRepository, + VerificationMessagePublisher messagePublisher, + ObjectMapper objectMapper, + OutboxRepublisherProperties properties) { + this.outboxRepository = outboxRepository; + this.messagePublisher = messagePublisher; + this.objectMapper = objectMapper; + this.properties = properties; + } + + /** + * 폴러 사이클 1회. 컷오프 이내의 PENDING outbox를 SKIP LOCKED로 조회해 재발행한다. + * + *

사이클 전체가 단일 트랜잭션이다(research R-002) — 조회된 행의 락이 커밋까지 + * 유지되어 다른 폴러 인스턴스는 해당 행을 건너뛴다. 한 건의 발행 실패가 사이클 + * 전체를 롤백시키지 않도록, 발행 실패 건은 PENDING으로 남아 다음 사이클에 + * 재시도된다(FR-003). + */ + @Transactional + public void republishPending() { + Instant cutoff = Instant.now().minus(properties.cutoffMinutes(), ChronoUnit.MINUTES); + List batch = outboxRepository.findRepublishableForUpdateSkipLocked( + OutboxStatus.PENDING.ordinal(), cutoff, properties.batchSize()); + if (batch.isEmpty()) { + return; + } + int succeeded = 0; + for (OutboxEvent event : batch) { + if (republishOne(event)) { + succeeded++; + } + } + log.info("[OutboxRepublisher] republish cycle done: {}/{} succeeded", succeeded, batch.size()); + } + + /** + * outbox 1건 재발행. payload를 역직렬화해 {@link MessageCommand}로 변환 후 + * 기존 발행 경로({@link VerificationMessagePublisher})에 위임한다. + * 발행이 성공하면 위임 대상이 내부에서 {@link OutboxEvent#published()}를 호출한다. + * + * @return 발행 후 상태가 PUBLISHED이면 true + */ + private boolean republishOne(OutboxEvent event) { + try { + VerificationOutboxPayload payload = + objectMapper.readValue(event.getPayload(), VerificationOutboxPayload.class); + messagePublisher.publish(payload.toMessageCommand(event)); + return event.getStatus() == OutboxStatus.PUBLISHED; + } catch (Exception e) { + log.warn("[OutboxRepublisher] failed to republish outbox event id={}", event.getId(), e); + return false; + } + } +} diff --git a/src/main/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisherScheduler.java b/src/main/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisherScheduler.java new file mode 100644 index 0000000..94ae362 --- /dev/null +++ b/src/main/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisherScheduler.java @@ -0,0 +1,28 @@ +package com.planetrush.planetrush.outbox.republisher; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * {@link OutboxRepublisher#republishPending()}를 주기적으로 호출하는 스케줄 트리거. + * + *

Spec 002 — FR-006. `outbox.republisher.enabled=true`일 때만 빈으로 등록된다 + * (`test` 프로필은 `false`라 미등록 → 자동 폴링이 통합 테스트와 간섭하지 않음). + * 폴링 주기는 `outbox.republisher.polling-interval-ms` 설정값을 따른다. + */ +@Component +@ConditionalOnProperty(name = "outbox.republisher.enabled", havingValue = "true") +public class OutboxRepublisherScheduler { + + private final OutboxRepublisher outboxRepublisher; + + public OutboxRepublisherScheduler(OutboxRepublisher outboxRepublisher) { + this.outboxRepublisher = outboxRepublisher; + } + + @Scheduled(fixedDelayString = "${outbox.republisher.polling-interval-ms:5000}") + public void schedule() { + outboxRepublisher.republishPending(); + } +} diff --git a/src/test/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisherIntegrationTest.java b/src/test/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisherIntegrationTest.java new file mode 100644 index 0000000..5516088 --- /dev/null +++ b/src/test/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisherIntegrationTest.java @@ -0,0 +1,147 @@ +package com.planetrush.planetrush.outbox.republisher; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doNothing; + +import java.util.UUID; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.jdbc.core.JdbcTemplate; + +import com.planetrush.planetrush.IntegrationTest; +import com.planetrush.planetrush.outbox.domain.EventType; +import com.planetrush.planetrush.outbox.domain.OutboxEvent; +import com.planetrush.planetrush.outbox.domain.OutboxStatus; +import com.planetrush.planetrush.outbox.repository.OutboxRepository; +import com.planetrush.planetrush.verification.event.publisher.VerificationMessagePublisher; +import com.planetrush.planetrush.verification.service.dto.MessageCommand; + +/** + * Spec 002 US1·US3 인수 기준 검증. + * + *

SC-001: Publisher 일시 장애 시 폴러가 자동 재시도하여 메시지가 결국 발행됨. + *

SC-003: createdAt 컷오프 초과 PENDING은 폴링 대상에서 제외됨. + * + *

발행 경로(`VerificationMessagePublisher`)는 `@MockBean`으로 대체해 결정론적으로 + * 검증한다. mock은 실제 `VerificationRedisStreamPublisher`의 계약 — "발행 성공 시 + * 해당 OutboxEvent를 published()로 전환" — 을 흉내 낸다. + */ +class OutboxRepublisherIntegrationTest extends IntegrationTest { + + @Autowired + private OutboxRepublisher outboxRepublisher; + + @Autowired + private OutboxRepository outboxRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @MockBean + private VerificationMessagePublisher messagePublisher; + + @AfterEach + void cleanUp() { + outboxRepository.deleteAll(); + } + + @Test + @DisplayName("SC-001: PENDING outbox를 폴러가 발견해 재발행하면 PUBLISHED로 전환된다") + void pendingEventIsRepublishedToPublished() { + // given + String eventId = UUID.randomUUID().toString(); + outboxRepository.save(OutboxEvent.pending(eventId, EventType.VERIFICATION_REQUEST, payloadJson())); + stubPublishSuccess(); + + // when + outboxRepublisher.republishPending(); + + // then + assertThat(outboxRepository.findById(eventId).orElseThrow().getStatus()) + .isEqualTo(OutboxStatus.PUBLISHED); + } + + @Test + @DisplayName("SC-001: 일시 장애 시 PENDING으로 남고, 장애 해소 후 사이클에서 재발행에 성공한다") + void transientFailureKeepsPendingThenSucceedsOnRetry() { + // given + String eventId = UUID.randomUUID().toString(); + outboxRepository.save(OutboxEvent.pending(eventId, EventType.VERIFICATION_REQUEST, payloadJson())); + + // when: 1차 사이클 — 발행이 일시 장애 (published() 미호출) + doNothing().when(messagePublisher).publish(any(MessageCommand.class)); + outboxRepublisher.republishPending(); + + // then: 여전히 PENDING + assertThat(outboxRepository.findById(eventId).orElseThrow().getStatus()) + .isEqualTo(OutboxStatus.PENDING); + + // when: 2차 사이클 — 장애 해소 + stubPublishSuccess(); + outboxRepublisher.republishPending(); + + // then: PUBLISHED + assertThat(outboxRepository.findById(eventId).orElseThrow().getStatus()) + .isEqualTo(OutboxStatus.PUBLISHED); + } + + @Test + @DisplayName("SC-003: createdAt이 컷오프를 초과한 오래된 PENDING은 재발행되지 않고 PENDING으로 잔존한다") + void staleEventBeyondCutoffIsExcludedFromPolling() { + // given: 컷오프(기본 5분)보다 오래된 PENDING + 컷오프 이내 PENDING + String staleId = UUID.randomUUID().toString(); + String freshId = UUID.randomUUID().toString(); + outboxRepository.save(OutboxEvent.pending(staleId, EventType.VERIFICATION_REQUEST, payloadJson())); + outboxRepository.save(OutboxEvent.pending(freshId, EventType.VERIFICATION_REQUEST, payloadJson())); + // created_at은 @CreationTimestamp라 JdbcTemplate native update로 60분 전 시각 주입 + forceCreatedAt(staleId, 60); + stubPublishSuccess(); + + // when + outboxRepublisher.republishPending(); + + // then: 컷오프 이내 건만 PUBLISHED, 오래된 건은 PENDING 잔존 + assertThat(outboxRepository.findById(freshId).orElseThrow().getStatus()) + .isEqualTo(OutboxStatus.PUBLISHED); + assertThat(outboxRepository.findById(staleId).orElseThrow().getStatus()) + .isEqualTo(OutboxStatus.PENDING); + } + + /** + * mock 발행기가 실제 `VerificationRedisStreamPublisher`의 계약을 흉내 낸다: + * 발행이 성공하면 해당 OutboxEvent를 published()로 전환한다. + */ + private void stubPublishSuccess() { + doAnswer(invocation -> { + MessageCommand command = invocation.getArgument(0); + outboxRepository.findById(command.eventId()).ifPresent(OutboxEvent::published); + return null; + }).when(messagePublisher).publish(any(MessageCommand.class)); + } + + /** + * `@CreationTimestamp`로 자동 설정되는 created_at을 테스트에서 과거 시각으로 + * 강제 변경한다(컷오프 시나리오용). + * + *

MySQL {@code UTC_TIMESTAMP()} 기반으로 갱신한다 — `java.sql.Timestamp`는 + * JDBC 전송 시 JVM 기본 시간대(KST)로 해석되어 `@CreationTimestamp`의 UTC 저장과 + * 어긋나므로, DB 서버가 UTC datetime을 직접 계산하도록 위임한다. + */ + private void forceCreatedAt(String eventId, long minutesAgo) { + jdbcTemplate.update( + "UPDATE outbox_event SET created_at = DATE_SUB(UTC_TIMESTAMP(), INTERVAL ? MINUTE) WHERE id = ?", + minutesAgo, eventId); + } + + private String payloadJson() { + return "{\"standardImgUrl\":\"https://img.example/std.jpg\"," + + "\"verificationImgUrl\":\"https://img.example/target.jpg\"," + + "\"memberId\":1,\"planetId\":2}"; + } +} From 63e6a7b476e8fe2c36c0f4f882eff78463994928 Mon Sep 17 00:00:00 2001 From: simhani1 Date: Fri, 22 May 2026 20:30:36 +0900 Subject: [PATCH 07/11] =?UTF-8?q?test(spec-002):=20US2=20concurrency=20?= =?UTF-8?q?=E2=80=94=20SKIP=20LOCKED=20prevents=20double=20processing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- specs/002-outbox-republisher/tasks.md | 2 +- .../OutboxRepublisherConcurrencyTest.java | 114 ++++++++++++++++++ 2 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 src/test/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisherConcurrencyTest.java diff --git a/specs/002-outbox-republisher/tasks.md b/specs/002-outbox-republisher/tasks.md index 5c3cbfa..8b3a1f2 100644 --- a/specs/002-outbox-republisher/tasks.md +++ b/specs/002-outbox-republisher/tasks.md @@ -86,7 +86,7 @@ description: "Task list for Spec 002 — Outbox Republisher Worker (simplified)" > 동시성 보장 메커니즘(`FOR UPDATE SKIP LOCKED`)은 T004에서 이미 구현됨. 본 Phase는 그 동작을 검증한다. -- [ ] T009 [US2] Add `src/test/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisherConcurrencyTest.java` — `extends IntegrationTest`. SC-002: `PENDING` OutboxEvent 100건 저장 → 폴러 사이클을 스레드 2개로 동시 실행(`ExecutorService` 또는 병렬 호출) → 각 outbox의 발행 횟수 정확히 1, `PUBLISHED` 카운트 100 검증. `@RepeatedTest(10)`으로 안정성 확인. +- [X] T009 [US2] Add `src/test/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisherConcurrencyTest.java` — `extends IntegrationTest`. SC-002: `PENDING` OutboxEvent 100건 저장 → 폴러 사이클을 스레드 2개로 동시 실행(`ExecutorService` 또는 병렬 호출) → 각 outbox의 발행 횟수 정확히 1, `PUBLISHED` 카운트 100 검증. `@RepeatedTest(10)`으로 안정성 확인. **Checkpoint**: SC-002 통과. 다중 인스턴스 안전 검증. diff --git a/src/test/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisherConcurrencyTest.java b/src/test/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisherConcurrencyTest.java new file mode 100644 index 0000000..35fde1a --- /dev/null +++ b/src/test/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisherConcurrencyTest.java @@ -0,0 +1,114 @@ +package com.planetrush.planetrush.outbox.republisher; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; + +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.RepeatedTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.TestPropertySource; + +import com.planetrush.planetrush.IntegrationTest; +import com.planetrush.planetrush.outbox.domain.EventType; +import com.planetrush.planetrush.outbox.domain.OutboxEvent; +import com.planetrush.planetrush.outbox.domain.OutboxStatus; +import com.planetrush.planetrush.outbox.repository.OutboxRepository; +import com.planetrush.planetrush.verification.event.publisher.VerificationMessagePublisher; +import com.planetrush.planetrush.verification.service.dto.MessageCommand; + +/** + * Spec 002 US2 인수 기준 검증. + * + *

SC-002: PENDING outbox 100건에 대해 폴러 2개를 동시 실행해도 각 outbox가 + * 정확히 1회만 발행된다. `FOR UPDATE SKIP LOCKED`가 동일 행의 이중 처리를 + * 차단함을 검증한다. + * + *

`batch-size`를 20으로 낮춰(`@TestPropertySource`) 한 사이클이 전체를 + * 독식하지 못하게 하고, 두 폴러 스레드가 여러 사이클에 걸쳐 실제로 경합하도록 + * 한다. `@RepeatedTest(10)`으로 경합 안정성을 반복 확인한다. + */ +@TestPropertySource(properties = "outbox.republisher.batch-size=20") +class OutboxRepublisherConcurrencyTest extends IntegrationTest { + + private static final int PENDING_COUNT = 100; + private static final int POLLER_THREADS = 2; + private static final int CYCLES_PER_THREAD = 20; // 2 threads × 20 cycles × 20 batch = 800 capacity > 100 + + @Autowired + private OutboxRepublisher outboxRepublisher; + + @Autowired + private OutboxRepository outboxRepository; + + @MockBean + private VerificationMessagePublisher messagePublisher; + + @AfterEach + void cleanUp() { + outboxRepository.deleteAll(); + } + + @RepeatedTest(10) + @DisplayName("SC-002: 폴러 2개 동시 실행에서 각 outbox는 정확히 1회만 발행된다") + void concurrentPollersPublishEachOutboxExactlyOnce() throws Exception { + // given: PENDING outbox 100건 + eventId별 발행 횟수를 세는 mock + for (int i = 0; i < PENDING_COUNT; i++) { + outboxRepository.save(OutboxEvent.pending( + UUID.randomUUID().toString(), EventType.VERIFICATION_REQUEST, payloadJson())); + } + ConcurrentHashMap publishCount = new ConcurrentHashMap<>(); + doAnswer(invocation -> { + MessageCommand command = invocation.getArgument(0); + publishCount.merge(command.eventId(), 1, Integer::sum); + outboxRepository.findById(command.eventId()).ifPresent(OutboxEvent::published); + return null; + }).when(messagePublisher).publish(any(MessageCommand.class)); + + // when: 폴러 2개를 동시에 출발시킨다 + ExecutorService executor = Executors.newFixedThreadPool(POLLER_THREADS); + CountDownLatch startLatch = new CountDownLatch(1); + Future[] futures = new Future[POLLER_THREADS]; + for (int t = 0; t < POLLER_THREADS; t++) { + futures[t] = executor.submit(() -> { + startLatch.await(); + for (int cycle = 0; cycle < CYCLES_PER_THREAD; cycle++) { + outboxRepublisher.republishPending(); + } + return null; + }); + } + startLatch.countDown(); + for (Future future : futures) { + future.get(30, TimeUnit.SECONDS); + } + executor.shutdown(); + + // then: 전부 PUBLISHED, 각 outbox는 정확히 1회만 발행 + assertThat(outboxRepository.findAll()) + .hasSize(PENDING_COUNT) + .allMatch(event -> event.getStatus() == OutboxStatus.PUBLISHED); + assertThat(publishCount) + .as("모든 outbox가 발행되어야 한다") + .hasSize(PENDING_COUNT); + assertThat(publishCount.values()) + .as("어떤 outbox도 두 번 발행되어선 안 된다 (SKIP LOCKED 이중 처리 차단)") + .allMatch(count -> count == 1); + } + + private String payloadJson() { + return "{\"standardImgUrl\":\"https://img.example/std.jpg\"," + + "\"verificationImgUrl\":\"https://img.example/target.jpg\"," + + "\"memberId\":1,\"planetId\":2}"; + } +} From 21f6195badc6de9ae23bcf2478b3c739b9c435b5 Mon Sep 17 00:00:00 2001 From: simhani1 Date: Fri, 22 May 2026 21:30:23 +0900 Subject: [PATCH 08/11] =?UTF-8?q?fix(test):=20singleton=20Testcontainers?= =?UTF-8?q?=20+=20executor=20leak=20=E2=80=94=20full=20suite=20green?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 전체 테스트 실행 시 `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) --- .../planetrush/IntegrationTest.java | 26 +++--- .../PlanetrushApplicationTests.java | 13 +-- .../planet/PlanetIntegrationTest.java | 89 ++++++++++--------- 3 files changed, 69 insertions(+), 59 deletions(-) diff --git a/src/test/java/com/planetrush/planetrush/IntegrationTest.java b/src/test/java/com/planetrush/planetrush/IntegrationTest.java index 75aaf45..3790bfa 100644 --- a/src/test/java/com/planetrush/planetrush/IntegrationTest.java +++ b/src/test/java/com/planetrush/planetrush/IntegrationTest.java @@ -7,8 +7,6 @@ import org.springframework.test.context.DynamicPropertySource; import org.testcontainers.containers.GenericContainer; import org.testcontainers.containers.MySQLContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; import org.testcontainers.utility.DockerImageName; /** @@ -18,27 +16,33 @@ * MySQL/Redis 컨테이너를 자동 부팅하고 Spring DataSource·Redis 설정을 동적으로 * 주입한다. 로컬에 데몬이 없어도 {@code ./gradlew test}가 통과해야 한다. * - *

컨테이너 재사용은 {@code ~/.testcontainers.properties}에 - * {@code testcontainers.reuse.enable=true}로 활성화한다(quickstart.md §1.2 참조). + *

싱글톤 컨테이너 패턴 — {@code @Testcontainers}/{@code @Container} + * 라이프사이클을 쓰지 않고 static 초기화 블록에서 컨테이너를 한 번만 시작한다. + * 이유: {@code @Container}는 테스트 클래스 종료 시 컨테이너를 stop하는데, + * Spring TestContext는 컨텍스트를 캐시하므로 — 다음 클래스가 캐시된 컨텍스트를 + * 재사용하면 그 HikariCP 풀이 이미 stop된 컨테이너를 가리켜 + * "No operations allowed after connection closed" 오류가 난다. static 블록으로 + * 시작한 컨테이너는 JVM 생존 내내 단일 인스턴스로 유지되어 이 문제가 없다. + * (JVM 종료 시 Testcontainers Ryuk가 정리한다.) */ -@Testcontainers @ActiveProfiles("test") @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) public abstract class IntegrationTest { - @Container static final MySQLContainer MYSQL = new MySQLContainer<>(DockerImageName.parse("mysql:8.0.36")) .withDatabaseName("planetrush") .withUsername("test") .withPassword("test") - .withReuse(true) - .withLabel("project", "planetrush-api"); + .withReuse(true); - @Container static final GenericContainer REDIS = new GenericContainer<>(DockerImageName.parse("redis:7-alpine")) .withExposedPorts(6379) - .withReuse(true) - .withLabel("project", "planetrush-api"); + .withReuse(true); + + static { + MYSQL.start(); + REDIS.start(); + } @DynamicPropertySource static void registerContainerProperties(DynamicPropertyRegistry registry) { diff --git a/src/test/java/com/planetrush/planetrush/PlanetrushApplicationTests.java b/src/test/java/com/planetrush/planetrush/PlanetrushApplicationTests.java index 67c9b04..df94111 100644 --- a/src/test/java/com/planetrush/planetrush/PlanetrushApplicationTests.java +++ b/src/test/java/com/planetrush/planetrush/PlanetrushApplicationTests.java @@ -1,12 +1,15 @@ package com.planetrush.planetrush; import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; -@SpringBootTest -@ActiveProfiles(profiles = "test") -class PlanetrushApplicationTests { +/** + * 애플리케이션 컨텍스트 로딩 스모크 테스트. + * + *

{@link IntegrationTest} 베이스를 상속하여 Testcontainers MySQL/Redis로 부팅한다. + * 직접 {@code @SpringBootTest}를 선언하면 로컬 데몬(localhost:3306)에 의존해 + * Constitution 원칙 I을 위반하고, 전체 테스트 실행 시 컨텍스트 로딩이 실패한다. + */ +class PlanetrushApplicationTests extends IntegrationTest { @Test void contextLoads() { diff --git a/src/test/java/com/planetrush/planetrush/planet/PlanetIntegrationTest.java b/src/test/java/com/planetrush/planetrush/planet/PlanetIntegrationTest.java index 9ebb00e..0fe8460 100644 --- a/src/test/java/com/planetrush/planetrush/planet/PlanetIntegrationTest.java +++ b/src/test/java/com/planetrush/planetrush/planet/PlanetIntegrationTest.java @@ -111,12 +111,13 @@ void should_guarantee_order_between_register_and_delete_requests() throws Interr }; // WHEN - ExecutorService executor = Executors.newFixedThreadPool(2); - executor.submit(registerResidentTask); - executor.submit(deleteResidentTask); + try (ExecutorService executor = Executors.newFixedThreadPool(2)) { + executor.submit(registerResidentTask); + executor.submit(deleteResidentTask); - startLatch.countDown(); - doneLatch.await(); + startLatch.countDown(); + doneLatch.await(); + } // THEN planet = planetRepository.findById(planet.getId()).get(); @@ -188,27 +189,28 @@ void should_ensure_idempotency_when_duplicate_register_resident_within_ten_secon // WHEN int loop = 10; - ExecutorService executor = Executors.newFixedThreadPool(loop); - CountDownLatch startLatch = new CountDownLatch(1); - Callable task = () -> { - startLatch.await(); - planetService.registerResident(registerDto); - return null; - }; - - List> futures = IntStream.range(0, loop) - .mapToObj(i -> executor.submit(task)) - .toList(); - startLatch.countDown(); - int successCnt = 0; int failedCnt = 0; - for (Future future : futures) { - try { - future.get(); - successCnt++; - } catch (Exception e) { - failedCnt++; + try (ExecutorService executor = Executors.newFixedThreadPool(loop)) { + CountDownLatch startLatch = new CountDownLatch(1); + Callable task = () -> { + startLatch.await(); + planetService.registerResident(registerDto); + return null; + }; + + List> futures = IntStream.range(0, loop) + .mapToObj(i -> executor.submit(task)) + .toList(); + startLatch.countDown(); + + for (Future future : futures) { + try { + future.get(); + successCnt++; + } catch (Exception e) { + failedCnt++; + } } } @@ -227,27 +229,28 @@ void should_ensure_idempotency_when_duplicate_delete_resident_within_ten_seconds // WHEN int loop = 10; - ExecutorService executor = Executors.newFixedThreadPool(loop); - CountDownLatch startLatch = new CountDownLatch(1); - Callable task = () -> { - startLatch.await(); - planetService.deleteResident(deleteDto); - return null; - }; - - List> futures = IntStream.range(0, loop) - .mapToObj(i -> executor.submit(task)) - .toList(); - startLatch.countDown(); - int successCnt = 0; int failedCnt = 0; - for (Future future : futures) { - try { - future.get(); - successCnt++; - } catch (Exception e) { - failedCnt++; + try (ExecutorService executor = Executors.newFixedThreadPool(loop)) { + CountDownLatch startLatch = new CountDownLatch(1); + Callable task = () -> { + startLatch.await(); + planetService.deleteResident(deleteDto); + return null; + }; + + List> futures = IntStream.range(0, loop) + .mapToObj(i -> executor.submit(task)) + .toList(); + startLatch.countDown(); + + for (Future future : futures) { + try { + future.get(); + successCnt++; + } catch (Exception e) { + failedCnt++; + } } } From 868012fbcb3f01680e7a056a741f15bc80fd1e64 Mon Sep 17 00:00:00 2001 From: simhani1 Date: Fri, 22 May 2026 21:30:32 +0900 Subject: [PATCH 09/11] =?UTF-8?q?feat(spec-002):=20US3=20profile=20+=20pol?= =?UTF-8?q?ish=20=E2=80=94=20close=20out=20outbox=20republisher?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- specs/002-outbox-republisher/plan.md | 28 ++++++++++++- specs/002-outbox-republisher/spec.md | 2 +- specs/002-outbox-republisher/tasks.md | 8 ++-- .../OutboxRepublisherIntegrationTest.java | 20 +++++++++ .../OutboxRepublisherProfileTest.java | 42 +++++++++++++++++++ 5 files changed, 94 insertions(+), 6 deletions(-) create mode 100644 src/test/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisherProfileTest.java diff --git a/specs/002-outbox-republisher/plan.md b/specs/002-outbox-republisher/plan.md index 4bbe583..5f8edba 100644 --- a/specs/002-outbox-republisher/plan.md +++ b/specs/002-outbox-republisher/plan.md @@ -123,4 +123,30 @@ Phase 0·1 산출물(research.md, quickstart.md) 작성 후 갱신: - 신규 위반 없음. `data-model.md`/`contracts/` 생략은 스키마·외부 계약 변경 0에 따른 의도된 N/A이며 본 plan Structure에 명시. ✅ - research.md의 락 쿼리 결정(native `FOR UPDATE SKIP LOCKED`)이 원칙 III와 무관함 재확인 — 엔티티 조회이지 DTO 매핑 아님. ✅ -- 전 게이트 재통과. **Phase 2(`/speckit-tasks`) 진입 가능.** +- 전 게이트 재통과. Phase 2(`/speckit-tasks`) 진입 가능. + +## Post-Implementation Constitution Re-Check (T014) + +구현 완료(T001~T013) 후 갱신: + +| 원칙 | 구현 결과 | +|---|---| +| **I. Testcontainers (NON-NEGOTIABLE)** | ✅ `OutboxRepublisherIntegrationTest`·`ConcurrencyTest`·`ProfileTest`가 `IntegrationTest`(MySQL/Redis Testcontainers) 상속. SKIP LOCKED 동시성은 실제 MySQL에서만 검증 가능 — Testcontainers 당위성 확인. | +| **II. 외부 의존 어댑터 격리** | ✅ 폴러는 `VerificationMessagePublisher` 인터페이스에만 의존, Redis 직접 호출 0. 도메인 코드 변경 0. | +| **III. QueryDSL Projections** | ✅ N/A — 폴링은 `OutboxEvent` 엔티티 조회(DTO 매핑 아님). | +| **IV. Outbox 강제 (NON-NEGOTIABLE)** | ✅ 본 스펙이 Outbox 신뢰성을 강화. 발행은 전부 OutboxEvent 경유. | +| **V. 시크릿 로그 금지 (NON-NEGOTIABLE)** | ✅ 폴러 로그는 outbox id·건수만 출력(payload·시크릿 미출력). `verifySecretLogScan` clean 확인. | +| **VI. 듀얼 AI 리뷰** | ⏳ PR 단계에서 Claude + Codex 리뷰 첨부 예정. | +| **VII. 인수 기준 자동 테스트** | ✅ SC-001~005 전부 자동 테스트로 검증 (16 tests). tasks.md에 SC↔Task 매핑. | + +**Complexity Tracking**: 위반 없음. + +**구현 중 발견·결정 사항**: + +1. **`OutboxEvent.status` ORDINAL 매핑** — `@Enumerated` 미지정이라 DB에 정수로 저장됨. native 폴링 쿼리는 `status = 'PENDING'`이 아니라 `OutboxStatus.PENDING.ordinal()`(정수)로 비교하도록 구현. research R-001의 예시 SQL을 ordinal 파라미터 방식으로 정정. +2. **폴러 2클래스 분리** — `@Scheduled`+`@Transactional`을 한 클래스에 두면 `test` 프로필(`enabled=false`)에서 빈이 통째 사라져 통합 테스트가 호출 불가. `new`로 만들면 `@Transactional` 프록시 미적용으로 SKIP LOCKED 락 검증 불가. → `OutboxRepublisher`(항상 빈, 로직) + `OutboxRepublisherScheduler`(`@ConditionalOnProperty`, 트리거)로 분리. plan Structure 반영 완료. +3. **컷오프 테스트 timezone 버그** — `java.sql.Timestamp`는 JDBC 전송 시 JVM TZ(KST)로 해석되어 `@CreationTimestamp`의 UTC 저장과 9시간 어긋남. MySQL `UTC_TIMESTAMP()` 기반 native update로 수정. +4. **analyze U1 해소** — SC-005를 "평균 발행 지연 측정"에서 "폴러 사이클 1회의 배치 완전성"으로 재정의(`test` 프로필은 폴러 자동 실행이 비활성이라 주기 기반 측정 불가). spec.md SC-005 갱신. +5. **analyze M1 해소** — `@EnableConfigurationProperties(OutboxRepublisherProperties.class)`를 `OutboxRepublisher`에 부착해 properties 등록을 명확히 고정. + +**결과**: 게이트 재통과. 위반 0. PR 머지 준비 단계로 진입. diff --git a/specs/002-outbox-republisher/spec.md b/specs/002-outbox-republisher/spec.md index 8fd5fe1..eee9676 100644 --- a/specs/002-outbox-republisher/spec.md +++ b/specs/002-outbox-republisher/spec.md @@ -105,7 +105,7 @@ - **SC-002**: 동시성 통합 테스트가 통과한다 — `OutboxEvent` 100건 `PENDING` 상태에서 폴러 2개 동시 실행 시 각 outbox가 정확히 1회만 발행됨을 `RepeatedTest 10회` 모두 만족. - **SC-003**: 컷오프 통합 테스트가 통과한다 — `createdAt`이 컷오프 초과인 `PENDING`은 폴링 결과에서 제외되고, 컷오프 이내인 `PENDING`만 재발행됨을 확인. - **SC-004**: 폴러 비활성 검증 — `test` 프로필 부팅 시 폴러 빈이 컨텍스트에 부재함. -- **SC-005**: 정상 경로 평균 발행 지연이 폴링 주기 + 1초 이내(기본 5초 주기 → 평균 ≤ 6초)임을 통합 테스트로 측정. +- **SC-005**: 폴러 사이클 1회 실행 시, 조회된 PENDING 배치가 모두 그 사이클 내에 발행 시도됨을 통합 테스트로 검증한다. (운영 환경의 실제 평균 발행 지연은 폴링 주기에 의해 결정되며 — 기본 5초 주기 → 평균 ≈ 2.5초, 최악 ≈ 5초 + 발행 시간 — `test` 프로필은 폴러 자동 실행이 비활성이라 주기 기반 지연을 직접 측정하지 않고 per-cycle 완전성으로 갈음한다. analyze U1 반영.) ## Assumptions diff --git a/specs/002-outbox-republisher/tasks.md b/specs/002-outbox-republisher/tasks.md index 8b3a1f2..d0066d7 100644 --- a/specs/002-outbox-republisher/tasks.md +++ b/specs/002-outbox-republisher/tasks.md @@ -112,13 +112,13 @@ description: "Task list for Spec 002 — Outbox Republisher Worker (simplified)" **Purpose**: 잔여 인수 기준 + 머지 준비. -- [ ] T011 [P] Add `src/test/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisherProfileTest.java` — SC-004: `test` 프로필 부팅 시 `OutboxRepublisher` 빈이 컨텍스트에 부재함 검증(`assertThatThrownBy(() -> context.getBean(OutboxRepublisher.class))` 또는 `ObjectProvider` 부재 확인). -- [ ] T012 [US1] Add 발행 지연 측정 to `OutboxRepublisherIntegrationTest.java` — SC-005: `PENDING` 저장 시각 ~ `PUBLISHED` 전환 시각 차이가 폴링 주기 + 1초 이내임을 측정(테스트에서는 폴러 직접 호출이므로 "주기 내 1회 호출로 발행됨"을 확인하는 형태로 검증). -- [ ] T013 Run full local validation: +- [X] T011 [P] Add `src/test/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisherProfileTest.java` — SC-004: `test` 프로필 부팅 시 `OutboxRepublisher` 빈이 컨텍스트에 부재함 검증(`assertThatThrownBy(() -> context.getBean(OutboxRepublisher.class))` 또는 `ObjectProvider` 부재 확인). +- [X] T012 [US1] Add 발행 지연 측정 to `OutboxRepublisherIntegrationTest.java` — SC-005: `PENDING` 저장 시각 ~ `PUBLISHED` 전환 시각 차이가 폴링 주기 + 1초 이내임을 측정(테스트에서는 폴러 직접 호출이므로 "주기 내 1회 호출로 발행됨"을 확인하는 형태로 검증). +- [X] T013 Run full local validation: - `./gradlew test --tests "*OutboxRepublisher*"` 전 통과 - `./gradlew verifySecretLogScan` clean (Spec 001 게이트) - `./gradlew check` 통과 -- [ ] T014 Update `specs/002-outbox-republisher/plan.md` "Post-Design Constitution Re-Check" 섹션을 구현 완료 결과로 갱신 + 발견 사항 기록. +- [X] T014 Update `specs/002-outbox-republisher/plan.md` "Post-Design Constitution Re-Check" 섹션을 구현 완료 결과로 갱신 + 발견 사항 기록. **Checkpoint**: 전 SC 통과. PR 머지 준비 완료. diff --git a/src/test/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisherIntegrationTest.java b/src/test/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisherIntegrationTest.java index 5516088..80df376 100644 --- a/src/test/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisherIntegrationTest.java +++ b/src/test/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisherIntegrationTest.java @@ -113,6 +113,26 @@ void staleEventBeyondCutoffIsExcludedFromPolling() { .isEqualTo(OutboxStatus.PENDING); } + @Test + @DisplayName("SC-005: 폴러 사이클 1회가 조회된 PENDING 배치를 모두 그 사이클 내에 발행한다") + void singleCyclePublishesEntireBatch() { + // given: batch-size(기본 100) 이내의 PENDING 10건 + for (int i = 0; i < 10; i++) { + outboxRepository.save(OutboxEvent.pending( + UUID.randomUUID().toString(), EventType.VERIFICATION_REQUEST, payloadJson())); + } + stubPublishSuccess(); + + // when: 폴러 사이클 1회 + outboxRepublisher.republishPending(); + + // then: 조회된 배치 전량이 같은 사이클 안에서 PUBLISHED + // (운영 환경의 실제 발행 지연은 폴링 주기에 의해 결정됨 — spec.md Assumptions) + assertThat(outboxRepository.findAll()) + .hasSize(10) + .allMatch(event -> event.getStatus() == OutboxStatus.PUBLISHED); + } + /** * mock 발행기가 실제 `VerificationRedisStreamPublisher`의 계약을 흉내 낸다: * 발행이 성공하면 해당 OutboxEvent를 published()로 전환한다. diff --git a/src/test/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisherProfileTest.java b/src/test/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisherProfileTest.java new file mode 100644 index 0000000..0cb3fad --- /dev/null +++ b/src/test/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisherProfileTest.java @@ -0,0 +1,42 @@ +package com.planetrush.planetrush.outbox.republisher; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.NoSuchBeanDefinitionException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; + +import com.planetrush.planetrush.IntegrationTest; + +/** + * Spec 002 US3 / SC-004 인수 기준 검증. + * + *

SC-004: `test` 프로필에서 폴러의 자동 스케줄 트리거가 비활성화된다. + * + *

로직 빈({@link OutboxRepublisher})과 스케줄 트리거({@link OutboxRepublisherScheduler})를 + * 분리한 설계에서, `test` 프로필은 `outbox.republisher.enabled=false`라 + * 트리거 빈만 부재하고 로직 빈은 존재한다 — 통합 테스트가 폴러 사이클을 + * 직접 호출해 결정론적으로 검증할 수 있도록. + */ +class OutboxRepublisherProfileTest extends IntegrationTest { + + @Autowired + private ApplicationContext context; + + @Test + @DisplayName("SC-004: test 프로필에서 OutboxRepublisherScheduler(자동 폴링 트리거) 빈이 부재한다") + void schedulerBeanAbsentUnderTestProfile() { + assertThatThrownBy(() -> context.getBean(OutboxRepublisherScheduler.class)) + .isInstanceOf(NoSuchBeanDefinitionException.class); + } + + @Test + @DisplayName("OutboxRepublisher 로직 빈은 존재한다 — 통합 테스트의 직접 호출 대상") + void republisherLogicBeanPresentForDirectInvocation() { + assertThat(context.getBeanProvider(OutboxRepublisher.class).getIfAvailable()) + .isNotNull(); + } +} From 506c4351a9fe2a1d448774cdfbd45ab22647b322 Mon Sep 17 00:00:00 2001 From: simhani1 Date: Fri, 22 May 2026 21:41:33 +0900 Subject: [PATCH 10/11] refactor(spec-002): use Lombok @RequiredArgsConstructor for DI components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../outbox/republisher/OutboxRepublisher.java | 13 ++----------- .../republisher/OutboxRepublisherScheduler.java | 7 +++---- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/src/main/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisher.java b/src/main/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisher.java index ee2df8c..5e550ad 100644 --- a/src/main/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisher.java +++ b/src/main/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisher.java @@ -15,6 +15,7 @@ import com.planetrush.planetrush.verification.event.publisher.VerificationMessagePublisher; import com.planetrush.planetrush.verification.service.dto.MessageCommand; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; /** @@ -32,6 +33,7 @@ */ @Slf4j @Component +@RequiredArgsConstructor @EnableConfigurationProperties(OutboxRepublisherProperties.class) public class OutboxRepublisher { @@ -40,17 +42,6 @@ public class OutboxRepublisher { private final ObjectMapper objectMapper; private final OutboxRepublisherProperties properties; - public OutboxRepublisher( - OutboxRepository outboxRepository, - VerificationMessagePublisher messagePublisher, - ObjectMapper objectMapper, - OutboxRepublisherProperties properties) { - this.outboxRepository = outboxRepository; - this.messagePublisher = messagePublisher; - this.objectMapper = objectMapper; - this.properties = properties; - } - /** * 폴러 사이클 1회. 컷오프 이내의 PENDING outbox를 SKIP LOCKED로 조회해 재발행한다. * diff --git a/src/main/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisherScheduler.java b/src/main/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisherScheduler.java index 94ae362..230ab88 100644 --- a/src/main/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisherScheduler.java +++ b/src/main/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisherScheduler.java @@ -4,6 +4,8 @@ import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; +import lombok.RequiredArgsConstructor; + /** * {@link OutboxRepublisher#republishPending()}를 주기적으로 호출하는 스케줄 트리거. * @@ -12,15 +14,12 @@ * 폴링 주기는 `outbox.republisher.polling-interval-ms` 설정값을 따른다. */ @Component +@RequiredArgsConstructor @ConditionalOnProperty(name = "outbox.republisher.enabled", havingValue = "true") public class OutboxRepublisherScheduler { private final OutboxRepublisher outboxRepublisher; - public OutboxRepublisherScheduler(OutboxRepublisher outboxRepublisher) { - this.outboxRepublisher = outboxRepublisher; - } - @Scheduled(fixedDelayString = "${outbox.republisher.polling-interval-ms:5000}") public void schedule() { outboxRepublisher.republishPending(); From 980b53cbd4835f0557cc8ee67dd343983e6d6f8c Mon Sep 17 00:00:00 2001 From: simhani1 Date: Fri, 22 May 2026 22:20:53 +0900 Subject: [PATCH 11/11] =?UTF-8?q?tune(spec-002):=20outbox=20=ED=8F=B4?= =?UTF-8?q?=EB=A7=81=20=EC=A3=BC=EA=B8=B0=205=EC=B4=88=20=E2=86=92=201?= =?UTF-8?q?=EB=B6=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- specs/002-outbox-republisher/research.md | 6 ++++-- specs/002-outbox-republisher/spec.md | 10 +++++----- .../republisher/OutboxRepublisherProperties.java | 2 +- .../outbox/republisher/OutboxRepublisherScheduler.java | 2 +- src/main/resources/application.yml | 2 +- 5 files changed, 12 insertions(+), 10 deletions(-) diff --git a/specs/002-outbox-republisher/research.md b/specs/002-outbox-republisher/research.md index a3e32f6..fa535f0 100644 --- a/specs/002-outbox-republisher/research.md +++ b/specs/002-outbox-republisher/research.md @@ -134,11 +134,13 @@ spring: | 키 | 타입 | 기본값 | 의미 | |---|---|---|---| | `enabled` | boolean | `true` | 폴러 활성 여부 | -| `polling-interval-ms` | long | `5000` | 폴링 주기(밀리초) | +| `polling-interval-ms` | long | `60000` | 폴링 주기(밀리초). 컷오프 ÷ 목표 재시도 횟수에서 역산된 종속값 — 5분 컷오프 ÷ 4~5회 ≈ 1분 | | `cutoff-minutes` | long | `5` | `createdAt` 컷오프(분) — 초과 PENDING은 폴링 제외 | | `batch-size` | int | `100` | 사이클당 처리 상한 | -`@Scheduled(fixedDelayString = "${outbox.republisher.polling-interval-ms:5000}")`로 주기를 주입한다. +`@Scheduled(fixedDelayString = "${outbox.republisher.polling-interval-ms:60000}")`로 주기를 주입한다. + +**폴링 주기 설계 원칙**: 폴링 주기는 독립 변수가 아니라 **컷오프에 종속된 파생값**이다. 컷오프(비즈니스·운영 근거로 결정되는 주 기준)가 먼저 정해지고, "일시 장애 복구에 필요한 재시도 기회 횟수"를 정한 뒤 `폴링 주기 = 컷오프 ÷ 재시도 횟수`로 역산한다. 폴링 주기가 컷오프와 같거나 크면 경계에서 재시도 기회가 0~1회로 붕괴하므로, 폴링 ≪ 컷오프여야 한다. 기본값은 5분 컷오프 ÷ 약 5회 = 1분. 컷오프가 바뀌면 폴링도 비례해 따라가야 한다. **Rationale**: diff --git a/specs/002-outbox-republisher/spec.md b/specs/002-outbox-republisher/spec.md index eee9676..14538d2 100644 --- a/specs/002-outbox-republisher/spec.md +++ b/specs/002-outbox-republisher/spec.md @@ -74,7 +74,7 @@ ### Edge Cases - **앱 종료 중 폴러 사이클**: 진행 중 작업은 graceful shutdown으로 완료 후 종료. 강제 종료 시 해당 outbox는 다음 인스턴스 폴러가 재발견해 재시도(US1). -- **사이클 간격보다 발행이 오래 걸림**(예: 5초 사이클 + 6초 발행): 행 단위 락이 살아있는 동안 다음 사이클은 해당 outbox를 건너뜀(US2와 동일 메커니즘). +- **사이클 간격보다 발행이 오래 걸림**: 행 단위 락이 살아있는 동안 다음 사이클은 해당 outbox를 건너뜀(US2와 동일 메커니즘). - **컷오프 경계의 메시지**: `createdAt`이 정확히 컷오프 직전인 outbox가 폴링 도중 컷오프를 넘기면 다음 사이클부터 제외 — 자연스러운 동작, 별도 처리 불필요. - **컷오프 초과 후 영구 잔존**: 컷오프를 넘긴 `PENDING`은 자동 복구 대상에서 빠지며 DB에 남는다. 운영자 가시성(알림/메트릭)은 본 스펙 범위 밖(후속 observability 스펙). - **메시지 순서**: 본 스펙은 순서를 약속하지 않는다(At-Least-Once만 보장). 재발행으로 순서가 뒤바뀔 수 있으며 컨슈머가 이를 감내한다. @@ -83,11 +83,11 @@ ### Functional Requirements -- **FR-001**: 시스템은 `status = PENDING`이며 `createdAt`이 컷오프 이내인 `OutboxEvent`를 주기적(기본 5초 간격)으로 조회한다. +- **FR-001**: 시스템은 `status = PENDING`이며 `createdAt`이 컷오프 이내인 `OutboxEvent`를 주기적(기본 1분 간격)으로 조회한다. - **FR-002**: 폴링 쿼리는 행 단위 락 + 건너뛰기 의미론(`SELECT ... FOR UPDATE SKIP LOCKED` 또는 동등 효과)으로 동시 폴러 인스턴스 간 안전을 보장한다. 잠긴 행은 대기 없이 즉시 건너뛴다. - **FR-003**: 폴러는 조회된 각 `OutboxEvent`를 기존 발행 경로(`VerificationMessagePublisher`)로 재발행한다. 성공 시 상태가 `PUBLISHED`로 전환되고, 실패 시 상태는 `PENDING`으로 남아 다음 사이클에 자연 재시도된다(별도 재시도 카운터·백오프 없음). - **FR-004**: `createdAt`이 컷오프를 초과한 `PENDING` outbox는 폴링 대상에서 제외된다. 해당 레코드는 삭제되지 않고 DB에 `PENDING`으로 잔존한다. -- **FR-005**: 컷오프 시간, 폴링 주기, 배치 크기는 `application.yml` 설정값으로 외부화되며 코드 수정 없이 변경 가능하다. 기본값 — 컷오프 5분, 폴링 주기 5초, 배치 크기 100. +- **FR-005**: 컷오프 시간, 폴링 주기, 배치 크기는 `application.yml` 설정값으로 외부화되며 코드 수정 없이 변경 가능하다. 기본값 — 컷오프 5분, 폴링 주기 1분, 배치 크기 100. - **FR-006**: 폴러는 `dev`/`prod` 프로필에서 활성화되고 `test` 프로필에서는 비활성화된다. `@ConditionalOnProperty(name = "outbox.republisher.enabled", havingValue = "true")` 기반이며 `test` 프로필은 해당 속성을 `false`로 둔다(통합 테스트는 폴러를 직접 호출해 검증). - **FR-007**: 폴러는 graceful shutdown을 지원한다 — 종료 신호 수신 시 in-flight 발행 작업 완료 후 종료한다. - **FR-008**: 본 스펙은 `OutboxEvent` 엔티티에 새 필드를 추가하지 않으며 `OutboxStatus` enum도 변경하지 않는다. 컷오프 판정은 기존 `createdAt` 필드만으로 수행한다. @@ -105,12 +105,12 @@ - **SC-002**: 동시성 통합 테스트가 통과한다 — `OutboxEvent` 100건 `PENDING` 상태에서 폴러 2개 동시 실행 시 각 outbox가 정확히 1회만 발행됨을 `RepeatedTest 10회` 모두 만족. - **SC-003**: 컷오프 통합 테스트가 통과한다 — `createdAt`이 컷오프 초과인 `PENDING`은 폴링 결과에서 제외되고, 컷오프 이내인 `PENDING`만 재발행됨을 확인. - **SC-004**: 폴러 비활성 검증 — `test` 프로필 부팅 시 폴러 빈이 컨텍스트에 부재함. -- **SC-005**: 폴러 사이클 1회 실행 시, 조회된 PENDING 배치가 모두 그 사이클 내에 발행 시도됨을 통합 테스트로 검증한다. (운영 환경의 실제 평균 발행 지연은 폴링 주기에 의해 결정되며 — 기본 5초 주기 → 평균 ≈ 2.5초, 최악 ≈ 5초 + 발행 시간 — `test` 프로필은 폴러 자동 실행이 비활성이라 주기 기반 지연을 직접 측정하지 않고 per-cycle 완전성으로 갈음한다. analyze U1 반영.) +- **SC-005**: 폴러 사이클 1회 실행 시, 조회된 PENDING 배치가 모두 그 사이클 내에 발행 시도됨을 통합 테스트로 검증한다. (운영 환경의 실제 발행 지연은 폴링 주기에 의해 결정되며 — 기본 1분 주기 → 발행 실패분의 추가 대기 최대 ≈ 1분 — `test` 프로필은 폴러 자동 실행이 비활성이라 주기 기반 지연을 직접 측정하지 않고 per-cycle 완전성으로 갈음한다. analyze U1 반영.) ## Assumptions - **컨슈머 멱등성 전제**: 컨슈머(Spec 003)가 동일 `eventId` 중복 메시지를 무해화한다. 따라서 본 스펙은 "정확히 1번"이 아닌 "최소 1번"만 보장하면 충분하다. -- **컷오프 값 근거**: 기본 5분은 (a) 정상적 일시 장애의 최대 회복 시간 — 배포 롤링 업데이트 약 2~3분, Redis/네트워크 순단 1분 미만 — 보다 길고, (b) 챌린지 인증 검증의 비즈니스 허용 지연(당일 인증이면 충분, 분 단위 지연 무해) 이내이다. 5분 ÷ 5초 폴링 = 약 60회 재시도 기회. 운영 데이터로 조정 가능하도록 설정값으로 둔다. +- **컷오프 값 근거**: 기본 5분은 (a) 정상적 일시 장애의 최대 회복 시간 — 배포 롤링 업데이트 약 2~3분, Redis/네트워크 순단 1분 미만 — 보다 길고, (b) 챌린지 인증 검증의 비즈니스 허용 지연(당일 인증이면 충분, 분 단위 지연 무해) 이내이다. **컷오프가 주(主) 기준이고 폴링 주기는 종속 파생값**이다: 컷오프 5분 ÷ 목표 재시도 횟수(4~5회) ≈ 폴링 1분. 운영 데이터로 조정 가능하도록 설정값으로 두며, 컷오프를 바꾸면 폴링 주기도 비례해 따라가야 한다. - 신규 컬럼이 없으므로 DB 마이그레이션이 불필요하다. (정식 마이그레이션 도구 Flyway는 Spec 005.) - Spec 001에서 마련한 `IntegrationTest` 베이스(Testcontainers MySQL/Redis)를 그대로 상속하여 새 테스트를 작성한다. - 운영은 다중 인스턴스(HA)를 가정한다. 단일 인스턴스라면 `SELECT ... FOR UPDATE SKIP LOCKED`는 불필요하지만, 유지해도 무해하며 HA 전환 시 안전망이 된다. diff --git a/src/main/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisherProperties.java b/src/main/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisherProperties.java index 6753e01..8e96ab1 100644 --- a/src/main/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisherProperties.java +++ b/src/main/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisherProperties.java @@ -17,7 +17,7 @@ @ConfigurationProperties(prefix = "outbox.republisher") public record OutboxRepublisherProperties( @DefaultValue("true") boolean enabled, - @DefaultValue("5000") long pollingIntervalMs, + @DefaultValue("60000") long pollingIntervalMs, @DefaultValue("5") long cutoffMinutes, @DefaultValue("100") int batchSize ) { diff --git a/src/main/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisherScheduler.java b/src/main/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisherScheduler.java index 230ab88..0d0ebca 100644 --- a/src/main/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisherScheduler.java +++ b/src/main/java/com/planetrush/planetrush/outbox/republisher/OutboxRepublisherScheduler.java @@ -20,7 +20,7 @@ public class OutboxRepublisherScheduler { private final OutboxRepublisher outboxRepublisher; - @Scheduled(fixedDelayString = "${outbox.republisher.polling-interval-ms:5000}") + @Scheduled(fixedDelayString = "${outbox.republisher.polling-interval-ms:60000}") public void schedule() { outboxRepublisher.republishPending(); } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7d39161..48e4be2 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -91,6 +91,6 @@ verification: outbox: republisher: enabled: true # Spec 002 — PENDING outbox 자동 재발행 폴러 (test 프로필은 false) - polling-interval-ms: 5000 # 폴링 주기 + polling-interval-ms: 60000 # 폴링 주기 1분 (컷오프 5분 ÷ 재시도 4~5회에서 역산) cutoff-minutes: 5 # createdAt 컷오프 — 초과한 PENDING은 폴링 제외 batch-size: 100 # 사이클당 처리 상한 \ No newline at end of file