Published on

Spring Boot 3+ Tomcat 503 원인별 진단·해결

Authors

서버가 살아있는데도 클라이언트는 503 Service Unavailable을 받는 상황은 운영에서 가장 난감한 장애 유형 중 하나입니다. 특히 Spring Boot 3(= Spring Framework 6, Jakarta EE 10)로 올라오면서 패키지 변경(jakarta.*), 기본 의존성/서버 설정 변화, 관측(Actuator/Micrometer) 방식이 정교해진 반면 503의 표면 증상은 동일해서 원인 분류가 더 중요해졌습니다.

이 글은 “Tomcat이 왜 503을 내보내는가?”를 원인별로 나누고, 각 케이스마다 **어디부터 확인할지(로그/지표/스레드덤프/네트워크)**와 **즉시 적용 가능한 해결책(설정/코드/아키텍처)**을 제공합니다.

> 참고: 컨테이너/노드 리소스 이슈로 애플리케이션이 불안정해지는 경우는 503과 함께 CrashLoopBackOff/OOMKilled로 이어지기도 합니다. 쿠버네티스 환경이라면 EKS CrashLoopBackOff - OOMKilled·Exit 137 원인과 해결도 함께 확인하세요.

1) 503의 “출처”부터 구분하기

503은 크게 세 군데에서 나올 수 있습니다.

  1. 프록시/로드밸런서(ALB/Nginx/Ingress) 가 백엔드 불가로 503
  2. Tomcat 자체가 현재 요청을 처리할 수 없어 503
  3. 애플리케이션 코드가 503을 직접 반환(@ResponseStatus/ResponseEntity)

1-1. 가장 빠른 판별법: 응답 헤더와 바디

  • Server: nginx / server: awselb/2.0 등: 앞단에서 발생했을 확률이 큼
  • Server: Apache-Coyote/1.1 또는 Apache Tomcat: Tomcat에서 생성
  • 바디가 “Service Temporarily Unavailable” 고정 문구: 프록시 기본 에러페이지일 가능성

Spring Boot에서 Tomcat 응답임을 확실히 보려면 access log를 켜서 요청이 Tomcat까지 들어왔는지를 확인합니다.

# application.yml
server:
  tomcat:
    accesslog:
      enabled: true
      pattern: '%h %l %u %t "%r" %s %b %D'
  • access log에 기록이 없다면: 앞단(로드밸런서/인그레스)에서 503
  • access log에 503이 찍힌다면: Tomcat/애플리케이션에서 503

2) Tomcat이 503을 내는 대표 원인 6가지

Tomcat에서 흔히 “503”으로 관찰되는 케이스는 아래로 수렴합니다.

  1. 스레드 풀 고갈(maxThreads/큐 포화) → 처리 불가
  2. 커넥션/소켓 대기 폭증(keep-alive, acceptCount) → 백로그/타임아웃
  3. 애플리케이션이 정상 응답 불가(예: DB 풀 고갈) → 결과적으로 503/타임아웃
  4. Graceful shutdown/재기동 중 요청 유입 → 일시적 503
  5. HTTP/2, TLS, 프록시 설정 불일치 → 특정 경로에서만 503
  6. 리소스 고갈(OOM, FD 부족, 디스크 100%) → 연쇄 장애

아래에서 각 케이스별 진단/해결을 구체화합니다.

3) 스레드 풀 고갈: maxThreads/acceptCount/큐 포화

3-1. 증상

  • 트래픽이 순간적으로 치솟을 때 503 증가
  • 응답 지연이 먼저 커지고, 이후 503/타임아웃이 동반
  • CPU가 100%가 아닐 수도 있음(대기 스레드가 많을 수 있음)

3-2. 진단 체크리스트

  1. Actuator로 Tomcat 스레드/커넥션 지표 확인
management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,threaddump

예: Tomcat 지표(환경에 따라 이름이 다를 수 있음)

  • tomcat.threads.current
  • tomcat.threads.busy
  • tomcat.connections.current
  1. 스레드 덤프에서 http-nio-xxxx-exec-* 상태 확인
  • RUNNABLE이 많고 CPU가 높으면: 실제 계산/루프 문제
  • WAITING/TIMED_WAITING이 많고 스택에 JDBC/HTTP 호출이 보이면: 외부 I/O 병목
  1. access log의 처리시간 %D(마이크로초)로 tail 분석
# 상위 20개 느린 요청
awk '{print $(NF)}' /var/log/tomcat_access.log | sort -nr | head

3-3. 해결 전략

