[Test] 점진적 부하테스트: 20,000명 유저의 SSE 메시지 전송 - WebFlux

2026. 2. 20. 17:51·Test

저번 포스트에 이어서 SSE 실시간 이벤트 메시지 전송에 대한 부하테스트를 진행합니다.

https://soberyl.tistory.com/43

 

[Test] 점진적 부하테스트: 10,000명 유저의 SSE 메시지 전송

실시간 메시지 전송의 오버헤드 구현하고 있는 이커머스 플랫폼에서는 SSE를 사용해서 사용자에게 알림을 보내는 기능을 가지고 있습니다. 알림 내역을 DB에 저장하고, 이후에 성공적으로 알림

soberyl.tistory.com

 

시나리오 3: 20000명의 유저에게 동시에 SSE 메시지를 전송한다.

  이전에 진행했었던 SSE 구독 유지의 환경에서 3만명의 한계치를 경험했었기 때문에, 2만명을 시작으로 스트레스 테스트의 시나리오를 진행해보겠습니다.

최소 서버의 상태

  기존에 SSE Emitter 한 개당 차지하는 메모리가 100KB였기 때문에 2만명의 환경에서는 

테스트 결과

K6에서 다음과 같은 경고 로그가 나오기 시작했습니다.

WARN[0451] The flush operation took higher than the expected set push interval. If you see this message multiple times then the setup or configuration need to be adjusted to achieve a sustainable rate. output=InfluxDBv1 t=1.091544251s

 

  이 경고문은 K6가 1s안에 데이터를 비워서 넘겨야 하는데 한번 넘기는데 1s를 넘겨버려서 내부에서 데이터가 밀리고 있다고 하는 내용입니다. 이 때문에 그라파나에서 관측하는 값이 관측되지 않는 공백인 부분이 생길 수 있습니다. 2초 단위로 데이터를 옮겨서 K6에서 발생하는 부하를 줄이겠습니다. 또한 커넥션 수를 조정해서 좀 더 커넥션을 써서 더 빨리 데이터를 밀어넣도록 조정하겠습니다.

    environment:
      - K6_OUT=influxdb=http://influxdb:8086/k6
      - K6_INFLUXDB_PUSH_INTERVAL=2s
      - K6_INFLUXDB_CONCURRENT_PUSHES=5

 

  램 사용량이 늘었는데요, 2.38GB를 사용하고 있습니다. 1만명의 유저에서 1GB정도가 늘었는데, 이는 한 개의 Emitter에 대해서 100KB정도로 생각했을 경우 1GB 늘어날 것이라는 예측과 부합합니다. 메모리 사용량은 현 상황에서 합리적으로 보입니다. 다만 힙 메모리를 과하게 차지하고 있다는 걱정도 버릴수 없습니다. SSE 연동 하나의 로직으로 인해서 다른 로직이 돌아갈 틈이 반절밖에 남지 않는데, 가장 중요한 도메인 로직은 주문과 결제이기 때문입니다.

기대를 저버리지 않는 성능 저하^^

  min이 -40으로 나왔습니다. sse_msg_latency의 측정 방식은 (클라이언트 측 시간) - (서버에서 로직을 실행한 시간) 으로 측정하고 있는데, 여기서 K6 내부 자바스크립트 엔진이 CPU 자원을 할당받지 못해 렉이 걸리면서 그 보다 전의 캐시된 시간을 뱉는 현상이 생길 수 있습니다. 혹은 OS 레벨에서도 PC의 부하가 심해지면 시계를 10~50ms정도 조정해서 동기화하는 경우가 있습니다. 요는 시간을 거슬러간게 아니라, 측정 과정에서 과한 부하로 인한 오류가 발생한 것입니다. min에 대한 고려는 잠시 넘어가겠습니다.

 

  충격적인 avg값인 4118ms, 그리고 p(95) 9101ms가 나왔습니다. 명백하게 개선을 해야하는 부분인 것 같습니다.

  GC가 총 500ms 정도 소요됐습니다. 한번의 GC에서 100ms가 넘게 걸린 부분도 있었는데, 이례적인 경우입니다. minor GC의 평균적인 양에 비해서 Eden에 너무 많은 메모리를 적재하니 발생한 문제인 것 같습니다.

 

  메모리 사용 개선과 응답 지연 속도 개선을 위해서 아키텍처 관점으로 접근해보려고 합니다.

  1. 메모리 최적화 비용
    1. 가상 스레드는 생성에 대한 시간적 비용과 공간적 비용이 매우 저렴하지만, 결국 하나의 서블릿 객체 위에서 통신하게 됩니다. 여기서 워커 스레드 1개에 할당되는데, 결국 근본적인 Spring MVC의 블로킹 현상은 피할 수 없습니다.
    2. 많은 동시 접속 유저에 대한 연결 정보를 RAM 위에 적재해놓는데, 적재해놓을 때 Eden에 많은 데이터가 쏟아지기 때문에 많은 GC가 일어납니다. 이것이 응답 지연 속도를 높이는 문제로 작용할 수 있습니다.
    3. 연결에 대한 정보 저장이 무거우므로 동시 접속 유저를 받는데 물리적인 메모리를 늘릴 수 밖에 없는데, 이는 비용이 큰 부분입니다.
  2. 응답 지연 속도
    1. N명에게 알림을 쏘기 위해서 N개의 가상 스레드를 생성합니다. 이때 OS의 네트워크 소켓 (Send-Q)에 데이터를 밀어 넣으려고 경합하는데, 여기서 병목이 발생할 수 있습니다. 실제로 1만 명에서 2만명으로 늘렸을 때, 1초 단위로 새로이 갱신되던 모습과 달리 2초를 유지하는 부분을 확인했습니다.
    2. Spring Servlet에서 제공하는 SSE Emitter의 send는 기본적으로 ReentrantLock을 사용합니다. 한 개에 대해서 여러 알림을 보내는 로직에 대해서는 Lock-Free하지 않은 구조로 더욱 느려질 수 있어 확장성이 떨어집니다. 본 프로젝트에서는 Lock-Free에 근접한 상황만 연출되지만, 이런 구조에서도 Lock을 걸고, Unlock하는 오버헤드는 필연적으로 발생합니다.

