[Test] 점진적 부하테스트: 50,000명 유저의 SSE 구독 유지

2026. 3. 3. 18:19·Test

최대 동시 접속자 증가

  최초에 상정했던 동시 접속자 수는 37만여명입니다. 3만명의 환경에서 로컬호스트 테스트의 한계를 느낀 바 있습니다. 저는 m7i-flex.large 모델을 사용하고 있습니다. CPU 코어 2개에 8GB RAM 사이즈를 가지고 있는데, 여기서 RAM이 중요합니다. RAM의 4GB를 JVM이 사용하고 있고, 4GB를 DB(MySQL)에서 사용하고 있습니다. DB가 특히나 RAM을 많이 사용합니다(InnoDB Buffer pool 등). 그리서 RAM의 절반정도인 4GB를 최소로 가지고 있어아합니다. 따라서 4GB 정도가 JVM에게 할당해줄 수 있는 현 상황에서의 최대값입니다. 테스트에서 일관되게 보였던 SSE Emitter 연결 1개당 필요한 RAM의 필요한 메모리 사이즈는 130KB입니다. 그러면 현 상황에서 물리적으로 가능한 연결 가능한 유저 수는 3만명을 넘

지 못합니다. (30000*130KB = 3.9GB)

 

  생각할 수 있는 방법으로는 1. SSE 연결 비용에 필요한 메모리 양 최적화와 2. RAM 자체의 스케일 업 입니다.

 

  먼저 SSE 연결 비용에 필요한 메모리 양을 줄이는 방법입니다. SSE 연결이 맺어지면 JVM에는 해당 객체 하나당 다음과 같은 내용들이 포함됩니다.

  1. SseEmitter 객체 및 핸들러
  2. 서버가 메시지를 보낼 때 데이터를 임시적으로 담아두는 Output Buffer
  3. 메시지를 보낼 때 생성되는 임시 데이터들이 연결별로 남아있을 수 있습니다. (Jackson 캐싱 등)

  이 영역들은 Eden에 속하지 않고 Old에 속합니다. SSE는 연결이 끊기지 않고 수 분, 수 시간동안 유지되는 연결이기 때문입니다. 이때 생기는 연결마다 JVM 뿐만 아니라 커널에서도 메모리 할당이 생깁니다.

  1. 커널 레벨에서 각 소켓마다 rmem(수신), wmem(송신)을 위해 할당하는 Socket Buffer (sk_buff)

  유저의 네트워크가 막히면 커널 버퍼가 꽉 차게되고, 그 다음 JVM의 Output buffer가 차오르기 시작하면서 메모리 양이 130KB 이상으로 치솟게 될 수도 있습니다.

 

  이 구조를 알게됨으로써 바로 보이는 방법은 버퍼 사이즈를 최소화하는 것, 중복 JSON 직렬화 없애기,  커널 소켓 메모리 할당량 줄이기 등이 있을 것 같습니다. 가상 스레드를 사용해도 스레드 별로 Stack이 개별적으로 생성되기 때문에 이 부분도 최적화가 필요합니다.

C370K에 대한 필요 메모리 사이즈

  일반적으로 버퍼 사이즈는 다른 필요한 메모리에 비해 크므로, 버퍼 사이즈를 조절해서 연결 당 메모리 필요량을 최적화한다면 많은 메모리 확보를 할 수 있을 것 같습니다. 톰캣의 서블릿 기본 출력 버퍼 크기를 줄이고, 깊은 메서드 호출 (Deep Stack)을 제한해서 Stack 메모리 사용을 줄이고, 커널 레벨에서 파라미터를 튜닝해서 소켓 메모리 사용량을 제한한다면 효과적일 것입니다. 만약 130KB를 차지하고 있던 연결에 필요한 메모리 사이즈를 50KB로 줄인다고 한다면 37만 명 접속 시 약 19GB의 RAM으로 수용이 가능해집니다. 기존의 130KB에서는 48GB가 필요한 반면에 말이죠.

 

  아무리 최적화를 해도 JVM 4GB에서 수용가능 한 유저 수는 5만명 남 짓일 것입니다. 이 부분도 고려를 한다면 여러 컴퓨터의 리소스를 사용해야한다는 점은 명확합니다. 복수 개의 PC를 사용한다고 한다면 TCP 세션 등의 사용자 간 커넥션 정보를 어떻게 저장해놓아야 할까요?

 

  저는 최초에 "해당 연결 정보를 한 인스턴스에 몰아넣고 서버에서 SSE 연결에 대한 로직을 처리할 때, 해당 연결 정보를 참조해서 처리한다"는 방법을 생각했습니다.

