웹소켓? 그게 뭔데?
누구나 전시회를 열어 작품을 전시할 수 있는 프로젝트를 기획하면서, 온라인 전시회의 강점을 살리고자 했습니다.
전통적인 전시회에서는 관람 중 침묵을 유지하는 경우가 많지만, 온라인 전시회는 채팅 기능을 통해 관람객들이 자유롭게 소통할 수 있다는 점에서 차별화된 경험을 제공할 수 있다고 판단했습니다.
이에 저는 실시간 채팅 서비스를 구현하기 위해 웹소켓과 STOMP를 결합한 방식을 선택했습니다.
웹소켓은 HTTP 요청-응답 모델과 달리, 단 한 번의 연결로 클라이언트와 서버 간에 지속적인 양방향 통신을 제공하여 빠른 실시간 반응을 가능하게 합니다. 이러한 특성은 채팅 서비스처럼 사용자 간 즉각적인 상호작용이 필요한 환경에서 매우 적합하다고 생각했습니다.
하지만 웹소켓만으로는 메시지의 구조화나 구독 관리, 그리고 라우팅 기능 등이 기본적으로 제공되지 않기 때문에, 여러 사용자가 동시에 참여하는 채팅 환경에서는 복잡한 로직을 직접 구현해야 한다는 한계가 있었습니다.
이를 보완하기 위해, 저는 웹소켓 위에서 동작하는 텍스트 기반 메시징 프로토콜인 STOMP를 도입했습니다. STOMP는 CONNECT, SEND, SUBSCRIBE 등 표준 명령어를 통해 메시지를 체계적으로 처리할 수 있도록 지원하여, 클라이언트와 서버 간의 메시지 전달을 관리할 수 있도록 했습니다.
다음은 웹소켓과 STOMP를 활용한 메시징 기능을 활성화하기 위해 구성한 설정 클래스입니다.

@EnableWebSocketMessageBroker를 선언함으로써 STOMP 메시지 브로커 기능을 활성화했습니다. 해당 어노테이션을 통해 클라이언트와 서버 간에 구조화된 메시지 교환이 가능해집니다.
configureMessageBroker: 메시지 브로커의 동작 방식을 설정
enableSimpleBroker를 호출하여 클라이언트가 구독할 수 있는 단순 메시지 브로커를 활성화합니다.
﹒ 해당 브로커는 “/sub”으로 시작하는 목적지로 전송된 메시지를 자동으로 처리합니다.
setApplicationDestinationPrefixes를 통해 클라이언트가 서버로 메시지를 전송할 때 “/pub”으로 시작하는 목적지로 설정합니다.
﹒ STOMP 컨트롤러의 @MessageMapping 메서드로 라우팅 되어 애플리케이션 로직이 실행됩니다.
registerStompEndpoints: 클라이언트가 웹소켓 연결을 수립할 엔드포인트를 등록
﹒ addEndpoint를 사용하여 웹소켓 엔드포인트로 “/ws”를 지정합니다.
﹒ setAllowedOriginPatterns을 통해 해당 엔드포인트에 접근할 수 있는 도메인을 설정함으로써 CORS 문제를 해결합니다.
﹒ withSockJS를 통해 브라우저에서 웹소켓을 지원하지 않아도 자동으로 풀백 옵션을 제공하여, 안정적인 연결을 유지했습니다.
테스트? 외 않되?
웹소켓과 STOMP의 모든 설정과 서비스 로직 구현을 완료한 후, 테스트를 진행하려고 하였습니다.
웹소켓은 일반 HTTP 통신이 아닌 특수한 프로토콜이기 때문에, POSTMAN으로 테스트할 때는 통신 방식을 WebSocket으로 변경하여 테스트해야 합니다.
먼저, 이전에 지정한 엔드포인트로 요청을 보내는 방식에 대해 말씀드리면, “ws://localhost:8080/ws”로 요청을 보내면 정상적으로 웹소켓이 연결됨을 확인할 수 있습니다.
STOMP 테스트에 관해 언급한다면, POSTMAN에서는 RAW(기본 웹소켓) 모드로 설정한 후 헤더에 필요한 정보를 추가하고, 베이스 64 인코딩 후 마지막에 null 문자를 추가하면 STOMP 테스트도 가능하다고 합니다.
다만, 이러한 방식은 매우 복잡하고 번거로워서 별도의 테스트 도구나 웹 앱을 사용하기로 했습니다.
저는 메시지 송수신 테스트를 위해 APIC 테스트 사이트를 사용하였습니다.
여기서 문제가 좀 있었는데, 이전에는 해당 웹 사이트 접속이 가능하여 테스트를 진행할 수 있었습니다. 하지만 그때 당시 따로 사진으로 남겨두었던 자료가 없어 다시 접속을 하였는데, 현재는 사이트 접근이 불가능하여 테스트를 수행할 수 없는 상황입니다. 웹 앱으로 출시되었다는 글을 보고 크롬 확장 프로그램 설치까지 진행하였으나, 여전히 접속이 되지 않아 글로 설명하도록 하겠습니다.
우선 아래는 웹소켓 전송 컨트롤러 로직입니다.