WebFlux

  이러한 문제로, 기존 Spring MVC를 채택하는 것에서 한계를 느껴서 Netty를 채택하고 있는 WebFlux로 전환하려고 합니다. Netty에 대한 구조의 차이 및 분석은 아래의 url에 기재돼있습니다.

https://soberyl.tistory.com/40 

 

[Network] Spring에서의 Tomcat과 비교한 Netty의 구조

What is Netty? Netty는 기존의 톰캣에서 제공하는 처리 방식 중의 일부를 개선하기 위해서 등장한 네트워크 프레임워크입니다. 전통적인 톰캣은 1 Request 1 Thread 모델이었습니다. 여기서 요청을 스레

soberyl.tistory.com

 

  이벤트 루프 방식을 적용하려면 처리 과정 중에 단 한 곳이라도 Blocking이 생기면 안됩니다. 이는 DB도 마찬가지라서, 이벤트 루프 방식을 지원하는 DB를 사용해야 의미가 있는 작업이 됩니다. 프로젝트에서는 MySQL을 이용하고 있는데 이는 JDBC 드라이버를 포함해서 이벤트 루프를 지원하는 데이터베이스입니다. 하지만 지금 SSE Emitter는 인메모리 방식을 취하고 있기 때문에 DB까지 갈 일이 없기 때문에 비교적 코드를 마이그레이션하는데 수월할 것으로 판단됩니다.

가설 (1)

Spring MVC의 구조의 한계에서 비롯된 것으로, WebFlux의 이벤트 루프 아키텍처를 채용하면 문제가 전반적으로 개선될 것입니다.

가설 (1) 개선 시도

  Spring MVC에서 제공하는 Servlet을 기반으로한 SSE Emitter Repository의 기존 구현 코드는 아래와 같습니다.

public class EmitterRepository {
    private final Map<Long, Map<String, SseEmitter>> emitterStorage = new ConcurrentHashMap<>();

    public SseEmitter save(Long memberId, String emitterId, SseEmitter emitter) {
        emitterStorage.computeIfAbsent(memberId, key -> new ConcurrentHashMap<>())
                .put(emitterId, emitter);
        return emitter;
    }
    
    public void deleteById(String emitterId) {
        if (emitterId == null || !emitterId.contains("_")) {
            return;
        }

        try {
            String[] parts = emitterId.split("_");
            Long memberId = Long.parseLong(parts[0]);

            Map<String, SseEmitter> userEmitters = emitterStorage.get(memberId);

            if (userEmitters != null) {
                userEmitters.remove(emitterId);

                if (userEmitters.isEmpty()) {
                    emitterStorage.remove(memberId);
                }
            }
        } catch (NumberFormatException e) { log.warn("Invalid Emitter ID format: {}", emitterId); }
    }
    
    public Map<String, SseEmitter> findAllStartWithById(Long memberId) {
        Map<String, SseEmitter> userEmitters = emitterStorage.get(memberId);

        return userEmitters != null ? userEmitters : Collections.emptyMap();
    }
    
    ...
}

 

  SseEmitter 대신 Reactor의 Sinks.Many를 사용합니다. 이를 이용해서 SseEmitter를 Sinks.Many<ServerSentEvent<Object>>으로 변경해야합니다. 다음은 ServerSentEvent<T>의 구현체의 코드입니다.

public final class ServerSentEvent<T> {

	private final @Nullable String id;

	private final @Nullable String event;

	private final @Nullable Duration retry;

	private final @Nullable String comment;

	private final @Nullable T data;
    ...
    
}

  이에 따르면 T에 들어가는 내용은 페이로드 타입을 의미합니다.

 

public class EmitterRepository {
    private final Map<Long, Map<String, Sinks.Many<ServerSentEvent<Object>>>> emitterStorage = new ConcurrentHashMap<>();

