QR 결제 중계 플랫폼은 API 통신이 가능한 POS와 TCP 통신만 가능한 POS를 모두 지원해야 했습니다. 두 방식 모두 결제가 끝나면 단말에 결과를 알려줘야 합니다.
영업 단계에서는 TCP POS도 결제 결과를 받을 수 있다고 안내됐지만, 연동을 점검해 보니 TCP Push 기능 자체가 구현돼 있지 않았습니다. 아직 장애가 발생한 것은 아니었습니다. 요구사항과 실제 시스템 사이의 간극을 상용 연동 전에 발견했고, 다중 인스턴스 환경에서 동작하는 실시간 Push 기능을 새로 만들어야 했습니다.
API POS는 요청한 연결로 결제 결과를 응답하면 됩니다. TCP POS는 달랐습니다. 결제 요청을 받은 TCP 연결과 결제 완료 결과를 받은 API 서버가 서로 다른 프로세스에 있었습니다. 문제는 메시지를 만드는 코드보다 어느 서버가 실제 POS 연결을 가지고 있는지 찾는 것이었습니다.
결제 결과를 아는 서버와 연결을 가진 서버가 달랐다
플랫폼은 처음부터 API 서버와 TCP 서버를 여러 인스턴스로 운영했습니다. TCP POS는 여러 TCP 서버 중 하나와 연결되고, Netty Channel은 그 연결을 수립한 프로세스의 메모리에만 존재합니다. 반면 결제 결과는 API 서버가 받습니다.
Channel 객체를 다른 서버로 옮길 수는 없습니다. 따라서 API 서버가 결제 결과를 안다는 사실만으로는 충분하지 않았습니다. 결과를 Channel을 보유한 TCP 서버까지 전달하고, 그 서버만 Push를 실행해야 했습니다.
이 문제에는 모든 서버가 공유할 수 있는 결제 결과와 특정 TCP 서버만 소유한 Netty Channel이 섞여 있었습니다. 공유 데이터만 전달해서는 해결되지 않습니다. 메시지를 받은 서버가 Channel의 실제 소유자인지도 함께 확인해야 합니다.
세 가지 전달 방식을 비교했다
팀원들과 API 내부 호출, Redis Pub/Sub, Kafka를 비교했습니다. 판단 기준은 네 가지였습니다.
- 연결을 가진 인스턴스를 찾을 수 있는가
- 일시적인 소비 실패에도 메시지를 다시 처리할 수 있는가
- API 서버와 TCP 서버의 장애가 서로 전파되지 않는가
- 현재 운영 환경에서 추가 인프라와 운영 비용이 얼마나 드는가
| 선택지 | 장점 | 감수해야 할 비용 | 판단 |
|---|---|---|---|
| API → TCP 내부 HTTP 호출 | 구현과 호출 흐름이 단순함 | API가 TCP 서버의 위치와 연결 소유권을 알아야 하며, 동기 호출 실패가 API 처리에 영향을 줄 수 있음 | 제외 |
| Redis Pub/Sub | 브로드캐스트 구성이 간단하고 지연이 짧음 | 구독자가 일시적으로 연결되지 않으면 메시지를 다시 받을 수 없음 | 결제 결과 유실 가능성 때문에 제외 |
| Kafka fan-out | 메시지 보관과 재소비가 가능하고, API와 TCP 처리를 비동기로 분리할 수 있음 | 모든 TCP 인스턴스가 메시지를 소비한 뒤 소유자를 판별해야 함 | 채택 |
Kafka는 이미 플랫폼에서 운영하고 있어 새 인프라를 도입할 필요가 없었습니다. TCP 서버 수가 많지 않은 당시 조건에서는 모든 인스턴스가 메시지를 한 번씩 확인하는 비용보다, 결제 결과를 잃지 않고 실패 시 다시 처리할 수 있다는 이점이 더 중요했습니다.
Kafka가 항상 더 좋은 선택이라는 뜻은 아닙니다. 이 구조는 TCP 서버가 늘어날수록 같은 메시지의 소비 횟수도 함께 증가합니다. 당시에는 적은 인스턴스 수, 기존 Kafka 인프라, 메시지 유실 방지라는 조건에서 이 비용을 감수할 만하다고 판단했습니다.
모두가 받고, 연결을 가진 서버만 보냈다
API 서버는 결제 결과를 Kafka에 발행하는 데까지만 책임집니다. 각 TCP 서버는 메시지를 모두 받되, Redis의 연결 정보를 확인해 자신이 해당 Channel을 가진 경우에만 Push합니다.
인스턴스마다 Consumer Group을 분리했다
같은 Consumer Group에 속한 Consumer끼리는 하나의 메시지를 나눠 갖습니다. 하지만 Kafka가 선택한 인스턴스와 POS Channel을 보유한 인스턴스가 같다는 보장은 없습니다.
그래서 TCP 서버마다 UUID 기반 12자리 식별자로 고유한 Consumer Group을 생성했습니다. TCP-01과 TCP-02를 서로 다른 Group으로 두면 같은 결제 결과가 두 서버 모두에 전달됩니다. 최종 실행 여부는 각 서버가 Redis 연결 정보를 조회해 결정합니다.
이 선택으로 Kafka의 역할과 TCP 서버의 역할을 분리했습니다.
- Kafka는 결제 결과를 모든 TCP 서버에 전달합니다.
- Redis는 어느 서버가 연결을 소유했는지 알려줍니다.
- Netty의 로컬 Channel 저장소는 실제 전송에 사용할 Channel을 찾습니다.
Redis에는 Channel 객체가 아니라 연결의 위치를 기록했다
POS가 TCP 연결을 맺으면 터미널 ID와 거래번호를 기준으로 Redis에 연결 정보를 저장했습니다. 값에는 연결을 수립한 서버의 host·port와 로컬 Netty Channel을 찾기 위한 Channel ID가 포함됩니다. 실제 Channel 객체는 연결을 가진 TCP 서버의 메모리에 그대로 둡니다.
결제 유효시간 120초에 처리 오버헤드 5초를 더해 TTL을 125초로 설정했습니다. 거래가 끝난 뒤 오래된 연결 정보가 남아 잘못된 서버를 소유자로 판단하는 시간을 제한하기 위해서입니다.
소유 서버로 판별되면 로컬 ConcurrentMap<ChannelId, ChannelInformation>을 순회해 거래번호가 일치하는 Channel을 찾았습니다. 이 조회는 연결 수에 비례하는 O(n) 연산입니다. 현재 운영 규모에서는 문제가 되지 않았지만, 연결 수가 커지면 transactionNo → ChannelId 보조 인덱스를 두어 O(1) 조회로 바꿔야 하는 지점입니다.
실패를 재시도와 보상으로 나눴다
Kafka 메시지 소비 실패와 TCP 쓰기 실패는 같은 오류처럼 보이지만 복구 방법이 다릅니다.
메시지 소비에 실패하면 지수 백오프로 최대 3회 재시도합니다. 재시도를 모두 소진하면 이벤트를 DLT로 보내고, DLT 처리 과정에서 거래 상태를 확인한 뒤 망취소합니다.
메시지는 소비했지만 channel.writeAndFlush()가 실패한 경우에도 거래 상태를 조회합니다. 이미 승인이 완료됐다면 취소를 요청해 결제는 성공했지만 결과 통지는 실패한 상태가 남지 않도록 보상합니다.
여기서 “쓰기 성공”의 의미는 제한적입니다. Netty의 ChannelFuture 성공은 서버가 소켓에 데이터를 쓰는 데 성공했다는 뜻입니다. POS 애플리케이션이 메시지를 읽고 처리했다는 ACK는 아닙니다. 따라서 이 구조가 보장하는 것은 POS의 최종 수신이 아니라 서버 측 TCP 쓰기 성공 여부까지입니다.
이 구분은 실패 대응에도 영향을 줍니다. 쓰기 실패는 즉시 감지해 보상할 수 있지만, 쓰기는 성공하고 POS가 처리하지 못한 경우는 프로토콜 ACK가 없기 때문에 서버가 확정할 수 없습니다. 글과 모니터링에서도 이를 “전송 성공”과 “수신 확인”으로 구분해야 합니다.
파트너별 차이는 전략으로 격리했다
결제사마다 Push 메시지 규격과 처리 방식이 달랐습니다. 공통 Listener 안에 조건문을 늘리는 대신 TcpPushHandler 전략과 전략 팩토리로 분리했습니다.
Listener는 Kafka 소비, 연결 소유권 확인, 재시도와 보상 같은 공통 흐름만 담당합니다. 전략 팩토리는 결제사 식별값으로 적절한 TcpPushHandler를 선택하고, 각 구현체는 메시지 생성과 전송 규칙을 담당합니다. 결제사별 변화가 공통 Listener의 분기 증가로 이어지지 않도록 책임을 나눈 것입니다.
내가 맡은 범위
요구사항 분석부터 Kafka·Redis 구조 설계, 구현, 테스트, 배포, 운영 모니터링까지 전 과정을 직접 담당했습니다. API 내부 호출·Redis Pub/Sub·Kafka의 대안 비교는 팀원들과 함께 검토했고, 최종 구조를 구체화하고 코드로 구현하는 역할은 제가 맡았습니다.
결과
| 항목 | 구축 전 | 구축 후 |
|---|---|---|
| TCP POS 결제 결과 통지 | 기능 없음 | 다중 인스턴스에서 실시간 Push |
| 중복 통지 | 기능 없음 | 운영 중 0건 |
| 실패 처리 | 별도 처리 없음 | 소비 실패 3회 재시도·DLT, TCP 쓰기 실패 거래 조회·망취소 |
| 서버 간 통신 | Push 경로 없음 | API는 결과 발행, TCP 서버는 소유 연결에 Push |
상용 운영 6개월 동안 해당 TCP Push 경로로 5,000건 이상, 10억 원 이상의 거래를 처리했습니다. 이 기간에 중복 Push는 0건이었고, API 서버와 TCP 서버 사이의 통신을 분리하면서 알림 누락과 장애 전파 위험을 통제했습니다.
다만 이 결과를 exactly-once 보장으로 해석해서는 안 됩니다. Kafka 구간은 재시도를 허용하고 POS의 애플리케이션 ACK도 없기 때문입니다. Redis에서 소유 서버를 판별해 중복 가능성을 통제했고, 실제 운영에서 중복 Push가 관측되지 않았다는 것이 정확한 의미입니다.
운영하면서 확인한 구조적 한계
첫 번째는 fan-out 비용입니다. TCP 서버가 N대라면 하나의 결제 결과도 N번 소비되고 N번 Redis 소유권을 확인합니다. 서버 수가 적을 때는 단순한 구조의 이점이 크지만, 인스턴스가 늘어나면 메시지 처리량도 함께 증가합니다.
두 번째는 로컬 Channel 탐색 비용입니다. 현재 구현은 ConcurrentMap을 순회하므로 연결 수가 늘어날수록 조회 시간이 증가합니다.
세 번째는 재연결 경쟁 조건입니다. POS가 TCP-01에서 끊어진 직후 TCP-02에 다시 연결되면 Redis에는 새 연결 정보가 기록됩니다. 그런데 늦게 도착한 TCP-01의 disconnect 처리가 같은 키를 삭제하면 정상적인 TCP-02 연결 정보까지 사라질 수 있습니다. TTL 125초는 오래된 데이터의 수명을 제한하지만, 새 세션과 이전 세션을 구분해 주지는 않습니다.
이 허점은 현재 구조를 다시 설계할 때 가장 먼저 보완해야 할 부분입니다.
다시 설계한다면 브로드캐스트와 라우팅을 분리한다
Redis 자체가 메시지를 전달하는 라우터가 되게 하지는 않겠습니다. Kafka는 결제 결과를 잃지 않기 위한 내구성 계층으로 유지하고, Redis는 연결의 위치만 관리하는 Connection Registry로 사용하겠습니다.
Payment API가 결제 결과를 Outbox 또는 Kafka에 기록하면, 하나의 Consumer Group으로 묶인 Push Dispatcher 중 한 인스턴스가 처리를 맡습니다. Dispatcher는 Redis에서 현재 연결 소유 서버를 조회해 해당 서버에만 내부 HTTP 또는 gRPC로 Push를 요청합니다. TCP 서버는 세션과 Channel을 검증한 뒤 로컬 Channel에 씁니다.
처음 검토했던 “API가 TCP 서버를 직접 호출하는 방식”과 비슷해 보이지만 책임이 다릅니다. Payment API가 동기 호출하는 대신, Kafka 뒤의 Dispatcher가 호출합니다. TCP 서버가 일시적으로 실패해도 결제 API 요청에는 장애가 전파되지 않고, Kafka 이벤트를 기준으로 다시 처리할 수 있습니다.
Connection Registry에는 단순한 host·port가 아니라 terminalId → {ownerInstanceId, channelId, sessionEpoch, lastSeenAt}을 저장하겠습니다.
POS가 재연결할 때마다 sessionEpoch를 증가시킵니다. disconnect 시에는 “내가 종료하려는 epoch가 Redis의 현재 epoch와 같을 때만” 삭제하는 compare-and-delete를 적용합니다. 그러면 이전 연결의 늦은 종료 이벤트가 새 연결 정보를 삭제하지 못합니다.
로컬 TCP 서버에는 transactionNo → ChannelId 보조 인덱스를 둬 Channel 탐색을 O(n)에서 O(1)로 바꾸겠습니다. Push 요청에는 eventId와 sessionEpoch를 함께 보내 오래된 세션으로의 전달과 재시도 중복을 판별할 수 있게 합니다.
이 구조의 장점은 결제 이벤트 한 건을 TCP 서버 수만큼 소비하지 않아도 된다는 점입니다. 반면 Connection Registry의 정확성, Targeted RPC 재시도, Dispatcher 운영이라는 새로운 책임이 생깁니다.
그래서 당시 선택이 잘못됐다고 생각하지는 않습니다. TCP 서버 수가 적고 Kafka가 이미 운영 중이던 시점에는 fan-out이 단순하고 안전했습니다. 서버와 연결 수가 충분히 증가해 O(N) 소비 비용과 O(n) Channel 조회가 실제 병목이 되는 시점에 Connection Registry와 Targeted RPC 구조로 전환하는 것이 더 합리적입니다.
결국 중요한 것은 Kafka와 Redis 중 더 좋은 기술을 고르는 일이 아닙니다. 현재 규모에서 가장 단순하게 신뢰성을 확보하고, 규모가 바뀌었을 때 어떤 비용을 기준으로 구조를 전환할지 설명할 수 있는가가 더 중요한 의사결정이었습니다.
Related
- Kafka Request-Reply로 국내·해외 결제를 하나의 진입점으로 통합하기 — 같은 시스템, Kafka를 동기 응답에 쓴 반대 방향의 사례
