@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;
}
}
해당 포스팅은 프로젝트 팀블로그에 제가 작성한 글을 옮긴 기록입니다. 언제 팀블로그가 사라질 지 모르기에..
Tistory
좀 아는 블로거들의 유용한 이야기
www.tistory.com
안녕하세요, [모두의택시] 팀 서버 개발자 박성훈입니다.
자바에서는 오류 코드를 전파하지 않기 위해 메서드에서 예외를 '던지는'방법으로 예외 처리를 진행할 수 있습니다.
이번 포스팅에서는 우리 서비스에서 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;
}
}
SocketErrorC
위와 같은 방법으로 다음과 같은 형식으로 로직에서 호출되었습니다.
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를 반환하는 로직을 작성하였습니다.
결과적으로 아래와 같이 로그가 찍히는 것을 확인할 수 있고
프론트 측에서도 정상적으로 errorCode를 받을 수 있도록 해결되었습니다.
'개발' 카테고리의 다른 글
[AWS] 실시간의 실시간 - CloudFront, Amazon Kinesis Data Streams (2) | 2024.10.04 |
---|---|
[Spring Boot] 예약 알림을 위한 동적 스케줄링(with. TaskScheduler) (0) | 2024.06.20 |
[Spring Boot] WebSocket & Stomp & Redis pub/sub & FCM으로 개발하는 채팅기능 (0) | 2024.06.20 |