본문 바로가기

개발/SpringBoot

Spring MVC, Spring Webflux SSE 성능 비교

개요


많은 사용자 트래픽이 예상되는 프로젝트에서 SSE(Server-Sent Events)를 활용해 사용자에게 데이터를 스트림으로 전송해야 하는 요구사항이 생겼습니다.

 

이러한 환경에서 서버 성능 이슈로 SSE를 빠르게 전달하지 못하면 UX가 심각하게 나빠지기 때문에 효율적인 요청 처리가 무엇보다 중요합니다.

 

그렇기에 프로젝트를 시작하기 전, Spring Boot 환경에서 어떤 방식으로 SSE를 구현할지 고민했습니다.

 

 

Spring에서 SSE를 구현하려면 두 가지 방법이 있습니다.

1. SseEmitterWebMVC에서 사용하는 방식

2. WebFlux 환경에서 Flux를 사용하는 방식.

 

많은 사용자 요청을 효율적으로 처리해야 하기 때문에 두 가지 방법의 성능테스트 해보면서 더 나은 성능을 보이는 방법을 찾아보겠습니다.

 

 

 

SSE란


SSE는 Server Sent Event의 약어로, HTTP 1.1부터 지원되는 기능입니다.

 

기존 HTTP 1.0에서는 Persistence Connection을 지원하지 않았지만 1.1부터 해당 기능을 지원해서, 서버와 클라이언트 간 연결을 유지하면서 지속적으로 데이터를 전송할 수 있게 되었습니다. 

 

SSE는 Persistence Connection을 활용해 한번의 connection으로 클라이언트에게 지속적으로 데이터를 전송합니다.

 

ChatGPT에서 답변을 작성할 때, 한번에 모든 답변이 표시되지 않고, 글자가 연속적으로 표현되는 것이 SSE를 이용한 것입니다.

 

 

 

 

SseEmitter


SseEmitterSpring MVC에서 SSE를 구현하기 위해 제공되는 클래스입니다.

 

구현한 코드는 아래와 같습니다.

 

public SseEmitter test() {
    String str = getText();
    SseEmitter emitter = new SseEmitter(100000000L);
    try {
        for (char c : str.toCharArray()) {
            emitter.send(c);
        }
        emitter.complete();
    } catch (IOException e) {
        emitter.completeWithError(e);
    }

    return emitter;
}

 

 

이제 ngrinder를 이용해 성능을 테스트해보겠습니다.

 

 

Vuser를 10명으로 잡고 1분간 테스트한 결과 TPS15.0, MTT692.21로 측정되었습니다.

 

다음으로 WebFlux를 사용했을때의 성능을 테스트해보겠습니다.

 

 

 

Spring WebFlux


Spring WebFluxSpring 5부터 도입된 논블로킹(non-blocking), 리액티브(reactive) 프로그래밍 모델을 지원하는 웹 프레임워크입니다.

 

Servlet API와 같은 블로킹 I/O 대신 Reactor 라이브러리 기반의 Reactive Streams를 사용하여 더 많은 동시 연결을 효율적으로 처리할 수 있습니다.

 

이 글에서는 성능 비교가 목적이기 때문에 WebFlux에 대해 자세히 알고 싶다면 아래 링크를 참고하시면 될 것 같습니다.

https://docs.spring.io/spring-framework/reference/web/webflux/new-framework.html

 

 

구현한 코드는 아래와 같습니다.

public Flux<String> getRelaySseData() {
    String text = getText();

    return Flux.<String>create(sink -> {
                for (int i = 0; i < text.length(); i++) {
                    sink.next(text.substring(i, Math.min(i + 1, text.length())));
                }
                sink.complete();
            })
            .doOnCancel(() -> log.info("Client disconnected. Stopping stream."))
            .doOnComplete(() -> log.info("Stream Completed"));
}

 

이제 ngrinder로 성능을 테스트해보겠습니다.

 

 

Vuser를 10명으로 잡고 1분간 테스트한 결과 TPS 67.3, MTT 149.28로 측정되었습니다.

 

SseEmitter를 사용했을때와 대비해 TPS가 448% 향상되었습니다.

 

 

 

 

성능 차이가 나는 이유


같은 환경에서 테스트했는데 SseEmitterWebflux에서 네배 이상의 성능 차이가 발생했습니다.

 

성능 차이가 발생하는 이유는 아래와 같습니다.

 

Spring MVCServlet 기반의 blocking I/O 방식을 사용합니다.

 

Servlet은 사용자 요청당 하나의 스레드를 할당하는데 SseEmitter는 클라이언트와 지속적으로 연결을 유지하기 때문에, 하나의 요청이 들어오면 하나의 스레드가 계속 blocking 되는 상태로 유지됩니다.

 

이 때 많은 사용자가 동시에 SSE 요청을 보내면 스레드 풀에 가용 가능한 스레드가 남아있지 않게 됩니다.

 

그러면 더 이상 사용 가능한 스레드가 없어서 스레드풀에 스레드가 반환될 때 까지 요청이 대기 상태에 놓이게 됩니다.

 

예를 들어, 스레드 풀의 기본 스레드 정책으로 200개의 스레드를 사용한다고 가정해 보겠습니다.

 

이 때, 200명의 클라이언트가 SSE 연결을 유지하는 동안 스레드 200개가 모두 사용되면 다른 요청을 처리하지 못하게 되고, 이로 인해 사용자가 응답을 받기까지 지연이 발생하게됩니다.

 

반면, Webflux는 요청당 하나의 스레드를 점유하지 않고, 이벤트 루프를 통해 비동기적으로 I/O 작업을 처리합니다.

 

이벤트 루프는 사용자에게 메시지를 보내는 I/O 작업을 유저 스레드 생성 없이 커널 스레드에게 직접 전달하기 때문에 유저 스레드가 즉시 반환되어 다른 요청을 처리할 수 있습니다.

 

이러한 방식으로 요청을 대기 상태에 놓지 않게 만들고, 더 많은 사용자 요청을 효율적으로 처리할 수 있게 됩니다.

 

 

 

결론


SSE를 효율적으로 처리하기 위해 Spring WebFluxSseEmitter를 비교 테스트해 보았습니다.

 

만약 성능 테스트 없이 SseEmitter를 선택했더라면, 불필요한 스케일 아웃으로 인해 높은 비용을 지불했을 것입니다. 하지만 이번 테스트를 통해 이러한 비용을 절감할 수 있었습니다.

 

앞으로도 개발을 진행하기 전, 개발 도구의 성능과 장단점을 비교 테스트하여 더 나은 방법을 선택하는 습관을 들여야겠습니다.