Java & Spring/WebSocket & Stomp

[Spring] 웹소켓/Stomp 5

nippycloud 2026. 2. 17. 15:27

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