Search
🤦🏻‍♀️

웹소켓 Error Custom 도전기

Person
태그
에러 커스텀

웹소켓 에러 메세지 커스텀

웹소켓 통신 구현은 완료했는데 새로운 문제가 생겼다.
AH…바로.. 에러 분기 처리의 문제였다.
웹소켓을 구현하면서 처음 써보는 기술이라 연결 과정과 메세지 전달 여부에 신경이 쏠려있어 미처 에러 커스텀에는 신경을 쓰지 못하고 있었다.
특히나 프론트에서는 웹소켓 통신이 끊어졌을 경우 프론트에서는 아래와 같이 메세지를 보낼 수 없다는 에러 메세지만 받고 있었기 때문에 메세지 전송이 실패된 것이 토큰이 만료된 탓인지 아니면 서버 내부 통신 문제인지 정확히 알 길이 없었다.
Failed to send message to ExecutorSubscribableChannel[clientInboundChannel]
그래서 웹소켓 통신의 에러 분기 작업을 시작하기로 했다.
그리고 또 하나! 코드를 보면서 웹소켓 통신 후 이루어지는 내부 로직에서 기존 RestApi에서 사용하는 예외 처리를 사용하고 있었다.
결과적으로 해결해야 할 작업 목록은 다음과 같았다.
1.
웹소켓 에러 핸들러 작성
2.
웹소켓 통신에 대한 예외 처리 구현 (RestApi → Websocket)
이제 어떻게 해결하였는지 차근차근 살펴보도록 하자.

1. 웹소켓 에러 핸들러 작성하기