    public Sinks.Many<ServerSentEvent<Object>> save(Long memberId, String emitterId, Sinks.Many<ServerSentEvent<Object>> sink) {
        emitterStorage.computeIfAbsent(memberId, key -> new ConcurrentHashMap<>())
                .put(emitterId, sink);
        return sink;
    }
    
    public void deleteById(String emitterId) {
        if (emitterId == null || !emitterId.contains("_")) {
            return;
        }

        try {
            String[] parts = emitterId.split("_");
            Long memberId = Long.parseLong(parts[0]);

            Map<String, Sinks.Many<ServerSentEvent<Object>>> userSinks = emitterStorage.get(memberId);

            if (userSinks != null) {
                userSinks.remove(emitterId);

                if (userSinks.isEmpty()) {
                    emitterStorage.remove(memberId);
                }
            }
        } catch (NumberFormatException e) {
            log.warn("Invalid Emitter ID format: {}", emitterId);
        }
    }
    
    public Map<String, Sinks.Many<ServerSentEvent<Object>>> findAllStartWithById(Long memberId) {
        Map<String, Sinks.Many<ServerSentEvent<Object>>> userSinks = emitterStorage.get(memberId);
        return userSinks != null ? userSinks : Collections.emptyMap();
    }
    ...
}

  Map 객체에서의 Emitter를 Sinks.Many<T>로 치환해주면 끝입니다. physical layer는 끝났고, service layer와 presentation layer가 남았습니다.

    @Override
    public Flux<ServerSentEvent<Object>> connect(Long memberId, LocalDateTime connectTime) {
        if(memberId == null || memberId <= 0L){
            throw new IllegalArgumentException("멤버 ID는 양수여야 합니다.");
        }

        long timestamp = connectTime.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
        String emitterId = memberId + "_" + timestamp;

        Sinks.Many<ServerSentEvent<Object>> sink = Sinks.many().unicast().onBackpressureBuffer();

        emitterRepository.save(memberId, emitterId, sink);

        return sink.asFlux()
                .doOnCancel(() -> emitterRepository.deleteById(emitterId))
                .doOnTerminate(() -> emitterRepository.deleteById(emitterId))
                .doOnError(e -> {
                    log.info("SSE Connection Error: ", e);
                    emitterRepository.deleteById(emitterId);
                });
    }

    @Override
    public void send(List<NetworkMessageDto> messages) {
        Flux.fromIterable(messages)
                .doOnNext(message -> {
                    Long receiverId = message.getReceiverId();
                    Map<String, Sinks.Many<ServerSentEvent<Object>>> userSinks = emitterRepository.findAllStartWithById(receiverId);

                    userSinks.forEach((emitterId, sink) -> {
                        sendBySse(sink, emitterId, message.getEventId(), message.getEventName(), message.getData());
                    });
                })
                .subscribe();
    }
    
    public NotificationSendResult sendBySse(Sinks.Many<ServerSentEvent<Object>> sink, String emitterId, String eventId, String eventName, Object data) {
        ServerSentEvent<Object> event = ServerSentEvent.builder()
                .id(eventId)
                .event(eventName)
                .data(data)
                .build();

        Sinks.EmitResult result = sink.tryEmitNext(event);

        if (result.isFailure()) {
            emitterRepository.deleteById(emitterId);
            return NotificationSendResult.failure(emitterId, eventId, result.name());
        }
        return NotificationSendResult.success(emitterId, eventId);
    }

  코드의 변화점을 짚어보겠습니다.

  • timeout이 필요 없어졌습니다. 이벤트 루프 기반이므로 해당 태스크를 다음 이벤트 루프에게 던져버리는 게 끝입니다. error의 기준은 넘겨버리는 이벤트 루프 컨텍스트의 큐가 꽉 찼을 때이므로 timeout 시간을 선언할 필요가 없습니다.
  • Sinks.many().unicast().onBackPressureBuffer()가 추가됐습니다. Sinks.many()는 단일 값이 아닌 연속적인 데이터를 처리하겠다는 선언입니다. unicast()는 하나의 Sink 객체에는 단 한 명의 구독자가 매핑될 수 있다는 설정입니다. Sink 객체 하나는 기존의 SSE Emitter 1개에 해당합니다. muliticast()를 사용한다면 하나의 객체에 여러 이벤트가 달라붙을 수 있습니다. 이는 메시지 매핑과 관련해서 정합성 오류가 생길 수 있습니다. 마지막으로 onBackPressureBuffer()는 서버 스레드를 멈추는 대신에 메모리 내부의 버퍼를 만들어서 데이터를 쌓아놓고 임시로 기다리는 백프레셔입니다. 버퍼가 가득찼을 때의 전략을 지정해놓을 수 있는데, 다른 설정이 없다면 기본적인 전략은 무제한 큐잉(Unbounded Queueing)입니다.
  • sink.asFlux()에서는 콜백을 등록할 필요 없이 선언적인 라이프사이클을 제공합니다. 콜백을 선언하는 것, 즉 MVC의 onTimeout 등은 객체의 상태에 의존합니다. 객체를 생성하고 뒤에다가 콜백을 붙이는 형식입니다. 코드 레벨에서 로직이 복잡해져서 누락 등의 이슈가 발생하면 그대로 자원의 누수가 발생합니다. 반면에 리액터의 doFinally 등은 데이터 파이프의 일부로 조립되서 reactive streams 스펙에 따라 정상 종료, 에러 등을 막론하고 반드시 종료 신호가 발생하도록 설계돼있습니다. 따라서 누수가 발생하지 않습니다. 즉, 리액터는 데이터 흐름 자체를 정의하므로, 더 선언적인 특징을 가집니다.
  • send에서 subscribe의 내용을 정의합니다. doOnNext에서 흘러들어오는 객체마다 처리할 태스크를 선언하고, 구독을 선언합니다. 여기서 Producer는 Flux.fromIterable(messages), subscriber는 메서드 그 자체로, 이 메서드 자체가 이벤트 생성과 트리거를 담당하게 됩니다.
  • sink.tryEmitNext(event)는 이벤트를 다음 파이프에 던지고 바로 결과를 반환합니다. 블로킹을 원천적으로 차단하는 WAS 로직 상 마지막 단계라고 할 수 있습니다.

  Presentation layer, 즉 컨트롤러 단에서는 반환 값을 수정하고, 객체 타입을 수정하면 충분합니다.

 

  이렇게 로직을 WebFlux로 수정하고 결과를 관찰해보겠습니다.

