Springboot/chatting

Spring Websocket STOMP 통신과 JWT 인증

기발개발 2025. 6. 26. 10:37

 

이 글의 목적

Spring websocket , stomp로  기본 채팅서버를 만들고 

Spring security와  JWT를 이용해 인증 후  

웹, app 모두  같은 채팅방에서 채팅을 하는 기능을 구현한다.

웹에서는 ajax를 통해 /api  요청을 해서 화면을 구성한다 

(근데 따로 웹 서버 만들기 귀찮아서 그냥  채팅서버에  WebController ->  return 단순화면  추가) 

앱의 경우 안드로이드스튜디오에서  device manger로 가상 머신으로 실행해야

서버 주소(localhost:8080)에 요청할 수 있다. 

 

완성 코드 https://github.com/gks930620/spring_basic/tree/master/spring_chat 

 

 

Spring Websocket  

 웹 소켓은 HTML5에 등장한 실시간 웹 애플리케이션을 위해 설계된 통신 프로토콜이며, TCP 기반으로 연결합니다.

엡 소켓은 신뢰성 있는 데이터 전송을 보장하며, 메시지 경계를 존중하고, 순서가 보장된 양방향 통신을 제공합니다.

클라이언트 - 서버 간 최초 연결이 이루어지면 , 이 연결을 통해 양방향 통신을 지속적으로 할 수 있습니다. 

이 때 데이터는 패킷형태로 전달되며, 전송은 연결 중단과 추가 HTTP 요청없이  양방향으로 이뤄집니다.

 (첫 연결은 HTTP를 통해 이루어지고, 이후 WS프로토콜로 중단없이  통신이 이루어집니다.

이 때 HTTP 첫 연결을 HTTP 핸드쉐이크라고 합니다.

TCP를 통해 HTTP 핸드쉐이크 후 TCP 연결은 계속 유지 된 상태.   

즉, TCP 기반 위에 HTTP 핸드쉐이크 후  ,  TCP 기반위에 WS 프로토콜 통신 )

 

 

 

STOMP

STOMP는 Simple Text Oriented Messaging Protocol의 약자로

메시징 시스템 간에 데이터를 교환하기 위한 간단하면서도 유연한 텍스트 기반 프로토콜이다.

웹 소켓 기반으로 동작하며, 메시징 애플리케이션에서 표준 프로토콜로 채택되어 있다.

 

STOMP는 WebSocket에는 없는 Pub/Sub 구조를 가지고 있다.

발행자(Publisher)가 특정 토픽이나 큐에 메시지를 생성하고 발행한다.

그러면 메시지 브로커(Message Broker)가 발행된 메시지를 관리한다.

구독자(Subscriber)는 특정 주제나 큐에 구독할 수 있는데 이 때 메시지 브로커는

등록된 모든 구독자에게 해당 주제의 메시지들을 전달한다.

메시지 브로커는 메시지의 라우팅, 필터링, 분배 등의 역할을 수행한다.

구독자는 발행된 메시지를 처리하거나 원하는 동작을 수행할 수 있다.

 

Web Socket은 서버와 클라이언트사이에 1:1 통신을 지원하고

간혹 연결이 끊기면 메세지가 사라지거나 하는 불상사가 발생하기도 한다.

그러나 STOMP는 메세지 브로커로 1:N 통신을 지원하기도 하고,

메세지를 서버에 저장했다가 클라이언트에 송신해주기 때문에 더 안전하다.

 

 

 

여기서 발행자는 단순히 메세지를 보내는 사람일것이고

구독자는 특정 주제나 큐에 구독할 수 있다고 했는데  채팅방 정도로 생각하면 된다.

STOMP 없이 웹 소켓만 사용한다면 이 채팅방 기능을 개발자가 직접 코딩해야됐다면

STOMP를 이용한다면 채팅방기능이 이미 만들어져있다고 생각하면 된다. 

