분산 시스템에서 장애는 “서버가 죽었을 때”만 발생하지 않습니다. 오히려 더 흔한 원인은 느린 응답입니다. 하나의 다운스트림 서비스가 평소보다 몇 초 느려지면, 타임아웃을 걸어두지 않은 클라이언트는 스레드와 커넥션을 붙잡은 채 대기하고, 그 대기가 연쇄적으로 상위 서비스의 리소스를 고갈시킵니다. 결국 “죽지 않은 서비스” 하나가 전체 장애를 유발합니다.

이 글에서는 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 requeststimeout을 지정하지 않으면 무한 대기합니다. 이 한 줄이 야간 장애의 단골 원인입니다.

# 잘못된 예: 무한 대기 가능
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를 워크로드에 맞게 관리하며, 멱등한 요청에 한해 지수 백오프+지터로 재시도하고, 재시도 예산·서킷 브레이커·데드라인 전파로 폭주를 막는다 — 이 네 가지를 기본값으로 삼으면 대부분의 연쇄 장애를 예방할 수 있습니다. 위험한 기본값을 방치하지 않는 것, 그것이 장애에 강한 클라이언트의 출발점입니다.