Transactional Outbox는 “DB 저장은 성공했는데 후속 이벤트가 사라지는 문제”를 막습니다. 업무 데이터와 발행할 이벤트를 같은 transaction에 저장한 뒤, 별도 worker가 안전하게 처리합니다.
Task 생성·취소
→ Task·Audit·event_publication을 한 번에 commit
→ Outbox worker가 처리할 row를 lease
→ 각 handler 실행
→ event_consumption으로 handler 완료를 기록
→ event_publication을 COMPLETED로 종료
Kafka나 RabbitMQ가 필요한 구조는 아닙니다. MVP는 기존 PostgreSQL을 내구성 저장소로
사용하고, 호출 코드는 DomainEventPublisher Port에만 의존합니다.
| 테이블 | 의미 |
|---|---|
event_publication |
처리해야 할 이벤트와 현재 상태, 시도 횟수, 다음 시각, lease를 저장 |
event_consumption |
어느 handler가 어느 이벤트를 이미 성공했는지 저장 |
outbox_manual_retry |
ADMIN의 수동 재처리 사유와 중복 방지 키 해시를 변경 불가 기록으로 저장 |
event_publication의 상태는 다음과 같습니다.
| 상태 | 의미 |
|---|---|
PENDING |
아직 처리하지 않음 |
PROCESSING |
특정 서버가 제한 시간 동안 처리 권한을 가짐 |
RETRY_WAIT |
일시 실패 후 다음 처리 시각을 기다림 |
COMPLETED |
모든 handler 처리가 끝남 |
REVIEW_REQUIRED |
자동 처리를 멈추고 개발자·운영자 확인이 필요함 |
- 기능 모듈에서 과거형 업무 사실 이름을 정합니다. 예:
TaskCreated. - payload field를 상수 allow-list로 선언합니다.
DomainEventEnvelope를 만들고 application service의 기존@Transactionalmethod 안에서DomainEventPublisher.publish()를 호출합니다.- 업무 저장, Audit, Event 중 하나라도 실패하면 전체가 rollback되는 통합 테스트를 작성합니다.
transaction 밖에서 publish()하면 서버가 즉시 거부합니다. 이벤트를 먼저 commit한 뒤
업무 저장을 따로 수행하면 원자성이 깨지므로 금지합니다.
DomainEventHandler를 구현한 Spring Bean을 기능 모듈의 infrastructure 또는
application adapter에 둡니다.
handlerName()은 배포 뒤 의미를 바꾸지 않는 고유 이름을 사용합니다.supports(eventType)으로 처리할 이벤트를 명시합니다.handle(event)는 다른 모듈 Entity를 직접 수정하지 않고 해당 모듈의 application command 또는 Port를 호출합니다.- handler의 DB 변경과
event_consumption저장은 같은 transaction입니다. - 외부 API는 상대 시스템에도
event_id또는 별도 업무 unique key를 idempotency key로 전달해야 합니다. DB 완료 기록만으로 상대 시스템의 중복 실행까지 되돌릴 수는 없습니다. - 일시 오류는
RetryableEventHandlingException, 입력·계약 오류처럼 반복해도 성공하지 않는 오류는NonRetryableEventHandlingException으로 분류합니다.
이미 완료된 (event_id, handler_name)은 재전달 시 건너뜁니다. handler 이름을
변경하면 새 handler로 인식되므로 단순 refactoring 때 이름을 바꾸지 않습니다.
Event payload는 SafeEventPayload.of(allowedFields, values)를 통과해야 합니다.
- 필요한 작은 업무값만 넣습니다. 예:
status,workflow_id,task_type. - Worker 이름·이메일·전화번호·여권번호·외국인등록번호·계좌번호를 넣지 않습니다.
- JWT, Worker Link 원본 token, 비밀번호, API Key, 전체 Prompt를 넣지 않습니다.
- 큰 객체, 중첩 JSON, Entity 전체를 넣지 않습니다.
- 실패 로그에는 payload나 예외 원문 대신
event_id,event_type, 안전한error_code, 시도 횟수만 기록합니다.
| 환경변수 | 기본값 | 설명 |
|---|---|---|
OUTBOX_ENABLED |
true |
scheduler 실행 여부 |
OUTBOX_POLL_INTERVAL |
1s |
처리할 이벤트를 확인하는 간격 |
OUTBOX_BATCH_SIZE |
20 |
한 번에 lease할 최대 row 수 |
OUTBOX_LEASE_DURATION |
5m |
한 서버의 처리 권한 유효시간. Renewal 재분석 240초보다 길게 유지 |
OUTBOX_MAX_ATTEMPTS |
8 |
자동 시도 한도 |
OUTBOX_INITIAL_BACKOFF |
1s |
첫 재시도 대기시간 |
OUTBOX_MAX_BACKOFF |
5m |
재시도 대기시간 상한 |
lease는 정상 handler 최대 처리시간보다 길어야 합니다. 값을 줄이기 전에 느린 handler와 외부 API timeout을 확인합니다.
현재 Outbox worker에는 lease heartbeat가 없습니다. 따라서 하나의 handler 처리와 외부 API 호출은 반드시 lease 만료 전에 끝나야 합니다.
- 외부 API adapter는 연결·응답·전체 호출 timeout을 명시하고, 전체 처리 제한을
OUTBOX_LEASE_DURATION보다 짧게 둡니다. - 한 batch에서 여러 이벤트를 순서대로 처리하므로 staging에서 handler 최대 처리시간을
측정한 뒤
OUTBOX_BATCH_SIZE와 lease를 함께 조정합니다. - Renewal 자동 재분석은 최대 240초 Runtime 호출만 수행하고, HWP/HWPX 생성은 자동 continuation에서 실행하지 않습니다. 문서 생성은 HR의 수동 검토 실행에서만 수행합니다.
- 5분을 넘길 수 있는 handler를 추가할 때는 timeout만 늘리지 않습니다. 현재
lease_owner가 여전히 유효한지 확인하며 lease를 연장하는 heartbeat와, 만료된 worker가 완료 상태를 덮어쓰지 못하게 하는 fencing 검증을 먼저 구현합니다.
Actuator가 노출되는 내부 운영 환경에서는 다음 Micrometer 지표를 확인합니다.
fowoco.outbox.publications.backlog: 미완료 이벤트 수fowoco.outbox.publications.oldest.delay.seconds: 가장 오래된 미완료 이벤트 지연fowoco.outbox.publications.processed{result=completed|retry|review_required}: 처리 결과 누적 수
DB에서는 payload를 출력하지 않고 상태와 안전한 오류만 확인합니다.
SELECT event_id, company_id, event_type, status, attempt_count,
next_attempt_at, lease_owner, lease_expires_at, last_error_code, updated_at
FROM event_publication
WHERE status <> 'COMPLETED'
ORDER BY occurred_at;PROCESSING lease가 만료되면 다음 poll에서 자동 복구됩니다. RETRY_WAIT도
next_attempt_at 이후 자동 처리됩니다. REVIEW_REQUIRED는 원인을 수정했다고 해서
DB를 임의로 PENDING으로 바꾸지 않습니다.
- 안전한
last_error_code와 관련 handler 상태를 확인하고 원인을 먼저 해결합니다. - ADMIN Access Token으로 아래 API를 호출합니다.
- 응답이
202이면 이벤트는PENDING이 되고 다음 Outbox poll에서 한 번 더 시도됩니다. event_publication,outbox_manual_retry,audit_event를 payload 없이 확인합니다.
POST /api/v1/admin/outbox-events/{eventId}/retry
Authorization: Bearer {admin-access-token}
Idempotency-Key: incident-20260806-event-001
Content-Type: application/json
{
"expected_version": 3,
"reason": "내부 handler 복구와 점검을 완료했습니다."
}expected_version은 조회 당시event_publication.version입니다. 그 사이 상태가 바뀌면409로 거부하므로 최신 상태를 다시 확인합니다.- 같은
Idempotency-Key와 같은 요청은 재실행하지 않고 최초 접수 결과를 반환합니다. - 사유는 10~300자로 작성하며 이름·연락처·문서 원문·token·payload·예외 원문을 입력하지 않습니다.
- 수동 재처리가 승인되면
attempt_count를 0으로 초기화해 handler가 실제로 다시 실행될 기회를 부여합니다. 초기화 전 횟수는outbox_manual_retry.previous_attempt_count에 변경 불가 이력으로 보존합니다. 이후 실패하면 일반 자동 재시도 한도를 다시 적용합니다. - HR·VIEWER와 다른 사업장의 ADMIN은 호출할 수 없습니다.
OUTBOX_ENABLED=false는 자동 처리를 멈출 뿐 새 이벤트 저장을 막지 않습니다. 장애 중
이벤트가 계속 누적될 수 있으므로 backlog를 함께 관찰하고, 수정 배포 후 다시 활성화해
순서대로 처리합니다.
- H2 통합 테스트: 업무 변경·이벤트 원자성, 재시도 rollback, 중복 전달, lease 만료 복구, 수동 검토 전환
- PostgreSQL CI 테스트: V7 migration, 상태 CHECK, tenant-aware FK, handler unique constraint, index
- 기능 통합 테스트: 실제 command가 올바른 event type과 최소 payload를 발행하는지 검증
- 운영 API 통합 테스트: ADMIN 권한, 사업장 격리, version 충돌, Idempotency-Key, 동시 재처리와 감사로그, 최대 횟수 이벤트의 실제 handler 재실행 검증
로컬 전체 검증:
./gradlew clean testCI는 PostgreSQL 16 service에도 모든 Flyway migration을 적용하고 계약을 검증합니다.