본문 바로가기
Spring/WebSocket&STOMP

[Spring WebSocket] 웹소켓(WebSocket)과 STOMP, 그리고 Redis로 구현하는 실시간 채팅 정리

by coding_whale 2026. 5. 31.
반응형

1. 도입부: 실시간 웹이라는 거대한 도전

전통적인 웹은 "클라이언트가 요청(Request)을 보내면, 서버가 그에 맞는 응답(Response)을 주는" 단방향의 정적인 통신을 기반으로 설계되었다. 이 구조는 웹서버의 자원을 효율적으로 관리하는 데 매우 유리했지만, "실시간(Real-time) 상호작용"이 필수적인 현대 웹 애플리케이션(채팅, 실시간 주식 차트, 협업 도구 등)을 구현하기에는 근본적인 한계가 존재했다.

서버가 클라이언트에게 먼저 이벤트를 보낼 수는 없을까? 클라이언트와 서버가 한 번 연결을 맺고 끊임없이 가볍게 데이터를 주고받는 방법은 없을까?

이러한 물음에서 실시간 통신 기술들의 진화가 시작되었다. 초기 개발자들이 우회책으로 쓰던 Polling 방식부터 시작하여, 반이중 단방향 스트리밍인 SSE(Server-Sent Events), 그리고 현대 실시간 웹의 표준인 웹소켓(WebSocket)과 메시징 프레임워크 STOMP, 더 나아가 대규모 트래픽을 감당하기 위한 Redis Pub/Sub 분산 아키텍처까지 차례대로 깊숙이 파헤쳐 보자.

 

2. 웹 통신의 진화: Polling, SSE, 그리고 WebSocket

실시간 통신을 구현하기 위해 백엔드 아키텍처가 선택할 수 있는 전략은 크게 세 가지 흐름으로 나뉜다. 이들의 동작 원리와 장단점을 비교하는 것에서부터 실시간 기술의 본질을 이해해야 한다.

2.1 Polling (주기적 데이터 확인)

가장 원초적인 방식이다. 클라이언트가 특정 시간 간격(예: 3초, 5초)마다 서버에 "혹시 새로운 메시지 있어?" 하고 계속해서 HTTP GET 요청을 던지는 구조다.

  • 장점: 특별한 서버 설정이나 추가적인 프로토콜 없이 순수 HTTP 요청만으로 즉시 구현이 가능하다.
  • 단점: 새로운 메시지가 없더라도 주기적으로 무의미한 HTTP 연결을 맺고 끊어야 한다. 무거운 HTTP 헤더(Header)가 매 요청마다 동반되므로 대용량 트래픽 환경에서 서버 커넥션 과부하와 불필요한 네트워크 대역폭 낭비가 치명적이다.

 

2.2 SSE (Server-Sent Events: 서버 단방향 이벤트 송신)

HTTP 프로토콜 위에서 동작하면서, 서버에서 클라이언트로 실시간 이벤트를 흘려보내는(Streaming) 단방향 통신 기술이다. 최초 연결(Connection)을 유지한 상태로 서버가 끊임없이 텍스트 데이터를 Push한다.

  • 동작 방향: $서버 \longrightarrow 클라이언트$ (단방향)
  • 클라이언트의 송신: 클라이언트가 서버로 메시지를 보낼 때는 일반적인 HTTP 통신(POST, PUT 등)을 사용한다.
  • 장점: HTTP 기반이라 프록시, 방화벽, 인증 설정을 그대로 재사용할 수 있으며 재연결(Reconnect) 메커니즘을 브라우저가 기본 지원한다. 주로 사용자 알림 알림, 실시간 피드, 주가 지수 등 서버 업데이트가 주를 이루고 양방향 타자가 빈번하지 않은 도메인에 극도로 유용하다.

 

2.3 WebSocket (전이중 양방향 실시간 프로토콜)

클라이언트와 서버 간에 지속적이고 가벼운 양방향 커넥션을 유지하는 연결형 프로토콜이다. 최초 1회, HTTP 요청을 통한 통신 규칙 규약 합의(Handshake) 과정을 거친 후 프로토콜을 ws:// 또는 wss://로 업그레이드하여 통신을 전이중(Full-Duplex) 방식으로 전개한다.

  • 동작 방향: $서버 \longleftrightarrow 클라이언트$ (실시간 양방향)
  • 장점: 헤더가 극도로 가볍다. (초기 오버헤드 이후에는 단 몇 바이트의 프레임으로 메시지를 교환). 무거운 HTTP 메시지 객체 변환이나 매번 TCP 3-way handshake를 실행할 필요가 없어 지연 시간(Latency)이 거의 없는 빠른 통신을 보장한다.

