이전에 부트캠프에서 웹소켓을 이용해서 실시간 서비스를 만드는 팀들이 종종 보였었는데 관심이 있었다.
그래서 이번에 개인 프로젝트로 실시간 채팅 서비스를 구현하려고 한다.
첫번째 게시글로는 실시간 채팅을 위해서는 어떤 기술이 있고, 어떤 과정을 통해 선택하게 되었는지를 정리하고자 한다.
실시간 채팅을 위한 기술
1. Polling
클라이언트가 일반적인 HTTP 요청을 주기적으로 보내서 변경된 데이터를 확인하는 방식을 Polling이라고 한다.
이전에 사용자의 위치 정보를 서버에 보내야 할 일이 있었는데 시간상, 내가 알고있는 지식상 이렇게 밖에 구현하지 못한 기억이 있다. 가장 단순한 방식이지만 클라이언트가 주기적으로 요청을 보내기 때문에 사용자가 많아지면 서버의 부담이 커진다.
HTTP 요청 오버헤드 또한 비용이 만만찮다. 규모가 있는 서비스를 생각해봤을때 사용할 수 없는 방식이라 배제했다.
2. SSE(Server Sent Event)
SSE는 클라이언트가 서버로 HTTP 요청을 보내면 서버는 이벤트에 대한 응답을 할 때 HTTP 요청에 대한 연결을 끊지 않고 계속 유지한다. 그리고 서버에서 이벤트가 발생할 때마다 응답을 계속 보낸다.
즉, 클라이언트가 요청을 보내던 방식에서 서버가 요청을 보내는 방식으로 변경된 것이다.
클라이언트는 한번만 요청을 보내면 커넥션이 끊기지 않는 한 계속 데이터를 받을 수 있고 서버에서도 이벤트가 발생할 때마다 HTTP 커넥션을 생성하지 않아도 되서 부담이 줄어들게 된다.
내가 구현할 실시간 채팅 서비스는 양방향 통신이 필요하므로 단방향인 SSE 방식도 부족함이 있었다.
3. WebSocket
기존의 단방향 HTTP 프로토콜과 호환되어 양방향 통신을 제공하기 위해 개발된 프로토콜.
웹소켓은 실시간 양방향 통신을 위해 필수적이다. 서버와 클라이언트 사이에 Socket 커넥션을 유지하면서 양방향 통신을 가능하게 한다. 채팅 등록 요청을 서버에 보내야하고, 채팅방에 있는 모든 인원에게 다시 뿌려줘야하는 실시간 채팅 서비스에 가장 적합한 기술이다.

기술들을 비교해봤을 때 웹소켓이 가장 적합해보여 웹소켓으로 결정했다.
이제 메시징 프로토콜을 선정해야할 차례다.
웹소켓을 순수하게 사용할 수도 있지만 이 경우 내가 직접 메시지의 누가 누구에게 무엇을 보내는지 전부 구현해야 한다.
웹소켓은 통신하기 위해 Socket.io, STOMP, MQTT 등을 이용하곤 한다.
메시징 프로토콜
1. Socket.io
Socket.io는 엄밀히 말하자면 프로토콜이 아니다. 프로토콜은 규격만 맞다면 어느 클라이언트, 서버와도 통신이 가능하지만 Socket.io는 라이브러리로 양쪽에 전용 라이브러리를 설치해야만 하고 구현체가 별도로 필요하다.
현재 백엔드 서버로 SpringBoot를 계획 중에 있는데 Spring 진영은 Socket.io를 지원하지 않는다고 한다.
그래서 내 선택지에서 배제되었다.
2. STOMP
Simple Text Oriented Message Protocol의 약자로 메시지 전송을 효율적으로 하기 위한 메시징 프로토콜.
STOMP는 클라이언트와 서버가 전송할 메시지의 형식(프레임)을 정의한다는데 장점이 있다. 다음과 같은 형식이다.
SEND
destination:/pub/chat/message
content-type:application/json
{"roomId":"1", "message":"안녕하세요!"}
STOMP는 상단에 Command 부가 있어 CONNECT, SEND, SUBSCRIBE와 같은 명령어를 사용하여 어떤 목적으로 메시지를 보냈는지 이해하기 쉽다.
또한, JS는 물론 Spring 프레임워크에서 STOMP를 강력하게 지원하고 있다. @MessageMapping 어노테이션을 통해 MVC 컨트롤러를 작성하듯 익숙하게 백엔드 로직을 구현할 수 있다.
3. MQTT