APIC에서 STOMP 테스트를 위해서는 Subscription URL과 Destination Queue에 엔드포인트를 각각 지정해줘야 합니다.

Subscription URL은 클라이언트가 서버에서 발행한 메시지를 받아보기 위해 구독하는 경로입니다.
위 코드에서는 simpMessageSendingOperations.convertAndSend("/sub/ws/" + chatRoomId, chatMessageCreateDto)를 사용하여 메시지를 전송합니다. 채팅방 ID가 1인 경우, 서버는 메시지를 “/sub/ws/1”로 전송하게 됩니다. 따라서, 클라이언트는 해당 채팅방의 새 메시지를 받기 위해서 “/sub/ws/1”에 구독을 신청해야 합니다.
Destination Queue는 클라이언트가 서버로 메시지를 보내기 위해 사용하는 경로입니다.
위 코드에서는 @MessageMapping(value = "/ws/{chat-room-id}/chat-messages")는 클라이언트가 메시지를 보낼 때 사용해야 하는 URL입니다.
채팅방 ID가 1인 경우, 클라이언트는 “/ws/1/chat-messages”로 JSON 형식의 ChatMessageCreateDto를 전송합니다.
여기까지 엔드포인트를 지정하고 테스트를 실행했는데, 정상적으로 실행할 수 없었습니다.
그 이유는 웹소켓 설정 시 withSockJS를 사용하였기 때문이었습니다. withSockJS는 웹 브라우저에서 웹소켓을 지원하지 않는 경우 풀백 메커니즘으로 HTTP 기반의 통신을 제공하기 위해 사용됩니다. 그러나 APIC 테스트 사이트와 같이 순수 웹소켓 통신만을 지원하는 환경에서는 SockJS의 풀백 기능이 제대로 동작하지 않아 테스트가 실패하는 현상이 발생하였습니다.
따라서, withSockJS 설정을 제거하니 정상적으로 테스트를 진행할 수 있었습니다.
채팅방 접속 사용자 목록 조회
처음에는 채팅방에 접속한 사용자들의 정보를 채팅방 ID를 기반으로 조회하는 방식으로 구현하려 했습니다. 그러나 다수의 사용자가 동시에 접속할 경우, 데이터베이스 조회나 단순한 인메모리 검색 방식은 성능 저하를 초래할 수 있다는 문제가 있었습니다.
특히, 데이터베이스 조회 방식은 실시간성이 중요한 웹소켓 환경에서 비효율적이었으며, 인메모리에서 데이터를 관리한다고 해도 효율적인 조회 및 동기화 방식이 필요했습니다.
이러한 문제를 해결하기 위해 웹소켓 이벤트 리스너를 활용하여 사용자 연결 및 연결 해제 이벤트 발생 시마다 세션 정보를 관리하는 방식으로 개선했습니다.
﹒ SessionSubscribeEvent: 사용자가 채팅방에 입장할 때 감지하여 사용자 정보를 등록.
﹒ SessionDisconnectEvent: 사용자가 채팅방을 나갈 때 감지하여 사용자 정보를 제거.
﹒ MemberSessionRegistry: 사용자 정보를 저장하는 인메모리 저장소(ConcurrentHashMap 활용).
이를 통해, 별도의 실시간 데이터베이스 조회 없이도 인메모리에서 빠르게 사용자 정보를 관리할 수 있도록 개선하였습니다.
1. 웹소켓 이벤트 리스너
﹒ 웹소켓의 구독 및 연결 해제 이벤트를 감지하여, 사용자 정보를 동적으로 관리합니다.

