- Published on
Spring Boot 3+ Tomcat 503 원인별 진단·해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 살아있는데도 클라이언트는 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은 크게 세 군데에서 나올 수 있습니다.
- 프록시/로드밸런서(ALB/Nginx/Ingress) 가 백엔드 불가로 503
- Tomcat 자체가 현재 요청을 처리할 수 없어 503
- 애플리케이션 코드가 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”으로 관찰되는 케이스는 아래로 수렴합니다.
- 스레드 풀 고갈(maxThreads/큐 포화) → 처리 불가
- 커넥션/소켓 대기 폭증(keep-alive, acceptCount) → 백로그/타임아웃
- 애플리케이션이 정상 응답 불가(예: DB 풀 고갈) → 결과적으로 503/타임아웃
- Graceful shutdown/재기동 중 요청 유입 → 일시적 503
- HTTP/2, TLS, 프록시 설정 불일치 → 특정 경로에서만 503
- 리소스 고갈(OOM, FD 부족, 디스크 100%) → 연쇄 장애
아래에서 각 케이스별 진단/해결을 구체화합니다.
3) 스레드 풀 고갈: maxThreads/acceptCount/큐 포화
3-1. 증상
- 트래픽이 순간적으로 치솟을 때 503 증가
- 응답 지연이 먼저 커지고, 이후 503/타임아웃이 동반
- CPU가 100%가 아닐 수도 있음(대기 스레드가 많을 수 있음)
3-2. 진단 체크리스트
- Actuator로 Tomcat 스레드/커넥션 지표 확인
management:
endpoints:
web:
exposure:
include: health,info,metrics,threaddump
예: Tomcat 지표(환경에 따라 이름이 다를 수 있음)
tomcat.threads.currenttomcat.threads.busytomcat.connections.current
- 스레드 덤프에서
http-nio-xxxx-exec-*상태 확인
RUNNABLE이 많고 CPU가 높으면: 실제 계산/루프 문제WAITING/TIMED_WAITING이 많고 스택에 JDBC/HTTP 호출이 보이면: 외부 I/O 병목
- 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분 안에 좁히기)
- 503이 어디서 생성되는지 확인(응답 헤더 + Tomcat access log)
- Tomcat까지 들어온다면
tomcat.threads.busy/connections.current확인threaddump로 상위 블로킹 지점 확인
- DB/외부 API 의존이 있다면
- Hikari pending/timeout
- 외부 호출 타임아웃/재시도 폭주 여부
- 배포/스케일 이벤트와 겹치면
- readiness 전환/드레인/종료 유예 점검
- 시스템 리소스
- 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)를 더 구체적으로 정리해드릴 수 있습니다.