실시간 댓글 개발기(part.2) – 험난했지만 유익했던 웹소켓 스트레스 테스트 및 안정화 작업

전편(실시간 댓글 개발기(part.1) -DAU 60만 Alex 댓글의 실시간 댓글을 위한 이벤트 기반 아키텍처) 에서는 기본적인 테스트 환경 구축에 대한 설명을 드렸다면, 이번에는 테스트 진행에 대해서 공유하고자 합니다.

목표는 라이브 댓글 서버 한대당 10k 커넥션을 안정적으로 유지하고 2800TPS를 받을 수 있도록 하는 것인데요, 안정화를 위해서 Spring WebSocket 기능 뿐만 아니라 Tomcat, VIP, 물리장비, Docker 컨테이너 등에 대해서 메모리 및 file descriptor, configuration 등을 튜닝하였고 이를 위해 nGrinder, jcmd, Neo, FastThread.io, 직접 제작한 Golang Client 등 다양한 툴을 활용하였습니다.

너무 많은 것들을 동시다발적으로 튜닝하다보니 일관된 흐름으로 풀어내지 못한 것이 아쉽긴 하지만 가능한한 비슷한 것들을 묶어서 정리해보았습니다.

 

확인 및 튜닝 항목

 

 

위에서 언급한 바와 같이 한개의 변수만을 바꾸어가며 차근차근 진행이 되지 못하고 한번에 다양한 방면의 변수들을 동시 다발적으로 테스트 한 경우가 많았지만, 진행된 테스트들을 크게 분류 해보자면 다음과 같습니다.

 

VIP – 최대 커넥션 수 조절
– 최대 TPS 조절
Virtual/Physical Machine – fileDescriptor 수 조절
– CPU, 메모리 사용률 모니터링
Docker – fileDescriptor 수 조절
Tomcat – 최대 동시 커넥션 리밋 조절
Spring WebSocket Application – 연결이 한번 발생할 때 생성되는 process와
task 개수 및 사이즈
– SimpleBroker 설정 조절

 

몇 개월 동안 위 항목들에 대한 수많은 테스트와 튜닝을 진행하면서 알게 된 사실들이 많지만 그 중 핵심 내용들만 다뤄보고자 합니다.

 

어플리케이션 분석 툴 소개

 

Neo 도입

우선 위 테스트들을 진행 하면서 소켓 관련 스프링 내부의 구조를 파악하기 위해 클라이언트를 소량으로 테스트하거나, 대량으로 테스트를 하더라도 커넥션이 성공적으로 맺어졌는지 집중적으로 확인할 필요가 있습니다. 또한 실제로 트래픽이 들어왔을 때 정확히 어떤 리소스가 어떻게 사용이 되는지에 대한 이해가 꼭 필요합니다.

이 부분을 더 정확하게 파악하기 위하여 당시 사내 자바 모니터링 시스템인 Neo를 사용해보기로 했습니다.

Neo의 분석 메뉴에서 메소드 분석 탭에 들어가면 관리 기능을 통해서 특정 장비의 Thread Dump를 쉽게 뜰 수 있습니다. 분석 키워드는 기본적으로 kakao, daum 두 개가 등록되어 있는데, 우리는 spring messaging 패키지의 분석을 수행했으므로 messaging 키워드만 남겨놓고 분석 시작 버튼을 눌렀습니다.

분석이 끝나면 위 그림과 같이 분석 키워드에 매칭되는 메소드 목록을 보여주며, 그 중 하나를 클릭하면 쓰래드의 상태를 표시해줍니다.

위 그림은 현재 서버 상태의 스크린샷을 뜬 것으로써 특이사항은 없는데요, 아쉽게도 장애 시점의 스크린샷을 남긴 것이 없는 관계로 기억을 더듬어보면 위 녹색 RUNNABLE 대신에 BLOCKED 라는 라벨이 붙은 빨간색 표시가 있었습니다.

지금은 익숙해져서 비슷한 장애 발생시에도 Neo가 제공하는 정보만으로 장애 포인트를 찾을 수 있지 않을까 싶지만, 문제의 장애 시점에는 Neo의 차트를 정확히 이해하지 못하여 아래 소개된 FastThread를 이용하였습니다.

 

