결제와 정산을 분리하면서 생긴 데이터 정합성 문제
결제와 정산을 분리한 뒤 발생한 중복 이벤트와 재처리 문제를 Kafka, 멱등성, 최종적 일관성 기준으로 풀어낸 기록이다.
결제와 정산을 서로 다른 서비스와 데이터베이스로 나누자 하나의 트랜잭션으로 숨겨져 있던 문제가 드러났다. 결제 승인은 성공했는데 정산 대상이 늦게 생기거나, 같은 이벤트가 다시 들어와 정산이 중복으로 만들어질 수 있었다.
Payment DB와 Settlement DB 사이를 Kafka로 연결했지만 Kafka를 붙였다는 사실만으로 정합성이 생기지는 않았다. 발행, 소비, 재처리, 대사 기준을 함께 정해야 했다.
분산 트랜잭션 대신 복구 가능한 상태를 만들었다
Payment DB
→ payment-approved event
→ Kafka
→ Settlement DB
결제와 정산을 동시에 커밋하는 대신 결제가 승인되면 정산에 전달할 사건이 반드시 남도록 했다. 정산은 이벤트를 소비해 자기 데이터베이스에 결과를 만들었다. 두 데이터베이스가 같은 순간에 바뀌지는 않았지만, 어느 단계에서 멈췄는지 확인하고 다시 처리할 수 있게 했다.
먼저 지켜야 할 불변식을 세 문장으로 정했다
기술 선택 전에 돈의 상태가 만족해야 할 조건을 적었다. 이 문장이 없으면 Kafka 전송 성공과 비즈니스 정합성을 같은 것으로 착각하기 쉬웠다.
- 승인된 결제는 누락되지 않고 정산 판단 대상이 된다.
- 같은 승인 사건이 여러 번 와도 정산 금액은 한 번만 반영된다.
- 취소와 변경은 허용된 순서와 금액 범위 안에서만 반영된다.
각 불변식에 저장소 제약, 이벤트 키, 모니터링, 대사 중 어떤 장치가 책임지는지 연결했다. ‘Kafka가 보장한다’처럼 경계를 넘는 설명은 사용하지 않았다.
| 불변식 | 주요 방어선 | 마지막 확인 |
|---|---|---|
| 누락 방지 | 결제와 outbox의 로컬 트랜잭션 | 미발행 건 조회와 대사 |
| 중복 방지 | 이벤트 ID 유일성 | 정산 비즈니스 키 제약 |
| 순서·금액 | 파티션 키와 상태 전이 | 금액 합계 대사 |
결제 상태와 발행 의도를 같은 트랜잭션에 남겼다
결제 데이터 커밋 뒤 Kafka 발행 전에 프로세스가 멈추면 승인은 남고 이벤트는 사라질 수 있었다. 반대로 Kafka 발행 뒤 DB가 롤백되면 존재하지 않는 승인을 정산이 받을 수 있었다. 두 시스템을 하나의 트랜잭션으로 묶는 대신 outbox를 사용했다.
BEGIN
UPDATE payment SET status = 'APPROVED' ...
INSERT INTO event_outbox (
event_id, aggregate_id, event_type, payload, status
) VALUES (...)
COMMIT
outbox 행은 결제와 같은 로컬 트랜잭션에 저장했다. 릴레이는 아직 발행되지 않은 행을 읽어 Kafka에 보내고 발행 상태를 갱신했다. 발행과 상태 갱신 사이에 멈출 수 있으므로 중복 발행은 허용했다.
outbox가 보장한 것은 정확히 한 번 발행이 아니었다. 결제가 승인 상태로 남았다면 발행할 의도도 함께 남아 다시 시도할 수 있다는 것이었다. 누락을 복구 가능한 데이터 상태로 바꿨다.
이벤트 식별자와 비즈니스 키를 구분했다
이벤트 ID는 전달되는 사건 하나를 식별했고, 결제 ID는 여러 사건이 공유하는 비즈니스 대상을 식별했다. 승인과 취소는 다른 이벤트 ID를 갖지만 같은 결제 ID를 사용했다.
paymentId: 결제 한 건의 상태 흐름을 묶음
eventId: 승인·취소 등 개별 사건과 재전달을 식별
version: 같은 결제 안에서 사건의 순서를 확인
재발행할 때 이벤트 ID를 새로 만들면 소비자는 같은 사건을 새 입력으로 봤다. 전송 재시도와 운영 재처리에서도 원본 이벤트 ID를 유지했다. 비즈니스에서 실제 새 사건이 생긴 경우에만 새 ID를 만들었다.
중복 이벤트를 예외가 아니라 정상 입력으로 봤다
메시지는 재전달될 수 있었다. 컨슈머가 처리한 뒤 커밋 전에 멈추면 같은 이벤트가 다시 들어왔다. 이벤트 ID를 기준으로 처리 이력을 남기고 정산 데이터 변경과 같은 트랜잭션에서 중복을 확인했다.
사전 조회만 믿지 않고 데이터베이스 유니크 제약을 마지막 방어선으로 뒀다. 두 컨슈머가 동시에 같은 이벤트를 처리해도 저장소가 하나만 받아들이게 했다.
BEGIN
INSERT INTO processed_event (event_id) VALUES (:eventId)
INSERT INTO settlement_target (payment_id, amount, status)
VALUES (:paymentId, :amount, 'PENDING')
COMMIT
처리 이력과 정산 데이터는 같은 트랜잭션에 넣었다. 이력만 저장되고 비즈니스 반영이 실패하면 재전달을 중복으로 판단해 영원히 건너뛸 수 있기 때문이다. 유일성 충돌은 이미 처리된 사건으로 해석하고 현재 결과를 확인했다.
이벤트 ID 제약만으로 모든 중복을 막지는 못했다. 생산자 버그로 같은 결제 승인이 서로 다른 이벤트 ID로 만들어질 수 있었다. 정산 대상에도 결제와 정산 규칙에 맞는 비즈니스 유일성을 두어 마지막 방어선을 만들었다.
순서가 필요한 기준을 정했다
승인과 취소 이벤트의 순서가 뒤바뀌면 정산 금액이 틀어질 수 있었다. 같은 결제의 이벤트가 같은 파티션으로 가도록 결제 ID를 키로 사용했다. 정상 순서를 벗어난 상태 전이는 무조건 적용하지 않고 보류했다.
순서가 틀렸다는 이유로 계속 재시도하면 같은 메시지가 파티션을 막았다. 재시도로 해결할 수 있는 실패와 데이터 확인이 필요한 실패를 나눴다.
상태 전이와 이벤트 버전을 함께 확인했다
같은 파티션은 정상 발행 흐름의 순서를 보장하지만 운영 재처리와 다른 토픽을 거치면 순서가 달라질 수 있었다. 정산 데이터에 마지막 반영 버전을 남기고 오래된 이벤트가 현재 상태를 되돌리지 못하게 했다.
UPDATE settlement_target
SET status = :nextStatus,
event_version = :version
WHERE payment_id = :paymentId
AND event_version < :version;
버전이 건너뛴 경우에는 무조건 적용하지 않았다. 누락된 사건이 있는지 확인할 수 있도록 보류 상태와 알람을 남겼다. 이벤트 순서가 완벽할 것이라는 기대보다 잘못된 순서를 데이터가 거절하게 했다.
이벤트 계약의 변경도 정합성 문제로 봤다
생산자가 필드 의미를 바꾸면 메시지는 정상 전달돼도 소비 결과가 틀릴 수 있었다. 필드 추가와 의미 변경을 구분하고, 소비자가 지원하는 버전을 명시했다.
| 변경 | 처리 원칙 |
|---|---|
| 선택 필드 추가 | 이전 소비자가 무시해도 되는지 확인 |
| 필수 의미 변경 | 새 버전 또는 새 이벤트로 분리 |
| 코드값 추가 | 알 수 없는 값의 처리 정책 준비 |
| 필드 제거 | 모든 소비자의 의존 제거 뒤 진행 |
계약 테스트는 직렬화 성공만 확인하지 않았다. 이전 버전의 메시지를 새 소비자가 처리하고, 새 메시지의 선택 필드를 이전 소비자가 안전하게 무시할 수 있는지 확인했다.
재처리는 처음부터 운영 기능으로 만들었다
실패 메시지를 다시 넣는 것만으로는 부족했다. 누가 어떤 이유로 재처리했고, 이전 처리 결과와 무엇이 달라졌는지 남겨야 했다. 이벤트 ID는 재처리해도 바꾸지 않았고, 중복 방지 기준도 그대로 유지했다.
재처리 전에 현재 결제와 정산 상태를 조회했다. 이미 정상 반영된 사건은 다시 넣지 않았고, 코드나 데이터가 수정되지 않은 영구 실패도 반복하지 않았다. 재처리는 버튼이 아니라 판단과 검증을 포함한 운영 절차였다.
재처리 요청
→ 원본 이벤트와 실패 원인 확인
→ 현재 비즈니스 상태 확인
→ 수정 조건 충족 여부 확인
→ 같은 eventId로 재처리
→ 정산 결과와 처리 이력 확인
최종적 일관성을 확인하는 대사
이벤트가 결국 도착할 것이라는 기대만 두지 않았다. 결제 승인 수와 정산 대상 수, 금액 합계를 주기적으로 비교했다. 차이가 생기면 누락인지 지연인지 중복인지 분류할 수 있게 했다.
결제 원장 집계
↕ paymentId / 기준일 / 금액
정산 대상 집계
차이 → 지연 | 누락 | 중복 | 계약 불일치로 분류
건수만 같으면 서로 다른 거래가 빠지고 중복돼도 놓칠 수 있었다. 비즈니스 키와 금액을 함께 비교하고, 지연 허용 시간을 지난 차이만 조사 대상으로 분리했다. 대사 결과도 누가 언제 조치했는지 이력을 남겼다.
정합성을 운영 지표로 드러냈다
Kafka lag만 보면 메시지가 쌓였다는 사실은 알 수 있지만 어떤 돈의 상태가 늦어지는지는 알기 어려웠다. outbox 대기, 이벤트 처리 결과, 중복 건너뜀, 보류된 상태 전이, 대사 차이를 함께 봤다.
| 지표 | 알려주는 위험 |
|---|---|
| 미발행 outbox | 승인은 남았지만 사건 전달이 늦음 |
| 소비 실패·DLQ | 정산 반영이 진행되지 않음 |
| 중복 처리 | 재전달 또는 생산자 중복 신호 |
| 버전 불일치 | 누락이나 순서 역전 가능성 |
| 대사 차이 | 최종 비즈니스 결과가 다름 |
각 지표에서 대표 이벤트와 결제 상태로 이동할 수 있게 식별자를 연결했다. 정합성은 장애가 발생한 뒤 SQL로 찾는 숨은 문제가 아니라 평소에 관찰하는 운영 상태가 됐다.
최종적 일관성에도 한계 시간을 뒀다
‘언젠가 맞아진다’는 말은 운영 기준이 아니었다. 정산 업무가 기다릴 수 있는 범위와 대사가 조사할 시점을 정했다. 허용 범위를 넘은 지연은 최종적 일관성이라는 이름으로 정상 처리하지 않았다.
모든 분산 실패를 자동 복구하지는 못했다. 계약에 없는 상태, 운영 보정으로 원장이 바뀐 경우, 외부 결과가 불명확한 거래는 사람이 확인해야 했다. 대신 자동 처리와 수동 판단의 경계를 명확히 하고 필요한 데이터와 절차를 준비했다.
결제와 정산을 나누면서 정합성은 데이터베이스가 자동으로 주는 성질이 아니라 운영 절차까지 포함해 만들어야 하는 약속이 됐다. Kafka는 전달 경로였고, 멱등성·재처리·대사가 실제 안전장치였다.