2. 사용자 세션 저장 클래스
﹒ 사용자의 세션 정보를 인메모리에서 관리하기 위해 ConcurrentHashMap을 활용하여 빠른 조회가 가능하도록 설계했습니다.

이를 통해 실시간 데이터베이스 조회 없이도 빠르게 접속 사용자 목록을 조회 가능하도록 하였습니다.
웹소켓 이벤트를 활용하여, 불필요한 쿼리 호출을 줄이고 성능을 최적화하였고, ConcurrentHashMap을 사용하여 동시성 문제를 해결하고 빠른 조회가 가능하도록 했습니다.
하지만 서버가 재시작되면 세션 정보는 초기화가 되기 때문에 Redis와 같은 외부 저장소로 확장 가능할 수 있다고 생각합니다.
현재는 단일 인스턴스로 실행되고 있지만, 서버 인스턴스가 여러 개로 분산될 경우 동기화 문제가 발생할 수 있어 메시지 큐인 Kafka나 Redis의 PUB/SUB 모델을 활용하는 것도 좋은 방법이라고 생각합니다. 또한, 인메모리 저장 방식은 데이터가 많아질 경우 메모리 사용량이 증가하기 때문에 분산 캐시 활용도 충분히 고려해 볼만하다고 생각합니다.
웹소켓 이벤트 기반의 사용자 세션 관리 방식을 도임하여, 실시간 성능을 최적화하면서도 서버 부하를 줄이는 구조를 만들 수 있었습니다.
추후 대규모 트래픽 환경에서도 안정적으로 운영하기 위해서는 Redis, 메시지 큐, 분산 시스템 적용 등의 확장 가능성을 열어두고 있으며, 이에 대한 학습을 지속하고 있습니다.
RDB에 다 저장하면 서버 터지겠습니다만?
채팅 서비스에서는 사용자가 채팅방에 입장할 때, 이전 대화 내역을 빠르게 조회할 수 있어야 합니다. 이를 위해 모든 채팅 메시지를 RDB에 저장하는 방식을 고려했지만, 몇 가지 문제점이 있었습니다.
﹒ 사용자가 많아질수록 채팅 메시지 저장과 조회 요청이 급증하여 RDB의 부하가 증가하는 문제가 발생할 수 있다고 판단하였습니다.
﹒ 디스크 기반의 RDB는 I/O 속도에 한계가 있어 실시간 메시지 조회 시 즉각적인 응답을 보장하기 어렵습니다.
﹒ 실시간 채팅에서 생성되는 대량의 메시지를 즉시 RDB에 저장하는 것을 비효율적이라 생각했습니다.
이러한 문제를 해결하기 위해, Redis를 활용한 메시지 저장 구조를 도입하였습니다. 채팅 메시지를 먼저 Redis에 저장하고, 사용자는 Redis에서 메시지를 조회하도록 하며, 일정 주기로 Redis에 저장된 데이터를 RDB에 일괄 저장하는 방식으로 개선했습니다.
Redis를 활용한 채팅 메시지 저장 및 조회 방식은 다음과 같이 구성되었습니다.
1️⃣ 채팅 메시지 저장: Redis → RDB로 일괄 저장
﹒ 채팅 메시지는 먼저 Redis에 저장되며, 이를 통해 실시간 저장 및 조회가 가능합니다.
﹒ Redis는 인메모리 방식으로 RDB보다 빠른 읽기와 쓰기 성능을 제공하며, 높은 응답 속도가 요구되는 환경에 적합합니다.

2️⃣ Redis → RDB 일괄 저장 스케줄러
﹒ 사용자는 이전 채팅 내역을 조회할 때 Redis에서 데이터를 불러오므로, RDB보다 훨씬 빠른 응답 속도를 제공할 수 있습니다.
﹒ Redis에 저장된 메시지가 부족하면, RDB에서 조회하여 Redis에 캐싱한 후 제공하는 방식으로 성능을 최적화할 수 있습니다.
﹒ 또한, 5분 간격마다 실행되는 스케줄러를 통해 Redis에 저장된 메시지를 한 번에 RDB로 저장하는 과정을 자동화하였습니다.