fastThread

fastThread는 https://fastthread.io/ 주소로 서비스하고 있는 웹 기반 어플리케이션 분석 툴입니다. fastThread는 아래 그림과 같이 좀 더 시각적으로 풍부하게 정보를 제공합니다.

참고로 이 리포트를 보기 위해서 쓰레드 덤프를 떠야 하는데, 이때 쓰레드 덤프는 Neo에서 다운로드 받을 수 있습니다. 즉, 원본 데이터는 Neo와 동일한 것을 사용하며 시각화 방식만 차이가 납니다.

fastThread가 제공하는 다양한 정보 중에 가장 큰 도움이 되었던 것은 아래처럼 생긴 (아마도) DEAD LOCK 리포트였습니다.

아래 그림을 보면 messageListenerContainer-1517364 쓰레드의 DestinationCache$1 클래스가 52개 쓰레드를 blocking 하고 있다는 것을 한눈에 보여주고 있는데요, 이를 통해 어느 코드가 문제인지 파악할 수 있었으며 blocking을 유발하는 코드를 제거함으로써 성능 개선을 할 수 있었습니다. 이에 대한 것은 후속 글(Spring의 동시성 접근 제어에 발목 잡힌 이야기)에서 더 심도 있게 다뤄볼 예정입니다.


시스템 튜닝

 

 


TCP 커넥션 테스트 – HTTP vs WebSocket 테스트

시중에 나와있는 대량 클라이언트 무료 테스트 툴은 많습니다. 대표적으로 nGrinder, Jmeter가 있는데요, nGrinder는 사전에 사내에서 준비된 클러스터 서버들에서 다량의 클라이언트를 생성하여 동시다발적으로 또는 단계적으로 요청을 보낼 수 있게 해줍니다.

nGrinder의 경우 이미 흔히 각종 API 부하 테스트에서 사용되고 있어 이를 사용하여 초반에 소켓 테스트를 진행해보았습니다. 하지만 일반 HTTP(S)로 요청을 받는 서버에 대한 테스트와 소켓으로 요청을 받는 서버에 대한 테스트 차이가 많았음을 확인할 수 있었습니다.

 

TCP Connection 상태 유지 테스트하기

일반적인 HTTP(S) 요청을 통해 처리하는 서버에 대한 테스트는 매우 간단합니다. 테스트 진행시 HTTP(S)의 경우 일반적으로 CORS와 헤더로 넘기는 값들에 대해서만 주의하면 되지만, 소켓의 경우 HTTP(S)에서 요구하는 것들 이외에도 연결 지속 여부를 확인 해야하는 절차까지 추가됩니다. 여기에 STOMP 프로토콜까지 들어가게 되어 특정 STOMP 토픽에 잘 붙고 있는지 확인하는 과정이 있어 더욱더 테스트가 어려웠습니다.

다음의 사이클을 무탈하게 한번 도는 것을 기준으로 한개의 클라이언트 커넥션 성공 여부를 판단합니다.

HTTP(S)를 통해 소켓 연결 > STOMP Protocol CONNECT > STOMP Protocol SUBSCRIBE > 임의의 메시지가 잘 들어오는지 확인 > STOMP Protocol UNSUBSCRIBE > STOMP Protocol DISCONNECT > HTTP(S) 소켓 연결 단절

API 스트레스 테스트로 자주 사용되는 nGrinder에서도 소켓 테스트가 가능하지만 10k 커넥션을 진행했을때 안정적으로 연결이 유지되지 않고, 중간중간 커넥션이 끊기는 현상이 발생하여 테스트 중인 서버의 문제인지 nGrinder의 문제인지 구분하기 어려웠습니다. 무엇보다 커넥션이 맺어지고 끊어질 때 사용된 리소스가 문제 없이 반환되고 메모리 릭이 없는지도 확인이 필요했습니다.

