AquilaLog
WebSocket실시간채팅

WebSocket + STOMP 실시간 채팅 설계기

aquila profile image
aquila
2026.03.24 10:16수정 2026.04.13 13:50조회 33
댓글 0조회33

요약: WebSocket은 “연결”만 열어준다고 해서 곧바로 실시간 채팅이 완성되지는 않습니다. 실제 서비스에서는 메시지 라우팅 규칙, 구독 구조, 인증 시점, 브로커 한계, 프록시 환경, 권한 검증까지 함께 설계해야 하죠. 이 글은 Spring WebSocket + STOMP 기반 채팅 기능을 구성하면서, 왜 이 조합을 선택했고 어떤 문제를 미리 고려해야 했는지까지 실무 관점으로 정리한 기록입니다.

시작하며

실시간 기능을 처음 붙일 때는 보통 기대가 단순합니다.

“메시지를 보내면 상대방 화면에 바로 뜨면 되지 않을까?”

하지만 막상 구현에 들어가 보면, 단순 HTTP 요청/응답과는 결이 완전히 다릅니다.

연결은 한 번 열리고, 메시지는 지속적으로 오가고, 누가 어떤 채널을 구독하는지 관리해야 합니다. 여기에 인증, 권한, 브로커, 장애 상황까지 얹히면 생각보다 훨씬 많은 설계 포인트가 생깁니다.

Spring에서는 이 문제를 풀 때 WebSocket + STOMP 조합을 많이 사용합니다.

WebSocket으로 양방향 연결을 만들고, STOMP로 메시지 목적지와 흐름을 표준화하면, 채팅뿐 아니라 알림, 이벤트 스트림, 실시간 대시보드 같은 기능도 비교적 일관된 구조로 확장할 수 있기 때문입니다.

이번 글에서는 단순히 “어떻게 설정하는가”를 넘어서,

  • 왜 WebSocket만으로는 부족했는지
  • 왜 STOMP를 함께 붙였는지
  • 왜 인증과 브로커 확장성을 초기에 같이 봐야 하는지

를 중심으로, 실제 구현 흐름에 맞춰 정리해보려고 합니다.


TL;DR

항목내용
문제HTTP 요청/응답만으로는 채팅 같은 실시간 양방향 통신을 자연스럽게 처리하기 어려움
핵심 구성WebSocket 연결 + STOMP 메시지 규약 + Broker 기반 구독/배포
핵심 경로클라이언트는 /ws-stomp로 연결하고, /pub로 발행하며, /sub를 구독해 메시지를 받음
인증 포인트HTTP 필터가 아닌 STOMP CONNECT 시점에서 토큰 검증
주의할 점Simple Broker 한계, roomId 스푸핑, 프록시 업그레이드 헤더, 토큰 만료 처리
적합한 상황채팅, 알림, 이벤트 스트림처럼 서버와 클라이언트가 지속적으로 메시지를 주고받아야 하는 기능

왜 HTTP만으로는 부족했을까

처음에는 채팅도 결국 “메시지를 저장하고 다시 불러오는 기능”처럼 보일 수 있습니다.

하지만 실제 사용자 경험은 단순 저장보다 즉시성에 훨씬 민감합니다.

  • 내가 보낸 메시지가 바로 보여야 하고
  • 상대방 화면에도 거의 동시에 반영돼야 하고
  • 입장, 퇴장, 읽음, 알림 같은 상태도 실시간으로 흘러야 합니다

HTTP로도 폴링이나 롱 폴링을 조합해 비슷하게 만들 수는 있습니다. 다만 사용자가 많아질수록 요청 수가 급격히 늘고, 서버와 클라이언트 모두 불필요한 비용을 지게 됩니다.

그 지점에서 WebSocket이 필요해집니다.

WebSocket은 한 번 연결을 열어두고 서버와 클라이언트가 서로 밀어넣듯 메시지를 주고받을 수 있게 해주기 때문에, 채팅처럼 지속적인 양방향 통신이 필요한 기능에 잘 맞습니다.

그런데 여기서 끝이 아니었습니다.

연결만 열린다고 해서 메시지 흐름이 자동으로 정리되지는 않기 때문입니다.

“어떤 메시지를 어디로 보내고, 누가 무엇을 구독하며, 서버는 어떤 규칙으로 라우팅할 것인가?”

이 문제를 정리해주는 것이 바로 STOMP였습니다.


WebSocket만 붙이면 끝이 아니었던 이유

WebSocket API만으로도 메시지를 주고받을 수는 있습니다.

