← 모든 글
분산시스템

결제와 정산을 분리하면서 생긴 데이터 정합성 문제

결제와 정산을 분리한 뒤 발생한 중복 이벤트와 재처리 문제를 Kafka, 멱등성, 최종적 일관성 기준으로 풀어낸 기록이다.

결제와 정산을 서로 다른 서비스와 데이터베이스로 나누자 하나의 트랜잭션으로 숨겨져 있던 문제가 드러났다. 결제 승인은 성공했는데 정산 대상이 늦게 생기거나, 같은 이벤트가 다시 들어와 정산이 중복으로 만들어질 수 있었다.

Payment DB와 Settlement DB 사이를 Kafka로 연결했지만 Kafka를 붙였다는 사실만으로 정합성이 생기지는 않았다. 발행, 소비, 재처리, 대사 기준을 함께 정해야 했다.

분산 트랜잭션 대신 복구 가능한 상태를 만들었다

sequenceDiagram
  participant Payment as 결제 서비스
  participant PaymentDB as Payment DB
  participant Relay as Outbox Relay
  participant Kafka
  participant Settlement as 정산 서비스
  participant SettlementDB as Settlement DB
  Payment->>PaymentDB: 승인 상태와 outbox를 함께 저장
  PaymentDB-->>Payment: 로컬 트랜잭션 커밋
  Relay->>PaymentDB: 미발행 outbox 조회
  PaymentDB-->>Relay: payment-approved 이벤트
  Relay->>Kafka: 같은 eventId로 발행
  Kafka-->>Settlement: 승인 이벤트 전달
  Settlement->>SettlementDB: 처리 이력과 정산 대상을 함께 저장
  SettlementDB-->>Settlement: 로컬 트랜잭션 커밋

결제와 정산을 동시에 커밋하는 대신 결제가 승인되면 정산에 전달할 사건이 반드시 남도록 했다. 정산은 이벤트를 소비해 자기 데이터베이스에 결과를 만들었다. 두 데이터베이스가 같은 순간에 바뀌지는 않았지만, 어느 단계에서 멈췄는지 확인하고 다시 처리할 수 있게 했다.

먼저 지켜야 할 불변식을 세 문장으로 정했다

기술 선택 전에 돈의 상태가 만족해야 할 조건을 적었다. 이 문장이 없으면 Kafka 전송 성공과 비즈니스 정합성을 같은 것으로 착각하기 쉬웠다.

  1. 승인된 결제는 누락되지 않고 정산 판단 대상이 된다.
  2. 같은 승인 사건이 여러 번 와도 정산 금액은 한 번만 반영된다.
  3. 취소와 변경은 허용된 순서와 금액 범위 안에서만 반영된다.

각 불변식에 저장소 제약, 이벤트 키, 모니터링, 대사 중 어떤 장치가 책임지는지 연결했다. ‘Kafka가 보장한다’처럼 경계를 넘는 설명은 사용하지 않았다.

불변식주요 방어선마지막 확인
누락 방지결제와 outbox의 로컬 트랜잭션미발행 건 조회와 대사
중복 방지이벤트 ID 유일성정산 비즈니스 키 제약
순서·금액파티션 키와 상태 전이금액 합계 대사

결제 상태와 발행 의도를 같은 트랜잭션에 남겼다

결제 데이터 커밋 뒤 Kafka 발행 전에 프로세스가 멈추면 승인은 남고 이벤트는 사라질 수 있었다. 반대로 Kafka 발행 뒤 DB가 롤백되면 존재하지 않는 승인을 정산이 받을 수 있었다. 두 시스템을 하나의 트랜잭션으로 묶는 대신 outbox를 사용했다.

SQL
BEGIN
  UPDATE payment SET status = 'APPROVED' ...
  INSERT INTO event_outbox (
      event_id, aggregate_id, event_type, payload, status
  ) VALUES (...)
COMMIT

outbox 행은 결제와 같은 로컬 트랜잭션에 저장했다. 릴레이는 아직 발행되지 않은 행을 읽어 Kafka에 보내고 발행 상태를 갱신했다. 발행과 상태 갱신 사이에 멈출 수 있으므로 중복 발행은 허용했다.

릴레이가 행을 집는 시점은 결제를 처리하던 그 순간이 아니다. 요청 맥락은 이미 사라졌고, 남은 것은 행에 적힌 값뿐이다. 그래서 어느 파티션으로 보낼지도 행 안에서 읽어야 했다. aggregate_id에 결제 ID가 들어가 있어 그 값을 그대로 메시지 키로 썼다. 이 값을 비워 두면 릴레이가 임의로 분배하고, 같은 결제의 승인과 취소가 다른 파티션으로 흩어진다.