💡 기술적 원리: HTTP는 비연결형(Stateless/Connectionless)으로 매번 통신 자원을 새로 획득해야 하는 반면, 웹소켓은 Stateful 프로토콜로 소켓 연결 자체가 메모리에 장시간 유지된다. 따라서 실시간 다중 사용자 채팅 환경에서는 웹소켓이 서버 부하와 응답성 측면에서 압도적인 우위를 점하게 된다.

3. HTTP 통신과 웹소켓 연결의 심층 비교

웹소켓이 무조건 HTTP보다 우월한 것은 아니다. 각 프로토콜은 탄생 배경과 네트워크 생명 주기가 완전히 다르다.

비교 항목 HTTP 프로토콜 WebSocket 프로토콜
연결 방식 비연결형 (Connectionless) 연결 지향형 (Connection-Oriented)
통신 방향 단방향 (Client -> Server 요청에 대한 응답만 가능) 양방향 (Client <-> Server 전이중 양방향)
프로토콜 스키마 http://, https:// ws://, wss:// (보안 적용)
연결 지속성 요청-응답 주기 후 종료 (Keep-Alive 옵션으로 완화하나 근본은 단발성) 한 번 맺은 TCP 커넥션을 명시적 닫기 전까지 영구 유지
네트워크 부하 매번 거대한 HTTP 헤더와 공통 데이터가 수반됨 연결 수립 시 가볍고 단순한 데이터 프레임만 전달하여 부하 극소화

 

3.1 웹소켓 핸드셰이크(Handshake)의 비밀

웹소켓도 완전히 새로운 전용 망을 타고 들어가는 것이 아니다. 기존 HTTP 80/443 포트 인프라를 그대로 사용하기 때문에 최초 연결 시 HTTP 헤더를 이용한 합의를 진행한다.

  1. 클라이언트 요청 (Upgrade Request):
    • Upgrade: websocket과 Connection: Upgrade 헤더를 통해 "서버야, 이 HTTP 요청을 웹소켓 프로토콜로 바꿔줘!"라고 강력히 신호를 보낸다.
    • 이때 HTTP 인증 헤더(Authorization)나 쿼리 스트링을 통해 JWT, Token 등의 인증 정보를 실어서 보낼 수 있다. 최초 1회 핸드셰이크 시점에서 가볍게 사용자 세션을 검증하고 안전하게 보안 관문을 통과시키는 것이 실무 보안의 정석이다.
GET /chat/inbox HTTP/1.1 
Host: myapp.com 
Upgrade: websocket 
Connection: Upgrade 
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== 
Sec-WebSocket-Version: 13 
Authorization: Bearer jwt_token_value_here
  1. 서버 응답 (101 Switching Protocols):
    • 서버가 이를 정상 수락하면 상태 코드 101을 반환하고, 이 순간부터 TCP 소켓 세션은 더 이상 HTTP가 아닌 WebSocket 프로토콜로 전환되어 동작하게 된다.
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

 

 

4. 단순 웹소켓의 한계와 STOMP 프로토콜의 등장

