Backend/Spring Boot & JPA

[Spring Boot] WebSocket(+STOMP) 서버 전역 예외 처리

나죽못고나강뿐 2024. 9. 26. 17:53
📕 목차

1. Introduction
2. Error Status Spec
3. Interceptor Exception Handler
4. Business Exception Handler (Controller)

1. Introduction

 

📌 작성 계기
 

[Spring Boot] WebSocket + RabbitMQ를 활용하여 채팅 시스템 구축하기 (with. STOMP)

🫠 포스팅 길이가 길어지면 임시 저장 데이터가 자꾸 날아가버려서, 점진적으로 내용 추가 중입니다.수정 일자내용`24.09.15• System Design• Message pub/sub• Proxy Server Routing`24.09.19• Authenticate (작성

jaeseo0519.tistory.com

아직도 열심히 보완하면서 작성 중인 위 포스팅.

하다보니, HTTP 요청 전역 예외를 처리할 때처럼 GlobalExceptionHandler의 필요성이 발생했다.

 

예상했던 부분은 HTTP 예외 처리를 위한 코드로는 WebSocket 예외를 감지하지 못 할 거라는 것. (애초에 WAS Server와 Chat Server를 물리적으로 분리해놔서 상관없었다.)

그러나 더 큰 문제는 HTTP Protocol과 WebSocket Protocol과의 차이를 해결해야 한다는 부가적인 문제였다.

 

WAS에선 JWT 인증에 실패했을 때, status 401을 반환하면 됐었다.

WebSocket은..? 똑같이 401을 반환할 것인가?

 

물론 그렇게 처리해도 동작에 문제는 없지만, 내가 그런 거에 만족할 인간이 아니라는 게 문제다.

가자, 머리 깨지러.

 

📌 요구 사항

1️⃣ 모든 예외는 전역적으로 처리되어야 한다. 단, 비지니스 로직 수행 중 발생한 에러는 Web Socket 연결이 끊어지지 않아야 한다.

  • WebSocket Connect 시점에 인증에 실패하면, 적절한 에러 응답을 반환하고 WebSocket 연결을 거부해야 한다.
  • 이미 WebSocket이 연결된 이후, AT의 만료, 인가 실패, 비지니스 로직 실패 등은 예외 응답을 반환하되, WebSocket 연결을 유지해야 한다.

 

2️⃣ 기존 HTTP 에러 응답을 위해 표준화한 코드를 기반으로, WebSocket 에러 코드를 표준화해야 한다.

  • 현재 프로젝트는 single repo, multi module 구조로 구성되어 있고, 이미 HTTP 응답을 위한 모든 예외 체계가 갖추어져 있다.
  • 해당 클래스는 완전히 HTTP protocol 표준에 맞춰져 있기 때문에, WebSocket 예외 응답 정책과 호환 가능한 체계를 구축해야 한다.

 


2. Error Status Spec

 

📌 WebSocket Error Spec

우선 WebSocket에서 Connect 요청을 refuse할 때, 지켜야 할 원칙이 있을까 싶어서 스펙을 찾아봤다.

글씨 빨갛게 칠해놔서 보이지도 않음.

https://websockets.spec.whatwg.org//#feedback-from-the-protocol

우선, User Agents는 다음 상황을 구별할 수 있도록 허용하는 스크립트를 담은, 그 어떠한 실패 정보를 전달해서는 안 된다.

  • 호스트 이름을 확인할 수 없는 서버
  • 패킷을 성공적으로 라우팅할 수 없는 서버
  • 지정된 포트에서 연결을 거부한 서버 (ex. Server 인증서를 확인할 수 없음)
  • TLS handshake를 올바르게 수행하지 못 한 서버 (ex. WebSocket Server가 아닌 경우)
  • 올바른 Opening Handshake를 전송했지만, Client가 연결을 끊게 하는 옵션을 지정한 WebSocket Sever(ex. Server가 Client가 제공하지 않은 Sub Protocol을 지정한 경우)
  • Opening Handshake를 완료한 후 갑자기 연결을 종료한 WebSocket Server

 

처음에 이걸 보고, 그럼 WebSocket 에러 발생했을 때 이유를 Client 측에 전달하지 말라는 건가 싶어서 아찔했으나 잘 읽어보면, Client Agent가 지켜야 할 사항이다.

그니까 Server한테 저런 에러 응답 받았다고 정직하게 User한테 보고하지 말라는 의미였음..console 찍을 때 주의하시구요.

 

https://datatracker.ietf.org/doc/html/rfc6455#section-5.5.1

IETF 공식 문서를 확인해보면, Close frame은 "아마도" closing 이유를 담은 body를 포함하고 있을 수도 있다고 한다.

즉, 개발자가 포함하면 하는 거고, 따로 설정하지 않으면 없다는 의미이므로, 개발자 선택에 맡기겠다는 의미.

 

body는 사람이 읽기 쉬울 필요는 없지만, 디버깅이나 패싱 정보로 유용하게 쓰려면 잘 생각해보라고 넌지시 알려주고 있다.

 

그럼 WebSocket 예외에 대한 정보를 Server to Client로 보내는 건, protocol 상으로도 허용하고 있음을 알 수 있다.

혹시 status code도 약속된 게 있을까요? 😋

 

https://datatracker.ietf.org/doc/html/rfc6455#section-7.4

우선 WebSocket에서 status code range는 다음과 같이 구분한다.

말이 status code지, 전부 연결을 close할 때 사용하는 코드들이라 close code라고 부른다.

(오역이 있을 수 있으니, 원문을 읽어보길 바랍니다.)

  • 0~999: 사용하지 않는다. (아마 HTTP 프로토콜이 이미 사용 중인 영역이라, 혼란을 방지하기 위함이라 생각한다.)
  • 1000~2999: WebSocket protocol을 위한 정의를 위해 예약된 범위. 추후 status code가 새롭게 지정될 수 있기에, 함부로 커스텀해서 사용해선 안 된다.
    • 1000: 연결이 설정된 목적이 충족되었기 때문에, 정상적인 종료를 위해 사용.
    • 1001: "end-point가 사라지고 있다"를 나타낸다고 한다. (ex. 서버가 다운되고 있거나, 브라우저가 페이지를 벗어난 경우)
    • 1002: 프로토콜 오류로 인해 연결을 종료하는 경우
    • 1003: end-point가 허용할 수 없는 유형의 데이터를 수신했기 때문에 연결을 종료하는 경우
    • 1004: 예약된 값. 의미는 나중에 정의될 수 있음.
    • 1005: 상태 코드가 없음을 나타내는 상태 코드를 기대하는 애플리케이션을 위함. end-point에서 close controll frame에 status code를 설정해서는 안 됨.
    • 1006: 연결이 비정상적으로 닫힌 경우. end-point에서 close control frame에 status code를 설정해선 안 되며, 상태 코드를 기대하는 애플리케이션은 close 제어 프레임을 보내거나 받지 않음.
    • 1007: 유효하지 않은 타입의 message를 수신해서 연결이 종료되는 경우. (ex. non-UTF-8이 포함된 message 수신)
    • 1008: 정책을 위반하는 메시지를 수신한 경우. 다른 더 적합한 상태 코드가 없거나, 정책에 대한 특정 세부 정보를 숨겨야 할 필요성이 있는 경우 반환될 수 있는 상태 코드
    • 1009: end-point가 처리할 수 없는 너무 큰 메시지를 수신한 경우
    • 1010: end-point(클라이언트)가 서버가 하나 이상의 확장을 협상할 것으로 예상했지만, 서버가 WebSocket handshake 응답 메시지에서 이를 반환하지 않았기 때문에 종료함을 알림. Close frame의 "reason" 부분에 확장 목록을 나타내야 한다. 서버에선 사용하지 않음.
    • 1011: 요청을 충족하지 못하게 하는 조건이 발생하여 연결 종료하는 경우
    • 1015: TLS handshake를 수행하지 못해 연결이 닫힌 경우. (ex. 서버 인증서 확인 불가)
  • 3000~3999: 라이브러리, 프레임워크 및 애플리케이션에서 사용하도록 예약되어 있음. IANA(Internet Assigned Numbers Authority)에 직접 등록되어 있으며, 코드 해석은 WebSocket Protocol이 정하지 않음.
  • 4000~4999: 개인용으로 사용하면 되며, WebSocket 애플리케이션 간의 사전 계약에 의해 사용 가능함. 마찬가지로 코드 해석은 WebSocket Protocol에서 정의하지 않음.

 

✒️ 3000번 대와 4000번 대 범위의 차이

두 range 모두 WebSocket에서 명시한 Protocol이 아닌, 개발자가 직접 정의할 수 있지만 3000번대의 status 코드는 약간 다르다.

4000번대 코드는 말 그대로 무법지대같은 느낌이라, 누구든지 본인들의 애플리케이션에서 자유롭게 사용할 수 있으며, 공개적으로 문서화하거나 공유될 의무를 갖지 않는다.

반면, 3000번대의 코드들은 IANA에 등록하여 어느 정도 표준화가 되어 있으며, 널리 사용되는 라이브러리나 프레임워크에서 공통적으로 인식할 수 있다.

 

어라, 그럼 Spring도 널리 쓰이는 Framework니까 가지고 있지 않을까?

 

 

CloseStatus (Spring Framework 6.1.13 API)

"1007 indicates that an endpoint is terminating the connection because it has received data within a message that was not consistent with the type of the message (e.g., non-UTF-8 [RFC3629] data within a text message)." "1003 indicates that an endpoint is t

docs.spring.io

라고 생각했는데 없네. ㅎ

 

STOMP 서브 프로토콜을 사용하고 있기 때문에 Web Socket 레벨의 status code가 필요할지 아직 의문이 들지만, 궁금해서 찾아봤다.

 

📌 STOMP Error Spec
💡 처음에 RECEIPT를 응답 프레임으로 선택하려다 실패한 이유를 가장 마지막에 적어놨습니다.
 

https://stomp.github.io/stomp-specification-1.2.html#ERROR

STOMP Protocol Specification, Version 1.2 Abstract STOMP is a simple interoperable protocol designed for asynchronous message passing between clients via mediating servers. It defines a text based wire-format for messages passed between these clients and s

stomp.github.io

STOMP에선 server to client를 위한 4가지 Frame(CONNECTED, MESSAGE, RECEIPT, ERROR)을 제공한다.

이 중에서 응답을 위한 Command로 무엇을 사용할 지 결정해야 하는데, 설명할 필요도 없이 CONNECTED는 기각.

 

MESSAGE는 일반적으로 subscribe을 하고 있는 client에게 데이터를 전달하기 위한 command.

목적이 너무 명확한 command라, 요청 실패에 대한 응답을 보내기엔 다소 부적절하다고 생각한다.

 

그렇다면 ERROR를 사용하면 되지 않겠는가 싶겠지만, ERROR comand는 frame 전송 후 반드시 연결을 해제해야 한다.

요구 사항에서 Interceptor와 달리, Business 예외에 대해서는 연결이 해제되어선 안 된다고 했으므로 Interceptor 예외 핸들러에서만 사용할 수 있다.

(심지어 Interceptor에서 실패했더라도 연결을 유지해야 하는 경우가 있다면, 이걸 또 구분해주어야 한다.)

(물론 비지니스 로직 실패 시에도, 경우에 따라선 연결을 끊어야 할 수 있다.)

 

하나 남은 RECEIPT를 살펴보면 다음과 같이 정의되어 있다.

  • server가 수신하는 요청을 성공적으로 처리하면, RECEIPT 프레임이 client로 전달된다.
  • 헤더에 반드시 receipt-id(수신자)를 포함하라.
  • client의 frame이 server에서 처리되었다는 확인 frame이지만, STOMP는 stream 기반이라 이전 frame이 모두 서버에서 수신되었다는 누적 확인이기도 하다. 아직 모두 처리되지 않은 이전 frame이 있다면, client가 연결을 끊어도 서버에서 모두 처리해야 한다.

놀랍게도 이거 말곤 딱히 정해진 게 없다.

 

그렇다면, STOMP 프로토콜을 준수하면서 예외 응답을 보내려면 다음과 같이 응답을 구성해야 한다.

  • ERROR: client 요청이 실패했고, 복구가 불가능하거나 할 이유가 없는 경우. 예외를 반환하고 연결 해제
  • RECEIVE: (client의 요청이 성공한 경우도 있을 수 있지만) client의 요청은 실패했으나, 연결은 유지하고 싶은 경우 

 

🤔 그냥 Message 프레임 사용하면 안 되나?

물론, Message 프레임으로 예외를 보내도 상관은 없겠지만, protocol에 의하면 message 프레임은 "구독 중인 client에게 server가 데이터를 전달할 때 사용한다"는 점이다.
비지니스 예외를 생각해보자. 예외가 발생했다고 구독 중인 모든 client에게 예외 응답을 보내진 않는다.
이런 관점에서 보면, Message 프레임이 아니라, 요청에 대한 실패를 의미하는 Receipt 프레임을 사용하는 게 적절하다고 생각한다.
🫠 (최종 결론) Receipt 프레임을 사용할 수 없는 이유

(4) Business Exception Handler: AbstractSubscribableChannel에서 구현하다가 공식 문서에서 파악하지 못한 치명적인 문제가 있었다.
Receipt 프레임 헤더에는 receipt-id를 필수값으로 포함해야 하는데, 이 값이 session-id 비슷한 건줄 알았으나 client가 헤더에 포함하는 정보를 기반으로 식별하는 값이었다.

그 말은 즉, client가 모든 요청마다 receipt를 포함하지 않으면, 예외를 반환할 방법이 완전히 없어지게 된다.
그렇다고 모두 포함시켜버리면, 나중에 정말 receipt 하고 싶은 정보가 있어서 포함한 건지, 예외를 받기 위해 포함한 건지 알 수가 없어진다.

따라서, Receipt 프레임을 사용할 수 없으며, 선택지는 Message 프레임밖에 남지 않게 된다.

 

📌 HTTP 기반으로 정의한 기존 Error 처리 시스템 분석

이걸 하려면 일단 현재 문제를 인식해야만 한다.

코드는 깃헙에서 확인 가능.

프로젝트 깃헙은 아니지만, 통채로 옮겨온 거라 동일한 코드를 사용 중이다.

 

우리 서버는 HTTP 예외 응답 시에 "code" 정보를 반드시 포함하도록 약속했다.

이는 4자리 정수 값으로 이루어져 있는데, status 정보 3bit, reason 정보 1bit를 할당받는다.

마음같아선 bit 연산으로 code 만들고 싶었는데, 예외에 대한 핸들링이 어려워져서 포기했다.

 

여튼 이 당시만 해도, 진짜 체계적인 예외 처리 시스템을 구축했다고 생각했는데, 이제와서 다시 보니 총 2가지 실수를 범했다는 것을 깨달았다. (무려...2개나...😇)

 

라고 생각했는데, 막상 다시 설계하려고 보니 문제가 없었다. ㅋㅋㅋㅋㅋㅋㅋ

ErrorCode가 web 의존성을 가지고 있는 것도 아니고, 전역 예외 처리를 수행할 때도 socket을 유지할 지, 끊을 지 판단하는 게 워낙 명료하다보니, 굳이 현재 Exception 체계를 수정할 이유가 없다고 생각된다.

원래는 해당 챕터는 시스템에서 사용 중인 Error 체계를 수정하는 내용을 담으려고 했으나 필요가 없어져버렸다.

 

아래는 문제점들을 나름 열심히 분석했지만, 이제는 의미 없어진..그래도 지우기 아까워서 남겨뒀다.

더보기

1️⃣ BaseErrorCode 인터페이스의 구체 클래스(CausedBy) 의존성

신이 나에게 다시 기회를 준다면, 반드시 위와 같이 구성했을 것이다.

BaseErrorCode를 interface로 잘 만들어놓고는 정작 CausedBy와 Reason/StatusCode를 구현체로 만들어버린 실수를 범했다.

CausedBy는 status가 3자리 수인데, 생성할 때부터 엄격하게 상태를 검사하고 있어 Socket Error로 써먹을 방도가 없다.

그렇다고 Socket 기준으로 수정하려면, 기존의 Http 서비스 체계가 망가지는데 OCP 원칙을 지키지 않은 대가를 톡톡히 치르는 중이다.

 

위처럼 설계했다면, 바로 추상 팩토리 패턴 도입해서 뚝딱 해치웠을 텐데...라고 찡찡거리고 있지만, 사실 수정하는 게 그리 어렵진 않다.

현재 구현체로 정의된 거 이름만 수정하고, 중간에 인터페이스 끼워넣어서 처리하면 그만이기 때문.

약간의 실수가 있을 순 있어도, 조금 귀찮을 뿐이지 어려운 작업은 아니다.

 

진짜 문제는 그 다음이다.

 

2️⃣ Infrastructure 모듈의 HTTP 스펙 기반의 예외 반환

BaseErrorCode는 common 모듈에 위치하며, 모든 모듈이 의존하고 있다.

그 말은 즉, 기존에 external-api 모듈이 의존하고 있는 infra와 domain 모듈 또한 GlobalErrorException 기반의 예외를 반환할 수 있다는 뜻이다.

 

다행히도 domain 영역은 tx rollback 문제가 지속적으로 발생한 적이 있었기 때문에, GlobalErrorException를 예외로 던지는 경우가 없다.

설령 발생하더라도 IllegarArgumentException이나 IllegarStateException을 던진다.

 

눈 여겨 봐야 할 곳은 infra 영역인데, 여기선 tx rollback 문제가 발생할 일도 없었고, 이 또한 외부 actor와 상호작용하는 코드가 많기 때문에 web 의존성을 갖고 있다.

따라서 예외를 처리할 때도 GlobalErrorException을 던지고 있는데, 문제는 이걸 socket 모듈이 똑같이 의존하게 된다는 점이다.

 

그렇다면 infra 측에선 의존하는 쪽이 socket인지, external-api인지에 따라 선택적으로 HttpBaseErrorCode인지, SocketBaseErrorCode인지를 택해서 반환해야 한다.

당연히 말도 안 되는 노가다 작업을 수반하기도 하지만, 애초에 상위 모듈이 하위 모듈이 무엇이냐에 따라 분기 처리를 하는 것 자체가 우스운 일이다.

새로운 프로토콜을 사용하는 하위 모듈이 붙으면, 또 infra를 몽땅 수정할 생각이 아니라면?

 

따라서 (1)의 방식대로 설계를 수정하는 것은 아무래도 어려운 일이다.

그렇다면 지금 당장 떠오르는 방법은 Http 기반의 예외를 받은 socket 모듈 측에서, wrapper로 핸들링하는 방법 뿐이다.

 

🤔 합리적인가?

생각해보면 socket 모듈을 제외하면, 나머지는 모두 HTTP 기반으로 동작을 한다.
어찌보면 MSA로 설계했어야 할 socket을 억지로 끼워넣는 것처럼 보는 게 더 적절하다고 판단했다.
이러한 관점에서 보면 socket 모듈을 위해 common을 수정하여, 모든 모듈로 변경을 전파하는 것은 잘못된 일이다.
오히려 socket 모듈에서 독자적인 Exception 생태를 구성하고, Adapter 패턴으로 BaseErrorCode를 처리하는 것이 보다 적절하다고 생각한다.

 


3. Interceptor Exception Handler

 

📌 How to implement?

Interceptor에서 예외가 발생했을 때, 이를 처리해주기 위해 사용할 수 있는 도구가 무엇인지 알아봐야 할 차례다.

Spring Security할 때랑 비슷비슷 해보이니까 별로 어렵지 않게 찾을 수 있을 것 같다.

 

addEndPoint()에 연달아서 쓸 수 없을까 싶었는데, StompWebSocketEndpointRegistration을 내부적으로 체이닝되고 있어 그건 안 된다. (하기사 addEndPoin()에 연결해버리면, "/chat" end-point에만 예외 처리를 적용하겠다는 말이 되어버리려나)

 

여튼 StompSubProtocolErrorHandler라는 걸 필요로 한다니, 뭐하는 클래스인지 알아보자.

public interface SubProtocolErrorHandler<P> {

	/**
	 * Handle errors thrown while processing client messages providing an
	 * opportunity to prepare the error message or to prevent one from being sent.
	 * <p>Note that the STOMP protocol requires a server to close the connection
	 * after sending an ERROR frame. To prevent an ERROR frame from being sent,
	 * a handler could return {@code null} and send a notification message
	 * through the broker instead, e.g. via a user destination.
	 * @param clientMessage the client message related to the error, possibly
	 * {@code null} if error occurred while parsing a WebSocket message
	 * @param ex the cause for the error, never {@code null}
	 * @return the error message to send to the client, or {@code null} in which
	 * case no message will be sent.
	 */
	@Nullable
	Message<P> handleClientMessageProcessingError(@Nullable Message<P> clientMessage, Throwable ex);

	/**
	 * Handle errors sent from the server side to clients, e.g. errors from the
	 * {@link org.springframework.messaging.simp.stomp.StompBrokerRelayMessageHandler
	 * "broke relay"} because connectivity failed or the external broker sent an
	 * error message, etc.
	 * @param errorMessage the error message, never {@code null}
	 * @return the error message to send to the client, or {@code null} in which
	 * case no message will be sent.
	 */
	@Nullable
	Message<P> handleErrorMessageToClient(Message<P> errorMessage);

}
public class StompSubProtocolErrorHandler implements SubProtocolErrorHandler<byte[]> {

	private static final byte[] EMPTY_PAYLOAD = new byte[0];


	@Override
	@Nullable
	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
	@Nullable
	public Message<byte[]> handleErrorMessageToClient(Message<byte[]> errorMessage) {
		StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(errorMessage, StompHeaderAccessor.class);
		Assert.notNull(accessor, "No StompHeaderAccessor");
		if (!accessor.isMutable()) {
			accessor = StompHeaderAccessor.wrap(errorMessage);
		}
		return handleInternal(accessor, errorMessage.getPayload(), null, null);
	}

	protected Message<byte[]> handleInternal(StompHeaderAccessor errorHeaderAccessor, byte[] errorPayload,
			@Nullable Throwable cause, @Nullable StompHeaderAccessor clientHeaderAccessor) {

		return MessageBuilder.createMessage(errorPayload, errorHeaderAccessor.getMessageHeaders());
	}

}

코드가 단순하기 그지 없다.

유일하게 accessor.setLeaveMutuable(true)만 이해가 안 가서 주석을 보니, 원래 불변 헤더 반환되는데 가변으로 쓰고 싶으면 true 설정했다가, 나중에 complete 어쩌구 호출해서 다시 불변으로 막으라고 해놨다. 뭐 이런 좀스런 방법을 쓴다냐.

 

여튼 메서드가 두 개가 있는데, 주석을 참고하면 대충 이렇다.

  • handleClientMessageProcessingError(@Nullable Message<P> clientMessage, Throwable ex)
    • client message 처리하다가 에러난 경우에 이걸 사용하면 된다.
    • WebSocket 메시지 파싱하다가 에러가 난 경우처럼 clientMessage가 null인 경우도 있으니까 조심하렴
    • 만약, 에러 응답을 message로 전달할 게 아니라, push notification을 쓴다던가 다른 방식으로 처리할 거면 반환값은 null이어도 상관 없음.
    • 참고로 WebSocket Protocol에서 Error Frame 반환하면 무조건 socket 연결 끊어야 하니까, 그게 싫으면 message를 null로 반환할 것.
  • handleErrorMessageToClient(Message<P> errorMessage)
    • 외부 브로커 연결 실패같은 사유로, Client의 요청에 대한 실패가 아닌 서버 측의 오류를 client에게 전달할 때 사용.

 

그럼 우리는 StompSubProtocolErrorHandler의 handleClientMessageProcessingError만 오버라이딩해서 사용하면 되지 않을까.

 

다만, 한 가지 우려스러운 점이 있다면 registry에서 addErrorHandler()가 아니라, setErrorHandler()라는 이름이 붙은 것으로 보아 errorHandler는 하나만 등록될 수 있다는 점이었다.

ㄹㅇ임.

지금은 Connect 시에 인증 실패하는 경우만 고려할 거라지만, ping-pong 하다가 에러가 난다거나

아니면 나중에 새로운 에러가 나면 어쩔 건데...

그래서 StompSubProtocolErrorHandler를 상속한 중간 계층 클래스를 Interceptor로 등록하고, 하위에 추가적인 예외를 처리 가능하도록 만들 것이다.

YANGI 원칙을 어긴다고 볼 수도 있겠지만, 내가 뭐 회사 프로젝트 하는 것도 아니고 개인 공부하는 건데 이 정도는 괜찮지 않을까?

 

🥲 내가 바보였던 점. (`24.10.11 추가)

알고보니 interceptor 당 handler를 하나 설정해줄 수 있는 거였다.
이걸 실제 프로젝트 옮기는 작업을 하다가, interceptor를 등록하는 메서드는 복수형인 걸 보고 설마 했는데,
interceptor를 여러 개 만들고 등록하면 되는 거였음. 허허

 

📌 StompExceptionHandler

고뇌의 흔적들

StompSubProtocolErrorHandler를 구현하는데 여러모로 구상할 게 많다.

부모 클래스에선 handleClientMessageProcessingError()가 시작되자 마자, StompHeaderAccessor를 Error로 지정해버린다.

그러나 특정 예외는 Error로 반환하고 싶지 않다면? 이런 방식은 문제가 된다.

 

조금 더 고민해보자.

특수한 예외를 처리하려는 경우 외의 에러들을 핸들링해줄 전역 예외가 필요한 건 사실이다.

만약 이런 defaultExceptionHandler를 interceptors에 넣는 순간, 빈이 주입되는 순서를 명시적으로 선언하지 않는 이상 잘못될 확률이 높다.

 

이런 상황을 고려하면, StompExceptionInterceptor 인터페이스가 가져야 할 명세는 다음 기능을 포함해야 한다.

  • 핸들링할 예외 정보를 포함하는 메서드를 제공해야 한다.
  • 예외를 핸들링할 로직을 제공해야 한다. 이 때, 반환은 byte[]가 아닌 Message<byte[]> 타입으로, 반환값 그 자체로 handler의 응답으로 사용할 수 있어야 한다.
    • 이렇게 하지 않으면, interceptor의 반환값이 데이터(byte[])와 Stomp 설정값 등 여러 값들을 반환해야 하는데, 쓸 데 없이 번거롭다.
    • java는 매개변수로 객체를 넘기면 값에 의한 참조를 하므로, 수신측에서 임의로 데이터를 조작할 수 없으므로 반환값을 1개로 줄이기 용이하질 않다.
  • Stomp Command는 각 Interceptor가 결정할 수 있어야 하며, 반환값 또한 null을 허용해야 한다.

그리고 따로 에러를 처리할 interceptor가 없다면, 기존 interceptor에 의해 Error 커멘드로 연결을 해제한다.

 

/**
 * STOMP 인터셉터에서 발생한 예외를 처리하기 위한 인터페이스
 */
public interface StompExceptionInterceptor {
    /**
     * 해당 예외를 처리할 수 있는지 여부를 반환하는 메서드
     * @return true: 해당 예외를 처리할 수 있음, false: 해당 예외를 처리할 수 없음
     */
    boolean canHandle(Throwable ex);

    /**
     * 예외를 처리하는 메서드.
     * WebSocket 프로토콜에 의해 ERROR 커맨드를 사용하면, client와의 연결을 반드시 끊어야 한다.
     * 이를 원치 않는 경우, {@link StompCommand#ERROR}를 사용하여 Accessor를 설정해서는 안 된다.
     *
     * @param clientMessage {@link Message}: client로부터 받은 메시지
     * @param ex Throwable: 발생한 예외
     * @return {@link Message}: client에게 보낼 최종 메시지
     */
    @Nullable
    Message<byte[]> handle(@Nullable Message<byte[]> clientMessage, Throwable ex);

    /**
     * client로부터 받은 메시지의 HeaderAccessor에서 필요한 정보를 추출하는 편의용 메서드
     * 기본으로는 receiptId만을 추출하도록 구현되어 있으며, 필요한 정보가 있다면 해당 메서드를 구현하여 사용한다.
     *
     * @param clientMessage {@link Message}: client로부터 받은 메시지
     * @param errorHeaderAccessor {@link StompHeaderAccessor}: client에게 보낼 메시지를 생성하기 위한 errorHeaderAccessor
     */
    default void extractClientHeaderAccessor(@NonNull Message<byte[]> clientMessage, @NonNull StompHeaderAccessor errorHeaderAccessor) {
        StompHeaderAccessor clientHeaderAccessor = MessageHeaderAccessor.getAccessor(clientMessage, StompHeaderAccessor.class);

        if (clientHeaderAccessor != null) {
            String receiptId = clientHeaderAccessor.getReceipt();
            if (receiptId != null) {
                errorHeaderAccessor.setReceiptId(receiptId);
            }
        }
    }
}
@Slf4j
@Component
@RequiredArgsConstructor
public class StompExceptionHandler extends StompSubProtocolErrorHandler {
    private final List<StompExceptionInterceptor> interceptors;

    @Override
    @Nullable
    public Message<byte[]> handleClientMessageProcessingError(@Nullable Message<byte[]> clientMessage, Throwable ex) {
        for (StompExceptionInterceptor interceptor : interceptors) {
            if (interceptor.canHandle(ex)) {
                return interceptor.handle(clientMessage, ex);
            }
        }
        
        log.error("STOMP client message processing error", ex);
        return super.handleClientMessageProcessingError(clientMessage, ex);
    }
}

명세를 그대로 인터페이스로 옮겨담으면 위와 같이 정의할 수 있다.

StompExceptionHandler를 보면, 커스텀 인터페이스로 처리가 가능한 예왼지 우선 스캔하고, 없으면 기존 에러 처리 로직을 수행하도록 부모에게 값을 전달해버렸다.

 

이제 StompExceptionInterceptor를 구현하여, Connect 요청에서 Jwt 인증 실패에 대한 예외를 핸들링하면 된다.

 

📌 AuthenticateExceptionInterceptor

인증 예외 인터셉터를 구현하기 위해 StompSubProtocolErrorInterceptor를 참고하면서 작성해보면 좋다.

JwtException이 발생했을 때, 내가 원하는 동작은 다음과 같다.

  • WebSocket Connect 단계에서 발생한 인증 실패이므로, WebSocket 연결이 끊어져야 한다. 따라서 Error 프레임을 보낸다.
  • 모든 에러는 "code", "reason" 정보가 응답에 포함되어야 한다.

 

공식 문서를 참조하면, Error 프레임에 대해선 딱히 필수로 요구하는 헤더도 없고, 선택사항으로 message를 포함할 수 있다고 하니 구현 가능한 범주 내라고 판단할 수 있다.

 

@Slf4j
@Component
public class AuthenticateExceptionInterceptor implements StompExceptionInterceptor {
    private static final byte[] EMPTY_PAYLOAD = new byte[0];

    @Override
    public boolean canHandle(Throwable cause) {
        return cause instanceof JwtErrorException;
    }

    @Override
    public Message<byte[]> handle(Message<byte[]> clientMessage, Throwable cause) {
        StompHeaderAccessor errorHeaderAccessor = StompHeaderAccessor.create(StompCommand.RECEIPT);

        if (cause instanceof JwtErrorException ex) {
            JwtErrorException jwtErrorException = JwtErrorCodeUtil.determineAuthErrorException(ex);
            log.error("[인증 예외] {}", jwtErrorException.getErrorCode().getMessage());

            errorHeaderAccessor.setMessage(jwtErrorException.getErrorCode().getMessage());
            errorHeaderAccessor.setLeaveMutable(true);
        }

        extractClientHeaderAccessor(clientMessage, errorHeaderAccessor);
        errorHeaderAccessor.setImmutable();

        return createMessage(errorHeaderAccessor, EMPTY_PAYLOAD, cause);
    }

    private Message<byte[]> createMessage(StompHeaderAccessor errorHeaderAccessor, byte[] errorPayload, @Nullable Throwable cause) {
        return MessageBuilder.createMessage(errorPayload, errorHeaderAccessor.getMessageHeaders());
    }
}

별로 어렵지 않은 내용이다.

에러 응답을 원하는 형식으로 보내기 전에, 일단 정상적으로 handling이 되는 지를 보이기 위해 message를 헤더에 삽입했다.

 

참고로 setImmutable()을 한 이유는 위에서 setLeaveMutable() 명세에서, 수정 완료됐으면 다시 불변한 상태로 바꾸라고 이야기했기 때문이다.

그런데 정작, StompSubProtocolErrorInterceptor는 setImmutable()을 호출하지 않는 대범함을 보여주는데, 어차피 클래스 내에서 사용하고 끝낼 거라 의미가 없기 때문인 듯.

 

실행해봤는데, 내가 정의한 AuthenticateExceptionInterceptor가 아니라, 여전히 StompSubProtocolErrorInterceptor가 실행되고 있다.

ㅋㅋ 하지만 Spring Security 예외 처리로 단련된 나는 단숨에 원인을 예상할 수 있었다.

 

비록 Interceptor에서 JwtException을 던졌지만, Stomp 라이브러리 내부에서 해당 예외를 다른 예외(MessageDeliveryException)로 감쌌기 때문이다.

이전과 같은 논리로 해결할 수 있다.

내가 예외를 한 번 더 감싸는 필터라도 만들지 않는 이상, Interceptor에서 발생하는 모든 오류는 MessageDeliveryExeption으로 넘어올 거고, 여기서 getCause()를 확인하면 언제나 내가 던진 예외를 추적할 수 있다.

 

@Override
@Nullable
public Message<byte[]> handleClientMessageProcessingError(@Nullable Message<byte[]> clientMessage, Throwable ex) {
    Throwable cause = ex.getCause();

    for (StompExceptionInterceptor interceptor : interceptors) {
        if (interceptor.canHandle(cause)) {
            log.error("STOMP client message processing error", cause);
            return interceptor.handle(clientMessage, cause);
        }
    }

    log.error("STOMP client message processing error", ex);
    return super.handleClientMessageProcessingError(clientMessage, ex);
}

즉, 코드를 위와 같이 수정하면 해결될 문제다.

`if (ex instanceof MessageDeliveryException mde)`같은 조건 검사를 한 번 해줄까 싶었는데, 굳이 그래야 할 이유를 모르겠어서 하지 않았다.

 

짜잔

그런데 하다보니, message를 header에 넣어도 되고, body에 넣어도 되는데 어디 넣는 게 적절한 거지?

 

https://stomp.github.io/stomp-specification-1.2.html#ERROR

공식 문서에선 message 헤더에 짧은 설명을 담을 수 있고, 더 자세한 정보를 body에 포함할 수도 있다고만 이야기하고 있다.

그냥 개발자가 알아서 선택하라는 건데..error의 주요 정보는 code와 message.

여기서 client 측이 핸들링을 할 때는 message가 아닌 code를 기준으로 둔다. (message에 두면 문자열 종속이라서)

그렇다면 code를 header의 message에 담고, error message를 body에 포함해주면 되지 않겠나.

 

Spring message support 라이브러리에서 ErrorMessage를 제공해주긴 하던데, 내가 원하는 거랑은 동작이 달라서 그냥 직접 만들기로 했다.

 

public record ErrorMessage(String reason) {
    public ErrorMessage {
        Objects.requireNonNull(reason, "reason must not be null");
    }
}

simple is best.

 

@Slf4j
@Component
@RequiredArgsConstructor
public class AuthenticateExceptionInterceptor implements StompExceptionInterceptor {
    private final ObjectMapper objectMapper;
    private static final byte[] EMPTY_PAYLOAD = new byte[0];

    @Override
    public boolean canHandle(Throwable cause) {
        return cause instanceof JwtErrorException;
    }

    @Override
    public Message<byte[]> handle(Message<byte[]> clientMessage, Throwable cause) {
        StompHeaderAccessor errorHeaderAccessor = StompHeaderAccessor.create(StompCommand.ERROR);

        ErrorMessage errorMessage = null;
        if (cause instanceof JwtErrorException ex) {
            JwtErrorException jwtErrorException = JwtErrorCodeUtil.determineAuthErrorException(ex);
            log.error("[인증 예외] {}", jwtErrorException.getErrorCode().getMessage());

            errorHeaderAccessor.setMessage(jwtErrorException.getErrorCode().causedBy().getCode());
            errorHeaderAccessor.setLeaveMutable(true);
            errorMessage = new ErrorMessage(jwtErrorException.getErrorCode().getExplainError());
        }

        extractClientHeaderAccessor(clientMessage, errorHeaderAccessor);
        errorHeaderAccessor.setImmutable();

        return createMessage(errorHeaderAccessor, errorMessage);
    }

    private Message<byte[]> createMessage(StompHeaderAccessor errorHeaderAccessor, ErrorMessage errorPayload) {
        if (errorPayload == null) {
            return MessageBuilder.createMessage(EMPTY_PAYLOAD, errorHeaderAccessor.getMessageHeaders());
        }

        try {
            byte[] payload = objectMapper.writeValueAsBytes(errorPayload);
            return MessageBuilder.createMessage(payload, errorHeaderAccessor.getMessageHeaders());
        } catch (Exception e) {
            log.error("[인증 예외] 에러 메시지 생성 중 오류가 발생했습니다.", e);
            return MessageBuilder.createMessage(EMPTY_PAYLOAD, errorHeaderAccessor.getMessageHeaders());
        }
    }
}

ObjectMapper는 생성 비용이 비싸니까 꼭 Bean으로 주입받도록 하자.

그럴 일이 있겠냐만, message가 생성되지 않거나 역직렬화에 실패하는 경우를 대비해서 EMPTY_PAYLOAD로 보내버리도록 처리했다. (솔직히 좀 과한 검사라고 생각함)

 

별다른 설정을 하지만 않았다면 ObjectMapper의 writeValueAsBytes()는 내부적으로 UTF8로 인코딩을 하기 때문에, WebSocket Protocol을 어기지도 않는다.

 

이제 다시 요청을 보내면, 성공적으로 원하는 응답을 받는 것을 확인할 수 있다.

 


4. Business Exception Handler (Controller)

 

📌 Why we need to implement that?

예외가 Interceptor에서만 발생한다면 상관없겠지만, Business Logic을 실행하던 도중 발생한다면 어떻게 될까?

 

@Slf4j
@Controller
@RequiredArgsConstructor
public class ChatController {
    private final ChatMessageProducer chatMessageProducer;

    ...

    @MessageMapping("chat.message.exception")
    public void exceptionMessage(ChatMessage message, Principal principal) {
        throw new RuntimeException("강제로 발생한 예외");
    }
}

테스트를 위해서 메시지를 보내면, 강제로 서버에서 예외가 발생하도록 만들어보자.

이는 client 측의 오류일 수도 있고, server 측의 오류(db 연결 실패 등)일 수도 있다.

 

당연히 server에선 에러가 발생하지만, 문제는 client 측에 그 어떤 정보도 전달하지 않는다.

(내가 본 포스팅에서는 STOMP 기본 핸들러가 Error 응답을 내보내면서 연결을 끊어버린다고 했었는데, 그냥 처리를 안 해버린다.)

 

Spring Security와는 동작이 다른데, 그 당시엔 모든 요청 이전에 Security Filter에 의해 예외가 발생하면 Security Filter의 예외 처리에 걸려서 어쨌든 응답이 돌아가긴 했었다는 점이었다.

하지만 WebSocket은 Client가 Message를 보냈다고 해서, 반드시 ACK을 보낼 이유가 없다보니 예외가 발생했다고 해서 Interceptor에서 예외를 반환하지도 않는 것.

 

따라서 우리는 비지니스 로직에서 발생한 로직을 별도로 처리할 수 있어야 한다.

 

📌 @ControllerAdvise & @MessageExceptionHandler

Http에서 전역 예외 처리를 할 때와 유사하나, 여기선 @ExceptionHandler가 아니라, @MessageExceptionHandler를 사용한다는 차이만이 존재한다.

 

@Slf4j
@ControllerAdvice
@RequiredArgsConstructor
public class WebSocketGlobalExceptionHandler {
    private final SimpMessagingTemplate template;

    @MessageExceptionHandler(BusinessException.class)
    public void handleBusinessException(Principal principal, @Payload SocketRequest request, BusinessException e) {
        template.convertAndSendToUser(principal.getName(), request.getResponseChannel(), methodProvider.handleBusinessException(e).getBody());
    }

    @MessageExceptionHandler(OtherExcpetion.class)
    @SendToUser("/queue/error")
    public ErrorResponse broadcaseBusinessException(Message<?> message) throws Exception {
        log.error("Error occurred: {}", message);
        throw new ErrorResponse(getSession(message), "Error occurred");
    }
}

사용법이 어려운 건 아닌데, 문제는 위와 같이 보내면 Protocol이 Message로 강제된다는 점이다.

 

따라서 Business Exception을 handling 하기 전에, 우선 Receipt 프레임을 어떻게 전달할 수 있는 지를 살펴봐야 한다.

 

📌 (실패) SimpMessagingTemplate

SimpMessagingTemplate의 send 메서드를 호출하면 내부적으로 Message 프레임을 선택함을 확인할 수 있다.

최종 메시지를 구성하는 게 simpAccessor가 될 테니, 저 값을 어떻게 조작할 수 있어야 하는데, 재밌는 건 이 다음 스니펫이다.

 

simpAccessor가 null이 아니고, isMutable()이 false라면, 클라이언트가 전달한 Accessor로 SimpMessageHeaderAccessor를 덮어쓰고 있는 로직을 확인할 수 있다.

그럼 Message를 전달할 때, Accessor를 mutable 상태로 전달해주면 되는 거 아닌가?

(MessageHeaderInitializer라는 필드를 설정해줄 수 있길래 읽어봤는데, IdGenrator랑 Timestamp 관련 값만 설정하고 다른 건 딱히 하지 않아서 관둠.)

 

라고 생각하고 싱글벙글 악의적인 조작을 가하고 있었는데, SimpMessageHeaderAccessor 이 자식.

RECEIPT를 위해 필수적으로 넣어야 하는 receipt-id를 설정하는 메서드는 제공해주지 않는다.

그럼 어쩌라고. setHeader()에 넣으면 그만이야~

 

참고로 create()만 하면, 자동으로 Message 타입으로 만들어지니까 SimpMessageType을 지정해줘야 하는데

 

public enum SimpMessageType {
	CONNECT,
	CONNECT_ACK,
	MESSAGE,
	SUBSCRIBE,
	UNSUBSCRIBE,
	HEARTBEAT,
	DISCONNECT,
	DISCONNECT_ACK,
	OTHER
}

아니, 여기까지 와서 이걸 막겠다고..?

하지만 아직 포기하긴 이르다. nativeHeader를 때려넣어서라도 구현해보자.

 

우선 파라미터로 전달되는 값들이 유효한 정보들을 담고 있는 지 확인해주었다.

다행히 예외를 잡는 것도 잘 하고 있고, Principal과 client가 전송한 message 정보도 모두 포함하고 있음을 확인할 수 있다.

 

@Slf4j
@ControllerAdvice
@RequiredArgsConstructor
public class WebSocketGlobalExceptionHandler {
    private final SimpMessagingTemplate template;

    @MessageExceptionHandler(RuntimeException.class)
    public void handleRuntimeException(Principal principal, RuntimeException ex, @Payload Message<byte[]> message, StompHeaderAccessor accessor) {
        log.error("Exception occurred[{}]: {}", ex.getClass(), ex.toString());
        log.error("Principal: {}", principal.getName());
        log.error("Headers: {}", message.getHeaders());
        log.error("Payload: {}", new String(message.getPayload()));
        log.error("Accessor: {}", accessor);
        String sessionId = accessor.getSessionId();
        String receiptId = accessor.getReceiptId();

        if (receiptId != null) {
            StompHeaderAccessor receiptHeaderAccessor = StompHeaderAccessor.create(StompCommand.RECEIPT);
            receiptHeaderAccessor.setReceiptId(receiptId);
            receiptHeaderAccessor.setNativeHeader("message", "Error occurred: " + ex.getMessage());

            Message<byte[]> receiptMessage = MessageBuilder
                    .createMessage(new byte[0], receiptHeaderAccessor.getMessageHeaders());

            template.send("/user/" + sessionId + "/queue/errors", receiptMessage);
        } else {
            template.convertAndSendToUser(sessionId, "/queue/errors", "Error occurred: " + ex.getMessage());
        }
    }
}

1차 원정대를 호다닥 완성시키고, 바로 실행해보았다.

 

대차게 실패했다.

 

📌 (실패) AbstractSubscribableChannel
여기서부터 아래 부분 포스팅하다가 전부 잘려서 다시 작성하느라 사진이 좀 부실할 수 있습니다..

애초에 Message 응답을 위한 SimpMessagingTemplate를 사용하려고 했던 게 잘못이었던 거 같다.

일반적으로 RECEIPT 응답을 보내기 위해 어떤 식으로 하는 지 알아보니, StackOverFlow에서 답을 얻을 수 있었다.

 

 

Spring Websocket STOMP: send RECEIPT frames

I have a Websocket-stomp server based on Spring and its SimpleBroker implementation (not utilizing an external broker). I would like to enable STOMP RECEIPT messages. How I could configure my cod...

stackoverflow.com

본문에선 SessionsSubscribeEvent에 대해서, Receipt 메시지를 client측으로 보내는 방법을 제시하고 있다.

우리는 여기서 AbstractSubscribableChannel만 가져와서 사용하면 되지 않을까?

 

그런데 한 가지 문제가 있었다.

Receipt 프레임에 필수적으로 포함해야 할 헤더인 receipt-id를 client 메시지에서 가져와야 한다는 점이었는데,

처음에는 session-id처럼 알아서 할당되는 고유값 정돈 줄 알았다.

 

이 문제를 해결할 방도를 못 찾아서, 클로드에게 도움을 요청했더니 충격적인 답변을 받았다.

바로, client가 receipt 헤더를 반드시 담아야 server가 이에 대한 응답을 돌려줄 수 있다는 점.

 

function sendMessage() {
    const roomId = document.getElementById('roomId').value;
    const content = document.getElementById('message').value;
    stompClient.send("/pub/chat.message.exception", {'Authorization': TokenManager.getAccessToken(), 'receipt': "hello"}, JSON.stringify({
        'roomId': roomId,
        'content': content
    }));
}

테스트 해보니 진짜로 잘 나오고 있음을 확인할 수 있었다.

 

이렇게 되면, 더 개발을 진행하기 전에 정말 Receipt 프레임으로 에러 응답을 반환하는 것이 올바른 접근이었을지 다시 고민해봐야 한다.

 

💡 예외 응답을 Message 프레임으로 전달해야 하는 이유

무슨 수를 써서라도 Receipt 프레임으로 응답을 보내야 한다고 한다면, 불가능한 일은 아니다.
client가 모든 요청마다 receipt 헤더를 포함하도록 요구한다면 구현 가능하다.

문제는 그게 올바른 해결책이냐는 것이다.
receipt는 stomp 프로토콜에서 정의하듯, 단순히 ACK 목적 뿐만 아니라 작업의 진척을 위한 지표로 사용할 수도 있다.

추후에는 이런 기능을 구현해야 할 수도 있다.
그런데 예외 응답을 위해 모든 요청에 receipt를 포함하라고 강제한다면, client가 예외 응답을 받기 위해 포함한 건지, 다른 작업을 위해 포함한 건지 알 수가 없다.

이를 처리하기 위해서 추가적인 header를 또 삽입해야만 하는데, 굳이 이렇게 할 바엔 Message 프레임을 적절하게 사용하는 걸 고려하는 게 더 옳다는 판단을 하게 되었다.

 

이거 때문에 3시간이나 삽질해버렸지만, 득보다 실이 많았기 때문에 나쁘지 않았다.

이제 Message 프레임을 broadcast하지 않도록, 특정 사용자에게 전송할 방법을 찾아보자.

 

📌 SimpMessageTemplate으로 특정 사용자에게 Message 전송

특정 사용자를 식별하여 Message를 보내는 방법은 2가지가 존재한다.

하나는 @SendToUser 어노테이션을 사용하는 것이고, 다른 하나는 SimpMessageSendingOperation 인터페이스를 구현한 SimpMessageTemplate의 convertAndSendToUser()를 사용하는 것이었다.

 

개인적으로 어노테이션이 덕지덕지 붙어있는 걸 별로 선호하지도 않고, reflection으로 인한 오버헤드를 줄일 방법이 있는데 굳이 어노테이션을 사용할 이유가 없다고 생각한다.

 

/**
 * A specialization of {@link MessageSendingOperations} with methods for use with
 * the Spring Framework support for Simple Messaging Protocols (like STOMP).
 *
 * <p>For more on user destinations see
 * {@link org.springframework.messaging.simp.user.UserDestinationResolver
 * UserDestinationResolver}.
 *
 * <p>Generally it is expected the user is the one authenticated with the
 * WebSocket session (or by extension the user authenticated with the
 * handshake request that started the session). However if the session is
 * not authenticated, it is also possible to pass the session id (if known)
 * in place of the user name. Keep in mind though that in that scenario,
 * you must use one of the overloaded methods that accept headers making sure the
 * {@link org.springframework.messaging.simp.SimpMessageHeaderAccessor#setSessionId
 * sessionId} header has been set accordingly.
 *
 * @author Rossen Stoyanchev
 * @since 4.0
 */
public interface SimpMessageSendingOperations extends MessageSendingOperations<String> {

	/**
	 * Send a message to the given user.
	 * @param user the user that should receive the message.
	 * @param destination the destination to send the message to.
	 * @param payload the payload to send
	 */
	void convertAndSendToUser(String user, String destination, Object payload) throws MessagingException;

	/**
	 * Send a message to the given user.
	 * <p>By default headers are interpreted as native headers (e.g. STOMP) and
	 * are saved under a special key in the resulting Spring
	 * {@link org.springframework.messaging.Message Message}. In effect when the
	 * message leaves the application, the provided headers are included with it
	 * and delivered to the destination (e.g. the STOMP client or broker).
	 * <p>If the map already contains the key
	 * {@link org.springframework.messaging.support.NativeMessageHeaderAccessor#NATIVE_HEADERS "nativeHeaders"}
	 * or was prepared with
	 * {@link org.springframework.messaging.simp.SimpMessageHeaderAccessor SimpMessageHeaderAccessor}
	 * then the headers are used directly. A common expected case is providing a
	 * content type (to influence the message conversion) and native headers.
	 * This may be done as follows:
	 * <pre class="code">
	 * SimpMessageHeaderAccessor accessor = SimpMessageHeaderAccessor.create();
	 * accessor.setContentType(MimeTypeUtils.TEXT_PLAIN);
	 * accessor.setNativeHeader("foo", "bar");
	 * accessor.setLeaveMutable(true);
	 * MessageHeaders headers = accessor.getMessageHeaders();
	 * messagingTemplate.convertAndSendToUser(user, destination, payload, headers);
	 * </pre>
	 * <p><strong>Note:</strong> if the {@code MessageHeaders} are mutable as in
	 * the above example, implementations of this interface should take notice and
	 * update the headers in the same instance (rather than copy or re-create it)
	 * and then set it immutable before sending the final message.
	 * @param user the user that should receive the message (must not be {@code null})
	 * @param destination the destination to send the message to (must not be {@code null})
	 * @param payload the payload to send (may be {@code null})
	 * @param headers the message headers (may be {@code null})
	 */
	void convertAndSendToUser(String user, String destination, Object payload, Map<String, Object> headers)
			throws MessagingException;

	/**
	 * Send a message to the given user.
	 * @param user the user that should receive the message (must not be {@code null})
	 * @param destination the destination to send the message to (must not be {@code null})
	 * @param payload the payload to send (may be {@code null})
	 * @param postProcessor a postProcessor to post-process or modify the created message
	 */
	void convertAndSendToUser(String user, String destination, Object payload, MessagePostProcessor postProcessor)
			throws MessagingException;

	/**
	 * Send a message to the given user.
	 * <p>See {@link #convertAndSend(Object, Object, java.util.Map)} for important
	 * notes regarding the input headers.
	 * @param user the user that should receive the message
	 * @param destination the destination to send the message to
	 * @param payload the payload to send
	 * @param headers the message headers
	 * @param postProcessor a postProcessor to post-process or modify the created message
	 */
	void convertAndSendToUser(String user, String destination, Object payload, @Nullable Map<String, Object> headers,
			@Nullable MessagePostProcessor postProcessor) throws MessagingException;

}

하지만 막상 사용하려고 보니 상당히 난해하기 그지없다.

user는 뭐고, destination은 무슨 값을 설정해야 하는지, 타입을 String으로 해둔 덕에 뭘 의미하는지도 모르겠다.

 

https://docs.spring.io/spring-framework/reference/web/websocket/stomp/user-destination.html

문서에 의하면, Spring Stomp는 기본적으로 사용자마다 고유한 세션인 "/user/" 목적지를 만든다고 한다. (정확히는 UserDesinationMessageHandler가 이걸 처리하고, 사용자 세션에 고유한 대상으로 변환한다.)

이는, 해당 메시지가 인증된 사용자에게만 전달되어야 한다는 의미를 갖는 접두사다.

 

인증된 사용자 정보는 Principle 객체의 name 정보로 판단하며, convertAndSendToUser에서 user 파라미터도 이 정보를 요구하는 것이었다.

 

@Slf4j
@Configuration
@EnableWebSocketMessageBroker
public class WebBrokerSocketConfig implements WebSocketMessageBrokerConfigurer {
    ...
    
    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        ...
        config.setUserDestinationPrefix("/user"); // 유저별로 메시지를 구분하기 위한 프리픽스
    }

    ...
}

여기서 직접 명시할 수도 있다.

error 메시지를 받기 위해서, client는 "/user/..."로 시작하는 queue에 대해서도 subscribe를 할 필요가 있다.

즉, 성공적으로 처리가 된다면 채팅방 채널을 구독하는 측에서 메시지를 받을 것이고, 에러가 발생하면 error queue에서 메시지를 받게 되는 것이다.

 

그럼, destination을 "/queue/errors"로 함을 가정하고 진행해보자.

function onConnected(frame) {
    console.log('Connected: ' + frame);
    stompClient.subscribe('/sub/chat.room.1', onMessageReceived, {'auto-delete':true, 'durable':false, 'exclusive':false});
    stompClient.subscribe('/user/queue/errors', onErrorReceived); // durable false 했다가 바로 에러남 ㅎㅎ
}

function onErrorReceived(message) {
    const error = JSON.parse(message.body);
    console.log('Received error:', error);
}

이제, "/user/queue/errors" queue에 메시지가 pub되면, client의 log로 message를 수신하게 될 것이다.

 

@Slf4j
@ControllerAdvice
@RequiredArgsConstructor
public class WebSocketGlobalExceptionHandler {
    private final SimpMessagingTemplate template;
    private static final String ERROR_DESTINATION = "/queue/errors";

    @MessageExceptionHandler(RuntimeException.class)
    public void handleRuntimeException(Principal principal, RuntimeException ex, @Payload Message<byte[]> message, StompHeaderAccessor accessor) {
        template.convertAndSendToUser(principal.getName(), ERROR_DESTINATION, ex.getMessage());
    }
}

SendToUser 메서드이므로, 특정 사용자에게만 메시지가 전달될 것이다.

식별을 위해 getName을 전달하고, destination을 "/queue/errors"로 할당해주었다.

메시지는 일단 전달되는 걸 확인하기 위해, 적당히 아무 값이나 삽입

 

queue가 잘 만들어지는 걸 확인할 수 있고, 문자열을 그대로 보내버리는 바람에 client 측에서 역직렬화에 실패하긴 했지만 수신하는 것을 확인할 수 있다.  

 

public record ErrorMessage(
        @JsonInclude(JsonInclude.Include.NON_NULL)
        String code,
        String reason
) {
    public ErrorMessage {
        Objects.requireNonNull(reason, "reason must not be null");
    }

    public static ErrorMessage of(String reason) {
        return new ErrorMessage(null, reason);
    }

    public static ErrorMessage of(String code, String reason) {
        return new ErrorMessage(code, reason);
    }
}

이번엔 ErrorMessage도 정상적으로 전달해보자.

Error 프레임에선 code를 header에 삽입했지만, Message 프레임은 header의 message가 optional로 제공되지 않는다.

넣으려면 넣을 순 있겠지만, Error 프레임과 구분을 해주기 위해서 body에 넣어서 전달하기 위해 code를 삽입하되, null인 경우 직렬화 대상에 포함되지 않도록 만들었다.

 

@Slf4j
@ControllerAdvice
@RequiredArgsConstructor
public class WebSocketGlobalExceptionHandler {
    private final SimpMessagingTemplate template;
    private static final String ERROR_DESTINATION = "/queue/errors";

    @MessageExceptionHandler(RuntimeException.class)
    public void handleRuntimeException(Principal principal, RuntimeException ex, @Payload Message<byte[]> message, StompHeaderAccessor accessor) {
        ErrorMessage errorMessage = ErrorMessage.of("5000", ex.getMessage());
        template.convertAndSendToUser(principal.getName(), ERROR_DESTINATION, errorMessage);
    }
}

이번엔 문자열을 던지지 않고, 제대로 ErrorMessage를 사용해 역직렬화를 해서 보내자 client 측에서 성공적으로 수신하는 것을 확인할 수 있었다.

 

📌 추가로 고려해볼 사항

Spring 공식 문서 보다가 생긴 의문인데, 생각해보니 이건 단순히 예외 처리 뿐만 아니라 Pub/Sub를 위한 라이프 사이클에 대한 이야기에 가까운 것 같다.

 

1️⃣ error의 message를 확인하기 전에 client가 WebSocket을 종료한다면?

Server가 사용자 세션의 error queue에 message를 삽입했는데, Client가 queue를 소비해서 error를 핸들링하지 않고 앱을 종료해버린다면 어떻게 되어야 할까?

Queue를 지속성(durable)으로 설정할 수는 있지만, 더 이상 접속하지 않을 수도 있는 모든 사용자의 queue를 유지하는 것은 server에 상당한 부담이 될 것이다.

이를 위해 message나 queue에 x-message-ttl을 설정할 수 있지만, 나중에 client가 재연결했을 때 반드시 처리했어야 할 error라면 message 보존과 관련한 문제가 발생한다.

 

만약, 이런 경우에 대해 처리를 해야 한다면...나라면 처리되지 않은 메시지만 별도로 Dead Letter Exchange로 전달하지 않을까 싶었는데, 이러면 durable로 유지하는 거랑 뭔 차이..

 

2️⃣ chatServer가 불안정해져, 도중에 client가 다른 chatServer와 연결된다면?

spring 문서에서 멀티 애플리케이션 서버 시나리오에 대응하기 위해 고민해보라고 나와있다.

예를 들어, client에게 message를 전달하려 했으나, 다른 chat server에 연결되어 있다면 UserDestinationBroadcast를 사용해 다른 chat server들에게 해당 사항을 전파해 처리할 수 있도록 만들라고 한다.

 

근데 우린 RabbitMQ로 외부 브로커 쓰니까 굳이?

어차피 MQ를 중앙에서 관리하므로, 어떤 서버에 연결하든 사용자에게 메시지를 전달할 수 있으므로 나와는 관련 없는 이야기다.