(A) Tomcat 스레드/백로그 튜닝

server:
  tomcat:
    threads:
      max: 300
      min-spare: 30
    accept-count: 200
    max-connections: 10000
  • threads.max: 동시 처리 워커 스레드 상한
  • accept-count: 워커가 꽉 찼을 때 소켓 대기(backlog) 큐
  • max-connections: 동시 연결 상한(keep-alive가 길면 특히 중요)

주의: 무작정 max를 올리면 컨텍스트 스위칭 증가/메모리 증가로 오히려 악화될 수 있습니다. 다음 (B)(C)와 함께 적용해야 합니다.

(B) “블로킹 I/O”를 줄여 스레드 점유 시간을 줄이기

예를 들어 외부 API 호출을 RestTemplate로 오래 붙잡고 있으면 Tomcat 스레드가 그 시간 동안 점유됩니다. Spring Boot 3에서는 WebClient(reactor)로 전환하거나, 최소한 타임아웃/서킷브레이커를 걸어 점유 시간을 제한하세요.

@Bean
WebClient webClient(WebClient.Builder builder) {
    HttpClient httpClient = HttpClient.create()
        .responseTimeout(Duration.ofSeconds(2));

    return builder
        .clientConnector(new ReactorClientHttpConnector(httpClient))
        .build();
}

(C) 느린 요청 상한 설정(서버 보호)

  • 서버 레벨 read/connection timeout
  • 애플리케이션 레벨 타임아웃
server:
  tomcat:
    connection-timeout: 3s

4) DB 커넥션 풀(HikariCP) 고갈 → 요청 정체 → 503/타임아웃

Tomcat이 직접 503을 내지 않더라도, DB 풀이 고갈되면 요청이 대기하다가 타임아웃/에러로 이어지며 관측상 503 폭증처럼 보일 수 있습니다(특히 앞단 타임아웃이 503으로 변환할 때).

4-1. 진단

  • 애플리케이션 로그에서
    • HikariPool-1 - Connection is not available, request timed out
  • Actuator metrics
    • hikaricp.connections.active, pending, timeout

4-2. 해결

spring:
  datasource:
    hikari:
      maximum-pool-size: 30
      minimum-idle: 10
      connection-timeout: 2000
      max-lifetime: 1800000
      leak-detection-threshold: 5000

그리고 근본적으로는 슬로우쿼리/락/인덱스 부재를 해결해야 합니다. DB가 PostgreSQL이라면 autovacuum 지연으로 테이블 팽창/슬로우가 발생할 수 있으니 PostgreSQL autovacuum 지연으로 팽창·슬로우쿼리 잡기도 같이 점검하세요.

5) Graceful shutdown/재배포 중 503: 준비/종료 신호 불일치

쿠버네티스/오토스케일 환경에서 503이 “배포 시점”과 맞물리면, 대개 readiness/liveness, 종료 유예, graceful shutdown이 어긋난 겁니다.

5-1. 진단

  • Pod 종료 직전에 503이 튄다
  • ALB/Nginx가 아직 트래픽을 보내는데 애플리케이션은 이미 종료 단계

5-2. 해결: Spring Boot graceful shutdown + readiness 분리

server:
  shutdown: graceful
spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s
management:
  endpoint:
    health:
      probes:
        enabled: true
  health:
    livenessstate:
      enabled: true
    readinessstate:
      enabled: true
  • readiness가 먼저 OUT_OF_SERVICE로 전환되어야 트래픽이 빠집니다.
  • Ingress/Service의 드레인 시간(예: deregistration delay)도 함께 맞추세요.

6) 파일 디스크립터(FD) 부족, 디스크 100%, 로그 폭증 → 연쇄 503

Tomcat은 소켓/파일 핸들을 많이 씁니다. FD가 고갈되면 신규 연결이 실패하고, 결과적으로 503/연결 실패가 증가합니다. 또한 디스크가 100%면 로그 기록/임시 파일/세션 저장 등이 막히며 장애가 확대됩니다.

6-1. 진단

# FD 제한 확인
ulimit -n

# 프로세스 FD 사용량
ls /proc/$(pgrep -f 'java.*jar')/fd | wc -l

# 디스크 사용량
df -h

디스크가 100%인데도 파일을 지워도 줄지 않는다면, 삭제된 파일을 프로세스가 계속 열고 있는 케이스를 의심해야 합니다. 이 패턴은 운영에서 매우 흔하니 리눅스 디스크 100%인데 용량이 안 줄 때 - deleted-but-open(lsof) 글의 절차대로 lsof로 확인하세요.