가설 (1) 개선 결과

  기존보다 악화됐습니다... 그리고 메모리 점유가 기존과 같습니다. 조금 다른점이 있다면 min 값이 음수에서 양수로 전환됐다는 사실입니다. 나머지 부분은 전부 응답 대기 시간이 길어졌습니다. 달라진 부분인 min 값의 변화에 대해서 생각해보겠습니다. 이전에는 -40ms라는 이상수치가 측정됐는데, 이번에는 79ms라는 정상적인 수치가 측정됐습니다. 이는 일단 불안정하지 않은, 안정적인 구조에서 데이터를 처리했다는 반증으로 보입니다. 기존에서는 sse_event가 40만으로 찍혔지만 이번에는 그렇지 않은데, 음수 값이 나왔음을 생각하면 정보의 신뢰성이 떨어지는 상황입니다. 즉, 서비스 안정도의 측면에서는 WebFlux로 이전한 것이 옳은 선택이었다는 뜻이 됩니다.

 

로컬호스트 테스트의 한계

  이벤트 루프도 역시 CPU를 제대로 할당 받아야 제대로 사용할 수 있습니다. 본 부하 테스트의 CPU 사용 추이에서 JVM이 사용하는 CPU는 많아봤자 0.1이고, 나머지는 전부 K6, DB, Docker 등의 외부 프로그램이 사용하고 있습니다. 이 사용량이 1을 넘어서 터져버리는 것도 확인됐습니다. 로컬호스트 테스트에서 K6, DB, WAS를 모두 돌리는 것이 무리라고 판단됩니다. 따라서 테스트 환경을 개선할 필요가 있는 것 같습니다.

 

Client와 Server의 환경 분리

  Client와 Server가 격리된 환경에서 기존에 WebFlux로 전환하기 전의 코드와 WebFlux로 전환한 코드를 비교해보려고 합니다. AWS EC2를 사용해서 이제는 L1, L2, L3 Layer가 포함된 환경에서 부하테스트를 진행해보겠습니다.

AWS EC2에서 띄운 서버와 DB

  ec2 instance model은 m7i-flex.large입니다. 코어가 2개에, 램은 8기가 입니다. m7i.large의 light한 버전이지만 그래도 나쁘지 않은 성능을 가지고 있습니다. 또한 기존에 로컬호스트 테스트에서 돌리던 상황과 거의 유사한 상황을 연출할 수 있을 것 같습니다.

// 현재 로컬에서 바로 빌드 후 도커 허브에 push
docker buildx build --platform linux/amd64 -t [도커ID]/[도커허브에 올릴 이미지 이름]:[버전] --push .

// Github에서 리포지토리 조회 후 해당 리포지토리 내용을 도커 허브에 push
docker buildx build --platform linux/amd64 \
  -t [도커ID]/[도커허브에 올릴 이미지 이름]:[버전] \
  --push https://github.com/사용자명/리포지토리명.git#[브랜치명]

  도커를 통해서 DB와 구현한 WAS를 띄워서 돌릴 것입니다. 이렇게 하면 로컬에서 개발한 내용을 쉽게 EC2에서도 업데이트 후 별다른 엔진없이 돌릴 수 있습니다. 구현하고 있는 로컬 환경은 ARM 프로세서를 사용하고 있는 반면에 EC2의 m7i-flex.large는 amd64 프로세서를 사용하고 있기 때문에 컨버팅한 버전을 Push해야 EC2에서 올바른 동작을 할 수 있습니다.

 

  이제 프로메테우스와 K6가 바라보는 대상만 변경하면 됩니다. influxdb와 grafana는 각각 내부에서 값을 저장해놓는 DB 역할과, 단순히 화면에 출력해주는 역할을 담당하기 때문에 다른 설정을 할 필요는 없습니다.

