| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| 8 | 9 | 10 | 11 | 12 | 13 | 14 |
| 15 | 16 | 17 | 18 | 19 | 20 | 21 |
| 22 | 23 | 24 | 25 | 26 | 27 | 28 |
- 프로젝트 이름 변경
- batch insert
- N + 1
- AWS
- naturalid
- 컨트리뷰터
- OIDC
- websocket
- 이펙티브 자바
- Git
- spring
- Cannotacquirelockexception
- awspring
- @Transaction(readOnly=true)
- 정적 팩터리 메서드
- 성능테스트
- @RequestMapping
- @controller
- JPA
- oauth2.0
- mockito
- ngrinder
- fetch join
- intellij
- redis
- spring-cloud-starter-aws
- MySQLTransactionRollbackException
- convertAndSendToUser
- injellij
- assert
- Today
- Total
정리정리
webSocket convertAndSendToUser 예외 처리 동작과정 및 삽질기 본문
소켓 통신을 구현하면서 비즈니스 로직 예외 처리를 구현하다가 겪은 삽질들을 기록하려고 합니다.
목표는 크게 두 가지였습니다.
- 전역적인 비즈니스 예외 처리
- subscribe 과정에서 예외가 발생할 경우, 구독에 실패하며 예외 처리
우선 동작하는 최종 코드입니다.
@Configuration
@RequiredArgsConstructor
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
private final AuthChannelInterceptor authChannelInterceptor;
private final StompExceptionHandler stompExceptionHandler;
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.setErrorHandler(stompExceptionHandler)
.addEndpoint("/chat-game")
.addInterceptors()
.setAllowedOriginPatterns("*");
// .withSockJS();
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/sub", "/queue");
registry.setApplicationDestinationPrefixes("/pub", "/sub");
registry.setUserDestinationPrefix("/user");
}
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(authChannelInterceptor);
}
}
@Slf4j
@ControllerAdvice
@RequiredArgsConstructor
public class WebSocketGlobalExceptionHandler {
private final SimpMessageSendingOperations messageTemplate;
@MessageExceptionHandler(IllegalArgumentException.class)
public void handle(IllegalArgumentException ex, @Header(StompHeaderAccessor.SESSION_ID_HEADER) String sessionId) {
log.info(ex.getMessage());
ErrorResponse errorResponse = new ErrorResponse(ex.getMessage());
SimpMessageHeaderAccessor accessor = SimpMessageHeaderAccessor.create(SimpMessageType.MESSAGE);
accessor.setContentType(MimeTypeUtils.APPLICATION_JSON);
accessor.setSessionId(sessionId);
messageTemplate.convertAndSendToUser(
sessionId,
"/queue/errors",
errorResponse,
accessor.getMessageHeaders()
);
}
}
@Slf4j
@Controller
@RequiredArgsConstructor
public class PlayerStompController {
@SubscribeMapping("/room/{roomId}")
public void connect(
@DestinationVariable("roomId") Long roomId,
@Header(StompHeaderAccessor.SESSION_ID_HEADER) String sessionId,
@Authenticated AuthContext authContext
) {
new IllegalArgumentException("방 입장 실패");
}
}

convertAndSendToUser 동작 과정
우선 삽질을 하면서 알아낸 convertAndSendToUser의 동작 과정입니다.

맨 처음 SimpMessageSendingOperations의 구현체인 SimpMessagingTemplate에서 convertAndSendToUser를 보면 config에서 설정한 destinationPrefix와 파라미터로 넘어온 user(현재는 sessionId), destination인 /queue/errors를 붙여 /{prefix}/{userName}/queue/errors 형식의 새로운 destination을 만듭니다.


이후에 convertAndSend 메서드를 계속 따라가다 보면 어떤 handler를 통해 처리를 할지 정하는 코드가 있습니다.
handler에는 config 설정에 따라 SimpleBrokerMessageHandler와 UserDestinationMessageHandler가 존재하는데, 특정 유저에게 메시지를 보내는 부분을 보기 위해서는 UserDestinationMessageHandler를 봐야 합니다.



이후에 UserDestinationMessageHandler의 resolveDestination을 따라가면 parseMessage 메서드를 볼 수 있는데, 여기에서 /{prefix}/{userName}/queue/errors을 /queue/errors-{prefix}{userName}의 형태로 다시 변경을 해줍니다.


