본문 바로가기
Research Notes (개인)

K8s + Spring Boot + 내장 Tomcat에서 대규모 트래픽 때 자주 만지는 설정 정리

대규모 트래픽을 받는 Spring Boot 서비스를 운영하다 보면 "Tomcat 설정을 어디부터 봐야 하나?"라는 질문을 자주 하게 된다.

실무적으로 자주 손대는 축은 생각보다 많지 않다. 대부분은 아래 7축으로 수렴한다.

  • 스레드풀
  • 커넥션/백로그
  • 타임아웃/keep-alive
  • 헤더·업로드 보호값
  • 프록시 헤더
  • graceful shutdown
  • 메트릭

상황에 따라 압축과 HTTP/2까지 더해 보면, 운영에서 실제로 손대는 Tomcat 관련 설정 대부분을 설명할 수 있다.


0. 먼저 보는 기본값

질문에서 정리한 최근 Spring Boot 문서 기준 값을 먼저 기준점으로 잡고 보면 이해가 쉽다.

설정기본값
server.tomcat.threads.max200
server.tomcat.threads.min-spare10
server.tomcat.threads.max-queue-capacity2147483647
server.tomcat.max-connections8192
server.tomcat.accept-count100
server.tomcat.max-keep-alive-requests100
server.tomcat.mbeanregistry.enabledfalse

추가로 자주 함께 보는 값은 아래다.

  • server.tomcat.connection-timeout
  • server.tomcat.keep-alive-timeout
  • server.shutdown
  • spring.lifecycle.timeout-per-shutdown-phase

그리고 Java 21에서 virtual threads를 켜면 server.tomcat.threads.max, server.tomcat.threads.min-spare는 사실상 의미가 없어진다. 이 경우 병목은 Tomcat worker thread 수보다 DB 풀, 외부 API 대기, CPU limit 쪽에서 먼저 드러나는 경우가 많다.


1. 제일 많이 만지는 것: server.tomcat.threads.*

가장 먼저 보는 설정은 거의 항상 이 세 가지다.

  • server.tomcat.threads.max
  • server.tomcat.threads.min-spare
  • server.tomcat.threads.max-queue-capacity

Tomcat은 들어온 non-async 요청 하나당 처리 스레드 하나가 필요하다. 동시에 들어온 요청 수가 현재 가용 스레드보다 많아지면 maxThreads까지 스레드를 늘린다. 그 이상이 오면 요청은 더 이상 즉시 처리되지 못하고 연결 수 한도와 대기열 쪽으로 밀린다.

즉, Spring MVC + 블로킹 I/O 구조라면 threads.max는 사실상 한 Pod가 동시에 적극 처리 가능한 요청 수의 상한에 가깝다.

diagram rendering...

실무 감각으로 보면 이렇게 이해하면 된다.

  • CPU-bound API가 많으면 threads.max를 너무 크게 올릴수록 문맥 전환 비용만 늘고 손해가 날 수 있다.
  • DB나 외부 API 대기가 긴 MVC 서비스라면 너무 작게 잡았을 때 금방 포화된다.
  • max-queue-capacity는 "풀 내부 대기열"이라 너무 크게 두면 시스템이 버티는 것처럼 보이지만 실제로는 latency를 숨기고 tail latency와 메모리 사용량만 키운다.
  • 기본값이 사실상 무한대이므로, 최근에는 이 값을 의도적으로 제한하는 팀도 많다.

가장 많이 하는 실수는 Tomcat 스레드 수만 단독으로 올리는 것이다. 예를 들어 Tomcat 스레드만 500으로 늘리고 HikariCP 최대 커넥션이 30이면, 애플리케이션은 요청을 더 빨리 처리하는 것이 아니라 DB 커넥션 대기열을 더 크게 만든 것에 가까워진다.


2. 그다음 핵심: max-connectionsaccept-count

두 번째로 많이 만지는 축은 아래 두 개다.

  • server.tomcat.max-connections
  • server.tomcat.accept-count

문서 기준 기본값은 다음과 같다.

  • max-connections: 8192
  • accept-count: 100