이를 통해 데이터베이스 부하를 줄이고, 실시간 메시지 저장을 Redis에서 처리하여 RDB에 대한 지속적인 쓰기 부담을 최소화할 수 있었습니다.
또한, 사용자는 Redis에서 메시지를 조회하므로 빠른 응답 속도를 제공할 수 있도록 설계했습니다. 더불어, 5분 간격으로 일괄 저장을 수행함으로써 데이터베이스 성능을 최적화하고, 필요할 때만 RDB에 접근하여 저장하도록 구성했습니다.
다만, Redis 장애가 발생했을 때 메시지 유실 가능성이 존재하므로, RDB와의 동기화를 보장할 수 있는 추가적인 방안이 필요합니다.
그리고 채팅방 접속 사용자 목록 관리와 마찬가지로, 서버 인스턴스가 증가하는 경우 Redis 클러스터링 또는 메시지 큐와의 연계 방안을 고려해 볼 필요가 있다고 생각합니다.
마치며
웹소켓과 STOMP를 활용하여 실시간 채팅 서비스를 구현하면서, Kafka와 RabbitMQ 같은 메시지 브로커의 도입을 고민했습니다. 하지만 현재 서버는 단일 인스턴스 환경으로 구성되어 있어, 메시지 브로커를 추가하는 것이 꼭 필요할지에 대한 고민이 있었습니다. 또한, Kafka나 RabbitMQ를 적용하면 서버가 무거워지는 부담이 생길 수 있다는 현실적인 이유도 고려해야 했습니다.
대안으로 Redis의 PUB/SUB 모델을 활용하는 방법도 검토했지만, 적용하는 데 시간이 필요했고 학습 부담이 컸습니다. 결국, 이번 프로젝트에서는 웹소켓과 STOMP를 활용하여 최대한의 효율을 내는 방향으로 개발을 진행했습니다.
이번 프로젝트를 진행하면서, 기존의 RESTful API를 활용한 HTTP 통신 방식과는 완전히 다른 접근 방식으로 서비스를 구현해야 했습니다. 웹소켓과 STOMP의 비동기 통신 모델을 처음 적용해 보며, 메시지 브로커 설정, 세션 관리, 사용자 상태 유지 등의 개념을 깊이 이해할 수 있었습니다. 물론, 처음에는 새로운 개념을 익히고 적용하는 과정에서 어려움이 있었지만, 결과적으로 실시간 통신 구조에 대한 이해도를 높이는 좋은 경험이 되었습니다.
PUB/SUB 구조에 대한 개념을 깊이 이해하고, 새로운 방식의 테스트 및 검증 방법도 경험할 수 있었습니다. 향후 시스템이 다중 인스턴스 환경으로 확장되거나, 더 높은 확장성과 안정성이 요구될 경우 Kafka나 RabbitMQ를 통한 메시지 처리 최적화, Redis PUB/SUB 활용, 웹소켓의 한계를 보완할 수 있는 대체 기술 검토 등을 고려해 볼 수 있을 것 같습니다.
이번 프로젝트를 통해 실시간 통신을 위한 최적화 방안을 고민하며, 새로운 기술을 직접 적용하고 학습하는 경험을 쌓을 수 있었습니다. 이 과정에서 얻은 지식과 경험이 앞으로의 개발 과정에서도 큰 도움이 될 것이라 생각합니다.
'REFLECTION > 7️⃣ LUCKY7 트러블 슈팅' 카테고리의 다른 글
| [ 7️⃣ LUCKY-SEVEN ] JMeter와 함께한 병목 추적기 (0) | 2025.04.09 |
|---|---|
| [ 7️⃣ LUCKY-SEVEN ] 배포 성공을 위한 삽질의 기록 (0) | 2025.02.26 |
| [ 7️⃣ LUCKY-SEVEN ] 로그인 시스템을 위한 JWT 고군분투기 (0) | 2025.02.24 |