그리고 정확한 테스트를 위해서는 진행 중인 테스트에 대한 비교 대상이 될만한 base case가 있어야 하는데, 매 테스트마다 다른 양상을 보이는 경향이 있어 가설을 세우는 단계에서부터 어려움을 겪었습니다. 또한 nGrinder가 사내에서 쉐어하는 리소스다보니 타 개발자가 이미 nGrinder를 통해 스트레스 테스트를 진행 중인 경우 리소스가 부족하여 테스트 대기를 해야하는 상황이 빈번했습니다.

요약하자면…

소켓 + STOMP 테스트의 문제점

1. 연결을 유지한 상태에서 테스트가 진행
2. 한번 보내고 끝나는 일반 API들과 달리 단계별로 접근해야 함
(CONNECT > SUBSCRIBE > SEND 등)
3. 소켓을 연결, 유지, 끊는 과정에서 사용된 리소스가 제대로 반환이 되었는지 확인이 어려움
4. 서버가 죽었을 때, 원인이 커넥션 과부하 때문인지 전송되어야할 메세지가 밀렸기 때문인지 파악이 어려움

4번의 경우, 어느 한 순간에 서버단에서 발생하는 메시지 수는 그 순간에 댓글에서 발생하는 이벤트 수(댓글 쓰기, 추천, 비추천 등)와 같고 클라이언트에 전달해야하는 메시지의 수는 (서버단에서 발생한 메시지 수) x (소켓으로 연결되어 있는 모든 유저 수)와 같습니다. 하지만 이렇게 설계할 경우 낱개의 메시지를 유저별로 전달해야하기에 비효율적이며, 대량의 메시지가 불규칙하게 발생할 여지가 많아 서버가 감당해야하는 최대 TPS를 예측하기 어려워지는데요, 이는 이벤트 묶음처리 작업을 통해 3TPS로 고정시켜 해결하였습니다. 다음에 이 묶음 처리를 주제로 후속글을 써보도록 하겠습니다!

 

GoLang Client 이용해서 문제해결

nGrinder에서는 안정적인 테스트가 어렵다는 것을 깨달은 후 다시 처음으로 돌아가 특정 토픽을 구독할 수 있게 해주는 STOMP 프로토콜은 무시하고, GoLang으로 소켓만 잘 연결되는지 확인하는 코드를 짜서 10k 연결이 안정적으로 이루어지는지 확인해보았습니다. GoLang으로 스트레스 테스트를 전환하면서 느낀 것은 GoLang이 리소스를 상대적으로 많이 사용하지 않는다는 것이였는데요, 보통은 Java나 Python으로 프로그램을 돌리게 되면 클라이언트 요청에 대한 핸들러는 물론 그 외에 리소스를 필요 이상으로 많이 사용하게 되어 heap 메모리가 빨리 차게 되고, gc와 같은 리소스 매니징에 손이 더 자주 가게 된다는 단점이 있습니다. 따라서 Java와 같은 클라이언트 테스트 코드로는 10K는 고사하고 3~4K 커넥션에서 메모리 부족으로 죽는 경우가 많았지만, GoLang으로 짠 프로그램은 10K는 거뜬하게 처리해주었습니다. 그렇게 GoLang을 통해 성공적으로 10K 커넥션을 맺는 것을 확인 후 메모리 사용률 그리고 커넥션 카운트 확인이 가능하였습니다.

위 그림에서와 같이 GoLang으로 만든 클라이언트는 개발장비 한 대에서 수천 개의 연결을 거뜬이 처리할 수 있었으며, 원하는 시점에 원하는 만큼의 커넥션을 열고 닫을 수 있었습니다.

이를 통해서 커넥션 성공 후에는 STOMP를 사용해 SUBSCRIBE까지 성공을 했는지 확인을 해보는 테스트가 가능하게 되었고, Application에 대한 다양한 튜닝을 진행할 수 있었습니다.

 

Docker

 

이 글에서 다루고 있는 라이브 댓글 웹소켓 서버는 도커 위에서 돌게 되어있습니다. 따라서 테스트 초기에 커넥션이 요청 수만큼 발생하지 않거나 정상적으로 유지되지 않는 현상을 분석할 때, 호스트와 도커 컨테이너와의 ulimit이 별도 설정이 가능하여 컨테이너의 fileDescriptor를 높여주고 테스트 해 본 적이 있었습니다. 관련해서 조금 더 자세히 이야기해보자면,


