Timeout 설정 헷갈리지 않기: Tomcat, WebFlux, HTTP Client, HikariCP 기준 정리

#Spring#Database
운영에서 timeout 로그를 보면 먼저 헷갈리는 경우가 있다.
timeout 의 경우 TCP 연결을 못 맺어서인지, 응답을 기다리다가 발생한건지, 아니면 오래된 커넥션을 재사용하다가 발생했는지 바로 구분하긴 어렵다.
Tomcat, Netty, HTTP Client, HikariCP 에서 자주 만나는 timeout 설정을 계층별로 나누어서 정리해보았다.

왜 헷갈릴까?

timeout 이 헷갈리는 이유는 같은 이름이 여러 계층에서 다른 뜻으로 쓰이기 때문이다.

예를 들어 connection timeout 은 클라이언트(HttpClient, HikariCP)에서는 "상대와 TCP 연결을 맺는 데 허용하는 시간"을 뜻한다.

반면 서버(Tomcat/Netty)에서는 비슷한 이름의 설정이 "연결은 맺었지만 요청을 기다리는 시간"을 가리키기도 한다.

keepalive 도 TCP keepalive, HTTP keep-alive, HikariCP keepaliveTime 이름만 비슷할 뿐 서로 다른 위치에서 동작한다.

그래서 "타임아웃이 났다"는 로그 한 줄만 보고는 어느 계층 문제인지 알 수 없다. 계층별로 나눠서 봐야 한다.


핵심 개념 정리

이름상황어디서 설정하는가대표 설정 키
connection timeoutTCP 연결을 맺는 동안HTTP 클라이언트, DB 커넥션 풀RestClient/RestTemplate의 connect timeout, spring.datasource.hikari.connection-timeout
socket timeout / read timeout커넥션은 맺었는데 응답 데이터가 안 올 때HTTP 클라이언트RestClient/RestTemplate의 read timeout, WebClient의 ReadTimeoutHandler
write timeout요청 바디를 상대에게 다 못 보내고 있을 때HTTP 클라이언트WebClient의 WriteTimeoutHandler
server connection timeout커넥션은 맺었는데 클라이언트가 요청을 안 보낼 때Tomcat 등 WASserver.tomcat.connection-timeout
keep-alive timeout요청/응답이 끝난 뒤 커넥션을 재사용 대기시키는 시간Tomcat, HTTP 클라이언트server.tomcat.keep-alive-timeout
max lifetime커넥션 하나를 최대 얼마나 오래 살려둘지HikariCP, HTTP 커넥션 풀spring.datasource.hikari.max-lifetime, Apache HttpClient5의 ConnectionConfig TTL

connection timeout 과 read timeout 은 특히 헷갈리기 쉬운데, 그 기준은 생각보다 간단하다.

기준은 TCP 연결이 끝났는가? 이다.

아직 연결 중이면 connection timeout 이다.

연결은 됐는데 응답 데이터가 안 오면 read timeout 이다. 비슷해 보이는 상황을 나누어보면 아래와 같다.

상황에러
상대 IP가 방화벽에 막혀 있거나 라우팅되지 않음 (SYN에 응답 없음)connection timeout
TCP 연결은 맺었는데 상대 애플리케이션이 응답을 안 만듦(DB 락, GC pause, 무한루프), 또는 응답을 시작했지만 끝까지 보내지 않고 멈춤read timeout
요청 바디가 커서 클라이언트가 다 못 보내고 있음(느린 업로드 등)write timeout
상대 서버는 떠 있지만 해당 포트에 아무것도 안 열려 있음ConnectException: Connection refused
hostname 자체를 못 찾음UnknownHostException

참고로, Connection refusedUnknownHostException은 timeout이 아니다.

둘 다 "연결이 안 됐다"는 점에서는 connection timeout처럼 보이지만, 설정한 timeout 시간까지 기다리지 않고 바로 실패한다. "기다릴 것도 없이 바로 안 되는" 경우다.

반면, timeout은 "기다렸는데도 안 됐을 때" 발생한다.


용어 사전

들어가기에 앞서, 아래와 같은 단어는 계속해서 나오는데 미리 알아두면 이해하기 쉽다.

