1. 도입부: 왜 단순 웹소켓(WebSocket)이 아니라 STOMP인가?
우리는 이전 포스팅에서 HTTP 프로토콜의 단방향 통신 한계를 극복하기 위해 웹소켓(WebSocket)을 도입했다. 웹소켓은 최초 1회 핸드셰이크(Handshake) 과정을 거쳐 HTTP 프로토콜을 TCP 연결로 업그레이드(Upgrade)한 뒤, 지속적인 양방향 통신을 가능하게 해준다.
하지만 순수 웹소켓(Raw WebSocket)만을 사용하여 채팅 서비스를 만들려고 하면 다음과 같은 중대한 장벽에 직면하게 된다.
- 메시지 규격의 부재: 들어오고 나가는 메시지가 단순 텍스트(문자열) 혹은 바이너리 형태이기 때문에, 개발자가 직접 메시지 포맷(예: JSON 구조)을 파싱하고 해석하는 파이프라인을 매번 직접 구현해야 한다.
- 구독 및 발행(Pub/Sub) 구현의 어려움: 어떤 사용자가 어느 채팅방(Topic)에 참여하고 있으며, 특정 메시지를 누구에게 전달해야 하는지에 대한 라우팅 로직을 서버 메모리 상에서 손수 코딩해야 한다.
이 문제를 해결해 주는 구원투수가 바로 STOMP(Simple Text Oriented Messaging Protocol)다.
STOMP는 웹소켓 위에서 동작하는 서브 프로토콜로, 클라이언트와 서버가 서로 메시지를 보낼 때 정해진 메시지 프레임 규격(CONNECT, SUBSCRIBE, SEND, MESSAGE, DISCONNECT 등)을 따르도록 강제한다. 이를 통해 우리는 스프링이 제공하는 강력한 메시지 브로커 인프라를 그대로 활용할 수 있게 되며, 개발자는 온전히 메시지 비즈니스 로직에만 집중할 수 있게 된다.
2. STOMP 메시지 흐름과 스프링 내부 아키텍처
코드 분석에 들어가기 앞서, 스프링 내부에서 STOMP 메시지가 어떻게 순환하고 처리되는지 그 생명주기(Life Cycle)와 아키텍처를 이해하는 것이 필수적이다.

3. 웹소켓 브로커 핵심 설정: StompWebSocketConfig
스프링 부트에서 STOMP를 가동하기 위해서는 WebSocketMessageBrokerConfigurer 인터페이스를 구현하고 @EnableWebSocketMessageBroker 애노테이션을 설정 클래스에 부여해야 한다.
이 설정 클래스는 최초 소켓 연결을 맺기 위한 엔드포인트(Endpoint) 정의와, 메시지를 송수신하기 위한 라우팅 프리픽스를 정의하는 '컨트롤 타워' 역할을 수행한다.
📌 StompWebSocketConfig 소스 코드
/**
* STOMP 프로토콜을 기반으로 WebSocket 메시지 브로커를 설정하고 구성하는 클래스입니다.
*/
@Configuration
@EnableWebSocketMessageBroker // WebSocketMessageBroker를 활성화하여 STOMP 메시징을 지원합니다.
public class StompWebSocketConfig implements WebSocketMessageBrokerConfigurer {
private final StompHandler stompHandler;
// JWT 토큰 검증 등을 수행할 인터셉터 주입
public StompWebSocketConfig(StompHandler stompHandler) {
this.stompHandler = stompHandler;
}
/**
* 클라이언트가 최초 웹소켓 연결(Handshake)을 맺기 위한 엔드포인트를 정의합니다.
*/
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/connect")
.setAllowedOrigins("http://localhost:8081") // 클라이언트(Vue.js 등)의 도메인 허용
// WebSocket을 지원하지 않는 브라우저 환경에서도 HTTP 폴백(Fallback) 통신을 가능하게 하는 SockJS 설정 활성화
.withSockJS();
}
/**
* 메시지를 발행(Publish)하고 구독(Subscribe)하기 위한 URL 패턴(Prefix)을 정의합니다.
*/
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// 1. 메시지 발행(송신) 경로 프리픽스 설정
// 클라이언트가 이 프리픽스를 붙여 전송한 메시지는 @MessageMapping 어노테이션이 선언된 컨트롤러 메서드로 라우팅됩니다.
registry.setApplicationDestinationPrefixes("/publish");
// 2. 메시지 구독(수신) 경로 프리픽스 설정 (SimpleBroker 활성화)
// 이 경로를 타깃으로 구독한 클라이언트들에게 메시지를 중개해 주는 내장 메모리 브로커 역할을 수행합니다.
registry.enableSimpleBroker("/topic");
}
/**
* 클라이언트로부터 들어오는 메시지(Inbound Channel)의 가로채기(Interceptor)를 설정합니다.
*/
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
// 소켓 요청(CONNECT, SUBSCRIBE, SEND 등) 유효성 검증을 위한 인터셉터 등록
registration.interceptors(stompHandler);
}
}
💡 핵심 포인트 분석
- registerStompEndpoints: 클라이언트가 맨 처음 소켓 통신을 시작하기 위해 문을 두드리는 엔드포인트를 /connect로 명시했다. withSockJS()는 웹소켓이 차단된 인트라넷 방화벽이나 구형 브라우저에서도 HTTP Streaming, Long Polling 등으로 대체 통신을 열어 두는 안전장치다.
- setApplicationDestinationPrefixes("/publish"): 클라이언트가 전송 프레임 목적지를 /publish/1로 잡으면, 스프링은 이 메시지가 서버 비즈니스 로직 가공 대상임을 인지하고 @MessageMapping 핸들러로 안내한다.
- enableSimpleBroker("/topic"): 메모리 기반의 메시지 중개자를 활성화한다. 클라이언트가 /topic/1 채널을 구독하고 있으면, 해당 채널로 메시지가 떨어지는 순간 실시간으로 메시지를 복사 배달받게 된다.
4. 실시간 통신 보안 관문: StompHandler (인터셉터)
웹소켓 환경에서 가장 빈번하게 발생하는 보안 실수는 "최초 핸드셰이크(HTTP 요청) 시점에만 JWT를 검증하고 끝내는 것"이다. 하지만 실제로 세션이 유지되는 도중에 권한이 박탈되거나 만료될 수 있으며, 비정상적인 유저가 소켓을 유지한 채 악의적인 데이터를 주입할 여지가 있다.
STOMP는 TCP 연결이 맺어진 후 클라이언트가 서버로 전송하는 개별 프레임 단위(CONNECT, SUBSCRIBE, SEND 등)를 가로챌 수 있는 ChannelInterceptor 인터페이스를 지원한다. 이를 통해 웹소켓 레이어 내부에서도 강력한 인가 프로세스를 작동시킬 수 있다.