막상 기본 웹소켓(ws://)만을 가지고 실시간 채팅방을 구현하려고 하면 즉각 기술적 난관에 부딪힌다.

4.1 기본 웹소켓의 문제점

순수 웹소켓은 단지 텍스트 또는 바이너리 데이터 프레임을 송수신하는 파이프 역할만 제공한다. 그 말은 즉, 들어온 텍스트가 "채팅방 생성"에 대한 신호인지, "채팅 메시지 송신"인지, 아니면 "사용자 퇴장"에 대한 명령어인지 규격화되어 있지 않다는 뜻이다.

이 때문에 순수 웹소켓만 쓰면 들어온 JSON 메시지를 일일이 내부 조건문(if-else 분기문)으로 뜯어보고 헤더와 본문을 분리 정규화하는 파싱 코드를 백엔드에 잔뜩 도배하게 된다. 이는 유지보수성을 바닥으로 떨어뜨리는 주범이 된다.

 

4.2 STOMP (Simple Text Oriented Messaging Protocol)

STOMP는 웹소켓 위에서 작동하는 서브 메시징 프로토콜이다. 클라이언트와 서버가 서로 어떤 규격(Format)과 규칙으로 통신할지 약속한 프레임 규약이다.

STOMP는 이메일이나 HTTP처럼 명확한 Frame, Header, Body 구조를 갖추고 있어, 들어오는 메시지를 구조적으로 가공할 수 있게 돕는다.

SEND
destination:/pub/chat/message
content-type:application/json

{"roomId": "room-104", "sender": "VueMaster", "message": "안녕하세요!"}
  • 명령어(Command): SEND, SUBSCRIBE, CONNECT, DISCONNECT 등 행위를 명시한다.
  • 헤더(Header): destination (목적지/경로), content-type 등 메타데이터를 담는다.
  • 바디(Body): 실제 전송할 비즈니스 데이터(JSON 등)를 실어 보낸다.

 

4.3 목적지 기반 메시지 라우팅 (Destination-based Routing)

STOMP의 가장 강력한 강점은 발행/구독(Publish/Subscribe) 패러다임을 바탕으로 메시지를 목적지(Destination) 경로 단위로 자동 분류 및 배달(라우팅)해 준다는 점이다.

  • 발행(Publish): 클라이언트는 메시지를 특정 주소(예: /pub/chat/message)로 발행한다.
  • 구독(Subscribe): 클라이언트는 특정 채널 주소(예: /sub/chat/room/104)를 구독하고 기다린다.
  • 브로커(Broker): 스프링 내장 심플 브로커(Simple Message Broker)가 /sub 경로를 매핑하여 메시지를 올바른 채널 구독자들에게 실시간 브로드캐스팅(Broadcasting)해 준다.

💡 기술적 원리: STOMP 프로토콜을 사용하면 백엔드 서버는 복잡한 커넥션 세션 맵과 소켓 리스트를 수동으로 관리하지 않아도 된다. 대신 경로(URI) 지향의 컨트롤러 맵핑을 구현하여 일반적인 MVC 패턴처럼 코드를 안전하고 유연하게 고도화할 수 있다.

 

 

5. 다중 웹소켓 서버(Multi-Server)의 한계와 Redis Pub/Sub 도입

실시간 채팅방 서비스를 성공적으로 론칭하여 사용자가 폭발적으로 급증하면, 단일 스프링부트 서버 장비 하나로는 수많은 웹소켓 TCP 연결 커넥션을 감당할 수 없게 된다. 결국 서버를 3대, 5대로 수평 확장(Scale-out)하고 그 앞에 Nginx 등 로드밸런서를 장착하여 트래픽을 분산시켜야 한다.

그러나 이 시점에서 웹소켓 통신의 가장 치명적인 아키텍처 장애가 발생한다. 바로 웹소켓 세션의 인메모리 의존성 문제다.

5.1 싱글 서버 메모리 의존성으로 인한 메시지 실종 사건

웹소켓 세션(WebSocketSession) 객체는 클라이언트와 연결이 이루어진 해당 웹 어플리케이션 서버(WAS)의 로컬 RAM 메모리에 저장된다. 이 세션 객체는 다른 물리 장비나 컨테이너 서버와 공유할 수 없다.

 

 

5.2 Redis Pub/Sub을 활용한 분산 브로드캐스팅 구원 투수

이러한 한계를 극복하고 멀티 서버 간에 메시지 송수신 경로를 유기적으로 전파 및 결합하기 위해 Redis Pub/Sub(발행/구독) 기능을 활용한다. Redis는 메모리 기반의 데이터 저장소로서 극도로 빠를 뿐만 아니라 고성능의 실시간 메시지 분배(Pub/Sub) 엔진을 탑재하고 있다.

백엔드 서버들을 모두 하나의 중앙 Redis 인스턴스(혹은 클러스터)의 특정 토픽(Topic) 채널에 전원 구독(Subscribe)자로 밀어 넣는 방식이다.

  1. 클라이언트 송신: Client A가 Server 1에 메시지를 송신한다.
  2. Redis 발행 (Publish): Server 1은 연결된 사용자에게 즉각 메시지를 뿌리지 않고, 해당 메시지를 즉각 중앙 Redis의 채널 토픽으로 Publish한다.
  3. 서버 간 전파 (Subscribers): Server 1을 포함해 Redis를 동시에 실시간으로 Subscribe하고 있는 모든 백엔드 서버들(Server 1, 2, 3...)이 Redis로부터 일제히 실시간 메시지 패킷을 수신(전파)받는다.
  4. 최종 도달: 메시지를 전달받은 각 서버들은 오직 자기 자신의 로컬 메모리 소켓 세션 맵을 검사한다. 이 방에 수신 대상 유저가 본인 서버에 연결되어 있다면, 드디어 소켓 세션을 태워 사용자 단말기 브라우저(Vue.js 등)에 실시간으로 최종 발송한다.

💡 기술적 원리: 이 구조를 활용하면 웹소켓 통신의 스케일아웃 제약이 완전히 사라진다. 새로운 서버 WAS가 100대로 늘어나더라도 기동 시점에 Redis Topic에 등록(구독)만 완료하면, 메시지가 모든 인스턴스를 관통하여 실시간으로 전달되므로 완벽한 Stateless 백엔드 설계에 무한히 가까워지게 된다.

 

 

6. 실무 관점에서의 핵심 트러블슈팅 및 고려 사항

실전에서 안정적인 웹소켓 채팅 서비스를 운영하려면 다음과 같은 심화 설계가 보강되어야 한다.

6.1 웹소켓 핸드셰이크 시점의 JWT 토큰 인증과 보안

많은 개발자가 웹소켓을 연결할 때 일반 HTTP 인증용 스프링 시큐리티 인터셉터나 필터를 태우려고 시도하다 곤혹을 치른다. 웹소켓 핸드셰이크는 최초 1회만 HTTP 기반이고 그 이후엔 통신 규칙이 영구 변경(Switching)된다. 따라서 매번 메시지가 들어올 때마다 헤더에 토큰을 태우는 것은 대단히 무겁고 낭비적이다.

  • 해결책 (Spring Security ChannelInterceptor): Spring 메시징 아키텍처에서는 ChannelInterceptor의 preSend() 메서드를 재정의하여 처리한다. 최초 클라이언트가 서버에 연결 신호를 보내는 StompCommand.CONNECT 시점에 한하여, STOMP 헤더(nativeHeaders)에서 JWT 토큰을 추출해 사용자의 신원을 명확히 검증하고, 유효하지 않은 세션인 경우 아예 커넥션 수립 단계에서 Fail-Fast하게 연결을 거절 및 드롭시켜야 한다.
// Spring STOMP 보안 인증 인터셉터 예시
@Order(Ordered.HIGHEST_PRECEDENCE + 99)
public class JwtChannelInterceptor implements ChannelInterceptor {
    private final JwtTokenProvider tokenProvider;

    @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel) {
        StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
        
        // 웹소켓 최초 연결 시점에만 보안 서명 인증 진행
        if (StompCommand.CONNECT.equals(accessor.getCommand())) {
            String authToken = accessor.getFirstNativeHeader("Authorization");
            if (authToken != null && authToken.startsWith("Bearer ")) {
                String jwt = authToken.substring(7);
                if (tokenProvider.validateToken(jwt)) {
                    // 성공 시 인증 컨텍스트 설정 및 보관
                    Authentication userAuth = tokenProvider.getAuthentication(jwt);
                    accessor.setUser(userAuth);
                } else {
                    throw new MessageDeliveryException("UNAUTHORIZED_WEBSOCKET_ACCESS");
                }
            }
        }
        return message;
    }
}

 