최초에 생각했던 메모리 분산 구조

  이 아키텍처는 큰 문제를 가지고 있었습니다. 바로 TCP 소켓을 외부 저장소에 저장 해놓는 방식이 불가능하다는 점입니다. TCP 소켓은 물리적인 파이프로, 단순히 회원 정보 같은 텍스트 데이터가 아니어서 현재 클라이언트와 패킷을 주고받고 있는 동적인 I/O 스트림입니다. 한편 Redis와 같은 자료구조는 문자열, 숫자, 바이트 배열과 같은 정적인 데이터를 저장하는 곳으로 이 TCP 소켓을 바이트로 직렬화해서 Redis에 밀어 넣고 필요할 때 꺼내서 사용한다는 것은 물리적으로 불가능하다는 점입니다. 또한 클라이언트가 WAS에 접속하면 해당 WAS가 띄워진 리눅스 커널은 네트워크 소켓을 열고 FD를 할당합니다. 이 FD와 커널 버퍼는 오직 그 클라이언트와 물리적으로 연결된 바로 그 WAS의 OS 커널 메모리에 존재해야만 합니다. 따라서 위의 구조는 물리적으로 불가능합니다. 또한 가능하다고 해도, 이는 고가용성이 결여된 구조라서, 레디스를 돌리고 있는 PC가 죽으면 SSE 처리를 완전히 못하는, 장애 상황을 피할 수 없는 상황이 연출됩니다.

 

  연결 정보 자체는 OS와 연결이 되어있어야 한다는 결론에 도달했습니다. 즉, 복수의 서버에 JVM 위에다가 연결 정보를 분산시켜서 저장을 해놓아야 한다는 뜻입니다. 그렇다면 그냥 똑같은 서버를 2대 만들어서 각각에 분산 시켜서 연결 정보를 저장해서 운영하는 방법은 어떨까요?

단순하게 메인 WAS 2개로 분리하는 방식.

  사용자의 구독 요청이 들어오면 앞단의 로드 밸런서가 요청을 단순한 RR(Round-Robin) 방식으로 나눠서 메인 WAS 2대에 나눠서 보내게 됩니다. 모든 요청은 로드 밸런서를 통하게 되므로, 알림 로직을 포함한 모든 로직이 RR에 의해 스케줄링에 의해 분산돼서 WAS로 향하게 됩니다. 이때 SSE 연결 정보를 활용하는 로직이 들어오고 물리 서버 1로 요청이 보내졌는데 해당 TCP 연결이 없을 경우 메인 서버 2에게 요청을 전달합니다. 전달된 요청이 물리 서버 2에 존재하면 처리하고, 없다면 Broken pipe error 처리를 합니다. 반대의 경우도 같습니다. 서버 간의 통신을 다이렉트로 할 경우 다른 자료구조를 거치지 않으므로 매우 빠르게 서버 간 통신을 처리할 수 있습니다. 다만, 이 경우에도 논리적인 결함이 존재합니다. 제한적인 스케일 아웃과 알림 기능 만을 위한 처리라는 점입니다.

 

  여러 서비스의 도메인에는 핵심적인 기능이 존재합니다. 이커머스 플랫폼이라면 주문과 결제 등이 그 예시겠죠. 실시간 통신이 중요한 플랫폼도 존재합니다. 채팅이 중요한 서비스 등이 있을 것입니다. 제가 개발하고 있는 이커머스 플랫폼에서 위와 같은 형태를 취할 경우에 C100K에 상황에는 메인 WAS에서 메모리를 에미터 정보를 제외한 1GB 메모리에서 모든 WAS 로직을 처리해야합니다. 그런데 이커머스에서 가장 중요한 기능인 주문 및 결제 기능에서 고작 1GB의 메모리로 로직 처리가 가능할까 생각해보면, 무리라는 결론에 다다릅니다. 당장에 JPA를 쓴다는 전제 하에 상품을 주문할때 사용자 정보, 상품 N개의 정보, 주문 정보 등의 객체를 주문과 결제가 완료될 때 까지 메모리에 올려놓아야 합니다. 특히나 외부 API의 통신이 지연되는 상황 (PG사 연결 등)에서는 요청이 완료될 때 까지 메모리 위에 적재된 상태로 존재할 텐데, 보통 외부 API는 느리면서도 최적화가 매우 어렵기 때문에 동시에 많은 인원수가 주문 및 결제를 진행할 경우에는 많은 메모리를 사용하게 됩니다. 요는 현 도메인의 부가적인 기능인 알림 기능을 위해서 주문, 결제와 같은 중요한 기능이 작동할 틈을 못주는, 배보다 배꼽이 큰 상황이 된다는 것입니다. 또한 위와 같은 형태의 경우, 모든 WAS를 연결해 놓아야 하는데 이는 n개의 서버일 경우 n Combination 2의 경우의 수를 따릅니다. 스케일 아웃을 더 하고 싶어서 PC를 증설할 수록, 통신망 설계가 매우 많아지고 복잡해집니다. 이를 Mesh Network 문제라고 합니다. 심지어는 Spring MVC를 복수로 생성할 경우에는 다른 메인 WAS의 응답을 기다리는 블로킹 타임도 추가로 생기게 되는데, 서버가 많아질 수록 블로킹 타임도 늘어나서 정작 원활한 서버도 느려지는, 장애 전파 현상이 생길 수도 있습니다. 이를 Cascading Failure라고 합니다.

 

  따라서 위의 메인 WAS를 2개 띄워서 처리하는 방식은 간단하는 점을 제외한다면 나머지 부분이 너무나도 아쉽습니다. 위의 문제점을 정리해서 나열하면 다음과 같습니다.

  1. 같은 기능의 서버를 API로 연결해 놓는 작업은 스케일 아웃 비용이 매우 커지고 블로킹 시간도 비례해서 커진다.
  2. 도메인에 따라서 다르지만, 현재 구현 중인 이커머스 서비스에서 서브 기능인 알림 기능이 메인 WAS의 중요 기능(주문, 결제 등)에 필요한 리소스까지 다 점유해버리면 주객전도이다.

   이러한 문제들을 해결한 아키텍처가 아래와 같은 형태입니다.