우선 웹소켓 통신은 서버와 클라이언트 간 socket connection을 유지하게 하여 언제든 양방향 통신이 가능해야 한다.
즉, 프론트에서 메세지 전송에 실패했을 경우 가장 큰 이유는 클라이언트와 서버의 통신이 끊어졌기 때문이다.
프로젝트에서 웹소켓 연결은 ChannelInterceptor를 구현한 StompHandler에서 이루어진다.
코드는 아래와 같다.
<StompHandler>
public class StompHandler implements ChannelInterceptor { private final WebsocketUtil websocketUtil; @Override public Message<?> preSend(Message<?> message, MessageChannel channel) { StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message); String accessToken = accessor.getFirstNativeHeader("Authorization"); log.info("Received STOMP Message: " + message); log.info("All headers: " + accessor.toNativeHeaderMap()); log.info("Access Token: " + accessToken); log.info("Incoming message type: " + accessor.getMessageType()); // websocket 연결 시 헤더의 JWT 토큰 유효성 검증 if (SimpMessageType.CONNECT.equals(accessor.getMessageType()) || SimpMessageType.MESSAGE.equals(accessor.getMessageType())) { log.info("accessor: " + accessor.getMessageType()); if (websocketUtil.isValidToken(accessToken)) { String principal = websocketUtil.getEmail(accessToken); log.info("어세스토큰: " + accessToken); log.info("유저 이메일: " + principal); // JWT 토큰이 유효하면, 사용자 정보를 연결 세션에 추가 UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(principal, accessToken, new ArrayList<>()); SecurityContextHolder.getContext().setAuthentication(authenticationToken); Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); String username = authentication.getName(); // 현재 사용자의 email 얻기 log.info("authentication: " + authentication); log.info("username: " + username); } } return message; } }
Java
복사
이 코드에서는 Stomp 메세지의 헤더를 가져와 메세지 타입이 연결(CONNECT) 상태인지 그리고 메세지 타입인지(MESSAGE)를 검사하여 상태가 True일 경우 토큰 검사 후 연결을 진행한다.

첫 번째 시도 - 실패

그렇다면 StompHandler에서 연결이 실패하는 지점. 그러니까 else 지점에 연결 실패(DISCONNECT) 상태일 때에 대한 에러 분기를 하면 되지 않을까? 하는 생각이 들었다.
그리고 실제로 연결 실패 상태일 경우의 메세지 타입을 시도하였으나 문제는 클라이언트 콘솔에 계속 아래의 저 고정적인 에러 메세지만이 뜬다는 것..!
Failed to send message to ExecutorSubscribableChannel[clientInboundChannel]
결과적으로 메세지 연결 상태 확인은 가능하지만 에러 메세지 커스텀에 어려움이 있어 다른 방법을 시도해보았다.

두 번째 시도 - 성공

다른 방법을 고민하다 또 떠오른 생각은 security에서 EntryPoint의 역할처럼 웹소켓 연결 엔드 포인트에 대한 에러 핸들러를 작성하면 되지 않을까? 하는 것이었다.
그래서 곧바로 찾아보았고 아래와 같은 에러 핸들러 코드를 작성해보았다.
<StompErrorHandler>
public class StompErrorHandler extends StompSubProtocolErrorHandler { public StompErrorHandler(){ super(); } @Override public Message<byte[]> handleClientMessageProcessingError(Message<byte[]> clientMessage, Throwable ex) { Throwable exception = ex; if(exception instanceof MalformedInputException){ log.info("잘못된 양식에 의한 에러"); return handleUnauthorizedException(clientMessage, exception); } if (exception instanceof MessageDeliveryException) { // 메세지 전송 도중 토큰 만료 return handleUnauthorizedException(clientMessage, exception); } return super.handleClientMessageProcessingError(clientMessage, ex); } private Message<byte[]> handleUnauthorizedException(Message<byte[]> clientMessage, Throwable ex) { ErrorType errorType; if (ex.getCause() instanceof ExpiredJwtException){ //토큰 만료 시 errorType = ErrorType.ACCESS_TOKEN_EXPIRED; }else if(ex.getCause() instanceof ApiException){ //권한 없을 시 errorType = ErrorType.USER_NOT_AUTHORIZED; }else{ //이외 메세지 데이터 타입 에러 errorType = ErrorType.INVALID_MESSAGE_FORMAT; } return prepareErrorMessage(clientMessage, errorType, String.valueOf(errorType)); } private Message<byte[]> prepareErrorMessage(Message<byte[]> clientMessage, ErrorType apiError, String errorCode) { String message = apiError.getMessage(); StompHeaderAccessor accessor = StompHeaderAccessor.create(StompCommand.ERROR); accessor.setMessage(errorCode); accessor.setLeaveMutable(true); return MessageBuilder.createMessage(message.getBytes(StandardCharsets.UTF_8), accessor.getMessageHeaders()); } }
Java
복사
StompSubProtocolErrorHandler는 웹소켓 클라이언트로부터 수신 된 메세지를 처리하는 도중 발생하는 에러를 처리한다.
이 핸들러는 StompSubProtocolErrorHandler을 확장하여 만약 웹소켓 통신이 끊어지거나 혹은 클라이언트에서 잘못된 데이터 타입의 메세지를 보내면 이 에러 핸들러에서 처리할 수 있도록 구현하였다.
그리고 계속 해결하고자 했던 콘솔로 출력되는 웹소켓 에러 통신 커스텀!
마지막의 prepareErrorMessage로 해결할 수 있었다.
받아온 에러 코드와 메세지를 세팅하고 MessageBuilder를 이용하여 Message<byte[]> 형식으로 반환한다.
그럼 프론트에서는 기존에 뜨던 에러가 아닌 커스텀 한 에러 메세지를 확인할 수 있게 된다.
*** 테스트 결과 토큰이 만료되었을 때 혹은 접근 권한이 없을 때 모두 웹소켓 에러 헨들러에서는 MessageDeliveryException로 분류하여 내려주고 있었다. 따라서 세부 분류를 위해 ex.getCause()로 한 번 더 에러 객체를 확인하여 분기처리를 하였다. ***
Java
복사

Test

RestApi의 경우 postman을 활용하여 통신 테스트를 할 수 있지만 웹소켓의 경우 실시간 통신이다 보니 쉽지 않았다.
Apic을 사용하려 했으나 토큰 문제로 사용하는데 어려움을 겪었고
얼마 없는 자료를 찾아보다 websocket-debug-tool 을 찾았다. 이를 활용하여 웹소켓 통신 테스트를 성공적으로 할 수 있었다.

결과

<커스텀 된 에러 메세지>
권한이 없는 사용자 접근 시
어세스 토큰 만료 시

2. 웹소켓 예외 처리 구현

이제 웹소켓 연결에 대한 에러 메세지 커스텀은 완료하였다.
그러나 아직 다 해결된 것이 아니다. 그동안 RestApi에 익숙해져 있었기 때문에 웹소켓 통신에 대한 예외 처리도
전부 기존에 사용하던 ApiException으로 터뜨리도록 해 놓았기 때문이다.
때문에 클라이언트에서도 예외 메세지를 알 수 있도록 하기 위해 웹소켓 통신에 대한 예외 처리 구현을 시도하였다.

첫 번째 시도 - 실패

우선 가장 처음 접근했던 방법은 에러 메세지를 커스텀 했던 것처럼 예외 처리 메시지를 MessageBuilder로 프론트로 반환하면 되지 않을까 하는 생각이었지만
그리 쉽게 해결되는 문제가 아니었다..
기존 프로젝트에서 생성하던 Exception 패키지에서 웹소켓 에러 클래스를 아래와 같이 생성하였고
@ExceptionHandler({WebSocketException.class})
MessageBuilder 를 사용하여 메세지를 반환하는 방식으로 WebSocketException Response를 작성해 보았지만
실패하였다.
서비스 단에서 터지는 예외 상황은 웹소켓 자체의 에러가 아니기 때문에 웹소켓 에러 핸들러를 구현했던 방식은 통하지 않았다.
무슨 말인가 하면 웹소켓은 계속 연결되어 있다. 즉, 통신에는 지장이 없다. 다만 내부 개발 로직에서 무언가 오류가 생긴 것이다. 예를 들어 유저가 존재하지 않아서 insert를 하지 못한다거나 혹은 해당 플래너에 접근 권한을 가지고 있는 유저가 아니라던가..
아무튼 이 방식으로는 해결되지 않았기 때문에 바로 다른 접근법을 생각해 보았다.

두 번째 시도 - 성공

웹소켓 예외 컨트롤러 구현
또 다른 접근법은 바로 웹소켓 예외 컨트롤러를 구현한 것이다.
쉽게 말하자면 특정 주소를 구독하고 있는 유저가 그 주소로 전송된 메세지를 받아볼 수 있다는 점을 활용하여
에러 메세지만 확인할 수 있는 컨트롤러를 생성한 것이다.
<WebSocketErrorController>
@Controller public class WebSocketErrorController { @Autowired private SimpMessagingTemplate messagingTemplate; @MessageMapping("/") public void handleChatMessage(ErrorType errorType){ // 전달받은 에러 코드를 프론트로 전송하기 위한 부분 messagingTemplate.convertAndSend("/sub/error/planner-message" , Map.of("code", errorType.getErrorCode(), "message", errorType.getMessage(), "status", errorType.getStatus() ) ); } }
Java
복사
웹소켓 에러 컨트롤러 코드이다.
모든 엔드 포인트에 대해 에러 메세지를 받아볼 수 있도록 하기 위해 @MessageMapping("/")으로 설정하였다.
이제 이것을 어떻게 활용하는가?
아래를 확인해보자.
private final WebSocketErrorController webSocketErrorController; // 플래너와 사용자에 대한 검증 public Planner validatePlannerAndUserAccess(Long plannerId) { Planner planner = plannerRepository.findById(plannerId) /*.orElseThrow(() -> new ApiException(ErrorType.PLANNER_NOT_FOUND));*/ .orElse(null); if(planner == null){ webSocketErrorController.handleChatMessage(ErrorType.PLANNER_NOT_FOUND); throw new ApiException(ErrorType.PLANNER_NOT_FOUND); } String currentEmail = authUtil.getCurrentMember().getEmail(); List<GroupMember> groupMembers = groupMemberRepository.findGroupMemberByPlannerId(plannerId); if (groupMembers.stream().noneMatch(gm -> gm.getEmail().equals(currentEmail))) { webSocketErrorController.handleChatMessage(ErrorType.USER_NOT_FOUND); throw new ApiException(ErrorType.USER_NOT_AUTHORIZED); } return planner; }
Java
복사
다음은 채팅을 보낼 때 해당 플래너와 메세지를 보내는 사용자에 대한 검증을 시도하는 코드이다.
if(planner == null){ webSocketErrorController.handleChatMessage(ErrorType.PLANNER_NOT_FOUND); throw new ApiException(ErrorType.PLANNER_NOT_FOUND); }
Java
복사
이 부분은 만약 조회한 플래너가 존재하지 않을 경우 플래너가 존재하지 않는다는 예외 메세지를 터뜨리는 부분이다.
웹소켓 에러 메세지 컨트롤러는 말 그대로 메세지를 에러 구독 주소로 보내주는 기능만 하는 것이기 때문에
서버 내부에서 예외가 발생했을 때 이를 막는 것까지 처리하진 않는다.
때문에 예외가 발생할 경우 에러 구독 주소로 ‘왜 메세지 전송이 실패하였는지’ 알려주는 에러 메세지를 보낸 후
기존의 예외 처리 코드로 내부에서 예외를 터뜨려 더 이상 프로세스가 진행되지 못하도록 하였다.

Test

웹소켓 테스트 툴을 이용하여 존재하지 않는 유저일 경우를 가정하여 메세지를 전송하였고 아래는 에러 주소를 구독한 웹소켓 테스트 화면이다.
가장 하단을 살펴보면 아래와 같이 서버에서 보내주는 에러 코드를 확인할 수 있다.

고민해볼 점

1.
에러를 구독하지 않고 메세지를 전송할 때 그 즉시 확인할 수 있는 방법은 없을까?
a.
시도한 방법은 성공하지 못하였다.
2.
현재는 토큰 만료, 접근 권한 제한 등의 에러만 분기 처리하였는데 이외에 또 발생 가능한 다른 에러는 무엇이 있을까?
a.
잘못된 양식에 대한 에러 처리로 MalformedInputException에 대한 분기 처리를 해 놓았는데 테스트 도중 아직 이 에러를 마주하지 못하였다. (특수 문자를 포함하여 여러 메세지를 보내보아도 나오지 않았다..)