(https://brilliantdevelop.tistory.com/162 에서 STOMP 없이  방 기능 구현)

 

 

 

 

 

Spring websocket  채팅 동작 과정    

WebSocketMessageBrokerConfigurer 를 상속받은 WebSocketConfig를 spring에 등록하면

Spring 서버 안에 websocket서버가 생긴다.

 

 

 

 

 

 

 

 

 클라이언트가    new SockJs("/ws-chat")을 하는 순간   웹 소켓 서버와  HTTP 핸드쉐이크를 한다.

이 때 localhost:8080/ws-chat 요청은  HTTP 요청이기  때문에 Srping security 의 HTTP 설정의 영향을 받는다.

여기까지하면 클라이언트 웹 소켓과   서버가  물리적인 연결만 된 것이다. 

 

 

 

 

 

 

 물리적인 연결이 된 후에 Stomp방식으로 메시지를 주고받겠다고 선언 한다 (Stomp.over)

 

 

 

 

 

 

 

 

 

Connect를 하는 순간부터 웹 소켓 인터셉터가 작동하는데,   이 글에서는  Connect 때  JWT 인증을 한다.

HttpSession처럼 웹 소켓 서버도  각각의 웹 소켓 클라이언트를 식별할 수 있는 Session이 있는데 

인증 후 이 Session에  UsernamePasswordAuthenticationToken을 저장한다.    

이후에는  웹 소켓 클라이언트는 유저 이름을 굳이 message에 보내지 않아도  서버에서 유저이름을 알 수 있다.

 

또 연결에 성공하면 WebSocketEventListener에 의해 연결성공 이벤트가 실행된다. 

이 때 같은방에 있는 사람들한테만 메세지를 전송한다. (~~가 입장했습니다.)

 

 

 

더보기

※ 웹 소켓 서버의 인터셉터는 HTTP 핸드쉐이크부터 동작 할 수 있다.

 이  때의 인터셉터는 HandshakeInterceptor 이고,

CONNECT,SUBSCRIBE, SEND,  DISCONECT 등에서 동작하는  인터셉터는  ChannelInterceptor이다.

이 글에서는 ChannelInterceptor를 통해 Connect일 때 JWT 인증,  나머지는 그냥 통과하도록 했다. 

 

그 후 subscribe("/sub/room/1") 을 통해 1번 방에 입장한다. 

 

 

 

 

 

 

이 후에는  sned("/pub/room/1") 을 통해  C1이 메세지를 보내면 
웹 소켓 서버는   room/1 에 있는 c1 , c2한테  메세지를 전부 보낸다.

 

연결 할 때  유저정보( UsernamePasswordAuthenticationToken)을 웹 소켓 세션에서 저장했기 때문에
msg를 서버가 받은 다음  갖고있는 유저정보를 같이 보내기 때문에  (가공)  

클라이언트는 유저정보를 굳이 보내지 않는다.  

(인증 없이 Spring websocket만 할 때   클라이언트 측 코드에서  {user : "user1", msg : "내용"} ,

이 글에서는 {msg : "내용"} 만 보냄 )

 

 

 

더보기

 

참고로  현재  이 글에서는 

/room/1에 c1이 있는 상황에서   c2가 새로 입장한 상황이라면 

c2 클라이언트에서  Connect -> 인터셉터 통과 -> 성공

-> 연결성공이벤트( 방안 유저들에게 메세지보내기)가 된다

이 후 c2가  subscribe를 하면서 방 안에 들어오게 된다. 

즉 c2는  본인이 입장한 메세지를  볼 수 없다.

 

 

C2가 입장했을 때  본인이 입장한 메세지를 보고 싶다면   

Spring은 따로 subscribe 이벤트를 제공하지 않기 때문에 

client쪽 코드에서   다음과 같이  connect ->subscribe->send  하면 된다.   (비동기는 알아서 처리하자...)

stompClient.connect({}, function(frame) {
    stompClient.subscribe(`/sub/room/${roomId}`, function(message) {
        // 메시지 처리
        
         // 구독 후 서버에 '입장' 메시지 요청
    	stompClient.send("/pub/room/${roomdId}", {}, JSON.stringify({ content: message }));
    });

   
});

 

 

 

퇴장하는 경우에는  Connect 이벤트 리스너처럼

Disconnect 이벤트가 있는데 직접 disconnect 뿐만아니라

브라우저 종료, 뒤로가기 등을 모두 감지한다.

퇴장 할 때는 퇴장하고 나서 이벤트가 실행되기 때문에 퇴장한 사람은 메세지를 당연히 보지 못한다.

 

※  /ws-chat 순간이나, (이 때는 인터셉터가 아니라 spring security가 인증)

매 메시지보내는 순간마다 JWT 인증을 할 수도 있지만,  여기서는 Connect때만 인증을 했다.

또 인터셉터는  Connect 뿐만 아니라  매번 메시지 보낼 때마다 거치게 된다.  

(다만 Connect가 아닐 때는 그냥 통과하도록 했다)

채팅과 별개로 방 정보도 Spring seucrity 인증이 필요하지만, 채팅과는 관련이 없기 때문에

여기서는  설명을 생략한다.

 

 

 

 

동작 이미지

 

 

 

 

 

 

 

 

 

 user1(왼쪽브라우저) user3(앱)  user2(오른쪽브라우저) 순으로 입장했다.

 

 

 

 

 

 

 

user1(왼쪽브라우저) user3(앱)  이 퇴장한 모습. 

 

 

 

 

 

Spring 채팅 코드 설명

이 글에서는 채팅과 관련된 부분만 작성하고 

그 외에 코드는 위의 깃허브 링크에서 보면 된다.

간단히 설명해 보자면  웹에서의  화면은 단순 html로 이동하는 webController가 있고, 

실제 화면의 데이터 ( 방 정보 등)    html에서  jquery ajax로   /api/rooms , /api/room/**로 요청한다.

또 jwt 토큰은 localStorage에 저장하고, 서버에 인증을 보낼 때는 서버에서 요청하는 형식에 맞게 

요청한다. 

또 깃허브 링크에는 app도 있는데 앱은 스프링 채팅서버에 맞게 요청하는 앱을 플러터로 구성했는데

여기서는 따로 설명하지 않는다. 

 

밑의 내용들은  위에서의  채팅 동작 이미지들에 관한 코드들이다. 

채팅 동작 외의 코드는 깃허브 코드 참고. 

 

 

Spring security 

spring security  jwt 기본 기능은 다음 참고

https://brilliantdevelop.tistory.com/226

 

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtUtil jwtUtil;
    private final CustomUserDetailsService customUserDetailsService;  //내가 빈으로 등록한것들

    private final AuthenticationConfiguration authenticationConfiguration;  //authenticationManger를 갖고있는 빈.
    private final RefreshRepository refreshRepository;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }


    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http  //내부H2DB  확인용.  진짜 1도 안중요함.
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/h2-console/**").permitAll() // H2 콘솔 접근 허용
            )
            .csrf(csrf -> csrf.ignoringRequestMatchers("/h2-console/**")) // H2 콘솔 CSRF 비활성화
            .headers(
                headers -> headers.frameOptions(frame -> frame.disable())); // H2 콘솔을 iframe에서 허용

        http    //기본 session방식관련 다 X
            .csrf(csrf -> csrf.disable())
            .sessionManagement(
                session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .formLogin(form -> form.disable())
            .logout(logout -> logout.disable())  //기본 로그아웃 사용X
            .httpBasic(basic -> basic.disable());



        http   //경로와 인증/인가 설정.
            .authorizeHttpRequests(auth -> auth
                .requestMatchers(
                    "/login", "/loginPage" ,  "/api/refresh/reissue" , "/home" , "/" , "/rooms" ,"/room/**"
                              ,"/ws-chat" , "/ws-chat/**",
                              // Swagger / OpenAPI 문서 및 UI
                              "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html"
                ).permitAll()
                // ws 연결 자체는 http 통신이기때문에 필요. 다만 인증 자체는 ws에서 한게 아니라stomp 첫 연결시에 하는걸로 함.
                .requestMatchers("/api/me").authenticated()
                .requestMatchers("/api/rooms", "/api/room/**").authenticated()
                .requestMatchers("/api/logout").authenticated()  //security 기본 로그아웃 url인 /logout은 사용X
            );



        http          //필터
            .userDetailsService(customUserDetailsService)
            .addFilterAt(
                new JwtLoginFilter(authenticationConfiguration.getAuthenticationManager(), jwtUtil,
                    refreshRepository),
                UsernamePasswordAuthenticationFilter.class)  //기존 세션방식의 로그인 검증필터 대체.
            .addFilterBefore(
                new JwtAccessTokenCheckAndSaveUserInfoFilter(jwtUtil, customUserDetailsService),
                UsernamePasswordAuthenticationFilter.class);

       
        http
            .exceptionHandling(ex -> ex
                .authenticationEntryPoint((request, response, authException) -> {
                    String errorCause =
                        request.getAttribute("ERROR_CAUSE") != null ? (String) request.getAttribute(
                            "ERROR_CAUSE") : null;
                    //인증없이(access token없이) 인증필요한 곳에 로그인했을 떄.
                    if (errorCause == null) {
                        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                        response.setContentType("application/json;charset=UTF-8");
                        response.getWriter().write("{\"error\": \"인증이 필요합니다.\"}");
                        return;
                    }
                    // JwtAccessTokenCheckAndSaveUserInfoFilter  토큰체크하는부분
                    if (errorCause.equals("토큰만료")) {
                        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401 응답
                        response.setContentType("application/json");
                        response.setCharacterEncoding("UTF-8");
                        response.getWriter().write("{\"error\": \"Access Token expired\"}");
                        return;
                    }
                    if (errorCause.equals("로그인실패")) { //jwtLoginFilter 로그인시도부분.
                        response.setStatus(
                            HttpServletResponse.SC_UNAUTHORIZED); //로그인실패도 401로 하는게 보통
                        response.setContentType("application/json;charset=UTF-8");
                        response.getWriter().write("{\"error\": \"아이디 비번 틀림.\"}");
                        return;
                    }
                })
            );
        return http.build();
    }
}

 

 

/ws-chat, /ws-chat/**는 허용해서 웹 소켓 핸드쉐이크 자체는 인증이 필요없게

/api/rooms, /api/room/**    채팅방 정보는 인증이 필요  

 

그리고 웹 소켓 인증은 연결(stomp.connect)단계에서 하는데

이는  spring security에서 하는게 아니라 Websocket 처리 과정에서 함. 

 

 

※이 security 구현부분은 refresh TOKEN 을 요청해서 token 재발급하는 거까지 구현되어있으나

채팅 프론트부분에서는 refresh token요청없이 access_token만 사용해서 인증을 처리한다.

 

 

 

 

 

Spring websocket + STOMP 채팅서버 구현 

 

WebSocket

@Configuration
@EnableWebSocketMessageBroker
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
       // WebSocketMessageBrokerConfigurer   이 인터페이스를 구현하며너 STOMP를 사용하게 되는 것.

    private  final JwtUtil jwtUtil;
    private final CustomUserDetailsService customUserDetailsService;

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws-chat")  //spring 서버 내부의 웹소켓 엔드포인트
            .setAllowedOriginPatterns("*")
            // 모든 출처 허용 (CORS 설정) , 모두 허용 ; localhost:8080 외의 url에서도   이 websocket서버 엔드포인트에 연결요청가능함.
            // 실무에서는 내 서버 url만 허용하던가 특정 url만 허용하도록 설정해야 함.
            .withSockJS(); // SockJS fallback 허용  
          

	    // 참고 : 클라이언트에서 new WebSocket이나 new SockJS나 똑같은 역할이지만
        // websocket이 브라우저 버전 등의 이유로 연결 실패했을 때 
        // HTTP Streamin, Long Polling과 같은 HTTP 기반의 다른 기술로 전환해 다시 연결을 시도하게 하는 것
         //   
      
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.setApplicationDestinationPrefixes("/pub"); // 클라이언트 → 서버
        // pub/room/1 이면 room/1 채팅방(구독한 사람들)에  메시지 전달

        registry.enableSimpleBroker("/sub"); // 서버 → 클라이언트 (구독)
        // sub/room/1   구독하면  사용자는 room/1  채팅방에 있는 것
    }

    // 채널 인터셉터 등록,  인터셉터를 적용해서
    // 모든  채팅 점검,   이 인터셉터에서  CONNECT 요청에 대해 JWT 토큰 검증 수행
    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        registration.interceptors(new JwtChannelInterceptor(jwtUtil, customUserDetailsService));
       
    }
}

 

 

 

 

 

 

JwtChannelInterceptor

@Component
@RequiredArgsConstructor
public class JwtChannelInterceptor implements ChannelInterceptor {

    private final JwtUtil jwtUtil;
    private final CustomUserDetailsService userDetailsService;

    @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel) {
        StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);  //매 새로운 메시지마다 생성됨.

        if (!StompCommand.CONNECT.equals(accessor.getCommand())) {
            return message;  //CONNECT가 아니면 토큰검증 안함.
        }

        //CONNECT 요청에 대해 JWT 토큰 검증
        String token = accessor.getFirstNativeHeader("Authorization");
        if (token == null || !token.startsWith("Bearer ")) {
            // JWT가 없거나 잘못된 경우
            return null;  // 메시지 전송 차단 → 연결 거부
        }

        token = token.substring(7);
        String username = jwtUtil.validateAndExtractUsername(token);
        if (username == null) {
            return null; //검증실패
        }

        //정상적으로 검증된 경우, 사용자 인증 정보 설정
        UserDetails userDetails = userDetailsService.loadUserByUsername(username);
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
            userDetails, null, userDetails.getAuthorities());
        accessor.getSessionAttributes()
            .put("user", authenticationToken);  //연결할 때 session에 한번 저장 (HttpSession아님. 웹소켓세션)
        accessor.getSessionAttributes().put("roomId", accessor.getFirstNativeHeader(
            "roomId"));  //연결할 때 session에 한번 저장 (HttpSession아님. 웹소켓세션)   , 프론트에서 roomId 항상 온다고 가정.
        accessor.setUser(authenticationToken);
        return message;
    }
}

 

 

 

 

 

 

ChatController

@Controller
@RequiredArgsConstructor
public class ChatController {

    private final SimpMessagingTemplate messagingTemplate;

    
      
    // 클라이언트 -> 서버
    @MessageMapping("/room/{roomId}")   //pub일 때, send일 때만 옴
    public void sendMessage(
        @DestinationVariable String roomId,
        ChatMessage message,
        Message<?> msg
        ) {
        StompHeaderAccessor accessor = StompHeaderAccessor.wrap(msg);  //매 새로운 메시지마다 생성됨.
        Object sessionUser = accessor.getSessionAttributes().get("user");  //interceptor에서 인증 성공 후 저장한 user정보
        Authentication auth=(Authentication) sessionUser;
        String username = auth.getName();
        message.setSender(username);
        messagingTemplate.convertAndSend("/sub/room/" + roomId, message);  //여기에서 해당 방(구독자들에만 메세지 전송
    }
}

 

 

 

 

room.html 

<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8">
  <title>방 상세</title>
  <script src="https://cdn.jsdelivr.net/npm/sockjs-client@1.6.1/dist/sockjs.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/stompjs@2.3.3/lib/stomp.min.js"></script>
  <script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
  <style>
    /* 전체 기본 스타일 */
    body {
      font-family: Arial, sans-serif;
      margin: 20px;
    }
    #chatList {
      list-style: none;
      padding: 0;
    }
    #chatList li {
      margin: 6px 0;
      padding: 8px 12px;
      border-radius: 12px;
      max-width: 60%;
      word-wrap: break-word;
    }
    /* 시스템 메시지 (입장/퇴장) */
    .system-msg {
      color: gray;
      font-style: italic;
      text-align: center;
      background: none;
    }
    /* 내가 보낸 메시지 */
    .my-msg {
      background-color: #d0ebff;
      text-align: right;
      margin-left: auto;
    }
    /* 남이 보낸 메시지 */
    .other-msg {
      background-color: #f1f3f5;
      text-align: left;
      margin-right: auto;
    }
  </style>