단순 레플리카 서버 2대를 두는 방식을 해결한 메인 WAS와 알림 WAS의 분리.

  먼저 알림 기능을 담당하는 서버가 생겼습니다. 또한 이들을 중계하는 Redis가 생겼는데요, 각각 2번과 1번 문제를 해결합니다.

 

  알림 기능을 분리해서 서버를 띄웁니다. 이는 SSE의 메모리를 매우 많이 사용한다는 점에서 다른 메인 기능 들의 올바른 동작을 고려한 부분이 되겠습니다. 별도의 서버로 기능만 분리해서 띄우게 되면 메인 WAS의 RAM을 걱정할 필요 없이 알림 WAS에서 해당 요청을 처리할 수 있게됩니다. 또한 다른 기능이 없으므로 RAM도 해당 네트워크 연결 비용 만을 위해서 사용할 수 있어서 로직도 단순해집니다. 메인 WAS를 거치지 않으면 '알림 기능을 꺼놓은 유저인가?' 등의 복잡한 판단을 알림 WAS가 처리해야하는데, 알림 WAS를 분리해놓은 이유인 "SSE 연결 정보만을 저장해서 메모리 사용을 최적화한다."는 목표에 위배됩니다. 알림 서버는 DB와 로직을 모르는, 순수 네트워크 I/O만을 진행하는 상태여야 가장 효과적으로 SSE 연결을 해놓을 수 있습니다.

 

  다른 추가된 부분이 메시지 브로커인데요, 이 메시지 브로커가 1번의 문제를 해결해줍니다. 이 메시지 브로커 역할인 Redis는 메인 WAS에서 알림 전송이 될 타이밍에 Redis에게 던져주고 Redis는 이것을 알림 서버에게 브로드케스트합니다. 하나의 중앙 메시지 컨트롤러가 기능을 수행하라는 메시지를 전달하니, 서버 간의 너무 복잡한 네트워크 파이프 문제인 Mesh Network 문제가 해결되는 것입니다.

 

  여기서 단순히 메인 WAS의 명령을 받고 브로드케스팅하는 역할이라면 굳이 Redis를 사용하지 않고, Queue와 같은 자료구조를 최소한의 기능과 함께 직접 정의하는 것이 나을 것이다라는 생각을 했습니다. DB 서버와 같이 서버를 띄운다면 특히 RAM이 중요한데, RAM을 최소한으로 사용할수 있다면 매우 좋으니까 말이죠. 혹은 그냥 바로 메인 WAS에서 알림 WAS로 브로드캐스팅하는 방법도 떠오릅니다. 다만 그렇게 상황은 녹록치 않습니다.

