https://nippyclouding.tistory.com/49
[Spring] 웹소켓/Stomp 4
Stomp는 순수 웹소켓과 다르게 각 room (주로 topic이라고 호칭)들을 구분하여 관리할 수 있다. (사용자마다 group화 되어있다)순수 웹소켓도 각 room을 구분 가능하지만, 직접 모두 구현해주어야 한다.S
nippyclouding.tistory.com
Stomp Handler
웹소켓 / Stomp 4의 코드는 현재 JWT 토큰 검증을 하고 있지 않다.
=> 로그인 여부와 관련없이 채팅 기능을 이용할 수 있다는 것이 문제이다.
현재는 security config 코드에서 "/connect" 요청은 인증에서 제외하고 있다.
security filter에서는 http 요청에 대해서만 인증을 검증할 수 있다. : 웹소켓 요청 전용 별도 검증 로직 필요
1. StompWebSocketConfig에 오버라이딩 메서드 추가
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(stompHandler);
}
웹소켓 요청은 세큐리티의 인터셉터를 건너뛴다.
문제점 : 세큐리티 요청을 건너뛰기 때문에 클라이언트 인증 불가 (로그인 정보 등 토큰 검증 불가)
=> configureClientInboundChannel 메서드를 통해 웹소켓 요청이 들어오기 전 전용 인터셉터로 요청을 가로챈다.
사용자 요청 -> 세큐리티 필터 건너뛰고 -> 현재 메서드로 들어온다.
웹소켓 요청 (connect, subscribe, disconnect) 시에는 http header 등 http 메시지를 넣을 수 있고
이를 "인터셉터"를 통해 가로채어 토큰을 검증 가능 (이 외 일반적인 웹소켓 메시지 송수신은 http 메시지를 넣을 수 없다)
"인터셉터" 역할을 하는 부분이 위 코드의 "stompHandler"이고, 위 메서드는 인터셉터를 이용한 웹소켓 JWT 검증 코드이다.
웹소켓 요청 시 http 정보를 넣을 수 있는 시점
- connect : 가장 처음 클라이언트 - 서버 간 웹소켓 연결하는 시점
- subscribe : 클라이언트가 특정 주제(topic/{roomId})를 수신하겠다고 서버에 알리는 시점
- send : 클라이언트가 브로커에게 메시지를 전달하는 시점
- disconnect : TCP 연결을 해제하고 서버 자원을 정리하는 시점 (종료 시점이기 때문에 실질적으로 Http 메시지를 잘 넣진 않는다.)
2. StompHandler : 웹소켓 전용 인터셉터 역할
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
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;
@Component
@Slf4j
public class StompHandler implements ChannelInterceptor {
@Value("${jwt.secretKey}")
private String secretKey;
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
final StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
if (StompCommand.CONNECT == accessor.getCommand()) {
log.info("connect 요청 시 유효성 검증 시작");
String bearerToken = accessor.getFirstNativeHeader("Authorization"); // 토큰을 꺼낸다.
String token = bearerToken.substring(7);
// 토큰 검증
Claims claims = Jwts.parserBuilder() // claims = payload, Authentication 객체를 생성하기 위해 claims 추출
.setSigningKey(secretKey) // secretKey 를 다시 넣어 사용자의 payload, header 부분을 결합
.build() // => 다시 암호화를 해서 현재 서버가 생성한 토큰이 맞는지 검증
.parseClaimsJws(token) // 토큰 검증
.getBody(); // 검증이 끝나면 payload 부분을 꺼낸다 (email, role)
log.info("connect 요청 시 유효성 검증 완료");
}
return message;
}
}
Flow
- 웹소켓 요청이 아닐 때 : Spring Security Filter의 JWT 토큰 검증
- 웹소켓 요청일 때 : StompHandler의 JWT 토큰 검증

