채팅 메시지를 보낼 때 클라이언트가 다음과 같이 senderId를 함께 전달한다고 가정한다면
{
"senderId": 1,
"roomId": 10,
"content": "안녕하세요"
}
서버가 이 senderId를 그대로 믿는다면 사용자는 값을 다른 회원의 ID로 바꿔 타인인 것처럼 메시지를 보낼 수 있다.
클라이언트가 보낸 데이터는 언제든지 조작될 수 있으므로 발신자 정보는 메시지 본문이 아니라 서버가 검증한 JWT에서 찾아야 한다.
HTTP API에서는 일반적으로 JWT를 Filter에서 검사한다. 그런데 WebSocket 위에서 동작하는 STOMP 메시지는 왜 Filter가 아니라 ChannelInterceptor에서 검사해야 할까?
핵심은 HTTP 요청과 STOMP 메시지가 서로 다른 계층의 데이터라는 점이다.
HTTP와 STOMP의 인증 흐름은 무엇이 다를까?
HTTP와 STOMP의 차이를 간단하게 비교하면 다음과 같다.
| 구분 | HTTP API | STOMP over WebSocket |
| 서버가 처리하는 단위 | HTTP Request | STOMP Frame 또는 Message |
| 대표 명령 | GET, POST, PUT, DELETE | CONNECT, SUBSCRIBE, SEND, DISCONNECT |
| 메시지가 들어오는 위치 | Servlet Filter | Client Inbound Channel |
| 인증 담당 | Filter | ChannelInterceptor |
| 일반적인 인증 시점 | 각 HTTP 요청 | STOMP CONNECT |
| 인증된 사용자 | SecurityContext 등 | WebSocket 세션의 Principal |
HTTP는 요청 하나가 들어올 때마다 Filter가 실행되는 구조다.

이를 문에 비유하면 다음과 같다.
사람 1 → 문 → Filter 검사 → 처리 → 응답
사람 2 → 문 → Filter 검사 → 처리 → 응답
여기서 매번 문을 연다는 것은 실제 TCP 연결을 반드시 새로 만든다는 뜻이 아니다.
HTTP Keep-Alive를 사용하면 동일한 TCP 연결을 재사용할 수도 있다.
중요한 것은 각 HTTP 요청이 서버에 들어올 때마다 Filter 체인을 통과한다는 사실이다.
STOMP는 한 번 연결한 뒤 메시지를 계속 주고받는다
STOMP over WebSocket은 먼저 HTTP를 이용해 WebSocket 연결을 만든다.
연결이 수립된 뒤에는 같은 WebSocket 연결 안에서 STOMP 프레임을 계속 주고받는다.
WebSocket Handshake로 연결 생성
└─ STOMP CONNECT
├─ SUBSCRIBE
├─ SEND
├─ SEND
└─ DISCONNECT
즉, 최초 Handshake 이후의 CONNECT, SEND, SUBSCRIBE는 새로운 HTTP 요청이 아니다.
네트워크를 통해 데이터가 들어오기는 하지만 Spring MVC가 다루는 HttpServletRequest가 아니라 메시징 계층의 Message<?>로 처리된다.
따라서 “STOMP에는 요청이 없다”라는 설명은 정확히는 다음 의미다.
STOMP 통신에는 Servlet Filter가 처리할 새로운 HTTP 요청이 없고, 대신 기존 WebSocket 연결 안으로 STOMP 메시지가 들어온다.
WebSocket Handshake와 STOMP CONNECT는 같은 것이 아니다
WebSocket Handshake는 HTTP 프로토콜을 이용해 WebSocket 연결로 전환하는 과정이다.
이 단계는 HTTP 요청이므로 Servlet Filter가 동작할 수 있다.
STOMP CONNECT는 만들어진 WebSocket 연결 위에서 STOMP 세션을 시작하겠다고 알리는 첫 번째 STOMP 프레임이다.
이름에 CONNECT가 들어가지만 WebSocket Handshake와는 별개의 과정이다.

