스레드풀, 감으로 잡지 마세요: 부하 테스트로 증명하는 최적의 설정값

@MJ · 15 min read
Created Date · 2025년 09월 27일 11:09
Updated Date· 2026년 02월 18일 03:02

커피빵(CoffeeShout) 서비스의 WebSocket 스레드풀을 부하 테스트 기반으로 튜닝했다. "스레드 몇 개가 적절한가?"라는 질문에 감이 아닌 데이터로 답을 내리는 과정을 기록한다.

왜 스레드풀 튜닝이 필요했는가

커피빵은 실시간 멀티플레이어 게임 서비스다. 여러 명이 방에 모여서 미니게임을 하고, 룰렛으로 당첨자를 뽑는다. 모든 게임 진행이 WebSocket으로 이뤄진다.

Spring의 WebSocket(STOMP) 아키텍처에서는 메시지 처리를 위한 스레드풀이 존재한다. 클라이언트에서 서버로 오는 메시지를 처리하는 inbound 채널, 서버에서 클라이언트로 보내는 메시지를 처리하는 outbound 채널, 그리고 WebSocket 연결 자체를 관리하는 Tomcat 스레드풀이 있다.

Spring Boot의 기본값은 inbound/outbound 모두 1개 스레드다. 개발 환경에서는 문제없지만, 실제 서비스에서 250명이 동시 접속하면 얘기가 다르다. 1개 스레드로 수백 TPS의 메시지를 처리하면 큐에 메시지가 쌓이고, 응답 지연이 발생한다. 게임 서비스에서 응답 지연은 곧 "렉"이고, 렉은 곧 이탈이다.

그렇다고 스레드를 무조건 많이 잡으면 되는 것도 아니다. t4g.small 인스턴스(vCPU 2개, 메모리 2GB)에서 돌리고 있으니 리소스 제약이 있다. 스레드가 CPU 코어 수를 크게 초과하면 CPU 경합, 캐시 무효화, OS 스케줄러 오버헤드 등이 누적되면서 오히려 성능이 떨어진다. "어디까지 늘려야 빨라지고, 어디서부터 역효과가 나는가"를 찾아야 했다.

목표 수치 설정

튜닝 전에 목표부터 정했다. 실제 서비스 운영 데이터를 기준으로 잡았다.

항목 목표값
동시 접속자 250명
Inbound TPS 325 (250명 × 1.3 req/s)
Outbound TPS 1,600 (250명 × 6.4 res/s)
평균 응답시간 40ms 이내
최대 응답시간 100ms 이내

사용자 1명 기준으로, 평균 초당 0.4회 요청 / 0.7회 응답이 발생하고, 최대 초당 1.3회 요청 / 6.4회 응답이 발생한다. 레이싱 게임 중에는 플레이어 위치가 프레임마다 업데이트되기 때문에 응답 TPS가 요청 TPS보다 훨씬 높다. 여기에 방 참여자가 최대 9명이니까, 1건의 요청에 최대 9건의 응답(브로드캐스트)이 발생하는 구조다.

테스트 환경

  • 인스턴스: AWS t4g.small (vCPU 2, RAM 2GB)
  • Redis: AWS ElastiCache t4g.micro
  • 부하 테스트 도구: k6

Inbound 스레드풀 튜닝

Inbound 채널은 클라이언트 → 서버 메시지를 처리한다. 게임 중 플레이어 액션, 방 참여 요청 등이 여기서 처리된다.

스레드 수를 2, 4, 8, 16, 32, 64개로 올려가면서 TPS 171, 373, 513에서 최대 응답시간을 측정했다.

Inbound 최대 응답시간 (ms)

TPS 스레드 2 스레드 4 스레드 8 스레드 16 스레드 32 스레드 64
171 285 113 22 23 22 28
373 7,780 1,959 235 151 113 193
513 15,632 7,250 3,757 2,550 1,747 3,883

목표 TPS 325에 가장 가까운 373 기준으로 보면, 스레드 2개에서는 7,780ms로 사실상 서비스 불가 수준이었다. 8개로 올리면 235ms, 32개에서 113ms까지 떨어진다.

여기서 주목할 점은 64개부터 오히려 느려진다는 것이다. TPS 373에서 32개일 때 113ms인데, 64개에서는 193ms로 올라간다. TPS 513에서도 32개(1,747ms) 대비 64개(3,883ms)로 2배 이상 느려진다.

