LabHub
배우기 러닝패스 코스

Tomcat & nginx Operations

What maxThreads 200 Actually Means

LabHub 에서 이어서 보기

한국어 원문으로 표시합니다.

한 줄 요약

acceptCount·maxConnections·maxThreads 는 각각 다른 지점에서 다른 일을 하며, 느린 응답의 원인이 스레드 부족이 아닐 때 maxThreads 를 올리면 상황이 나빠진다.

Concept map: maxThreads · maxConnections · acceptCount · 백로그 포화

왜 이 구분이 필요한가

세 값을 하나로 뭉뚱그리면 조치가 늘 같아진다 — 숫자를 올리는 것. 그런데 각각이 막히는 지점이 다르므로 증상도 다르다. acceptCount 를 넘기면 커널이 연결 자체를 거절해 Connection refused 가 나고, maxConnections 를 넘기면 연결은 되는데 응답이 안 오며, maxThreads 를 넘기면 요청이 큐에서 순서를 기다린다. 증상만 보고 어느 숫자를 만질지 고를 수 있어야 한다.

세 개의 숫자가 하는 일이 각각 다르다

성능 이슈가 생기면 누군가 반드시 이렇게 말한다. "maxThreads 를 올려 보죠." 그런데 대부분의 경우 그 조치는 아무것도 바꾸지 못하거나, 상황을 더 나쁘게 만든다. 왜인지 알려면 세 값이 각각 어느 지점에서 동작하는지 알아야 한다.

            [클라이언트 소켓]
                  |
                  v
   (1) acceptCount        ← OS 소켓 백로그 큐. 여기까지 넘치면 Connection refused
                  |
                  v
   (2) maxConnections     ← 톰캣이 동시에 '들고 있는' 커넥션 수
                  |
                  v
   (3) maxThreads         ← 동시에 '처리 중인' 요청 수 (워커 스레드)
                  |
                  v
              [애플리케이션]

Connection refused 가 찍혔다는 것은 "서버가 죽었다"가 아니라 "서버가 살아 있는데 받을 여력이 없었다"일 수 있다. 앞단 nginx 로그에 connect() failed (111: Connection refused) 가 보이면 백엔드 프로세스 사망뿐 아니라 백로그 포화도 후보에 올려야 한다.

maxThreads 를 올려도 안 되는 이유

요청 처리 시간의 대부분이 DB 대기라면, 스레드를 늘리는 것은 DB 앞에 줄 선 사람을 늘리는 일이다. 처리량은 그대로고 대기 시간만 늘어난다. 심하면 커넥션 풀 고갈로 전체가 멈춘다.

스레드 덤프를 떠서 이런 패턴이 보이면 스레드풀 문제가 아니다.

"http-nio-8080-exec-73" ... WAITING (parking)
  at jdk.internal.misc.Unsafe.park
  at com.zaxxer.hikari.pool.HikariPool.getConnection   ← DB 커넥션 풀 대기

이 덤프의 메시지는 명확하다: 문제는 WAS 스레드풀이 아니라 DB 커넥션 풀이다. maxThreads 를 400 으로 올리면 대기 중인 스레드가 200개에서 400개로 늘 뿐이다.

근거 있게 산정하기 — 리틀의 법칙

감으로 정하지 말고 계산하자. 리틀의 법칙은 이렇게 쓴다.

동시 처리 수 = 목표 처리량(TPS) × 평균 응답시간(초)

목표가 초당 300건이고 평균 응답이 200ms 라면,

300 × 0.2 = 60

최소 60개 스레드가 동시에 일하고 있어야 300 TPS 가 나온다. 여기에 여유율(피크 대비, 보통 20~50%)을 곱한다. 30% 여유면 60 × 1.3 = 78.

CPU 바운드 작업이라면 다르다. 코어 수보다 많은 스레드는 컨텍스트 스위칭 비용만 늘린다. 경험칙은 대략 이렇다.