실무적으로는 이렇게 이해하면 쉽다.

  • threads.max: 일할 사람 수
  • max-connections: 한 Pod가 붙잡고 있을 수 있는 연결 수
  • accept-count: 그마저 넘쳤을 때 잠깐 줄 세우는 길이

특히 accept-countmax-connections에 도달했을 때 OS가 유지하는 incoming connection queue 길이에 가깝다. 이 큐까지 차면 추가 연결은 거절되거나 timeout될 수 있다.

이 설정을 자주 만지는 경우는 대체로 이렇다.

  • 순간 피크가 큰 서비스
  • LB/Ingress 뒤에서 짧은 burst가 자주 오는 서비스
  • 스레드는 괜찮아 보이는데 연결 대기나 연결 거절이 먼저 발생하는 서비스

K8s에서는 HPA가 있어도 스케일아웃이 즉시 일어나지 않는다. Pod 수가 늘어나더라도 새 Pod가 Ready 상태가 되어 실제 트래픽을 받기까지는 시간이 걸린다. 그래서 그 사이 burst를 버티는 건 결국 각 Pod의 max-connectionsaccept-count 같은 per-pod 한도다.

diagram rendering...

3. 자주 건드리는 타임아웃: connection-timeout, keep-alive-timeout, max-keep-alive-requests

대규모 트래픽에서 꽤 자주 보는 축이다.

  • server.tomcat.connection-timeout
  • server.tomcat.keep-alive-timeout
  • server.tomcat.max-keep-alive-requests

의미를 간단히 정리하면 아래와 같다.

  • connection-timeout: 연결 수락 후 request URI line을 기다리는 시간
  • keep-alive-timeout: 다음 HTTP 요청을 기다리는 시간
  • max-keep-alive-requests: 하나의 keep-alive 연결에서 허용할 최대 요청 수

기본값 기준으로 max-keep-alive-requests100이고, Tomcat은 keepAliveTimeout이 별도로 설정되지 않으면 보통 connectionTimeout 동작을 따른다.

실무에서 이 축을 자주 보는 이유는 명확하다.

  • keep-alive를 너무 오래 두면 idle connection이 연결 자원을 오래 점유한다.
  • 너무 짧게 두면 재연결이 늘어나면서 LB, TLS, 클라이언트 비용이 증가한다.
  • max-keep-alive-requests를 너무 높게 두면 연결 재사용 효율은 좋아질 수 있지만, 특정 연결이 지나치게 오래 살아남으면서 편향이 생길 수 있다.

특히 Ingress나 Service Mesh가 앞단에 있는 환경에서는 앞단 idle timeout과 Tomcat의 keep-alive-timeout을 함께 봐야 한다. 둘이 어긋나면 프록시가 먼저 연결을 끊고, 애플리케이션에서는 499, 502, 504처럼 보이는 애매한 증상이 나올 수 있다.


4. 보호 장치로 많이 만지는 것: 헤더·폼·업로드 제한

대규모 서비스에서는 성능 튜닝만큼 이상 요청 방어도 중요하다. 이쪽도 운영 중 꽤 자주 손댄다.

  • server.max-http-request-header-size 기본값 8KB
  • server.tomcat.max-http-response-header-size 기본값 8KB
  • server.tomcat.max-http-form-post-size 기본값 2MB
  • server.tomcat.max-swallow-size 기본값 2MB
  • server.tomcat.max-parameter-count 기본값 1000
  • server.tomcat.max-part-count 기본값 50
  • server.tomcat.max-part-header-size 기본값 512B

이 영역은 "문제가 생기면 급하게 올리는 값"처럼 보이지만, 사실은 함부로 키우면 안 된다. 대표적으로 maxHttpRequestHeaderSize를 과도하게 키우면 요청마다 그만큼의 메모리를 잡아먹을 수 있다.

실무적으로는 보통 이렇게 접근한다.

  • 쿠키나 JWT가 커서 431, 400이 난다면 먼저 토큰/쿠키 크기 자체를 줄일 수 있는지 본다.
  • 파일 업로드 API가 있으면 max-swallow-size와 multipart 관련 제한을 명시적으로 맞춘다.
  • 악성 쿼리파라미터 폭탄, multipart 폭탄을 막기 위해 max-parameter-count, max-part-count를 의도적으로 조정한다.