vCPU가 2개인 인스턴스에서 64개 스레드를 돌리면, 스레드 대 코어 비율이 32:1이 된다. 실제 동시에 실행 가능한 건 2개뿐인데 64개 스레드가 CPU를 잡으려고 경쟁한다. 이때 발생하는 오버헤드가 복합적으로 작용한다. CPU 경합으로 각 스레드의 대기 시간이 길어지고, 스레드가 교체될 때마다 CPU 캐시가 무효화되면서 캐시 미스가 늘어나고, OS 스케줄러가 64개 스레드를 관리하는 비용도 커진다. 이 오버헤드의 합이 스레드를 늘려서 얻는 병렬 처리 이점을 넘어서는 지점이 바로 32 → 64 구간이었다.

결론: inbound 스레드 32개. 8 → 16 → 32까지는 성능이 개선되지만, 64부터는 역효과다. 목표 TPS(325) 기준 최대 응답시간 113ms로, 100ms 목표에 근접한다.

Outbound 스레드풀 튜닝

Outbound 채널은 서버 → 클라이언트 메시지를 처리한다. 브로드캐스트가 주 용도라 inbound보다 TPS가 높다.

스레드 수를 4, 8, 16, 32개로 올려가면서 TPS 290, 700, 1400, 2800, 4200에서 측정했다.

Outbound 최대 응답시간 (ms)

TPS 스레드 4 스레드 8 스레드 16 스레드 32
290 56 22 14 13
700 69 71 19 22
1,400 832 109 96 60
2,800 1,505 182 92 185
4,200 - 226 109 369

목표 TPS 1600에 가장 가까운 1400 기준으로 보면, 16개(96ms)와 32개(60ms) 모두 목표(100ms)를 충족한다. 하지만 TPS 2800에서 패턴이 바뀐다. 16개(92ms)가 32개(185ms)보다 빠르다. TPS 4200에서는 격차가 더 벌어진다. 16개(109ms) vs 32개(369ms).

inbound와 같은 현상이다. 다만 outbound는 inbound보다 역전 지점이 낮다. inbound는 64개에서 역전이 발생했는데, outbound는 32개에서 이미 역전이 시작된다.

이유를 추정하면, outbound 메시지는 주로 브로드캐스트라서 메시지 하나당 처리 시간이 짧다(직렬화 → 전송). 작업 하나의 처리 시간이 짧을수록 스레드 간 전환 빈도가 높아지고, 그만큼 CPU 경합과 캐시 무효화가 더 자주 발생한다. 같은 vCPU 2개 환경이라도, 작업 특성에 따라 최적 스레드 수가 달라지는 것이다.

결론: outbound 스레드 16개. 목표 TPS(1600) 기준 100ms 이내를 충족하면서, 고부하(2800~4200 TPS)에서도 안정적이다.

Tomcat 스레드풀 및 커넥션 설정

WebSocket 연결 수립은 HTTP 핸드셰이크로 시작되기 때문에 Tomcat 스레드풀의 영향을 받는다. Spike 테스트(10초간 집중 연결)로 한계를 확인했다.

커넥션 Spike 테스트 (10초간 집중 연결)

초당 연결 총 연결 (10s) 최대 연결시간 (ms) Tomcat 스레드 비고
100 1,000 14 12 안정
150 1,500 22 14 안정
200 2,000 18 21 안정
300 3,000 151 156 지연 발생 시작
400 4,000 638 200 (max) 1.4% 연결 실패
500 5,000 1,015 200 (max) 19.2% 연결 실패

초당 200개 연결까지는 안정적이다. 300개부터 지연이 시작되고, 400개에서 톰캣 스레드가 최대(200)에 도달하면서 연결 실패가 발생한다.

별도로 WebSocket 연결 + 구독(5개 topic) 테스트도 진행했다. 초당 10명씩 천천히 늘려서 총 동시 연결 수의 한계를 확인했더니, 약 4800개 연결에서 OOM이 발생했다. t4g.small의 힙 메모리 한계에 도달한 것이다.

이 데이터를 바탕으로 Tomcat 설정을 잡았다.

server:
  tomcat:
    threads:
      max: 200
      min-spare: 30
    max-connections: 700
    accept-count: 300

