왜 문제가 되는가
운영 중 "타임아웃"이라는 단어는 자주 등장하지만, 대부분의 장애는 이 둘을 구분하지 못해서 발생한다. 커넥션 타임아웃(connect timeout)은 TCP 3-way handshake를 맺는 데까지 허용하는 시간이고, 리드 타임아웃(read/socket timeout)은 연결이 맺어진 뒤 응답 바이트를 기다리는 시간이다. 두 값을 하나로 뭉뚱그리거나, 둘 다 무한대로 두면 스레드 풀이 대기 상태로 고갈되어 서비스 전체가 멈춘다.
두 타임아웃의 차이
커넥션 타임아웃은 상대 서버가 죽었거나 방화벽에 막혀 SYN에 응답이 없을 때 걸린다. 정상 네트워크라면 수십 ms 안에 handshake가 끝나므로 값이 클 이유가 없다. 리드 타임아웃은 연결은 됐지만 애플리케이션이 느리게 응답할 때, 즉 상대 DB 쿼리가 느리거나 GC가 도는 상황에서 걸린다. 이 값은 대상 API의 정상 응답 시간(p99)에 여유를 더해 잡아야 한다.
| 구분 | 커넥션 타임아웃 | 리드 타임아웃 |
|---|---|---|
| 대상 구간 | TCP handshake | 응답 바이트 수신 |
| 실패 원인 | 서버 다운, 방화벽, DNS | 느린 처리, 대용량 응답 |
| 권장 값 | 짧게 (1~3초) | API p99 기준 (수 초) |
흔한 안티패턴
가장 위험한 설정은 리드 타임아웃을 아예 지정하지 않는 것이다. 많은 HTTP 클라이언트가 기본 리드 타임아웃을 무제한으로 둔다. 이 상태에서 상대 서버가 연결만 수락하고 응답을 보내지 않으면, 호출 스레드는 영원히 대기한다. 트래픽이 몰리면 톰캣의 워커 스레드가 모두 여기에 묶여 다른 요청까지 처리하지 못하는 연쇄 장애로 번진다.
Python 예시
requests는 튜플로 두 값을 분리해 지정할 수 있다. 하나의 숫자만 넘기면 두 타임아웃에 동일하게 적용되므로 반드시 튜플을 쓴다.
import requests
# (커넥션 타임아웃, 리드 타임아웃)
resp = requests.get(
"https://api.internal/orders",
timeout=(2, 5), # 연결 2초, 응답 대기 5초
)
# httpx는 세분화된 제어 제공
import httpx
timeout = httpx.Timeout(connect=2.0, read=5.0, write=5.0, pool=1.0)
client = httpx.Client(timeout=timeout)
Java / Spring 예시
Spring의 RestClient나 내부 커넥터에서도 두 값을 분리한다. YAML 설정과 코드 양쪽에서 명시하는 습관이 중요하다.
# application.yml
http:
client:
connect-timeout: 2s
read-timeout: 5s
var factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(Duration.ofSeconds(2)); // 커넥션
factory.setReadTimeout(Duration.ofSeconds(5)); // 리드
RestClient client = RestClient.builder()
.requestFactory(factory)
.build();
타임아웃과 재시도의 관계
커넥션 타임아웃 실패는 요청이 상대에 도달하지 않았음을 뜻하므로 재시도가 안전하다. 반면 리드 타임아웃은 이미 요청이 전달돼 서버가 처리 중일 수 있어, 결제·주문 같은 비멱등 API를 그대로 재시도하면 중복 처리가 발생한다. 재시도는 커넥션 실패 위주로 걸고, 리드 타임아웃 재시도는 멱등성이 보장된 경우로 한정해야 한다.
주의점
첫째, 타임아웃은 항상 호출 체인 전체를 고려한다. A→B→C 구조에서 안쪽 호출의 리드 타임아웃이 바깥보다 길면 바깥이 먼저 끊겨 자원만 낭비된다. 안쪽으로 갈수록 값을 짧게 잡는다. 둘째, 커넥션 풀을 쓴다면 커넥션 획득 대기 시간(pool timeout)도 별도로 존재하니 함께 점검한다. 셋째, DB 드라이버의 socketTimeout과 애플리케이션 타임아웃은 별개이므로 둘 다 명시적으로 설정해 두어야 예기치 못한 무한 대기를 막을 수 있다.