브라우저 기반 STOMP 클라이언트의 connectHeaders에 넣은 Authorization 값은 일반적으로 HTTP Handshake 헤더가 아니라 STOMP CONNECT 프레임의 Native Header로 전달된다.
따라서 이 값을 읽는 위치도 Servlet Filter가 아니라 StompHeaderAccessor를 사용하는 ChannelInterceptor다.
Filter가 STOMP 메시지 인증에 사용될 수 없는 이유
Servlet Filter는 Servlet 컨테이너로 들어오는 HTTP 요청과 응답을 대상으로 동작한다.
HTTP Request
→ Servlet Filter
→ DispatcherServlet
→ Controller
반면 WebSocket 연결이 만들어진 뒤의 STOMP 메시지는 다음 경로로 처리된다.
STOMP Frame
→ Client Inbound Channel
→ ChannelInterceptor
→ 메시지 핸들러 또는 브로커
SEND나 SUBSCRIBE가 들어올 때는 새로운 HttpServletRequest가 생성되지 않는다.
Servlet Filter를 실행할 대상 자체가 없으므로 Filter에 JWT 검사 코드를 작성해도 STOMP 프레임이 들어올 때마다 호출되지 않는다.
이를 문에 비유하면 다음과 같다.
- Filter는 건물의 출입문에서 HTTP 요청을 검사하는 경비다.
- WebSocket 연결이 만들어지면 클라이언트는 이미 건물 안에 있는 상태다.
- 이후에는 건물 내부의 메시지 통로로 STOMP 프레임을 계속 보낸다.
- 내부 메시지 통로를 검사하는 담당자가 ChannelInterceptor다.
Filter와 ChannelInterceptor가 완전히 같은 컴포넌트라는 뜻은 아니다.
다만 각자의 계층으로 들어오는 데이터를 실제 처리 전에 가로채 검사한다는 역할이 비슷하다고 이해하면 쉽다.
ChannelInterceptor로 CONNECT JWT 인증 구현하기
다음은 CONNECT 프레임에서 JWT를 검증하고 인증된 사용자를 등록하는 코드다.
@Component
@RequiredArgsConstructor
public class StompAuthInterceptor implements ChannelInterceptor {
private final JwtUtil jwtUtil;
private final UserRepository userRepository;
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(
message,
StompHeaderAccessor.class
);
// CONNECT 연결일 때 JWT 토큰 검증
if (StompCommand.CONNECT.equals(accessor.getCommand())) {
String token = accessor.getFirstNativeHeader("Authorization")
.replace("Bearer ", "");
Long userId = jwtUtil.getUserId(token);
User user = userRepository.findById(userId)
.orElseThrow();
accessor.setUser(new AuthenticatedUser(user));
}
return message;
}
}
preSend(...)는 메시지가 채널로 전달되기 전에 실행된다.
ChannelInterceptor 자체는 여러 종류의 STOMP 프레임을 볼 수 있지만, 위 코드는 StompCommand.CONNECT일 때만 인증을 수행한다.
코드를 한 줄씩 이해하기
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(
message,
StompHeaderAccessor.class
);
Message<?>에 들어 있는 STOMP 명령과 Native Header에 접근하기 위해 StompHeaderAccessor를 가져온다.
이를 통해 현재 프레임이 CONNECT, SEND, SUBSCRIBE 중 무엇인지 확인할 수 있다.
if (StompCommand.CONNECT.equals(accessor.getCommand())) {
현재 메시지가 STOMP 세션을 시작하는 CONNECT 프레임인지 검사한다.
JWT 인증은 이 시점에 한 번 수행한다.
String token = accessor.getFirstNativeHeader("Authorization")
.replace("Bearer ", "");
클라이언트가 STOMP CONNECT 헤더로 전송한 Authorization 값을 읽고 Bearer 접두사를 제거한다.
여기서 읽는 값은 HTTP 요청 헤더가 아니라 STOMP 프레임의 Native Header다.
Long userId = jwtUtil.getUserId(token);
JWT에서 사용자 ID를 추출한다.
다만 getUserId(...)가 단순히 JWT 내용을 읽기만 하는 메서드라면, 사용자 ID를 추출하기 전에 반드시 다음 항목도 검증해야 한다.
- JWT 서명
- 토큰 만료 시간
- 토큰 형식
- 필요한 Claim 존재 여부
User user = userRepository.findById(userId)
.orElseThrow();
토큰에 저장된 사용자 ID에 해당하는 회원이 실제로 존재하는지 확인한다.
탈퇴했거나 존재하지 않는 사용자의 토큰이라면 연결을 허용하지 않는다.
accessor.setUser(new AuthenticatedUser(user));
검증된 사용자를 현재 STOMP 세션의 Principal로 등록한다.
setUser(...)는 Principal 타입을 받으므로 AuthenticatedUser는 java.security.Principal을 구현해야 한다.
return message;
인증이 완료된 메시지를 다음 처리 단계로 전달한다.
인증 과정에서 예외가 발생하면 메시지는 정상적인 처리 단계로 넘어가지 않는다.
AuthenticatedUser는 Principal을 구현해야 한다
accessor.setUser(...)에 전달하는 객체는 반드시 Principal이어야 한다.
예를 들어 다음과 같이 구현할 수 있다.
@Getter
@RequiredArgsConstructor
public class AuthenticatedUser implements Principal {
private final User user;
@Override
public String getName() {
return String.valueOf(user.getId());
}
public Long getUserId() {
return user.getId();
}
}
다만 WebSocket 세션에 JPA Entity 전체를 저장하면 불필요한 데이터나 영속성 관련 문제가 생길 수 있다.
따라서 실제 애플리케이션에서는 사용자 ID처럼 인증에 필요한 최소 정보만 보관하는 방식이 더 안전하다.
@Getter
@RequiredArgsConstructor
public class AuthenticatedUser implements Principal {
private final Long userId;
@Override
public String getName() {
return String.valueOf(userId);
}
}
이 방식을 사용한다면 Interceptor에서도 다음과 같이 사용자 ID만 전달할 수 있다.
accessor.setUser(new AuthenticatedUser(user.getId()));
Interceptor를 Client Inbound Channel에 등록하기
StompAuthInterceptor에 @Component만 붙여서는 자동으로 STOMP 메시지를 가로채지 않는다.
반드시 WebSocket 설정에서 클라이언트 Inbound Channel의 Interceptor로 등록해야 한다.
@Configuration
@EnableWebSocketMessageBroker
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
private final StompAuthInterceptor stompAuthInterceptor;
@Override
public void configureClientInboundChannel(
ChannelRegistration registration
) {
registration.interceptors(stompAuthInterceptor);
}
}
이제 클라이언트가 서버로 보내는 다음 메시지들이 preSend(...)를 거치게 된다.
- CONNECT
- SEND
- SUBSCRIBE
- UNSUBSCRIBE
- DISCONNECT
그중 현재 인증 코드는 CONNECT에만 반응한다.
클라이언트는 CONNECT 헤더에 JWT를 넣는다
JavaScript STOMP 클라이언트에서는 연결 헤더에 다음과 같이 JWT를 전달할 수 있다.
사용하는 STOMP 라이브러리와 버전에 따라 API 이름은 달라질 수 있지만, 핵심은 Authorization이 STOMP CONNECT 헤더에 포함된다는 점이다.
const client = new Client({
brokerURL: "ws://localhost:8080/ws",
connectHeaders: {
Authorization: `Bearer ${accessToken}`
}
});
client.activate();
서버에서는 다음 코드로 해당 값을 읽는다.
accessor.getFirstNativeHeader("Authorization");
즉, 클라이언트와 서버의 값은 다음과 같이 대응한다.
클라이언트 connectHeaders.Authorization
↓
STOMP CONNECT Native Header
↓
accessor.getFirstNativeHeader("Authorization")
@MessageMapping에서는 senderId 대신 Principal을 사용한다
CONNECT에서 인증된 사용자를 등록했다면 이후 메시지 처리 메서드에서 Principal을 받을 수 있다.
@MessageMapping("/chat.send")
public void sendMessage(
ChatMessageRequest request,
Principal principal
) {
AuthenticatedUser authenticatedUser =
(AuthenticatedUser) principal;
Long senderId = authenticatedUser.getUserId();
chatService.send(
senderId,
request.getRoomId(),
request.getContent()
);
}
요청 DTO에는 발신자 ID를 넣지 않는 편이 안전하다.
public record ChatMessageRequest(
Long roomId,
String content
) {
}
클라이언트는 roomId와 content처럼 메시지 처리에 필요한 데이터만 전달한다.
senderId는 서버가 인증된 Principal에서 가져오므로 사용자가 요청 본문을 조작해도 다른 회원으로 위장할 수 없다.
flowchart LR
A[클라이언트 메시지<br/>roomId, content] --> B[@MessageMapping]
C[인증된 Principal<br/>userId] --> B
B --> D[ChatService]
D --> E[안전한 메시지 저장]
마무리 질문
Q. Handshake에서도 Filter가 실행되는데 Filter로 JWT를 검사하면 안 되나?
쿠키나 세션 기반 인증처럼 Handshake 단계에서 사용자 인증이 이미 완료되는 구조도 가능하다.
그러나 STOMP CONNECT의 Native Header로 JWT를 전달하는 방식이라면 해당 헤더는 HTTP Filter가 볼 수 없다. 이 경우 ChannelInterceptor에서 처리해야 한다.
Q. STOMP CONNECT도 이름이 CONNECT인데 WebSocket 연결을 만드는 과정 아닌가?
아니다.
WebSocket Handshake가 먼저 실제 WebSocket 연결을 만들고, STOMP CONNECT는 만들어진 WebSocket 연결 위에서 STOMP 세션을 시작한다.
Q. ChannelInterceptor는 CONNECT 때만 실행되나?
아니다.
Client Inbound Channel에 등록하면 여러 종류의 STOMP 메시지를 가로챌 수 있다. 예제 코드가 CONNECT일 때만 JWT 인증을 수행하도록 조건을 둔 것이다.
Q. SEND마다 JWT를 다시 검증해야 하나?
일반적인 구조에서는 CONNECT에서 등록한 Principal을 같은 STOMP 세션의 후속 메시지에 사용한다.
다만 다음과 같은 요구사항이 있다면 별도의 정책이 필요하다.
- 토큰 만료 후 장시간 연결을 유지하는 경우
- 계정 정지를 즉시 반영해야 하는 경우
- 채팅방별 접근 권한을 매번 검사해야 하는 경우
- 사용자의 권한이 연결 중간에 변경될 수 있는 경우
Q. 클라이언트가 보낸 senderId와 Principal의 사용자 ID가 같은지 검사해서 사용하면 안 되나?
굳이 클라이언트가 보낸 값을 사용할 이유가 없다.
서버가 신뢰할 수 있는 값은 검증된 Principal이므로 발신자 ID는 항상 Principal에서 가져오는 것이 안전하다.
'개발공부 > WEB' 카테고리의 다른 글
| STOMP 채팅에 Redis Pub/Sub은 왜 필요할까? 단일 서버부터 다중 서버까지 이해하기 (0) | 2026.09.11 |
|---|---|
| WebSocket 메시지 흐름을 구조화하는 STOMP (0) | 2026.09.07 |
| WebSocket이란? Spring Boot로 간단한 실시간 통신 구현하기 (0) | 2026.09.04 |
| SSR(Server Side Rendering)과 CSR(Client Side Rendering)이란? (0) | 2026.06.25 |