주소 지정을 위한 네 개의 필드

각 TCP / IP 패킷에는 기본적으로 주소 지정을 위한 5개의 필드가 있습니다.

  • source IP
  • source port
  • target IP
  • target port
  • protocol

 

TCP 스택 내에서는 필드들이 패킷 연결(예: fileDescriptor)과 일치시키는 복합 키로 사용됩니다.

 

포트의 한계

일반적으로 포트의 한계를 흔히들 64K라고 하는데 정확히는 다음과 같습니다.

하나의 클라이언트에서 하나의 서버에 여러 개의 접속을 열게 된다면, 이 5개의 값 중에서 4개가 동일하고 오직 클라이언트 포트만 달라지므로 클라이언트 포트가 한계값이 됩니다. 따라서 포트는 16비트로 64K 까지만 유효하므로 64K가 연결의 한계가 됩니다.

하지만 대부분 여러 클라이언트가 각각 서버에 접속할 것이므로 이 한계는 크게 의미가 없고, 서버에 다중 포트 또는 multihomed(ip가 여러개)인 있는 경우 이 한계는 더욱 늘어나는데요, 기본적으로 서버에서 64K의 포트가 준비되어 있고 포트별로 64K의 커넥션을 처리할 수 있습니다. 따라서 64K^2개의 커넥션이 가능하고 결과적으로 연결의 제한은 사실상 fileDescriptor가 됩니다.

각각의 개별 소켓 연결은 fileDescriptor에 할당되기 때문에 결과적으로 제한은 실제로 시스템이 허용하는 fileDescriptor 수와 리소스가 처리할 수 있는 수에만 영향을 받게 됩니다.

$ ulimit -a (라이브 댓글 서버)

core file size                         (blocks -c) 0
data seg size                         (kbytes, -d) unlimited
scheduling priority                           (-e) 0
file size                              (blocks -f) unlimited
pending signals                               (-i) 653514
max locked memory                     (kbytes, -l) 64
max memory size                       (kbytes, -m) unlimited
open files                                    (-n) 65536
pipe size                          (512 bytes, -p) 8
POSIX message queues                   (bytes, -q) 819200
real-time priority                            (-r) 0
stack size                            (kbytes, -s) 8192
cpu time                             (seconds, -t) unlimited
max user processes                            (-u) 65536
virtual memory                        (kbytes, -v) unlimited
file locks                                    (-x) unlimited

이 중 중요한 값은 아래 두개입니다.

open files                                    (-n) 65536
max user processes                            (-u) 65536

그리고 open files 값의 최대치는 현재 커널의 최대값에 영향을 받습니다.

# 현재 커널의 open files 최대값
$ cat /proc/sys/fs/nr_open

1048576

# open files 값을 수정하기 위해서는 ulimit을 사용하거나 아래 limits.conf 파일을 수정해서 처리할 수 있다.
$ sudo vi etc/security/limits.conf

*         soft   nofile        65536
*         hard   nofile        65536
root      soft   nofile        65536
root      hard   nofile        65536

위 방법을 통해 fileDescriptor를 서버 한계에 맞춰 늘리고 줄이는 게 가능하며, 포트의 한계를 높이기 위해 도커 컨테이너 설정을 바꾸는 것도 최대 커넥션 수를 추가적으로 늘릴 수 있는 좋은 방법이 될 수 있습니다.

 

메모리 테스트

 

메모리 관리 테스트

메모리부터 문제가 없는지 그리고 커넥션은 최대 몇만까지 받을 수 있는지 확인을 해보았습니다.

  • VM 서버에서 2기가 메모리 할당하여 30k 커넥션까지 성공하다가 메모리 부족으로 프로세스가 멈추는 현상 발견
  • 테스트 서버에서도 50k 커넥션에 도달하면 GC 완료까지 너무 오래 걸리는 현상 발견

 

위 문제점들에 대한 해결은 아래에서 다뤄보도록 하겠습니다.

 

커넥션 관리 테스트