📌 StompHandler 소스 코드
/**
* 클라이언트가 서버로 전송하는 STOMP 메시지(CONNECT, SUBSCRIBE, SEND 등)를
* 가로채서 사전 유효성 검증(예: JWT 인증)을 수행하는 보안 인터셉터입니다.
*/
@Component
@Slf4j
public class StompHandler implements ChannelInterceptor {
@Value("${jwt.secretKey}")
private String secretKey;
/**
* 메시지가 실제 채널로 전송되기 전에 호출되어 흐름을 제어하거나 헤더를 검증합니다.
*/
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
// 메시지로부터 STOMP 프로토콜 헤더 정보를 래핑하여 꺼냅니다.
final StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
// 1. 최초 웹소켓 세션 연결(CONNECT) 명령인 경우 토큰 검증 수행
if (StompCommand.CONNECT == accessor.getCommand()) {
log.info("WebSocket CONNECT 요청 감지: JWT 토큰 유효성 검증을 시작합니다.");
// HTTP 헤더와 다른 네이티브 헤더(Native Header)에서 Authorization 값을 추출합니다.
String bearerToken = accessor.getFirstNativeHeader("Authorization");
if (bearerToken == null || !bearerToken.startsWith("Bearer ")) {
log.error("웹소켓 인증 오류: Authorization 헤더가 유실되었거나 형식이 유효하지 않습니다.");
throw new IllegalArgumentException("유효하지 않은 토큰 포맷입니다.");
}
// "Bearer " 뒤쪽의 순수 토큰 내용 추출
String token = bearerToken.substring(7);
try {
// 토큰 검증 및 claims 추출 진행 (만료되거나 위조된 경우 파싱 단계에서 예외 발생)
Claims claims = Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
.getBody();
log.info("JWT 토큰 인증 완료. 접속 유저 Subject: {}", claims.getSubject());
} catch (Exception e) {
log.error("JWT 검증 실패: 소켓 연결 요청을 거부합니다. 사유: {}", e.getMessage());
throw new IllegalArgumentException("인증 토큰이 유효하지 않습니다.");
}
}
// 유효성 검증을 통과한 메시지는 그대로 전송 흐름을 이어갑니다.
return message;
}
}
5. 실시간 모니터링 및 상태 감지: StompEventListener
실시간 채팅 서비스에서는 "현재 접속해 있는 세션 수가 얼마나 되는지", 혹은 "비정상적인 이유로 접속이 끊긴 유저가 누구인지"를 실시간으로 추적하고 로깅하는 인프라가 매우 중요하다.
스프링 프레임워크는 소켓 세션의 생성 및 소멸 주기에 부합하여 이벤트를 자동으로 퍼블리싱해 주는데, 우리는 @EventListener를 활용해 이 이벤트들을 수집하여 로깅 및 세션 카운트 분석을 처리할 수 있다.
📌 StompEventListener 소스 코드
/**
* 스프링의 자동 세션 관리 주기에 맞춰 연결/해제 이벤트를 캐치하고 기록하는 이벤트 리스너입니다.
* 실시간 모니터링, 메트릭 수집 및 디버깅 목적으로 활용됩니다.
*/
@Component
@Slf4j
public class StompEventListener {
// 멀티스레드 환경에서 안전하게 세션 ID를 적재할 수 있도록 동시성 컬렉션 사용
// lookup 성능과 메모리 공간의 효율적인 관리를 위해 $O(1)$ 복잡도를 보장하는 ConcurrentHashMap 기반 Set 구성
private final Set<String> sessions = ConcurrentHashMap.newKeySet();
/**
* 클라이언트가 연결 요청을 마친 직후 세션이 최종 확정되면 작동하는 이벤트 리스너입니다.
*/
@EventListener
public void connectHandle(SessionConnectEvent event) {
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage());
String sessionId = accessor.getSessionId();
sessions.add(sessionId);
log.info("[SOCKET CONNECTED] 새로운 소켓 세션 활성화 - Session ID: {}", sessionId);
log.info("[METRICS] 현재 서버 접속 세션 수: {}", sessions.size());
}
/**
* 클라이언트가 연결을 명시적으로 종료하거나, 타임아웃 등으로 인해 끊겼을 때 작동하는 이벤트 리스너입니다.
*/
@EventListener
public void disconnectHandle(SessionDisconnectEvent event) {
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage());
String sessionId = accessor.getSessionId();
sessions.remove(sessionId);
log.info("[SOCKET DISCONNECTED] 소켓 세션 소멸 - Session ID: {}", sessionId);
log.info("[METRICS] 현재 서버 접속 세션 수: {}", sessions.size());
}
}
💡 아키텍처적 깊이 더하기 (동시성 처리)
- ConcurrentHashMap.newKeySet(): 다수의 유저가 한꺼번에 소켓 연결을 맺고 끊는 웹소켓 서버 환경에서는 동시성 이슈가 번번하게 발생한다. 일반적인 HashSet이나 ArrayList를 사용하면 동시성 수정 에러(ConcurrentModificationException)나 데이터 정합성 파손 위험이 있다. 따라서 내부에 락 분할(Lock Striping) 전술을 적용한 Thread-safe 컬렉션을 사용하여 무상태 동적 증감을 철저히 제어해야 한다.
6. 메시지 라우팅의 두 가지 패턴: StompController
메시지가 클라이언트에서 들어왔을 때, 이를 수신받아 대상자들에게 뿌려 주는 방법에는 크게 두 가지가 있다.
방법 1. @MessageMapping + @SendTo 조합 (단순 라우팅)
이 방식은 들어온 데이터를 가공 없이 그대로 대상 토픽에 전달하고 끝내는 1:1 패스스루(Pass-through) 모델이다. 코드는 아주 간결하지만, 중간에 데이터베이스 영속성 레이어(JPA 등)에 메시지를 저장하거나 추가적인 보안 필터링, 형식 검증(Validation)을 유연하게 덧붙이기 곤란하다.
방법 2. @MessageMapping + SimpMessageSendingOperations 조합 (송수신 분리 및 확장 모델)
가장 권장되는 실무적인 설계입니다. 메시지를 컨트롤러가 가로채어 비즈니스 판단(도메인 검증, 데이터베이스 저장, 수신 유저 상태 체크 등)을 가한 뒤, messageTemplate.convertAndSend()를 통해 주도적으로 메시지를 가공 발송한다.
📌 StompController 소스 코드
/**
* STOMP 프로토콜로 들어오는 메시지를 수신하고, 비즈니스 판단 후 알맞은 채널로 전송해 주는 컨트롤러입니다.
*/
@Controller
@Slf4j
public class StompController {
// 특정 브로커 타깃 채널로 프로그래밍 방식으로 메시지를 송신하는 템플릿 컴포넌트
private final SimpMessageSendingOperations messageTemplate;
public StompController(SimpMessageSendingOperations messageTemplate) {
this.messageTemplate = messageTemplate;
}
/*
// =========================================================================
// 방법 1. @MessageMapping과 @SendTo를 활용한 간이 라우팅 (단순 전달용)
// =========================================================================
@MessageMapping("/{roomId}") // 클라이언트에서 "/publish/{roomId}" 경로로 SEND 프레임을 보낼 시 수신
@SendTo("/topic/{roomId}") // 해당 메서드 리턴값을 "/topic/{roomId}"의 구독자들에게 즉시 송신
public String sendMessage(@DestinationVariable String roomId, String message) {
// DestinationVariable은 @MessageMapping 내에서만 URL 내 동적 변수를 파싱하기 위해 사용됩니다.
log.info("[SIMPLE ROUTING] Room ID: {}, Message: {}", roomId, message);
return message;
}
*/
// =========================================================================
// 방법 2. SimpMessageSendingOperations의 convertAndSend 기능을 통한 확장형 라우팅 (실무 지향)
// =========================================================================
/**
* 클라이언트가 발송한 메시지를 객체 타입 DTO로 수집하고, 정합성을 제어한 뒤 구독 채널로 수동 송출합니다.
*/
@MessageMapping("/{roomId}") // 최종 전송 대상 목적지: /publish/{roomId}
public void sendMessage(@DestinationVariable String roomId, @Payload ChatMessageReqDto chatMessageReqDto) {
log.info("[ADVANCED ROUTING] 수신 감지 -> Room ID: {}, Content: {}", roomId, chatMessageReqDto);
// 💡 실무 확장 포인트 예시:
// 1. chatService.saveMessage(chatMessageReqDto); (RDB/NoSQL 데이터 영속화)
// 2. 유저 차단 정책 필터링 실행
// 3. 메시지 텍스트 유해 필터 가동 등등...
// 특정 목적지(/topic/{roomId})로 가공된 DTO 객체를 직렬화하여 송출시킵니다.
messageTemplate.convertAndSend("/topic/" + roomId, chatMessageReqDto);
}
}
7. 채팅 코드 테스트 가이드 (Vue.js 프론트엔드 연동)
STOMP 기반의 통신은 프레임 프로토콜이기 때문에, 단순한 HTTP API 테스트 도구인 일반 Postman 환경이나 커스텀 소켓 연동이 쉽지 않다. 따라서 프론트엔드 라이브러리(@stomp/stompjs 및 sockjs-client)를 활용한 테스트 샌드박스를 구축해 연동 상태를 체크해 주는 것이 가장 이상적이다.
Vue.js 클라이언트 연동용 코드 스니펫 예시
import SockJS from 'sockjs-client';
import Stomp from 'webstomp-client';
// 1. 웹소켓 핸드셰이크 엔드포인트 연결 (/connect)
const socket = new SockJS('http://localhost:8080/connect');
const stompClient = Stomp.over(socket);
// 2. JWT 토큰을 인증 헤더에 동봉하여 소켓 커넥션 수립
const headers = {
Authorization: 'Bearer ' + yourJwtToken
};
stompClient.connect(headers, (frame) => {
console.log('STOMP Connected: ' + frame);
// 3. 수신을 위해 특정 방 구독 (/topic/{roomId})
stompClient.subscribe('/topic/1', (message) => {
const chatMessage = JSON.parse(message.body);
console.log('실시간 메시지 수신: ', chatMessage);
});
}, (error) => {
console.error('STOMP 연결 에러: ', error);
});
// 4. 메시지 발행 (수신 라우팅: /publish/{roomId})
function sendChatMessage(text) {
const payload = {
senderId: "user-123",
message: text
};
stompClient.send('/publish/1', JSON.stringify(payload), headers);
}
8. 결론 및 요약
스프링 부트와 STOMP 메시지 브로커를 활용하면 비즈니스 중심의 탄탄한 실시간 아키텍처를 설계할 수 있다.
단순히 소켓 연결을 열어 두는 것을 넘어 ChannelInterceptor 기반의 JWT 다중 검증 파이프라인을 확보해야 보안 누수를 원천 봉쇄할 수 있으며, EventListener와 안전한 멀티스레드 컬렉션을 채택해야 실시간 동접자 및 유저 통계의 정합성을 수호할 수 있다.
나아가 @SendTo를 이용한 간이 라우팅 보다는, 비즈니스 확장성이 열려 있는 SimpMessageSendingOperations 기반의 명시적 중개 아키텍처를 구성하여 영속성 저장 및 차단 정책 연계에 대응할 수 있도록 아키텍처를 진화시키는 전략이 좋은 전략이라고 생각한다.