하지만 실무에서 그대로 쓰기 시작하면 금방 이런 문제가 생깁니다.

  • 메시지 포맷이 제각각이 되기 쉬움
  • 라우팅 규칙을 직접 구현해야 함
  • 구독 개념을 일관되게 만들기 어려움
  • 클라이언트와 서버가 약속해야 할 프로토콜이 점점 커짐

즉, WebSocket은 “통신 채널”을 열어주는 기술이지, 메시징 규칙까지 정리해주는 도구는 아닙니다.

그래서 Spring에서는 STOMP를 함께 얹어 많이 사용합니다.

STOMP를 붙이면 메시지 흐름을 다음처럼 나눌 수 있습니다.

  • 연결을 여는 Endpoint
  • 클라이언트가 서버로 보내는 Application Prefix
  • 서버가 구독자에게 배포하는 Broker Prefix

이 구조가 생기면 클라이언트와 서버 모두 훨씬 읽기 쉬운 규칙 안에서 움직일 수 있게 됩니다.


전체 구조를 먼저 잡은 이유

실시간 기능은 처음엔 작은 기능처럼 보여도, 나중에는 인증, 인터셉터, 방 관리, 메시지 저장, 알림 확장 같은 요소가 빠르게 늘어납니다.

그래서 구현보다 먼저 패키지 구조를 어떻게 나눌지를 잡아두는 편이 훨씬 안전했습니다.

실시간 기능은 전역 관심사가 빠르게 늘어납니다

Config, Security, Interceptor 같은 공통 요소를 global에 모아두면 채팅 외에도 알림, SSE, 이벤트 스트림으로 확장할 때 구조가 덜 흔들립니다.

TXT
src/main/java/com/team/bidnow├── BidNowApplication.java├── global│   ├── config│   │   └── WebSocketConfig.java│   ├── handler│   │   └── StompHandler.java│   └── security│       └── JwtTokenProvider.java└── domain    ├── chat    │   ├── controller    │   │   └── ChatController.java    │   ├── dto    │   │   └── ChatMessageDto.java    │   ├── entity    │   │   ├── ChatMessage.java    │   │   └── ChatRoom.java    │   ├── repository    │   │   └── ChatRepository.java    │   └── service    │       └── ChatService.java    └── ...

이 구조를 먼저 정해두면, 채팅 기능이 커져도 파일 위치와 책임이 비교적 안정적으로 유지됩니다.

리소스 설정도 같은 관점에서 분리했습니다.

TXT
src/main/resources├── application.yml└── static/

환경별 설정은 resources에 두고, 실시간 테스트용 정적 페이지가 필요하면 static 아래에 배치하는 방식이 운영과 개발 모두에서 깔끔했습니다.


핵심 개념 한 번에 이해하기 — Endpoint, Prefix, Broker

처음 STOMP를 접했을 때 가장 헷갈렸던 건 설정 값이 아니라 각 요소가 실제로 어떤 책임을 가지는지였습니다.

핵심은 세가지 입니다.
  • Endpoint는 연결을 여는 입구입니다.
  • Prefix는 메시지의 진입 방향과 배포 방향을 나누는 규칙입니다.
  • Broker는 구독자에게 메시지를 전달하는 우체국입니다.
구성 요소역할실무에서 자주 생기는 문제
EndpointWebSocket 연결 URLCORS 설정, 프록시의 업그레이드 헤더 누락
Application Prefix클라이언트가 서버로 보내는 메시지 경로컨트롤러 매핑 충돌, 경로 규칙 혼란
Broker Prefix서버가 구독자에게 메시지를 배포하는 경로트래픽 증가 시 Simple Broker 한계

이 세 가지가 분리되면 실시간 흐름을 한 문장으로 설명할 수 있습니다.

“클라이언트는 Endpoint로 연결하고, Application Prefix로 발행하며, Broker Prefix를 구독해 서버 메시지를 받는다.”


1단계 — 의존성 설정

실시간 채팅의 출발점은 당연히 WebSocket 의존성입니다.

그런데 실제 서비스에서는 “연결만 되는 상태”보다 “누가 연결하는지 검증되는 상태”가 더 중요합니다.

그래서 WebSocket 의존성과 함께 Spring Security도 같이 넣었습니다.

TXT
dependencies {	implementation 'org.springframework.boot:spring-boot-starter-websocket'	compileOnly 'org.projectlombok:lombok'	annotationProcessor 'org.projectlombok:lombok'	implementation 'org.springframework.boot:spring-boot-starter-security'}

이렇게 두면 단순 실시간 연결을 넘어서, 인증 정책까지 연결 흐름 안에 포함시킬 수 있습니다.


2단계 — WebSocketConfig에서 흐름을 고정하기

