1. 서론: 대규모 트래픽 앞에서 멈춰서는 WAS의 진실

서비스에 마케팅 이벤트가 시작되거나 출퇴근 시간대에 대규모 사용자가 일시에 몰릴 때, 모니터링 대시보드에 빨간 불이 들어오며 504 Gateway Timeout이나 Connection Refused 에러가 폭증하는 경우가 많습니다.

이때 많은 주니어 엔지니어들이 가장 먼저 시도하는 실수가 **“Tomcat의 maxThreads를 무작정 200에서 2,000으로 늘리는 것”**입니다. 하지만 쓰레드 수만 무작정 키우면 성능이 개선되기는커녕, OS 레벨의 컨텍스트 스위칭(Context Switching) 오버헤드가 급증하고 데이터베이스 커넥션 풀(HikariCP) 경합이 심화되어 CPU 사용률이 100%를 찍으며 서버가 완전히 뻗어버리는 역효과가 발생합니다.

Tomcat이 클라이언트의 TCP 연결을 받아 서블릿 컨테이너로 전달하기까지의 내부 파이프라인과, 이를 통제하는 3대 핵심 파라미터(acceptCount, maxConnections, maxThreads)의 유기적 관계를 명확히 이해해야 올바른 성능 튜닝이 가능합니다.


2. Tomcat 요청 처리 3단계 아키텍처

클라이언트가 HTTP 요청을 보낼 때 Tomcat 내부에서 데이터가 흐르는 3단계 큐와 스레드 풀 구조입니다.

flowchart LR Client["클라이언트 다중 요청"] --> Step1
subgraph Step1 ["1. OS TCP Backlog"]
    AC["acceptCount (OS 대기열 큐: 예 100)"]
    AC_Full["초과 시: Connection Refused 발생"]
end

Step1 --> Step2

subgraph Step2 ["2. NIO Non-blocking Selector"]
    MC["maxConnections (최대 TCP 소켓 연결 수: 기본 10,000)"]
    Poller["Poller 스레드가 Keep-Alive 유지"]
end

Step2 --> Step3

subgraph Step3 ["3. Worker Thread Pool"]
    MT["maxThreads (실제 연산 실행 스레드: 예 200)"]
    MinSpare["minSpareThreads (유휴 상시 스레드: 예 25)"]
    Servlet["Spring DispatcherServlet & DB I/O"]
end

3. 3대 핵심 파라미터 심층 분석

1) acceptCount (운영체제 레벨의 TCP 대기열)

  • 개념: Tomcat의 작업 스레드가 모두 바쁘고(maxThreads 포화), 유지 가능한 최대 연결(maxConnections)까지 가득 찼을 때, 운영체제(OS)의 TCP 소켓 계층에서 대기시키는 큐의 크기입니다.
  • 초과 시 증상: acceptCount 큐마저 꽉 차면 OS는 더 이상의 SYN 패킷을 거부하고 클라이언트에 즉각 Connection Refused 에러를 리턴합니다.
  • OS 커널 연동: Tomcat의 acceptCount를 500으로 설정하더라도, 리눅스 커널 파라미터인 net.core.somaxconn과 net.ipv4.tcp_max_syn_backlog 값이 작으면 OS 수준에서 먼저 잘려 나갑니다. 따라서 리눅스 커널 설정도 함께 조정해야 합니다.
    # /etc/sysctl.conf
    net.core.somaxconn = 1024
    net.ipv4.tcp_max_syn_backlog = 2048
    

2) maxConnections (Tomcat이 유지 가능한 최대 TCP 연결 수)

  • 개념: Tomcat 인스턴스가 동시에 열어둘 수 있는 물리적 TCP 소켓의 최대 한도입니다.
  • NIO 커넥터의 위력: Non-blocking I/O 방식인 Http11NioProtocol에서는 Poller 스레드가 소켓 셀렉터를 감시하므로, 요청을 처리 중이지 않은 유휴(Idle) Keep-Alive 커넥션을 스레드 점유 없이 수만 개까지 가볍게 유지할 수 있습니다.
  • 기본값: 10,000개 (일반적인 웹 서비스 환경에서는 기본값으로도 충분함).

3) maxThreads (실제 서블릿 요청을 실행하는 작업 스레드 수)

  • 개념: HTTP 요청을 받아 비즈니스 로직(Spring Controller, Service, DB I/O)을 직접 수행하는 Worker 스레드의 최대 개수입니다.
  • 기본값: 200개
  • 적정값 계산: 작업 스레드는 생성될 때마다 JVM 힙 외부에서 약 1MB(-Xss)의 네이티브 스택 메모리를 점유합니다. 스레드가 1,000개면 스택 메모리만 1GB를 소비하며, CPU 코어 수가 적은 서버에서 스레드가 너무 많으면 CPU가 실제 연산 대신 스레드 교체(Context Switching)에 대부분의 시간을 허비하게 됩니다.

4. 리틀의 법칙(Little’s Law) 기반 스레드 풀 사이징 공식

내 서비스에 적합한 maxThreads는 감이나 추측이 아니라 대기행렬 이론인 **리틀의 법칙($L = \lambda \times W$)**으로 계산할 수 있습니다.