front - StompChatPage.vue
<template>
<v-container>
<v-row justify="center">
<v-col cols="12" md="8">
<v-card>
<v-card-title class="text-center text-h5">
채팅
</v-card-title>
<v-card-text>
<div class="chat-box">
<div
v-for="(msg, index) in messages"
:key="index"
:class="['chat-message', msg.senderEmail === this.senderEmail ? 'sent' : 'received']"
>
<strong>{{ msg.senderEmail }}: </strong> {{ msg.message }}
</div>
</div>
<v-text-field
v-model="newMessage"
label="메시지 입력"
@keyup.enter="sendMessage"
/>
<v-btn color="primary" block @click="sendMessage">전송</v-btn>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
</template>
<script>
// 외부 라이브러리
import SockJS from 'sockjs-client';
import Stomp from 'webstomp-client';
//import axios from 'axios';
export default {
data() {
return {
messages: [], // 현재 채팅방 전체 메시지
newMessage: "", // 서버에 전달할 새 메시지
stompClient: null,
token: "",
senderEmail: null
}
},
created() { // 훅 함수 (화면이 실행할 때)
this.senderEmail = localStorage.getItem("email");
this.connectWebsocket(); // 화면이 실행되면 바로 웹소켓 연결 메서드 실행
},
beforeRouteLeave(to, from, next) { // 사용자가 현재 라우트에서 다른 라우트로 이동하려 할 때 호출되는 함수
this.disconnectWebSocket();
next(); // 다음 화면으로 넘어간다.
},
beforeUnmount() { // 화면을 나올 때 (dom 객체가 삭제되었을 때)
if (this.stompClient?.connected) {
this.stompClient.disconnect();
}
},
methods: {
connectWebsocket() {
if (this.stompClient && this.stompClient.connected) return; // 이미 연결되어있다면 return
const baseUrl = process.env.VUE_APP_API_BASE_URL || "http://localhost:8080";
const sockJs = new SockJS(`${baseUrl}/connect`);
this.stompClient = Stomp.over(sockJs); //connect 맺기
this.token = localStorage.getItem("token");
this.stompClient.connect(
{
Authorization : `Bearer ${this.token}`
},
() => {
// 메시지를 받아 화면에 렌더링
this.stompClient.subscribe(`/topic/1`, (message) => {
const parseMessage = JSON.parse(message.body);
this.messages.push(parseMessage);
this.scrollToBottom();
})
}
);
},
sendMessage() {
if(this.newMessage.trim() === "") return; // 유효성 검증
const message = {
senderEmail : this.senderEmail,
message : this.newMessage
}
this.stompClient.send(`/publish/1`, JSON.stringify(message)); // 메시지를 JSON으로 서버에 전달
this.newMessage = ""; // 세 메시지를 "" 처리 (하지 않을 경우 전송 후 입력란에 글자가 그대로 보여진다.)
},
scrollToBottom() {
// 채팅방에서 채팅 입력 시 스크롤 다운이 자동으로 되도록 하는 함수
this.$nextTick(() => {
const chatBox = this.$el.querySelector(".chat-box");
chatBox.scrollTop = chatBox.scrollHeight;
})
},
disconnectWebSocket() {
if (this.stompClient && this.stompClient.connected) {
this.stompClient.disconnect(`/topic/1`);
}
}
}
}
</script>
<style>
.chat-box{
height: 300px;
overflow-y: auto;
border: 1px solid #ddd;
margin-bottom: 10px;
}
.chat-message{
margin-bottom: 10px;
}
.sent{
text-align: right;
}
.received{
text-align: left;
}
</style>'Java & Spring > WebSocket & Stomp' 카테고리의 다른 글
| [Spring] 웹소켓/Stomp 7 (0) | 2026.02.18 |
|---|---|
| [Spring] 웹소켓/Stomp 6 (0) | 2026.02.17 |
| [Spring] 웹소켓/Stomp 4 (0) | 2026.02.16 |
| [Spring] 웹소켓/Stomp 3 (0) | 2026.02.16 |
| [Spring] 웹소켓/Stomp 2 (0) | 2026.02.14 |