</head>
<body>
<h2>채팅방</h2>
<div>
  <input type="text" id="msgInput" placeholder="메시지를 입력하세요">
  <button id="sendBtn">보내기</button>
</div>
<ul id="chatList"></ul>

<script>
  $(document).ready(function() {
    const token = localStorage.getItem("accessToken");
    if (!token) {
      alert("로그인한 사람만 방 목록 가능합니다");
      window.location.href = "/loginPage";
      return;
    }

    const roomId = window.location.pathname.split("/").pop();  // /room/123 -> 123

    // ✅ 현재 로그인한 사용자 이름 가져오기 (/api/me)
    let currentUser = null;
    $.ajax({
      url: `/api/me`,
      method: "GET",
      headers: { "Authorization": "Bearer " + token },
      async: false, // 동기 요청 (connect 전에 username 확보)
      success: function(res) {
        if (res.username) {
          currentUser = res.username;
          console.log("현재 로그인 사용자:", currentUser);
        } else {
          alert("사용자 정보를 불러올 수 없습니다.");
        }
      },
      error: function() {
        alert("사용자 정보를 불러오지 못했습니다.");
      }
    });


    // ✅ STOMP 연결
    const socket = new SockJS('/ws-chat');
     //spring 서버의 websocket 엔드포인트 , 이 연결자체는 Http핸드쉐이크  이기때문에 security 설정에 걸림
      // 이 외의 stomp 연결 주소들은 spring securiy 랑 상관없고  채팅 인터셉터 JwtChannelInterceptor에 걸림
    const stompClient = Stomp.over(socket);

    stompClient.connect(
      { Authorization: "Bearer " + token, "roomId": roomId },
      function(frame) {
        console.log('Connected: ' + frame);


        // 연결 후 현재 채팅방에 websocket 구독
        stompClient.subscribe(`/sub/room/${roomId}`, function(message) {
          let msg;
          try {
            msg = JSON.parse(message.body);   // 서버에서  보낸 데이터를 받아서 파싱
          } catch (e) {
            // 서버에서 단순 문자열 보낼 경우 대비
            msg = { content: message.body };
          }

          // ✅ (1) 시스템 메시지 (입장/퇴장)
          if (!msg.sender) {
            $('#chatList').append(
              `<li class="system-msg">${msg.content}</li>`
            );
            return;
          }

          // ✅ (2) 내가 보낸 메시지
          if (msg.sender === currentUser) {
            $('#chatList').append(
              `<li class="my-msg"><b>${msg.sender}</b>: ${msg.content}</li>`
            );
          }
          // ✅ (3) 남이 보낸 메시지
          else {
            $('#chatList').append(
              `<li class="other-msg"><b>${msg.sender}</b>: ${msg.content}</li>`
            );
          }
        });
      },
      function(error) {
        console.error('STOMP 연결 오류', error);
        alert("채팅 연결 실패");
      }
    );

    // ✅ 메시지 보내기
    $('#sendBtn').click(function() {
      const msg = $('#msgInput').val();
      if (msg.trim() === "") return;

      stompClient.send(
        `/pub/room/${roomId}`,  //구독자 방에 msg 보내기 , 이  데이터가 ChatController로 가서 Meessage<> msg가 됨
        {},
        JSON.stringify({ content: msg })  
      );

      $('#msgInput').val('');
    });
  });