6-2. 해결

  • systemd/컨테이너 런타임에서 nofile 상향
  • 로그 롤링 정책 점검(Logback size/time 기반)
  • 디스크 알람(95% 등)과 로그 볼륨 분리

Logback 롤링 예시:

<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
  <file>/var/log/app/app.log</file>
  <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
    <fileNamePattern>/var/log/app/app.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
    <maxFileSize>100MB</maxFileSize>
    <maxHistory>14</maxHistory>
    <totalSizeCap>3GB</totalSizeCap>
  </rollingPolicy>
  <encoder>
    <pattern>%d %-5level [%thread] %logger - %msg%n</pattern>
  </encoder>
</appender>

7) 프록시/로드밸런서 타임아웃이 503으로 “변환”되는 케이스

현장에서 가장 많이 놓치는 부분은 “Tomcat이 503을 냈다”가 아니라, 앞단이 백엔드 타임아웃을 503으로 바꿔서 내려주는 경우입니다.

7-1. 진단

  • Tomcat access log에는 200/응답 없음인데, 클라이언트는 503
  • ALB Target Response Time 초과, Nginx upstream timed out 로그

7-2. 해결

  • 애플리케이션의 P99 응답 시간 < 프록시 idle timeout을 목표로 최적화
  • 프록시 타임아웃을 “현실적인 값”으로 조정(무한정 증가 금지)
  • 긴 작업은 비동기(큐/배치)로 전환하고, 요청은 job id로 즉시 응답

Spring MVC에서 긴 작업을 비동기 처리하는 간단 예:

@RestController
@RequiredArgsConstructor
class ReportController {
  private final TaskExecutor taskExecutor;

  @PostMapping("/reports")
  public ResponseEntity<Map<String, String>> create() {
    String jobId = UUID.randomUUID().toString();
    taskExecutor.execute(() -> {
      // 긴 작업 수행
      generateReport(jobId);
    });
    return ResponseEntity.accepted().body(Map.of("jobId", jobId));
  }

  private void generateReport(String jobId) {
    // TODO
  }
}

8) Spring Boot 3 업그레이드 이후 자주 만나는 함정

8-1. jakarta 전환으로 필터/서블릿 호환 문제

Boot 3는 javax.servlet.*이 아니라 jakarta.servlet.*입니다. 오래된 라이브러리/필터가 섞이면 런타임 오류로 요청 처리가 깨지고, 앞단에서 503으로 보일 수 있습니다.

  • 의존성 트리에서 javax.servlet 기반 라이브러리 제거/업그레이드
  • 커스텀 필터/서블릿 import 확인

8-2. 관측(Actuator) 없이는 원인 분리가 불가능

503은 “결과”일 뿐이라, 최소한 아래는 켜두는 것을 권장합니다.

  • metrics, health, threaddump
  • 로그: access log + 애플리케이션 에러 로그

운영에서 지표가 없다면 원인 분리가 늦어지고, 임시로 스케일만 늘리다 장애가 반복됩니다.

9) 실전 트러블슈팅 런북(10분 안에 좁히기)

  1. 503이 어디서 생성되는지 확인(응답 헤더 + Tomcat access log)
  2. Tomcat까지 들어온다면
    • tomcat.threads.busy / connections.current 확인
    • threaddump로 상위 블로킹 지점 확인
  3. DB/외부 API 의존이 있다면
    • Hikari pending/timeout
    • 외부 호출 타임아웃/재시도 폭주 여부
  4. 배포/스케일 이벤트와 겹치면
    • readiness 전환/드레인/종료 유예 점검
  5. 시스템 리소스
    • OOM/FD/디스크 100%/CPU steal

마무리

Spring Boot 3+ 내장 Tomcat에서 503을 안정적으로 잡으려면, “Tomcat 설정만 튜닝”하는 접근보다 (1) 503 출처 구분 → (2) 스레드/커넥션/의존성 병목 분리 → (3) 리소스/배포 신호 정합성 확보 순서로 접근하는 것이 가장 빠릅니다.

원인을 한 번이라도 정확히 분류해두면, 다음부터는 지표 한두 개만으로도 재발을 즉시 감지할 수 있습니다. 운영 환경(단일 VM, ECS/EKS, ALB/Nginx, DB 종류)을 알려주면 그 환경에 맞춘 체크 포인트(예: ALB idle timeout, Ingress 설정, JVM 옵션, Hikari sizing)를 더 구체적으로 정리해드릴 수 있습니다.