은행 간편결제를 국내 가맹점에서도 사용할 수 있게 해달라는 요구가 들어왔습니다. 기존 국내 결제 시스템은 POS와 국내 결제사를 연결하고 있었고, 해당 은행은 해외 결제 시스템에만 연동되어 있었습니다.
처음에는 결제사 하나를 추가하는 일처럼 보였습니다. 하지만 실제 과제는 달랐습니다. 은행과 직접 연동할 수 없는 조건에서 서로 다른 거래 원장과 상태를 관리하는 국내·해외 시스템을 연결해야 하는 일이었습니다.
제가 맡은 범위는 국내 시스템의 요구사항 분석부터 대안 비교, Kafka 통신과 원장 상태 처리 설계·구현, 테스트·배포·운영까지였습니다. 해외 시스템은 담당 개발자와 Topic, Payload, Reply, timeout 계약을 협의한 뒤 각자 맡은 시스템을 구현했습니다.
먼저 결론을 요약하면 다음과 같습니다.
- QR 생성·결제 검증·취소·환불은 Kafka Request-Reply로, 결제 결과는 단방향 이벤트로 분리했습니다.
- 개발 환경에서 약 50% 발생한 Reply timeout을 공유 Reply Topic, 인스턴스별 Consumer Group,
CorrelationId조합으로 해결했습니다. - 거래번호와 국내 원장 상태를 함께 검증해 중복·역순 이벤트가 유효한 거래 상태를 덮어쓰지 못하게 했습니다.
- 운영 약 2개월 동안 해당 은행 결제를 일평균 약 500건 처리했고, 메시지 유실과 15초 Reply timeout은 각각 0건이었습니다.
새 결제사를 붙이는 일이 아니었다
기존 국내 결제의 기본 구조는 POS → 국내 시스템 → 결제사였습니다. 반면 새로 국내 결제를 제공해야 했던 은행은 해외 시스템에만 연결되어 있었습니다. 인프라와 일정 제약 때문에 국내 시스템이 은행에 직접 연동하는 선택지는 없었습니다.
따라서 국내 시스템과 해외 시스템 사이에 새로운 계약이 필요했습니다. 해외 시스템도 자체 거래 원장과 상태를 관리했기 때문에 단순히 요청을 전달하는 Proxy로 볼 수는 없었습니다. 어느 시스템이 어떤 거래를 생성하고, 어떤 상태를 최종 판단할지 먼저 나누어야 했습니다.
은행 결제에는 두 가지 시작점이 있었습니다.
POS 시작 거래에서는 국내 시스템이 거래 원장을 만들고 QR 문자열과 거래 식별자를 POS에 반환했습니다. 고객이 은행 앱으로 QR을 스캔하면 은행에서 해외 시스템을 거쳐 국내 시스템으로 결제 검증과 결과가 전달됐습니다. POS는 국내 시스템을 폴링해 최종 결과를 가져갔습니다.
은행 웹에서 시작한 거래는 은행에서 해외 시스템을 거쳐 국내 시스템에 QR 생성을 요청했습니다. 국내 시스템은 이때도 신규 원장을 만든 뒤 QR과 거래 식별자를 역방향으로 반환했습니다.
취소와 환불은 반대 방향이었습니다. POS가 국내 시스템에 요청하면 국내 → 해외 → 은행 순서로 처리했습니다. 국내 시스템은 POS에 결과를 제공해야 했기 때문에, 국내 원장의 상태를 기준으로 성공·실패·만료·취소·환불을 판단했습니다.
HTTP가 더 자연스러웠지만 Kafka를 선택했다
QR 생성이나 취소처럼 즉시 결과가 필요한 기능에는 HTTP가 자연스럽습니다. 내부 네트워크도 빨랐고 프로토콜 제약도 없었습니다. 실제 HTTP 장애가 발생했던 것도 아닙니다.
다만 시스템 하나의 지연과 처리량 부족이 다른 시스템의 스레드 점유와 timeout으로 이어질 가능성을 설계 단계에서 고려했습니다. 해외 시스템이 국내 시스템의 주소와 인스턴스 구성을 알아야 한다는 점도 부담이었습니다.
검토한 선택지는 다음과 같았습니다.
| 선택지 | 장점 | 비용과 한계 |
|---|---|---|
| 은행 직접 연동 | 호출 경로가 가장 짧음 | 인프라 제약과 일정 때문에 선택 불가 |
| 국내·해외 HTTP 연동 | 구현과 추적이 단순하고 동기 계약에 자연스러움 | 주소·인스턴스 의존, 지연 전파, 호출 스레드 점유 |
| Kafka 단방향 이벤트 | 시스템 결합과 처리 시점을 가장 크게 분리 | 즉시 응답이 필요한 QR 생성·검증·취소·환불에 부적합 |
| Kafka Request-Reply | 기존 Kafka 재사용, 주소 비의존, 메시지 보관, Consumer 독립 확장 | 비동기 인프라에 동기 의미를 얹는 복잡성, timeout과 Reply 라우팅 필요 |
Kafka는 이미 운영 중이어서 추가 인프라 비용이 없었습니다. 해외 시스템은 국내 시스템의 HTTP 주소나 인스턴스를 몰라도 됐고, 국내 시스템은 Consumer 동시성으로 처리량을 독립적으로 조절할 수 있었습니다. 문제가 생겼을 때 메시지를 확인할 수 있다는 점도 분석에 유리했습니다.
반면 Kafka가 비즈니스 실패 전파까지 없애주는 것은 아닙니다. Request-Reply는 Reply를 기다리는 동안 두 시스템을 시간적으로 다시 묶습니다. 저는 이 한계를 받아들이되, 기다리는 시간을 15초로 제한했습니다. 15초를 넘겼다면 일시적 지연보다 시스템 이상일 가능성이 높다고 보고, 동일한 비즈니스 요청을 다시 발행하지 않고 거래를 실패시켜 새로운 결제를 시작할 수 있게 했습니다. Broker 전송 과정의 일시 오류를 다루는 Producer 재시도와, timeout 이후 결제 요청을 다시 보내는 애플리케이션 재시도는 서로 다른 계층으로 구분했습니다.
모든 거래에 Request-Reply를 사용하지 않았다
기능마다 응답 계약이 달랐습니다. 하나의 통신 방식으로 통일하는 대신, 호출자가 즉시 답을 받아야 하는지에 따라 나눴습니다.
| 기능 | 방향 | 통신 방식 | 선택 이유 |
|---|---|---|---|
| QR 생성 | POS 또는 은행 → 국내 | Request-Reply | QR과 거래 식별자를 즉시 반환해야 함 |
| 결제 검증 | 은행 → 해외 → 국내 | Request-Reply | 가맹점·단말·QR·유효 거래를 즉시 판정해야 함 |
| 취소·환불 | POS → 국내 → 해외 → 은행 | Request-Reply | POS에 처리 결과를 반환해야 함 |
| 결제 결과 통지 | 은행 → 해외 → 국내 | 단방향 이벤트 | 국내 원장을 갱신하고 POS는 PL로 조회함 |
Request Topic과 Reply Topic은 기능별로 분리했습니다. 결제 로직은 전략 패턴으로 추상화해 기능별 처리와 결제 조건이 한 Listener에 뒤섞이지 않게 했습니다.
요청 측은 Reply Topic을 Kafka Header에 지정했고 Spring Kafka가 생성한 CorrelationId로 요청과 응답을 연결했습니다. 거래 정보는 Header가 아니라 Java DTO Payload에 담아 JSON으로 직렬화했습니다.
@KafkaListener(topics = "payment-result")
public void handle(@Payload PaymentResultRecord payload) {
paymentResultStrategy.apply(payload);
}
별도의 메시지 스키마 버전 정책은 두지 않았습니다. 국내·해외 시스템이 DTO 변경 시점을 함께 조율할 수 있다는 전제에 의존한 선택이었습니다. 참여 시스템과 독립 배포가 늘어난다면 가장 먼저 보완해야 할 지점입니다.
Reply는 도착했는데 요청자는 받지 못했다
국내·해외 시스템을 각각 2개 인스턴스로 실행한 개발 환경에서 실제 결제 흐름을 테스트했습니다. 이때 같은 요청이 약 50% 확률로 timeout 되는 문제가 나타났습니다.
메시지가 유실된 것은 아니었습니다. 두 Reply Consumer가 같은 Consumer Group에 속해 있었기 때문에 Kafka가 Reply를 두 인스턴스 중 하나에만 전달했습니다. 요청을 보낸 인스턴스가 아닌 다른 인스턴스가 소비하면, Reply는 존재하지만 해당 CorrelationId를 기다리는 Future가 없었습니다. 요청 인스턴스는 15초 뒤 timeout 됐습니다.
해결책은 네 가지를 비교했습니다.
| 대안 | 장점 | 단점 |
|---|---|---|
| 인스턴스별 Reply Topic | 요청 인스턴스로 정확히 라우팅 | 인스턴스 증감마다 Topic을 동적으로 관리해야 함 |
| 공유 Topic + 동일 Consumer Group | 중복 소비가 없고 가장 단순 | Reply가 요청하지 않은 인스턴스로 갈 수 있음 |
| 공유 Topic + 인스턴스별 Consumer Group | 모든 인스턴스가 Reply를 받고 로컬 Future와 매칭 가능 | 인스턴스 수만큼 중복 소비량 증가 |
| Redis Pending Request Registry | 중앙에서 요청 소유자를 조회 가능 | 외부 저장소 의존과 만료·정합성 관리 추가 |
최종적으로 공유 Reply Topic과 인스턴스별 고유 Consumer Group을 선택했습니다. 모든 인스턴스가 Reply를 소비하되, CorrelationId에 해당하는 Future를 보유한 인스턴스만 응답을 완료했습니다.
인스턴스가 2대라 Reply 중복 소비 비용이 작았고, 인스턴스별 Topic을 생성·삭제하는 것보다 운영이 단순했습니다. 현재 규모에 맞춘 선택이었지, 인스턴스 수와 무관하게 좋은 구조라고 판단한 것은 아닙니다.
Reply Consumer에는 CooperativeStickyAssignor도 선제적으로 적용했습니다. 각 인스턴스는 고유 Consumer Group을 사용했기 때문에 이 설정이 인스턴스 사이의 Reply 라우팅을 해결한 것은 아닙니다. 대신 concurrency=2로 구성된 각 인스턴스 내부 Consumer Group에서 리밸런싱이 발생할 때, 모든 파티션을 한 번에 회수하는 범위를 줄이기 위한 예방 조치였습니다. 실제 장애를 해결한 설정으로 평가하지 않고, Reply Consumer의 중단 범위를 줄이기 위한 운영 설정으로 구분했습니다.
동기 의미는 유지하고, 기다리는 방식만 바꿨다
초기 구현은 ReplyingKafkaTemplate.sendAndReceive() 이후 Future.get(15초)로 결과를 기다렸습니다. 코드는 단순했지만 Reply가 올 때까지 호출 스레드를 점유했습니다.
JDK 17 환경이라 Virtual Thread를 사용할 수 없었습니다. 설계를 재검토한 뒤 thenApply, thenAccept, whenComplete를 조합한 CompletableFuture 콜백 체인으로 변경했습니다.
RequestReplyFuture<String, PaymentRequest, PaymentReply> future =
replyingKafkaTemplate.sendAndReceive(record);
future
.thenApply(ConsumerRecord::value)
.thenAccept(pendingReply::complete)
.whenComplete((ignored, error) -> {
if (error != null) {
pendingReply.completeExceptionally(error);
}
});
별도 Executor는 지정하지 않았습니다. 따라서 콜백은 Reply Future를 완료한 스레드에서 실행됐고, 그 안에는 응답 변환과 Future 완료처럼 가벼운 작업만 두었습니다. DB 저장·조회 등 거래 트랜잭션 작업은 콜백 밖에서 처리했습니다.
이 변경을 전체 흐름의 논블로킹 전환이라고 부를 수는 없습니다. Kafka 위에서 Request-Reply라는 동기적 비즈니스 의미는 그대로 유지했기 때문입니다. 처리량이나 스레드 수를 전후로 측정하지도 않았습니다. 확인 가능한 변화는 get()으로 대기하던 구현을 콜백 기반으로 바꿨다는 구조적 개선까지입니다.
현재처럼 응답 변환과 Future 완료만 수행하는 콜백은 Reply를 완료한 스레드에서 짧게 끝내는 편이 단순합니다. 이후 DB 조회나 외부 I/O 같은 블로킹 작업이 콜백에 추가된다면 해당 단계에만 thenApplyAsync와 크기가 제한된 전용 Executor를 적용할 계획입니다. Pool 크기, Queue 용량, 거부 정책을 함께 정의해 Reply 처리 스레드와 후속 작업의 부하를 격리하되, 가벼운 콜백까지 무조건 다른 스레드로 넘겨 생기는 Queue 대기와 전환 비용은 피하려는 선택입니다.
유실 방지와 Exactly Once를 구분했다
Topic은 파티션 5개, 복제 팩터 3으로 구성했습니다. min.insync.replicas=2, Producer acks=all, enable.idempotence=true를 적용해 전송 재시도와 Broker 장애 상황에서 메시지 내구성을 높였습니다. 2 CPU, 8GB 환경에서는 Consumer concurrency=2로 시작했고, 처리량이 필요하면 파티션과 Consumer 수를 함께 늘릴 수 있게 했습니다.
하지만 Kafka Transaction으로 DB 반영과 Offset Commit을 하나로 묶지는 않았습니다. 따라서 End-to-End Exactly Once를 보장했다고 쓰지 않습니다. Producer 설정과 별개로 애플리케이션에서 중복과 순서 역전을 방어해야 했습니다.
QR 생성은 거래번호를 만드는 단계이므로 멱등 재처리 대상이 아니었습니다. 원장 저장 후 Reply가 전달되지 않아 호출자가 다시 요청하면 새로운 거래와 QR을 만들고, 이전 원장은 사용되지 않은 채 만료되도록 했습니다. 구현은 단순하지만 전달되지 않은 원장이 유효시간 동안 남는 비용을 받아들인 선택입니다.
QR 생성 이후의 결제 검증·결과·취소·환불에는 생성된 거래번호를 멱등성 기준으로 사용했습니다. 거래번호를 Kafka 메시지 Key로 사용해 같은 Topic 안의 동일 거래가 같은 Partition으로 전달되게 했습니다. 다만 기능별 Topic 사이의 전역 순서까지 보장되는 것은 아니므로, 국내 원장의 조건부 UPDATE를 최종 정합성 방어선으로 두었습니다.
상태를 변경할 때는 애플리케이션에서 조회한 결과를 그대로 덮어쓰지 않고, 허용된 이전 상태를 WHERE 조건에 포함했습니다. 개념을 단순화하면 다음과 같습니다.
UPDATE payment_transaction
SET status = :next_status
WHERE transaction_no = :transaction_no
AND status IN (:allowed_current_statuses);
중복 이벤트나 뒤늦게 도착한 역순 이벤트가 현재 상태와 맞지 않으면 갱신 대상이 0건이 됩니다. Kafka가 전달 순서의 모든 문제를 해결한다고 가정하는 대신, DB가 실제 상태 전이를 허용하는 마지막 경계가 되도록 설계했습니다.
만료 뒤 도착한 성공은 별도 상태로 수거했다
비동기 결제에서는 POS가 기다릴 수 있는 시간과 은행의 최종 승인 시점이 일치하지 않을 수 있습니다. 이는 Kafka만의 문제가 아닙니다. 기존 POS → 국내 → 결제사 구조에서도 발생했고, POS → 국내 → 해외 → 은행처럼 경로가 길어지면 더 명시적으로 다뤄야 했습니다.
이때 Kafka Request-Reply의 15초 timeout과 결제 거래의 만료는 서로 다른 기준입니다.
| 구분 | 판단 기준 | 발생 후 처리 |
|---|---|---|
| Request-Reply timeout | 시스템 간 요청 후 15초 동안 Reply가 없음 | 해당 요청을 실패 처리하고 새로운 결제를 허용 |
| 결제 거래 만료 | POS가 기다리는 거래 유효시간 경과 | 늦은 승인 결과를 ABORT로 수거해 보상 처리 |
POS에 이미 EXPIRED를 반환한 거래에 은행의 성공 결과가 늦게 도착하면 성공으로 덮어쓰지 않았습니다. 승인번호와 결과 수신 시각을 기록한 뒤 자동 취소 대상인 ABORT로 전환했습니다.
10분 주기 배치가 ABORT 거래를 조회해 결제사 상태를 다시 확인했습니다. 실제 승인 거래라면 취소했고 성공 시 ABAPR, 취소 대상이 아니면 ABNTA, 취소 실패 시 ABFAL로 변경했습니다. 실패 건은 retry_count를 관리하며 최대 3회 재시도했고, 마지막까지 실패한 거래만 텔레그램으로 운영팀에 알렸습니다.
직접 집계한 시스템 지표는 아니지만, 운영팀으로부터 이런 늦은 성공 거래가 월 5건 이상 발생한다는 피드백을 받았습니다. 자동 취소와 최종 실패 알림을 나눠 대부분의 거래는 시스템이 수거하고, 사람이 확인해야 할 거래만 운영팀에 전달했습니다.
결과보다 중요한 것은 수치의 경계였다
운영 약 2개월 동안 이번 은행 결제는 일평균 약 500건 처리됐습니다. 이 기간 운영에서 확인된 Kafka 메시지 유실은 0건이었고, 개발 환경에서 약 50% 발생했던 15초 Reply timeout도 운영에서는 0건이었습니다.
| 항목 | 확인된 결과 |
|---|---|
| 운영 기간 | 약 2개월 |
| 은행 결제 처리량 | 일평균 약 500건 |
| 운영 중 Reply timeout | 0건 |
| 운영에서 확인된 Kafka 메시지 유실 | 0건 |
| 늦은 성공 거래 | 운영팀 구두 피드백 기준 월 5건 이상 |
Future.get()을 콜백 체인으로 바꾼 결과는 별도 부하 테스트 수치가 없어 정량 성과로 쓰지 않았습니다. 대신 어디까지 운영 데이터이고 어디부터 정성적 판단인지 구분했습니다.
다시 설계한다면 Kafka의 역할부터 줄인다
현재 구조는 국내·해외 시스템이 각각 2대였기 때문에 실용적이었습니다. 하지만 Reply를 모든 인스턴스에 fan-out하는 비용은 인스턴스 수에 비례해 증가합니다. 규모가 커질수록 요청 하나를 완성하기 위해 불필요하게 소비하는 메시지가 많아집니다.
다시 설계한다면 QR 생성·검증·취소·환불처럼 본질적으로 동기 응답이 필요한 명령은 HTTP 또는 gRPC로 분리하겠습니다. Load Balancer와 Service Discovery로 주소 의존성을 낮추고, timeout·Circuit Breaker·Bulkhead로 장애 범위를 제어하는 편이 통신 의미에 더 잘 맞습니다. Kafka는 결제 결과 통지와 보상·정산처럼 호출자가 즉시 응답을 기다리지 않는 이벤트에 집중시키겠습니다.
Kafka Request-Reply를 계속 사용해야 한다면 인스턴스별 Reply Topic 또는 외부 Pending Request Registry를 다시 비교해야 합니다. 인스턴스가 늘어난 환경에서는 중복 소비를 감수한 브로드캐스트가 더 이상 가장 단순한 선택이 아닐 수 있습니다.
함께 보완할 부분도 분명합니다.
- JSON DTO에 명시적인 버전과 하위 호환 정책을 둡니다.
- Consumer Lag, Reply timeout,
CorrelationId불일치,ABORT·ABFAL건수를 지표로 수집합니다. - DB 상태 변경과 Kafka 처리 경계에서 발생할 수 있는 불일치를 별도의 정합성 전략으로 다룹니다.
이 구축에서 가장 어려웠던 일은 Kafka API를 사용하는 것이 아니었습니다. 동기 응답이 필요한 기능과 비동기로 흘려도 되는 결과를 구분하고, 두 원장 사이에서 최종 상태를 누가 책임질지 정하는 일이었습니다. 기술 선택은 그 경계를 구현하기 위한 수단이었습니다.
Related
- 분산 환경 실시간 TCP Push 아키텍처 설계기 - 같은 시스템에서 Kafka 브로드캐스트를 사용한 반대 방향의 사례
- 결제 상태 불일치를 FINAL API와 보상 배치로 줄인 과정 - 늦은 결제 결과와 상태 확정 경계를 다룬 사례
- 모놀리식에서 멀티모듈·DDD 기반 이벤트 주도 아키텍처로 - 결제 기능의 모듈 경계를 나눈 과정