초반 스트레스 테스트 단계에서는 nGrinder를 사용하여 STOMP SUBSCRIBE까지 성공하는지 원큐에 확인하려고 했습니다. 하지만 fileDescriptor 메모리에 문제가 없는데도 불구하고 40k 시도 했을 때 30k만 성공하고 있었고, SUBSCRIBE 성공 여부는 체크도 안하고 커넥션 갯수만 Neo에서 확인하고 있었습니다. 당시에는 표면적으로는 별 다른 문제가 없어보이는데도 커넥션에 실패가 있어 VIP를 의심해 보았습니다.

 

VIP

 

우선 VIP 구성은 다음과 같았습니다.

  • inline 방식 사용 (전사 SSL 인증서 처리를 위해 필요)
  • sticky 옵션 사용
  • websocket 옵션 사용
  • max TPS: 2800
  • health check L7 방식 사용
  • routing SOURCEIPHASH 사용

 

TPS를 꽤 높게 잡았다고 생각했는데도 불구하고 초기에는 대량 커넥션을 테스트하는 도중 문제가 발생했었고 그 때마다 VIP를 여러차례 의심했었습니다.

메모리, CPU 사용량, fileDescriptor 모두 넉넉한 상황에서도 커넥션을 생성하지 못하는 때가 많았습니다. 그래서 서버에 직접 붙어서 문제가 있는지 확인을 해보았고, 확인 결과 서버 자체에는 문제가 없었고 VIP의 TPS가 테스트로 붙는 클라이언트 속도를 대응하지 못하고 있었음을 인지하게 되었습니다. 특히 경량화된 GoLang 앱을 통해서 클라이언트 생성을 할 경우 클라이언트가 너무 빠르게 서버에 붙게 되어 무리가 된다는 사실도 파악할 수 있었습니다. 그리고 이를 해결하기 위해 기존에 설정했던 최대 접속 클라이언트 수와 더불어 초당 접속을 시도하는 클라이언트 수도 고려하여 테스트 및 TPS 리밋을 수정해 나갔습니다.

 

VM Server에서의 이상 증상?

 

테스트 중에 제일 큰 변수로 작용한 부분이 서버장비였습니다. 테스트를 진행하면서 안정적인 커넥션이 이루어지지 않고 있을 때 서버의 메모리와 커넥션 수만 집중적으로 분석하였으나, 임의의 서버들에서 불규칙적으로 메모리 부족 현상이 발생하고 있어 앱 자체에서 메모리 릭 또는 데드락이 있는지 지속적으로 확인해 보았습니다. 물론 이어지는 후속글(Spring의 동시성 접근 제어에 발목 잡힌 이야기)에서 다루는 데드락 현상도 있었지만, 그 이슈가 해결된 이후에도 문제가 지속되어 이를 추가적으로 분석해보았습니다. 그리고 사내 인프라 모니터링 시스템을 활용해 서버가 멈췄을 당시 메모리 사용량을 알아본 결과, 평소와 같이 여분 메모리도 넉넉하다는 것을 알게 되었습니다.

위 그림은 당시 문제가 되고 있던 서버의 상태를 보여주는 그래프입니다. 왼쪽 중앙의 그래프가 Tomcat의 커넥션 수치를 나타내는데, 라벨이 표시된 부분에서 수치가 순간적으로 떨어지는 증상이 발생하는 것을 확인할 수 있습니다. 그런데 여기서 특이한 사실은 Tomcat 커넥션 수치는 약 6.1k에서 약 4.8k으로 떨어졌는데 OS에서 인식하는 file descriptor(오른쪽 세번째)는 약 2.3k에서 1k로 떨어졌다는 점인데요, 절대값으로 보면 비슷한 숫자가 줄어들긴 했지만 Tomcat 커넥션 갯수와 file descriptor의 숫자가 비슷해야 하는 점을 고려해볼 때, 알 수 없는 이유로 Tomcat에 Garbage 커넥션이 생기고 있는 것으로 보였습니다.

당시 발주가 빠른 OpenStack VM 장비를 사용하여 테스트 중이었는데, 인프라 측에 문의해보니 VM의 경우 같은 PM을 공유하기 때문에 타 서비스에서 PM의 리소스를 많이 사용하게 되면 우리쪽에도 문제가 된다는 것을 알게 되었습니다(일명 시끄러운 이웃). 그 이후로 PM 장비를 발주 받아 테스트하게 되었고 다시는 간헐적인 메모리 문제가 발생하지 않았습니다.

 

