csct3434
메시지 유실을 고려한 채팅 시스템 클러스터 개발기 본문
미리보기
기획 배경
2024 관광데이터 활용 공모전에 참여하면서 야구장 카풀을 주제로 한 서비스를 개발했습니다.
카풀이 이루어지기 위해서는 소통 수단이 필요했고, 사용자 편의성을 고려하여 자체적인 채팅 기능을 제공하기로 했습니다.
초기 시스템 설계 과정
[ Why : WebSocket 도입 ]
Polling, Long Polling, Server Sent Event, WebSocket 등 다양한 실시간 통신 방식을 검토한 결과, 채팅 시나리오에 WebSocket의 실시간성 및 양방향 통신이 잘 어울린다고 판단하여 구현 기술로 WebSocket을 선택했습니다.
[ Why : 클러스터링 구성 ]
사용자가 약속 장소에 도착한 상황에서 채팅 기능이 중단된다면 대면 만남에 있어 큰 지장을 줄 수 있습니다.
따라서 실시간성 뿐만 아니라 가용성 또한 보장해야 될 중요한 성능 지표라고 판단했습니다.
이에 단일 서버가 아닌 다중 서버로 서비스를 제공하여 가용성을 높이는 클러스터 시스템을 구축하기로 결정했습니다.
[ Why : 마이크로서비스 분리 ]
클러스터 환경이라도 모놀리식 아키텍처라면 채팅과 무관한 기능에서 발생한 장애가 전체로 퍼져 채팅 기능이 중단될 가능성이 있습니다. 이에 장애 격리(Fault Isolation)를 위해 채팅 기능을 별도의 마이크로서비스로 분리하기로 결정했습니다.
[ Why: STOMP 도입 ]
클라이언트와의 통신에 STOMP를 도입하기로 한 가장 큰 이유는 바로 Pub/Sub 패턴입니다.
카카오톡을 생각해보면 사용자가 메시지 전송 버튼을 누른 후 화면에 즉시 숫자가 표시되지 않습니다. 네트워크 지연이 발생하면 종이비행기 아이콘이 표시되고, 전송에 실패한 경우 재전송/취소 버튼이 표시되는 것이 그 예시입니다. 즉, 클라이언트 측은 메시지를 전송한 후 서버로부터 정상 응답을 수신할 때까지 채팅 화면에 숫자를 표시하지 않고 있습니다. 이는 전송자의 화면에는 메시지가 표시되었지만 수신자의 화면에는 메시지가 표시되지 않는 비일관성 문제를 예방하기 위한 것입니다.
이러한 시나리오는 서버가 메시지를 처리한 후 모든 참가자들에게 동일한 메시지를 전파하는 방식으로 구현이 가능하다고 생각했습니다. 이는 Pub/Sub 패턴과 동작 방식이 동일했기에, WebSocket 환경에서 Pub/Sub 기능을 제공하는 STOMP 프로토콜을 활용하기로 결정했습니다. 결과적으로 채팅방에 참가하는 두 사용자를 동일한 토픽에 구독시켜 메시지를 Broadcast하는 방식으로 카카오톡의 채팅 시나리오를 구현했습니다.
[ 인메모리 STOMP 브로커의 한계 ]
채팅 서비스가 단일 서버가 아닌 다중 서버로 구성되어 있으므로, 채팅방에 참여한 두 사용자가 서로 다른 서버에 접속한 상황일 수 있습니다. Spring에서 STOMP는 기본적으로 In-Memory 메시지 브로커(Simple Broker)를 사용하므로, 전송자와 수신자가 서로 다른 서버에 접속한 경우 채팅 메시지를 전달할 수 없습니다. 저는 아래의 두 요소를 활용하여 이러한 문제를 해결하고자 시도했습니다.
1. 상대방이 접속한 채팅 서버 정보
사용자가 채팅방에 입장 시 접속한 채팅 서버의 IP 주소를 공유 데이터베이스에 기록함으로써, 상대방이 접속한 채팅 서버의 정보를 확인하고자 했습니다.
2. 채팅 서버간에 메시지 전달 수단
채팅 서버간에 메시지를 전달할 수 있는 방법으로는 다양한 수단이 존재합니다. 크게 Kafka, RabbitMq, Redis Pub/Sub과 같은 메시지 브로커를 사용하는 비동기적 방식과 HTTP, gRPC와 같은 동기적 방식으로 구분할 수 있습니다.
저는 이 중에서 초기에 Redis Pub/Sub을 선택했습니다. 그 이유는 Redis가 In-Memory DB이기 때문에 빠른 처리가 가능하고, Kafka/RabbitMQ와 달리 메시지가 축적되지 않아 대량의 채팅 메시지가 전송되어도 안정적으로 처리할 수 있다고 생각했습니다. 또한 채팅 메시지는 안정적으로 저장되어야 하는 정보이므로 ACID가 보장되는 RDBMS에 별도로 저장했기 때문에, 메시지 브로커에 누적되는 것은 중복 저장이라는 생각이 들었습니다. 이에 STOMP와 Redis Pub/Sub을 활용한 초기 채팅 시스템을 설계했습니다.
초기 모델 : Redis Pub/Sub
앞서 빠른 메시지 처리 및 Fire and Forget 방식을 이유로 Redis Pub/Sub을 메시지 브로커로 선택했다고 언급했습니다. 이러한 선택은 반은 맞고 반은 틀린 선택이었습니다.
jMeter로 부하테스트를 진행한 결과 측정된 TPS는 1784로, 이후 다른 방식으로 설계한 채팅 시스템들에 비해 높은 TPS를 보였습니다. 따라서, 빠른 메시지 처리는 적중했습니다.
하지만 문제는 Fire and Forget 방식이었습니다. 약 60만건의 메시지 전송 도중 147건의 메시지가 유실되는 문제가 발생했기 때문입니다.
메시지 유실이 발생할 경우 메시지 재전송을 시도하거나 Push 알림과 같은 방식으로 클라이언트에 실패를 알리는 방식으로 대응할 수 있습니다. 하지만 이는 어디까지나 ‘메시지 전송 결과 확인’이 가능할 경우입니다.

