Spring/Spring

[Spring WebSockets] 2-3. STOMP: 부가 기능

noahkim_ 2025. 5. 4. 20:21

1. Order of Messages

  • 브로커에서 클라이언트로 보내는 메시지는 clientOutboundChannel을 통해 전달됩니다.
  • 이 채널은 내부적으로 ThreadPoolExecutor(멀티스레드 풀)로 운영되기 때문에, 메시지들이 여러 스레드에서 처리됩니다.
  • 그래서 클라이언트가 실제로 받는 메시지 순서가 서버에서 보낸 순서와 다를 수 있습니다.

 

순서 보장 설정

예제) Outbound 

더보기
@Configuration
@EnableWebSocketMessageBroker
public class PublishOrderWebSocketConfiguration implements WebSocketMessageBrokerConfigurer {

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        // 기타 설정...
        registry.setPreservePublishOrder(true);
    }
}
  • 같은 클라이언트 세션 내에서 메시지가 하나씩 순서대로 전달됨

 

 

예제) Inbound

더보기
@Configuration
@EnableWebSocketMessageBroker
public class ReceiveOrderWebSocketConfiguration implements WebSocketMessageBrokerConfigurer {

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.setPreserveReceiveOrder(true);
    }
}
  • 같은 클라이언트 세션 내에서 서버가 메시지를 받은 순서 그대로 처리함

 

2. Events

  • Spring WebSocket에서는 다양한 ApplicationContext 이벤트가 발생합니다.
  • 이 이벤트들은 Spring의 ApplicationListener 인터페이스를 구현해서 받을 수 있습니다.

 

이벤트

이벤트 발생 시점 설명
BrokerAvailabilityEvent 브로커 연결/끊김 발생 시
브로커 연결 상태 변화 알림
SessionConnectEvent 클라이언트가 CONNECT 요청 시 새 세션 시작 알림
SessionConnectedEvent 브로커가 CONNECTED 응답 시
세션 완전히 연결 완료
SessionSubscribeEvent SUBSCRIBE 요청 시 구독 시작 알림
SessionUnsubscribeEvent UNSUBSCRIBE 요청 시 구독 해제 알림
SessionDisconnectEvent 세션 종료 시
세션 종료 알림
- 중복 발생 가능 (클라이언트 요청 + 타임 아웃 등)
- ⚠️ 하나의 세션에 대해 여러번 인터셉트 될 수 있음
- ✅ idempotent하게 리스너 핸들러 작성 필수
  • 외부 STOMP Broker Relay를 써도 똑같이 이벤트가 발생함

 

3. Interception

  • STOMP 연결의 생명주기(CONNECT, DISCONNECT)는 Event로 알림 받을 수 있다.
  • 하지만 모든 클라이언트 메시지(SEND, SUBSCRIBE, UNSUBSCRIBE 등)를 감시하려면 Interceptor를 써야 한다.

 

ChannelInterceptor

  • 클라이언트가 보낸 모든 메시지 가로챌 수 있음

 

예제

더보기
public class MyChannelInterceptor implements ChannelInterceptor {

    @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel) {
        StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
        StompCommand command = accessor.getCommand();
        // command 종류에 따라 처리 (CONNECT, SUBSCRIBE, SEND, DISCONNECT 등)
        return message;
    }
}
  • StompHeaderAccessor를 이용해 메시지의 STOMP 헤더를 읽을 수 있다.

 

ExecutorChannelInterceptor

  • ChannelInterceptor의 하위타입
  • 메시지가 실제로 핸들러(Consumer)에서 처리될 때도 가로챌 수 있음.

 

예제

더보기
public class MyExecutorChannelInterceptor implements ExecutorChannelInterceptor {

    @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel) {
        System.out.println("[preSend] 메시지가 채널로 보내지기 직전: " + message);
        return message;
    }

    @Override
    public void afterSendCompletion(Message<?> message, MessageChannel channel, boolean sent, Exception ex) {
        System.out.println("[afterSendCompletion] 채널로 메시지 보낸 후: " + message);
    }

    @Override
    public Runnable beforeHandle(Message<?> message, MessageChannel channel, Runnable task) {
        System.out.println("[beforeHandle] 핸들러가 메시지를 처리하기 직전: " + message);
        return task;
    }

    @Override
    public void afterMessageHandled(Message<?> message, MessageChannel channel, Runnable task, Exception ex) {
        System.out.println("[afterMessageHandled] 핸들러가 메시지를 처리한 후: " + message);
    }
}

 

설정

더보기
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfiguration implements WebSocketMessageBrokerConfigurer {

    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        registration.interceptors(new MyChannelInterceptor());
    }
}

 

4. WebSocket Scope

WebSocket 세션과 속성 맵

  • 각 WebSocket 세션에는 Map<String, Object> 형태의 속성 맵이 존재함.
  • 이 속성 맵은 클라이언트가 보낸 메시지의 헤더에 포함되어 전송됨.
  • HttpSessionHandshakeInterceptor를 추가할 경우, 속성 맵에 세션 정보를 추가함

 

예제) SimpMessageHeaderAccessor

더보기

컨트롤러

@Controller
public class MyController {

    @MessageMapping("/action")
    public void handle(SimpMessageHeaderAccessor headerAccessor) {
        Map<String, Object> attrs = headerAccessor.getSessionAttributes();
        // 세션에 저장된 속성들 사용 가능
    }
}

 

Interceptor

public class MyInterceptor implements ChannelInterceptor {

    @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel) {
        StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
        
        if (StompCommand.CONNECT.equals(accessor.getCommand())) {
            accessor.getSessionAttributes().put("userId", "12345");
            accessor.getSessionAttributes().put("nickname", "test");
        }
        
        return message;
    }
}
  • 직접 추가 가능

 

WebSocket 범위

  • 스프링에서는 "websocket" 스코프를 사용해 WebSocket 세션마다 별도의 빈을 생성할 수 있다.
    • WebSocket 세션의 속성 맵에 저장한다.
  • 이 빈은 WebSocket 세션 동안만 살아있고, 세션이 끝나면 파괴된다.
    • 세션 종료 시, @PreDestroy 메서드를 호출해서 자원 정리를 한다.
  • 주로 컨트롤러나 채널 인터셉터에서 이 빈을 주입받아 사용한다.

 

예제

더보기
import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Component;

@Component
@Scope("websocket")  // WebSocket 스코프 적용
public class WebSocketSession {

    private String username;

    // 세션 시작 시 클라이언트 이름 저장
    public void setUsername(String username) {
        this.username = username;
    }

    // 세션 중 클라이언트의 이름을 가져옴
    public String getUsername() {
        return username;
    }
}
@Controller
public class WebSocketController {

    // 클라이언트에서 메시지 전송 시 WebSocketSession에 사용자 이름 저장
    @MessageMapping("/chat.join")
    public void joinChat(String username, SimpMessageHeaderAccessor headerAccessor) {
        // WebSocketSession 빈을 주입받아 사용자 정보 설정
        WebSocketSession session = headerAccessor.getSessionAttributes().get("webSocketSession");
        if (session != null) {
            session.setUsername(username);
        }
    }

    // 클라이언트로부터 받은 메시지를 모든 클라이언트에게 브로드캐스트
    @MessageMapping("/chat.send")
    @SendTo("/topic/public")
    public String sendMessage(String message) {
        return message;
    }
}

 

출처