6.2 Redis Pub/Sub 메시지 손실과 Message Store(DB) 연계 설계

Redis Pub/Sub은 기본적으로 "Fire and Forget(전송 후 망각)" 패러다임이다. 즉, 메시지를 뿌려준 뒤 수신자(구독자인 WAS들)가 실제로 잘 받았는지 확인하지 않고 즉시 데이터를 지워버린다.

만약 특정 스프링 WAS 장비가 배포나 하드웨어 장애 문제로 0.5초 동안 재부팅되거나 일시 정지(Stop-the-world) 상태에 들어갔다면, 그 짧은 시간 동안 Redis를 통해 전파된 소중한 채팅 메시지는 WAS의 실시간 메모리에 닿지 못하고 공중 분해(유실)된다.

  • 현업 설계 가이드: 따라서 채팅 메시지가 발생하면 즉각 Redis Pub/Sub으로만 끝내는 것이 아니라, 비동기 파이프라인(예: 별도 스레드 풀, 이벤트 리스너, 혹은 Kafka)을 활용해 MySQL, PostgreSQL, MongoDB, Cassandra 등의 영구 저장소(Message Store)에 먼저 이력을 보관해 두어야 한다. 클라이언트 Vue.js 애플리케이션은 웹소켓 연결이 예기치 않게 끊겼다가 복구될 때(Reconnection), 마지막으로 수신한 메시지 ID 이후의 유실 구간 데이터를 HTTP REST API로 영구 저장소로부터 즉각 보충 로딩(Sync-up)하는 복구 로직을 장착해야 장애에 완벽하게 내성을 가질 수 있다.

 

 

7. 마무리: 실시간 웹의 지평을 넓히다

우리는 HTTP 프로토콜의 단방향 정적 구조에서 출발해, 가벼운 연결 유지형 웹소켓의 출현, 그리고 무규격 데이터를 정형화하는 STOMP 프로토콜을 통과하여 다중 서버 장벽을 돌파하는 Redis Pub/Sub 분산 브로드캐스팅 아키텍처까지 완전 정복했다.

실시간 통신 아키텍처는 단순히 연결을 "유지하는가 끊는가"의 기술적 도구 선택 문제가 아니다. 본인 서비스의 비즈니스 목적과 트래픽 규모에 따라 적절한 도구를 합리적으로 선택하고, 인프라 수평 확장을 예측하여 설계 단계를 쌓아나가는 거대한 구조화 게임이다.

이번 정리 노트를 통해 웹소켓 채팅 시스템의 이면을 가늠하고, 견고한 대용량 백엔드 채팅 인프라를 설계할 수 있는 소중한 밑거름을 획득했기를 바란다.

반응형