용어쉽게 말하면
유휴 (idle)지금 아무 요청도 처리하지 않고 그냥 쉬고 있는 상태
socket timeoutread timeout과 같은 말
커넥션 풀매번 새로 연결하지 않고, 다 쓴 연결을 모아뒀다가 재사용하는 통
keep-alive연결을 끊지 않고 잠깐 열어둬서 다음 요청 때 재사용하게 해주는 것
evict / expire재사용하던 연결을 진짜로 닫아버리는 것
3-way handshakeTCP 연결을 맺을 때 오가는 신호 3번(SYN → SYN-ACK → ACK)
wait_timeout (MySQL)DB가 한동안 아무 활동 없는 연결을 알아서 끊어버리는 시간

Tomcat (서버) 설정

Spring Boot 내장 Tomcat 기준으로 application.yml에 아래 설정들이 있다.

yaml
server:
  tomcat:
    connection-timeout: 20000       # 커넥션을 맺은 뒤 요청 라인이 이 시간 안에 안 오면 끊는다
    keep-alive-timeout: 60000       # 응답을 보낸 뒤 다음 요청이 이 시간 안에 안 오면 커넥션을 닫는다
    max-keep-alive-requests: 100    # 커넥션 하나로 재사용 가능한 최대 요청 수
    threads:
      max: 200                      # 워커 스레드 최대 개수

Spring Boot 공식 문서(Common Application Properties)에는 server.tomcat.keep-alive-timeout 이 이렇게 정의되어 있다.

Time to wait for another HTTP request before the connection is closed. When not set the connectionTimeout is used. When set to -1 there will be no timeout.

응답 후 다음 요청을 얼마나 기다릴지, 즉 "이 시간 안에 다음 요청 없으면 커넥션을 닫는다"는 것이 문서 그대로의 의미다.

방향을 반대로 잡기 쉬운데, 앞단에 로드밸런서나 리버스 프록시가 있다면 Tomcat 의 keep-alive-timeout 을 로드밸런서의 idle timeout 보다 길게 잡는 편이 안전하다.

Tomcat이 먼저 닫으면 로드밸런서가 죽은 커넥션으로 요청을 보내려다가 에러가 나는데, 클라이언트는 502를 만나게 된다.

반대로 로드밸런서가 먼저 닫는 경우도 있다. 이때는 유휴 커넥션인지, 처리 중인 요청인지 나눠봐야 한다.

  • 요청/응답이 이미 끝난 유휴(idle) keep-alive 커넥션이었다면, 보통 사용자 요청 실패로 이어지지 않는다.
  • Tomcat이 긴 작업을 처리하느라 응답을 못 만드는 중이었다면, 로드밸런서 idle timeout이 먼저 만료될 수 있다.
    • 이때 클라이언트는 먼저 504 Gateway Timeout을 받고, Tomcat은 뒤늦게 응답을 쓰려다 아래 같은 Broken pipe 로그를 남길 수 있다.
text
org.apache.catalina.connector.ClientAbortException: java.io.IOException: Broken pipe

같은 로그라도 원인이 다르기 때문에 대응도 각각 다르다.

  • keep-alive 정렬 문제 : Tomcat keep-alive-timeout을 로드밸런서 idle timeout보다 길게 잡는다.
    • Tomcat과 로드밸런서는 서로 다른 프로세스라 각자 따로 시간을 재기 때문에, 서버 쪽을 몇 초라도 확실히 더 길게 잡아야 한다(예: 로드밸런서 60초 → Tomcat 65초).
  • 요청 처리 중 504 문제 : 로드밸런서 idle timeout을 늘리거나, 애플리케이션 처리 시간을 줄이거나, 비동기/큐/폴링 구조로 바꾼다.
  • DB/외부 API 지연이 원인일 때 : DB query timeout, HTTP 클라이언트 read timeout을 로드밸런서 idle timeout보다 짧게 잡아서 애플리케이션이 먼저 실패 처리하게 만든다.

Tomcat 이 실제로 keep-alive-timeout 이 지난 커넥션을 닫는지는 직접 소켓을 열어서 확인할 수 있다.


WebFlux(Reactor Netty) 설정

Reactor Netty(WebFlux) 기준으로는 application.yml에 아래 설정들이 있다.

yaml
server:
  netty:
    connection-timeout: 20000       # 서버 채널의 연결 관련 타임아웃
    idle-timeout: 60000             # Tomcat의 keep-alive-timeout과 같은 역할
    max-keep-alive-requests: 100

개념은 Tomcat과 동일하다. 로드밸런서 뒤에서는 idle-timeout을 로드밸런서 idle timeout보다 길게 잡아야 같은 이유로 안전하다.

