WebSocket 연결 요청은 HTTP로 시작되지만, 일반적인 Spring Security 필터 체인은 거의 적용되지 않는다.
따라서 WebSocket 연결 시점에서 토큰을 검증하려면, Spring Security 필터가 아닌 HandshakeInterceptor를 통해 직접 인증 처리를 해줘야 한다.
이때 토큰을 꺼내 사용자 정보를 세션 또는 메시지에 주입하면, 이후의 STOMP 메시지 처리에도 사용자 정보를 활용할 수 있다.
- ServerHttpRequest
이것은 Spring이 제공하는 HTTP 추상화 인터페이스야.
HttpServletRequest보다 더 범용적이고 스프링스럽게 추상화된 형태.
WebSocket 핸드셰이크 같은 특수한 요청 처리에 자주 쓰임.
- ServletServerHttpRequest
ServerHttpRequest를 HttpServletRequest로 감싼 구현체야.
즉, 내부적으로 실제 HttpServletRequest에 접근할 수 있음.
@Slf4j
public class HttpHandshakeInterceptor implements HandshakeInterceptor {
@Override
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler,
Map<String, Object> attributes) throws Exception {
if(request instanceof ServletServerHttpRequest servletServerHttpRequest) {
HttpServletRequest httpServletRequest = (HttpServletRequest) servletServerHttpRequest;
String authHeader = httpServletRequest.getHeader("Authorization");
if(authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
if (!jwtProvider.validateToken(token)) {
throw new RuntimeException("유효하지 않은 JWT");
}
String username = jwtProvider.getUsernameFromToken(token);
// 🔥 인증된 사용자 정보를 WebSocket 세션에 저장
attributes.put("username", username);
} else {
throw new RuntimeException("헤더 정보가 없습니다");
}
}
return false;
}
@Override
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler,
Exception exception) {
if(request instanceof ServletServerHttpRequest servletServerHttpRequest) {
String ip = servletServerHttpRequest.getServletRequest().getRemoteAddr();
String userAgent = servletServerHttpRequest.getServletRequest().getHeader("User-Agent");
log.info("WebSocket 연결됨 - IP: {}, UA: {}", ip, userAgent);
}
}
}
package com.websocket.chat.config.handler;
// import ... 생략
@Slf4j
@RequiredArgsConstructor
@Component
public class StompHandler implements ChannelInterceptor {
private final JwtTokenProvider jwtTokenProvider;
// websocket을 통해 들어온 요청이 처리 되기전 실행된다.
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
if (StompCommand.CONNECT == accessor.getCommand()) { // websocket 연결요청
String hi = accessor.getFirstNativeHeader("hi");
String bye = accessor.getFirstNativeHeader("bye");
log.info("CONNECT {}", hi);
log.info("CONNECT {}", bye);
} else if (StompCommand.SUBSCRIBE == accessor.getCommand()) { // 채팅룸 구독요청
System.out.println("message: ");
System.out.println(message);
// header정보에서 구독 destination정보를 얻고, roomId를 추출한다.
String roomId = chatService.getRoomId(Optional.ofNullable((String) message.getHeaders().get("simpDestination")).orElse("InvalidRoomId"));
// 채팅방에 들어온 클라이언트 sessionId를 roomId와 맵핑해 놓는다.(나중에 특정 세션이 어떤 채팅방에 들어가 있는지 알기 위함)
String sessionId = (String) message.getHeaders().get("simpSessionId");
chatRoomRepository.setUserEnterInfo(sessionId, roomId);
// 채팅방의 인원수를 +1한다.
chatRoomRepository.plusUserCount(roomId);
// 클라이언트 입장 메시지를 채팅방에 발송한다.(redis publish)
String name = Optional.ofNullable((Principal) message.getHeaders().get("simpUser")).map(Principal::getName).orElse("UnknownUser");
chatService.sendChatMessage(ChatMessage.builder().type(ChatMessage.MessageType.ENTER).roomId(roomId).sender(name).build());
log.info("SUBSCRIBED {}, {}", name, roomId);
} else if (StompCommand.DISCONNECT == accessor.getCommand()) { // Websocket 연결 종료
// 연결이 종료된 클라이언트 sesssionId로 채팅방 id를 얻는다.
String sessionId = (String) message.getHeaders().get("simpSessionId");
String roomId = chatRoomRepository.getUserEnterRoomId(sessionId);
// 채팅방의 인원수를 -1한다.
chatRoomRepository.minusUserCount(roomId);
// 클라이언트 퇴장 메시지를 채팅방에 발송한다.(redis publish)
String name = Optional.ofNullable((Principal) message.getHeaders().get("simpUser")).map(Principal::getName).orElse("UnknownUser");
chatService.sendChatMessage(ChatMessage.builder().type(ChatMessage.MessageType.QUIT).roomId(roomId).sender(name).build());
// 퇴장한 클라이언트의 roomId 맵핑 정보를 삭제한다.
chatRoomRepository.removeUserEnterInfo(sessionId);
log.info("DISCONNECTED {}, {}", sessionId, roomId);
}
return message;
}
}
if (StompCommand.CONNECT == accessor.getCommand()) {
String hi = accessor.getFirstNativeHeader("hi");
String bye = accessor.getFirstNativeHeader("bye");
log.info("CONNECT {}", hi);
log.info("CONNECT {}", bye);
}
클라이언트가 WebSocket 연결을 시도할 때 발생
여기선 일단 "hi", "bye" 같은 STOMP 헤더값을 로그로 출력
실무에서는 여기서 Authorization 헤더 받아서 JWT 인증 처리하는 게 일반적임
else if (StompCommand.SUBSCRIBE == accessor.getCommand()) {
이 안에서 하는 일:
| 작업 | 설명 |
|---|---|
| ① destination 추출 | simpDestination에서 채팅방 ID 뽑음 |
| ② sessionId → roomId 맵핑 | 나중에 유저 퇴장 시 어떤 방에 있었는지 알기 위해 저장 |
| ③ 인원수 증가 | 채팅방 참여자 수 카운팅 |
| ④ 입장 메시지 생성 및 전송 | Redis나 서버 내부로 "누가 입장했어요" 메시지 발송 |
| ⑤ 유저 이름 추출 | simpUser → Principal 객체에서 이름 가져옴 |
else if (StompCommand.DISCONNECT == accessor.getCommand()) {
이 안에서 하는 일:
| 작업 | 설명 |
|---|---|
| ① sessionId → roomId 조회 | 이 유저가 어떤 방에 있었는지 파악 |
| ② 인원수 감소 | 참여자 -1 |
| ③ 퇴장 메시지 발송 | 채팅방에 "누가 퇴장했어요" 메시지 보냄 |
| ④ 유저-방 맵핑 정보 제거 | 메모리/캐시 정리 작업 |
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
✅ STOMP 헤더는 accessor.getFirstNativeHeader("Authorization") 이렇게 꺼내는 거야.
String token = accessor.getFirstNativeHeader("Authorization");
🔍 왜 Message<?> message 밖에 없는데 가능한가?
Spring WebSocket에서는 Message<?> 내부에 있는 STOMP 헤더들(Authorization, destination, sessionId 등)을
StompHeaderAccessor라는 도구를 통해 꺼낼 수 있게 설계돼 있어.
if (StompCommand.CONNECT.equals(accessor.getCommand())) {
String token = accessor.getFirstNativeHeader("Authorization");
if (token == null || !token.startsWith("Bearer ")) {
throw new IllegalArgumentException("JWT 토큰이 없습니다.");
}
token = token.substring(7); // "Bearer " 제거
if (!jwtTokenProvider.validateToken(token)) {
throw new IllegalArgumentException("유효하지 않은 토큰입니다.");
}
String username = jwtTokenProvider.getUsernameFromToken(token);
// 유저 정보를 인증된 Principal로 설정
accessor.setUser(new StompPrincipal(username)); // Principal 인터페이스 구현체
}