[Test] 점진적 부하테스트: 30,000명 유저의 SSE 구독 요청 - TCP

2026. 2. 4. 22:46·Test

이전 포스트에 이어서 SSE 구독 요청 시나리오를 계속해서 진행합니다.

https://soberyl.tistory.com/36

 

[Test] 점진적 부하테스트: 10,000명 유저의 SSE 구독 요청 - 톰캣

저번 포스트에 이어서, SSE 구독 요청을 동시에 수행하는 부하 테스트를 진행합니다.https://soberyl.tistory.com/34 [Test] 점진적 부하테스트: 10,000명 유저의 동시 SSE 구독 요청 - 설계 실패지금 유지 보수

soberyl.tistory.com

 

  이전까지는 만명의 SSE 구독 요청 수용을 완료했었고, 이제는 3만명으로 늘려서 커버해보려고 합니다.

Peak CCU 375000이 목표였기 때문에 50만명까지는 커버할 수 있어야 한정적이라고 할수 있을 것입니다. 이에 목표치에 1/10 정도도 못미치는 크기인 3만명 수용까지 밖에 오지 못 했습니다만... 일단은 파이팅 해보겠습니다!


시나리오 2: 30000명 유저 SSE 구독 요청

먼저 사전 세팅으로 프로세스가 열 수 있는 파일 디스크립터 개수를 생각합니다. 이전에

---

begroom@serverui-MacBookPro ~ % docker exec -it begroom-was cat /proc/1/limits | grep "Max open files"

Max open files 1048576 1048576 files

---

이렇게 세팅해놓았었기 때문에 변경은 필요 없을 것 같습니다만, 일단 5만으로 바꿔서 설정해놓겠습니다. 필요한만큼만 설정해놓고 싶었습니다.

 

또 sommaxconn과 accept-count, 그리고 톰캣의 개수를 변경해야겠죠. 이전에 했던 튜닝과 비슷합니다.

톰캣의 max-connections도 설정해 줍니다. 이전 포스트에서 계산한 바에 따르면 3만개 까지 늘려도 메모리에 대한 부담이 OOM을 일으킬 정도로 크지는 않은 것 같습니다.

...
server:
  servlet:
    context-path: /api
  tomcat:
    mbeanregistry:
      enabled: true
    max-connections: 33000
...

이제 돌려보겠습니다.

최초의 서버 상태

테스트 결과

  is status 1050은 무시해도 되는 결과입니다. 테스트 세팅 코드 상 논리적 오류이기 때문입니다..

  일단 응답 처리에 실패한 것들이 보입니다. http_req_waiting이 60초라는 것과 hikaricp의 pending 상태의 커넥션이 200개 대를 찍었다는 것이 전체적으로 테스트가 실패했다는 것을 보여줍니다.

  힙 메모리도 좀 많이 사용한 것 같습니다. 지금 힙 메모리에 저장되는 메모리는 SSE Emitter 밖에 없는데 이렇게 많이 사용한다면 다른 로직이 사용할 메모리가 너무 적어질 것 같습니다. open files도 16439 이전에서는 아마 요청을 처리하지 못한게 너무 쌓여서 프로메테우스가 읽어오지 못해서 기록을 못한 것 같습니다.

이것도 히카리CP와 결부해서 생각해볼 수 있을 것 같습니다.

  Connection서버에서 DB 커넥션을 받아오려고 할때 Connection Acquire Time을 보게되면 1초 동안 고정으로 기다리게 되는 시간이 있습니다. 순환은 되고 있으나, 1초의 딜레이가 발생하고 있다는 점이 문제점으로 보입니다.

  Connection Usage Time에서는 테스트 시작 후에 50ms 정도로 일정하게 유지되는데, 50ms 정도로 할일을 하고 히카리CP를 반납하고 있는 것으로 보입니다. 여기에서는 큰 문제는 없어 보입니다.

  Connection Creation Time은 히카리가 새로운 연결을 맺는데 걸리는 시간인데, 테스트 중 3만명 도달 시점에서 DB 부하가 심해지면서 새로운 커넥션을 맺는 데 시간이 평소보다 오래 걸린 것으로 보입니다.

 

  Max heap이 4GB인데 Used(실제 사용 중인 메모리 양)이 2GB입니다. 단순하게 계산해서 6만명이 SSE 구독 요청을 해버리면 바로 OOM이 나올 것입니다. 기준치에는 애초에 미치지 못하는 양입니다. 5만명 정도가 간당간당한 사이즈가 될 것 같은데, 문제는 순수 SSE Emitter 저장용으로 이렇게 사용하는 점 입니다. 다른 이커머스 로직 ( 주문, 결제, 정산 등 )이 끼어들면 바로 OOM이 터질 것이 보입니다.

 

  일단 서버의 수용량 관련해서는 잠시 접어두고, 이 모놀리스 서버에서 최대한 수용할 수 있는 만큼 수용해보는 목표로 가보겠습니다.