$$\text{필요 스레드 수}(L) = \text{초당 목표 요청 수}(\lambda) \times \text{평균 응답 시간}(W)$$

  • 실제 계산 예시:
    • 초당 1,000건의 요청(1,000 RPS)을 처리해야 하는 서비스
    • 애플리케이션의 평균 응답 시간(Latency)이 **0.2초(200ms)**인 경우: $$\text{필요 스레드 수} = 1,000 \times 0.2 = 200\text{개}$$

즉, 평균 응답 시간이 200ms라면 maxThreads=200으로 초당 1,000건을 안정적으로 처리할 수 있습니다. 만약 응답 속도를 50ms로 최적화한다면 스레드는 불과 50개만으로도 동일한 1,000 RPS를 감당할 수 있게 됩니다. 즉, 스레드를 늘리는 것보다 애플리케이션의 응답 지연을 줄이는 것이 본질적인 해결책입니다.


5. HikariCP 커넥션 풀과의 상관관계 (중요 함정)

Tomcat 튜닝 시 가장 치명적인 불일치는 Tomcat 스레드 풀과 DB 커넥션 풀(HikariCP)의 비율 불균형입니다.

[Tomcat maxThreads = 500] ──────▶ [HikariCP maximumPoolSize = 10]

Tomcat 스레드를 500개로 넉넉하게 늘려놓더라도, Spring Boot의 DB 커넥션 풀이 기본값인 10개에 묶여 있다면 어떻게 될까요? 동시에 인입된 500개의 스레드 중 10개만 DB 쿼리를 날리고, 나머지 490개의 스레드는 DB 커넥션을 획득하기 위해 락(Lock) 대기 상태에 빠집니다. 그 결과 ConnectionTimeoutException이 터지며 서버 전체가 동결됩니다.

  • 권장 비율: 일반적으로 maxThreads의 20~30% 수준으로 DB 커넥션 풀을 조율하거나, DB 부하를 감안하여 WAS 인스턴스를 수평 확장(Scale-out)하는 구조가 이상적입니다.

6. 프로덕션 server.xml 설정 템플릿

독립된 <Executor> 스레드 풀을 선언하여 커넥터와 분리하고 공유하는 표준 엔터프라이즈 설정입니다.

<Service name="Catalina">

    <!-- 1. 재사용 가능한 공유 스레드 풀 (Executor) 정의 -->
    <Executor name="tomcatThreadPool" 
              namePrefix="catalina-exec-"
              maxThreads="300"
              minSpareThreads="50"
              maxIdleTime="60000"
              prestartminSpareThreads="true" />

    <!-- 2. HTTP/1.1 NIO 커넥터 바인딩 -->
    <Connector executor="tomcatThreadPool"
               port="8080" 
               protocol="org.apache.coyote.http11.Http11NioProtocol"
               connectionTimeout="20000"
               keepAliveTimeout="15000"
               maxKeepAliveRequests="100"
               maxConnections="10000"
               acceptCount="200"
               redirectPort="8443"
               enableLookups="false"
               URIEncoding="UTF-8" />

</Service>
  • minSpareThreads="50": 서버가 유휴 상태일 때도 50개의 스레드를 미리 생성해 두어, 갑작스러운 트래픽 인입 시 스레드 생성 비용(Thread Creation Latency)을 없앱니다.
  • enableLookups="false": DNS 역방향 조회를 비활성화하여 불필요한 네트워크 지연을 차단합니다.

7. 실무 모니터링 명령어와 장애 증상 진단표

장애 증상 주요 원인 파라미터 권장 점검 및 해결책
Connection Refused acceptCount 초과, OS backlog 한도 도달 acceptCount 상향 및 리눅스 somaxconn 튜닝
504 Gateway Timeout maxThreads 부족, DB 커넥션 획득 대기 HikariCP 지연 쿼리 튜닝, maxThreads 점진적 상향
CPU 100% (High Load) 과도한 maxThreads로 인한 Context Switch maxThreads 축소, 스레드 덤프(jstack) 분석

스레드 및 소켓 실시간 상태 확인 CLI

# 8080 포트의 TCP 소켓 상태별 개수 집계
netstat -ant | grep 8080 | awk '{print $6}' | sort | uniq -c

# Tomcat 활성 스레드 목록 및 데드락 상태 확인
jstack <TOMCAT_PID> | grep -E "BLOCKED|WAITING|RUNNABLE" | sort | uniq -c

8. 결론

Tomcat 스레드 풀 튜닝은 단순히 파라미터 수치를 높이는 것이 아니라, **OS 네트워크 백로그(acceptCount) ➔ NIO 연결 풀(maxConnections) ➔ 작업 스레드(maxThreads) ➔ DB 커넥션 풀(HikariCP)**로 이어지는 전체 인프라 파이프라인의 균형을 맞추는 엔지니어링 과정입니다.

부하 테스트 도구(nGrinder, JMeter)를 활용해 임계점을 정확히 측정하고, 본 가이드의 사이징 공식을 바탕으로 점진적으로 최적의 값을 찾아가시기 바랍니다.