Tomcat과 달리 connection-timeout, idle-timeout 모두 기본값이 없다(Spring Boot 4.0.7 기준).
설정하지 않으면 무제한으로 동작하므로, 운영 환경이라면 반드시 명시적으로 값을 잡아줘야 한다.

커넥션 풀은 어떻게 동작하는가

Tomcat, WebFlux(Reactor Netty)와 달리, 지금부터 나오는 HTTP 클라이언트와 HikariCP는 커넥션을 "풀"에 넣어두고 재사용한다. 풀 안의 커넥션은 크게 두 상태를 오간다.

  • 풀 안에서 쉬고 있는 상태
  • 애플리케이션이 빌려가서 쓰는 상태

Apache HttpClient5 는 이 둘을 available/leased 라고 부르고, HikariCP 는 idle/active 라고 부르는데, 이름은 달라도 핵심은 같다.

Pool Capacity: 4 slotsPoolStats [ leased: 1; available: 3; max: 4 ]
Socket #1AVAILABLE
Status: idle (waiting)
Requests: 3
Socket #2AVAILABLE
Status: idle (waiting)
Requests: 1
Socket #3LEASED
Status: in-use (querying)
Requests: 5
Socket #4AVAILABLE
Status: idle (waiting)
Requests: 0
풀 초기화 완료: maxTotal=4, available=3, leased=1 (자동 시뮬레이션 실행 중)
1) 대여 (Borrow)
AVAILABLE 소켓을 대여해 요청을 처리하고 LEASED 로 전이
2) 반납 (Release)
close() 시 실제 소켓을 끊지 않고 AVAILABLE 로 풀에 재사용 반납
3)·4) 만료 & 실패 회전 (Expire)
max-lifetime 만료(3) 또는 liveness 실패(4) 시 기존 소켓을 닫고 새 소켓 번호로 안전하게 교체
  • 대기 중 (Available) - 풀 안에서 다음 요청을 기다리는 커넥션
  • 사용 중 (Leased) - 애플리케이션이 빌려가서 요청이나 쿼리를 처리하는 커넥션
  • 폐기됨 (Expired) - 실제로 닫혀서 더 이상 쓸 수 없는 커넥션
Available = 풀 안에서 대기, Leased = 누가 빌려서 쓰는 중, Expired = 실제로 닫혀서 없어짐

커넥션은 요청이 들어올 때마다 대기 중과 사용 중 사이를 왔다 갔다 하지만 폐기되는 시점은 별도 조건으로 결정된다.

  • 유휴 상태로 idle-timeout을 넘겼을 때
  • 살아있는 시간이 max-lifetime 또는 커넥션 최대 수명(TTL)을 넘겼을 때
  • 살아있는지 확인하는 체크(keepalive)에서 이미 죽은 것으로 판정됐을 때

중요한 점은 커넥션을 언제 폐기할지는 애플리케이션이 아니라 풀이 결정한다는 것이다. 코드에서 connection.close() 를 호출해도 실제로는 물리 커넥션을 닫지 않고, 풀에 되돌려 놓는 동작일 때가 많다.

그래서 max-lifetime을 DB나 중간 장비가 커넥션을 끊는 시점보다 길게 잡으면 문제가 생길 수 있다. 상대는 이미 끊었는데, 풀은 아직 쓸 수 있다고 보고 애플리케이션에 다시 빌려줄 수 있기 때문이다(자세한 기준은 HikariCP 섹션에서 다룬다).


HTTP 클라이언트 설정

각 Http Client마다 connect/read/write timeout을 설정하는 API가 다르고, 커넥션 풀을 쓰는지에 따라 keep-alive 동작도 달라진다.

Clientconnect / read timeout커넥션 풀
Feign (feign.Client.Default)있음명시적 풀 없음 - 요청마다 HttpURLConnection 생성. 호스트당 5개(http.maxConnections)로 제한된 JDK 캐시로만 일부 재사용
Feign + HC5/OkHttp있음Apache HttpClient5 / OkHttp 풀 사용
RestTemplate (HttpComponentsClientHttpRequestFactory)있음Apache HttpClient5 풀 사용 가능
WebClient (Reactor Netty)있음 (connect는 채널 옵션, read/write는 핸들러)Reactor Netty 커넥션 풀 사용
java.net.http.HttpClient (JDK 11+)있음내장 커넥션 풀 사용

이 계층에서 connect/read timeout은 실제로 이런 예외로 나타난다.

  • connect timeout - org.apache.hc.client5.http.ConnectTimeoutException
    • RestClient/RestTemplate에서는 ResourceAccessException으로 래핑되어 올라온다
  • read timeout - java.net.SocketTimeoutException: Read timed out

