Spring Boot WebSocket 연결 및 상태 관리

koonlx·2024년 9월 22일

stay_connect

목록 보기
8/10

기존에 팀원이 구현한 실시간 채팅 기능이 배포한 환경에서 동작하지 않아서 해결방법을 모색하다가 도저히 고칠 수가 없어서 재구현하기로 했다.

client

<!-- layout.html -->
<!DOCTYPE html>
<html
   lang="en"
   xmlns:th="http://www.thymeleaf.org"
   xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
   xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity6">
   <head>
      <meta charset="UTF-8" />
      <meta name="viewport" content="width=device-width, initial-scale=1.0" />
      <link rel="stylesheet" th:href="@{/css/common/footer.css}" />
      <link rel="stylesheet" th:href="@{/css/common/header.css}" />
      <link rel="stylesheet" th:href="@{/css/layouts/layout.css}" />
      <link rel="stylesheet" th:href="@{/lib/bootstrap/bootstrap.min.css}" />
      <link rel="stylesheet" th:href="@{/lib/googleapis/fonts.css}" />
      <script th:src="@{/lib/bootstrap/bootstrap.min.js}"></script>
      <script th:src="@{/lib/ajax/popper.min.js}"></script>
      <script th:src="@{/lib/jquery/jquery.min.js}"></script>
      <script th:src="@{/lib/jsdelivr/sockjs.min.js}"></script>
      <script th:src="@{/lib/jsdelivr/stomp.umd.min.js}"></script>
      <script th:src="@{/js/layout/wsManager.js}" defer></script>
      <!-- title -->
      <th:block layout:fragment="title"></th:block>
      <!-- css -->
      <th:block layout:fragment="css"></th:block>
      <!-- javascript -->
      <th:block layout:fragment="javascript"></th:block>
   </head>
   <body>
      <div class="layoutContainer">
         <header>
            <div th:insert="~{fragments/common/header_frag::header}"></div>
         </header>
         <section>
            <aside>
               <th:block layout:fragment="leftAside"></th:block>
            </aside>
            <main><th:block layout:fragment="main"></th:block></main>
            <aside>
               <th:block layout:fragment="rightAside"></th:block>
            </aside>
         </section>
         <div th:insert="~{fragments/common/chatCard_frag::chatCard}"></div>
         <footer>
            <div th:insert="~{fragments/common/footer_frag::footer}"></div>
         </footer>
      </div>
   </body>
</html>

client에서 socket의 상태관리를 해야하는데 모든 페이지에서 socket이 연결하도록 하기 위해 미리 분리해둔 layout.html에 wsManager 적용했다.
일단 사용자가 사용할 chat ui를 <div th:insert="~{fragments/common/chatCard_frag::chatCard}"></div>에 구현하여서 모든 페이지에서 꺼낼 수 있도록 설정했다.

<!-- header_frag.html -->
<div class="header" th:fragment="header">
   <div class="d-flex justify-content-between align-items-center">
      <div class="logo">
         <a th:href="@{/}">
            <img th:src="@{/img/logo.svg}" alt="StayConnect Logo" class="img-fluid" ; />
         </a>
      </div>
      <nav>
         <ul class="nav">
            <li class="nav-item" sec:authorize="isAnonymous()">
               <a class="nav-link" th:href="@{/auth/signin}">로그인</a>
            </li>
            <li class="nav-item" sec:authorize="isAnonymous()">
               <a class="nav-link" th:href="@{/auth/signup}">회원가입</a>
            </li>
            <li class="nav-item" sec:authorize="hasRole('ROLE_ADMIN')">
               <a class="nav-link" th:href="@{/admin/dashBoard}">관리자 대시보드</a>
            </li>
            <li class="nav-item" sec:authorize="hasRole('ROLE_USER')">
               <a id="inquiry_chat_btn" class="nav-link" role="button">문의 채팅</a>
            </li>
            <script th:src="@{/js/header/toggleChat.js}"></script>
            <li class="nav-item" sec:authorize="isAuthenticated()">
               <a id="logout" class="nav-link" th:href="@{/user/logout}">로그아웃</a>
            </li>
            <li class="nav-item">
               <a class="nav-icon" th:href="@{/user/myPage}">
                  <span class="material-symbols-outlined">account_circle</span>
               </a>
            </li>
         </ul>
      </nav>
   </div>
</div>

header_frag에서 문의 채팅을 클릭하면 채팅 ui를 toggle할 수 있도록 설정.