이제 handleMessage를 계속 따라가다 보면 SimpBrokerMessageHandler의 handleMessageInternal에서 checkDestinationPrefix를 통해 config에서 enableSimpleBroker로 지정했던 prefix들을 체크하는 과정을 거칩니다.

여기까지 과정이 모두 끝나면 클라이언트에게 메시지를 보내는 작업을 하게 됩니다.
삽질기
1. enableSimpleBroker 설정
1-1. /queue 추가 x
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// registry.enableSimpleBroker("/sub", "/queue");
registry.enableSimpleBroker("/sub");
registry.setApplicationDestinationPrefixes("/pub", "/sub");
registry.setUserDestinationPrefix("/user");
}
주석처리가 되지 않은 부분이 처음 설정인데, 처음에는 이 부분을 아예 신경을 안 쓰고 있었습니다.

그래서 checkDestinationPrefix에서 계속 false가 나와 메시지를 보내지 않았습니다.
1-2. /user 추가
이 방법은 디버깅하다가 만약 enableSimpleBroker에 destinationPrefix를 추가하면 어떻게 될까 궁금해져서 해본 삽질입니다.
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// registry.enableSimpleBroker("/sub", "/queue");
registry.enableSimpleBroker("/sub", "/queue", "/user");
registry.setApplicationDestinationPrefixes("/pub", "/sub");
registry.setUserDestinationPrefix("/user");
}
왜 이렇게 하면 안 되는지 알기 위해서는 먼저 구독 과정을 알아봐야 합니다.


유저가 /user/queue/errors에 대해 구독 요청을 보내면 handler를 선택하는 과정에서 UserDestinationMessageHandler 이전에 SimpleBrokerMessageHandler에서 먼저 /user/queue/errors에 대해서도 구독 등록 과정을 처리하게 됩니다.
일반적인 상황이라면 위처럼 checkDestinationPrefix 메서드에서 /user/queue/errors 자체에 대한 처리를 실패하기 때문에 구독 등록을 하지 않습니다.

이후에 /queue/errors-{prefix}{userName} 형식으로 변경해 위의 과정을 다시 거치고, subscriptionRegistry에 구독 등록을 합니다.
만약에 enableSimpleBroker에 prefix를 추가하면 어떻게 될까요?


SimpleBrokerMessageHandler에서 /user/queue/errors를 처리하는 부분에서 문제가 발생합니다.
원래라면 여기서 걸러져야 했을 경로가 true가 되기 때문에 /user/queue/errors가 subscriptionRegistry에 등록이 됩니다.

그러면 어차피 /user/queue/errors도 등록되고 /queue/errors-{prefix}{userName}도 등록되면 상관없는 거 아닌가 할 수 있지만, 위에 있는 addSubscription 메서드를 보면 아니라는 것을 금방 알 수 있습니다.
구독 요청할 때 생기는 sub-0과 같은 id를 key값으로 putIfAbsent 메서드를 사용하고 있기 때문에 /user/queue/errors가 먼저 등록이 되면 동일 요청의 /queue/errors-{prefix}{userName}에 대해서는 구독 등록이 되지 않게 됩니다.


그 결과, 동일 구독 id에 대하여 /user/queue/errors를 반환하게 되고, 이는 해당 유저의 destination과 다른 값이기 때문에 메시지를 보내는데 실패하게 됩니다.
2. convertAndSendToUser
2-1. convertAndSendToUser API
@MessageExceptionHandler(IllegalArgumentException.class)
public void handle(IllegalArgumentException ex, StompHeaderAccessor accessor) {
log.info(ex.getMessage());
ErrorResponse errorResponse = new ErrorResponse(ex.getMessage());
String sessionId = requireNonNull(accessor.getSessionId());
//messageTemplate.convertAndSendToUser(sessionId, "/queue/errors", errorResponse, accessor.getMessageHeaders());
messageTemplate.convertAndSendToUser(sessionId, "/queue/errors", errorResponse);
}
사실 이번 포스팅을 하게 된 원흉(?)입니다.
처음에는 별생각 없이 convertAndSendToUser이라는 메서드가 있다는 것만 알고 헤더 없이 위의 코드처럼 작성을 했었습니다.
결론부터 말하면 당연히 안 됩니다.