메인 WAS의 직접 브로드캐스팅 vs Redis를 통한 브로드캐스팅

  먼저 메인 WAS가 직접 알림 WAS들에게 브로드캐스팅하는 경우를 살펴봅시다. 해당 경우에는 알림 WAS의 서버 대수가 k개라면 k*connection을 생성해놓아야 합니다. 특히 알림 WAS 중 1개가 모종의 장애 상황으로 timeout이 발생한다면 해당 메시지 전달을 진행한 메인 WAS의 스레드 (톰캣이라면 worker 스레드)가 timeout 시간만큼 블로킹됩니다. 트래픽이 몰릴 경우에는 이러한 점이 치명적으로 다가올 수 있습니다. 이런 강한 결합에서 나오는 문제를, 중간에 모종의 자료구조를 넣어서 느슨한 결합으로 만들어서 해결할 수 있습니다.

 

  그럼 그 사이를 제가 직접 정의한 최소한의 기능이 들어간 Queue를 사용해서 연결하는 것은 어떨까요? 이 과정은 그리 쉬운 작업이 아닙니다. 결국 다른 PC 간의 연결과 동기화 구조를 제가 직접 짜야한다는 말과 같기 때문입니다. 데이터 직렬화, TCP/UDP 소켓 연결 관리, 서버 상태에 따른 Retry 처리 로직, 동시에 여러 서버가 접근할 때의 동기화 등이 그 예입니다. 자료구조를 공유하는 상황에서는 동시성 문제가 필수적으로 다루어지는데, 필요한 기능이 적어도 구현해야할 일이 많습니다. 여기서 Redis는 C 언어로 커널 레벨에서 공유 자료구조로서의 기능을 최적화 해놓았고, 복잡한 네트워크 로직도 이미 효율적으로 구현해놓았습니다. 따라서 Redis를 사용하는 것이 시스템 안정성에서 합리적일 것 같습니다.

 

  Redis 말고도 메시지 큐 소프트웨어는 많이 있습니다. RabbitMQ, Kafka 등이 그 예시인데요, 저는 Redis를 사용하기로 했습니다. 그 이유는 아래의 표를 비교한 결과로 알 수 있습니다.

비교 항목 Redis (Pub/Sub) RabbitMQ Kafka
핵심 철학 초고속 브로드캐스트 메시지 전달 보장 분산 로그 저장소 (Event stream)
전달 방식 Fire-and-Forget Smart Broker Dumb Broker
메시지 영속성 메모리 저장 X 디스크 저장 가능 기본 수 일간 로그로 저장
처리속도 최상 보통 높음 (Throoughput이 중요)
리소스 사용량 매우 가벼운 수십 MB 무거움 (Erlang VM 기반) 매우 무거움 (JVM 기반)

 

  실시간 알림을 구현하고 있기 때문에 속도가 빠르고, 다른 정보와 비교해서 영속성이 필요없는 알림 기능이기 때문에 단순한 인메모리 기반이면서 매우 빠른 지연 속도의 소프트웨어인 Redis를 채택하겠습니다.

 

시나리오 4: 30,000명의 유저의 SSE 연결을 유지한다.

  이전에 3만명으로 간당간당하게 수용해본 적이 있기 때문에, 바로 커널 튜닝과 톰캣 튜닝을 해서 전후를 비교해보겠습니다. 커널 최적화를 진행하는데, 이는 3개의 튜닝이 있습니다.

C370K에 대한 필요 메모리 사이즈

server:
  servlet:
    context-path: /api
  tomcat:
    mbeanregistry:
      enabled: true
    max-connections: 33000
    accept-count: 10000
    threads:
      max: 400

  위 코드는 기존의 WAS의 톰캣 설정입니다. 이를 알림에 맞게 수정할 것이기 때문에 기존의 메인 WAS의 설정을 보관해두겠습니다.

 

  1. 커널 소켓 버퍼 튜닝

  TCP 송신, 수신 버퍼를 조회해보겠습니다.

  위의 값은 차례대로 최소, 기본, 최대 값이며 단위는 Byte입니다. wmem(송신 버퍼)은 서버가 유저에게 알림을 보낼 때 사용하는 메모리 공간이고, rmem(수신 버퍼)은 유저가 서버로 요청을 보낼 때 임시로 담아두는 공간입니다.

 

  131072B = 128KB, 16384B = 16KB이므로 소켓 1개당 합계가 144KB입니다. 이 상태에서 1만명을 수용한다고 하면 144KB * 10000 = 1.44GB이므로 3만명을 넘길시에 OOM이 터지게됩니다.

  

  SSE 통신의 특성을 이용해서 이 설정을 줄여보겠습니다. SSE는 서버가 클라이언트에게 데이터를 일방향적으로 푸시하는 방식입니다. 클라이언트는 연결 요청 이후에 서버로 데이터를 거의 보내지 않습니다. 그럼에도 128KB를 차지하고 있다는 것은 메모리 누수로 보입니다. 또한 송신 버퍼의 내용은 애초에 로그로 확인해봤을 때, 500Byte를 넘긴 적이 없습니다. 즉, Payload가 매우 가벼운 편입니다.

