WAS 커넥터 튜닝 — 스레드는 몇 개가 맞나
앞 편까지가 "요청을 받아 넘기는" 쪽이었다면, 이 편은 넘겨받는 쪽이다. Tomcat 을 예로 들지만, 숫자의 의미는 어느 WAS 든 같다.
1. 요청이 WAS 안에서 겪는 세 단계
- ① OS 가 SYN 을 받아 accept 큐에 넣는다 — ← acceptCount
- ② WAS 가 accept 해서 연결로 인정한다 — ← maxConnections
- ③ 처리할 스레드를 할당한다 — ← maxThreads
세 값은 서로 다른 층의 한도다 "연결을 받는 것" 과 "처리하는 것" 이 분리돼 있다는 게 핵심이다
2. Tomcat 11.0.24 기준 기본값
| 속성 | 기본값 | 의미 |
|---|---|---|
maxThreads | 200 | 동시에 요청을 처리할 최대 스레드 수 |
maxConnections | 8192 | 동시에 수용·유지할 최대 연결 수 |
acceptCount | 100 | maxConnections 도달 후 OS 큐에 대기시킬 길이 |
connectionTimeout | 60000 | 연결 수락 후 요청 라인이 올 때까지 기다리는 시간(ms) |
keepAliveTimeout | connectionTimeout 값 | 다음 요청을 기다리는 시간 |
maxKeepAliveRequests | 100 | 한 연결에서 처리할 최대 요청 수 |
함정 —
connectionTimeout의 문서 기본값은 60000 이지만, 배포되는server.xml은 20000 으로 설정한다. 문서만 보고 "기본 60초" 라고 하면 실제 환경과 다르다. 값을 논할 때는 항상 "무엇 기준인지" 를 밝힌다.
-
maxConnections(8192) > maxThreads(200) 인 것이 의도적이다
-
연결을 8천 개 유지하면서 실제 처리는 200개만 동시에 한다
-
keep-alive 로 붙어만 있는 연결이 스레드를 잡지 않는다
-
NIO 커넥터라서 가능하다 (연결과 스레드가 1:1 이 아니다)
3. 그래서 스레드는 몇 개가 맞나
"많을수록 좋다" 는 틀렸다. 여기서 커넥션 풀과 정확히 같은 함정이 반복된다.
리틀의 법칙으로 하한을 구한다
필요한 동시 처리 수 = 처리량(TPS) × 평균 응답시간(초)
예) 1,000 TPS · 평균 50ms
- 1000 × 0.05 = 50 — → 스레드 50개면 이론상 처리된다
여유를 감안해 2~3배로 시작한다 → 100~150
상한은 무엇이 정하나
-
CPU 바운드 작업이면 — 코어 수를 크게 넘겨 봐야 컨텍스트 스위치만 는다
-
I/O 대기가 길면 — 대기하는 동안 스레드가 놀므로 코어보다 많아도 된다
- 다만 뒤에 있는 자원(DB 커넥션 풀)이 진짜 상한이다
-
스레드 200개 · DB 커넥션 20개라면
- 180개는 커넥션을 기다린다
- 스레드를 400개로 늘려도 DB 앞의 줄만 길어진다
- 오히려 대기 시간이 타임아웃을 넘겨 낭비가 된다
가장 중요한 규칙 — 스레드 수는 뒤에 있는 가장 좁은 자원에 맞춘다. 앞을 넓히면 병목이 사라지는 게 아니라 줄이 다른 곳으로 옮겨갈 뿐이다.
메모리 관점
스레드 하나 = 네이티브 스택 (리눅스 기본 1MB 예약)
maxThreads 2000 으로 올리면
숫자를 올릴 때는 반드시 메모리 예산과 함께 본다
4. 큐가 어디에 생기는지가 중요하다
maxConnections 에 도달하면 → OS accept 큐(acceptCount)에서 대기
- acceptCount 까지 차면 — → OS 가 연결을 거부한다 (클라이언트는 연결 거부/타임아웃)
즉 부하가 몰릴 때 순서는
- ① 스레드가 모자라 요청이 기다린다 (연결은 유지, 응답만 느려진다)
- ② 연결이 모자라 accept 큐에 쌓인다
- ③ 큐도 차서 연결이 거부된다
어느 단계에서 막히길 원하는지를 정하는 것이 설계다.
-
빠른 실패를 원한다 — acceptCount 를 작게 → 거부가 빨리 나고 앞단이 다른 서버로 보낸다
-
버티기를 원한다 — 크게 → 대기가 늘지만 끊기지는 않는다
-
큐를 크게 잡는 것은 대체로 나쁜 선택이다
- 큐에서 오래 기다린 요청은 클라이언트가 이미 포기한 요청일 가능성이 높다
- 처리해 봐야 버려진다 (일은 하고 값은 못 받는다)
5. 타임아웃은 계층마다 있고, 어긋나면 사고가 난다
- 클라이언트 타임아웃 — 3초
- CDN / LB 유휴 타임아웃 — 60초
- nginx proxy_read_timeout 60초
- WAS connectionTimeout — 20초
- DB 쿼리 타임아웃 — 없음 ← 여기가 구멍
바깥이 안쪽보다 짧으면
-
클라이언트는 이미 끊었는데 서버는 계속 처리한다 → 자원 낭비 바깥이 안쪽보다 훨씬 길면
-
죽은 요청을 오래 붙들고 있다 → 스레드·커넥션 고갈
원칙: 안쪽으로 갈수록 타임아웃이 짧아지게 잡는다
- 그리고 '없는' 타임아웃이 가장 위험하다
nginx 쪽의 대응 지시자:
location /api/ {
proxy_pass http://backend;
proxy_connect_timeout 3s; # 업스트림에 연결하는 데까지
proxy_send_timeout 10s; # 요청을 보내는 중 무응답 허용 시간
proxy_read_timeout 30s; # 응답을 읽는 중 무응답 허용 시간
proxy_next_upstream error timeout http_502 http_503;
}
proxy_next_upstream에non_idempotent를 넣지 않는 한, POST 같은 비멱등 요청은 재시도하지 않는다. 재시도가 중복 처리를 만들 수 있기 때문이다. 이 기본값을 함부로 바꾸지 않는다.
6. graceful shutdown — 배포마다 요청이 잘리지 않게
잘못된 순서
- ① 프로세스에 SIGTERM
- ② 즉시 종료
- 처리 중이던 요청이 그대로 끊긴다 (5xx)
- LB 는 아직 이 서버를 '정상' 으로 알고 새 요청을 계속 보낸다
올바른 순서
-
① 헬스체크를 실패로 바꾼다 (준비 상태 해제)
-
② LB 가 이 서버를 뺄 때까지 기다린다 (헬스체크 주기 × 실패 임계 + 여유)
-
③ 새 요청 수용 중단
-
④ 처리 중인 요청 완료 대기 (grace period)
-
⑤ 종료
-
② 를 빠뜨리는 것이 가장 흔하다 —
-
LB 가 아직 모르는 상태에서 수용을 멈추면 그 사이 요청이 전부 실패한다
컨테이너 환경의 기본 흐름
-
SIGTERM → (terminationGracePeriodSeconds) → SIGKILL
-
grace period 가 처리 시간보다 짧으면 결국 잘린다
-
가장 긴 요청의 처리 시간 + 여유로 잡는다
-
배치성 긴 작업이 있다면 그 경로는 별도 워커로 분리하는 편이 낫다
7. 관측 — 무엇을 봐야 하나
스레드 풀
활성 / 전체 스레드 수 — 상시 포화면 상한이 낮거나 뒤가 느리다
큐 대기 수 — 0 보다 크게 지속되면 병목 신호
연결 현재 연결 수 / maxConnections
- accept 큐 길이 — ss -lnt 의 Recv-Q(대기) · Send-Q(백로그 한도)
응답
p50 / p95 / p99 — 평균은 병목을 숨긴다
상태 코드 분포 — 502/504 가 앞단인지 뒤인지 가른다
앞단 로그로 뒤를 진단한다 nginx access log 에 upstream 시간을 남기면 어디가 느린지 바로 갈린다
log_format upstream_time '$remote_addr "$request" $status '
'rt=$request_time uct=$upstream_connect_time '
'uht=$upstream_header_time urt=$upstream_response_time';
-
request_time — nginx 가 요청을 받아 응답을 다 보낼 때까지 (클라이언트 전송 포함)
-
upstream_connect_time — 업스트림에 연결하는 데 걸린 시간
-
upstream_header_time — 업스트림이 첫 헤더를 줄 때까지 (= 서버 처리 시간에 가깝다)
-
upstream_response_time — 업스트림 응답을 다 받을 때까지
-
rt 는 큰데 urt 가 작다 — → 뒤는 빠르다. 느린 클라이언트이거나 앞단 문제
-
urt 가 크다 — → 애플리케이션이 느리다
-
uct 가 크다 — → 연결 자체가 안 된다. 포화·네트워크·풀 고갈
이 한 줄짜리 로그 포맷이 "앞이냐 뒤냐" 를 가르는 가장 싼 도구다. 트랙을 열 때 가장 먼저 넣는다.
한눈에 정리
- 세 층 acceptCount(OS 큐) · maxConnections(수용) · maxThreads(처리)
- 기본값 — 200 / 8192 / 100 · connectionTimeout 60000
- 단 배포 server.xml 은 20000 — 문서 기본값과 다르다
- 스레드 수 — 리틀의 법칙으로 하한, 뒤의 가장 좁은 자원으로 상한
- 앞을 넓히면 줄이 옮겨갈 뿐이다
- 큐 크게 잡는 것이 대체로 나쁘다 — 버려진 요청을 처리하게 된다
- 타임아웃 — 안쪽으로 갈수록 짧게. '없는' 타임아웃이 가장 위험하다
- graceful — 헬스체크 실패 → LB 제외 대기 → 수용 중단 → 완료 대기 → 종료
- LB 제외 대기를 빠뜨리는 것이 가장 흔하다
- 관측 — upstream_*_time 을 로그에 남기면 앞·뒤가 즉시 갈린다
출처 — Apache Tomcat 11.0.24 Config Reference: HTTP Connector (maxThreads · maxConnections · acceptCount · connectionTimeout · keepAliveTimeout · maxKeepAliveRequests) · nginx docs
ngx_http_proxy_module·ngx_http_log_module