// wsManager.js
document.addEventListener('DOMContentLoaded', () => {
   const WebSocketManager = {
      stompClient: null,

      connect() {
         if (typeof StompJs === 'undefined') {
            console.error('STOMP.js is not loaded');
            return;
         }

         const socket = new SockJS('/ws');
         this.stompClient = new StompJs.Client({
            webSocketFactory: () => socket,
            reconnectDelay: 5000,
         });

         this.stompClient.onConnect = frame => {
            console.log('Connected: ' + frame);
         };

         this.stompClient.onDisconnect = () => {
            console.log('Disconnected');
         };

         this.stompClient.onStompError = frame => {
            console.error('STOMP error:', frame);
            this.retryConnect();
         };

         this.stompClient.activate();
      },

      retryConnect() {
         setTimeout(() => {
            console.log('Retrying WebSocket connection...');
            this.connect();
         }, 5000);
      },

      disconnect() {
         if (this.stompClient) {
            this.stompClient.deactivate();
            console.log('WebSocket disconnected');
         } else {
            console.log('No active WebSocket connection.');
         }
      },

      initialize() {
         this.connect();
      },
   };

   WebSocketManager.initialize();

   const observer = new MutationObserver((mutations, obs) => {
      const logoutButton = document.getElementById('logout');
      if (logoutButton) logoutButton.addEventListener('click', () => WebSocketManager.disconnect());
      obs.disconnect();
   });

   observer.observe(document.body, {
      childList: true,
      subtree: true,
   });
});

socket 상태관리를 위한 js 파일 구현. 이 또한 마찬가지로 전역적으로 어디서든지 동작하도록 layout.html에 추가해두었다.


Server

implementation 'org.springframework.boot:spring-boot-starter-websocket'
implementation 'org.springframework.security:spring-security-messaging'

WebSocket 연결 및 보안 설정을 위한 종속성을 추가해준다.

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
	@Autowired
	private JwtHandshakeInterceptor jwtHandshakeInterceptor;
	
	@Override
	public void configureMessageBroker(@NonNull MessageBrokerRegistry registry) {
		registry.enableSimpleBroker("/inquiry");
		registry.setApplicationDestinationPrefixes("/app");
	}
	
	@Override
	public void registerStompEndpoints(@NonNull StompEndpointRegistry registry) {
	registry.addEndpoint("/ws")
		.addInterceptors(jwtHandshakeInterceptor)
		.setAllowedOriginPatterns("http://localhost:8080", "https://stayconnect.shop")
		.withSockJS();
	}
}

pub 경로는 /app, sub 경로는 /inquiry 이다.

STOMP는 간단하게 설명하자면, client는 pub 경로로 메시지를 보내고 client가 구독한 sub 경로로만 메시지를 받을 수 있는 구조다.
필자는 ChatRoom Entity를 만들어서 id 값으로 경로를 분리하여 실시간 채팅을 구현할 생각이다.
WebSocketSecurity 파일은 다음에 만들 예정이다.

이렇게만 해주어도 ws 연결하는 데는 문제가 없다. 필자는 3-way-handshake가 이루어지는 /ws 경로는 인증된 사용자만 접근 가능하도록 하였기 때문에 로그인하지 않으면 ws 연결이 안된다. 이 때 어차피 연결이 안되지만 client와 server는 계속해서 연결 시도를 하기 때문에 트래픽이 낭비된다고 생각했다. 그래서 인터셉터 추가했다.

@Component
public class JwtHandshakeInterceptor implements HandshakeInterceptor {

  @Autowired
  private JWTokenProvider jwTokenProvider;

  @Override
  public boolean beforeHandshake(@NonNull ServerHttpRequest request, @NonNull ServerHttpResponse response,
      @NonNull WebSocketHandler wsHandler, @NonNull Map<String, Object> attributes) throws Exception {

    String jwToken = getJwtTokenFromCookies(request);
    if (jwToken != null && jwTokenProvider.validateToken(jwToken)) {
      return true;
    }
    response.setStatusCode(HttpStatus.UNAUTHORIZED);
    return false;
  }

  @Override
  public void afterHandshake(@NonNull ServerHttpRequest request, @NonNull ServerHttpResponse response,
      @NonNull WebSocketHandler wsHandler, @Nullable Exception exception) {
  }

