Spring

Spring WebSocket(STOMP) 통합 테스트 정리

chanyoun 2025. 10. 17. 11:10

Spring WebSocket(STOMP) 통합 테스트 정리

 

1. 통합 테스트를 결정한 이유

WebSocket의 전반적인 흐름을 실제와 동일하게 검증하기 위해 통합 테스트 방식을 선택하였습니다.

현재 STOMP 구조는 다음과 같습니다.

StompDispatcher → 각 command에 맞는 handler → SEND의 경우 @MessageMapping이 선언된 단에서 처리

이 흐름에서 최초 handshake 단계에서 사용자의 인증 및 인가 상태를 확인하며, 필요한 인가 정보는 session에 저장합니다.

이후 각 command에 해당하는 handler는 handshake 과정에서 설정된 session 값을 활용하여 비즈니스 로직을 수행합니다.

이러한 일련의 흐름은 단위 테스트로는 충분히 검증하기 어렵기 때문에, 실제 WebSocket 연결 및 메시지 송수신 과정을 포함하는 통합 테스트로 구성하였습니다.

 

2. 멀티스레드로 인한 문제

아래 코드는 SEND 커맨드를 테스트하기 위한 예시입니다.

// given
// ... 더미 데이터 저장

// when -----------------------------

BlockingQueue<String> messageQueue = new LinkedBlockingDeque<>();
BlockingQueue<String> errorQueue = new LinkedBlockingDeque<>();

// 1. Handshake + STOMP CONNECT
session = connectWithCookies(jwtCreatedBySavedMember.getAccessToken(),
    jwtCreatedBySavedMember.getRefreshToken(), errorQueue);

StompHeaders headers = buildSubscribeHeaders(chatRoom.getId());

// 2. 구독
session.subscribe(headers, new SimpleFrameHandler(messageQueue));

ChatMessageSendRequest payload = ChatMessageSendRequest.builder()
    ...
    .build();

// 3. SEND 요청
session.send("/api/pub/message", payload);

// then -----------------------------
await()
    .atMost(4, SECONDS)
    .untilAsserted(() -> {
        // DB 내 변경사항 검증
    });

// 응답 검증
String result = messageQueue.poll(3, SECONDS);
// ... 응답 검증

WebSocket 통합 테스트는 STOMP 요청을 수신하는 스레드와 메시지를 송신하는 스레드가 분리되어 동작하므로 멀티스레드 환경에서 수행됩니다.

이 때문에 DB 상태 검증 시점이 비동기적으로 지연될 수 있습니다. 이를 해결하기 위해 Awaitility 라이브러리의 await() 메서드를 사용하였습니다.

await() 메서드는 일정 간격으로 DB 쿼리를 반복 실행하여 원하는 변경사항이 발생했는지 확인합니다.

테스트에서는 타임아웃을 4초로 설정하여, 그 안에 기대한 변경사항이 감지되지 않으면 테스트가 실패하도록 하였습니다.

 

3. 트랜잭션(@Transactional) 관련 문제

기존 HTTP 테스트에서는 각 테스트 메서드에 @Transactional을 적용하였습니다.

그러나 WebSocket 통합 테스트는 멀티스레드 기반으로 실행되므로, 테스트 스레드와 STOMP 요청을 처리하는 스레드의 트랜잭션이 서로 분리됩니다.

즉, 테스트 코드에서 더미 데이터를 저장하더라도 해당 데이터가 STOMP 처리 스레드에서는 보이지 않는 문제가 발생합니다.

따라서 STOMP 기반 WebSocket 테스트에서는 @Transactional을 사용하지 않았습니다.

대신 테스트 실행 전후로 DB를 정리(clean-up)하여 항상 초기 상태를 유지하도록 하였습니다.

 

4. 서로 다른 스프링 컨텍스트

 

4.1 HTTP 테스트를 위한 어노테이션

기존 HTTP 통합 테스트에서 사용한 커스텀 어노테이션은 다음과 같습니다.

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
@Import(WireMockConfig.class)
public @interface ControllerIntegrationTest {
}

위 어노테이션은 @SpringBootTest의 기본 설정(WebEnvironment.MOCK)을 사용합니다.

즉, 애플리케이션 컨텍스트는 로드되지만 서블릿 환경은 Mock으로 구성되며, 실제 내장 톰캣(Tomcat)은 구동되지 않습니다.

image-20251017102700735

 

4.2 Websocket(STOMP) 를 위한 어노테이션

반면 STOMP 기반 WebSocket 테스트는 실제 서버가 존재해야 전체 프로토콜 흐름(핸드셰이크 → CONNECT→SUBSCRIBE → SEND → DISCONNECT)을 검증할 수 있습니다.

그 이유는 STOMP 통신이 양방향 비동기 구조로 동작하기 때문입니다.

따라서 테스트 환경에서도 다음 두 가지 스레드가 모두 필요합니다.

  1. 요청을 처리할 서버 스레드
    • 실제 톰캣(WebSocket 서버)이 기동되어야 클라이언트의 CONNECT, SUBSCRIBE, SEND 등의 프레임 요청을 수신하고 처리할 수 있습니다.
    • 즉, @MessageMapping이 붙은 컨트롤러 메서드나, ChannelInterceptor, StompHandler 등이 실제 네트워크 스레드에서 호출되어야 합니다.
  2. 요청을 발생시키는 클라이언트 스레드
    • 테스트 코드 내에서 StompSession을 통해 서버에 연결하고 메시지를 송수신합니다.
    • 이 스레드는 실제 네트워크 소켓을 열어 서버 스레드와 통신해야 하므로, Mock 서블릿 환경에서는 동작할 수 없습니다.

따라서 실제 서버 스레드를 띄워 요청과 응답이 오가는 환경을 재현하기 위해 @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) 설정을 사용하였습니다.

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
@ActiveProfiles("stomp-test")
public @interface StompHandlerTest {
}
image-20251017103142495

HTTP용 어노테이션(ControllerIntegrationTest)과 STOMP(WebSocket)용 어노테이션 (StompHandlerAnnotation)의 가장 큰 차이점은 @SpringBootTestwebEnvironment 설정입니다.

  • WebEnvironment.MOCK : 실제 서버 미구동(Mock 서블릿 환경)
  • WebEnvironment.RANDOM_PORT : 실제 내장 톰캣 서버 구동

WebEnvironment.RANDOM_PORT를 사용하면 테스트 시 실제 톰캣이 랜덤 포트에서 실행되며, 이를 통해 실질적인 WebSocket 통신이 가능해집니다.

따라서 같은 패키지 내에 존재하는 테스트이더라도, HTTP 기반 테스트와 WebSocket 테스트는 서로 다른 스프링 컨텍스트를 사용합니다.