여러 convertAndSendToUser를 거치다 보면 넘어온 파라미터로 Message 객체를 만들게 되는데, 이때 제가 사용한 convertAndSendToUser는 header를 null로 넘기기 때문에 doConvert 메서드 안에서 기본 MessageHeaders를 만들어 사용하게 됩니다.


기본 MessageHeaders에는 유저 정보가 들어있지 않기 때문에 targetDestination을 만들지 못하고 메시지를 보내지 못한다.
2-2. STOMP header
@MessageExceptionHandler(IllegalArgumentException.class)
public void handle(IllegalArgumentException ex, StompHeaderAccessor accessor) {
log.info(ex.getMessage());
ErrorResponse errorResponse = new ErrorResponse(ex.getMessage());
String sessionId = requireNonNull(accessor.getSessionId());
messageTemplate.convertAndSendToUser(sessionId, "/queue/errors", errorResponse, accessor.getMessageHeaders());
}
또 다른 삽질로는 요청 때 들어왔던 accessor를 재활용했을 때 발생했습니다.
이 경우, MESSAGE 요청에서 발생한 예외를 처리할 때는 문제가 되지 않지만 SUBSCRIBE를 할 때 발생한 예외의 경우 문제가 생깁니다.


이유는 간단한데, 우선 header에 messageType이 SUBSCRIBE이기 때문에 parseSubscriptionMessage에서 메시지를 처리하게 되기 때문에 targetDestination이 올바르게 파싱 되지 못합니다.
억지로 simpMessageType을 MESSAGE로 바꾼다 하더라도 현재 Message의 stompCommand가 Subscribe이기 때문에 프런트에서 올바르게 처리할 수가 없습니다.
번외
convertAndSendToUser의 user

처음에 user에 어떤 값을 넣어야 하는지 몰라서 삽질을 했습니다.

열심히 삽질한 결과, 기본적으로는 sessionId를 user 값으로 사용하면 되며, sessionId가 싫다면 SimpMessageHeaderAccessor에 setUser 메서드를 통해 Principal 객체를 등록하여 사용할 수도 있습니다.
현재 userName과 sessionId가 같지 않다면 getSessionIdsByUser 메서드를 통해 sessionId를 가져오는 작업을 수행합니다.
@Component
@RequiredArgsConstructor
public class AuthChannelInterceptor implements ChannelInterceptor {
private final TokenExtractor tokenExtractor;
private final JwtProvider jwtProvider;
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = StompHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
...
accessor.setUser((Principal) () -> jwtClaim.id());
return message;
}
}
@MessageExceptionHandler(IllegalArgumentException.class)
public void handle(Principal principal, IllegalArgumentException ex, @Header(StompHeaderAccessor.SESSION_ID_HEADER) String sessionId) {
log.info(ex.getMessage());
ErrorResponse errorResponse = new ErrorResponse(ex.getMessage());
SimpMessageHeaderAccessor accessor = SimpMessageHeaderAccessor.create(SimpMessageType.MESSAGE);
accessor.setContentType(MimeTypeUtils.APPLICATION_JSON);
accessor.setSessionId(sessionId);
messageTemplate.convertAndSendToUser(
principal.getName(),
"/queue/errors",
errorResponse,
accessor.getMessageHeaders()
);
}
따라서 위처럼 작성할 수도 있습니다.
웹소켓을 처음 사용해보기도 하고, 디버깅 하다가 오류가 있었을 수도 있기 때문에 혹시라도 잘못된 부분 있으면 말씀해주시면 감사하겠습니다!
웹소켓 툴은 다음 사이트를 사용했습니다
'Spring' 카테고리의 다른 글
| 소소한 스프링 레디스 컨트리뷰트 이야기 (0) | 2026.02.09 |
|---|---|
| 스프링6 @RequestMapping Handler 등록 방식 변화 (feat. @Controller) (0) | 2023.11.16 |
| 스프링 부트 버전에 따른 io.awspring.cloud s3 연결시 주의점 (0) | 2023.04.20 |
| org.springframework.cloud VS io.awspring.cloud (0) | 2023.04.17 |