1개의 에러가 출력됐습니다. 이를 자세히 들여다 봐보겠습니다.

[Error] dial tcp : i/o time out

요청은 잘 보내졌으나, 서버가 응답을 무시하고 버려서 생기는 오류입니다.

이에 대한 흐름을 OSI 7계층을 표현한 아래의 그림과 함께 살펴보겠습니다.

 

요청이 잘 처리되었을 때의 OSI 7계층의 시나리오

  1. K6에서 부하 테스트를 진행합니다. 이때 요청이 만들어지고, 서버로 요청이 보내집니다.
  2. Layer 1, Layer 2, Layer 3에서 각각 전기 신호, PC 주소 확인, IP 주소 확인이 이루어 집니다.
  3. Layer 4 (Transport)에서 TCP 패킷을 수신합니다. 이때 들어온 (SYN) 요청을 잘 받아서 SYN Backlog에 넣고 서버가 클라이언트에게 SYN+ACK 응답을 보냅니다. 이때 소켓은 ESTABLISHED 상태가 되고, Accept Queue에 담기게 됩니다.
  4. Layer 5 (Session)에서부터 톰캣이 등장합니다. 톰캣의 Acceptor 스레드가 커널의 Accept Queue에서 소켓을 가져와서 (Accept) Worker thread에게 해당 요청을 배정합니다.
    1. 이때 이 스레드와 소켓 (요청)은 연결되어서 클라이언트와의 세션을 유지합니다.
  5. Layer 6 (Presentation)에서 들어온 패킷(010101000101...)을 읽어서 HTTP 프로토콜 규격에 맞게 파싱합니다.
    1. GET, POST등의 메서드 종류 확인, 헤더에 Accept: text/event-stream 등을 확인합니다.
  6. Layer 7 (Application)에서 DispatcherServlet가 요청을 받아서 처리합니다.
    1. 제가 구현한 MVC 모델에서는 컨트롤러가 이를 받고, 서비스 레이어에서 메인 로직을 처리하고 반환하는 처리가 되겠습니다.
    2. 이때 서비스 레이어에서는 DB 조회를 요청하거나 계산을 하는 등의 메인 로직을 가지고 있습니다. DB를 사용한다면 DB 커넥션을 가져오고, 사용후 반납합니다.
    3. 본 부하테스트에서는 여기서 SseEmitter 객체를 ConcurrentHashMap에 저장합니다. SSE는 연결이 끊기지 않고 파이프를 열어둡니다.

TCP 3-way Handshake

  SYN+ACK가 되어야 ESTABLISHED가 이루어지고, 이를 다시 ACK를 보내야 완벽한 ESTABLISHED가 됩니다.

OSI 7계층 구조로 살펴본 현 부하테스트 시나리오 에러 상황

  1. K6에서 부하 테스트를 진행합니다. 이때 요청이 만들어지고, 서버로 요청이 보내집니다.
  2. Layer 1, Layer 2, Layer 3에서 각각 전기 신호, PC 주소 확인, IP 주소 확인이 이루어 집니다.
  3. Layer 4(Transport)에서 TCP 패킷을 수신합니다. 여기 까지는 동일합니다.

  여기서 어떤 처리가 이루어지는지 살펴볼 필요가 있습니다. TCP 3-way handshake가 진행되면 서버에서는 어떤 데이터 흐름이 생기는지 시퀸스 다이어그램을 통해서 잠시 살펴보겠습니다.

3-way handshake와 연결이 완료된 소켓들이 Layer 5 (톰캣)으로 들어가는 과정
User 영역과 Kernel 영역으로 나눠본 위의 상황

 

Accept Queue Overflow

Mac OS에서 확인한 Accept queue의 사이즈

  Layer 5에서 톰캣이 Accept()로 계속해서 요청을 받습니다. 여기서 Accept() 속도보다 Accpet queue에 쌓이는 속도가 더 빠르다면 Accept queue가 꽉 차게됩니다. Accept queue의 사이즈가 꽉차게 되면 유저에게 ACK를 받아도 들어갈 공간이 없습니다.

 

  서버는 그래도 일단 쌓여있는 SYN Backlog에서 SYN-ACK를 계속해서 보냅니다. 클라이언트 측은 SYN-ACK를 받았으니 ACK를 보내고 ESTABLISHED 상태가 됩니다.

 

  여기서 Accept queue가 꽉차서 넣을 자리가 없으므로 서버는 클라이언트 측에서 보낸 ACK 패킷을 그냥 무시(Drop)합니다. 재밌는건 서버는 ACK를 버린 것을 기억하지 않습니다. ACK를 못 받았다고 생각해서 SYN-ACK가 보내는 중 유실되었다고 생각하고 SYN-ACK를 다시 보냅니다(Retransmit).

