Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .specify/extensions/git/git-config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .specify/feature.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
"feature_directory": "specs/001-secure-logging-test-foundation"
"feature_directory": "specs/002-outbox-republisher"
}
10 changes: 6 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`
<!-- SPECKIT END -->
48 changes: 48 additions & 0 deletions specs/002-outbox-republisher/checklists/requirements.md
Original file line number Diff line number Diff line change
@@ -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` 진입 가능.
152 changes: 152 additions & 0 deletions specs/002-outbox-republisher/plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
# 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`) 진입 가능.

## 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 머지 준비 단계로 진입.
Loading