분산 시스템에서 장애는 “서버가 죽었을 때”만 발생하지 않습니다. 오히려 더 흔한 원인은 느린 응답입니다. 하나의 다운스트림 서비스가 평소보다 몇 초 느려지면, 타임아웃을 걸어두지 않은 클라이언트는 스레드와 커넥션을 붙잡은 채 대기하고, 그 대기가 연쇄적으로 상위 서비스의 리소스를 고갈시킵니다. 결국 “죽지 않은 서비스” 하나가 전체 장애를 유발합니다.
이 글에서는 HTTP 클라이언트를 장애에 강하게 만드는 세 축 — 커넥션 관리·타임아웃·재시도 — 를 실전 설정과 함께 다룹니다. 언어는 Python, Go, curl 예시를 섞어 개념이 언어에 종속되지 않도록 정리합니다. 핵심은 “합리적인 기본값을 명시적으로 설정하고, 위험한 기본값을 방치하지 않는 것”입니다.
타임아웃은 하나가 아니다: 4단계로 나눠 걸어라
많은 개발자가 “타임아웃을 걸었다”고 할 때 실제로는 전체 요청 타임아웃 하나만 설정합니다. 하지만 HTTP 요청 수명주기에는 여러 단계가 있고, 각 단계마다 별도의 타임아웃이 필요합니다.
- connect timeout: TCP 연결(및 TLS 핸드셰이크) 수립까지의 제한. 대개 짧게(1~3초).
- read/response timeout: 응답 바이트를 받기 시작할 때까지 또는 소켓 read 사이의 제한.
- write timeout: 요청 본문 전송 제한.
- total/request timeout: 요청 전체의 상한. 위 값들을 감싸는 안전망.
Python httpx는 이 네 가지를 개별적으로 지정할 수 있어 예시로 삼기 좋습니다.
import httpx
timeout = httpx.Timeout(
connect=3.0, # TCP + TLS 수립
read=5.0, # 응답 read 사이 제한
write=5.0, # 요청 본문 전송
pool=2.0, # 커넥션 풀에서 커넥션 획득 대기
)
client = httpx.Client(timeout=timeout)
resp = client.get("https://api.example.com/v1/orders")
가장 위험한 것은 “타임아웃 없음(None)”이 기본값인 라이브러리입니다. Python requests는 timeout을 지정하지 않으면 무한 대기합니다. 이 한 줄이 야간 장애의 단골 원인입니다.
# 잘못된 예: 무한 대기 가능
requests.get(url)
# 올바른 예: (connect, read) 튜플로 명시
requests.get(url, timeout=(3.0, 5.0))
커넥션 풀과 keep-alive를 명시적으로 관리하기
요청마다 새 TCP 연결을 맺으면 핸드셰이크·TLS 비용이 반복됩니다. HTTP keep-alive와 커넥션 풀을 사용하면 연결을 재사용해 지연과 CPU를 아낄 수 있습니다. 다만 풀 크기와 유휴 커넥션 수명을 명시하지 않으면, 대량 요청 시 커넥션이 폭증하거나 죽은 커넥션을 재사용하다 에러가 납니다.
import httpx
limits = httpx.Limits(
max_connections=100, # 전체 동시 커넥션 상한
max_keepalive_connections=20, # 유휴 유지 커넥션 수
keepalive_expiry=30.0, # 유휴 커넥션 만료(초)
)
client = httpx.Client(limits=limits, timeout=httpx.Timeout(5.0, connect=3.0))
Go의 http.Client는 기본 트랜스포트를 그대로 쓰면 커넥션 풀 파라미터가 워크로드에 맞지 않는 경우가 많으므로 명시하는 편이 좋습니다.
package main
import (
"net"
"net/http"
"time"
)
func newClient() *http.Client {
transport := &http.Transport{
DialContext: (&net.Dialer{
Timeout: 3 * time.Second, // connect timeout
KeepAlive: 30 * time.Second,
}).DialContext,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 20,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 3 * time.Second,
}
return &http.Client{
Transport: transport,
Timeout: 8 * time.Second, // 전체 요청 상한
}
}
주의: 응답 본문(resp.Body)을 끝까지 읽고 Close() 하지 않으면 Go는 커넥션을 풀에 반납하지 못하고 재사용을 포기합니다. 이는 조용히 커넥션 누수를 만드는 흔한 실수입니다.
재시도: 무엇을, 언제, 어떻게
재시도는 일시적 오류를 흡수해 주지만, 잘못 설계하면 장애를 증폭시킵니다. 핵심 원칙은 세 가지입니다.
- 멱등한 요청만 안전하게 재시도: GET/HEAD/PUT/DELETE는 대체로 안전. POST는 서버가 멱등성 키(idempotency key)를 지원할 때만.
- 재시도 가능한 오류만 대상: 커넥션 실패, 타임아웃, 429, 502/503/504 등. 400/401/403/404는 재시도해도 결과가 같으므로 즉시 실패.
- 지수 백오프 + 지터: 고정 간격 재시도는 여러 클라이언트가 동시에 재시도하는 “thundering herd”를 만든다. 지터로 흩어야 한다.
import random
import time
import httpx
RETRYABLE = {429, 502, 503, 504}
def request_with_retry(client, method, url, max_retries=3, **kwargs):
base = 0.2 # 200ms
for attempt in range(max_retries + 1):
try:
resp = client.request(method, url, **kwargs)
if resp.status_code not in RETRYABLE:
return resp
except (httpx.ConnectError, httpx.TimeoutException):
if attempt == max_retries:
raise
if attempt == max_retries:
return resp # 마지막 시도 결과 반환
# 지수 백오프 + full jitter
sleep = random.uniform(0, base * (2 ** attempt))
time.sleep(min(sleep, 5.0)) # 상한 캡
서버가 Retry-After 헤더를 주는 경우(특히 429/503)에는 그 값을 우선 존중해야 합니다. 백오프 계산보다 서버가 알려준 대기 시간이 더 정확하기 때문입니다.
재시도 폭주를 막는 안전장치
재시도 로직은 반드시 상위 안전장치와 함께 배치해야 합니다. 다운스트림이 이미 과부하 상태인데 모든 클라이언트가 재시도를 퍼부으면 회복을 방해합니다.
- 재시도 예산(retry budget): 전체 요청 중 재시도가 차지하는 비율에 상한을 둔다(예: 10%). 초과하면 재시도를 중단.
- 서킷 브레이커: 연속 실패가 임계치를 넘으면 일정 시간 요청을 즉시 차단(open)해 다운스트림을 쉬게 한다.
- 데드라인 전파: 상위에서 내려온 남은 시간(deadline)을 초과하는 재시도는 하지 않는다.
# 데드라인 전파 개념: 남은 시간 안에서만 재시도
import time
def call_with_deadline(fn, deadline_ts):
while time.monotonic() < deadline_ts:
remaining = deadline_ts - time.monotonic()
try:
return fn(timeout=min(remaining, 5.0))
except TimeoutError:
if time.monotonic() >= deadline_ts:
raise
raise TimeoutError("deadline exceeded")
curl로 빠르게 동작을 검증할 때도 타임아웃과 재시도 플래그를 습관화하면 좋습니다.
# connect 3초, 전체 10초, 재시도 3회 + 백오프
curl --connect-timeout 3 --max-time 10 \
--retry 3 --retry-delay 1 --retry-max-time 20 \
--retry-all-errors \
https://api.example.com/health
타임아웃 값은 상위로 갈수록 커져야 한다
여러 서비스가 계층을 이루면 타임아웃을 계층 간 일관되게 설계해야 합니다. 원칙은 간단합니다. 상위 서비스의 타임아웃은 하위 호출들의 (타임아웃 + 재시도) 합보다 커야 한다는 것입니다. 그렇지 않으면 하위가 아직 재시도 중인데 상위가 먼저 포기해, 하위의 작업이 무의미해집니다.
# 예시 타임아웃 계층 설계
# 게이트웨이(사용자 대면) : total 10s
# └─ 주문 서비스 호출 : total 6s (retry 2회 포함)
# └─ 결제 API 호출 : connect 2s / read 2s, retry 1회
# └─ 재고 API 호출 : connect 1s / read 1.5s, retry 1회
# 규칙: 하위 (read+retry) 합 < 상위 total 이 되도록 여유 확보
이 계층 설계를 문서화하고 코드의 기본값과 일치시키면, “왜 사용자에게는 성공인데 내부적으로는 중복 처리됐지?” 같은 미묘한 버그를 예방할 수 있습니다.
마무리
HTTP 클라이언트 설정은 사소해 보이지만, 대형 장애의 상당수가 여기서 시작됩니다. 요청마다 명시적 타임아웃(connect/read/write/total)을 걸고, 커넥션 풀과 keep-alive를 워크로드에 맞게 관리하며, 멱등한 요청에 한해 지수 백오프+지터로 재시도하고, 재시도 예산·서킷 브레이커·데드라인 전파로 폭주를 막는다 — 이 네 가지를 기본값으로 삼으면 대부분의 연쇄 장애를 예방할 수 있습니다. 위험한 기본값을 방치하지 않는 것, 그것이 장애에 강한 클라이언트의 출발점입니다.