과거에 관찰했었던 Send-Q 로그. 지금도 500Byte는 넘긴 적이 없다.

  따라서 tcp_wmem (송신 버퍼)를 기본 값인 4096으로 수정하고, tcp_rmem (수신 버퍼)도 4096으로 조정하겠습니다. 이는 지금만 임시적으로 설정해놓고, 메인 WAS와 알림 WAS를 분리했을 때는 메인에는 원래 값을 다시 설정해 줄 계획입니다. 여기서 500 Byte를 넘기지 않으니 그냥 700 Byte로 설정해 놓으면 될것 같지만, 리눅스 커널의 메모리 관리 최소 단위, 즉 1 Page는 4KB입니다. 리눅스 파일 시스템은 블록 단위로 파일을 관리하고, 이 값이 그 최솟값이기 때문에 700 Byte를 할당하려고 해도 4KB가 결국은 할당되므로, 4KB가 가장 효율적인 설정입니다. 이를 적용해보겠습니다.

  이렇게 하면 단순 계산으로 128KB가 4KB, 16KB가 4KB로 줄어드므로 144KB를 차지하던 연결이 8KB만 차지하게 됩니다.

 

  2. 톰캣 튜닝

  5만명이 서버에 접속할 수 있도록 max-connections를 안정적으로 6만으로 설정하고, accept-count를 2만으로 설정하겠습니다. 또한 아웃풋 버퍼를 튜닝할 수 있습니다. 다만 톰캣의 NIO 커넥터는 하나의 연결당 8KB으로 할당해놓는데, 이 수치는 400MB입니다. 서비스에서 SSE 통신을 위해서 보내는 메시지의 크기가 매우 낮기 때문에 절반 아래로 낮춰도 문제가 없을 것입니다. ("data: { message: "새로운 알림이 도착했습니다!" }\n\n" 와 같은 형태입니다.) 다른 수치에 비해서는 그렇게까지 큰 것 같지는 않으니 테스트를 돌려보고 필요하면 수정에 들어가도록 하겠습니다.

 

  3. 기타 새로운 인스턴스에 대한 점검

  마지막으로 파일 디스크립터 상한 수나, SYN Backlog (SYN Queue) 등의 환경을 튜닝하겠습니다.

SYN Queue 튜닝
확인한 FD 상한선. 매우 많다.
JVM에 할당해준 메모리. 4GB에서 5GB로 늘렸다.
K6의 5만 vus를 위해서 늘려준 도커 VM 메모리 할당량. 기존엔 8GB였다.

  Docker VM 메모리 할당량이 기존에는 8GB였는데, 5만 vus를 설정하니 애초에 initialize도 안 됐습니다. 이제는 클라이언트의 역할만 수행하므로 그냥 12GB로 늘려주었습니다.

docker run --rm --privileged --net=host alpine sysctl -w net.ipv4.ip_local_port_range="1024 65535"
-> net.ipv4.ip_local_port_range = 1024 65535
docker run --rm --net=host alpine sh -c "ulimit -n"
-> 1048576

  도커 가상머신 자체의 포트 제한과 FD 제한도 수정해줍니다. FD는 기본으로 10만이 넘어서 수정이 불필요했습니다.

docker network prune -f

  서버를 재시작할 때 네트워크 부분은 잘 지우지 못했다고 로그가 나오는데, 이때 위의 명령어를 통해서 제거해주면 편합니다.

 

Docker VM의 K6의 한계

  C28K를 넘지 못해서, 문제 파악에 시간을 많이 소모했습니다. 결론적으로 Linux 환경에서 부여할수 있는 기본 포트 범위가 28K정도 되는데, 제가 docker compose로 설정을 부여해도 이 범위를 넘지 못한 것 같습니다. 정확한 이유는 파악하지 못했으나, 테스트를 위해서 Docker에서 돌리던 K6를 로컬 환경으로 옮겨서 테스트를 진행해보겠습니다.

 

C30K에서 3.26GB를 사용하는 지금 상황.
이전에 진행했었던 C30K. 접속량이 C28K였는데 메모리는 3.45GB이다.

  로컬 환경으로 돌리니 드디어 C28K가 뚫렸습니다. 몇 개의 dial tcp timeout이 발생했었습니다만, 50여개의 커넥션이 오류가 난 것이라서, 일단은 성공으로 볼수 있을 것 같습니다. 또한 커넥션 당 필요 메모리인데요, 3.2GB - 9.1MB / 30000 = 113KB입니다. 소켓 버퍼를 최적화 한다고 했으나 해당 부분은 커널 영역에서 최소화 한 것이어서 JVM에서는 관찰되지 않는 결과인 것 같습니다.

 

  로직 중에 1개는 실패했으나, Eden이 차오르는 것을 고려하면 지금 상황이 마지노선인 것 같습니다. 원래는 모놀리스 서버에서 5GB까지 버텨보려고 했었는데요, 이제는 분산 서버에서 C25K만큼씩 담당하여 C50K를 버틸 수 있도록 설계를 바꿔보겠습니다.

 