풀을 쓴다면 이 둘 말고 챙길 게 두 개 더 있다.

connection request timeout

연결은 멀쩡한데 풀의 커넥션이 전부 대여 중(Leased)이라 빌릴 차례를 기다리다 시간 초과되는 것이다. HikariCP connection-timeout과 비슷한 풀 획득 대기 시간이다.

Apache HttpClient5는 RequestConfig.setConnectionRequestTimeout(...)으로 설정한다.

실무에서는 connect timeout보다 connection request timeout(풀 고갈)을 더 자주 만난다.

커넥션 최대 수명(TTL)

커넥션이 생성된 시각 기준으로 계산되는 유통기한으로, HikariCP max-lifetime처럼 커넥션 수명을 제한한다. Apache HttpClient5에서는 ConnectionConfig의 time-to-live(TTL)로 설정한다.

TTL을 안 정해두면 중간 장비가 조용히 끊어버린 죽은 커넥션을 풀이 계속 꺼내 쓸 수 있다.

OkHttp의 ConnectionPool은 유휴 시간 기준의 keepAliveDuration을 중심으로 동작한다. Apache HttpClient5처럼 connection request timeout과 커넥션 TTL을 풀 설정으로 세밀하게 나누는 모델은 아니다. Feign에 OkHttp를 붙였을 때 죽은 커넥션 재사용 이슈가 반복된다면 Feign + HC5 조합으로 전환하는 것도 검토할 수 있다.

유휴 커넥션 검증과 자동 퇴거 (Apache HttpClient5)

Apache HttpClient5는 HikariCP의 keepalive-time처럼 죽은 커넥션을 사전에 정리하는 두 가지 보조 장치를 제공한다.

  • validateAfterInactivity - 커넥션이 일정 시간 유휴 상태였으면, 재사용 직전에 소켓이 살아있는지 찔러보고 검증한다.
  • IdleConnectionEvictor - closeExpired(), closeIdle() 같은 수동 퇴거 메서드를 백그라운드 스레드에서 주기적으로 대신 호출해주는 자동 청소기다. 스프링 빈으로 등록하고 start()/shutdown() 생명주기만 맞춰주면 된다.

WebClient만 connect timeout과 read/write timeout을 아예 다른 API로 설정한다.

java
HttpClient httpClient = HttpClient.create()
    .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 3000)
    .doOnConnected(conn -> conn
        .addHandlerLast(new ReadTimeoutHandler(5, TimeUnit.SECONDS))
        .addHandlerLast(new WriteTimeoutHandler(5, TimeUnit.SECONDS)));

ReadTimeoutHandler를 빼먹으면 connect timeout만 걸리고, 응답이 영원히 안 와도 요청이 끝없이 대기한다.


HikariCP (DB 커넥션 풀) 설정

yaml
spring:
  datasource:
    hikari:
      connection-timeout: 30000   # 풀에서 커넥션을 못 받으면 이 시간 후 예외 (기본값 30초)
      max-lifetime: 1800000       # 커넥션 하나를 최대 이 시간까지만 사용 (기본값 30분, 최소 30초)
      idle-timeout: 600000        # 유휴 커넥션을 이 시간 후 제거 (minimum-idle < maximum-pool-size 일 때만 동작)
      keepalive-time: 120000      # 이 주기로 유휴 커넥션에 ping (최소 30초, max-lifetime 보다 작아야 함)

핵심은 max-lifetime과 DB/네트워크 장비의 idle timeout 관계다.

MySQL wait_timeout, AWS RDS Proxy, L4 로드밸런서, 방화벽은 각자 유휴 커넥션을 강제로 끊는 제한 시간을 갖고 있다. max-lifetime이 그보다 길면(0=무제한 포함) DB나 방화벽이 먼저 소켓을 끊어버린 죽은 커넥션을 풀이 계속 꺼내 쓰게 된다.

text
max-lifetime (HikariCP)  <  MIN( wait_timeout (MySQL), idle timeout (L4/RDS Proxy, 방화벽 등) )

"DB의 wait_timeout이 항상 더 짧다"고 오해하기 쉬운데, 실무에서는 반대인 경우가 많다. MySQL wait_timeout 기본값은 8시간(28,800초)이지만, AWS NLB(350초)나 방화벽, RDS Proxy의 유휴 타임아웃은 그보다 훨씬 짧게 설정돼 있는 경우가 대부분이다.

