Repository navigation
outbox channel dispatch
hyoguoo edited this page Aug 10, 2026
·
8 revisions
Outbox 패턴 의 즉시 발행 entry point 중 패턴 B (In-memory channel) 의 상세 메커니즘
기존에는 pg-service의 Kafka listener 트랜잭션 안에서 벤더 호출까지 처리했기 때문에, 인바운드 처리량이 벤더의 지연 시간에 종속되었다.
- 성능 측정 후 listener 트랜잭션에서 벤더 호출을 분리하기로 결정
- 그 결과 작업 큐 (inbox 채널)와 발행 큐 (outbox 채널) 구조를 적용
단일 스레드에서 두 단계를 모두 처리하면 producer의 ack 대기가 consumer의 poll을 차단하기 때문이다.
- consumer thread: 채널로 넘기고 컨슘만 수행
- 채널과 Worker: 컨슘과 프로듀스를 다른 스레드로 갈라 두는 분기점 장치
- Worker 가상 스레드: 실제 프로듀스 행위 담당
consumer 가 같은 스레드에서 직접 publish 하면 다음 연쇄가 일어난다.
- broker ack (평균 ms~수십ms, 최악 10초) 까지 같은 스레드가 묶임
- 그 동안 다음
poll()호출 불가 → lag 누적 - 누적이
max.poll.interval.ms초과 시 파티션 리밸런스 발동
pg-service는 작업 큐 (inbox 채널)와 발행 큐 (outbox 채널)를 활용해 2단계로 구성한다.
flowchart LR
Consumer[Kafka consumer 스레드<br/>OTel context 캡처]
InboxCh[PgInboxChannel<br/>작업 큐<br/>LinkedBlockingQueue cap=1024]
InboxWorker[PgInboxImmediateWorker<br/>VT Worker 5개]
PollInbox[PgInboxPollingWorker<br/>PENDING 60s, IN_PROGRESS 60s]
Vendor[PG 벤더 HTTP]
OutboxCh[PgOutboxChannel<br/>발행 큐<br/>LinkedBlockingQueue cap=1024]
OutboxWorker[PgOutboxImmediateWorker<br/>VT consumer loop]
Relay[PgOutboxRelayService.relay]
PollOutbox[PgOutboxPollingWorker<br/>processed_at NULL 폴백]
Consumer -->|offer InboxJob| InboxCh
InboxCh -->|take| InboxWorker
PollInbox -->|폴백 회수| InboxWorker
InboxWorker --> Vendor
InboxWorker -->|TX_B AFTER_COMMIT offer OutboxJob| OutboxCh
OutboxCh -->|take| OutboxWorker
PollOutbox -->|폴백 회수| Relay
OutboxWorker --> Relay
Immediate Worker 와 Polling Worker 가 간발의 차이로 동시에 같은 결제 건에 접근할 경우, 다음 두 가지 안전장치를 통해 중복 승인 (결제)을 완벽하게 차단한다.
- 시간 격리 (Time Separation)
- Polling Worker는 단순히
PENDING상태의 모든 row 를 가져오지 않고, 생성된 지 60초가 지난 (좀비 임계치) 데이터만 조회 - Immediate Worker는 보통 큐에서 바로 꺼내 처리하므로, 정상적인 상황에서는 두 워커의 처리 시점이 물리적으로 겹치지 않음
- Polling Worker는 단순히
- 배타적 선점 락 (
SKIP LOCKED)- 만약 서버 과부하 등으로 인해 큐 처리가 60초 이상 지연되어 두 워커가 정확히 동시에 처리를 시도하는 상황도 대응
- 두 워커 모두 진입 시
SELECT ... WHERE status = 'PENDING' FOR UPDATE SKIP LOCKED쿼리를 실행해 단 1개의 스레드만 락을 획득
- 두 워커 모두 진입 시
- 락을 먼저 획득한 워커는 상태를
IN_PROGRESS로 변경 - 락 획득에 실패한 (늦은) 워커는 대기하지 않고 즉시 해당 건을 무시 (Skip)하며, 이후 다시 시도하더라도 이미 상태가
PENDING이 아니므로 갱신에 실패하여 중복 처리가 원천 차단
- 만약 서버 과부하 등으로 인해 큐 처리가 60초 이상 지연되어 두 워커가 정확히 동시에 처리를 시도하는 상황도 대응
채널은 fast-path, RDB pg_inbox / pg_outbox 를 SoT 로 사용한다.
| 시나리오 | 채널 상태 | 회수 경로 | 회수 지연 |
|---|---|---|---|
| inbox 큐 full (작업 적재 실패) | 신규 항목 reject |
InboxReadyEventHandler 가 적재 실패 로그 → PgInboxPollingWorker 가 다음 tick 에 PENDING row 픽업 |
폴링 주기 1회 (≤ 5s) |
| PENDING 잔존 (60s 임계) | inbox 채널 미적재 또는 take 전 크래시 |
PgInboxPollingWorker 가 received_at < now-60s AND status=PENDING row 회수 → processPending 호출 |
폴링 주기 1회 (≤ 5s) |
| IN_PROGRESS 잔존 (60s 임계) | TX_A 후 Worker 크래시 |
PgInboxPollingWorker 가 updated_at < now-60s AND status=IN_PROGRESS row 회수 → processInProgressZombie 호출 → DuplicateApprovalHandler 보정 |
폴링 주기 1회 (≤ 5s) |
| outbox 큐 full (발행 적재 실패) | 신규 항목 reject |
OutboxReadyEventHandler 가 적재 실패 로그 → PgOutboxPollingWorker 가 다음 tick 에 RDB 에서 직접 픽업 |
폴링 주기 1회 (≤ 2s) |
| JVM 재시작 | 두 채널 모두 휘발 | 재기동 후 두 폴링 Worker가 각 SoT 에서 미처리 row 픽업 | 부팅 + 폴링 1회 |
| Worker 크래시 / 정지 | 채널 유지, take 정지 |
SmartLifecycle 종료 시 drain 시도, drain 못 한 항목은 RDB 에 남아 폴링 폴백이 회수 |
폴링 주기 1회 (≤ 2s) |
| Kafka publish 실패 | take 성공, send 예외 |
processedAt 미기록 → 같은 row 가 다음 폴링에서 재발행 — 발행 자체엔 재시도 횟수 상한이 없어 DLQ 전이 없이 무한 재폴링 |
폴링 주기 1회 (≤ 2s) |
| PG 5xx self-retry | take 후 self-publish |
pg_outbox.available_at 에 backoff 시각 박힘 → 그 시각 이후 폴링이 픽업. attempt 4 도달 시 DLQ 전이 |
available_at 까지 대기 |
| 키 | 기본값 | 의미 |
|---|---|---|
pg.inbox.channel.capacity |
1024 |
PgInboxChannel 의 LinkedBlockingQueue capacity |
pg.inbox.channel.worker-count |
5 |
PgInboxImmediateWorker VT Worker 수 |
pg.scheduler.inbox-polling-worker.fixed-delay-ms |
5000 |
PgInboxPollingWorker @Scheduled 주기 |
pg.scheduler.inbox-polling-worker.batch-size |
10 | 한 번 polling 에서 회수할 row 수 |
pg.scheduler.inbox-polling-worker.pending-timeout-ms |
60000 | PENDING 잔존 임계 — received_at < now - threshold
|
pg.scheduler.inbox-polling-worker.in-progress-timeout-ms |
60000 | IN_PROGRESS 잔존 임계 — updated_at < now - threshold
|
| 키 | 기본값 | 의미 |
|---|---|---|
pg.outbox.channel.capacity |
1024 |
PgOutboxChannel 의 LinkedBlockingQueue capacity |
pg.outbox.channel.worker-count |
1 |
PgOutboxImmediateWorker VT Worker 수 |
pg.scheduler.polling-worker.fixed-delay-ms |
2000 |
PgOutboxPollingWorker @Scheduled 주기 |
pg.scheduler.polling-worker.batch-size |
10 | 한 번 polling 에서 픽업할 row 수 |
두 채널 모두 최대 크기 (capacity)가 작아도 폴링 기반 폴백이 동작하므로 메시지 유실 위험은 없다.
- Outbox 패턴 — 발행 보장 메커니즘 전반과 두 모델 (payment / pg) 비교
- 분산 트레이싱 — OTel context 전파 일반론과 hop 별 메커니즘
- 메시지 전달 보장 + dedupe — at-least-once + 멱등 소비 + DLQ
🏠 Home
- MSA 전환
- 이벤트 드리븐 코레오그래피
- 재고 캐시 보상 회복 — Lua atomic
- Outbox 패턴
- 메시지 전달 보장 + dedupe
- 결제 복구 모델
- PG 결제 확인 흐름
- 분산 트레이싱
Phase 1 · 데이터 정합성 확립
Phase 2 · 결합도 해소 및 자가 복구력
Phase 3 · 운영 가시성 및 안정성
Phase 4 · 데이터 정합성 심화 및 중복 제어
Phase 5 · 비동기 결제 아키텍처 전환
ETC · 설계 유연성
ETC · AI 기반 개발 워크플로우