핵심은 단순히 "에러 없게 키우기"가 아니라, 정상 요청이 필요한 만큼만 허용하고 나머지는 빠르게 거절하는 것이다.


5. K8s/Ingress 뒤에서 거의 필수로 보는 것: 프록시 헤더

쿠버네티스 Ingress, ALB, NLB, API Gateway 뒤에 있으면 아래 설정은 거의 필수로 확인하게 된다.

  • server.forward-headers-strategy
  • 필요하면 server.tomcat.remoteip.*

server.forward-headers-strategyX-Forwarded-* 계열 헤더를 애플리케이션이 어떻게 처리할지 정하는 설정이다. 이게 제대로 맞지 않으면 앱은 원래 스킴, 호스트, 포트, 클라이언트 IP를 잘못 이해하게 된다.

실무에서 흔한 증상은 다음과 같다.

  • HTTPS로 들어왔는데 앱은 HTTP로 안다.
  • redirect URL이 내부 포트 기준으로 생성된다.
  • access log나 감사 로그에 client IP 대신 프록시 IP만 남는다.

이 문제는 성능 이슈처럼 보이지 않지만, 실제 운영에서는 인증 redirect, callback URL, 절대 URL 생성, 보안 로그 분석에서 자주 장애를 만든다. Ingress 뒤에 있다면 꽤 초기에 확인하는 것이 좋다.


6. 성능 체감에 은근 영향 주는 것: 압축과 HTTP/2

엄밀히 말하면 "스레드풀 튜닝"과는 조금 결이 다르지만, 대규모 트래픽에서는 같이 검토할 일이 많다.

  • server.compression.enabled 기본값 false
  • server.compression.min-response-size 기본값 2KB
  • server.http2.enabled 기본값 false

실무적으로는 이렇게 본다.

  • JSON 응답이 크고 모바일/글로벌 트래픽이 많으면 compression 효과가 크다.
  • 다만 압축은 CPU를 추가로 사용하므로, CPU가 빠듯한 Pod에서는 무조건 켜는 것이 정답은 아니다.
  • HTTP/2는 앞단 Ingress나 LB가 이미 종단하는 경우가 많아서, 애플리케이션 Tomcat까지 꼭 켤 필요는 없다.
  • 결국 먼저 확인할 것은 HTTP/2가 어디서 종단되는지다.

7. 관측용으로 자주 켜는 것: mbeanregistry.enabled, access log

튜닝은 결국 보여야 할 수 있다. 그래서 운영에서는 관측 설정도 꽤 자주 손댄다.

  • server.tomcat.mbeanregistry.enabled 기본값 false
  • server.tomcat.accesslog.enabled 기본값 false

Tomcat MBean Registry를 켜면 Tomcat 관련 메트릭 수집이 훨씬 수월해진다. access log 역시 원인 분석 시 강력하다.

다만 K8s에서는 이미 Ingress access log가 있거나, 애플리케이션 로그가 stdout으로 수집되는 체계가 갖춰진 경우가 많다. 그래서 Tomcat access log를 항상 켜두기보다는 아래 상황에서 선택적으로 켜는 편이 흔하다.

  • 부하 테스트
  • 장애 원인 추적
  • 특정 경로 병목 분석
  • 프록시 계층과 애플리케이션 계층의 요청 차이 비교

8. K8s에서는 Tomcat 자체보다 graceful shutdown을 더 자주 맞춘다

대규모 트래픽 환경에서 실제로 더 자주 만지는 것은 오히려 이쪽이다.

  • server.shutdown 기본값 graceful
  • spring.lifecycle.timeout-per-shutdown-phase 기본값 30s

Kubernetes는 readinessProbe가 실패하면 새 트래픽을 보내지 않고, 종료 시에는 PreStop 훅과 terminationGracePeriodSeconds를 사용한다. 중요한 점은 PreStop도 termination grace period 안에 포함된다는 것이다.

