결제 정산 시스템돈을 다루는 흐름 이라 “한 번 더 결제 / 한 번 덜 정산” 같은 작은 실수가 큰 사고 가 된다. 분산 시스템고전적 어려움 들 — 트랜잭션 경계, 멱등성, 재시도, DLQ, 추적성 — 이 전부 한 자리 에 등장한다.

본 글은 내가 만든 정산 시스템핵심 동작 원리 를 정리한다. 왜 그렇게 설계했는지이유 까지 함께. 특정 회사 구현이 아니라 교과서적 설계 패턴이 실전 코드 안에서 어떻게 굴러가는지 를 보여주는 교육 목적.

본 글의 구체적인 IP·도메인·내부 식별자는 모두 제거됨. 패턴과 흐름 만 다룬다.


TL;DR

핵심 내용
아키텍처 헥사고날 MSA (gateway / order / settlement) + 이벤트 드리븐
결제→정산 전파 Transactional Outbox + Kafka — DB 와 메시지의 원자성
멱등성 3중 방어 (UNIQUE outbox + Kafka idempotence + Consumer PK + 도메인 UNIQUE)
실패 처리 10회 재시도 → DLQ → 운영자 콘솔 (/admin/outbox/dlq)
추적성 traceparent 헤더 모든 경계 전파 → Tempo/Grafana 단일 trace
PG 대사 매일 PG CSV 받아 5종 분류 (MATCHED / ROUNDING_DIFF / AMOUNT_MISMATCH / MISSING_*)

1. 전체 구조 — 2개 Bounded Context + 1 Gateway

┌─────────────┐
│   Client    │  (Web / Mobile)
└──────┬──────┘
       │
       ▼
