Skip to content

outbox channel dispatch

hyoguoo edited this page Aug 10, 2026 · 8 revisions

Outbox 채널 디스패치 — pg-service 패턴 B 상세

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
Loading

큐 워커와 폴링 워커의 중복 처리 방지 (동시성 제어)

Immediate Worker 와 Polling Worker 가 간발의 차이로 동시에 같은 결제 건에 접근할 경우, 다음 두 가지 안전장치를 통해 중복 승인 (결제)을 완벽하게 차단한다.

  1. 시간 격리 (Time Separation)
    • Polling Worker는 단순히 PENDING 상태의 모든 row 를 가져오지 않고, 생성된 지 60초가 지난 (좀비 임계치) 데이터만 조회
    • Immediate Worker는 보통 큐에서 바로 꺼내 처리하므로, 정상적인 상황에서는 두 워커의 처리 시점이 물리적으로 겹치지 않음
  2. 배타적 선점 락 (SKIP LOCKED)
    • 만약 서버 과부하 등으로 인해 큐 처리가 60초 이상 지연되어 두 워커가 정확히 동시에 처리를 시도하는 상황도 대응
      • 두 워커 모두 진입 시 SELECT ... WHERE status = 'PENDING' FOR UPDATE SKIP LOCKED 쿼리를 실행해 단 1개의 스레드만 락을 획득
    • 락을 먼저 획득한 워커는 상태를 IN_PROGRESS로 변경
    • 락 획득에 실패한 (늦은) 워커는 대기하지 않고 즉시 해당 건을 무시 (Skip)하며, 이후 다시 시도하더라도 이미 상태가 PENDING이 아니므로 갱신에 실패하여 중복 처리가 원천 차단

장애 / 폴백 시나리오

채널은 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 까지 대기

관련 설정

작업 큐 (inbox 채널)

키 기본값 의미
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

발행 큐 (outbox 채널)

키 기본값 의미
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)가 작아도 폴링 기반 폴백이 동작하므로 메시지 유실 위험은 없다.


관련 위키

Clone this wiki locally