http://localhost:9090/targets (prometheus:port/targets)
타겟이 변경된 프로메테우스 설정

  프로메테우스로 초기 서버 상태를 확인하니 할당된 메모리가 기존보다 현저하게 적어짐을 확인했습니다. JVM은 별도의 설정이 없는 경우에는 호스트 메모리의 약 1/4을 최대 힙 크기로 할당하는 기본 정책을 가지고 있기 때문입니다. 메모리가 8GB이므로 그의 1/4인 약 2GB가 할당되어 있었습니다.

JVM의 기본 설정된 메모리 양

  DB와 WAS를 별개의 EC2로 분리할 계획은 아직은 없기 때문에 우선적으로 하나의 인스턴스에 넣어놓을 계획입니다. 하나의 PC, 즉 8GB의 RAM에서는 50%가 WAS, 50%가 DB가 할당되면 좋을 것 같습니다. 따라서 4GB를 할당해주겠습니다. 도커 컴포즈 파일을 수정해서 JVM 옵션을 달아주면 됩니다.

    environment:
      - JAVA_TOOL_OPTIONS=-Xms4g -Xmx4g

  Grafana를 다시 확인해보니 아래와 같이 약 4GB로 할당된 메모리가 늘었음을 확인했습니다.

2GB에서 4GB로 늘어난 JVM 사용 가능 메모리

이전의 테스트(C10K) 결과 재확인

  2만명의 상황에서는 WebFlux로 마이그레이션한 버전과 기존의 가상스레드를 이용한 버전 둘 다 이상 수치를 보였습니다. 따라서 두 개의 버전을 비교해서 더욱 올바른 비교를 할 수 있도록 이전에 진행했었던 1만명에 대한 부하 테스트를 진행한 후 두 결과를 비교해보도록 하겠습니다. 결과에서 반드시 추가적으로 고려해야할 점은 이전에 진행했던 Localhost 테스트에서는 OSI 7계층의 L1~L3에서 발생하는 오버헤드는 커널 내부의 루프백(Loopback) 인터페이스에서 가상으로 처리되지만, AWS로 옮기는 순간 해당하는 오버헤드가 추가된다는 점입니다. 다시 말해서, 기존에는 무시할만 했던 Network Latency가 이제는 발생하는 것입니다. 추가적인 오버헤드를 살펴보면 다음과 같습니다.

  • L1 (Physical)
    • 신호가 광케이블이나 구리선을 타고 이동하는 속도와 NIC(Network Interface Controller) 카드에서 데이터를 비트로 변환하는 지연 시간이 발생합니다.
    • 전기적 신호와 거리가 고려 사항에 추가됩니다.
  • L2 (Data Link)
    • 데이터를 프레임 단위로 나누고 PC 주소를 확인하며 이더넷 스위치를 거치는 과정입니다. AWS의 경우 가상 스위칭 환경에서의 오버헤드가 이를 대신합니다.
    • 프레임과 스위칭 비용이 고려 사항에 추가됩니다.
  • L3 (Network)
    • 커널이 데이터를 sk_buff 구조체에 담고 IP 헤더를 붙이는 과정에서 CPU를 사용합니다.
    • AWS VPC 내부에서 보안 그룹(Security Group) 규칙 검사, 네트워크 ACL 검사 그리고 서로 다른 물리 서버 간 통신을 위한 VPC 캡슐화(Geneve/VXLAN) 과정 등이 추가됩니다.
    • 라우팅과 캡슐화 비용이 고려 사항에 추가됩니다.

지연 = 전파 지연 + 전송 지연 + 큐잉 지연 + 처리 지연

 

  여기서 Latency는 Network Lantency만 고려한 것 입니다. 따라서 클라이언트의 요청을 시작으로 받을 때까지 걸린 시간, 즉 RTT(Round Trip Time)는 다음과 같습니다.

Calculate RTT

 

  따라서 기존의 그라파나에서 관측하던 패널에서 아래의 내용을 추가적으로 확인하겠습니다. InfluxDB를 이용해서 확인하면 됩니다.

  1. p(95)의 TCP 연결 지연 시간
    1. SELECT mean("value") FROM "http_req_connecting" WHERE $timeFilter GROUP BY time($__interval) fill(null)
  2. TLS 핸드셰이크 시간
    1. SELECT mean("value") FROM "http_req_tls_handshaking" WHERE $timeFilter GROUP BY time($__interval) fill(null)

  특히 기존에 관찰하던 Send-Q가 더 의미가 생겼습니다. 분산 환경에서는 BDP(Bandwidth-Delay Product)가 적용됩니다.

Calculate BDP

  지금 사용 중인 네트워크의 대역폭에 따라서 Send-Q의 병목이 생길 수도 있다는 점인데요, 이 부분을 놓치면 안 될 것 같습니다. 또한 서버가 데이터를 빨리 처리하지 못하면 Recevie window 크기를 줄여서 응답을 보내는데, 이때 클라이언트 커널은 데이터를 더 보내지 못하고 Send-Q에 계속 쌓아두게 됩니다. 즉 서버 측의 백프레셔가 클라이언트까지 영향을 미치는 사례입니다. 클라이언트와 서버, 양 측의 Send-Q와 Receive-Q를 관측해야하는 추가적인 태스크가 생긴 것입니다.