그래서 실무에서는 보통 아래 조합을 같이 맞춘다.

  • readiness를 먼저 내려서 새 트래픽 유입 차단
  • server.shutdown=graceful
  • spring.lifecycle.timeout-per-shutdown-phase를 충분히 확보
  • terminationGracePeriodSeconds를 그보다 길게 설정
  • 필요하면 PreStop으로 drain 시간을 추가 확보
diagram rendering...

이 관계가 맞지 않으면 rolling update 때 in-flight 요청 유실이 생긴다. 성능 튜닝만큼이나 운영 안정성에 직접적인 영향을 주는 축이다.


9. 실무적으로 "진짜 많이 만지는 것만" 추리면

운영에서 손대는 빈도 기준으로 줄 세우면 대체로 이 순서에 가깝다.

  1. server.tomcat.threads.max
  2. server.tomcat.threads.min-spare
  3. server.tomcat.threads.max-queue-capacity
  4. server.tomcat.max-connections
  5. server.tomcat.accept-count
  6. server.tomcat.connection-timeout
  7. server.tomcat.keep-alive-timeout
  8. server.max-http-request-header-size / server.tomcat.max-swallow-size
  9. server.forward-headers-strategy
  10. server.shutdown + spring.lifecycle.timeout-per-shutdown-phase
  11. server.tomcat.mbeanregistry.enabled

그 외 설정들은 업로드 API가 있거나, 프록시 구조가 복잡하거나, 특정 장애 패턴이 있을 때 붙는 경우가 많다.


10. 시작점으로 많이 쓰는 예시

아래 값은 어디까지나 정답이 아니라 부하 테스트 시작점 예시다.

spring:
  lifecycle:
    timeout-per-shutdown-phase: 45s

server:
  forward-headers-strategy: framework

  http2:
    enabled: true

  compression:
    enabled: true
    min-response-size: 2KB

  shutdown: graceful
  max-http-request-header-size: 16KB

  tomcat:
    threads:
      max: 300
      min-spare: 30
      max-queue-capacity: 1000
    max-connections: 10000
    accept-count: 1000
    connection-timeout: 5s
    keep-alive-timeout: 15s
    max-keep-alive-requests: 100
    max-swallow-size: 10MB
    mbeanregistry:
      enabled: true

K8s 쪽은 보통 이런 관계를 같이 맞춘다.

livenessProbe: ...
readinessProbe: ...
terminationGracePeriodSeconds: 60

여기서 중요한 건 값 자체보다 값들 사이의 관계다.

  • threads.maxHikari maxPoolSize
  • keep-alive-timeoutIngress/LB idle timeout
  • spring.lifecycle.timeout-per-shutdown-phaseterminationGracePeriodSeconds
  • max-connections / accept-count ↔ burst 패턴과 HPA 반응 속도
diagram rendering...

값을 개별적으로 크게 만드는 것보다, 서로의 상관관계를 맞추는 것이 실제 성능과 안정성에 훨씬 더 중요하다.


11. 한 줄 정리

K8s 대규모 트래픽 환경에서 자주 만지는 Tomcat 설정은 결국 두 가지를 정하는 설정들이다.

"Pod 한 대가 어디까지 동시성을 받아도 되는가"

그리고

"Pod가 내려갈 때 요청을 얼마나 안전하게 빼줄 수 있는가"

이 두 축으로 보면 어떤 값을 먼저 봐야 하는지가 훨씬 선명해진다.


12. 마무리

Tomcat 튜닝은 숫자를 크게 만드는 작업이 아니다. 실무에서는 항상 아래 질문으로 귀결된다.

  • 이 서비스는 CPU-bound인가, I/O-bound인가?
  • 병목은 Tomcat 스레드인가, DB 풀인가, 외부 API인가?
  • burst를 감당해야 하는가, steady traffic이 주된가?
  • 종료 시 요청 유실 없이 drain할 수 있는가?

결국 중요한 건 "유명한 설정값"을 외우는 것이 아니라, 트래픽 패턴과 시스템 병목에 맞게 각 설정의 관계를 읽는 것이다.

다음 단계로 이어서 보면 가장 실무적인 주제는 보통 이것이다.

  • Tomcat 스레드풀
  • HikariCP 최대 풀 크기
  • HPA 스케일아웃 기준

이 세 개를 같이 맞추는 튜닝 시나리오다.