발행 방식으로 데이터베이스 변경 로그를 그대로 흘려보내는 쪽도 검토했다. 애플리케이션이 발행을 신경 쓰지 않아도 되는 점이 매력적이었지만, 그 파이프라인 자체를 운영해야 하는 부담이 우리 규모에 비해 컸다. 폴링은 단순하고 느리지만 고장 났을 때 무엇을 봐야 하는지가 분명했다. 서비스 수가 더 늘면 다시 볼 결정으로 남겼다.

outbox가 보장한 것은 정확히 한 번 발행이 아니었다. 결제가 승인 상태로 남았다면 발행할 의도도 함께 남아 다시 시도할 수 있다는 것이었다. 누락을 복구 가능한 데이터 상태로 바꿨다.

이벤트 식별자와 비즈니스 키를 구분했다

이벤트 ID는 전달되는 사건 하나를 식별했고, 결제 ID는 여러 사건이 공유하는 비즈니스 대상을 식별했다. 승인과 취소는 다른 이벤트 ID를 갖지만 같은 결제 ID를 사용했다.

paymentId: 결제 한 건의 상태 흐름을 묶음
eventId: 승인·취소 등 개별 사건과 재전달을 식별
version: 같은 결제 안에서 사건의 순서를 확인

재발행할 때 이벤트 ID를 새로 만들면 소비자는 같은 사건을 새 입력으로 봤다. 전송 재시도와 운영 재처리에서도 원본 이벤트 ID를 유지했다. 비즈니스에서 실제 새 사건이 생긴 경우에만 새 ID를 만들었다.

중복 이벤트를 예외가 아니라 정상 입력으로 봤다

메시지는 재전달될 수 있었다. 컨슈머가 처리한 뒤 커밋 전에 멈추면 같은 이벤트가 다시 들어왔다. 이벤트 ID를 기준으로 처리 이력을 남기고 정산 데이터 변경과 같은 트랜잭션에서 중복을 확인했다.

사전 조회만 믿지 않고 데이터베이스 유니크 제약을 마지막 방어선으로 뒀다. 두 컨슈머가 동시에 같은 이벤트를 처리해도 저장소가 하나만 받아들이게 했다.

중복 방어를 두 층으로 나눈 것도 같은 생각이었다. 결제 재요청은 Redis에 멱등 키를 두어 1차로 걸러 냈고, 그걸 통과한 요청은 거래 키의 유일 제약이 최종적으로 막았다. 앞의 것은 대부분의 중복을 싸게 걸러 내는 장치이고, 뒤의 것은 정확성을 책임지는 장치다. 앞의 것만 두면 Redis가 비었을 때 뚫리고, 뒤의 것만 두면 매번 저장까지 가서 충돌한다.

SQL
BEGIN
  INSERT INTO processed_event (event_id) VALUES (:eventId)
  INSERT INTO settlement_target (payment_id, amount, status)
      VALUES (:paymentId, :amount, 'PENDING')
COMMIT

처리 이력과 정산 데이터는 같은 트랜잭션에 넣었다. 이력만 저장되고 비즈니스 반영이 실패하면 재전달을 중복으로 판단해 영원히 건너뛸 수 있기 때문이다. 유일성 충돌은 이미 처리된 사건으로 해석하고 현재 결과를 확인했다.

이벤트 ID 제약만으로 모든 중복을 막지는 못했다. 생산자 버그로 같은 결제 승인이 서로 다른 이벤트 ID로 만들어질 수 있었다. 정산 대상에도 결제와 정산 규칙에 맞는 비즈니스 유일성을 두어 마지막 방어선을 만들었다.

정산이 다시 발행하는 이벤트에도 같은 문제가 있었다

발행 측은 outbox로 막아 놓고 반대편을 한동안 비워 뒀다. 정산은 이벤트를 소비해 정산 대상을 만들고, 데이터 생성이 끝나면 결제에 완료를 알리는 이벤트를 다시 발행한다. 이 구간이 결제 쪽과 똑같은 모양의 문제를 갖고 있었다. 오프셋을 먼저 커밋하고 발행에 실패하면 정산은 처리를 마쳤는데 결제는 그 사실을 듣지 못한다. 결제는 승인된 채로, 정산은 반영된 채로, 둘을 잇는 사건만 사라진다.

