안녕하세요, [모두의택시] 팀 서버 개발자 박성훈입니다.
자바에서는 오류 코드를 전파하지 않기 위해 메서드에서 예외를 '던지는'방법으로 예외 처리를 진행할 수 있습니다.
이번 포스팅에서는 우리 서비스에서 WebSocket과 Stomp를 적용시키며 예외처리를 진행한 방법에 대해 소개드리고자 합니다.
첫 번째 문제 상황
우리의 서비스는 기본적으로 RuntimeException을 상속받은 구현체인 BaseException을 적용하여 예외처리를 합니다. 이를 통해 우리는 기본적으로 errorCode(특정 오류 코드)와 message(구체적인 메시지), status(HTTP 상태 코드)를 포함하여 사용자 정의 예외를 생성합니다.
@Getter
public class BaseException extends RuntimeException {
private final String errorCode;
private String message;
private final HttpStatus status;
public BaseException(ErrorCode code) {
this.errorCode = code.getErrorCode();
this.message = code.getMessage();
this.status = code.getStatus();
}
public BaseException(ErrorCode code, String message) {
this.errorCode = code.getErrorCode();
this.message = message;
this.status = code.getStatus();
}
}
이를 활용해 프론트 측에 전달하여 code와 상태 등으로 에러핸들링을 진행합니다.
❗️하지만 STOMP 프로토콜 위에서 동작하는 채팅 서비스는 STOMP 프로토콜에 정의된 기본 에러 처리 방식에 따라 기본 핸들러가 작동하여 우리가 원하는 에러 메시지를 보낼 수 없습니다.
구체적인 예시 두 가지를 들어보겠습니다.
1. 토큰을 비워두고 전송
▶ Unknown error라는 메세지를 반환하며 STOMP ERROR를 전송했다고 로깅됩니다.
2. 부적절한 토큰을 적용하여 전송
▶ 이번에도 마찬가지로 Unknown error라는 메세지를 반환합니다.
우리가 두 상황에서 우리는 사실 이런 객체를 전송하고 싶습니다.
EMPTY_JWT("AUTH_001", "JWT가 없습니다.", HttpStatus.UNAUTHORIZED),
INVALID_JWT("AUTH_002", "유효하지 않은 JWT입니다.", HttpStatus.UNAUTHORIZED),
하지만 Stomp 프로토콜은 예외를 catch했을 때 기본적으로 정의되어있는 메시지를 반환하여 구체적인 요인을 알 수 없고, 그저 상황에 맞춘 개발을 하게 됩니다.
보다 구체적으로는 프론트에서는 해당 요청의 실패가 Jwt 관련 Exception인 지 Room 관련 Exception인 지 혹은 그 외의 이유인 지 알 방법이 없으니 적절한 에러처리가 불가능하고, 이는 시스템의 치명적인 결함이 됩니다.
해결방법
이를 해결하기 위해서 우리는 Stomp에서 에러핸들링을 할 수 있는 StompSubProtocolErrorHandler를 상속받고 해당 클래스의 함수를 Override하여 payload를 우리가 원하는 형태로 반환할 수 있도록 코드를 작성하였습니다.
package com.modutaxi.api.common.config.websocket;
import lombok.extern.slf4j.Slf4j;
import org.springframework.lang.Nullable;
import org.springframework.messaging.Message;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.messaging.support.MessageHeaderAccessor;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.messaging.StompSubProtocolErrorHandler;
import java.nio.charset.StandardCharsets;
@Component
@Slf4j
public class StompExceptionHandler extends StompSubProtocolErrorHandler {
private static final byte[] EMPTY_PAYLOAD = new byte[0];
public StompExceptionHandler() {
super();
}
@Override
public Message<byte[]> handleClientMessageProcessingError(@Nullable Message<byte[]> clientMessage, Throwable ex) {
StompHeaderAccessor accessor = StompHeaderAccessor.create(StompCommand.ERROR);
//메세지 생성
accessor.setMessage(ex.getMessage());
accessor.setLeaveMutable(true);
StompHeaderAccessor clientHeaderAccessor = null;
if (clientMessage != null) {
clientHeaderAccessor = MessageHeaderAccessor.getAccessor(clientMessage, StompHeaderAccessor.class);
if (clientHeaderAccessor != null) {
String receiptId = clientHeaderAccessor.getReceipt();
if (receiptId != null) {
accessor.setReceiptId(receiptId);
}
}
}
return handleInternal(accessor, EMPTY_PAYLOAD, ex, clientHeaderAccessor);
}
@Override
protected Message<byte[]> handleInternal(StompHeaderAccessor errorHeaderAccessor, byte[] errorPayload,
@Nullable Throwable cause, @Nullable StompHeaderAccessor clientHeaderAccessor) {
String errorCause = "";
if(cause != null) errorCause = cause.getCause().toString();
log.error("before setting message: {}",errorCause);
String fullErrorMessage = extractErrorCode(errorCause);
log.error("after setting message: {}",fullErrorMessage);
byte[] newPayload = fullErrorMessage.getBytes(StandardCharsets.UTF_8);
return MessageBuilder.createMessage(newPayload, errorHeaderAccessor.getMessageHeaders());
}
private String extractErrorCode(String input) {
String[] parts = input.split(":");
// 콜론 다음 부분의 앞뒤 공백을 제거하여 반환
if (parts.length > 1) {
return parts[1].trim();
} else {
// ':' 문자가 없는 경우 정의된 에러 메세지 반환
return "Undefined exception";
}
}
}
결과
이처럼 우리는 상황에 맞게 핸들링되어진 메시지를 전송할 수 있습니다.
이로써 해결되는 듯 했지만 사실 숨겨진 문제가 몇 가지 남아있습니다.
두 번째 문제 상황
우리는 Socket에서 효율적인 예외처리를 하기 위해 우리는 ErrorCode만 전송해달라는 요청을 받았습니다. 하지만 막상 나온 결과는 위 스크린 샷처럼 error message였습니다. 즉, 아래와 같이 정의된 ENUM 객체가 있었을 때 AUTH_001을 원하는 상황에서 "JWT가 없습니다." 라는 문구가 반환된다는 점이었습니다.
EMPTY_JWT("AUTH_001", "JWT가 없습니다.", HttpStatus.UNAUTHORIZED),
간과했던 점은 message를 생성하는 과정에서 payload를 적절하게 설정하지 않았다는 점입니다.
(참고: 만약 payload를 정의하지 않으면 cause는 detailMessage를 반환합니다.)
아래는 조금 더 구체적으로 문제 상황을 인식하기 위해 디버깅을 진행한 결과입니다.
디버깅을 통해 Cause 객체가 생성되었을 때 예외 자체인 ex의 detailMessage와 cause의 detail message가 다른 것을 볼 수 있습니다. 우리는 저기서 detailMessage가 아닌 "FAULT_JWT"를 반환하고 싶습니다. 하지만 문제는 detailMessage가 반환된다는 점입니다. 이를 해결하기 위해 cause에서 곧바로 errorCode를 얻어오고 싶었지만 Throwable객체는 정의된 스펙이 있었기에 이는 불가능했습니다.
이에 따라 원하는 메시지를 던질 수 있도록 Cause를 설정해주어야 했습니다.
Throwble 객체의 Cause란?
cause는 실제 어떤 메세지가 원인인지 보다 구체화되어 표현된 방식입니다.
해결방법
첫 번째 방법 - RuntimeException을 상속받아 ErrorCode만 반환하는 클래스 생성
cause의 detailMessage가 정해지는 과정은 에러를 던졌을 때 getMessage를 통해서 얻어집니다. 하지만 기존 우리가 사용했던 ErrorCode로는 적절한 핸들링이 불가능했습니다. 이에 따라 errorCode만 명시해놓은 클래스를 추가로 구현하자고 결정내렸습니다.
(현재는 두번째 해결방법을 채택한 상태입니다.)
StompException
@Getter
public class StompException extends RuntimeException {
private final StompErrorCode errorCode;
public StompException(StompErrorCode errorCode) {
super(errorCode.getErrorCode());
this.errorCode = errorCode;
}
public StompErrorCode getErrorCode() {
return errorCode;
}
}
SocketErrorCode
public interface SocketErrorCode {
String getErrorCode();
}
StompErrorCode
@Getter
public enum StompErrorCode implements SocketErrorCode {
FULL_CHAT_ROOM("SOCKET_001"),
ROOM_ID_IS_NULL("SOCKET_002"),
FAULT_ROOM_ID("SOCKET_003"),
ALREADY_ROOM_IN("SOCKET_004"),
FAULT_JWT("SOCKET_005"),
;
private final String ErrorCode;
StompErrorCode(String ErrorCode) {
this.ErrorCode = ErrorCode;
}
}
SocketErrorCode
위와 같은 방법으로 다음과 같은 형식으로 로직에서 호출되었습니다.
StompHandler의 일부분
//roomId가 안들어왔으면 에러
if (roomId == null || roomId == "") {
log.error("구독요청 \"sub/chat/{roomId}\" 에서 roomId가 들어오지 않았습니다.");
throw new StompException(StompErrorCode.ROOM_ID_IS_NULL);
}
//없는 방 연결하려 할 때 에러
Room room = roomRepository.findById(Long.valueOf(roomId)).orElseThrow(
() -> new StompException(StompErrorCode.FAULT_ROOM_ID));
//이미 연결된 방이 있는데 애꿎은 방을 들어가려고 하면 에러
//연결되어 있는 방이 존재하면서 && 요청으로 들어온 roomId가 연결되어 있는 방과 다를 때
if (chatRoomMappingInfo != null && !roomId.equals(chatRoomMappingInfo.getRoomId())) {
log.error("사용자 ID: {}님은 현재 {}번 방에 참여해 있지만, 참여요청이 들어온 방은 {}번방 입니다. ",
memberId, chatRoomMappingInfo.getRoomId(), roomId);
throw new StompException(StompErrorCode.ALREADY_ROOM_IN);
}
결과값
errorCause = com.modutaxi.api.common.exception.StompException: SOCKET_003
이처럼 우리가 원했던 에러메세지가 반환할 수 있게 되었습니다. 하지만 이는 효율적인 해결방법이라고 할 수는 없습니다.
1. 코드의 중복
가장 큰 이유는 코드의 중복이 생긴다는 점입니다. 형태가 유사한 클래스와 메서드들이 중복해서 생기며 위에 구현된 세 개의 클래스도 그 예시라고 볼 수 있습니다. 한 가지 추가적인 예시로 jwtToken을 검증할 때도 BaseException을 반환하기 때문에 SocketException을 반환하는 로직을 추가해주어야 합니다. 이런 부분들은 전역적으로 사용할 수 있는 모든 부분에 적용이 됩니다.
public void validateToken(String key, String token) {
try {
Jwts.parser().setSigningKey(key).parseClaimsJws(token);
if (validateBlacklist(token)) {
throw new BaseException(AuthErrorCode.LOGOUT_JWT);
}
} catch (SecurityException | MalformedJwtException e) {
throw new BaseException(AuthErrorCode.INVALID_JWT);
} catch (ExpiredJwtException e) {
throw new BaseException(AuthErrorCode.EXPIRED_MEMBER_JWT);
} catch (UnsupportedJwtException | SignatureException e) {
throw new BaseException(AuthErrorCode.UNSUPPORTED_JWT);
} catch (IllegalArgumentException e) {
throw new BaseException(AuthErrorCode.EMPTY_JWT);
}
}
//소켓에서 소켓 예외를 처리하기 위한 중복 코드 발생
public void socketValidateToken(String key, String token) {
try {
Jwts.parser().setSigningKey(key).parseClaimsJws(token);
if (validateBlacklist(token)) {
throw new StompException(StompErrorCode.LOGOUT_JWT);
}
} catch (SecurityException | MalformedJwtException e) {
throw new StompException(StompErrorCode.INVALID_JWT);
} catch (ExpiredJwtException e) {
throw new StompException(StompErrorCode.EXPIRED_MEMBER_JWT);
} catch (UnsupportedJwtException | SignatureException e) {
throw new StompException(StompErrorCode.UNSUPPORTED_JWT);
} catch (IllegalArgumentException e) {
throw new StompException(StompErrorCode.EMPTY_JWT);
}
}
2. 낮은 명확성
우리는 Enum의 속성이 구체화되지 않음으로써 무슨 코드인 지 알기 어렵습니다. 그저 이름으로만 추측해야합니다. 직접 프론트에 전달할 부분은 ErrorCode String뿐이라고 하더라도 이는 공동작업자들이 있고 소통해야 할 때 문제가 발생할 것으로 생각되어집니다.
@Getter
public enum StompErrorCode implements SocketErrorCode {
FULL_CHAT_ROOM("SOCKET_001"),
ROOM_ID_IS_NULL("SOCKET_002"),
FAULT_ROOM_ID("SOCKET_003"),
ALREADY_ROOM_IN("SOCKET_004"),
FAULT_JWT("SOCKET_005"),
FAIL_SEND_MESSAGE("SOCKET_006"),
EMPTY_MEMBER("SOCKET_007"),
;
private final String ErrorCode;
StompErrorCode(String ErrorCode) {
this.ErrorCode = ErrorCode;
}
}
이에 따라 아래 명시된 두 번째 방법으로 코드를 수정하였습니다.
두 번째 방법 - 기존 BaseException에서 cause 설정
Throwble의 명세를 보다 보니 다음과 같이 Cause의 메시지를 설정할 수 있었습니다. 또한 정의된 toString을 호출할 때 해당 클래스의 이름과 LocalizedMessage로 정의된다는 것을 깨달았습니다.
LocalizedMessage는 역시 getMessage를 호출하여 우리의 메시지가 형성이 되었던 것입니다.
이에 따라 LocalizedMessage를 오버라이드 하여 errorCode를 반환하는 로직을 작성하였습니다.
@Getter
public class BaseException extends RuntimeException {
private final String errorCode;
private String message;
private final HttpStatus status;
public BaseException(ErrorCode code) {
this.errorCode = code.getErrorCode();
this.message = code.getMessage();
this.status = code.getStatus();
}
public BaseException(ErrorCode code, String message) {
this.errorCode = code.getErrorCode();
this.message = message;
this.status = code.getStatus();
}
//추가된 부분
@Override
public String getLocalizedMessage() {
return this.errorCode;
}
}
결과적으로 아래와 같이 로그가 찍히는 것을 확인할 수 있고
프론트 측에서도 정상적으로 errorCode를 받을 수 있도록 해결되었습니다.
'server' 카테고리의 다른 글
[Spring Boot] 예약 알림을 위한 동적 스케줄링(with. TaskScheduler) (36) | 2024.06.11 |
---|---|
[Spring Boot] WebSocket & Stomp & Redis pub/sub & FCM으로 개발하는 채팅기능 (34) | 2024.06.03 |