Application 튜닝

 

 

Spring WebSocket Application

 Spring STOMP의 소켓 연결 프로세스 분석

위에서 언급한 Neo와 또 다른 Java 모니터링 툴인 VisualVM으로 클라이언트에서 연결 시도시 프로세스 모니터링을 진행하였습니다. 이 때 메모리 사용 그래프에서 봉우리가 두 개가 나타났는데, 한 개의 커넥션이 생성되는데 왜 두번에 걸쳐 메모리 사용이 나타나는지 분석하였습니다.

이에 대한 확인은 클라이언트의 연결 시도 시 호출되는 configureClientInboundChannel 메소드에서 디버거로 진행해보았습니다. STOMP 커넥션을 한번 맺는데에는 CONNECT, SUBSCRIBE 커멘드가 처리가 되어야 하는데, 처음에는 위에 언급한 봉우리가 각각 CONNECT 그리고 SUBSCRIBE를 처리하기 위해 발생한 현상이라고 생각했습니다.

하지만 확인해 본 결과 예상외로 DISCONNECT가 한 봉우리, CONNECT와 SUBSCRIBE가 다른 한 봉우리를 만들고 있었고, CONNECT 전에 무조건 DISCONNECT가 일어나고 있었습니다.

또한 DISCONNECT 와 SUBSCRIBE 시 Task가 여럿 발생하는 것도 확인하였습니다.

CONNECT 시 다음 4개의 task 생성:

 

  • WebSocketAnnotationMethodMessageHandler : MessageMapping 및 /app 등록
  • SimpleBrokerMessageHandler : subscriptionRegistry를 사용하여 subscription 트래킹
  • UserDestinationMessageHandler : broker 채널에 메시지 전달
  • SubProtocolWebSocketHandler : 메시지를 client에 전달

 

DISCONNECT 시 다음 3개의 task 생성:

 

  • WebSocketAnnotationMethodMessageHandler : MessageMapping 및 /app 등록
  • SimpleBrokerMessageHandler : subscriptionRegistry를 사용하여 subscription 트래킹
  • UserDestinationMessageHandler : broker 채널에 메시지 전달

 

커넥션이 몰리게 되면 위 task들이 빠르게 생성되고, 제 시간에 처리가 되지 못한 task들은 queue에 쌓아놓고 ThreadPoolTaskExecutor가 차례대로 처리하는 것도 확인했으며, queue처리 방식도 별도로 조작이 가능한 것을 확인하였습니다.

선택할 수 있는 queue 처리 방식은 다음과 같습니다.

 

Direct handoffs

  • task를 queue에 쌓지 않고 바로 thread에 전달
  • 여분의 thread가 없으면 바로 fail
  • threadPool 사이즈 리밋 없도록 설정
  • 예: SynchronousQueue

 

Unbounded queues (기본값)

  • 설정된 코어 사이즈 이상으로 thread를 만들지 않고 task 대기
  • 스레드 maximumPoolSize 설정이 무시됨
  • 예: LinkedBlockingQueue

 

Bounded queues

  • thread 갯수와 queue 크기 조율 가능
  • 예: ArrayBlockingQueue

 

이 때 queue를 처리하는 TaskExecutor는 종류는 inbound와 outbound 각각 별도 지정이 가능한데요, 원하는 방식의 TaskExecutor를 생성하여 configureClientInboundChannel 또는 configureClientInboundChannel에 각각 넘겨주면 됩니다.

 

SynchronousQueue 방식 검토 후 탈락 결정

다량의 커넥션 생성이 요청될 때 메모리가 빨리 차는 부분들을 해결하기 위하여, LinkedBlockingQueue 방식에서 서버에 부하를 줄여줄 수 있는 SynchronousQueue 방식으로 처리하도록 수정해보았습니다. 이렇게 변경할 경우 리소스가 부족할 때 일정 수치 이상의 요청을 무시하게 되지만 서비스 전체 장애가 발생하지 않을 수 있다는 장점이 있습니다.

