-
Notifications
You must be signed in to change notification settings - Fork 4
feat: WebSocket 성능 최적화 #257
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
The head ref may contain hidden characters: "\uAE40\uC18C\uBA85"
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,101 @@ | ||
| package kr.inventory.domain.chat.controller.support; | ||
|
|
||
| import com.fasterxml.jackson.databind.ObjectMapper; | ||
| import java.time.OffsetDateTime; | ||
| import kr.inventory.domain.chat.constant.ChatConstants; | ||
| import kr.inventory.domain.chat.controller.dto.request.ChatSendMessageRequest; | ||
| import kr.inventory.domain.chat.controller.dto.response.ChatRealtimeEventResponse; | ||
| import kr.inventory.domain.chat.controller.dto.response.ChatRealtimeEventType; | ||
| import kr.inventory.domain.chat.entity.enums.ChatMessageStatus; | ||
| import kr.inventory.domain.chat.exception.ChatException; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.messaging.Message; | ||
| import org.springframework.messaging.handler.annotation.MessageExceptionHandler; | ||
| import org.springframework.messaging.simp.SimpMessageHeaderAccessor; | ||
| import org.springframework.messaging.simp.annotation.SendToUser; | ||
| import org.springframework.web.bind.annotation.ControllerAdvice; | ||
| import org.springframework.validation.FieldError; | ||
| import org.springframework.web.bind.MethodArgumentNotValidException; | ||
|
|
||
| @Slf4j | ||
| @ControllerAdvice | ||
| @RequiredArgsConstructor | ||
| public class ChatWebSocketExceptionHandler { | ||
|
|
||
| private final ObjectMapper objectMapper; | ||
|
|
||
| @MessageExceptionHandler(Exception.class) | ||
| @SendToUser(destinations = ChatConstants.USER_QUEUE_DESTINATION, broadcast = false) | ||
| public ChatRealtimeEventResponse handleException(Exception exception, Message<?> message) { | ||
| ChatSendMessageRequest request = extractRequest(message); | ||
| String sessionId = SimpMessageHeaderAccessor.getSessionId(message.getHeaders()); | ||
| String destination = SimpMessageHeaderAccessor.getDestination(message.getHeaders()); | ||
|
|
||
| log.warn( | ||
| "Handled chat WebSocket exception. sessionId={}, destination={}, threadId={}, clientMessageId={}, reason={}", | ||
| sessionId, | ||
| destination, | ||
| request != null ? request.threadId() : null, | ||
| request != null ? request.clientMessageId() : null, | ||
| exception.getMessage(), | ||
| exception | ||
| ); | ||
|
|
||
| return new ChatRealtimeEventResponse( | ||
| ChatRealtimeEventType.CHAT_FAILED, | ||
| request != null ? request.threadId() : null, | ||
| null, | ||
| request != null ? request.clientMessageId() : null, | ||
| ChatMessageStatus.FAILED, | ||
| null, | ||
| resolveErrorMessage(exception), | ||
| OffsetDateTime.now() | ||
| ); | ||
| } | ||
|
|
||
| private ChatSendMessageRequest extractRequest(Message<?> message) { | ||
| if (message == null) { | ||
| return null; | ||
| } | ||
|
|
||
| Object payload = message.getPayload(); | ||
| if (payload instanceof ChatSendMessageRequest request) { | ||
| return request; | ||
| } | ||
|
|
||
| try { | ||
| if (payload instanceof byte[] bytes) { | ||
| return objectMapper.readValue(bytes, ChatSendMessageRequest.class); | ||
| } | ||
|
|
||
| if (payload instanceof String text) { | ||
| return objectMapper.readValue(text, ChatSendMessageRequest.class); | ||
| } | ||
| } catch (Exception ignored) { | ||
| return null; | ||
| } | ||
|
|
||
| return null; | ||
| } | ||
|
|
||
| private String resolveErrorMessage(Exception exception) { | ||
| if (exception instanceof ChatException chatException) { | ||
| return chatException.getMessage(); | ||
| } | ||
|
|
||
| if (exception instanceof MethodArgumentNotValidException validationException) { | ||
| FieldError fieldError = validationException.getBindingResult().getFieldError(); | ||
| if (fieldError != null && fieldError.getDefaultMessage() != null) { | ||
| return fieldError.getDefaultMessage(); | ||
| } | ||
| } | ||
|
|
||
| String message = exception.getMessage(); | ||
| if (message == null || message.isBlank()) { | ||
| return "채팅 요청을 처리하지 못했습니다."; | ||
| } | ||
|
|
||
| return message; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -2,13 +2,17 @@ | |||||||||
|
|
||||||||||
| import kr.inventory.global.auth.interceptor.JwtHandshakeInterceptor; | ||||||||||
| import kr.inventory.global.auth.interceptor.StompAuthChannelInterceptor; | ||||||||||
| import kr.inventory.global.constant.WebSocketConstants; | ||||||||||
| import lombok.RequiredArgsConstructor; | ||||||||||
| import org.springframework.context.annotation.Bean; | ||||||||||
| import org.springframework.context.annotation.Configuration; | ||||||||||
| import org.springframework.messaging.simp.config.ChannelRegistration; | ||||||||||
| import org.springframework.messaging.simp.config.MessageBrokerRegistry; | ||||||||||
| import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; | ||||||||||
| import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; | ||||||||||
| import org.springframework.web.socket.config.annotation.StompEndpointRegistry; | ||||||||||
| import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; | ||||||||||
| import org.springframework.web.socket.config.annotation.WebSocketTransportRegistration; | ||||||||||
|
|
||||||||||
| @Configuration | ||||||||||
| @EnableWebSocketMessageBroker | ||||||||||
|
|
@@ -17,28 +21,63 @@ public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { | |||||||||
|
|
||||||||||
| private final JwtHandshakeInterceptor jwtHandshakeInterceptor; | ||||||||||
| private final StompAuthChannelInterceptor stompAuthChannelInterceptor; | ||||||||||
| private final CorsProperties corsProperties; | ||||||||||
|
|
||||||||||
| @Override | ||||||||||
| public void registerStompEndpoints(StompEndpointRegistry registry) { | ||||||||||
| String[] allowedOriginPatterns = corsProperties.getAllowedOrigins().toArray(String[]::new); | ||||||||||
|
|
||||||||||
| registry.addEndpoint("/ws") | ||||||||||
| .addInterceptors(jwtHandshakeInterceptor) | ||||||||||
| .setAllowedOriginPatterns("*"); | ||||||||||
| .setAllowedOriginPatterns(allowedOriginPatterns); | ||||||||||
|
|
||||||||||
| registry.addEndpoint("/ws") | ||||||||||
| .addInterceptors(jwtHandshakeInterceptor) | ||||||||||
| .setAllowedOriginPatterns("*") | ||||||||||
| .setAllowedOriginPatterns(allowedOriginPatterns) | ||||||||||
| .withSockJS(); | ||||||||||
| } | ||||||||||
|
|
||||||||||
| @Override | ||||||||||
| public void configureClientInboundChannel(ChannelRegistration registration) { | ||||||||||
| registration.interceptors(stompAuthChannelInterceptor); | ||||||||||
| registration.taskExecutor() | ||||||||||
| .corePoolSize(WebSocketConstants.INBOUND_CORE_POOL_SIZE) | ||||||||||
| .maxPoolSize(WebSocketConstants.INBOUND_MAX_POOL_SIZE) | ||||||||||
| .queueCapacity(WebSocketConstants.CHANNEL_QUEUE_CAPACITY); | ||||||||||
| } | ||||||||||
|
|
||||||||||
| @Override | ||||||||||
| public void configureClientOutboundChannel(ChannelRegistration registration) { | ||||||||||
| registration.taskExecutor() | ||||||||||
| .corePoolSize(WebSocketConstants.OUTBOUND_CORE_POOL_SIZE) | ||||||||||
| .maxPoolSize(WebSocketConstants.OUTBOUND_MAX_POOL_SIZE) | ||||||||||
| .queueCapacity(WebSocketConstants.CHANNEL_QUEUE_CAPACITY); | ||||||||||
| } | ||||||||||
|
|
||||||||||
| @Override | ||||||||||
| public void configureMessageBroker(MessageBrokerRegistry registry) { | ||||||||||
| registry.enableSimpleBroker("/topic", "/queue"); | ||||||||||
| registry.enableSimpleBroker("/topic", "/queue") | ||||||||||
| .setHeartbeatValue(WebSocketConstants.SIMPLE_BROKER_HEARTBEAT) | ||||||||||
| .setTaskScheduler(webSocketBrokerTaskScheduler()); | ||||||||||
| registry.setApplicationDestinationPrefixes("/app"); | ||||||||||
| registry.setUserDestinationPrefix("/user"); | ||||||||||
| } | ||||||||||
|
|
||||||||||
| @Override | ||||||||||
| public void configureWebSocketTransport(WebSocketTransportRegistration registry) { | ||||||||||
| registry.setMessageSizeLimit(WebSocketConstants.MESSAGE_SIZE_LIMIT) | ||||||||||
| .setSendBufferSizeLimit(WebSocketConstants.SEND_BUFFER_SIZE_LIMIT) | ||||||||||
| .setSendTimeLimit(WebSocketConstants.SEND_TIME_LIMIT_MS) | ||||||||||
| .setTimeToFirstMessage(WebSocketConstants.TIME_TO_FIRST_MESSAGE_MS); | ||||||||||
| } | ||||||||||
|
|
||||||||||
| @Bean | ||||||||||
| public ThreadPoolTaskScheduler webSocketBrokerTaskScheduler() { | ||||||||||
| ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); | ||||||||||
| scheduler.setPoolSize(2); | ||||||||||
| scheduler.setThreadNamePrefix("ws-broker-heartbeat-"); | ||||||||||
|
Comment on lines
+77
to
+78
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
참고:
Suggested change
References
|
||||||||||
| scheduler.setRemoveOnCancelPolicy(true); | ||||||||||
| scheduler.initialize(); | ||||||||||
| return scheduler; | ||||||||||
| } | ||||||||||
| } | ||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| package kr.inventory.global.websocket; | ||
|
|
||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.context.event.EventListener; | ||
| import org.springframework.messaging.simp.stomp.StompHeaderAccessor; | ||
| import org.springframework.stereotype.Component; | ||
| import org.springframework.web.socket.messaging.SessionConnectEvent; | ||
| import org.springframework.web.socket.messaging.SessionConnectedEvent; | ||
| import org.springframework.web.socket.messaging.SessionDisconnectEvent; | ||
| import org.springframework.web.socket.messaging.SessionSubscribeEvent; | ||
|
|
||
| @Slf4j | ||
| @Component | ||
| public class WebSocketSessionEventListener { | ||
|
|
||
| @EventListener | ||
| public void onSessionConnect(SessionConnectEvent event) { | ||
| StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); | ||
| log.info("[WebSocket] CONNECT sessionId={}, user={}, destination={}", | ||
| accessor.getSessionId(), | ||
| accessor.getUser() != null ? accessor.getUser().getName() : null, | ||
| accessor.getDestination()); | ||
| } | ||
|
|
||
| @EventListener | ||
| public void onSessionConnected(SessionConnectedEvent event) { | ||
| StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); | ||
| log.info("[WebSocket] CONNECTED sessionId={}, user={}", | ||
| accessor.getSessionId(), | ||
| accessor.getUser() != null ? accessor.getUser().getName() : null); | ||
| } | ||
|
|
||
| @EventListener | ||
| public void onSessionSubscribe(SessionSubscribeEvent event) { | ||
| StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); | ||
| log.info("[WebSocket] SUBSCRIBE sessionId={}, user={}, destination={}", | ||
| accessor.getSessionId(), | ||
| accessor.getUser() != null ? accessor.getUser().getName() : null, | ||
| accessor.getDestination()); | ||
| } | ||
|
|
||
| @EventListener | ||
| public void onSessionDisconnect(SessionDisconnectEvent event) { | ||
| StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); | ||
| log.info("[WebSocket] DISCONNECT sessionId={}, user={}, closeStatus={}", | ||
| accessor.getSessionId(), | ||
| accessor.getUser() != null ? accessor.getUser().getName() : null, | ||
| event.getCloseStatus()); | ||
| } | ||
| } | ||
|
Comment on lines
+14
to
+50
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
아래와 같이 헬퍼 메소드를 추가하고 각 이벤트 리스너에서 호출하는 것을 제안합니다. private String getUserName(StompHeaderAccessor accessor) {
return java.util.Optional.ofNullable(accessor.getUser())
.map(java.security.Principal::getName)
.orElse(null);
}적용 예시: @EventListener
public void onSessionConnect(SessionConnectEvent event) {
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage());
log.info("[WebSocket] CONNECT sessionId={}, user={}, destination={}",
accessor.getSessionId(),
getUserName(accessor),
accessor.getDestination());
}References
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
catch (Exception ignored)블록에서 예외를 무시하고 있습니다. 레포지토리 스타일 가이드 53번 라인("실패를 숨기지 않는다: 의미 없는 catch 후 무시 금지")에 따라, 예외를 무시하는 것은 잠재적인 버그를 찾기 어렵게 만들 수 있습니다. 페이로드 역직렬화 실패는 정상적인 흐름에서 발생할 수 있으므로, 디버깅을 위해 최소한debug레벨로 로그를 남기는 것이 좋습니다.References