시나리오 5: 50,000명의 유저의 SSE 연결을 유지한다.

  알림 WAS로 기능을 분리한 채 메시지 브로커를 도입해서 분산 아키텍처를 구축해야합니다. 이를 위해서 EC2 인스턴스를 증설해야합니다. t 시리즈는 CPU 크레딧 시스템을 사용하기 때문에 CPU를 갈아넣는 작업에 대해서는 순식간에 소진돼 부하 테스트 시에는 성능이 급격하게 안 좋아질 수 있습니다. 따라서 일정한 연산과 네트워크 안정성을 위해서 약간 더 비싸지만 안정적인 대역폭을 제공하는 c 시리즈를 WAS에 사용하고, 메모리 집약적 성격을 가지는 DB 서버에는 m 시리즈를 사용하려고 합니다. 스팟 인스턴스 옵션을 켜놔서 장시간 사용하지 않을 경우에 좀 더 저렴하게 모델을 사용하려고 합니다.

역할 사용할 AWS 모델 역할 상세 및 스팩
메인 WAS c5.large 비즈니스 로직 처리, Redis에 메시지 전송 / 2 Core & 4GB RAM
Redis + DB m5.large 영속성 데이터 저장, 메시지 브로커 / 2 Core & 8GB RAM
알림 WAS #1 c5.large 알림 로직 처리, SSE 연결 정보 저장 / 2 Core & 4GB RAM
알림 WAS #2 c5.large 알림 로직 처리, SSE 연결 정보 저장 / 2 Core & 4GB RAM

 

  다만 중간에 문제가 생겼습니다. 현실적인 금전적인 문제가 발생해서 모델을 압축할 수 밖에 없었습니다. 따라서 아래와 같이 인스턴스를 수정했습니다.

역할 사용할 AWS 모델 역할 상세 및 스팩
메인 WAS + Redis + DB r6i.large 비즈니스 로직 처리, Redis, MySQL / 2 Core & 16GB RAM
알림 WAS #1 m7g.medium 알림 로직 처리, SSE 연결 정보 저장 / 1 Core & 4GB RAM
알림 WAS #2 m7g.medium 알림 로직 처리, SSE 연결 정보 저장 / 1 Core & 4GB RAM

 

  해당 EC2에서 필요한 WAS, DB, Redis 등을 도커 이미지를 통해서 띄웁니다. 알림 서버가 DB와 Redis를 볼 수 있도록 포트를 열어줬습니다.

 

  이제 메인 로직을 수정해야합니다. 기존 로직은 애플리케이션 내에서 이벤트를 발행해서 트랜잭션을 분리하는 방식이었다면, 이제는 DB 로직이 완료된 후에 Redis에 해당 이벤트를 저장하고, Redis가 이를 Broadcast하는 방식으로 로직을 개선해야합니다.

        for (int attempt = 1; attempt <= maxRetries; attempt++) {
            try {
                LocalDateTime now = LocalDateTime.now().minusSeconds(1);

                // DB Insert
                memberNotificationJdbcRepository.partitionInsert(templateId, variables, start, end);

                // 실시간 메시지 객체 생성
                List<NetworkMessageDto> chunkData =
                        memberNotificationJdbcRepository.findNetworkMessageDtoByRange(templateId, start, end, now);

                // SSE Send 이벤트 발행
                if (!chunkData.isEmpty()) {
                    notificationPublisher.publish(chunkData);
                }

                return;

            } catch (Exception e) {
                ...
            }
        }

  기존의 코드입니다. 여기서 SSE Send 이벤트를 발행할 때, 애플리케이션 내부에서만 진행하는 것이 아니라 Redis로 이벤트를 전송해야합니다. 또한 알림 이벤트를 생성하는 로직은 main was에서만 진행되어야 하고, event를 수신해서 sse 로직을 처리하는 부분의 경우는 알림 서버에서만 처리해야합니다. 이를 프로필을 이용해서 구현하겠습니다.

public interface NotificationPublisher {
    void publish(List<NetworkMessageDto> data);
}

@Slf4j
@Component
@Profile("main")
@RequiredArgsConstructor
public class RedisNotificationPublisher implements NotificationPublisher {
    private final StringRedisTemplate stringRedisTemplate;
    private final ObjectMapper objectMapper;

    @Override
    public void publish(List<NetworkMessageDto> data) {
        if (!data.isEmpty()) {
            String jsonString = objectMapper.writeValueAsString(data);
            stringRedisTemplate.convertAndSend("notification-topic", jsonString);
        }
    }
}