  private String getJwtTokenFromCookies(@NonNull ServerHttpRequest request) {
    List<String> cookieHeaders = request.getHeaders().get(HttpHeaders.COOKIE);
    if (cookieHeaders != null) {
      return cookieHeaders.stream()
          .flatMap(cookieHeader -> HttpCookie.parse(cookieHeader).stream())
          .filter(cookie -> "jwt".equals(cookie.getName()))
          .map(HttpCookie::getValue)
          .findFirst()
          .orElse(null);
    }
    return null;
  }
}

token이 없으면 401 코드를 반환하도록 하였지만, 그래도 지속적으로 트래픽이 발생해서 로그를 봤다.

2024-09-22T06:15:31.401+09:00 DEBUG 71121 --- [StayConnect] [io-8080-exec-10] o.s.security.web.FilterChainProxy        : Securing GET /ws/334/4ayjl5ao/jsonp?c=_jp.awzsriq
2024-09-22T06:15:31.401+09:00 DEBUG 71121 --- [StayConnect] [io-8080-exec-10] o.s.s.w.a.AnonymousAuthenticationFilter  : Set SecurityContextHolder to anonymous SecurityContext
2024-09-22T06:15:31.402+09:00 DEBUG 71121 --- [StayConnect] [io-8080-exec-10] s.w.a.DelegatingAuthenticationEntryPoint : Trying to match using And [Not [RequestHeaderRequestMatcher [expectedHeaderName=X-Requested-With, expectedHeaderValue=XMLHttpRequest]], MediaTypeRequestMatcher [contentNegotiationStrategy=org.springframework.web.accept.ContentNegotiationManager@3988bf5a, matchingMediaTypes=[application/xhtml+xml, image/*, text/html, text/plain], useEquals=false, ignoredMediaTypes=[*/*]]]
2024-09-22T06:15:31.402+09:00 DEBUG 71121 --- [StayConnect] [io-8080-exec-10] s.w.a.DelegatingAuthenticationEntryPoint : Trying to match using And [Not [RequestHeaderRequestMatcher [expectedHeaderName=X-Requested-With, expectedHeaderValue=XMLHttpRequest]], MediaTypeRequestMatcher [contentNegotiationStrategy=org.springframework.web.accept.ContentNegotiationManager@3988bf5a, matchingMediaTypes=[application/xhtml+xml, image/*, text/html, text/plain], useEquals=false, ignoredMediaTypes=[*/*]]]
2024-09-22T06:15:31.402+09:00 DEBUG 71121 --- [StayConnect] [io-8080-exec-10] s.w.a.DelegatingAuthenticationEntryPoint : No match found. Using default entry point org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint@4f1c2a0f
2024-09-22T06:15:31.403+09:00 DEBUG 71121 --- [StayConnect] [io-8080-exec-10] o.s.s.web.DefaultRedirectStrategy        : Redirecting to http://localhost:8080/auth/signin
2024-09-22T06:15:31.418+09:00 DEBUG 71121 --- [StayConnect] [nio-8080-exec-8] o.s.security.web.FilterChainProxy        : Securing GET /auth/signin
2024-09-22T06:15:31.420+09:00 DEBUG 71121 --- [StayConnect] [nio-8080-exec-8] o.s.s.w.a.AnonymousAuthenticationFilter  : Set SecurityContextHolder to anonymous SecurityContext
2024-09-22T06:15:31.421+09:00 DEBUG 71121 --- [StayConnect] [nio-8080-exec-8] o.s.security.web.FilterChainProxy        : Secured GET /auth/signin

해당 로그가 매우 많이 반복해서 발생하길래 "redirect로 인해 상태코드가 전달되지 못한건 아닐까?" 추측하였다. 아마 SecurityFilter에서 상태가 초기화된거 아닐까라는 생각을 했다.

// WebSecurityConfig.java
.
..
...
                                .exceptionHandling(
                                                exception -> exception.defaultAuthenticationEntryPointFor(
                                                                unauthorizedEntryPoint(),
                                                                new AntPathRequestMatcher("/ws/**")))
...
..
.
        private AuthenticationEntryPoint unauthorizedEntryPoint() {
                return (request, response, authException) -> {
                        response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
                };
        }

그래서 SpringSecurity 설정 파일에서 /ws 경로에서 예외가 발생하면 401 코드를 반환하도록 해서 client가 다시 요청하는 일이 없도록 했다.

[Error] Failed to load resource: the server responded with a status of 401 () (info, line 0)

그럼 기존에 복수의 에러 로그가 발생하던 브라우저 개발자 도구 콘솔에서는 단 하나의 에러로그만 발생하는 것을 확인할 수 있다.

profile
Server Developer

0개의 댓글