SYN_RECV 상태에서 클라이언트의 오해로 받아온 패킷에 대한 서버의 처리

 

  다시 돌아와서 클라이언트 측에서는 TCP 연결이 ESTABLISHED 되었으므로 ACK를 보냈었습니다. 이때 ACK와 함께 미세한 차이로  HTTP 요청에 대한 내용을 연속해서 같이 보냅니다. 이에 대한 내용은 물리적으로는 패킷을 받습니다. 다만 Layer 4에서의 서버의 처리가 원래의 흐름과 달라집니다.

 

  서버의 랜카드를 지나서 TCP/IP 스택(커널)까지는 잘 들어왔으나, 커널은 들어온 패킷의 IP와 포트를 보고 자신의 연결 테이블을 확인합니다. 이때 커널은 5가지의 식별자(5-Tuple)로 들어온 소켓 패킷이 연결되어 있는지 아닌지를 확인합니다.

  • Protocol (TCP, UDP...)
  • Src IP (192.168.x.x, 사용자 PC의 IP)
  • DST IP (10.0.x.x, 서버의 IP)
  • Dst Port (8080, 서버의 포트)
  • Src Port (49152 ~ 65535 랜덤 포트) -> 구분의 핵심 인자!

  여기서 패킷의 상태를 확인하는데, 패킷이 ESTABLISHED가 아니라 SYN_RECV 상태입니다. 방금 ACK를 버렸기 때문입니다. 따라서 커널은 SYN_RECV 상태에서 갑자기 들어온 데이터 패킷을 유효하지 않은 것으로 간주하거나 어차피 Accept queue가 꽉 차서 처리할 수 없으므로 그냥 버립(Drop)니다. 서버는 하던 대로 SYN-ACK를 다시 보내는(Retransmit) 거죠.

 

  이렇게 되면 악순환이 시작됩니다.

  • 클라이언트 측은 ACK를 분명히 보냈는데 서버에서 Slient Drop으로 패킷을 버렸으므로 아무 응답을 보내지 않아서, 응답을 받지 못한 상태로 기다립니다. 기다리다가 일정 시간이 지나게 되면 데이터가 모종의 이유로 유실됬다고 판단하고, HTTP 요청 데이터 패킷을 다시 보냅니다. 근데 서버는 아직도 SYN_RECV로 되어있는 소켓 정보를 확인하고는 다시 drop 합니다.
  • 서버 측은 SYN_RECV로 되어있는 소켓이 연결용 순수 ACK가 안왔음을 확인하고 다시 SYN-ACK를 보냅니다. 클라이언트는 ESTABLISHED 상태이더라도 ACK를 다시 보냅니다. 이 연결용 ACK를 받아야 비로소 서버도 ESTABLISHED 상태가 되지만, Accept queue가 꽉차서 이것도 그냥 버립니다. 그리고 다시 SYN-ACK를 보내게 됩니다.

SYN Backlog Overflow (SYN Flooding)

  위와 같이 Accept queue overflow가 발생하고, TCP ESTABLISHED가 안된 연결들이 SYN Backlog에 쌓이게 됩니다. SYN Backlog에 쌓이다 못해 이 곳도 꽉 차버리면 TCP를 받을 수 있는 모든 대기열이 꽉 찬 것이므로 커널이 그냥 패킷을 버려버립니다. 클라이언트는 자신의 패킷이 가는 도중에 사라졌다(Packet Loss)고 판단합니다. 서버가 일부러 무시한 건지, 정말로 회선이 끊긴 것인지 뭔지 알방법이 없기 때문입니다.

 

  클라이언트는 패킷이 유실되었다고 생각하기 때문에 다시 TCP 연결을 요청하는 패킷을 보냅니다. 즉, SYN을 다시 보냅니다. 이것을 TCP Retransmission이라고 합니다. 서버는 SYN Backlog가 다 찼기 때문에 어차피 SYN 요청을 버릴 것입니다. 이에 대해서 계속해서 클라이언트 측에서 TCP Retransmission을 보내는데, 기다리는 시간을 RTO(Retransmission Timeout)라고 합니다. RTO는 Exponential Backoff 알고리즘을 취하고 있어서, 재전송 간격은 2배씩 늘어납니다.

 

  결국 클라이언트의 커널 혹은 애플리케이션에서 설정된 timeout 기간까지 이런 과정을 거치다가 timeout 시간이 되면 Error: dial tcp timeout이 발생되는 것입니다.

