왜 헷갈릴까?
timeout 이 헷갈리는 이유는 같은 이름이 여러 계층에서 다른 뜻으로 쓰이기 때문이다.
예를 들어 connection timeout 은 HTTP 클라이언트나 JDBC 드라이버에서는 "상대와 TCP 연결을 맺는 데 허용하는 시간"을 뜻한다.
반면 Tomcat의 server.tomcat.connection-timeout은 "연결은 맺었지만 요청 라인이 들어오기를 기다리는 시간"이고, HikariCP의 connectionTimeout은 또 "풀에서 커넥션을 빌릴 때 기다리는 시간"이다. 이름은 셋 다 비슷한데 재는 대상이 전부 다르다.
Reactor Netty는 Tomcat과 타임아웃 모델 자체가 달라서, 이름이 같다고 Tomcat의 connection-timeout처럼 해석하면 안 된다.
keepalive 도 TCP keepalive, HTTP keep-alive, HikariCP keepaliveTime 이름만 비슷할 뿐 서로 다른 위치에서 동작한다.
그래서 "타임아웃이 났다"는 로그 한 줄만 보고는 어느 계층 문제인지 알 수 없다. 계층별로 나눠서 봐야 한다.
핵심 개념 정리
| 이름 | 상황 | 어디서 설정하는가 | 대표 설정 키 |
|---|---|---|---|
| connect timeout | TCP 연결을 맺는 동안 | HTTP 클라이언트, JDBC 드라이버 | RestClient/RestTemplate의 connect timeout, JDBC connectTimeout |
| connection request timeout | 풀에서 커넥션을 빌릴 차례를 기다리는 동안 | HTTP 커넥션 풀, DB 커넥션 풀 | HttpClient5 setConnectionRequestTimeout, spring.datasource.hikari.connection-timeout |
| read timeout (socket timeout) | 커넥션은 맺었는데 응답 데이터가 안 올 때 | HTTP 클라이언트, JDBC 드라이버 | RestClient/RestTemplate의 read timeout, JDBC socketTimeout |
| response timeout | HTTP 응답을 읽는 동안 연속된 network read 사이에 허용하는 최대 대기 시간 | HTTP 클라이언트 | WebClient(Reactor Netty)의 responseTimeout |
| write timeout | 요청 바디를 상대에게 다 못 보내고 있을 때 | HTTP 클라이언트 | WebClient의 WriteTimeoutHandler |
| server connection timeout | 커넥션은 맺었는데 클라이언트가 요청을 안 보낼 때 | Tomcat 등 WAS | server.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 |
| validation timeout | 풀이 커넥션의 유효성을 검사할 때 그 검사 자체에 허용하는 시간 | HikariCP | spring.datasource.hikari.validation-timeout (기본 5000, 최소 250ms) |
| query timeout | DB에 보낸 쿼리 하나가 끝나기를 기다리는 시간 | JDBC, 트랜잭션 | Statement.setQueryTimeout(), @Transactional(timeout = ...), JPA jakarta.persistence.query.timeout |
| async request timeout | 서블릿 비동기 요청이 완료되기를 기다리는 시간 | Spring MVC | spring.mvc.async.request-timeout |
read timeout, socket timeout, response timeout은 라이브러리마다 이름과 적용 범위가 다르다. 특히 Reactor Netty의 responseTimeout은 HTTP 응답 전체의 총 수행 시간이 아니라, 응답을 읽는 동안 각 network-level read operation 사이의 최대 대기 시간을 뜻한다.
이름만 보고 서로 완전히 다른 종류의 타임아웃이라고 단정하기보다, 쓰고 있는 HTTP 클라이언트의 정확한 정의를 확인하는 게 안전하다.
SQLTransientConnectionException) connectTimeout으로 따로 잡는다. DataSource.setLoginTimeout()으로도 쓰여서 간접적인 상한은 걸린다.connect timeout 과 read timeout 은 특히 헷갈리기 쉬운데, 그 기준은 생각보다 간단하다.
기준은 TCP 연결이 끝났는가? 이다.
아직 연결 중이면 connect timeout 이다.
연결은 됐는데 응답 데이터가 안 오면 read timeout 이다. 비슷해 보이는 상황을 나누어보면 아래와 같다.
| 상황 | 에러 |
|---|---|
| 상대 IP가 방화벽에 막혀 있거나 라우팅되지 않음 (SYN에 응답 없음) | connect timeout |
| TCP 연결은 맺었는데 상대 애플리케이션이 응답을 안 만듦(DB 락, GC pause, 무한루프), 또는 응답을 시작했지만 끝까지 보내지 않고 멈춤 | read timeout |
| 요청 바디가 커서 클라이언트가 다 못 보내고 있음(느린 업로드 등) | write timeout |
| 상대 서버는 떠 있지만 해당 포트에 아무것도 안 열려 있음 | ConnectException: Connection refused |
| hostname 자체를 못 찾음 | UnknownHostException |
직접 조작해보면 더 명확하다. 상황을 바꿔가며 요청을 보내보면, 같은 "응답이 안 온다"라도 어느 구간에서 막히느냐에 따라 다른 타임아웃이 나는 걸 볼 수 있다.
TCP 연결 자체를 못 맺을 때. 방화벽에 막혔거나 상대가 SYN 에 응답하지 않는 경우다.
연결은 됐는데 응답 데이터가 안 올 때. DB 락, GC pause 처럼 상대가 응답을 못 만드는 경우다.
연결 수립 중이면 connect, 연결 후 응답을 읽다 정체되면 read 다. DNS, TLS handshake, 풀 대기처럼 별도 구간도 있다.
참고로, Connection refused와 UnknownHostException은 timeout이 아니다.
둘 다 "연결이 안 됐다"는 점에서는 connect timeout처럼 보이지만, 설정한 timeout 시간까지 기다리지 않고 바로 실패한다. "기다릴 것도 없이 바로 안 되는" 경우다.
반면, timeout은 "기다렸는데도 안 됐을 때" 발생한다.
용어 사전
들어가기에 앞서, 아래와 같은 단어는 계속해서 나오는데 미리 알아두면 이해하기 쉽다.
| 용어 | 쉽게 말하면 |
|---|---|
| 유휴 (idle) | 지금 아무 요청도 처리하지 않고 그냥 쉬고 있는 상태 |
| socket timeout | 소켓에서 다음 데이터를 읽을 때까지 기다리는 시간. 보통 read timeout이라 부르지만, 클라이언트에 따라 response timeout과 별도로 관리되기도 한다 |
| 커넥션 풀 | 매번 새로 연결하지 않고, 다 쓴 연결을 모아뒀다가 재사용하는 통 |
| keep-alive | 연결을 끊지 않고 잠깐 열어둬서 다음 요청 때 재사용하게 해주는 것 |
| evict / expire | 재사용하던 연결을 진짜로 닫아버리는 것 |
| 3-way handshake | TCP 연결을 맺을 때 오가는 신호 3번(SYN → SYN-ACK → ACK) |
| wait_timeout (MySQL) | DB가 한동안 아무 활동 없는 연결을 알아서 끊어버리는 시간 |
Tomcat (서버) 설정
Spring Boot 내장 Tomcat 기준으로 application.yml에 아래 설정들이 있다.
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.
응답 후 다음 요청을 얼마나 기다릴지, 즉 "이 시간 안에 다음 요청 없으면 커넥션을 닫는다"는 것이 문서 그대로의 의미다.
connection-timeout 쪽은 한 가지 더 봐야 할 게 있다. 이 값은 요청 라인이 들어오기를 기다리는 데만 쓰이는 게 아니라, 기본 설정에서 이미 request body를 읽는 구간에도 적용된다. Tomcat HTTP Connector 문서의 connectionTimeout 설명이 그대로 그렇게 말한다.
Unless disableUploadTimeout is set to false, this timeout will also be used when reading the request body (if any).
disableUploadTimeout 기본값이 true이기 때문에, 별도 설정을 안 했다면 body 읽기에도 connectionTimeout이 걸린다. 느린 업로드를 다루는 서비스라면 이 값을 요청 라인 기준으로만 생각하고 짧게 잡으면 안 된다. 업로드 구간만 따로 늘리고 싶다면 knob이 두 개 더 있다.
disableUploadTimeout=false로 두면 body를 읽는 동안에는connectionTimeout대신connectionUploadTimeout(기본 300000ms)이 쓰인다.- 즉 "요청 라인 대기는 짧게, 업로드는 길게" 는 이 두 값을 나눠서 만든다.
connectionTimeout의 실제 기본값은 환경에 따라 다르다.ServerProperties.Tomcat.connectionTimeout에 기본값이 없어(null) 아무것도 설정하지 않으면 Tomcat 자체 기본값인 60000이 그대로 적용된다.server.xml이 20000으로 덮어쓰기 때문에 20000이 뜬다.keepAliveTimeout도 Spring Boot가 값을 넣지 않으며, 미설정 시 connectionTimeout 값을 따라간다. 반면 maxKeepAliveRequests는 Spring Boot가 100을 명시한다.위 yaml 예시의 20000은 기본값이 아니라 흔히 쓰는 값일 뿐이다.방향을 반대로 잡기 쉬운데, ALB처럼 백엔드 keep-alive 커넥션을 재사용하는 로드밸런서나 리버스 프록시가 앞단에 있다면 Tomcat 의 keep-alive-timeout 을 로드밸런서의 idle timeout 보다 여유 있게 길게 잡는 편이 안전하다.
로드밸런서가 아직 재사용 가능하다고 보고 있던 커넥션을 서버가 먼저 종료한 상태에서 요청이 겹치면 502가 발생할 수 있다. AWS도 ALB 트러블슈팅 문서에서 타깃의 keep-alive duration이 ALB idle timeout보다 짧은 경우를 502 원인 중 하나로 안내한다.
반대로 로드밸런서가 먼저 닫는 경우도 있다. 이때는 유휴 커넥션인지, 처리 중인 요청인지 나눠봐야 한다.
- 요청/응답이 이미 끝난 유휴(idle) keep-alive 커넥션이었다면, 보통 사용자 요청 실패로 이어지지 않는다.
- Tomcat이 긴 작업을 처리하느라 응답을 못 만드는 중이었다면, 로드밸런서 idle timeout이 먼저 만료될 수 있다.
- 로드밸런서가 보는 idle은 "애플리케이션이 일하는 중인지"가 아니라 "그 TCP 커넥션에 데이터가 오갔는지"다. 그래서 서버가 열심히 처리 중이어도 그동안 아무것도 안 보냈다면 유휴로 판단한다.
- 이때 클라이언트는 먼저 504 Gateway Timeout을 받을 수 있고, Tomcat은 뒤늦게 응답을 쓰려다 아래 같은 Broken pipe 로그를 남길 수 있다.
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보다 짧게 잡아서 애플리케이션이 먼저 실패 처리하게 만든다.
아래에서 두 값을 직접 조절해보면 어느 쪽이 먼저 keep-alive 커넥션을 종료하는지에 따라 동작이 어떻게 달라지는지 확인할 수 있다.
서버가 먼저 닫은 커넥션을 로드밸런서가 재사용하려는 시점과 요청이 겹치면 502가 발생할 수 있고, 로드밸런서가 요청 사이의 유휴 keep-alive 커넥션을 먼저 정리한 경우에는 보통 사용자 요청 실패로 이어지지 않는다.
값을 동일하게 맞추면 서로 독립적인 타이머의 경계 시점에서 경합이 생길 수 있으므로, 서버 쪽 keep-alive timeout에 여유를 두는 편이 안전하다.
LB 가 아직 재사용 가능하다고 판단한 커넥션에 다음 요청이 겹치면 502 가 발생할 수 있다.
요청이 끝난 유휴 keep-alive 커넥션이었다면 보통 사용자 요청 실패로 이어지지 않는다. 서버에 반드시 에러가 남는 것도 아니다.
두 타이머는 서로 다른 프로세스에서 독립적으로 흐르므로 경계 시점의 경합을 피하기 어렵다. 서버 쪽 keep-alive timeout 을 몇 초 이상 여유 있게 잡는 편이 안전하다.
Tomcat 이 실제로 keep-alive-timeout 이 지난 커넥션을 닫는지는 직접 소켓을 열어서 확인할 수 있다.
WebFlux(Reactor Netty) 설정
Reactor Netty(WebFlux) 기준으로는 application.yml에 아래 설정들이 있다.
server:
netty:
idle-timeout: 60000 # 커넥션이 HTTP 요청을 기다리는 최대 시간
max-keep-alive-requests: 100 # Spring Boot 3.x 에서 추가된 property (버전 확인 필요)
커넥션이 HTTP 요청을 얼마나 기다릴지를 정하는 건 idle-timeout이다. 값을 지정하지 않으면 타임아웃이 적용되지 않아 무제한으로 기다린다.
여기서 주의할 게 있다. Reactor Netty HttpServer.idleTimeout javadoc의 정의는 keep-alive 이후로 한정되어 있지 않다.
Specifies an idle timeout on the connection when it is waiting for an HTTP request (resolution: ms).
즉 응답을 보낸 뒤 다음 요청을 기다리는 구간뿐 아니라, 연결 직후 최초 요청 라인을 기다리는 구간까지 포함한다. 결과적으로 Netty의 idle-timeout 하나가 Tomcat의 keep-alive-timeout + connection-timeout 두 역할을 겸하는 구조다. 이름만 보고 Tomcat 설정과 1:1로 옮기면 어긋난다.
Tomcat server.tomcat.keep-alive-timeout + server.tomcat.connection-timeout
Reactor Netty server.netty.idle-timeout (하나로 두 구간을 모두 커버)
각 property 의 정의는 Spring Boot - Common Application Properties 에서 확인할 수 있다. 로드밸런서 뒤에서 keep-alive 타임아웃을 정렬하려는 목적이라면, 봐야 할 값은 connection-timeout이 아니라 idle-timeout이다. 이 값을 로드밸런서 idle timeout보다 길게 잡아야 안전한 건 Tomcat과 같다.
server.netty.connection-timeout은 Tomcat의 connection-timeout과 전혀 다르다. Spring Boot의 NettyWebServerFactoryCustomizer는 이 값을 ChannelOption.CONNECT_TIMEOUT_MILLIS로 매핑한다.httpServer.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, (int) connectionTimeout.toMillis())
CONNECT_TIMEOUT_MILLIS는 연결을 거는 쪽이 TCP 연결 수립을 기다리는 시간이라, 연결을 받기만 하는 서버 소켓에는 사실상 의미가 없다. 요청을 기다리는 시간을 잡으려면 idle-timeout을 써야 한다.커넥션 풀은 어떻게 동작하는가
Tomcat, WebFlux(Reactor Netty)와 달리, 지금부터 나오는 HTTP 클라이언트와 HikariCP는 커넥션을 "풀"에 넣어두고 재사용한다. 풀 안의 커넥션은 크게 두 상태를 오간다.
- 풀 안에서 쉬고 있는 상태
- 애플리케이션이 빌려가서 쓰는 상태
Apache HttpClient5 는 이 둘을 available/leased 라고 부르고, HikariCP 는 idle/active 라고 부르는데, 이름은 달라도 핵심은 같다.
- 대기 중 (Available) - 풀 안에서 다음 요청을 기다리는 커넥션
- 사용 중 (Leased) - 애플리케이션이 빌려가서 요청이나 쿼리를 처리하는 커넥션
- 폐기됨 (Expired) - 실제로 닫혀서 더 이상 쓸 수 없는 커넥션
커넥션은 요청이 들어올 때마다 대기 중과 사용 중 사이를 왔다 갔다 하지만 폐기되는 시점은 별도 조건으로 결정된다.
- 유휴 상태로
idle-timeout을 넘겼을 때 - 살아있는 시간이
max-lifetime또는 커넥션 최대 수명(TTL)을 넘겼을 때 - 살아있는지 확인하는 체크(keepalive)에서 이미 죽은 것으로 판정됐을 때
중요한 점은 애플리케이션의 connection.close()가 항상 물리 커넥션 종료를 뜻하지는 않는다는 것이다. 풀을 쓰면 close()는 보통 커넥션을 실제로 닫지 않고 풀에 반납하는 동작이다.
커넥션 종료를 풀만 결정하는 건 아니다. 풀은 max-lifetime이나 idle-timeout 같은 정책에 따라 계획적으로 폐기하지만, DB 서버나 로드밸런서, 방화벽도 각자의 timeout 정책에 따라 독립적으로 커넥션을 끊는다. 그래서 풀 설정과 외부 시스템의 정책을 함께 확인해야 한다(자세한 기준은 HikariCP 섹션에서 다룬다).
HTTP 클라이언트 설정
각 HTTP 클라이언트마다 connect/read/write timeout을 설정하는 API가 다르고, 커넥션 풀을 쓰는지에 따라 keep-alive 동작도 달라진다.
| 클라이언트 | connect / read timeout | 커넥션 풀 |
|---|---|---|
Feign (feign.Client.Default) | 있음 | 명시적 풀 없음 - JDK HttpURLConnection의 keep-alive 캐시에만 의존 (http.maxConnections 기본값 5) |
| Feign + HC5/OkHttp | 있음 | Apache HttpClient5 / OkHttp 풀 사용 |
RestTemplate (HttpComponentsClientHttpRequestFactory) | 있음 | Apache HttpClient5 풀 사용 가능 |
| WebClient (Reactor Netty) | 있음 (connect는 채널 옵션, 응답 대기는 responseTimeout 또는 핸들러) | Reactor Netty 커넥션 풀 사용 |
java.net.http.HttpClient (JDK 11+) | connect timeout만 있음 (HttpClient.Builder.connectTimeout). read/socket timeout에 해당하는 설정은 없고, 응답 대기는 요청 단위 HttpRequest.Builder.timeout으로 건다 | 내장 커넥션 풀 사용 |
HttpClient에는 read timeout(socket timeout)이 없다. HttpClient.Builder.connectTimeout() → 연결 수립만 제한 (HttpConnectTimeoutException) HttpRequest.Builder.timeout() → 요청 하나의 응답 전체 시간 (HttpTimeoutException) 이 계층에서 connect/read timeout은 실제로 이런 예외로 나타난다.
- connect timeout -
org.apache.hc.client5.http.ConnectTimeoutExceptionRestClient/RestTemplate에서는ResourceAccessException으로 래핑되어 올라온다
- read timeout -
java.net.SocketTimeoutException: Read timed out
풀을 쓴다면 이 둘 말고 챙길 게 더 있다.
connection request timeout
연결은 멀쩡한데 풀의 커넥션이 전부 대여 중(Leased)이라 빌릴 차례를 기다리다 시간 초과되는 것이다. HikariCP connection-timeout과 비슷한 풀 획득 대기 시간이다.
Apache HttpClient5는 RequestConfig.setConnectionRequestTimeout(...)으로 설정한다.
커넥션 최대 수명(TTL)
커넥션이 생성된 시각 기준으로 계산되는 유통기한으로, HikariCP max-lifetime처럼 커넥션 수명을 제한한다. Apache HttpClient5에서는 ConnectionConfig의 time-to-live(TTL)로 설정한다.
TTL을 정해두면 오래 살아있던 커넥션을 주기적으로 교체할 수 있다. 그런데 TTL은 죽은 커넥션을 찾아내는 기능이 아니라 수명을 제한하는 기능이다. 중간 장비가 조용히 끊어버린 커넥션에 대응하려면 TTL만으로는 부족하고, 뒤에 나오는 유휴 커넥션 검증과 퇴거를 같이 봐야 한다.
keepAliveDuration 하나로 동작한다. retryOnConnectionFailure(기본 true)로 재시도하므로 오히려 잘 다루는 영역이다.유휴 커넥션 검증과 자동 퇴거 (Apache HttpClient5)
먼저 이 문제가 어떤 에러로 드러나는지 보는 게 이해에 도움이 된다.
org.apache.hc.core5.http.NoHttpResponseException: localhost:8080 failed to respond
NoHttpResponseException은 요청은 다 보냈는데 응답을 단 한 바이트도 못 받고 커넥션이 끊긴 상황이다. read timeout처럼 "기다리다 지친" 게 아니라 기다릴 것도 없이 EOF를 받은 것이라, 설정한 read timeout을 다 채우지 않고 즉시 발생하는 게 보통이다.
전형적인 원인은 stale 커넥션 재사용이다. 서버나 중간 장비(로드밸런서, 방화벽)가 keep-alive 커넥션을 이미 닫았는데 클라이언트 풀은 그 사실을 모른 채 들고 있다가, 그 커넥션으로 요청을 보내면서 생긴다. TCP 특성상 상대가 조용히 닫은 커넥션은 실제로 써보기 전까지는 로컬에서 멀쩡해 보이기 때문에 사전에 걸러지지 않는다. 앞서 나온 Tomcat keep-alive-timeout이 로드밸런서 idle timeout보다 짧을 때 502가 나는 것과 원인 구조가 같고, 클라이언트 풀 쪽에서 같은 일이 벌어지면 이 예외로 나타난다.
그래서 대응도 같은 방향이다. 내 풀의 커넥션 수명을 상대가 끊는 시간보다 짧게 잡아서, 상대가 끊기 전에 내가 먼저 교체하게 만든다. 그 위에 아래 두 보조 장치를 덧댄다.
- validateAfterInactivity
- 설정한 시간 이상 유휴 상태였던 커넥션을 풀에서 빌릴 때, 아직 쓸 수 있는지 검증한다.
- 매 요청마다 새로 연결한다는 뜻이 아니라, 정상 커넥션은 그대로 재사용된다.
-
이 검증은 기본으로 켜져 있다.
ConnectionConfig의 필드 자체는null이지만, 커넥션 매니저가 그null을 2초로 해석한다.PoolingHttpClientConnectionManager의resolveValidateAfterInactivity()가 값이 없으면TimeValue.ofSeconds(2)를 돌려준다.- 5.1.x 에서는 아예 매니저 필드가 2초로 초기화된다. 설정 위치가 매니저에서
ConnectionConfig로 옮겨졌을 뿐, 동작이 꺼진 적은 없다.
- 기본값 2초면 대부분의 환경에서 그대로 둬도 된다. 명시적으로 잡는다면 문서화 목적에 가깝다.
0으로 두면 매 요청 검증이 되어 지연만 늘어난다. (timeValue.getDuration() == 0이면 무조건 stale 검사를 탄다)- 음수로 두면 검증 자체가 꺼진다. (
TimeValue.isNonNegative()검사를 통과하지 못한다)
- evictExpiredConnections() / evictIdleConnections()
- HttpClientBuilder에서 활성화하면 백그라운드 작업이 돌면서, 수명이 끝났거나 일정 시간 이상 유휴 상태인 커넥션을 주기적으로 풀에서 제거한다.
- 내부적으로는 IdleConnectionEvictor 백그라운드 스레드가 떠서 closeExpired()/closeIdle()을 대신 호출해준다.
- 이쪽은 기본으로 꺼져 있다.
HttpClientBuilder의 두 플래그는 boolean 기본값false이고, evictor 스레드는 명시적으로 호출해야 뜬다.
CloseableHttpClient httpClient = HttpClients.custom()
.evictExpiredConnections()
.evictIdleConnections(TimeValue.ofSeconds(30))
.build();
이 기능은 백그라운드 스레드를 쓰기 때문에, 애플리케이션 종료 시 CloseableHttpClient.close()를 호출해 함께 정리해야 한다. 스레드는 클라이언트 인스턴스마다 하나씩 뜨므로, 클라이언트를 동적으로 만들어 쓰는 구조라면 close() 누락이 곧 스레드 누수가 된다.
실무에서는 이 셋을 같이 걸어두는 쪽이 안전하다.
ConnectionConfig connConfig = ConnectionConfig.custom()
.setValidateAfterInactivity(TimeValue.ofSeconds(2)) // 기본값과 동일, 명시적 문서화 목적
.setTimeToLive(TimeValue.ofMinutes(5)) // 인프라 idle timeout 보다 짧게
.build();
CloseableHttpClient client = HttpClients.custom()
.setConnectionManager(PoolingHttpClientConnectionManagerBuilder.create()
.setDefaultConnectionConfig(connConfig)
.build()) // shared 로 선언하지 않는다
.evictExpiredConnections()
.evictIdleConnections(TimeValue.ofSeconds(30))
.build();
TTL을 인프라 idle timeout보다 짧게 잡으면 애초에 stale 커넥션이 생기기 전에 교체되고, validateAfterInactivity는 그래도 새는 케이스의 안전망, eviction은 유휴 커넥션을 미리 치우는 역할이다. HikariCP에서 max-lifetime + keepalive-time을 같이 쓰는 것과 정확히 같은 구조다.
HikariCP max-lifetime keepalive-time (idle-timeout)
HttpClient5 ConnectionConfig TTL validateAfterInactivity evictIdleConnections
HC5 기본 재시도 전략이 IOException + 멱등 메서드는 재시도해주기 때문에 GET은 어느 정도 자동으로 덮인다. POST는 재시도 대상이 아니므로, 위 조합을 갖춰두는 게 더 중요하다.
한 가지 주의할 점은, 커넥션 매니저를 shared 로 구성하면 이 빌더 기반 eviction 이 동작하지 않는다는 것이다.
HttpClients.custom()
.setConnectionManager(connectionManager)
.setConnectionManagerShared(true)
기준은 커넥션 매니저를 외부에서 주입했는지가 아니라, setConnectionManagerShared(true) 로 shared 로 선언했는지다. HttpClientBuilder javadoc 의 evictIdleConnections / evictExpiredConnections 설명에 이 조건이 명시되어 있다.
WebClient(Reactor Netty)의 타임아웃
WebClient(Reactor Netty)는 connect timeout과 response timeout을 서로 다른 API로 설정한다.
TCP 연결 수립 시간은 CONNECT_TIMEOUT_MILLIS로 제한하고, HTTP 응답을 읽는 과정에서 network read 사이의 최대 대기 시간은 responseTimeout으로 설정한다.
HttpClient httpClient = HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 3000) // TCP 연결 수립
.responseTimeout(Duration.ofSeconds(5)); // 응답 read 사이 최대 대기 시간
따라서 응답 데이터가 설정한 시간보다 짧은 간격으로 계속 들어온다면, 전체 응답 시간이 responseTimeout보다 길어도 타임아웃이 발생하지 않을 수 있다.
반대로 응답을 읽는 도중 다음 데이터가 responseTimeout보다 오래 들어오지 않으면 타임아웃이 발생한다.
responseTimeout을 설정하지 않으면 Reactor Netty 자체의 response timeout은 적용되지 않는다. 상위 계층에 별도 타임아웃이 없다면 응답 지연을 그대로 기다리게 되므로, 운영 환경에서는 명시적으로 잡아주는 게 좋다.
요청 전체의 end-to-end 시간을 제한하고 싶다면 Mono.timeout()이나 Flux.timeout() 같은 리액티브 연산자를 따로 쓸 수 있다. 적용 범위가 responseTimeout처럼 응답 read 구간에 한정되지 않고 파이프라인 전체에 걸린다는 점이 다르다.
Mono.timeout()은 단일 신호를 기준으로 하므로 요청 하나의 전체 시간 제한에 가깝지만, Flux.timeout()은 스트림의 각 원소 사이 간격에 적용되기 때문에 그대로 전체 deadline이 되지는 않는다. 스트리밍 응답이라면 이 차이를 확인하고 써야 한다. 타임아웃 종류별 구분은 Reactor Netty - HTTP Client 와 Timeout Configurations 에 정리되어 있다.
Netty 채널 레벨의 read/write timeout을 직접 제어하려면 ReadTimeoutHandler나 WriteTimeoutHandler를 추가할 수 있다.
참고로 responseTimeout도 HTTP 응답을 읽는 동안 ReadTimeoutHandler를 채널 파이프라인에 추가하는 방식으로 동작한다. 그래서 둘을 완전히 별개의 메커니즘으로 보기보다는, 핸들러가 적용되는 시점과 범위가 다르다고 이해하는 편이 정확하다.
HttpClient httpClient = HttpClient.create()
.doOnConnected(conn -> conn
.addHandlerLast(new ReadTimeoutHandler(5, TimeUnit.SECONDS))
.addHandlerLast(new WriteTimeoutHandler(5, TimeUnit.SECONDS)));
HikariCP (DB 커넥션 풀) 설정
spring:
datasource:
hikari:
connection-timeout: 30000 # 풀에서 커넥션을 못 받으면 이 시간 후 예외 (기본값 30초)
max-lifetime: 1800000 # 커넥션 하나를 최대 이 시간까지만 사용 (기본값 30분, 최소 30초)
idle-timeout: 600000 # 커넥션이 풀에서 유휴로 유지될 수 있는 최대 시간 (기본값 10분, minimum-idle 아래로는 줄지 않음)
keepalive-time: 120000 # 이 주기로 유휴 커넥션에 ping (최소 30초, max-lifetime 보다 작아야 함)
# 5.x 기본값은 0(비활성), 6.0+ 기본값은 120000
여기서 먼저 구분해야 할 게 있다. 이름은 다 "시간"이지만 재는 기준이 다르다.
max-lifetime- 커넥션이 만들어진 시점부터 세는 절대 수명- 수명이 다했다고 사용 중인 커넥션을 강제로 끊지는 않고, 반납된 뒤에 제거한다.
idle-timeout- 커넥션이 풀에서 유휴 상태로 머무를 수 있는 기준 시간- 만료 즉시 제거되는 hard deadline은 아니고, HikariCP 문서도 실제 제거 시점에 약간의 편차가 있다고 설명한다.
- 유휴 커넥션이 제거되어도 풀은
minimum-idle아래로는 줄어들지 않는다. 그래서minimum-idle < maximum-pool-size인 탄력적인 풀에서 주로 의미가 있고, 둘이 같은 고정 크기 풀에서는 이 설정만으로 풀 크기가 줄지 않는다.
keepalive-time- 유휴 커넥션을 주기적으로 잠깐 풀에서 꺼내 유효성을 확인한 뒤 다시 반납- JDBC4를 지원하는 일반적인 드라이버에서는
Connection.isValid()를 쓰고,connectionTestQuery를 설정했다면 그 쿼리로 검증한다. HikariCP 문서는 JDBC4 드라이버라면connectionTestQuery를 설정하지 말고Connection.isValid()를 쓰길 권장한다. - 이 확인 과정에서 실제 DB 통신이 발생하기 때문에, DB나 인프라가 idle timeout으로 끊는 걸 막는 데 쓸 수 있다. 유휴 커넥션에만 동작하고,
max-lifetime보다 작아야 한다. - 기본값이 버전에 따라 다르다. HikariCP 5.x까지는
0(비활성)이었고, 6.0부터 2분(120000)으로 기본 활성화됐다. 쓰는 버전을 먼저 확인한다.- 5.x에서 올라온 서비스라면 이 값을 건드리지 않아도 동작이 달라진다. 조건(최소 30초,
max-lifetime미만)에 걸리면 시작 로그에 경고가 찍히고 비활성화되므로, 업그레이드 직후 로그를 한 번 확인하는 게 안전하다. - 참고로
HikariConfig.setKeepaliveTime()의 javadoc은 아직default is 0 (disabled)로 남아 있어 코드와 어긋난다. 문서보다 쓰는 버전의DEFAULT_KEEPALIVE_TIME상수를 보는 편이 정확하다.
- 5.x에서 올라온 서비스라면 이 값을 건드리지 않아도 동작이 달라진다. 조건(최소 30초,
- JDBC4를 지원하는 일반적인 드라이버에서는
이 셋을 하나로 뭉뚱그리면 설정이 꼬인다. 그래서 대응도 나눠서 봐야 한다.
DB나 인프라가 커넥션의 절대 수명을 제한하는 경우라면, 그보다 max-lifetime을 짧게 잡아 풀이 먼저 교체하게 만든다.
max-lifetime (HikariCP) < DB / Proxy 가 강제하는 절대 커넥션 수명
max-lifetime이 그보다 길면(0=무제한 포함) DB나 인프라가 애플리케이션보다 먼저 커넥션을 종료할 수 있다. 이렇게 되면 이미 끊긴 커넥션이 풀에 남아 있다가, 검증이나 재사용 시점에 쓸 수 없는 커넥션으로 드러난다.
중간 장비가 유휴 커넥션을 끊는 경우는 절대 수명이 아니라 유휴 시간이 문제다. 이때는 keepalive-time을 외부 idle timeout보다 충분히 짧게 잡아 유휴 커넥션에 주기적으로 활동이 생기게 하거나, 풀 크기를 탄력적으로 운영한다면 idle-timeout을 외부보다 짧게 잡아 HikariCP가 먼저 정리하게 만드는 방법을 검토한다. 앞서 말했듯 idle-timeout은 minimum-idle이 maximum-pool-size보다 작을 때만 커넥션 수를 줄인다.
validation-timeout은 HikariCP 문서상 connection-timeout보다 작아야 한다(This value must be less than the connectionTimeout). 다만 이 관계는 코드로 강제되지 않는다. validateNumerics()는 max-lifetime·keepalive-time·leakDetectionThreshold만 보정하고 이 둘의 대소는 검사하지 않으므로, 잘못 잡아도 경고 없이 넘어간다.주의할 점은 MySQL wait_timeout이나 로드밸런서의 idle timeout은 절대 수명이 아니라 유휴 시간 기준이라는 것이다. max-lifetime과 단순 비교해서 더 작은 값을 고르는 식으로 접근하면 안 된다. 유휴 시간 때문에 끊기는 환경이라면 keepalive-time이나 idle-timeout 쪽을 함께 봐야 한다.
참고로 MySQL wait_timeout 기본값은 8시간(28,800초)이고, AWS NLB의 TCP flow idle timeout 기본값은 350초다. 2024년 9월부터 TCP 리스너는 tcp.idle_timeout.seconds 리스너 속성으로 60초~6000초 범위에서 조정할 수 있고(기본값은 하위 호환을 위해 350초 유지), TLS 리스너는 여전히 350초 고정이다. 방화벽이나 프록시도 각자 idle timeout을 가질 수 있다. 값은 환경마다 다르니 짐작하지 말고 직접 확인한다(MySQL 기준 SHOW VARIABLES LIKE 'wait_timeout'). 커넥션 수명을 강제하는 제한이 있다면, 그보다 여유를 두고 max-lifetime을 잡는다.
NLB의 TCP idle timeout은 클라이언트와 타깃 어느 쪽에서도 데이터가 오가지 않은 시간을 기준으로 하며, TCP keepalive 패킷도 이 타이머를 다시 시작시킨다. 그래서 HikariCP keepalive-time처럼 애플리케이션 레벨에서 DB 트래픽을 발생시키는 방법뿐 아니라 OS TCP 스택의 TCP keepalive도 대응 수단이 될 수 있다. 둘은 비슷한 문제를 완화할 수 있지만 동작 계층은 다르다. NLB 공식 문서에도 클라이언트나 타깃이 보내는 TCP keepalive 패킷이 idle timeout을 다시 시작시킨다고 명시되어 있다.
keepalive-time은 반드시 max-lifetime보다 작고 최소 30초 이상이어야 한다. 이 조건을 안 지키면 설정이 보정되거나 무시될 수 있으니 시작 로그를 확인하는 게 안전하다.
아래는 풀에 있던 커넥션이 MySQL wait_timeout을 넘겨 유휴 상태로 방치되다가, DB가 먼저 끊어버렸을 때 나는 에러다.
com.mysql.cj.jdbc.exceptions.CommunicationsException:
Communications link failure
The last packet successfully received from the server was 28,801,047 milliseconds ago.
28,801,047ms는 약 8시간으로, MySQL wait_timeout 기본값(28,800초)과 거의 같다. 이 커넥션이 8시간 동안 아무 활동 없이 놀다가 DB에 의해 끊겼다는 뜻이다. 반대로 이 값이 1,800,000ms 근처였다면 HikariCP max-lifetime 기본값(30분)과 겹치므로 완전히 다른 시나리오를 봐야 한다.
여기서 볼 것은 max-lifetime과 wait_timeout의 대소 관계가 아니다. MySQL wait_timeout은 "마지막 활동 이후 얼마나 오래 놀았는가"를 기준으로 끊기 때문에, 로그의 "last packet ... milliseconds ago" 값이 어떤 idle timeout과 비슷하다면 그 커넥션이 오래 유휴 상태였다가 DB나 중간 장비에 의해 종료된 건 아닌지 확인해야 한다.
아래 예제는 MySQL(Docker)의 wait_timeout을 2초로 줄이고 커넥션을 2초 이상 놀린 뒤 다시 써서, 같은 유형의 에러를 몇 초 만에 재현한다.
💡 실무 팁
- 로드밸런서/리버스 프록시 뒤라면 서버(Tomcat) keep-alive-timeout을 그쪽 idle timeout보다 몇 초 더 길게 잡는다.
Tomcat keep-alive-timeout > 로드밸런서 idle timeout
- HTTP 클라이언트 풀을 쓴다면 connect timeout, read timeout 뿐 아니라 풀에서 커넥션을 얻기까지 기다리는 시간인 connection request timeout 도 명시적으로 설정하는 게 좋다.
- DB나 인프라가 "커넥션 생성 후 최대 N분" 같은 절대 수명을 강제한다면, HikariCP
max-lifetime을 그보다 여유 있게 짧게 잡는다. 0(무제한)은 먼저 끊는 쪽이 없다는 확신이 있을 때만 선택한다.max-lifetime < DB / Proxy 가 강제하는 절대 커넥션 수명
- 반대로 MySQL
wait_timeout이나 NLB idle timeout처럼 통신이 없는 시간을 기준으로 끊는 설정은max-lifetime과 직접 비교하지 않는다.keepalive-time, TCP keepalive,idle-timeout을 함께 본다.30초 ≤ keepalive-time < max-lifetimeidle-timeout은minimum-idle < maximum-pool-size일 때만 커넥션 수를 줄인다.
- HikariCP를 6.x로 올렸다면
keepalive-time이 기본으로 켜진다는 점을 먼저 확인한다. 5.x 기준으로 튜닝해둔 값이 있다면 의도와 달라질 수 있다. - 값을 정할 때는 항상 "이 timeout이 지나면 무슨 일이 생기는가"부터 확인한다. 재시도되는지, 에러가 사용자에게 노출되는지에 따라 안전한 여유값이 달라진다.
🖊️ 정리
타임아웃 값은 무조건 "안전하게 크게" 잡는다고 좋은 게 아니고, 무엇을 방어하기 위한 것인지 알아야, 장애 상황에서 어느 계층의 어느 값을 의심해야 할지 판단할 수 있다.
| 계층 | 이 값이 너무 짧으면 | 이 값이 너무 길면 |
|---|---|---|
| connect timeout | 일시적인 네트워크 지연에도 연결 실패가 증가 | 실패한 연결 시도가 오래 유지되어 장애 감지와 복구가 늦어짐 |
| read/write timeout | 정상적으로 느린 요청도 실패할 수 있음 | 느린 요청이 커넥션이나 메모리 같은 자원을 오래 점유하고 실패 판단이 늦어짐 |
| Tomcat keep-alive timeout | 커넥션 재사용 효과 감소, 재연결 증가 | 유휴 소켓/커넥션 관련 자원을 더 오래 유지 |
| HikariCP max-lifetime | 커넥션 교체가 잦아져 재연결 비용 증가 | 절대 수명을 더 짧게 강제하는 환경에서 외부가 먼저 커넥션을 종료할 가능성 증가 |
📚 Reference
- Spring Boot - Common Application Properties (server.tomcat.*)
- Spring Boot - ServerProperties (Tomcat / Netty 기본값)
- Spring Boot - NettyWebServerFactoryCustomizer (connection-timeout 매핑)
- Apache Tomcat 10.1 - HTTP Connector (connectionTimeout, disableUploadTimeout, connectionUploadTimeout)
- JDK - java.net.http.HttpClient.Builder javadoc (connectTimeout)
- JDK - Networking Properties (http.maxConnections)
- Project Reactor Netty - HttpServer javadoc (idleTimeout)
- Apache HttpClient5 - ConnectionConfig.Builder javadoc
- Apache HttpClient5 - PoolingHttpClientConnectionManager 소스 (resolveValidateAfterInactivity)
- Apache HttpClient5 - DefaultHttpRequestRetryStrategy 소스 (멱등 메서드 재시도)
- Apache HttpClient5 - Connection Management
- Apache HttpClient5 - Connection Pooling
- Apache HttpClient5 - HttpClientBuilder javadoc
- Apache HttpCore5 - PoolStats javadoc
- HikariCP - README (Configuration)
- HikariCP - HikariConfig 소스 (버전별 기본값 상수)
- HikariCP Wiki - About Pool Sizing
- Project Reactor Netty - Timeout Configurations
- Project Reactor Netty - HTTP Client
- Project Reactor Netty - HttpClientRequest javadoc (responseTimeout)
- AWS - Application Load Balancer 문제 해결 (HTTP 502)
- AWS - Network Load Balancer 구성 요소 (idle timeout, TCP keepalive)
- AWS - Network Load Balancer now supports configurable TCP idle timeout (2024.09)
- AWS Blog - Introducing NLB TCP configurable idle timeout
- Netmarble Engineering - HikariCP Options Optimization for Game Server
- pkgonan - HikariCP test-while-idle 문의 기록