기존에 팀원이 구현한 실시간 채팅 기능이 배포한 환경에서 동작하지 않아서 해결방법을 모색하다가 도저히 고칠 수가 없어서 재구현하기로 했다.
<!-- 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에 추가해두었다.
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)
그럼 기존에 복수의 에러 로그가 발생하던 브라우저 개발자 도구 콘솔에서는 단 하나의 에러로그만 발생하는 것을 확인할 수 있다.