netstat 옵션 (Listen queue, All, Numeric)

 

  숫자는 (qlen/incqlen/maxqlen)으로 구성되어있다고 명시돼있습니다.

표시 값 의미 설명
qlen Accept Queue ( 현재 사용량이 출력됨 ) ESTABLISHED 됐는데 서버가 아직 안 가져간 수
-> 이게 꽉 차면 Read Timeout
incqlen SYN Queue ( 현재 사용량이 출력됨 ) SYN_RCVD 상태 (TCP Handshake 중)인 개수
-> 이게 꽉 차면 dial tcp timeout
maxqlen Limit ( 최대 크기가 출력됨 ) kern.ipc.somaxconn 설정 값 (Mac OS 기준)

 

  결과의 첫번째 줄을 보면 8080 포트에서 제가 구현한 서버의 maxqlen은 100임을 확인할 수 있습니다.

 

  여기서 Read Timeout과 dial tcp timeout을 구분할 수 있으면 더 용이한 디버깅을 할 수 있을 것입니다. 위의 내용을 정리해보면 이 둘은 같은 클라이언트 타임 아웃으로 인해 발생한 에러이고, TCP 연결 단계 (상태) 에 따라서 갈리는 에러임을 확인할 수 있습니다.

구분 dial tcp timeout (Connect Timeout) Read Timeout (Socket Timeout)
의미 TCP 연결을 하려는데 실패했다. HTTP 응답(L7 데이터)를 읽으려는데 실패했다.
병목 원인 Layer 4 (TCP Handshake) Layer 7 (HTTP Data Transfer)
클라이언트 연결 요청을 걸었는데 신호가 안 감 연결 요청은 잘 받았는데 본론에 대한 응답이 없음 
서버 입장 자체가 막힘 (SYN Backlog Full, 방화벽..) 처리가 마비됨 (Accept Queue Stuck, Slow DB...)
해결 방향성 OS 커널 튜닝 애플리케이션 튜닝

 

  L4 문제는 Connection Rest, Broken Pipe (가다가 패킷이 다 터진 경우) 등의 경우가 더 있습니다.

  Accept queue(L4)에 패킷이 존재하지만 L7 문제라고 되어있는 이유는 원인이 L7의 애플리케이션이 소켓을 처리하지 못해서 발생하는 현상이기 때문입니다.

 

  한편 개념적으로는 둘 다 클라이언트 측의 타임아웃 에러이지만, 애플리케이션 단에서 서로 다른 타이머를 설정하는 경우가 많습니다. Java/Spring의 경우에는 다음과 같습니다.

  • connectionTimeout: 3초 (연결 맺을 때까지만 기다림), 터지면 dial tcp timeout
  • readTimeout: 30초 (연결 후 데이터 올 때까지 기다림), 터지면 Read Timeout

본 부하테스트에서 사용하는 k6는 위 내용을 내부적으로 http_req_connecting, http_req_waiting으로 구분합니다.

 

*Mac에서는 Syncache라는 기술을 써서 SYN Backlog 사이즈가 상수값이 아니고 Accept Queue 크기에 비례해서 자동으로 결정되거나 net.inet.tcp.syncache 관련 설정에 따름. -> somaxconn만 늘려주면 자동으로 SYN Backlog도 늘어남.

 

*dial tcp ...: i/o timeout 같은 형태의 에러 메시지: 'Go' 언어로 만들어진 프로그램에서 네트워크 연결에 실패했을 때 내뱉는 오류.

 

가설 (1)

  톰캣의 처리가 느려서 Accept queue까지 다 터지는 바람에 dial tcp timeout이 생겼을 것입니다. 따라서 application.yml에서 톰캣 설정을 변경해서 Accept queue 사이즈를 변경합니다.

 

가설 (1) 개선 시도

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

  accept-count를 10000으로 설정하겠습니다. 이에 따라서 somaxconn도 10000 이상이록 설정해야 합니다.

  somaxconn이 4096이었으므로 설정이 필요합니다.

socket max connection 변경
변경이 잘 된 것을 확인

이제 다시 부하테스트를 돌려보겠습니다!

가설 (1) 개선 결과

