maxThreads 200が実際に何を意味するのか
한국어 원문으로 표시합니다.
한 줄 요약
acceptCount·maxConnections·maxThreads 는 각각 다른 지점에서 다른 일을 하며, 느린 응답의 원인이 스레드 부족이 아닐 때 maxThreads 를 올리면 상황이 나빠진다.
왜 이 구분이 필요한가
세 값을 하나로 뭉뚱그리면 조치가 늘 같아진다 — 숫자를 올리는 것. 그런데 각각이 막히는 지점이 다르므로 증상도 다르다. acceptCount 를 넘기면 커널이 연결 자체를 거절해 Connection refused 가 나고, maxConnections 를 넘기면 연결은 되는데 응답이 안 오며, maxThreads 를 넘기면 요청이 큐에서 순서를 기다린다. 증상만 보고 어느 숫자를 만질지 고를 수 있어야 한다.
세 개의 숫자가 하는 일이 각각 다르다
성능 이슈가 생기면 누군가 반드시 이렇게 말한다. "maxThreads 를 올려 보죠." 그런데 대부분의 경우 그 조치는 아무것도 바꾸지 못하거나, 상황을 더 나쁘게 만든다. 왜인지 알려면 세 값이 각각 어느 지점에서 동작하는지 알아야 한다.
[클라이언트 소켓]
|
v
(1) acceptCount ← OS 소켓 백로그 큐. 여기까지 넘치면 Connection refused
|
v
(2) maxConnections ← 톰캣이 동시에 '들고 있는' 커넥션 수
|
v
(3) maxThreads ← 동시에 '처리 중인' 요청 수 (워커 스레드)
|
v
[애플리케이션]
- maxThreads (기본 200) — 동시에 요청을 처리하는 워커 스레드 수. 요청 하나가 스레드 하나를 점유한다. DB 를 기다리는 동안에도 점유한다.
- maxConnections (NIO 기본 10000) — 톰캣이 유지하는 소켓 수. Keep-Alive 로 열려만 있고 요청은 안 보내는 커넥션은 스레드를 안 먹으므로 이 값이 maxThreads 보다 훨씬 커도 된다. 그게 NIO 를 쓰는 이유다.
- acceptCount (기본 100) — maxConnections 마저 다 찼을 때 OS 소켓 백로그에서
기다리는 커넥션 수. 여기까지 넘치면 클라이언트는 503 이 아니라
Connection refused를 받는다. 이 차이가 장애 분석에서 결정적이다.
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 바운드 작업이라면 다르다. 코어 수보다 많은 스레드는 컨텍스트 스위칭 비용만 늘린다. 경험칙은 대략 이렇다.
- I/O 바운드(대부분의 업무 웹앱):
코어 수 × (1 + 대기시간 / 처리시간) - CPU 바운드(암호화, 이미지 처리):
코어 수 + 1
그리고 반드시 확인할 것 — 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" />
connectionTimeout— 커넥션을 수락한 뒤 요청 라인이 도착할 때까지 기다리는 시간. 요청 처리 시간 제한이 아니다. 이걸 착각해서 "타임아웃을 늘렸는데 여전히 느려요"가 나온다.keepAliveTimeout— 다음 요청을 기다리며 커넥션을 유지하는 시간. 길면 커넥션 재사용으로 지연이 줄지만, 유휴 커넥션이 maxConnections 를 갉아먹는다.maxKeepAliveRequests— 한 커넥션에서 처리할 최대 요청 수.-1은 무제한.
앞단에 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"
-Xms와-Xmx를 같게 잡는 것이 서버 애플리케이션의 관행이다. 힙이 늘었다 줄었다 하면서 생기는 GC 를 없앤다.-XX:+HeapDumpOnOutOfMemoryError는 옵션이 아니라 필수다. OOM 은 재현이 어렵다. 터지는 순간 덤프를 못 받으면 원인 분석은 추측이 된다. 단, 덤프 파일은 힙 크기만큼 생기니 디스크 여유를 확인해야 한다.- GC 로그는 성능 문제가 생긴 뒤에 켜면 늦다. 처음부터 켜 두고 파일 회전을 건다.
이 세 줄을 오픈 전에 넣어 두면, 안정화 기간의 장애 분석 시간이 절반이 된다.
현장에서 만나는 모습
성능 회의에서 가장 자주 나오는 조치가 "maxThreads 를 올려 보죠" 다. 그리고 대개 아무것도 나아지지 않는다. 요청이 DB 응답을 기다리는 동안에도 스레드는 점유돼 있으므로, 병목이 DB 라면 스레드를 늘려도 기다리는 스레드만 늘어난다. 오히려 컨텍스트 스위칭과 힙 사용이 늘어 GC 정지가 길어진다.
그래서 순서가 있다. 먼저 스레드 덤프를 떠서 워커들이 무엇을 하고 있는지 본다. 대부분이 RUNNABLE 이면 CPU 가 부족한 것이고, 대부분이 소켓이나 락에서 WAITING 이면 스레드가 아니라 그 뒤가 병목이다. 후자에서 maxThreads 를 올리는 것은 대기실을 키우는 일이지 창구를 늘리는 일이 아니다.