┌──────────────────────────┐
│  gateway-service         │ Spring Cloud Gateway
│  /api/* 라우팅            │
└──────┬───────────────────┘
       │
   ┌───┴────────────────┐
   ▼                    ▼
┌──────────────┐  ┌────────────────────┐
│ order-service│  │ settlement-service │
│              │  │                    │
│ - user       │  │ - settlement       │
│ - order      │  │ - reconciliation   │
│ - payment    │  │ - cashflow report  │
│ - product    │  │ - daily batch      │
│ - coupon     │  │ - ES indexing      │
└──────┬───────┘  └────────────────────┘
       │                ▲
       │ Outbox + Kafka │
       └────────────────┘
       │
       ▼
┌──────────────────────────┐
│   PostgreSQL 17          │ 공유 DB (스키마는 도메인 분리)
└──────────────────────────┘
              ▲
              │
┌──────────────────────────┐
│   Elasticsearch 8.17     │ 정산 검색 인덱스
└──────────────────────────┘

도메인 분리의 이유

  • Commerce (주문/결제) 과 Settlement (정산) 은 변경 주기 가 다름
    • Commerce: 프로모션·UX 변경 으로 주마다 배포
    • Settlement: 법규·회계 정책 따라 분기마다 변경
  • 서로 다른 SLA: 결제는 실시간, 정산은 분 단위 지연 OK
  • 팀이 분리 될 가능성 (현재 1인이지만, 회사 환경 가정 시)

왜 Gateway 를 따로?

  • 인증 을 한 곳에 (JWT 검증)
  • Rate limiting 한 곳에
  • 서비스 추가클라이언트 변경 없이 라우팅만 추가

2. 핵심 흐름 — 결제 → 정산 (Transactional Outbox 패턴)

분산 시스템에서 가장 어려운 문제: DB 트랜잭션 과 메시지 발행을 *동시에 보장* 하는 것. 둘 다 동시에 성공·실패해야 데이터 일관성 이 유지된다.

잘못된 방식 ❌

@Transactional
public void capturePayment(...) {
    paymentRepo.save(payment);              // DB 저장 OK
    kafkaTemplate.send("payment", event);   // ← Kafka 실패하면?
    // 트랜잭션 롤백되지만 *Kafka 는 이미 발행됨* (또는 그 반대)
}

DB 와 Kafka 는 서로 다른 시스템2PC (Two-Phase Commit) 없이는 원자적 보장 불가. 그리고 2PC 는 운영 부담이 큰 안티패턴.

Transactional Outbox 패턴 ✅

1. 비즈니스 트랜잭션 안에서:
   - 도메인 변경 (UPDATE payments)
   - outbox_events 테이블에 INSERT (이벤트 임시 저장)
   ↓ 한 번에 커밋 — DB 만 신경 쓰면 됨
   
2. 별도 워커 (OutboxPublisher) 가 *주기적으로 (2초)*:
   - SELECT pending outbox_events
   - Kafka 에 publish
   - 성공 시 outbox_events 상태 = PUBLISHED

핵심: DB 트랜잭션 만으로 원자성 보장. Kafka 는 나중에 발행 되지만 반드시 발행됨 (at-least-once).

시퀀스 (가독성 정리)

[ User ]
   │ POST /payments/{id}/capture
   ▼
[ PaymentController ]
   │
   ▼
[ CapturePaymentUseCase ]
   │ ① PgRouter.capture(...) → 외부 PG 호출
   │ ② 트랜잭션 시작
   │   UPDATE payments SET status=CAPTURED
   │   INSERT outbox_events (traceparent 함께)
   │ ③ 커밋
   ▼
[ User ] ← 200 OK   (사용자 응답 끝)
═══════════════════════════════ 비동기 경계 ═══════════════════════════════
[ OutboxPublisher (2초 폴링) ]
   │ ④ SELECT pending outbox_events
   │ ⑤ Kafka topic 'payment.captured' 에 publish (traceparent header 복원)
   │ ⑥ UPDATE outbox_events SET status=PUBLISHED
   ▼
[ PaymentEventKafkaConsumer (settlement-service) ]
   │ ⑦ 멱등 체크 (processed_events PK)
   │ ⑧ createSettlementFromPayment(...)
   │ ⑨ INSERT settlements (commission_rate 스냅샷)

결과

  • 사용자는 2초 안에 응답 받음 (Kafka 발행 대기 X)
  • 정산 INSERT 는 별도 워커 가 처리
  • 결제 성공했는데 정산 누락 → 절대 없음 (outbox 가 보장)
  • Kafka 일시 다운 → outbox 가 재시도

3. Triple Idempotency — 3중 멱등성 방어

분산 시스템의 현실: 메시지는 중복 전달될 수 있다. 네트워크 재시도, Consumer 재기동, Kafka rebalance 등 수많은 시나리오 에서.

같은 결제에 2번 정산 INSERT 가 들어가면 돈이 두 배 가 된다. 절대 안 됨.

4단 방어 체계

단계 위치 메커니즘 실패 시 동작
L1 Producer outbox_events.event_id UUID UNIQUE DB 제약 위반 → 비즈니스 트랜잭션 롤백
L2 Kafka Producer enable.idempotence=true Kafka 내부 sequence number 로 중복 record 방지
L3 Consumer processed_events(consumer_group, event_id) PK 같은 이벤트 재배달 시 즉시 ACK + 본문 처리 스킵
L4 Domain settlements.payment_id UNIQUE 위 3단 다 뚫어도 스키마가 최종 방어

왜 4중인가 — 한 단으론 부족?

각 단의 실패 시나리오:

  • L1 만 있으면 → outbox 는 한 번이지만 Publisher 가 2번 publish 가능
  • L2 만 있으면 → 다른 Producer 인스턴스 가 같은 메시지 발행 가능
  • L3 만 있으면 → Consumer 가 처리 중 죽음 + 다시 살아남 → 중복 처리 가능
  • 각 단이 *서로 다른 종류의 실패 를 막음*

결론: 한 단이 뚫려도 다음 단이 막는다. 4단 다 뚫리려면 4가지 동시 실패 — 사실상 불가능.

이게 defense in depth 의 교과서.


4. DLQ — 실패한 메시지의 종착역

Outbox 발행이 10번 재시도해도 실패 하면? 영원히 재시도 루프에 빠지면 시스템 자원 고갈.

DLQ (Dead Letter Queue) 분기

[ OutboxPublisher 폴링 ]
   │
   ▼
[ Kafka publish 시도 (실패) ]
   │
   ├─ retryCount < 10 → 다음 폴링에 재시도
   │
   └─ retryCount = 10 →
        ┌─ Kafka DLQ topic 으로 publish (with lastError header)
        ├─ UPDATE outbox_events SET status=FAILED
        └─ 운영자 알람 (Slack/Telegram)
   
[ 운영자 ]
   │ GET /admin/outbox/dlq
   ▼
[ failed events list 조회 ]
   │
   ├─ POST /dlq/{id}/retry   → 다시 발행 시도
   └─ POST /dlq/{id}/skip    → 영구 스킵 (수동 보정 결정)

핵심 — *DLQ 가 *운영의 안전판**

  • 자동 시스템은 언제든 실패할 수 있다 — 외부 PG 다운, Kafka 다운, 네트워크 단절
  • DLQ 없으면 실패가 누적되어 어디론가 사라짐
  • DLQ 있으면 운영자가 명시적으로 결정 (재시도 vs 스킵)

5. 분산 트레이싱 — 경계 너머로 trace 가 따라간다

비동기 시스템의 디버깅 지옥: “사용자 결제 요청이 어디서 멈춘 거지?”

해답: traceparent 전파

W3C TraceContext 표준의 traceparent 헤더가 모든 경계 를 통과:

[ HTTP request ]      traceparent: 00-abc123...-span1-01
       │ ← span 시작
       ▼
[ Spring MVC ]         traceparent: 00-abc123...-span2-01  (자식 span)
       │
       ▼
[ DB INSERT outbox ]   traceparent 컬럼에 *값 저장* (DB 안에 trace 정보 보존)
       │
═══ 비동기 경계 ═══
       │
       ▼
[ OutboxPublisher ]    DB 에서 *traceparent 복원* → Kafka header 에 첨부
       │
       ▼
[ Kafka deliver ]      header: traceparent: 00-abc123...-span3-01
       │
       ▼
[ Consumer ]           spring-kafka 자동 instrumentation → trace 합류
       │
       ▼
[ Settlement INSERT ]  traceparent: 00-abc123...-span4-01

결과 — Grafana Tempo 에서

한 결제 요청시작부터 정산 INSERT 까지가 단일 trace 로 보임.

  • 어느 span 이 얼마나 오래 걸렸나
  • 어느 경계에서 에러 발생 했나
  • 평균 얼마나 지연 되나 (E2E latency)

비동기 시스템의 디버깅 지옥클릭 두 번 으로 해결.


6. PG 정산파일 대사 (Reconciliation)

매일 PG 사가 정산 CSV 파일 을 보내준다. 우리가 내부에 기록한 결제 내역PG 가 알고 있는 내역일치하는지 확인해야 한다.

차이 나는 케이스 — 자주 발생:

  • 우리 DB 에서 결제 성공 처리 직전 네트워크 단절 → PG 는 성공, 우리는 실패 기록
  • Rounding 처리 차이 (소수점)
  • 동일 거래 중복 발생

5종 분류 자동화

[ POST /admin/pg-reconciliation/files (CSV 업로드) ]
   │
   ▼
[ ReconcilePgFileService ]
   │ ① CsvPgFileParser → List<PgTransactionRow>
   │ ② InternalPaymentsJdbcAdapter → List<InternalPaymentRow>
   │ ③ PgReconciliationMatcher.match(pgRows, internalRows)
   │      ↓
   │   ┌──────────────────────────────────────────────────┐
   │   │  5종 분류 (도메인 순수 로직)                       │
   │   ├──────────────────────────────────────────────────┤
   │   │ MATCHED          — 양쪽 동일                        │
   │   │ ROUNDING_DIFF    — 차이 < 1원, *자동 보정*          │
   │   │ AMOUNT_MISMATCH  — 차이 ≥ 1원, *검토 필요*           │
   │   │ MISSING_INTERNAL — PG 에만 있음 ⚠️ 위험             │
   │   │ MISSING_PG       — 내부에만 있음                    │
   │   │ DUPLICATE        — PG 에 중복 발생                   │
   │   └──────────────────────────────────────────────────┘
   ▼
[ pg_reconciliation_runs / discrepancies 테이블 INSERT ]
   │
   ▼
[ 운영자 대시보드 — 차이만 검토 ]

설계 핵심

  • MatcherSpring 의존성 0순수 도메인 로직, DB / 외부 mock 없이 단위 테스트 가능
  • 자동 보정작은 차이 (1원 미만) 만 — 큰 차이운영자 검토
  • MISSING_INTERNAL위험 신호PG 는 결제 알고 있는데 우리는 모름 = 돈 받았다가 사용자에게 안 줌 위험

7. 설계 결정 — 왜 이렇게 했나

7.1 왜 Kafka 인가 Application Event 안 쓰고?

Spring 의 ApplicationEvent같은 프로세스 내 에서만 동작. 서비스 분리 의미 무색. Kafka 는 서비스 경계 너머 까지 전달 + 재처리·DLQ·trace 까지 가능.

7.2 왜 Hexagonal Architecture 인가?

  • 외부 PG (TOSS, KCP, NICE, INICIS) 가 바뀌어도 도메인 영향 없음
  • 테스트 시 Port 만 mock — Spring context 불필요
  • settlement-service 가 order-service 코드 import 없이 JDBC 로 읽기 전용 payments 조회 → 모듈 경계 보존

7.3 왜 PostgreSQL 17 공유 DB 인가?

  • 운영 단순성: 2개 DB 인스턴스 관리 vs 1개
  • 스키마 분리 (commerce_, settlement_) 로 논리적 격리
  • cross-domain JOIN 절대 금지 (ArchUnit 으로 강제)
  • 장기 확장 시 물리 분리 가능 (event sourcing 또는 CDC 로)

7.4 왜 Elasticsearch 별도?

  • 정산 검색복합 조건 (날짜 + 가맹점 + 금액 범위 + 상태) — PostgreSQL 인덱스로는 cardinality 폭발
  • ES 는 역색인 + 점수수억 row밀리초 검색
  • 일별 배치로 동기화 — 실시간 필요성 낮음

8. 운영 패턴 — Triple Idempotency 가 일상에서 동작

실제 운영에서 이런 시나리오 들자주 발생:

시나리오 어느 단이 막는가
사용자가 결제 버튼 2번 클릭 L1 (outbox UNIQUE) — 비즈니스 트랜잭션 롤백
Kafka producer 가 네트워크 timeout 후 재시도 L2 (idempotence) — Kafka 가 중복 record 거절
Consumer 가 처리 중 OOM kill → restart L3 (processed_events PK) — 같은 이벤트 무시
L1·L2·L3 모두 코드 버그 L4 (settlements.payment_id UNIQUE) — DB 가 INSERT 거절

매주 모니터링 대시보드 에서 각 단에서 막힌 횟수 를 추적. 한 단이 비정상적으로 자주 발동하면 상위 단 문제 진단 신호.


9. 결론 — 이 시스템에서 배운 5가지

1. 분산 시스템에서 DB 트랜잭션은 신 이다

Outbox 가 DB 트랜잭션만으로 Kafka 발행을 원자화. 2PC 같은 복잡한 것 필요 없음.

2. 멱등성은 한 단으론 부족 하다

3~4중 방어가 교과서적 권장. 한 단만 있다면 언젠가 사고 난다.

3. DLQ 없는 시스템은 *모래 위 성

자동 시스템은 반드시 실패 한다. DLQ + 운영자 콘솔이 유일한 안전판.

4. 분산 트레이싱은 *후행 투자가 아니라 *선행 투자

처음부터 traceparent 전파 안 해두면 나중에 retrofit 비용 10배.

5. 도메인 순수성은 테스트 속도 와 직결

PgReconciliationMatcher 같은 Spring 없는 순수 도메인밀리초 단위 단위 테스트 + 수백 개 시나리오. 이게 진짜 자신감 있는 리팩토링 의 기반.


마무리 — 교과서가 *살아있는 시스템*

GoF·DDD·헥사고날·Outbox·CQRS — 책에서 본 패턴들결제 정산 한 도메인 안에서 다 같이 굴러갈 수 있다. 그리고 그렇게 제대로 굴러가게 만드는 게 시니어 백엔드 엔지니어의 일.

다음 글에선 Outbox 패턴 의 *깊은 함정 — at-least-once vs exactly-once 의 현실적 비용, 그리고 실제 코드에서 그 비용을 어떻게 줄였는지 를 정리할 예정.

분산 시스템 디버깅 의 마지막 답: traceparent + outbox + DLQ + idempotency. 이 4가지 없이는 *어떤 시스템도 운영할 수 없다.*