장렬하게 실패

 

  10000명에서 30000명으로 튈 때 에러가 나오기 시작했습니다. FD도 역시 3만까지 올라가지 못했고, 해결될 것이라고 예상했던 dial tcp timeout도 발생했습니다. 또한 다른 문제도 로그에 나온 것을 확인했습니다. FD가 16000대에 머물러 있는 건 왜 이러는 건지 예상이 잘 안됩니다.

 

  히카리CP 사이즈는 기본 값인 10입니다. 테스트는 18:55에 시작했는데 중간에 1분동안 3만명으로 증가하는 과정에서 pending이 증가하더니 172까지 튀었습니다. 서버 로그에서 1만명 까지는 워커 스레드가 200이 아니라 50까지도 안 올라갔었는데, 3만으로 올라가는 과정에서는 200이 금방 찍혔습니다. DB I/O가 이를 못 받아쳐주고 있는 상황인 것 같습니다. 이에 맞물려서 http_req_waiting도 20초를 넘었습니다. timeout값인 60s와 다른 값이므로 딜레이가 많이 돼서 그런 것으로 보여집니다. 실제로 만명까지 올라가는 상황에서도 증가는 했지만 그렇게 크게 증가하지는 않았기 때문에, DB의 영향으로 보여집니다.

 

  TCP 연결 시간이 20초가 넘었습니다. 이는 DB 성능에 이어서 Accept queue에 있는 내용이 처리가 안돼서 TCP ESTABLISHED가 안되니 이런 식으로 오래 걸렸다는 것을 보여주는 것 같습니다.

 

  이번에는 서버도 에러 로그를 함께 확인했습니다.

 

Client

[Error] Request Failed error="IP": dial tcp 192.168.65.254:8080: connect: connection refused"

  FD가 16000대에서 안 올라가는 것을 보니, 뭔가 프로세스 별로 FD 상한치에 도달한게 아닌가 싶습니다. 하지만 저는 분명 서버와 클라이언트 측의 쉘에서 다 ulimit -n 설정을 5만으로 했는데 왜 이러는 지 모르겠습니다.

[Error] Request Failed error="Get \"IP": dial: i/o timeout"

테스트 마지막 부근에 나온 것을 보아서 역시 진행 막바지에 SYN Backlog 까지 꽉 찬 것 같습니다. 이유를 알고나니 조금 편하게 다가오는 것 같습니다.

WARN[0220] 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.251346751s

부하를 주는 k6가 한계에 도달했다는 뜻입니다. 플러시 작업이 설정된 푸시 간격보다 더 오래 걸렸다는 건데, 부하 생성 + 로그 수집 + InfluxDB 전송까지 하는데 자원이 한계에 다달해서 결과에 에러가 발생했을 수 있습니다. 실제로 그라파나에 기록이 없는 부분이 있었는데 이 부분 때문인 것 같습니다.

Server

Caused by: java.io.IOException: Broken pipe
        at java.base/sun.nio.ch.SocketDispatcher.write0(Native Method) ~[na:na]
        ...

org.springframework.web.context.request.async.AsyncRequestNotUsableException: Servlet container error notification for disconnected client

 

끊어진 파이프(소켓)에 데이터를 쓰려고 하니 발생한 에러입니다. 클라이언트가 request timeout으로 없어진 상태인 것이죠. 지금의 문제와는 조금 다른 문제인 것 같습니다.

 

혹시 몰라서 살펴본 도커 자원 세팅. 매우 넉넉한 것 같다.

가설 (2)

프로세스 당 FD 제한으로 요청이 막혀버린다. ulimit -n이 잘 먹히지 않았다. 이 때문에 Request가 refused되는 것이다.

FD 제한을 제대로 풀고 테스트를 돌리면 성공할 것이다.

 

저는 K6를 도커에서 돌리고 있습니다. 따라서 도커에서 돌아가는 k6의 ulimit을 확인해봐야 합니다.

정상이네

아무리 제가 유저 단에서 ulimit -n 을 개선했어도 커널 레벨의 제한이 낮게 걸려있으면 소용없겠죠. 확인해보겠습니다.

정상이네?

  서버나 k6의 ulimit 파일 개수 제한이 문제가 아니라면, 사용 가능한 포트 번호가 제한이 걸려있다면 어떨까요?

위에서 소켓을 연결할 때, 랜덤 변수인 Src Port를 통해서 소켓을 구분한다고 언급한 바 있습니다. 이는 임시 랜덤 포트인데요, 서버 포트가 존재해야하는 것 처럼 클라이언트도 포트가 존재해야 합니다. 이때 OS가 클라이언트 소켓은 고정적으로 사용하지 않으니 주어진 번호에서 남는거 아무거나 쓰라고 랜덤하게 만들어 줍니다. Mac OS는 기본적으로 이 임시 포트 범위를 49152 ~ 65535로 설정해둡니다.