IoT 기기나 저전력 환경에서 최적화된 가벼운 프로토콜이다.
QoS(전달 보장 단계) 설정이 잘 이루어져 있다는 장점이 있다.
그러나 MQTT는 바이너리 기반이라 디버깅이 까다롭다. 또, 별도 브로커가 필요하다는 단점도 존재한다.
Spring 호환성과 텍스트 기반이라는 웹 개발의 편의성을 고려해봤을 때 STOMP가 가장 적합하다고 판단했다.
그리고 함께 사용하는 기술로 SockJS가 있다는 것을 알게되었다.
SockJS
웹소켓은 HTML5 이후에 나왔기 때문에 HTML5 이전의 기술로 구현된 서비스에서는 웹소켓 기술을 사용할 수 없다.
SockJS는 웹소켓을 지원하지 않는 구형 브라우저에 대체 옵션을 제공한다.
웹소켓 이용을 우선하되, 실패할 경우 SSE나 long polling 방식으로 연결을 시도한다.
또, 이 과정은 런타임에서 이루어지기 때문에 사용자에게 끊김없는 연결을 제공할 수 있다는 장점이 있다.
그리고 이 역시 JS 뿐만아니라 Spring 프레임워크와 쉽게 연동 가능하여 사용하게 되었다.
메시지 브로커
개인 프로젝트 규모라면 단일 서버에서 메모리 내에서 리스트만으로 접속자들에게 부담없이 메시지를 보내줄 수 있다. 그럼에도 메시지 브로커를 선택하게 된 이유는 다음과 같다.
1. 확장성과 신뢰성
사용자가 늘어나 서버를 2대 이상으로 늘리게 된다면 각 서버가 자신에게 접속한 클라이언트의 세션만 알고 있기 때문에 서버간에 사용자가 통신할 수 없는 상황이 생긴다. 메시지 브로커는 이러한 문제를 해결하는 중간 다리 역할을 해준다.
채팅 메시지를 보내면 이를 발행(publish)하고 그러면 이 주제(topic)을 구독(subscribe)하고 있던 모든 서버에 뿌려준다.
이를 통해 어떤 서버에 접속해 있더라도 같은 메시지를 받아볼 수 있다.
2. 책임의 분리
메시지 브로커를 사용하게 되면 발행자는 구독자의 상태에 대해 의존하지 않고 브로커에서 처리하게 된다.
이로 인해 추가적인 서버 구축이나 마이크로서비스로의 분리 등의 업무가 수월해진다.
3. 메시지의 유실 문제
인메모리 리스트는 서버가 꺼지면 데이터가 사라진다.
메시지 브로커는 디스크나 메모리 저장소에 메시지를 저장하기 때문에 안전하게 보관 가능하다.
만약 서버가 다운되더라도 서버가 복구된 후 해당 메시지를 다시 처리할 수 있다.
메시지 브로커 종류
Redis pub/sub
인메모리 저장소인 Redis를 사용하고 메시지 브로커와 같은 개념인 publish/subscribe을 사용한다. 하지만 데이터를 구독자가 받지 못한다면 메시지가 유실된다는 단점이 있었다. 나는 이전 메시지를 전부 볼 수 있는 채팅 서비스를 목표로 하고 있었기 때문에 선택지에서 제외되었다.
RabbitMQ
pub/sub의 단점이었던 메시지 유실을 해결하고 있다.
메시지 전달 보장(ACK) 기능을 통해 메시지 처리를 보장할 수 있다.
RabbitMQ는 발행자(Producer)로부터 소비자(Consumer)에게 전달되는 과정 사이에 'Exchange(교환기)'라는 중계 계층이 존재해서 규칙에 따라 메시지를 분류하고 큐에 넣어준다.
Kafka
여기서 소개하는 메시지 브로커 중 설정과 운영이 가장 까다롭고 리소스를 많이 점유한다.
대규모 데이터 트래픽 처리에 특화되어 있다.
로그 기반 저장소로, 메시지를 읽으면 사라지는 다른 브로커들과 달리 디스크에 물리적으로 저장하여 서버가 다운되어도 데이터를 복구할 수 있고 오프셋 조절을 통해 과거의 데이터를 다시 불러올 수 있다.
Topic을 통해 Producer가 보낼 메시지의 카테고리를 정하고 Consumer는 소비할 topic을 정해서 처리한다.
카프카는 내부에 서버를 여러대 두고 클러스터로 운영해 하나의 토픽을 여러 파티션으로 분산해 트래픽이 늘어나도 수평 확장이 가능하다.
replication 기능을 통해 파티션을 다른 브로커(서버 인스턴스)에 복제한다. 이를 통해 서버 한대가 터지더라도 복제본이 있는 다른 서버가 즉시 역할을 이어받아 중단 없는 서비스를 제공할 수 있다.
Redis Streams
pub/sub과 달리 과거 메시지를 다시 읽을 수 있는 영속성을 지원한다.
Kafka와 같이 log 기반 저장소로 Topic과 유사한 Consumer Group이라는 개념을 사용한다. Consumer Group은 하나의 스트림에 쌓인 메시지를 여러 Consumer가 나누어 처리할 수 있는 개념이다.
또한, Kafka의 오프셋 관리 개념과 유사한 PEL 기능을 통해, 서버 장애 시에도 처리되지 않은 메시지를 다시 추적할 수 있다.
PEL이란 Consumer가 메시지를 가져갔지만 ACK를 아직 보내지 않은 메시지들의 리스트다.
ACK란 컨슈머가 메시지를 성공적으로 처리한 후 성공 응답을 보내는것을 의미한다.
이를 통해 메시지를 처리하다 서버가 다운된 경우 재시작하여 처리되지 못한 부분부터 다시 처리할 수 있어 메시지의 유실이 없다.
현재 규모에서 적은 비용으로 적합한 성능을 내기 위해서 Redis Streams를 선택했는데, 이미 캐시 메모리로 활용 중인 Redis를 그대로 활용할 수 있어 인프라 비용이 적고, Consumer Group을 통해 유실없는 메시지 분산 처리가 가능하다고 판단했기 때문이다.
그러나 Redis는 어디까지나 인메모리 저장소기 때문에 사용할 수 있는 리소스가 제한적이다. 규모가 꽤 있는 실무 서비스를 가정했을 때 사용하기 어려울 것이다. 그래서 단순한 Redis Streams로 서비스를 완성시킨 후 고도화 작업에서 Kafka를 적용시켜보고자 한다. Kafka와 비슷한 부분이 많아 전략 패턴을 통해 인터페이스에 의존하게하고 구현체를 갈아끼우는 방식으로 진행할 예정이다.
정리
다양한 웹 브라우저 환경에서도 안정적인 실시간 통신을 보장하기 위해 WebSocket과 SockJS를 도입했으며, 메시지 규격을 효율적으로 관리하고자 STOMP 프로토콜을 적용했다. 메시지 브로커로는 Redis Streams를 채택했는데, 이는 기존 인프라를 활용함과 동시에 별도의 메시지 브로커(RabbitMQ, Kafka 등) 도입 없이 시스템 복잡도를 낮추고 메시지 전달 보장을 통해 유실없는 메시지 처리를 하고자 했기 때문이다. 전략패턴을 통해 비지니스 로직을 구현하고 향후 고도화 작업을 통해 Kafka로 기술스택을 전환할 예정이다.
'프로젝트' 카테고리의 다른 글
| 실시간 채팅 구현기(3) 코드 분석 (0) | 2026.04.16 |
|---|---|
| 실시간 채팅 구현기(2) 기술 설명 및 구현 방법 (0) | 2026.04.12 |