그리고 반드시 확인할 것 — maxThreads 를 정한 뒤에는 DB 커넥션 풀과의 관계를 본다. WAS 스레드 200개가 모두 DB 를 쓰는데 풀이 20개면, 180개는 대기한다. 반대로 풀을 200개로 키우면 이번엔 DB 쪽 max_connections 가 터진다. 인스턴스가 여러 대면 곱하기가 된다.

서비스 A 12대 × 풀 20 = 240
서비스 B  8대 × 풀 15 = 120
배치 워커 4대 × 풀 5  =  20
                합계   = 380
DB max_connections     = 200   ← 롤링 배포로 인스턴스가 잠깐 두 배 되는 순간 터진다

이 계산을 안 해 본 시스템은 롤링 배포 중에 FATAL: sorry, too many clients already 로 무너진다. 더 나쁜 것은 그 오류가 헬스체크와 모니터링 에이전트까지 막는다는 점이다. 장애 순간에 관측 수단이 함께 사라진다.

Keep-Alive 는 양날이다

<Connector port="8080" protocol="HTTP/1.1"
           connectionTimeout="20000"
           keepAliveTimeout="5000"
           maxKeepAliveRequests="100"
           maxThreads="150" minSpareThreads="20"
           maxConnections="2000" acceptCount="50" />

앞단에 nginx 가 있다면 nginx ↔ 톰캣 사이의 커넥션은 소수이고 재사용률이 높다. 그때는 keepAliveTimeout 을 넉넉히 주는 편이 낫다. 반대로 톰캣이 인터넷에 직접 노출돼 있으면 유휴 커넥션이 자원을 먹으므로 짧게 잡는다. "정답 값"이 아니라 "앞단 구성에 따라 달라지는 값"이다.

공유 Executor

커넥터를 여러 개 두면 각자 스레드풀을 갖는다. 그러면 전체 스레드 수를 통제하기 어렵다. 이때 공유 Executor 를 쓴다.

<Executor name="tomcatThreadPool" namePrefix="labhub-exec-"
          maxThreads="150" minSpareThreads="20"/>
<Connector executor="tomcatThreadPool" port="8080" protocol="HTTP/1.1" ... />

namePrefix 를 의미 있게 주는 것이 실무 팁이다. 스레드 덤프를 떴을 때 labhub-exec-37 처럼 나오면 이게 어느 커넥터의 스레드인지 바로 안다. 기본값 http-nio-8080-exec- 도 나쁘지 않지만, 커넥터가 여러 개일 때는 구분이 필요하다.

힙과 GC 로그는 튜닝 전에 켠다

CATALINA_OPTS="-Xms1g -Xmx1g \
  -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/logs/dump \
  -Xlog:gc*:file=/logs/gc.log:time,uptime:filecount=5,filesize=10m"

이 세 줄을 오픈 전에 넣어 두면, 안정화 기간의 장애 분석 시간이 절반이 된다.

현장에서 만나는 모습

성능 회의에서 가장 자주 나오는 조치가 "maxThreads 를 올려 보죠" 다. 그리고 대개 아무것도 나아지지 않는다. 요청이 DB 응답을 기다리는 동안에도 스레드는 점유돼 있으므로, 병목이 DB 라면 스레드를 늘려도 기다리는 스레드만 늘어난다. 오히려 컨텍스트 스위칭과 힙 사용이 늘어 GC 정지가 길어진다.

그래서 순서가 있다. 먼저 스레드 덤프를 떠서 워커들이 무엇을 하고 있는지 본다. 대부분이 RUNNABLE 이면 CPU 가 부족한 것이고, 대부분이 소켓이나 락에서 WAITING 이면 스레드가 아니라 그 뒤가 병목이다. 후자에서 maxThreads 를 올리는 것은 대기실을 키우는 일이지 창구를 늘리는 일이 아니다.