임시 포트 번호의 시작 숫자.

그래서 가능한 포트 범위는 65535-49152 = 16383개인 것입니다.

잠깐, 낯이 익은 숫자이지 않습니까?

최고로 올라간 FD 수치: 16625, 최초 FD 수치: 207이므로 16418입니다. 기록중에 유실된 것도 있을테니 굉장히 비슷합니다.

시작 숫자를 바꾸어 주었습니다. 이제 임시 포트 넘버로 55295개 만큼 사용할 수있으므로, 해결할 수 있을 것입니다!

 

또한 히카리CP를 기다리는 pending 상태도 170개로 올라갔으니, 워커 스레드를 기본값인 200에서 400으로 늘려주겠습니다. 또한 히카리 커넥션 풀 사이즈도 20개 정도로 늘려주겠습니다. 이렇게 하면 좀 더 원활하게 돌아갈 것이고, http_req_waiting도 개선될 것입니다.

 

  datasource:
    hikari:
      maximum-pool-size: 20
      minimum-idle: 20
...
  tomcat:
    mbeanregistry:
      enabled: true
    max-connections: 33000
    accept-count: 10000
    threads:
      max: 400
...

가설 (2) 개선 결과 - 1

 

 

  FD가 29194까지 올랐습니다. 몇 개는 실패한 것 같은데, 이는

WARN[0161] Request Failed error="Get \"IP": dial tcp 192.168.65.254:8080: connect: connection refused"
이 오류로 연결이 몇개는 실패해서 그랬구나라고 확인했습니다.

 

  응답 속도는 여전히 처참하긴 합니다. 제일 늦게 응답을 받은 유저는 30초 뒤에나 구독 연결 요청에 대한 응답을 받는게 너무 성능이 안 좋은 것 같습니다. 이때 TCP 연결 속도가 15초가 걸리니, 문제가 아닐 수가 없네요.

 

  또한 히카리 커넥션 풀 사이즈를 20까지 올렸는데도 많은 톰켓의 워커 스레드가 기다리게 됐는데요, 이 부분은 좀 더 사이즈를 늘려서 개선해야겠습니다. 10개를 늘려서 큰 변화가 없으니, 이번엔 50으로 늘리겠습니다. 워커 스레드는 일단 400을 유지해서 진행해보겠습니다.

 

  주목해볼만한 점은 Old 영역이 4기가에 거의 육박했다는 점인 것 같습니다. 성공적으로 SSE Emitter 객체를 만들고 Eden 영역에서 Old영역으로 이동하니, 이런 상황이 발생한 것 같습니다. 3만개의 요청이 OOM을 유발할 수 있으므로 JVM이 할당받은 램의 크기를 4기가에서 6기가로 늘려주겠습니다.

힙 메모리가 6GB로 늘어난 모습

가설 (2) 개선 결과 - 2

 

TCP Handshake는 18초에서 7초 정도로 줄었습니다. 그런데 여전히 응답 속도는 20초를 넘깁니다.

히카리 커넥션은 사이즈를 50개로 늘리니 나름 줄긴 했습니다. 기존에는 400개였는데 300개로 줄었으니까요. 그래도 여전히 pending이 많아보입니다.

끊긴 부분이 너무 많다.

  이상한 점은 위와 같이 이전보다 끊긴 부분이 너무 많다는 것입니다. influxdb에 너무 많은 것들을 한번에 밀어 넣었고, 그라파나가 읽는 작업을 잘 수행하지 못한 것 같습니다. 표시된 FD 값은 27251입니다. 아마 사이에 3만을 찍었을 거라고 예상합니다.

4GB의 87%이면 3.48G이므로 3.48/6=0.58으로 계산해보면 58%정도가 되어야 합니다. 근데 조금 떨어졌습니다.

WARN[0192] Request Failed error="Get \"IP": dial tcp IP:8080: connect: connection refused"

에러가 나오긴 했습니다. 하지만 육안으로 로그를 관찰한 바에 의하면 이전보다 확실히 로그 양이 줄어든 느낌입니다. 다른 에러 로그는 없었습니다.

WARN[0184] Request Failed error="Get \"IP\": read tcp IP:52394->IP:8080: read: connection reset by peer"

