웹 서버·WAS 학습 노트 목차

WAS 커넥터 튜닝 — 스레드는 몇 개가 맞나

앞 편까지가 "요청을 받아 넘기는" 쪽이었다면, 이 편은 넘겨받는 쪽이다. Tomcat 을 예로 들지만, 숫자의 의미는 어느 WAS 든 같다.


1. 요청이 WAS 안에서 겪는 세 단계

다이어그램 로딩 중…
  • OS 가 SYN 을 받아 accept 큐에 넣는다 — ← acceptCount
  • WAS 가 accept 해서 연결로 인정한다 — ← maxConnections
  • 처리할 스레드를 할당한다 — ← maxThreads

세 값은 서로 다른 층의 한도다 "연결을 받는 것" 과 "처리하는 것" 이 분리돼 있다는 게 핵심이다

2. Tomcat 11.0.24 기준 기본값

속성기본값의미
maxThreads200동시에 요청을 처리할 최대 스레드 수
maxConnections8192동시에 수용·유지할 최대 연결 수
acceptCount100maxConnections 도달 후 OS 큐에 대기시킬 길이
connectionTimeout60000연결 수락 후 요청 라인이 올 때까지 기다리는 시간(ms)
keepAliveTimeoutconnectionTimeout다음 요청을 기다리는 시간
maxKeepAliveRequests100한 연결에서 처리할 최대 요청 수

함정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 으로 올리면

  • 스택만으로 최대 2GB 규모의 주소 공간이 잡힌다
  • 컨테이너 메모리 limit 을 넘기면 커널이 프로세스를 죽인다
    • (OOMKilled — 애플리케이션 로그에 아무것도 안 남는다)

숫자를 올릴 때는 반드시 메모리 예산과 함께 본다

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_upstreamnon_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

TLS 종단과 HTTP/2·3운영 — 무중단 배포와 장애 진단