실제 흐름의 중심은 WebSocketConfig 입니다.

여기서 Endpoint, Broker Prefix, Application Prefix, 그리고 Inbound Interceptor를 함께 묶어 정의했습니다.

Java
import org.springframework.context.annotation.Configuration;import org.springframework.messaging.simp.config.ChannelRegistration;import org.springframework.messaging.simp.config.MessageBrokerRegistry;import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;import org.springframework.web.socket.config.annotation.StompEndpointRegistry;import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;import lombok.RequiredArgsConstructor;
@Configuration@EnableWebSocketMessageBroker@RequiredArgsConstructorpublic class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
private final StompHandler stompHandler;
@Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/ws-stomp") .setAllowedOriginPatterns("*") .withSockJS(); }
@Override public void configureMessageBroker(MessageBrokerRegistry registry) { registry.enableSimpleBroker("/sub"); registry.setApplicationDestinationPrefixes("/pub"); }
@Override public void configureClientInboundChannel(ChannelRegistration registration) { registration.interceptors(stompHandler); }}

이 설정으로 흐름은 꽤 명확해집니다.

  • 클라이언트는 /ws-stomp 로 연결합니다.
  • 메시지는 /pub 경로로 서버에 보냅니다.
  • 서버가 배포한 메시지는 /sub 경로를 구독해 받습니다.

그런데 여기서 중요한 건 “설정이 돌아간다”보다 “운영에서 안전한가”였습니다.

Waring

setAllowedOriginPatterns("*")는 개발 단계에서는 편하지만, 운영 환경에서는 위험할 수 있습니다.

실서비스에서는 실제 도메인만 허용하고, Nginx나 로드밸런서가 WebSocket 업그레이드 헤더를 제대로 전달하는지도 함께 확인해야 합니다.

또 하나 더 있습니다.

enableSimpleBroker 는 시작하기에는 매우 편하지만, 사용자가 늘어날수록 병목이 빨리 드러날 수 있습니다.

Simple Broker는 학습과 초기 구현에는 좋습니다.

하지만 동시 접속자 수와 메시지 처리량이 커지면 메모리 사용량, 브로드캐스트 효율, 다중 서버 확장에서 한계가 드러납니다.

그 시점부터는 RabbitMQ 같은 외부 브로커나 메시지 서버 분리를 검토해야 합니다.


3단계 — ChatController에서 메시지 라우팅하기

다음은 실제로 메시지를 받는 컨트롤러입니다.

HTTP의 @PostMapping 대신, STOMP에서는 @MessageMapping 을 사용합니다.

Java
import org.springframework.messaging.handler.annotation.MessageMapping;import org.springframework.messaging.simp.SimpMessageSendingOperations;import org.springframework.stereotype.Controller;import lombok.RequiredArgsConstructor;
@Controller@RequiredArgsConstructorpublic class ChatController {
private final SimpMessageSendingOperations messagingTemplate;
@MessageMapping("/chat/message") public void message(ChatMessageDto message) {
if (ChatMessageDto.MessageType.ENTER.equals(message.getType())) { message.setContent("대화가 시작되었습니다"); } else if (ChatMessageDto.MessageType.LEAVE.equals(message.getType())) { message.setContent("대화가 종료되었습니다"); }
messagingTemplate.convertAndSend( "/sub/chat/room/" + message.getRoomId(), message ); }}

이 흐름을 풀어보면 이렇습니다.

  1. 클라이언트가 /pub/chat/message 로 메시지를 보냅니다.
  2. 서버는 @MessageMapping("/chat/message") 에서 이를 받습니다.
  3. 메시지 타입이 입장이나 퇴장이라면 안내 문구를 세팅합니다.
  4. 최종 메시지를 /sub/chat/room/{roomId} 구독자에게 브로드캐스트합니다.

이 구조의 장점은 방 단위 구독이 매우 직관적이라는 점입니다.

방에 들어온 사용자는 해당 roomId 구독만 유지하면 되고, 서버는 그 채널로만 메시지를 배포하면 됩니다.

하지만 여기서도 보안 문제가 바로 따라옵니다.

Warning

현재 구조만 보면, 클라이언트가 임의의 roomId 값을 넣어 다른 방으로 메시지를 보내려 시도할 수 있습니다.

즉, STOMP 경로가 분리되어 있다고 해서 곧바로 권한이 보장되지는 않습니다.

반드시 컨트롤러나 서비스 계층에서 “이 사용자가 해당 방에 메시지를 보낼 수 있는가”를 추가로 검증해야 합니다.


4단계 — DTO를 단순 데이터가 아니라 프로토콜로 보기