Fire and Forget 방식은 메시지의 전달 여부를 확인하지 않고 메시지를 삭제하는 Redis Pub/Sub의 동작 방식을 말합니다. 이로 인해 메시지가 유실되어도 이를 직접적으로 감지할 수 없기 때문에 예외 처리가 불가능합니다. 결과적으로 신뢰성이 중요한 1:1 채팅 시나리오에서 Redis Pub/Sub은 적합하지 않은 선택이었습니다.
실제 채팅 시스템 사례 조사
이쯤 되어서 실제 상용 서비스는 어떤 기술을 기반으로 운영되는지 궁금하여 조사해보았습니다.
1. Line Live 채팅 시스템
Line Live 채팅에서 Redis Pub/Sub을 사용하는 사례를 보면서, 제가 처음에 시도했던 Redis Pub/Sub 모델은 라이브 채팅처럼 일부 메시지의 유실을 감내하면서 빠르게 메시지를 처리하는 시스템에 적합한 것을 확인할 수 있었습니다.
2. 당근마켓 채팅 시스템
저희 서비스의 채팅의 경우 당근 마켓과 서비스 사용 맥락이 유사하다고 판단하여 당근 마켓의 채팅 서버 시스템에 주목했습니다. 당근의 경우 초기에는 모놀리식 구조로 채팅 기능을 제공했지만, 사용자가 늘어나면서 장애 전파 문제를 겪었고 이에 마이크로서비스로 분리하면서 gRPC를 기반으로 마이크로 서비스간에 통신을 구현했다는 내용을 확인할 수 있었습니다.
이에 저도 gRPC에 주목하였고, 공식 문서를 통해 gRPC의 특징에 대해 알아보았습니다. 그 중 Protocol Buffer 직렬화 방식과 HTTP 2.0의 HPACK(헤더 압축 기술)이 채팅 메시지 전송에 수반되는 오버헤드를 줄이는 데 효율적이라고 판단했습니다. 또한, 동기적 통신 방식을 지원하기 때문에 즉각적인 예외 처리에 있어 이점이 있다고 생각했습니다. 이에 공식 가이드의 예제 코드를 참고하며 코드에 익숙해진 후, gRPC를 기반으로 채팅 시스템을 재구성 한 뒤 다시 한번 부하 테스트를 진행했습니다.
최종 모델 : gRPC
그 결과 목표했던 100만건의 메시지를 단 한건의 유실 없이 처리할 수 있었고, TPS는 Redis Pub/Sub 대비 20% 감소한 1398을 보였습니다. 하지만 이는 합당한 트레이드 오프라고 생각되어 그 결과에 만족할 수 있었습니다.
마무리 : 이후 노력 및 교훈
공모전에서 급하게 Redis를 도입해 겪은 시행착오로 더 깊은 학습의 필요성을 느껴 [개발자를 위한 Redis] 책을 공부했습니다. 책에서도Redis Pub/Sub이 신뢰성이 중요한 상황에 적합하지 않다는 점을 명확히 확인할 수 있었습니다. 도입 당시 이 한계를 어느 정도 알고 있었지만, 개발 속도를 우선시하여 기능 개발을 먼저 완료하고 추후 테스트로 문제의 심각성을 파악하려 했습니다.
Spring 공식 문서에 따르면 기본 메시지 브로커는 클러스터링에 적합하지 않아 RabbitMQ나 ActiveMQ 같은 외부 메시지 브로커 사용을 권장합니다. 당시에 이 사실을 알았다면, STOMP 브로커 릴레이로 이러한 메시지 큐를 활용해 ACK 메커니즘을 통해 메시지 손실을 방지하면서 서버 간 메시지 전달 문제를 해결했을 것입니다.
하지만 외부 메시지 브로커와 STOMP 연동 가능성을 몰랐기에, 인메모리 메시지 브로커로 클러스터링을 구성하는 여러 방식을 시행착오를 겪게된 이야기였습니다.
충분한 사전 조사가 있었다면 피할 수 있었던 문제였지만, 이 시행착오를 통해 기술 도입 전 철저한 사전 조사와 테스트의 중요성을 체득하는 값진 경험을 얻었습니다.
'개발 일지' 카테고리의 다른 글
CAS(Compare-And-Swap): 논블로킹 동시성 제어의 핵심 (0) | 2025.03.02 |
---|---|
메모리 가시성 문제로 인한 Java의 동시성 버그 (0) | 2025.03.02 |
[Spring Cloud Config Server] Github Private Repository 연동하기 (0) | 2025.02.08 |
실시간 통신 방식 비교 : Polling, Long Polling, Server Sent Event, WebSocket (0) | 2025.01.17 |
AWS WAF를 활용한 웹 스캐닝 공격 차단 (feat. web ACL) (1) | 2024.06.04 |