Files
clean-architecture-backend-…/docs/runbooks/outbox-dead-letter.md
T

74 lines
4.1 KiB
Markdown

---
title: Runbook — outbox dead letter
category: INTERNAL
error_codes: [OUTBOX_DEAD_LETTER]
severity: P1
owner: oncall
last_updated: 2026-06-11
status: stub
---
# Runbook: outbox dead letter (`runbook://outbox/dead-letter`)
## 1. Trigger
이 runbook은 다음 alert에서 발동됩니다.
- alert name: `outbox_dead_letter`
- alert payload 필수 field: `error.code=OUTBOX_DEAD_LETTER`, `event_type`, `event_id`, `correlation_id`, `runbook_link`
- 임계: `outbox.publisher.published.total{outcome=DEAD}` > 0 (DEAD 전이는 자동 회복이 없으므로 단건도 점검 대상)
## 2. First Response (5분 이내)
### Step 1 — 확인
1. ERROR log에서 `OUTBOX_DEAD_LETTER` 라인 확인: `event_id`, `event_type`, `correlation_id`, 마지막 실패 원인 예외 추출
2. DB에서 DEAD row 확인: `SELECT * FROM outbox_event WHERE status = 'DEAD' ORDER BY occurred_at;`
3. **차단 영향 파악 (중요)**: strict per-aggregate FIFO 정책상 DEAD row는 같은 `aggregate_id`의 후행 이벤트를 계속 차단함 —
`SELECT count(*) FROM outbox_event b WHERE b.status <> 'PUBLISHED' AND EXISTS (SELECT 1 FROM outbox_event d WHERE d.status='DEAD' AND d.aggregate_id=b.aggregate_id AND d.occurred_at < b.occurred_at);`
### Step 2 — 임시 격리
- DEAD는 max attempts(3) 소진의 최종 상태 — 자동 재시도 없음, 수동 개입 필수
- 차단된 aggregate가 비즈니스 크리티컬하면 아래 §4의 수동 처분(재발행 또는 skip)을 우선 수행
## 3. Diagnosis
- log query: `{service="app"} | error.code="OUTBOX_DEAD_LETTER" | stats count by event_type`
- 마지막 실패 원인 분류:
- poison event (payload 직렬화/계약 위반) → payload 자체 결함, 재발행해도 실패 — 수정 후 재발행 또는 skip
- broker 장기 outage 중 attempts 소진 → broker 회복 후 재발행으로 해결 가능
- 구성 오류 (Kafka disabled 상태에서 producer 활성) → 구성 수정 후 재발행
- 가능한 원인 우선순위: 구성 오류 > broker outage > poison payload
## 4. Mitigation (수동 처분 — 둘 중 하나)
- **재발행 (기본)**: 원인 해소 후 해당 row를 다시 claim 가능 상태로 되돌림 —
`UPDATE outbox_event SET status = 'PENDING', attempt_count = 0, next_attempt_at = now() WHERE event_id = '<id>' AND status = 'DEAD';`
(consumer는 at-least-once + idempotencyKey dedupe 의무가 있으므로 중복 발행은 안전)
- **skip (영구 폐기)**: 이벤트가 더 이상 유효하지 않으면 PUBLISHED로 마킹해 FIFO 차단을 해제 —
`UPDATE outbox_event SET status = 'PUBLISHED' WHERE event_id = '<id>' AND status = 'DEAD';`
⚠️ skip은 다운스트림에 영구 이벤트 갭을 만든다 — 비즈니스 오너 승인 후에만 수행하고 incident 기록에 남김
- 장기: poison event 재발 방지(payload 계약 테스트 보강), DEAD 빈발 event_type의 producer 검증 강화
## 5. Escalation
- 처분 판단(재발행 vs skip)이 불가하면 해당 이벤트의 비즈니스 오너에게 escalate
- DEAD 누적이 특정 event_type에 집중되면 producer 코드 오너에게 page
## 6. Recovery / Verification
- 회복 확인: `SELECT count(*) FROM outbox_event WHERE status='DEAD';` = 0, 차단됐던 aggregate의 후행 이벤트가 PUBLISHED로 전이
- `outbox.publisher.lag` 정상화(< 60s), 재발행분의 consumer dedupe 동작 확인
- post-incident: DEAD 원인 분류 기록, 같은 원인의 재발 방지 테스트 추가
## 7. Related
- error-codes.yaml rows: `OUTBOX_DEAD_LETTER` (INTERNAL, retryable=false)
- metrics.yaml: `outbox.publisher.published.total{outcome=DEAD}`, `outbox.pending.size{status=DEAD}`, `outbox.publisher.lag`
- 코드: `application-core` `PublishPendingOutboxEventsUseCase`(FAILED→DEAD 전이), `adapter-persistence` `outbox/OutboxEventJpaRepository`(FIFO 게이트 — DEAD가 후행 차단)
- 관련 runbook: [[outbox-publish-failed]]
- 관련 branch: [[feature-domain-event-outbox-contract]], [[feature-background-job-async-contract]] (max attempts/DLQ vocabulary SSOT)
---
> **Stub 상태 안내**: 이 runbook은 skeleton 단계의 stub. 실제 broker·DLQ 토픽·승인 체계 확정 시 보강 필요.