실시간 시스템에서 DTO는 단순 전달 객체가 아니라, 사실상 클라이언트와 서버가 공유하는 프로토콜에 가깝습니다.

필드 하나가 바뀌면 프론트엔드와 백엔드가 동시에 영향을 받기 때문입니다.

Java
import lombok.Getter;import lombok.Setter;
@Getter@Setterpublic class ChatMessageDto { public enum MessageType { ENTER, TALK, LEAVE } private MessageType type; private String roomId; private String sender; private String content;}

이 DTO는 간단해 보이지만 실시간 채팅의 핵심 약속을 담고 있습니다.

  • 어떤 종류의 메시지인지
  • 어느 방에 속한 메시지인지
  • 누가 보냈는지
  • 실제 본문은 무엇인지

실무에서는 여기에 보통 다음과 같은 고민이 추가됩니다.

  • 메시지 ID
  • 생성 시각
  • 읽음 여부
  • 시스템 메시지 여부
  • 클라이언트 버전 호환성

즉, DTO를 단순히 “필드 몇 개짜리 클래스”로 보면 나중에 프론트와 백엔드가 동시에 흔들릴 가능성이 큽니다.

초기부터 메시지 스키마는 곧 계약이라는 관점으로 다루는 편이 안전했습니다.


5단계 — 인증은 왜 CONNECT 시점에서 처리해야 할까

HTTP 요청은 필터 체인을 매번 통과합니다.

하지만 WebSocket은 한 번 연결이 성립되면, 그 이후에는 일반적인 HTTP 요청/응답 흐름처럼 매번 인증 필터를 타지 않습니다.

그래서 실시간 시스템에서는 보통 STOMP CONNECT 시점에 토큰을 검증합니다.

즉, 메시지가 오기 전에 아예 연결 자체를 거절하는 방식입니다.

Java
import org.springframework.messaging.Message;import org.springframework.messaging.MessageChannel;import org.springframework.messaging.simp.stomp.StompCommand;import org.springframework.messaging.simp.stomp.StompHeaderAccessor;import org.springframework.messaging.support.ChannelInterceptor;import org.springframework.stereotype.Component;import lombok.RequiredArgsConstructor;
@Component@RequiredArgsConstructorpublic class StompHandler implements ChannelInterceptor {
private final JwtTokenProvider jwtTokenProvider;
@Override public Message<?> preSend(Message<?> message, MessageChannel channel) { StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
if (accessor.getCommand() == StompCommand.CONNECT) { String token = accessor.getFirstNativeHeader("Authorization");
if (token == null || !jwtTokenProvider.validateToken(token)) { throw new IllegalArgumentException("유효하지 않은 토큰입니다."); } }
return message; }}

이 접근의 장점은 분명합니다.

비인가 사용자가 구독하거나 발행하기 전에 연결 자체를 막을 수 있기 때문입니다.

다만 운영 관점에서는 이 코드도 그대로 두기엔 아쉬운 지점이 있습니다.

Warning

토큰 검증 실패를 단순히 llegalArgumentException 으로 던지면, 서버 로그에서 원인 구분이 어렵고 클라이언트는 재시도 루프에 빠질 수 있습니다.

운영에서는 예외를 인증 실패, 만료, 서명 오류 등으로 구분하고, 연결 종료 사유와 추적 가능한 로그를 함께 남기는 편이 훨씬 유리합니다.

그리고 여기서 또 하나의 질문이 생깁니다.

“연결 시점에는 유효했지만, 연결 이후 토큰이 만료되면 어떻게 할 것인가?”

이건 단순 CONNECT 검증만으로는 완전히 해결되지 않습니다.

그래서 보통은 다음 중 하나를 함께 고려합니다.

  • 토큰 만료 시 클라이언트 재연결 정책
  • 서버 측 세션 종료 처리
  • 민감한 메시지 발행 시 추가 권한 검증

요청은 실제로 어떻게 흐를까

설정을 다 보고 나면, 전체 흐름을 한 번에 그려보는 것이 이해에 가장 도움이 됐습니다.