</script>
</body>
</html>

 

 

 

 

 

WebSocketEventListener   (연결에 한번, 연결끊길 때 한번 실행되는 이벤트) 

@Component
@RequiredArgsConstructor
public class WebSocketEventListener {

    private final SimpMessagingTemplate messagingTemplate;


    //연결됐을 때만 한번 실행
    //순서상 JwtChannelInterceptor  => 실제 연결 =>  여기.
    @EventListener
    public void handleWebSocketConnectListener(SessionConnectEvent event) {
        StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage());

        Object sessionUser = accessor.getSessionAttributes().get("user");
        Authentication auth= (Authentication) sessionUser;

        Object sessionRoomId = accessor.getSessionAttributes().get("roomId");
        String roomId=(String)sessionRoomId;

        if (auth != null) {
            String username = auth.getName();
            // roomId는 클라이언트에서 헤더로 보내거나, 세션에서 관리
            messagingTemplate.convertAndSend("/sub/room/" + roomId, username + "님이 입장했습니다.");

            //여기에서 해당 방에만..
            // html에   stompClient.subscribe(`/sub/room/${roomId}`  주소랑 같아야함.)
            // 실제 방을 나누는건 html에 stocmClient.subscribe를 통해   Stomp websocket이 방 나누는거고
            // 내 코드는 그 방에 메세지 전달하는 것 뿐
            
            //ChatController는 sendMessage에서 메시지 보낼 때마다 실행되지만  연결될 때는 실행X
            //여기는 연결될 때 한번만 실행됨. 한번 실행할 때 입장메세지 보내는 기능일 뿐.
            
        }
    }


    // 연결 해제(퇴장)   뒤로가기, 브라우저 종료 등 다 눈치챔.  앱에서 뒤로가기 등도 다 감지함.
    @EventListener
    public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) {
        StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage());
        Authentication auth = (Authentication) accessor.getSessionAttributes().get("user");
        String roomId = (String) accessor.getSessionAttributes().get("roomId");

        if (auth != null && roomId != null) {
            String username = auth.getName();
            messagingTemplate.convertAndSend("/sub/room/" + roomId, username + "님이 퇴장했습니다.");
        }
    }
}