정산 대상을 저장하고 나서 같은 흐름 안에서 완료 이벤트를 발행했다. 오프셋 커밋은 그 뒤에 일어난다. 기본 동작이 이미 안전한 쪽이었고, 그것을 바꾸지 않기로 한 것이 선택이었다. 발행 뒤 커밋 전에 멈추면 같은 완료 이벤트가 두 번 나가지만, 중복은 이벤트 ID로 이미 걸러지고 있었다. 누락과 중복 중 하나를 받아야 한다면 중복이었다.

sequenceDiagram
  participant Kafka
  participant Settlement as 정산 서비스
  participant SettlementDB as Settlement DB
  Kafka-->>Settlement: 승인 이벤트 전달
  Settlement->>SettlementDB: 처리 이력과 정산 대상 저장
  Settlement->>Kafka: 정산 완료 이벤트 발행
  Settlement->>Kafka: 오프셋 커밋
  Note over Settlement,Kafka: 발행과 커밋 사이에 멈추면 재발행

그래서 발행을 listener 밖으로 빼는 변경은 하지 않기로 했다. 별도 스레드나 커밋 이후 시점으로 옮기면 순서가 조용히 뒤집히고, 그때 생기는 것은 중복이 아니라 누락이다. 이 순서에 이름을 붙여 두지 않으면 나중에 누가 성능을 이유로 되돌려도 아무도 알아채지 못한다.

순서가 필요한 기준을 정했다

승인과 취소 이벤트의 순서가 뒤바뀌면 정산 금액이 틀어질 수 있었다. 같은 결제의 이벤트가 같은 파티션으로 가도록 결제 ID를 키로 사용했다. 정상 순서를 벗어난 상태 전이는 무조건 적용하지 않고 보류했다.

키를 맞추는 것과 순서를 지키는 것이 같은 일이라고 생각했다가 한 번 헛짚었다. 파티션은 도착한 대로 줄을 세울 뿐이다. 거꾸로 도착하면 거꾸로 쌓인다. 같은 파티션에 모으는 것은 순서를 지킬 대상을 만드는 일이지, 순서를 만들어 주는 일이 아니었다.

서로 다른 파티션에 있으면 애초에 처리하는 컨슈머가 다르다. 둘은 서로를 기다리지 않고 각자 속도로 읽는다. 한쪽 파티션에 앞서 밀린 메시지가 있으면 뒤늦게 발행된 취소가 먼저 반영되고, 그 뒤에 도착한 승인이 지워진 정산 대상을 되살린다. 같은 파티션에 넣으면 이 경우가 사라진다. 한 컨슈머가 한 줄에서 차례로 꺼내니 다른 순서로 처리할 방법이 없다.

그러면 그 줄에 올바른 순서로 넣는 일이 따로 남는다. 릴레이가 outbox를 읽는 순서가 첫 관문이었다. 조건만 걸고 정렬 없이 읽으면 같은 결제의 승인과 취소가 뒤바뀐 채로 나갈 수 있어서, 행이 만들어진 순서대로 읽어 보냈다. 발행 단계에서 순서가 뒤집히는 경로는 실패한 메시지 하나가 파티션을 계속 막았다에 따로 적었다.

결국 순서는 한 군데서 지켜지지 않았다. 층을 나누고 각 층이 무엇까지 책임지는지 적어 뒀다. 어느 한 층을 다른 층이 대신해 줄 것이라고 기대하지 않기 위해서였다.

층책임실패하면
파티션 키같은 결제를 한 파티션에 모음지킬 순서 자체가 없어짐
발행 순서그 파티션에 만들어진 순서대로 넣음거꾸로 쌓임
상태 검증뒤집힌 이벤트를 소비 측에서 거절오래된 값이 최신 상태를 덮음

순서가 틀렸다는 이유로 계속 재시도하면 같은 메시지가 파티션을 막았다. 재시도로 해결할 수 있는 실패와 데이터 확인이 필요한 실패를 나눴다.

상태 전이와 이벤트 버전을 함께 확인했다

같은 파티션은 정상 발행 흐름의 순서를 보장하지만 운영 재처리와 다른 토픽을 거치면 순서가 달라질 수 있었다. 정산 데이터에 마지막 반영 버전을 남기고 오래된 이벤트가 현재 상태를 되돌리지 못하게 했다.

SQL
UPDATE settlement_target
   SET status = :nextStatus,
       event_version = :version
 WHERE payment_id = :paymentId
   AND event_version < :version;

버전이 건너뛴 경우에는 무조건 적용하지 않았다. 누락된 사건이 있는지 확인할 수 있도록 보류 상태와 알람을 남겼다. 이벤트 순서가 완벽할 것이라는 기대보다 잘못된 순서를 데이터가 거절하게 했다.