테스트 결과 변화의 공통점

  1. 기본적으로 매우 빠르게 비워지던 커널의 Send-Q가 비워지는 속도가 눈에 띄게 느려지기 시작했습니다.

Event loop structure를 채택한 WAS의 테스트 결과 

  도커에서 버전을 latest로 해서 올린 후, 결과를 확인했습니다.

너무 슬프게도 안 좋아졌습니다...

 TCP 연결 최대 지연 시간은 20ms입니다.

  fd가 1만을 약간 넘는 모습으로 봐서, 모든 유저가 잘 연결된 것 같습니다.

  Old 영역을 확인하니, 1GB가 안되는 양으로 메모리를 사용하고 있습니다. 1만명을 연결할 때 이 만큼이라면, 이전보다 더 적은 양의 메모리를 사용함을 볼 수 있습니다.

  CPU를 사용하다가 그 사용량이 내려가고 있습니다. 10분부터 부하가 시작됐기 때문에 0.15 정도로 이어가다가 WebFlux가 사용되는 시점부터는 0.1로 CPU 사용량이 이어집니다. 오른쪽 Load average를 살펴보면 1이 안되는 정도로 수치가 보여지기 때문에 컨텍스트 스위칭이 거의 없는 상황입니다.

Virtual thread structure를 채택한 WAS의 테스트 결과

  도커에서 버전을 load-test로 변경 후 이미지만 변경해서 결과를 관찰했습니다.

  p(95)에서 2204ms이었던 WebFlux 로직에 비해서 p(95)가 1719ms로 500ms가량이 줄어있는 모습입니다. 개선 전의 상황이 0.5s나 더 빠른 응답 속도를 보였습니다.

  TCP 연결 지연 속도도 10ms로 WebFlux의 절반입니다.

  올바르게 연결돼 있고, 메모리 사용량은 1GB를 조금 넘는 수준입니다. WebFlux에 비해서 몇MB 정도 높습니다.

  CPU는 네트워크 I/O를 시도할 때 0.2까지 확 증가하는 모습을 보이지만, 웜업이 안된 상태이고, 이후에는 0.1정도의 사용량을 보입니다. Load Average는 1을 넘어 1.5 정도까지 도달했습니다. 컨텍스트 스위칭 비용은 WebFlux보다는 어느정도 있는 것 같습니다.

 

https://fastthread.io/

 

fastthread.io

Java Thread Dump Analyzer Elevate JVM troubleshooting with fastThread, your intelligent thread dump analysis companion. Discover the industry's first machine learning-guided tool for seamless Java thread dump analysis. Detect and resolve JVM thread issues

fastthread.io

 

  WebFlux가 응답 지연 시간이 더 좋지 못하게 나왔습니다. 기존에는 이벤트 루프 방식이 더 속도면에서 효율적이라고 생각했는데 말이죠. 이해가 잘 가지 않는 상황이라서, 스레드 덤프를 떠서 모니터링해보기로 했습니다. send가 진행되는 부분에서 다음과 같은 명령어로 스레드를 덤프했습니다.

for i in {1..3}; do
  jcmd 18087 Thread.print > thread_dump_$i.txt
  sleep 5

 

  이것을 fastthread.io로 가시화하고 RUNNABLE 상태인 스레드를 관찰했습니다. 그 중에 다음과 같은 스레드를 확인했습니다.

Runnable thread 중 하나인 pool-2-thread-5

 

스레드의 stackTrace를 간략하게 살펴보면 다음과 같습니다.

at sun.nio.ch.Net.poll : 현재 스레드가 네트워크 응답이 오기를 기다리며 Blocking된 상태.
...
at com.mysql.cj.jdbc.ConnectionImpl.commit(ConnectionImpl.java:794) : MySQL DB에 쿼리를 날리고 기다리는 상태
...
at org.springframework.orm.jpa.JpaTransactionManager.doCommit(JpaTransactionManager.java:553)
...

  WebFlux 로직에서 MySQL 조회로 인한 블로킹이 발생하고 있었습니다. 저는 부분적으로 WebFlux를 전환했기 때문입니다. 명확한 디버깅은 아니었지만, 제가 구조를 잘 파악하고 있지 못하고 있다는 생각이 들어서 기존의 구조와 지금의 구조를 명확하게 정리해야겠다고 판단했습니다.

WebFlux 기반 전체 Send 로직의 흐름

 