정확한 값을 짐작하지 말고 직접 확인한다(MySQL 기준 SHOW VARIABLES LIKE 'wait_timeout'). 그 중 더 짧은 쪽보다 최소 30초 이상 짧게 max-lifetime을 잡는다.

keepalive-time은 이 문제를 줄이는 보조 장치다. 유휴 커넥션에 주기적으로 ping을 보내 살아있는지 확인하는데, 반드시 max-lifetime보다 작고 최소 30초 이상이어야 한다. 이 조건을 안 지키면 설정이 보정되거나 무시될 수 있으니 시작 로그를 확인하는 게 안전하다.

아래는 max-lifetime이 DB의 wait_timeout보다 길 때 발생하는 에러다.

text
com.mysql.cj.jdbc.exceptions.CommunicationsException:
Communications link failure

The last packet successfully received from the server was 1,801,203 milliseconds ago.

max-lifetime을 30분(1,800,000ms)으로 잡았는데 로그의 "1,801,203 milliseconds ago"처럼 비슷한 시점에 에러가 난다면 timeout 관계를 의심해볼 수 있다. 30분씩 기다리지 않고도 재현해볼 수 있다.

아래 예제는 MySQL(Docker)의 wait_timeout을 2초로 잡고 같은 에러를 몇 초 만에 확인한다.


💡 실무 팁

  • 로드밸런서/리버스 프록시 뒤라면 서버(Tomcat) keep-alive-timeout을 그쪽 idle timeout보다 몇 초 더 길게 잡는다.
    • Tomcat keep-alive-timeout > 로드밸런서 idle timeout
  • HTTP 클라이언트 풀을 쓴다면 connect timeout, read timeout 말고 connection request timeout(풀 고갈)도 반드시 설정한다.
  • 일반적인 웹 애플리케이션에서는 HikariCP max-lifetime을 DB wait_timeout과 중간 장비 idle timeout 중 더 짧은 쪽보다 최소 30초 이상 짧게 잡는다. 0(무제한)은 DB/인프라가 먼저 끊지 않는다는 확신이 있을 때만 선택한다.
    • max-lifetime + 30초 ≤ MIN(wait_timeout, 중간 장비 idle timeout)
  • keepalive-time을 쓰려면 최소 30초 이상, max-lifetime보다 작게 잡아야 한다. 조건을 어기면 기대한 대로 동작하지 않을 수 있으니 시작 로그를 확인한다.
    • 30초 ≤ keepalive-time < max-lifetime
  • 값을 정할 때는 항상 "이 timeout이 지나면 무슨 일이 생기는가"부터 확인한다. 재시도되는지, 에러가 사용자에게 노출되는지에 따라 안전한 여유값이 달라진다.

🖊️ 정리

타임아웃 값은 무조건 "안전하게 크게" 잡는다고 좋은 게 아니고, 무엇을 방어하기 위한 것인지 알아야, 장애 상황에서 어느 계층의 어느 값을 의심해야 할지 판단할 수 있다.

계층이 값이 너무 짧으면이 값이 너무 길면
connection timeout네트워크가 조금만 느려도 실패장애 상황에서 스레드가 오래 묶임
read/write timeout정상 응답인데도 타임아웃 발생느린 응답 하나가 스레드를 오래 점유
Tomcat keep-alive timeout커넥션 재사용 효과 감소유휴 커넥션이 스레드/소켓 자원을 오래 점유
HikariCP max-lifetime커넥션 재생성 비용 증가중간 장비가 끊은 죽은 커넥션을 재사용하다 에러
요약
✔ connection timeout 은 TCP 연결 중, read/write timeout 은 연결 이후 응답/전송 중에 발동한다.
✔ 커넥션 풀은 대기 중(Available)과 사용 중(Leased)을 오가며, 커넥션을 언제 폐기할지는 애플리케이션이 아니라 풀이 결정한다.
✔ Tomcat 의 keep-alive-timeout 은 로드밸런서 idle timeout 보다 길게 잡아야, 유휴 keep-alive 재사용 중 502 가능성을 줄일 수 있다.
✔ 일부 HTTP 클라이언트 풀이 지원하는 커넥션 최대 수명(TTL)은 HikariCP max-lifetime 과 유사한 개념이다.
✔ HikariCP max-lifetime 은 DB/인프라의 idle timeout 보다 짧게 잡아야 죽은 커넥션 재사용을 피할 수 있다.

📚 Reference