또한 queueCapacity 가 0보다 크면 무조건 LinkedBlockingQueue 를 생성하고 queueCapacity 사이즈로 queue 사이즈가 정해지는 구조이기 때문에 SynchronousQueue로 사용하기 위해서는 queueCapacity 설정하면 됩니다.

좀 더 깊이 들어가보면, 어떤 메시지가 소켓 서버로 유입되면 ThreadPoolExecutor.execute() 가 호출됩니다.

그리고 corePoolSize보다 쓰레드 수가 적으면 쓰레드를 추가하고 (corePoolSize 는 우선은 min thread size 동일하다고 이해) corePoolSize보다 많으면 workQueue.offer(command) 를 호출해서 queue에 우선 넣어봅니다. 위에서 알 수 있듯이 LinkedBlockingQueue가 Integer.MAX_VALUE의 size로 설정 되었고, 해당 queue가 쌓이기 전까지는 대부분 true가 리턴될 것이고, 밀린 task는 queue에 넣었으니 쓰레드를 생성하지 않게 됩니다. 그리고 아무리 메시지가 쌓여도 쓰레드는 늘지 않고 task는 queue에만 쌓이고 메모리는 올라가고 응답속도도 느려집니다.

정리하자면, SynchronousQueue를 설정하게 되면 queue가 없는 것과 같은 효과가 발생해서 task가 queue에 쌓이지 않으며 바로 쓰레드가 생성되어 task가 실행됩니다.

또한 corePoolSize는 queue에 쌓이기 전 쓰레드를 생성하는 기준 카운트가 됩니다. 즉, 쓰레드가 부족하면 corePoolSize만큼은 항상 생성을 하게 되고, corePoolSize내에 속하는 쓰레드도 keepAliveTime이 지나면 종료가 필요할 수 있는데, 이것을 결정하는 것이 AllowCoreThreadTimeOut 값입니다. 만약 true이면 corePoolSize이내의 쓰레드도 keepAliveTime이 지나면 종료되고, false이면 한번 생성된 corePoolSize이내의 쓰레드는 계속 살아있게 됩니다.

하지만 SynchronousQueue는 위에서 이야기한 바와 같이 커넥션 리밋에 다다르게 되면 그 이후의 요청들은 모두 무시하게 되어 뒤늦게 접근하는 사용자들의 경우 라이브 댓글이 작동하지 않는 것처럼 보이는 현상이 있어 사용하지 않기로 결정하였습니다.

결과적으로 SynchronousQueue 사용시 쓰레드가 너무 많이 발급되어 위와 같은 메시지를 출력하면서 다량의 사용자 요청을 처리하지 못하게 되는데요, 이 때문에 10k 요청 처리가 불가능해져서 사용할 수 없었습니다.

 

GC를 고려한 메모리 튜닝 진행

메모리 튜닝을 위해서 queueCapacity, corePoolSize, maxPoolSize를 조절해가며 테스트를 진행하였으며, queue와 쓰레드 갯수에 비례해 메모리 사용량도 늘기 때문에 Java 메모리 Xms와 Xmx 그리고 자바 쓰레드 메모리 스택 사이즈인 Xss도 함께 조절해가며 테스트를 진행하였습니다. Xss도 기본값을 사용하지 않고 별도 설정이 필요했던 이유는 각 커넥션 관련 스레드가 헤비한 계산을 하거나 저장할 내용이 많은 것은 아니여서 평소보다 더 작은 스택을 사용할 수 있으며 이로 인해 추가적인 메모리 효율화가 가능해지기 때문입니다.

일반적으로 커넥션 하나를 생성할 때 필요한 쓰레드 메모리를 파악하여 최소한의 메모리보다 약간 더 큰 사이즈를 잡아주어 간단하게 수정하였습니다. 하지만 JVM 메모리의 경우는 계산이 복잡했는데요, 분석해본 결과 메모리는 웹소켓을 통한 SEND 커맨드 같이 서버가 서비스 관련 메시지를 보낼 때 보다 SUBSCRIBE/UNSUBSCRIBE(이하 SUB/UNSUB)시 더 많이 사용됩니다.