max: 200은 Spike 테스트에서 확인된 수용 가능한 최대치다. min-spare: 30은 cold-start 시 스레드 생성 병목을 방지하기 위해 설정했다. 최초 부하 테스트에서 min-spare가 10일 때, 갑작스러운 부하에 스레드 생성이 병목이 되는 현상이 있었다.

max-connections: 700은 목표 동시 접속 250명에 여유분을 더한 값이다. WebSocket 연결 250개 + HTTPS API 요청 여분을 고려했다. 4800개에서 OOM이 터지니까, 그보다 훨씬 낮은 수준에서 제한을 걸어 메모리를 보호한다.

게임 스레드풀

레이싱 게임은 라운드마다 일정 간격으로 게임 상태를 업데이트하는 스케줄러가 있다. 이 스케줄러의 스레드풀도 설정이 필요했다.

스레드 3개로 게임 100개, 200개를 동시에 실행해봤다. 결과는 둘 다 32.5초에 모든 라운드가 지연 없이 완료됐다. 게임 스케줄러의 작업은 "상태 계산 → 메시지 발행"으로 단순하고, 실제 메시지 전송은 outbound 스레드풀에서 처리하기 때문에 스케줄러 스레드가 많이 필요하지 않았다.

결론: 게임 스레드 3개 고정. 200개 게임 동시 실행에도 지연이 없으니 충분하다.

삽질: cold-start에서 응답이 느린 현상

부하 테스트를 반복하면서 이상한 현상을 발견했다. 애플리케이션을 재시작한 직후 첫 번째 테스트에서 응답시간이 비정상적으로 느려지는 것이다. 기존 테스트에서 109ms가 나오던 것이 6,812ms로 뛰었다.

처음에는 톰캣 스레드가 한꺼번에 200개까지 생성되면서 발생하는 비용이라고 생각했다. 하지만 min-spare를 10으로 설정한 이전 테스트에서도 스레드 생성이 문제가 되지 않았으니, 이건 원인이 아니었다.

스레드 덤프를 떠서 확인해보니, 대부분의 스레드가 BLOCKED 상태였다. SockJS에서 세션 ID를 생성할 때 SecureRandom을 사용하는데, 초기 엔트로피가 부족하면 난수 생성에서 블로킹이 발생하는 것이었다. 이건 Tomcat에서도 잘 알려진 이슈로, JVM 옵션에서 /dev/urandom을 사용하도록 설정하거나 워밍업을 해주면 해결된다.

최종 설정

구성 요소 스레드 수 큐 크기 비고
Inbound 채널 32 2,048 TPS 373 기준 113ms
Outbound 채널 16 4,096 TPS 2800 기준 92ms
Tomcat (min-spare / max) 30 / 200 300 (accept-count) max-connections 700
게임 스케줄러 3 (고정) 1,024 200개 게임 동시 실행 OK

핵심 포인트

  • 스레드는 많다고 좋은 게 아니다. vCPU 2개 환경에서 inbound는 64개부터, outbound는 32개부터 오히려 느려졌다. 스레드 수가 코어 수를 크게 초과하면 CPU 경합, 캐시 무효화, 스케줄러 오버헤드가 누적되면서 병렬 처리 이점을 상쇄한다. 역전 지점을 찾아야 한다.
  • inbound와 outbound의 최적 스레드 수는 다르다. 같은 하드웨어에서도 작업 특성에 따라 결과가 달라진다. 처리 시간이 긴 작업(inbound)은 스레드를 더 늘려도 효과가 있고, 처리 시간이 짧은 작업(outbound)은 적은 스레드에서 더 효율적이다.
  • 목표 수치를 먼저 정하고 테스트해야 한다. "빨라질 때까지 늘린다"가 아니라, 실제 트래픽 패턴에서 목표 응답시간을 정하고, 그 목표를 충족하는 최소 스레드 수를 찾는 게 맞다.
  • 커넥션 한계도 테스트해야 한다. 스레드풀만 튜닝하면 안 된다. 메모리 한계(OOM), 연결 수 한계, Spike 상황까지 확인해야 운영에서 안전하다.
  • cold-start 이슈를 간과하지 말아야 한다. 반복 테스트에서는 안 보이는 문제가 최초 실행에서 터질 수 있다. 스레드 덤프를 떠서 원인을 정확히 파악해야 한다.