Tomcat & nginx 운영 · 커넥터와 스레드풀 튜닝 · 이론
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) — 동시에 요청을 처리하는 워커 스레드 수.
- maxConnections (NIO 기본 10000) — 톰캣이 유지하는 소켓 수.
- acceptCount (기본 100) — maxConnections 마저 다 찼을 때 OS 소켓 백로그에서
요청 하나가 스레드 하나를 점유한다. DB 를 기다리는 동안에도 점유한다.
Keep-Alive 로 열려만 있고 요청은 안 보내는 커넥션은 스레드를 안 먹으므로
이 값이 maxThreads 보다 훨씬 커도 된다. 그게 NIO 를 쓰는 이유다.
기다리는 커넥션 수. **여기까지 넘치면 클라이언트는 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 합계 = 380DB 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— 다음 요청을 기다리며 커넥션을 유지하는 시간.maxKeepAliveRequests— 한 커넥션에서 처리할 최대 요청 수.-1은 무제한.
요청 처리 시간 제한이 아니다. 이걸 착각해서 "타임아웃을 늘렸는데 여전히 느려요"가 나온다.
길면 커넥션 재사용으로 지연이 줄지만, 유휴 커넥션이 maxConnections 를 갉아먹는다.
앞단에 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를 같게 잡는 것이 서버 애플리케이션의 관행이다.-XX:+HeapDumpOnOutOfMemoryError는 옵션이 아니라 필수다.- GC 로그는 성능 문제가 생긴 뒤에 켜면 늦다. 처음부터 켜 두고 파일 회전을 건다.
힙이 늘었다 줄었다 하면서 생기는 GC 를 없앤다.
OOM 은 재현이 어렵다. 터지는 순간 덤프를 못 받으면 원인 분석은 추측이 된다.
단, 덤프 파일은 힙 크기만큼 생기니 디스크 여유를 확인해야 한다.
이 세 줄을 오픈 전에 넣어 두면, 안정화 기간의 장애 분석 시간이 절반이 된다.
현장에서 만나는 모습
성능 회의에서 가장 자주 나오는 조치가 "maxThreads 를 올려 보죠" 다. 그리고 대개 아무것도 나아지지 않는다. 요청이 DB 응답을 기다리는 동안에도 스레드는 점유돼 있으므로, 병목이 DB 라면 스레드를 늘려도 기다리는 스레드만 늘어난다. 오히려 컨텍스트 스위칭과 힙 사용이 늘어 GC 정지가 길어진다.
그래서 순서가 있다. 먼저 스레드 덤프를 떠서 워커들이 무엇을 하고 있는지 본다. 대부분이 RUNNABLE 이면 CPU 가 부족한 것이고, 대부분이 소켓이나 락에서 WAITING 이면 스레드가 아니라 그 뒤가 병목이다. 후자에서 maxThreads 를 올리는 것은 대기실을 키우는 일이지 창구를 늘리는 일이 아니다.