@Component
@Profile("!main")
public class DummyNotificationPublisher implements NotificationPublisher {
    @Override
    public void publish(List<NetworkMessageDto> data) {}
}

 

  이렇게 인터페이스를 정의하고, notification인 경우와 아닌 경우에 따라서 다른 로직이 처리되도록 합니다. 프로필이 notification이 아닌 경우에는 아무것도 하지 않도록 더미로 정의합니다. 이후에 아래와 같이 로직을 작성한다면 프로필에 따라서 수행 여부가 자동으로 결정될 것입니다.

        // SSE Send 이벤트 발행
        if (!chunkData.isEmpty()) {
            notificationPublisher.publish(chunkData);
        }

  이벤트를 Redis에 넣었다면, 이 이벤트를 받았을 때의 행동도 정의해야 합니다.

@Slf4j
@Service
@RequiredArgsConstructor
@Profile("notification")
public class RedisMessageSubscriber implements MessageListener {

    private final NotificationNetworkService notificationNetworkService;
    private final ObjectMapper objectMapper;

    @Override
    public void onMessage(Message message, byte[] pattern) {
        try {
            String jsonMessage = new String(message.getBody());

            List<NetworkMessageDto> messageList = objectMapper.readValue(
                    jsonMessage,
                    new TypeReference<List<NetworkMessageDto>>() {}
            );

            notificationNetworkService.send(messageList);

        } catch (Exception e) {
            log.error("메시지 처리 중 에러 발생: ", e);
        }
    }
}

  이제 Subscriber를 만들었습니다. 이는 Redis를 구독하고 있는 서버인 알림 서버만 존재하면 되는 설정입니다. 이 설정을 가진 WAS가 브로드케스트를 받을 것입니다. 이 레디스의 메세지를 파싱해서 읽고 해당 에미터로 Message를 전송하는 로직을 수행하면 흐름은 끝이 납니다.

  

  이제 어떤 채널을 감시할 것인지에 대한 config 파일을 선언하면 끝입니다.

@Profile("notification")
@Configuration
public class RedisConfig {
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);

        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new GenericJackson2JsonRedisSerializer());

        return template;
    }
    
    @Bean
    @Profile("notification")
    RedisMessageListenerContainer container(RedisConnectionFactory connectionFactory,
                                            MessageListenerAdapter listenerAdapter) {
        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
        container.setConnectionFactory(connectionFactory);
        container.addMessageListener(listenerAdapter, new ChannelTopic("notification-topic"));
        return container;
    }

    @Bean
    MessageListenerAdapter listenerAdapter(RedisMessageSubscriber subscriber) {
        return new MessageListenerAdapter(subscriber, "onMessage");
    }
}

  notification-topic이라는 이름의 채널을 구독하고 감지되면 onMessage를 호출하도록 설정합니다.

 

  마지막으로 도커 컴포즈 파일에 프로필 설정을 합니다. 메인 WAS에는 main으로 프로필을 설정합니다. 이때 프로필 여부가 설정이 안된 로직의 경우는 main과 notification was 모두에서 돌아가는 로직이 될 것 입니다.

docker-compose.yml 옵션 추가

  이제 로드 밸런서를 앞단에 달아줘서 api 경로에 따라서 처리가 분기되도록 설정할 것입니다. 모든 구독 처리는 알림 서버를 향하게 하고, 나머지에 대해서는 main was를 통하도록 설정합니다. main was에서 send에 대한 처리가 발생할 경우 redis Queue에 넣도록하고, 이 이벤트가 알림 서버에 브로드케스트 되도록하는 것입니다. 로드 밸런서는 RR으로 스케줄링하는 것이 기본 값입니다.

API Gateway 설정

  *보안 그룹에 추가가 Redis용 포트가 안 돼있어서 추가해줬습니다.

도커에 띄우면 redis라고만 적어도 인식한다. 환경 옵션에 추가가 필요하다.
Health check도 정상임을 늘 확인해야한다.

 

  이제 프로메테우스로 알림 서버 2개의 상황을 모니터링하면 설정이 끝나서, 부하 테스트를 진행할 수 있게됩니다! 기본적으로 main-was가 외부로 뚫려있기 때문에 main-was를 통해서 notification-was 2대를 관찰해야하는데,  다음과 같이 터미널에서 각각을 실행합니다.

# 서버 1의 8080 포트를 내 컴퓨터의 8081로 연결
ssh -i "key.pem" -L 8081:10.0.3.200:8080 ubuntu@메인_서버_공인IP

# 서버 2의 8080 포트를 내 컴퓨터의 8082로 연결
ssh -i "key.pem" -L 8082:10.0.3.201:8080 ubuntu@메인_서버_공인IP

 

  그리고 Actuator가 401 에러가 나오지 않도록, 외부에 공개해줍니다.