기존의 가상스레드를 이용한 전체 Send 로직의 흐름

  여기서 주목해야할 점은 데이터 리스트를 n건 생성하고 --> 클라이언트에서 해당 SSE 이벤트 메시지를 받을 때 까지 걸린 시간입니다. 결과에서는 200ms~300ms 차이가 났었는데, 구조도를 보면 WebFlux는 순차적으로 처리하는 반면에, 가상 스레드는 거의 병렬 처리에 근접한 방법으로 Socket Send-Q에 데이터를 넣습니다. 따라서 이벤트 루프 스레드가 일을 빠르게 처리한다고 해도, 처음에 들어오는 send 요청과 마지막에 들어오는 send 요청은 epoll()*n 만큼의 격차가 발생할 수 밖에 없습니다. 다시 말해서, 가상 스레드의 경우가 빠른 이유는 애플리케이션 레벨의 직렬 처리에 대한 병목 현상을 kernel 레벨로 옮겨서 처리해, 성능적인 이점을 챙긴 것입니다.

 

  그렇다면 이벤트 루프 모델에서 이벤트 루프 스레드 수를 늘려서 병렬로 처리하는 것은 어떨까요? 위의 WebFlux를 취하고 있는 구조에서 이벤트 루프 스레드를 하나 더 만들고, 이벤트 발행과 이벤트 루프 스레드 사이에 큐 자료구조를 하나 만들어서 병렬로 로직을 처리하는 것입니다. 이렇게 하면 기존의 1개의 플랫폼 스레드가 계속해서 태스크를 빼내왔던 것이 2개로 늘어서 수치적으로는 2배 빨라질 것입니다. 그럼 응답 지연속도 성능은 개선될 것입니다. 다만, 이것이 올바른 설계 방향일지는 의문입니다. 

 

  이벤트 루프 모델은 근본적으로 Lock-Free한 모델을 만들기 위해서 탄생한 모델입니다. Queue 하나를 공유해 접근할 때 경합이 발생하고, 이는 전체 응답 지연으로 이어집니다. 또한 이벤트 루프 스레드를 만드는데 제한을 두는 것은 안티 패턴으로 취급되는데, 이벤트 루프 스레드는 결국 플랫폼 스레드로, 더 생길 경우에는 컨텍스트 스위칭 비용이 점점 커지기 때문입니다. 또한 CPU 코어당 1개의 스레드를 권장하는데, 이는 스레드가 특정 코어에서 계속 실행되면 그 코어의 L1,L2 캐시에 데이터가 머물러 있어서 속도가 극대화되기 때문입니다. 스레드가 증가하면 캐시가 깨지는 Cache Invalidation 현상이 발생해서 성능 저하가 생길 수 있습니다. 또한 이벤트 루프 스레드를 늘려서 병렬 처리하겠다는 것은, 결국 나중에는 N개가 될 것이고, 이는 가상 스레드와 큰 차이가 없습니다. 오히려 가상 스레드가 비용이 훨씬 저렴하기 때문에 효율적일 수 있습니다.

잘못된 아키텍처 성능의 비교

  올바른 두 구조에 대한 비교는 어떤 것이었을까요? 저는 두 구조의 스프링 이벤트 발행 시점의 윗 부분은 필연적으로 Blocking이 발생할 수 밖에 없다고 생각했습니다. Retry 전략을 취해서 안정성을 취하고 있다고는 하나, DB에 올바른 내용이 보장이 되고 나서야 SSE 네트워크 이벤트가 전송되는게 도메인 상 올바르다고 생각했기 때문입니다. 즉, 외부 미들웨어에 대한 I/O는 동기식 순차 실행이 필수적이다라고 생각하고 구조를 개선했습니다. 그런데 이는 Netty를 취하고 있는 WebFlux의 구조에서는 옳은 설계가 아닙니다.

  

  WebFlux에서는 시작부터 끝까지 모든 부분이 비동기 처리가 되어야 의미가 있습니다. WebFlux의 설계 철학이 '적은 수의 스레드로 최대의 효율을 내는 것'에 있기 때문입니다. 중간에 동기 구간이 끼어들면 바로 병목 현상이 발생할 수 밖에 없습니다. 또한 WebFlux는 컨텍스트 스위칭을 최소화하여 CPU 캐시 효율을 최대화하는 것을 기본으로 한 모델입니다. 이 때문에, 이벤트 루프 스레드는 절대 끊기지 않고 일을 하고있는 것에서 의미가 있습니다.

 

  물론 WebFlux에서도 비동기 로직에 대한 안정성을 보장하는 로직 패턴이 있습니다. Saga, Outbound 패턴이 그 예시입니다. Network I/O는 일단 실패를 염두해두고 로직을 설계해야합니다. 이러한 부분에서 Retry 전략은 동기 방식에서의 올바른 전략이었습니다. 이러한 비동기 상황에서의 안정성을 보장하는 로직을 추가하면 DB I/O와 같은 미들웨어와의 소통 부분도 분명히 비동기 로직으로 전환할 수 있을 것입니다. 다만 비동기 패턴에서의 데이터 정합성 보장의 로직 설계는 그 복잡성이 큽니다. 그래서 미들웨어에 대한 I/O가 많은 로직에 대해서는 비동기로 변경하는 것보다 동기적인 방식으로 설계하는 것이 안정성과 유지보수 측면에서 옳을 수 있습니다.

 

  결론적으로 로직 전체를 개선하지 않고 부분적으로만 WebFlux의 비동기 방식을 채택한다면 스레드 간 작업 넘기기(Hand off) 비용만 추가적으로 발생합니다. 게다가 구조적으로 시스템만 복잡해지는 결과를 초래할 수 있습니다. 올바르게 아키텍처의 성능을 비교하려고 했으면 기존의 DB I/O를 모두 비동기로 바꾸고 동기인 부분이 없이 환전한 끊김없는 비동기 처리여야 했습니다.

