이 글의 예외처리 방법을 알고난 뒤 예외처리 걱정은 없을 줄 알았는데, HTTP 통신의 예외처리와 STOMP 통신의 예외처리는 또 달랐습니다. 제가 담배200의 실시간 동시편집을 STOMP로 구현하면서 마주한 예외처리 문제들과 제가 사용했던 해결법들을 공유해보고자 합니다. 실무에선 어떻게 하는지 모르겠지만, 이 내용이 여러분들의 프로젝트를 진행할 때 어떤 힌트가 될 수 있다면 좋겠습니다.
제가 마주한 문제는 아래와 같습니다. 지금부터 하나씩 말씀드리겠습니다.
HTTP 요청 메시지를 처리하는 과정에서 발생하는 예외는 @ControllerAdvice의 @MessageExceptionHandler(🔗 코드, 🔗 설명 게시글) 가 글로벌하게 처리하게 했습니다.
HTTP 요청의 예외는 @ExceptionHandler가 붙은 메서드가 해결해주지만, STOMP 메시지 처리중 발생한 예외는 @MessageExceptionHandler가 붙은 메서드가 처리해줄 수 있습니다. 즉, @ControllerAdvice가 붙은 클래스에 @MessageExceptionHandler를 붙인 메서드들을 정의하면 컨트롤러 내에서 발생한 예외들을 처리할 수 있게 됩니다.
이 때, HTTP용 @ExceptionHandler의 내용들을 STOMP용 @MessageExceptionHandler에 넣어줄 수도 있지만, 일단 코드 중복이 발생하는 것이고, 만약 HTTP용 @ExceptionHandler가 추가되거나 코드가 변경되면, 소켓용 @ExceptionHandler에도 코드 수정이 필요해지는 문제가 있었습니다.
그래서, 소켓용 @ControllerAdvice 내에 private inner class를 만들어, 기존 예외 처리 메서드를 Wrapping하는 메서드들을 정의하고, 소켓용 @ControllerAdvice의 메서드들은 그 메서드들을 사용함으로써, 기존 HTTP용 @ControllerAdvice에서 예외를 처리하던 방식 을 참조하면서 소켓통신에 맞게 처리해주었습니다. 이 때 소켓용 @ControllerAdvice의 inner class(HTTP @ControllerAdvice의 메서드를 Wrapping하는)와 HTTP용 @ControllerAdvice는 같은 인터페이스 를 구현함으로써, 두 클래스가 처리하는 예외 종류에 차이가 없게 유도했습니다.
HTTP 요청으로 발생한 예외는 400대나 500대의 상태코드로 HTTP 응답으로 내려주지만, 소켓 통신에서 예외가 발생하면 STOMP(소켓 통신 라이브러리)의 기본 에러 핸들러가 ERROR 프레임을 보내며 연결을 끊어버립니다. ‘중복된 회원 아이디’와 같은 비지니스 예외가 발생할 때마다 연결을 끊어버리면 브라우저에서 재연결을 시도해야하는 불편함과 불필요한 네트워크 비용이 발생하는 문제가 있었습니다.
그래서 비지니스 예외를 응답할 땐 기존과는 다른 예외처리 방식을 택했습니다. 예외 내용을 일반 MESSAGE 프레임에 담아서, 브로드캐스팅이 아닌 요청 보낸 사용자에게만 보내는 방식입니다.
아래에 작성된 코드는 @ControllerAdvice에 작성된 @MessageExceptionHandler인데, STOMP 메시지의 처리 과정 중 컨트롤러 레이어 내부에서 발생하는 BusinessException 타입의 비지니스예외를 처리하게 됩니다.
@MessageExceptionHandler(BusinessException.class)
public void handleBusinessException(Principal principal, @Payload SocketRequest request, BusinessException e) {
template.convertAndSendToUser(principal.getName(), request.getResponseChannel(), methodProvider.handleBusinessException(e).getBody());
}
주의해야할 점은 @ControllerAdvice에 작성된 예외는 컨트롤러 내부의 예외들만 처리하기 때문에 소켓용 인터셉터인 ChannelInterceptor에서 발생한 예외는 잡을 수 없다는 것입니다. 아래에서 해당 내용을 다뤄보겠습니다.
@ControllerAdvice나 @ExceptionHandler에 대해 잘 모르시겠다면 이 글을 참조해주세요.
다시 위의 예외처리 코드를 들여다 보자면, SimpMessagingTemplate의 convertAndSendToUser를 사용해 특정 유저에게만 메시지를 전달하고 있습니다. 이걸 통해, 메시지를 보낸 유저에게만 예외내용을 담은 메시지를 보내게되고, ERROR FRAME이 아닌 MESSAGE FRAME으로 보내기 때문에 연결이 해제되지 않습니다.
simpMessagingTemplate.convertAndSendToUser(String user, String destination, Object payload)
이 때 String user
필드는 그냥 user이름을 넣어주는 것이 아닌 STOMP에서 해당 세션에 지정한 이름을 넣어줘야하는데, 이 이름을 지어주고 가져오는 내용은 convertAndSendToUser에 대해 구글링하면 쉽게 찾아볼 수 있을 거에요.
destination에는 해당 사용자가 메시지를 받을 구독 채널을 적어주면 됩니다. 저 같은 경우, 이 예외응답을 받을 채널은 메시지를 보낸 채널과 동일하게 하는 게 클라이언트측에서 예외처리하는 게 쉽다고 생각되어서, STOMP 요청을 보낼 때는 아예 응답받을 채널을 명시하도록 강제했습니다.
아래가 STOMP 요청용 body 포맷입니다.
public class SocketRequest<T> {
@NonNull
Long requestUserId;
@NonNull
String responseChannel;
T content;
}
마지막으로 payload 필드는 예외에 대한 내용을 보내면 되겠습니다. 제 코드에는 methodProvider.handleBusinessException(e).getBody()
라고 적혀 있는데, 이 내용은 이 글의 첫번째 주제에 자세히 나와있으니 참고바랍니다.
담배 목록(매장)은 다른 사람들과 공유될 수 있으며, 목록(매장)에 대한 접근 권한을 담은 Access entity를 통해 관리됩니다. 목록 관리자 권한, 접근 가능, 신청 대기중, 접근 불가의 4가지 상태가 존재합니다. 권한이 필요한 요청을 할 땐, 먼저 해당 user와 store에 해당하는 Access entity를 보고, 관리자권한이거나 접근 가능권한일 때 요청을 처리하고, 아닐 땐 예외를 발생시킵니다.
아래는 권한 검사 서비스 코드입니다.
@Transactional(readOnly = true)
public void checkAccess(final Long userId, final Long storeId){
if(userId == null || storeId == null)
throw new AccessNotAllowedException(userId, storeId, AccessType.ACCESSIBLE);
// 해당 entity 찾기. 없으면 권한이 없으므로 접근 불가
final Access access = accessRepository.findByUserIdAndStoreId(userId, storeId)
.orElseThrow(() -> new AccessNotAllowedException(userId, storeId, AccessType.ACCESSIBLE));
// 접근 불가 상태거나 신청이 승인 대기중이라면 접근 불가
switch (access.getAccessType()){
case INACCESSIBLE:
case WAITING:
throw new AccessNotAllowedException(userId, storeId, AccessType.ACCESSIBLE);
}
}
담배 목록과 관련된 서비스 코드마다 권한 로직을 넣는 코드 중복이 비효율적이라고 생각되어, 관련된 기능을 탐색하다가 인터셉터를 적용하면 좋겠다 생각했습니다. 인터셉터를 통해 담배 목록과 관련된 API 호출 시 마다 인터셉터가 해당 권한을 체크합니다.
담배 목록과 관련한 많은 API가 대부분 소켓 통신으로 이뤄집니다. 그런데 일반 인터셉터는 Http통신에만 관여하기 때문에, Stomp를 통해 처음 소켓 연결을 형성할 때만 권한을 검사하고, 실제 소켓 통신으로 메시지를 전송 받을 때에는 권한 검사를 하지 않는 문제가 있었습니다.
STOMP의 인터셉터는 ChannelInterceptor라는 인터페이스를 구현해서 만들 수가 있습니다. 그렇게 STOMP용 인터셉터를 만들어 매 소켓 메시지마다 권한을 검사하도록 했습니다.
문제는, 인터셉터에 의한 예외 발생은 ControllerAdvice가 잡아내지 않는 문제가 있었습니다. 이유는 ControllerAdvice는 Controller 내부에서 일어난 예외만 관여하고, 그 바깥인 Interceptor에서 발생한 예외는 처리하지 않기 때문이었습니다. 예외가 발생하면 자동적으로 ERROR 프레임에 메시지가 담겨 응답이 가며 소켓 연결이 끊어지긴 하지만, 응답 메시지를 커스텀하게 관리하고 싶어서 StompSubProtocolErrorHandler를 구현한 STOMP 예외 핸들러를 소켓 설정 클래스에 설정해놓음으로써, 예외 메시지를 커스텀하게 생성해 ERROR 프레임에 담아 보내고, 동시에 소켓 연결을 강제적으로 해제하도록 했습니다.
지금까지 STOMP와 관련된 예외 처리 방법들을 알아보았는데요, 여러분들이 STOMP를 이용한 프로젝트를 하실 때 도움이 되었으면 좋겠습니다.
감사합니다! 🙇♂️