server:
  forward-headers-strategy: native

 

  그냥 ALB에 요청을 해버리면 context-path: /api 속성이 먹히지 않습니다. 따라서 forward-headers-strategy를 native로 설정해서 진행하면 잘 동작합니다.

 

그라파나에서 관찰되는 2개 PC의 리소스.

  이제 모니터링 환경도 세팅 완료했습니다. 분산 서버로 테스트를 정말로 진행해보겠습니다.

최초의 서버 상태

 

WARN[0016] Request Failed error="Post \"https://[ALB-이름].ap-northeast-2.elb.amazonaws.com/api/noti/send/inspect\": tls: failed to verify certificate: x509: certificate is valid for [도메인 이름], not [ALB-이름].ap-northeast-2.elb.amazonaws.com"

 

  k6에서 테스트 할때 ALB의 주소 자체로 접근해서 API를 요청하고 있는데, 인증서에서는 도메인 이름으로 되어있기 때문에 서로 달라서 k6에서 경고하고 있는 것입니다. 따라서 insecureSkipTLSVerify: true를 k6 options에 추가해서 이름이 달라도 사용자가 의도적으로 한 것이니 그냥 처리하라고 명시해서 해결합니다.

스트레스 테스트 결과

요청이 나눠져서 들어가는 상황.

 

  하나의 서버에서 15000개 정도의 커넥션을 받고, 총 3만개 정도의 커넥션을 유지하고 있습니다. 시나리오에서 목표하던 5만개의 목표는 실패했습니다. 

MSA 전환에서의 단계적 테스트의 필요성

  많은 사용자를 수용하기 위해서 서버를 기능적으로 분산시켰지만, 한계가 보였던 것 같습니다. 특히 3만에서 5만으로 넘어가지 않는 이유를 찾기가 힘들었는데, 너무 스케일이 큰 상황에서 기술적인 전환을 시도하니까 디버깅이 힘들었던 것 같습니다. 맨 처음인 100 까지는 내려가지 않겠습니다만, 다시 천명에서부터 테스트를 실행해봐야겠다는 필요성을 느꼈습니다.

 

  또한 기능의 분산에서 레디스와 같은 자료구조와 데이터 영속성의 방법 등, 다양한 아키텍처와 응하는 기술들이 많았습니다. 도메인에서 해당 기능의 중요성이나 비용, 또 어떤 도메인에서의 기능인가 등의 총체적인 고려가 필요했습니다. 이 알림 기능이 그렇게까지 무거운 인프라(분산 서버, 레디스 브로드캐스트)를 감당할 만큼 도메인적으로 핵심 가치가 있는지를 고려하고, 너무 무겁다면 단순한 폴링으로 바꾸는 것도 유지 보수와 확장성 측면에서 더 유리할 것입니다.

 

  금번의 테스트에서는 항상 3만 VUs에서 실패를 한 것으로 보아서 네트워크에서 오는 한계로 보여집니다. 클라이언트 측에서는 가상 유저 생성에 대한 부분에 대해서 튜닝을 올바르게 마쳤고, 서버에서는 CPU나 RAM 자원이 모두 꽉차게 사용되지는 않았기 때문입니다. 또한 3만명에서 계속해서 네트워크 자체의 병목이 발생했는데요, 주변에서 네트워크를 이용하는 사람들이 사용을 못했던 것으로 보아, 3만명이 테스트의 한계점인 것 같습니다. 실제 유저는 다양한 네트워크 환경에서 다양하게 요청을 보냅니다. 따라서 네트워크 병목은 어느정도 해소가되지 않을까 싶고, 더 많은 SSE 구독 연결을 유치하기 위해서는 분산 서버의 개수를 늘리거나, 서버 자체의 스펙을 키우는 방법으로 37만명의 연결을 유지하면 되지 않을까 싶습니다. 이번 테스트가 그 초석을 마련할 수 있는 기반이 된 것 같습니다. 비록 테스트는 실패했지만, 많은 지식과 경험을 얻은 것 같아요.

 

  여기까지 분산 환경으로 진행하는 테스트를 마치고, 다음 번에는 다른 테스트로 부하 테스트를 진행해보겠습니다!

반응형

'Test' 카테고리의 다른 글

[Test] 점진적 부하테스트: 20,000명 유저의 SSE 메시지 전송 - WebFlux  (2) 2026.02.20
[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] 점진적 부하테스트: 20,000명 유저의 SSE 메시지 전송 - WebFlux
  • [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
      • 청춘
        • 희곡
        • 연극
      • 잡설
  • 블로그 메뉴

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

  • 공지사항

  • 인기 글

  • 태그

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

  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
soberyl
[Test] 점진적 부하테스트: 50,000명 유저의 SSE 구독 유지
상단으로

티스토리툴바