여기서 peer는 서버입니다. connection이 서버에 의해서 초기화됐다는 의미인데, 이는 서버가 너무 바쁘거나 죽어서 강제로 소켓을 닫은 상황입니다.

 

프로메테우스가 자꾸 읽어오는 것을 힘들어해서 그래프에 공백이 너무 많이 생겼습니다. 그래서 명령어로 직접 생성된 FD와 연결된 소켓 개수를 반복문으로 확인해봤습니다.

왼쪽은 netstat -an, 오른쪽은 lsof -p pid. sleep 5를 걸어서 5초마다 한번씩 실행됩니다.

 

내 PC에서 다 돌릴 수 있는 부하인가?

Spring Boot: 8GB (6GB 힙을 주고 나머지는 돌아가는데 사용)

Docker VM (MySQL + k6): 8GB

MacOS (System + IntelliJ): 4GB

= 19.5G

 

  저는 Mac M3 16GB를 사용하고 있습니다. 모자란만큼 SSD를 Swap 메모리로 사용할 것입니다. 그래도 엄청나게 느려지겠죠. 실제로 K6를 돌리면 컴퓨터가 느려진다는 느낌이 심했습니다.

수정한 도커 세팅. 이렇게 진행해봤더니 테스트 속도가 너무 느려져서 기다리다가 결국 테스트 중간에 터졌습니다.

 

다시 수정해서 세팅한 도커의 리소스. CPU가 터졌습니다.

 

사실 이렇게 많이 쓰이고 있었다..

 

Mysql의 성능의 90%는 메모리에서 결정됩니다. InnoDB Buffer Pool이 Disk를 읽지 않고 메모리에서 바로 꺼내주는데, 이게 성능을 크게 좌우하기 때문입니다. 따라서 꽤 램을 할당해줘야합니다. 

한편 Connection은 50개를 할당했는데 하나에 4MB정도 할당됩니다. 따라서 200MB 수준으로 크게 신경 쓸 부분은 없는 것 같습니다.

K6도 3만명의 유저를 띄우려면 2GB 정도는 필요합니다.

5만명의 벽...

이런 지금 5GB로 JVM에서 Heap에 할당할 수 있는 메모리를 올려줬는데 이 정도라면 

더군다나 5만명은 제 Localhost 테스트에서는 무리가 있는 것 같습니다. 제 컴퓨터에서 돌아가는 것이 JVM만 돌아가는 것이 아니기 때문입니다... PC를 분리하거나 자원을 늘리거나 해야 부하 테스트를 진행해볼 수 있을 것 같습니다.

 

* notification_stress: Up to 54000 looping VUs for 6m0s over 5 stages (gracefulRampDown: 30s, gracefulStop: 30s)

WARN[0026] The flush operation took higher than the expected set push interval.

 

vus: 53000까지도 influxdb가 간당간당 하다가, 받을수 없는 양의 메모리를 넣어서 k6 셋업 단계에서부터 터져버렸습니다.

 

따라서 SSE 구독 성능 테스트는 여기까지 진행해보고, 다음부터 DB 성능 최적화를 진행해보겠습니다!

 

❓TCP 3-way handshake에서 소켓의 패킷을 구분할 때 Src Port가 같은 경우는 없나?

더보기

같은 브라우저 탭에서 같은 요청을 해도 src port가 달라버리면, 너무 성능이 떨어질 것 같습니다.

 

위의 결과들에서 살펴볼수 있듯이 TCP 3-way handshake도 만만치 않은 비용이 듭니다.

따라서 이따 또 쓸 것 같은 요청의 경우에는 연결을 유지한 상태로 다음 요청을 보냅니다. 이때 연결이 유지되는 기간을 Keep-Alive 라고 합니다. 여기서 같은 요청을 또 보낼 경우에는 소켓 식별자의 5-Tuple이 모두 동일한 값이 되는거죠.

 

여기서 HTTP 프로토콜 버전에 따라서 차이를 보입니다.

 

HTTP/1.1

직렬 처리 방식입니다. 즉, 연결 1개당 1개의 요청만 처리가 가능합니다.

만약 같은 탭에서 이미지 10개를 동시에 불러와야 한다면 브라우저는 속도를 위해서 도메인당 최대 6개의 TCP 연결을 동시에 뚫습니다. 즉, Source Port가 다른 6개의 연결이 생깁니다. 도메인 별로 구분되기 때문에 탭이 달라도 상관없습니다. 또한 브라우저는 탭별로 스레드가 생성되고 독립적으로 작동하기 때문에 TCP는 스레드 별로 생성되는게 아니라고 생각할 수 있습니다.

 