sequenceDiagram
    autonumber
    participant Client as 클라이언트
    participant Endpoint as Endpoint<br/>(/ws-stomp)
    participant Interceptor as StompHandler<br/>(Interceptor)
    participant Controller as ChatController
    participant Broker as Simple Broker
    participant Sub as Subscribers<br/>(/sub/chat/room/1)

    Note over Client, Endpoint: 1. 연결 및 인증 단계
    Client->>Endpoint: HTTP Upgrade (WebSocket CONNECT)
    Endpoint->>Interceptor: STOMP CONNECT 전달
    alt 토큰 유효함
        Interceptor-->>Client: CONNECTED (연결 성공)
    else 토큰 유효하지 않음
        Interceptor-->>Client: ERROR (연결 거절/종료)
    end

    Note over Client, Broker: 2. 메시지 발행 및 배포
    Client->>Controller: SEND /pub/chat/message
    Note right of Controller: ENTER/LEAVE 메시지 가공 및 권한 검증
    Controller->>Broker: convertAndSend(/sub/chat/room/1)
    Broker-->>Sub: 구독자들에게 메시지 푸시(Push)

이 시퀀스를 기준으로 보면 각 책임이 명확해집니다.

  • Endpoint 는 연결을 받습니다.
  • Interceptor 는 연결 자격을 검사합니다.
  • Controller 는 메시지 의미를 해석합니다.
  • Broker 는 구독자에게 배포합니다.

실시간 시스템은 결국 “연결”, “검증”, “라우팅”, “배포” 네 단계가 분리되어 있어야 문제가 생겼을 때도 어디를 봐야 할지 빠르게 판단할 수 있었습니다.


실무에서 자주 부딪히는 문제들

구현 자체보다 더 중요했던 건, 운영 환경에서 어떤 문제가 자주 터지는지 미리 인식하는 것이었습니다.

상황증상대응
프록시 뒤 WebSocket연결은 되는 것 같은데 메시지가 안 오거나 끊김업그레이드 헤더, keep-alive, 타임아웃 설정 확인
Simple Broker 한계동시 접속 증가 시 지연, 누락, 끊김 발생외부 브로커 도입 또는 메시징 서버 분리 검토
인증 토큰 만료초기 연결은 성공했지만 중간에 권한 상태가 달라짐재연결 정책, 세션 종료, 추가 검증 정책 설계
방 권한 검증 누락roomId 조작으로 타 채널 메시지 발행 시도 가능서비스 계층에서 사용자-방 권한 매핑 검증

이런 예외들은 대체로 “기능 구현이 끝난 뒤”가 아니라 “사용자가 붙기 시작한 뒤”에 드러납니다.

그래서 구현 단계에서부터 운영 이슈를 함께 보는 편이 훨씬 비용이 적었습니다.


결국 배운 점 — 실시간 채팅의 본질은 연결이 아니라 규칙이다

처음에는 WebSocket을 붙이는 것이 핵심이라고 생각했습니다.

하지만 구현을 정리하고 나니 진짜 핵심은 연결 그 자체보다 메시지 규칙을 어떻게 설계하느냐에 있었습니다.

  • 어디로 연결할 것인가
  • 어디로 보낼 것인가
  • 어디를 구독할 것인가
  • 누가 연결할 수 있는가
  • 누가 어떤 방에 발행할 수 있는가
  • 브로커가 어느 규모까지 버틸 수 있는가

이 질문들에 답하지 않으면, WebSocket은 단순히 “연결만 열린 복잡한 통신 채널”이 되기 쉽습니다.

반대로 이 규칙들이 정리되면 STOMP 기반 구조는 꽤 강력해집니다.

채팅뿐 아니라 알림, 실시간 이벤트, 상태 동기화 같은 기능으로도 자연스럽게 확장할 수 있기 때문입니다.


마치며

이번 구현을 정리하면서 가장 크게 느낀 건, 실시간 기능은 겉보기보다 훨씬 아키텍처적인 기능이라는 점이었습니다.

단순 UI 인터랙션처럼 보이지만, 실제로는 연결 관리, 인증, 라우팅, 브로커, 운영 환경까지 함께 봐야 오래 버티는 구조가 나옵니다.

정리하면 이렇습니다.
  • WebSocket은 실시간 양방향 통신을 가능하게 합니다.
  • STOMP는 메시지 경로와 구독 규칙을 표준화합니다.
  • Spring에서는 Endpoint, Prefix, Broker, Interceptor를 함께 설계해야 합니다.
  • 그리고 운영 단계에서는 인증, 권한, 프록시, 브로커 확장성까지 반드시 같이 봐야 합니다.

채팅 기능은 “메시지가 오간다”에서 끝나지 않습니다.

어떤 구조로 열고, 어떤 규칙으로 흘리고, 어떤 위험을 막을지까지 설계해야 비로소 실서비스에서 버틸 수 있는 기능이 됩니다.

이 글이 Spring WebSocket + STOMP를 처음 붙이려는 분들이나, 이미 구현은 했지만 구조를 다시 정리해보고 싶은 분들에게 도움이 되면 좋겠습니다.


목록으로 돌아가기

댓글

댓글 0