Tomcat (virtual thread) 와 Netty (webflux), 기술의 선택

  아키텍처 선택을 해야합니다. 전체의 상황을 고려해야하는 것이 옳습니다. 저는 지금 Network I/O의 측면 만을 집중해서 보고있었습니다만, 제가 최초로 목표했던 것은 많은 CCU가 들어오는 상황을 어떻게 안정적으로 대처할 것인가 였습니다. 위의 과정을 통해서 기존의 로직을 WebFlux로 모두 전환하기에는 그 비용이 매우 크다는 것을 깨달았습니다. 기존 상황과 비교했을 때 그 비용을 감수해서라도 구조적인 변경을 해야하는가와 함께 프로젝트 마감일을 현실적으로 고려해야합니다. 기존의 구조보다 조금더 효율적일 수도 있지만, 기존의 구조는 매우 직관적이고 꽤 효율적이었습니다. 따라서 WebFlux로의 구조 변경은 아쉽지만 롤백하고, 기존의 가상 스레드 모델을 채택해서 안정적으로 많은 사람을 수용할 수 있도록 시스템 구조를 선택하려고 합니다.

 

❓Kernel의 Loopback

더보기

  컴퓨터가 자기 자신과 대화하기 위해서 사용하는 가상 네트워크 통로입니다. NIC를 거치지 않고 내부에서 데이터를 주고받는 매커니즘입니다.

 

  일반적인 네트워크 스택의 경로는 Application -> TCP/UDP -> IP -> NIC Driver -> Physical Hardware 순서로 내려갑니다. 하지만 루프백 드라이버는 IP 계층에서 목적지 주소가 루프백 범위인 것을 확인하면 데이터를 커널 내부의 수신 큐(Receive Queue)로 즉시 되돌립니다. 이를 통해서 하드웨어 처리 지연을 완전히 차단합니다.

 

표준 문서(RFC)에 따르면 루프백은 다음과 같이 정의됩니다

  • RFC 1122: 루프백 주소로 보내진 패킷은 절대로 호스트 외부(물리 네트워크)로 나가서는 안 된다고 명시.
  • 주소 체계: IPv4에서는 127.0.0.1, IPv6에서는 ::1이 표준 루프백 주소.
  • 범위: 127.0.0.0부터 127.255.255.255까지 전체가 루프백 용도로 예약.
반응형

'Test' 카테고리의 다른 글

[Test] 점진적 부하테스트: 50,000명 유저의 SSE 구독 유지  (1) 2026.03.03
[Test] 점진적 부하테스트: 10,000명 유저의 SSE 메시지 전송  (1) 2026.02.19
[Test] 점진적 부하테스트: 1,000,000명 유저의 알림 전송 요청 - 스레드  (1) 2026.02.10
[Test] 점진적 부하테스트: 10,000명 유저의 알림 전송 요청 - JPA  (0) 2026.02.06
[Test] 점진적 부하테스트: 30,000명 유저의 SSE 구독 요청 - TCP  (0) 2026.02.04
'Test' 카테고리의 다른 글
  • [Test] 점진적 부하테스트: 50,000명 유저의 SSE 구독 유지
  • [Test] 점진적 부하테스트: 10,000명 유저의 SSE 메시지 전송
  • [Test] 점진적 부하테스트: 1,000,000명 유저의 알림 전송 요청 - 스레드
  • [Test] 점진적 부하테스트: 10,000명 유저의 알림 전송 요청 - JPA
soberyl
soberyl
개발과 청춘 일지
    반응형
  • soberyl
    soberyl 님의 블로그
    soberyl
  • 전체
    오늘
    어제
    • 분류 전체보기 N
      • 프로젝트 N
        • Snap Trade N
        • 팀 프로젝트
        • 오픈 소스
      • 개발일지
        • 코딩 테스트
        • 알고리즘
        • CS
      • Full stack
      • Frontend
        • UXUI
        • React
        • Next.js
        • React Native
        • Monitor
      • Backend
        • NestJS
        • Spring
        • Monitor
        • DB
      • DevOps
        • CICD
      • Test
      • AI
      • Infra
        • AWS
        • On-premise
      • 청춘
        • 희곡
        • 연극
      • 잡설
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    thread
    희곡
    MySQL
    CPU
    OS
    io
    코테
    연극
    memory
    알고리즘
    java
    코딩테스트
    EC2
    최적화
    백엔드
    DB
    설계
    프론트엔드
    테스트
    거래소
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
soberyl
[Test] 점진적 부하테스트: 20,000명 유저의 SSE 메시지 전송 - WebFlux
상단으로

티스토리툴바