라이브 댓글의 경우 댓글이 들어간 페이지가 로딩되면 소켓 통신을 통해 무조건 SUB를 하기 때문에 이러한 SUB/UNSUB이 소켓을 사용하는 소규모 채팅방보다 훨씬 자주 발생하게 되어 성능이 현저히 더 낮아지는 상황이였습니다. 그리고 SUB/UNSUB가 자주 일어나게 되면 Xss와 상관 없이 GC가 자주 일어나게 되고, GC에 소요되는 시간이 길어지면서 트래픽이 몰릴 때 처리 속도가 밀리고 장애로 이어지게 됩니다.

결론적으로 여러차례의 테스트를 해본 결과 최대 SUB/UNSUB 빈도 수를 2800TPS을 기준으로 GC가 적절하게 발생하도록 설정하였습니다. 이 때 적절함의 기준은 GC가 발생했을 때 요청 처리 속도가 GC 발생 전보다 현저하게 느려지지 않고, 다음 GC전까지 회복을 할 수 있는 수준으로 기준을 잡았습니다.

 

Extra: Native Thread 분석

테스트 중 지속적으로 memory leak이 있는 듯한 현상을 보여 한참을 고민하던 중 native memory 쪽에 문제가 있지 않을까 하여 분석해보기로 하였습니다. 이를 위해 사용한 것은jcmd인데요, jcmd는 오라클에서 만든 자바 유틸리티인데 모니터링 관련 커맨드를 JVM에 전달하여 heap dump, class histogram, stack trace 등 다양한 상태값들을 볼 수 있게 해줍니다. 물론 VisualVM에서도 이러한 정보들을 제공해 줄 수 있지만 native thread에 대한 정보는 제공이 되지 않아 jcmd를 사용하게 되었습니다.

 

Tomcat

톰캣에서도 커넥션 처리수 설정을 통해 지속적으로 한번에 받을 수 있는 커넥션을 제한 시키는 것을 검토하였으며, 목표는 서버당 10k 커넥션을 받을 수 있는 것이 목표였기에 10k만 받도록 설정하였습니다. 위에서도 언급 하였듯이 설정된 커넥션이 모두 찬 이후에 접속하는 사용자들에 대해서는 장애가 될 여지가 있지만, 커넥션이 과다하게 발생했을 시 서버 자체가 멈추지 않도록 안전장치를 걸어두는데 목적이 있었습니다.

 

마치며

 

소켓 테스트는 커넥션 연결, 커넥션 유지, 커넥션 종료 이후 메모리 반납이 모두 잘 처리가 되는지 확인이 필요한 상황에서 넘어야 할 산이 너무 많았습니다. 단순히 호출 한번에 응답 한번으로 끝나는 일반 API와는 달리 서버 상태가 중간에 잠시의 끊김 현상도 허용하지 않는 지속적으로 안정적 상태가 유지 되어야만 서비스가 가능한데요, 따라서 트래픽 부담을 줄이기 위한 캐시 기능도 불가한 상황에서 기존과는 다르게 각 요청 및 프로세스가 어느 정도의 리소스를 사용하는지 꼼꼼한 분석만이 답이였기에 색다른 경험이 되었던 것 같습니다.

이어서 다양한 모니터링 및 디버깅 툴의 실제 활용 사례로써 Spring SimpleBroker 튜닝기를 소개할 예정입니다. Spring을 이용해서 WebSocket을 구현했는데 대용량 서비스에 적용했다가 성능 저하로 어려움을 겪어본 경험이 있는 독자들에게 조금이나마 도움이 되길 기대해 봅니다. 감사합니다!

 



인터랙션플랫폼셀에서는 더 좋은 서비스를 위해 고민을 하고 함께 문제를 해결해 나갈 분을 찾고 있습니다. 아래 모집 공고를 참고하여 많은 관심과 지원 부탁드립니다. : )
“인터랙션플랫폼(댓글, 투표, 공감)웹 서비스 개발자 모집” 바로가기!

 

실시간 댓글 개발기

 

카카오톡 공유 보내기 버튼

Latest Posts