만약 탭 1이 소켓 하나를 다 쓰고 돌려놨다면(Keep-Alive), 탭 2가 그 소켓을 그대로 재사용할 수 있습니다.

하지만 탭 1이 이미 6개를 다 쓰고 있다면, 탭 2는 대기해야합니다.

HTTP/2

Multiplexing 방식을 사용합니다. TCP 연결을 1개만 뚫어 놓고, 그 TCP 연결을 계속 재사용합니다.

이 방식을 Connection Coalescing(연결 병합) 기술이라고 합니다.

 

탭이 100개여도 도메인이 같으면 단 하나의 TCP 연결만 뚫어놓고 모든 탭이 그 파이프 하나를 공유합니다.

 

근데 여기서 연결 하나에 여러 요청을 동시에 보내게 될때 패킷 1, 2, 3을 보냈는데 2가 중간에 유실되었다고 가정해봅시다.

TCP는 순서가 생명이라서 3번 패킷이 잘 도착했어도 2번이 다시 올 때까지 3번을 주지 않습니다. 전체 정지가 되는거죠.

이걸 Head-of-Line (HoL) Blocking이라고 합니다. 멀티플렉싱의 단점이라고 할 수 있습니다.

*HTTP/3는 많이 다른 프로토콜입니다.

5-Tuple에서 발생한 문제인 소스 포트, IP, 연결 끊김 등에 대한 문제를 해결하기 위해서 TCP를 버리고 UDP를 채택한 프로토콜입니다. 가장 큰 특징은 QUIC이라는 프로토콜을 사용한다는 점입니다. QUIC은 IP나 포트 번호 대신 Connection ID (CID)를 사용합니다.

 

TCP의 순서에 의해서 처리가 막히는 문제를 해결하기 위해서 UDP위에 QUIC을 얹습니다. 이 요청들을 Stream이라고 하는데, 이는 모두 독립적입니다. 즉 2번 패킷이 유실되어도 3번을 먼저 처리할 수 있는 것입니다. 따라서 패킷 손실이 발생해도 전체 속도에 영향을 미치지 않습니다. No Hol Blocking 기술이 도입된 것입니다.

 

예를 들어, 사용자가 와이파이를 쓰다가 좀 느린 것 같아서 LTE로 네트워크를 변경한 상황이라고 생각해봅시다.

이때 기존 HTTP 1.1과 2의 경우 Source IP가 바뀌었으니 5-Tuple이 깨지므로 다시 TCP 3-way handshake를 진행해야 합니다.

HTTP 3의 경우는 IP나 포트 번호와 같은 5-Tuple을 사용하는게 아니라 Connection ID를 이용합니다. 따라서 Source IP가 바뀌어도 패킷 헤더의 CID만 똑같으면 계속 통신할 수 있습니다. 따라서 영상 따위의 것을 볼 때 와이파이 사용 중에 LTE로 바꿔도 영상이 멈추지 않고 계속 재생될 수 있습니다.

*SYN: Synchronize(동기화), ACK: Acknowledgement(승인)

*TLS: Transport Layer Security (전송 계층 보안) == SSL: Secure Sockets Layer

*TCP: Transmission Control Protocol (전송 제어 규약), UDP: User Datagram Protocol (사용자 데이터그램 규약)

*RTT: Round Trip Time (왕복 시간)

반응형

'Test' 카테고리의 다른 글

[Test] 점진적 부하테스트: 1,000,000명 유저의 알림 전송 요청 - 스레드  (1) 2026.02.10
[Test] 점진적 부하테스트: 10,000명 유저의 알림 전송 요청 - JPA  (0) 2026.02.06
[Test] 점진적 부하테스트: 10,000명 유저의 SSE 구독 요청 - 톰캣  (0) 2026.02.03
[Test] 500명 유저의 동시 로그인 요청 처리  (0) 2026.02.02
[Test] 점진적 부하테스트: 10,000명 유저의 동시 SSE 구독 요청 - 삽질  (0) 2026.02.02
'Test' 카테고리의 다른 글
  • [Test] 점진적 부하테스트: 1,000,000명 유저의 알림 전송 요청 - 스레드
  • [Test] 점진적 부하테스트: 10,000명 유저의 알림 전송 요청 - JPA
  • [Test] 점진적 부하테스트: 10,000명 유저의 SSE 구독 요청 - 톰캣
  • [Test] 500명 유저의 동시 로그인 요청 처리
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
      • 청춘
        • 희곡
        • 연극
      • 잡설
  • 블로그 메뉴

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

  • 공지사항

  • 인기 글

  • 태그

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

  • 최근 글

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

티스토리툴바