--- title: Runbook — outbox publish 일시 실패 category: TRANSIENT_DEPENDENCY error_codes: [OUTBOX_PUBLISH_FAILED] severity: P2 owner: oncall last_updated: 2026-06-11 status: stub --- # Runbook: outbox publish 일시 실패 (`runbook://outbox/publish-failed`) ## 1. Trigger 이 runbook은 다음 alert에서 발동됩니다. - alert name: `outbox_publish_failed_rate` 또는 `outbox_publisher_lag` - alert payload 필수 field: `error.code=OUTBOX_PUBLISH_FAILED`, `event_type`, `correlation_id`, `runbook_link` - 임계 (metrics.yaml verbatim): - P2: `outbox.publisher.published.total{outcome=FAILED}` rate > 1% for 10m - P2: `outbox.publisher.lag` > 60s for 10m / P1: > 300s for 5m - P2: `outbox.pending.size{status=PENDING}` growing for 10m ## 2. First Response (5분 이내) ### Step 1 — 확인 1. ERROR log에서 `OUTBOX_PUBLISH_FAILED` 라인 확인: `event_type`, `event_id`, `correlation_id`, `attempt_count` 추출 2. broker 상태 확인: `APP_MESSAGING_BROKER` 값과 broker endpoint 가용성. 이 키는 활성화 스위치가 아니라 **선택자**다 — messaging을 끄는 것은 `APP_MESSAGING_ENABLED=false`이고, 이 값을 비운다고 messaging이 꺼지지는 않는다. - `APP_MESSAGING_BROKER`가 공백인 채로 relay가 켜져 있으면 **애플리케이션이 기동하지 않는다** (`OutboxRelayBrokerRequirementValidator`, MSG-024). 이 조합에서는 publish가 전부 `AdapterDisabledException`으로 실패하며 PENDING row가 DEAD까지 소진되기 때문이다. 기동 실패를 보고 있다면 broker를 설정하거나 `ca-skeleton.outbox.relay-enabled=false`로 둔다. - 기동은 했는데 실패가 쌓인다면 broker는 설정돼 있고 도달이 안 되는 것이다 — endpoint부터 본다. 3. `outbox.pending.size` status 분포 확인 (FAILED 누적 vs PENDING 누적) ### Step 2 — 임시 격리 - 일시 실패는 자동 backoff 재시도(30s × 2^(attempt-1) + jitter, max attempts 3)가 동작 — 즉시 수동 개입 불필요 - broker 장기 다운이면 DEAD 전이 누적 전에 broker 회복을 우선 (max attempts 소진 시 `runbook://outbox/dead-letter`로 이관) - relay 자체를 멈춰야 하면 `ca-skeleton.outbox.relay-enabled=false`로 스케줄러 비활성 (이벤트는 outbox 테이블에 안전하게 보존됨 — 유실 없음) ## 3. Diagnosis - log query: `{service="app"} | error.code="OUTBOX_PUBLISH_FAILED" | stats count by event_type` - metric panel: - `outbox.publisher.published.total{outcome}` — FAILED 비율 - `outbox.publisher.lag{event_type}` — 최고령 미발행 이벤트 age - `outbox.pending.size{status}` — 상태별 분포 - DB 확인: `SELECT status, count(*) FROM outbox_event GROUP BY status;` - 가능한 원인: - broker outage/네트워크 → broker 측 회복 대기 - Kafka adapter 미구성(enabled인데 brokers 누락은 기동 시 차단됨) / disabled 상태에서 producer 활성화 - poison event (직렬화 불가/payload 계약 위반) → 재시도 무의미, attempts 소진 후 DEAD로 흘러감 (의도된 동작) - 동일 aggregate head 실패로 후행 이벤트가 FIFO 게이트에 차단되어 lag 증가 (strict per-aggregate FIFO — 설계 의도) ## 4. Mitigation - 단기: broker 회복 후 backoff 만료 시 자동 재발행 — `outcome=PUBLISHED` 회복 확인 - IN_FLIGHT orphan(claim 후 crash)은 in-flight-timeout(기본 PT5M) 경과 후 자동 재claim — at-least-once이므로 중복 발행 가능, consumer dedupe(idempotencyKey)가 흡수 - 장기: `ca-skeleton.outbox.poll-interval`/`batch-size` 조정, broker 가용성 SLA 점검, 빈발 event_type의 payload 계약 검토 ## 5. Escalation - P1 lag(>300s 5m) 지속 + broker 회복 불가면 broker/infra 팀에 page - DEAD 전이가 발생하기 시작하면 `runbook://outbox/dead-letter` 절차로 이관 ## 6. Recovery / Verification - 회복 확인 metric: `outcome=FAILED` rate < 1% 10분 지속, `outbox.publisher.lag` < 60s, `outbox.pending.size{status=FAILED}` 감소 추세 - post-incident: 실패 구간의 DEAD row 유무 확인, consumer 측 중복 처리량 확인(dedupe 동작 검증), backoff/attempts 상수 재평가 ## 7. Related - error-codes.yaml rows: `OUTBOX_PUBLISH_FAILED` (TRANSIENT_DEPENDENCY, retryable=true, retry_after 30s) - metrics.yaml: `outbox.publisher.published.total`, `outbox.publisher.lag`, `outbox.pending.size` - 코드: `application-core` `PublishPendingOutboxEventsUseCase`(상태머신), `adapter-persistence` `outbox/OutboxEventJpaRepository`(SKIP LOCKED claim + FIFO 게이트), `adapter-outbound` `messaging/outbox/KafkaOutboxMessagePublishAdapter`(fail-closed) - 관련 runbook: [[outbox-dead-letter]] - 관련 branch: [[feature-domain-event-outbox-contract]], [[feature-background-job-async-contract]] (retry/DLQ vocabulary SSOT) --- > **Stub 상태 안내**: 이 runbook은 skeleton 단계의 stub. 실제 broker 채택·alert 라우팅·대시보드 링크 확정 시 보강 필요.