결제 이벤트를 Kafka로 옮기며 지킨 세 가지 불변식
결제 승인과 이벤트 발행 사이의 원자성, 중복 소비, 파티션 순서를 운영 가능한 규칙으로 바꾼 과정을 정리합니다.
결제 시스템에 Kafka를 붙이는 일은 이벤트를 send() 하는 것으로 끝나지 않는다. 결제 승인은 성공했는데 이벤트가 유실되거나, 재시도로 같은 정산이 두 번 반영되거나, 취소가 승인보다 먼저 소비되면 금액이 틀어진다. 처리량보다 먼저 지켜야 할 것은 돈의 상태가 한 방향으로만, 설명 가능한 순서로 이동한다는 사실이다.
이 글은 결제 승인 이벤트를 동기 호출에서 Kafka 기반 비동기 흐름으로 옮길 때 정한 세 가지 불변식과 그 구현을 다룬다.
1. 승인 결과와 이벤트는 함께 남는다
처음에는 결제 트랜잭션이 끝난 뒤 프로듀서로 이벤트를 보냈다.
@Transactional
public Payment approve(ApproveCommand command) {
Payment payment = paymentRepository.save(Payment.approved(command));
kafkaTemplate.send("payment-approved", payment.getId(), toEvent(payment));
return payment;
}
코드는 짧지만 데이터베이스 커밋과 Kafka 발행은 하나의 원자적 연산이 아니다. DB 커밋 후 프로세스가 종료되면 승인은 남고 이벤트는 사라진다. 반대로 Kafka 발행을 기다리는 동안 DB 트랜잭션을 오래 열어 두면 지연과 장애의 영향 범위가 커진다.
그래서 Transactional Outbox를 사용했다. 결제 상태와 발행할 이벤트를 같은 로컬 트랜잭션에 기록하고, 별도 릴레이가 outbox를 읽어 Kafka로 보낸다.
@Transactional
public Payment approve(ApproveCommand command) {
Payment payment = paymentRepository.save(Payment.approved(command));
outboxRepository.save(OutboxEvent.pending(
payment.getId(),
"PaymentApproved",
eventSerializer.serialize(toEvent(payment))
));
return payment;
}
이 구조가 보장하는 것은 “정확히 한 번 발행”이 아니다. 승인이 존재하면 결국 발행할 이벤트도 존재한다는 복구 가능한 상태다. 릴레이는 발행 직후 종료될 수 있으므로 같은 이벤트를 다시 보낼 수 있다. 중복은 다음 불변식에서 처리한다.
2. 같은 이벤트는 여러 번 와도 결과가 같다
Kafka의 재전달, 컨슈머 재시작, outbox 릴레이 재시도 때문에 소비자는 중복을 정상 입력으로 받아들여야 한다. 이벤트 ID를 기준으로 처리 이력을 남기고, 비즈니스 변경과 같은 트랜잭션에서 중복 여부를 확인한다.
@KafkaListener(topics = "payment-approved")
@Transactional
public void handle(PaymentApproved event) {
if (processedEventRepository.existsById(event.eventId())) {
return;
}
settlementRepository.createPending(
event.paymentId(),
event.amount()
);
processedEventRepository.save(new ProcessedEvent(event.eventId()));
}
exists 다음 insert 사이에는 경쟁 조건이 있다. 따라서 processed_event.event_id에 유니크 제약을 두고, 충돌을 이미 처리된 이벤트로 해석한다. 애플리케이션의 사전 조회는 빠른 종료를 위한 최적화일 뿐, 정확성은 데이터베이스 제약이 지킨다.
이벤트 ID는 재시도마다 새로 만들지 않는다. 비즈니스 사건 하나에 ID 하나를 부여하고 outbox 레코드와 Kafka 메시지 헤더에서 끝까지 유지한다.
3. 순서가 필요한 사건은 같은 파티션으로 간다
결제에는 Approved → Cancelled처럼 인과 순서가 있다. 토픽 전체의 순서를 보장할 필요는 없지만, 같은 결제 건의 이벤트는 순서대로 처리되어야 한다. 프로듀서 키를 paymentId로 고정해 한 결제의 이벤트가 같은 파티션에 들어가게 했다.
kafkaTemplate.send(
"payment-events",
event.paymentId().toString(),
event
);
파티션 키를 정할 때는 순서와 부하 분산을 함께 본다. 가맹점 ID를 키로 사용하면 특정 대형 가맹점이 한 파티션을 과열시킬 수 있다. 결제 ID는 개별 결제의 인과 관계를 유지하면서 비교적 고르게 분산된다.
컨슈머에서는 예상하지 못한 상태 전이를 거부한다. 취소 이벤트가 도착했는데 승인 상태가 없다면 무작정 재시도하지 않고 보류 큐로 보내 원인을 분리한다. 순서 보장은 정상 조건의 약속이지, 손상된 데이터까지 자동으로 고치는 장치가 아니다.
운영 지표는 불변식의 위반을 보여줘야 한다
기술 지표만으로는 돈의 흐름이 맞는지 알 수 없다. 다음 지표를 함께 운영했다.
| 지표 | 의미 | 대응 기준 |
|---|---|---|
| outbox 대기 시간 | DB 기록 후 아직 발행되지 않은 시간 | p99가 1분을 넘으면 릴레이 확인 |
| 중복 이벤트 비율 | 재전달과 재시도의 빈도 | 급증 시 프로듀서·컨슈머 재시작 확인 |
| 유효하지 않은 상태 전이 | 순서 또는 데이터 계약 위반 | 1건도 조사 대상 |
| 컨슈머 지연 | 비동기 처리의 최신성 | 정산 마감 SLO와 연결 |
알람에는 토픽과 파티션뿐 아니라 eventType, 최초 발생 시각, 영향 결제 수를 넣었다. 그래야 “Kafka lag이 높다”가 아니라 “승인 312건의 정산 생성이 4분 늦다”로 상황을 설명할 수 있다.
마치며
Kafka를 도입해 얻은 가장 큰 이점은 처리량이 아니라 경계를 명확히 한 것이었다. 결제 서비스는 승인과 발행 의도를 원자적으로 남기고, 릴레이는 적어도 한 번 전달하며, 소비자는 중복을 제거하고 상태 전이를 검증한다.
정리하면 세 가지 불변식은 다음과 같다.
- 승인이 남으면 발행할 이벤트도 반드시 남는다.
- 같은 이벤트가 여러 번 도착해도 결과는 한 번 처리한 것과 같다.
- 같은 결제의 상태 전이는 관찰 가능한 순서를 지킨다.
“Exactly once”라는 한 문장보다 각 경계가 무엇을 보장하고 어디서 복구하는지 적어 두는 편이 운영에서 훨씬 강했다.