이벤트 계약의 변경도 정합성 문제로 봤다

생산자가 필드 의미를 바꾸면 메시지는 정상 전달돼도 소비 결과가 틀릴 수 있었다. 필드 추가와 의미 변경을 구분하고, 소비자가 지원하는 버전을 명시했다.

변경처리 원칙
선택 필드 추가이전 소비자가 무시해도 되는지 확인
필수 의미 변경새 버전 또는 새 이벤트로 분리
코드값 추가알 수 없는 값의 처리 정책 준비
필드 제거모든 소비자의 의존 제거 뒤 진행

계약 테스트는 직렬화 성공만 확인하지 않았다. 이전 버전의 메시지를 새 소비자가 처리하고, 새 메시지의 선택 필드를 이전 소비자가 안전하게 무시할 수 있는지 확인했다.

재처리는 처음부터 운영 기능으로 만들었다

실패 메시지를 다시 넣는 것만으로는 부족했다. 누가 어떤 이유로 재처리했고, 이전 처리 결과와 무엇이 달라졌는지 남겨야 했다. 이벤트 ID는 재처리해도 바꾸지 않았고, 중복 방지 기준도 그대로 유지했다.

재처리 전에 현재 결제와 정산 상태를 조회했다. 이미 정상 반영된 사건은 다시 넣지 않았고, 코드나 데이터가 수정되지 않은 영구 실패도 반복하지 않았다. 재처리는 버튼이 아니라 판단과 검증을 포함한 운영 절차였다.

flowchart TD
  A["재처리 요청"] --> B["원본 이벤트와 실패 원인 확인"]
  B --> C["현재 비즈니스 상태 확인"]
  C --> D["수정 조건 충족 여부 확인"]
  D --> E["같은 eventId로 재처리"]
  E --> F["정산 결과와 처리 이력 확인"]

최종적 일관성을 확인하는 대사

이벤트가 결국 도착할 것이라는 기대만 두지 않았다. 결제 승인 수와 정산 대상 수, 금액 합계를 주기적으로 비교했다. 차이가 생기면 누락인지 지연인지 중복인지 분류할 수 있게 했다.

건수만 같으면 서로 다른 거래가 빠지고 중복돼도 놓칠 수 있었다. 비즈니스 키와 금액을 함께 비교하고, 지연 허용 시간을 지난 차이만 조사 대상으로 분리했다. 대사 결과도 누가 언제 조치했는지 이력을 남겼다.

정합성을 운영 지표로 드러냈다

Kafka lag만 보면 메시지가 쌓였다는 사실은 알 수 있지만 어떤 돈의 상태가 늦어지는지는 알기 어려웠다. outbox 대기, 이벤트 처리 결과, 중복 건너뜀, 보류된 상태 전이, 대사 차이를 함께 봤다.

지표알려주는 위험
미발행 outbox승인은 남았지만 사건 전달이 늦음
소비 실패·DLQ정산 반영이 진행되지 않음
중복 처리재전달 또는 생산자 중복 신호
버전 불일치누락이나 순서 역전 가능성
대사 차이최종 비즈니스 결과가 다름

각 지표에서 대표 이벤트와 결제 상태로 이동할 수 있게 식별자를 연결했다. 정합성은 장애가 발생한 뒤 SQL로 찾는 숨은 문제가 아니라 평소에 관찰하는 운영 상태가 됐다.

최종적 일관성에도 한계 시간을 뒀다

‘언젠가 맞아진다’는 말은 운영 기준이 아니었다. 정산 업무가 기다릴 수 있는 범위와 대사가 조사할 시점을 정했다. 대사를 어떻게 만들었는지는 최종적 일관성을 검증하는 결제·정산 대사에 이어서 적었다. 허용 범위를 넘은 지연은 최종적 일관성이라는 이름으로 정상 처리하지 않았다.

모든 분산 실패를 자동 복구하지는 못했다. 계약에 없는 상태, 운영 보정으로 원장이 바뀐 경우, 외부 결과가 불명확한 거래는 사람이 확인해야 했다. 대신 자동 처리와 수동 판단의 경계를 명확히 하고 필요한 데이터와 절차를 준비했다.

결제와 정산을 나누면서 정합성은 데이터베이스가 자동으로 주는 성질이 아니라 운영 절차까지 포함해 만들어야 하는 약속이 됐다. Kafka는 전달 경로였고, 멱등성·재처리·대사가 실제 안전장치였다.