Skip to content
Published on

HTTP Keep-Alive와 커넥션 재사용 — 간헐적 502를 만드는 타임아웃 경쟁 조건

공유하기
Authors

들어가며 — 백엔드는 멀쩡한데 502가 섞여 나옵니다

트래픽이 늘어난 뒤부터 이런 보고가 올라옵니다.

  • 로드밸런서 접근 로그에 502가 전체의 0.05퍼센트쯤 섞여 나옵니다.
  • 같은 시각 백엔드 애플리케이션 로그에는 해당 요청 자체가 아예 없습니다.
  • CPU도 메모리도 여유롭고, 헬스 체크는 한 번도 실패한 적이 없습니다.
  • 재현이 안 됩니다. 부하 테스트를 돌리면 오히려 잘 됩니다.

여기서 흔한 대응은 백엔드를 늘리거나 헬스 체크 임계값을 조정하는 것입니다. 둘 다 효과가 없습니다. 이 증상의 원인은 용량이 아니라 타이밍이기 때문입니다.

유휴 커넥션을 서버가 닫으려는 순간과 클라이언트가 그 커넥션을 재사용하려는 순간이 겹치면, 요청과 FIN이 서로 지나칩니다. 서버는 이미 닫은 연결에 도착한 데이터에 RST로 답하고, 중간의 프록시는 그것을 502로 번역합니다. 이 경쟁 조건은 HTTP/1.1 구조상 완전히 없앨 수 없고, 타임아웃 순서를 맞춰서 확률을 0에 가깝게 만드는 것이 유일한 대응입니다.

이 글은 그 순서가 왜 그래야 하는지, 그리고 커넥션 재사용과 관련된 나머지 판단들을 어떻게 내리는지 정리합니다.

연결 하나를 여는 비용

먼저 왜 재사용이 중요한지를 숫자로 봅니다. 새 HTTPS 연결 하나를 여는 데 필요한 왕복은 이렇습니다.

DNS 조회        0~1 RTT (캐시되어 있으면 0)
TCP 핸드셰이크  1 RTT   (SYN, SYN-ACK, ACK)
TLS 1.3         1 RTT
TLS 1.2         2 RTT
                --------
합계            TLS 1.3 기준 최소 2 RTT

같은 리전 안에서 RTT가 1밀리초라면 2밀리초입니다. 무시할 만합니다. 그런데 서울에서 미국 동부까지는 RTT가 180밀리초쯤 됩니다. 연결 수립에만 360밀리초를 씁니다. 서버 처리 시간이 20밀리초여도 사용자 체감은 380밀리초입니다.

이 지점부터는 애플리케이션 최적화보다 커넥션 재사용이 압도적으로 큰 효과를 냅니다. 재사용된 연결에서는 이 2 RTT가 통째로 0이 되기 때문입니다.

실제로 측정해 보면 명확합니다.

cat > /tmp/curl-format.txt <<'EOF'
    time_namelookup:  %{time_namelookup}s
       time_connect:  %{time_connect}s
    time_appconnect:  %{time_appconnect}s
   time_pretransfer:  %{time_pretransfer}s
 time_starttransfer:  %{time_starttransfer}s
                      ----------
         time_total:  %{time_total}s
        num_connects:  %{num_connects}
EOF

curl -s -o /dev/null -w "@/tmp/curl-format.txt" \
  https://api.example.com/v1/orders \
  https://api.example.com/v1/orders
    time_namelookup:  0.004312s
       time_connect:  0.184901s
    time_appconnect:  0.371244s
   time_pretransfer:  0.371402s
 time_starttransfer:  0.393118s
                      ----------
         time_total:  0.394552s
        num_connects:  1

    time_namelookup:  0.000021s
       time_connect:  0.000029s
    time_appconnect:  0.000031s
   time_pretransfer:  0.000094s
 time_starttransfer:  0.022704s
                      ----------
         time_total:  0.023918s
        num_connects:  0

첫 요청은 394밀리초, 두 번째는 24밀리초입니다. 서버 처리 시간은 둘 다 22밀리초로 같습니다. 차이 370밀리초는 전부 연결 수립 비용이었습니다.

HTTP/1.1, HTTP/2, HTTP/3가 각각 없앤 것

버전마다 무엇을 해결했는지 구분해 두면 선택이 쉬워집니다.

HTTP/1.1은 지속 연결을 기본으로 만들었습니다. 응답 하나를 받고 연결을 닫는 대신 계속 씁니다. 다만 한 연결 위에서는 요청과 응답이 순서대로 하나씩만 오갑니다. 파이프라이닝이 명세에 있긴 했지만 중간 장비 호환성 문제로 사실상 폐기됐습니다. 그래서 동시성은 연결 개수로만 얻습니다. 브라우저가 도메인당 6개 연결을 여는 관행이 여기서 나왔습니다. 이것이 HTTP 계층의 선두 차단입니다.

HTTP/2는 하나의 TCP 연결 위에 여러 스트림을 다중화합니다. 요청 100개를 동시에 보내도 연결은 하나입니다. HTTP 계층의 선두 차단은 사라집니다. 헤더 압축과 서버 우선순위도 여기서 들어왔습니다.

여기서 정정할 오해가 있습니다. "HTTP/2는 연결이 하나라 언제나 빠르다"는 설명은 조건부로만 맞습니다. TCP는 순서 보장을 하므로, 세그먼트 하나가 손실되면 그 뒤에 도착한 모든 데이터가 커널 버퍼에 갇힙니다. 다중화된 100개 스트림이 전부 함께 멈춥니다. HTTP/1.1의 연결 6개였다면 손실은 그중 하나에만 영향을 줬을 것입니다. 즉 손실률이 높은 모바일 네트워크에서는 HTTP/2가 더 느릴 수 있습니다. TCP 계층의 선두 차단이 남아 있기 때문입니다.

HTTP/3가 QUIC 위로 옮겨 간 이유가 정확히 이것입니다. QUIC은 UDP 위에서 스트림별로 독립적인 손실 복구를 합니다. 한 스트림의 패킷이 사라져도 다른 스트림은 계속 진행합니다. 덤으로 TLS가 전송 계층에 통합되어 첫 연결이 1 RTT, 재접속은 0 RTT가 가능합니다. 그리고 연결 ID로 연결을 식별하므로 와이파이에서 LTE로 바뀌어도 연결이 유지됩니다.

실무 관점의 결론은 이렇습니다. 사용자와 엣지 사이는 HTTP/2나 HTTP/3가 유리합니다. 반면 데이터센터 내부의 프록시와 백엔드 사이는 손실률이 거의 0이므로 HTTP/1.1 keepalive로도 충분하고, 오히려 연결 수를 조절하기 쉬워 운영이 단순합니다. 실제로 많은 구성이 엣지는 HTTP/2, 백엔드는 HTTP/1.1 keepalive입니다.

프로토콜은 이렇게 확인합니다.

curl -sI --http2 https://api.example.com/v1/health -o /dev/null -w '%{http_version}\n'
curl -sI --http3 https://api.example.com/v1/health -o /dev/null -w '%{http_version}\n'
2
3

타임아웃이 어긋나면 생기는 경쟁 조건

이제 핵심입니다. 요청 하나가 지나가는 경로에는 유휴 타임아웃이 최소 세 개 있습니다.

[클라이언트 풀]  ---- 연결 A ----  [로드밸런서/프록시]  ---- 연결 B ----  [백엔드]
   idle timeout                        idle timeout                     keepalive_timeout

연결 B에서 벌어지는 일을 시간 순으로 보겠습니다. 백엔드의 keepalive 타임아웃이 5초, 로드밸런서의 유휴 타임아웃이 60초라고 하겠습니다.

  1. 요청이 처리되고 연결 B가 유휴 상태가 됩니다.
  2. 5초가 지나 백엔드가 FIN을 보냅니다.
  3. 그 FIN이 로드밸런서에 도착하기까지 왕복 시간의 절반이 걸립니다.
  4. 하필 그 사이에 새 요청이 들어와, 로드밸런서가 아직 유휴 목록에 있는 연결 B에 요청을 씁니다.
  5. 요청이 백엔드에 도착합니다. 백엔드는 이미 닫은 소켓이므로 RST로 답합니다.
  6. 로드밸런서는 백엔드가 응답 없이 연결을 끊었다고 판단하고 502를 냅니다.

백엔드 애플리케이션 로그에 아무것도 남지 않는 이유가 여기 있습니다. 요청은 애플리케이션 코드에 도달한 적이 없습니다.

발생 확률은 낮지만 0이 아닙니다. 그리고 초당 수천 요청이면 낮은 확률도 하루 수백 건이 됩니다. 트래픽이 늘 때만 눈에 띄는 이유입니다.

원칙은 하나입니다. 커넥션을 닫는 결정은 언제나 요청을 보내는 쪽이 내려야 합니다. 요청을 보내는 쪽이 닫으면 경쟁이 없습니다. 자기가 안 쓰기로 결정한 연결이니까요. 반대로 받는 쪽이 닫으면 항상 경쟁이 생깁니다.

따라서 타임아웃은 바깥에서 안쪽으로 갈수록 길어져야 합니다. 클라이언트 풀의 유휴 타임아웃이 가장 짧고, 로드밸런서가 그보다 길고, 백엔드가 가장 길어야 합니다.

AWS ALB의 기본 유휴 타임아웃이 60초이므로 백엔드는 그보다 넉넉히 길게 잡습니다.

# nginx가 백엔드일 때 — ALB 60초보다 길게
keepalive_timeout 75s;
keepalive_requests 10000;
# Node.js 백엔드
# server.keepAliveTimeout = 75000;
# server.headersTimeout   = 80000;   # keepAliveTimeout보다 커야 한다

# Go 백엔드
# srv := &http.Server{ IdleTimeout: 75 * time.Second }

Node.js의 기본 keepAliveTimeout이 5초라는 사실은 특히 자주 사고를 냅니다. ALB나 nginx 뒤에 Node 서버를 그대로 올리면 위의 시나리오가 그대로 재현됩니다. headersTimeoutkeepAliveTimeout보다 크게 두는 것도 함께 기억해야 합니다.

nginx가 프록시 쪽일 때는 업스트림 keepalive를 켜는 설정을 빠뜨리기 쉽습니다. 세 줄이 모두 있어야 실제로 재사용됩니다.

upstream backend {
    server 10.0.3.11:8080;
    keepalive 64;                 # 워커당 유지할 유휴 커넥션 수
    keepalive_timeout 60s;
    keepalive_requests 10000;
}

server {
    location / {
        proxy_pass http://backend;
        proxy_http_version 1.1;   # 없으면 1.0으로 나가 keepalive가 무효
        proxy_set_header Connection "";  # 기본 close 헤더를 제거해야 한다
    }
}

proxy_http_version 1.1과 빈 Connection 헤더 중 하나만 빠져도 업스트림 연결이 매 요청 새로 열립니다. 설정은 있는데 효과가 없다는 신고의 대부분이 이 두 줄입니다. 확인은 백엔드에서 셉니다.

# 백엔드에서 프록시로부터 온 ESTABLISHED 연결 수를 센다
ss -tan state established '( sport = :8080 )' | wc -l
watch -n1 "ss -tan state established '( sport = :8080 )' | wc -l"

이 값이 요청량에 비례해 계속 오르내리면 재사용이 안 되는 것이고, 안정된 수준에서 유지되면 재사용이 되는 것입니다.

경쟁 조건을 확률적으로 줄이는 것과 별개로, 클라이언트 쪽 재시도도 함께 갖춰야 합니다. Go의 net/http는 재사용된 연결에서 응답 바이트를 하나도 받기 전에 실패하면 멱등 요청을 자동으로 한 번 더 시도합니다. 자바의 아파치 HttpClient는 validateAfterInactivity로 빌려 주기 전에 연결 상태를 검사합니다. 이런 안전장치가 없는 클라이언트라면 GET과 PUT 같은 멱등 요청에 한해 명시적 재시도를 넣는 편이 좋습니다.

curl -w로 구간을 나눠 병목을 특정하기

지연을 보고할 때 "느리다"는 표현은 쓸모가 없습니다. 어느 구간이 느린지가 곧 담당 팀과 조치를 결정합니다. curl의 타이밍 변수가 이 일을 정확히 해 줍니다.

변수측정하는 구간커지면 의심할 것
time_namelookup시작부터 DNS 해석 완료까지리졸버 지연, search 도메인 과다, 캐시 미적용
time_connectDNS 완료부터 TCP 핸드셰이크 완료까지물리적 거리, 경로 혼잡, SYN 재전송
time_appconnectTCP 완료부터 TLS 핸드셰이크 완료까지TLS 1.2 사용, 인증서 체인 과다, OCSP 조회
time_pretransfer요청 전송 직전까지프록시 협상, 클라이언트 쪽 준비 지연
time_starttransfer요청 전송 후 첫 응답 바이트까지서버 처리 시간, 백엔드 큐잉, DB 지연
time_total전체본문 전송량, 대역폭, 수신 측 처리 속도

읽는 방법은 뺄셈입니다.

curl -s -o /dev/null -w "@/tmp/curl-format.txt" https://api.example.com/v1/orders
    time_namelookup:  0.004312s
       time_connect:  0.031104s
    time_appconnect:  0.098742s
   time_pretransfer:  0.098901s
 time_starttransfer:  0.341285s
         time_total:  0.343990s
  • DNS: 4.3밀리초입니다. 정상입니다.
  • TCP 핸드셰이크: 31.1에서 4.3을 빼면 26.8밀리초, 즉 RTT가 약 27밀리초입니다.
  • TLS: 98.7에서 31.1을 빼면 67.6밀리초입니다. RTT의 약 2.5배이므로 TLS 1.2일 가능성이 큽니다. TLS 1.3이면 27밀리초 근처여야 합니다.
  • 서버 처리: 341.3에서 98.9를 빼면 242.4밀리초입니다. 전체의 70퍼센트입니다.
  • 본문 전송: 344.0에서 341.3을 빼면 2.7밀리초입니다.

결론은 명확합니다. 이 요청의 병목은 네트워크가 아니라 서버 처리 시간입니다. 다만 TLS 1.3으로 올리면 40밀리초를 더 줄일 수 있고, 커넥션을 재사용하면 95밀리초를 통째로 없앨 수 있습니다.

여러 번 측정해 분포를 보려면 이렇게 씁니다.

for i in $(seq 1 20); do
  curl -s -o /dev/null -w '%{time_connect} %{time_appconnect} %{time_starttransfer} %{time_total}\n' \
    https://api.example.com/v1/orders
done | sort -k4 -n | tail -5
0.028112 0.094221 0.298104 0.301002
0.029004 0.096118 0.312880 0.315442
0.031104 0.098742 0.341285 0.343990
0.030221 0.097009 0.512033 0.515118
0.029880 0.095442 1.204118 1.207002

앞 두 열은 안정적인데 세 번째 열만 튄다면 네트워크가 아니라 서버 쪽 꼬리 지연입니다. 반대로 time_connect가 튄다면 그때가 네트워크를 볼 차례입니다.

커넥션 풀 크기 산정

풀 크기는 감으로 정하는 값이 아닙니다. 리틀의 법칙으로 계산합니다.

동시에 떠 있어야 하는 요청 수는 처리량과 응답 시간의 곱입니다.

동시 요청 수 = 초당 요청 수 × 평균 응답 시간

예) 인스턴스 하나가 초당 500 요청, 평균 응답 80밀리초
    500 × 0.08 = 40

    꼬리 지연을 감안해 1.5배 여유를 둔다
    권장 풀 크기 = 60

여기에 두 가지 상한을 함께 봐야 합니다.

첫째, 전체 연결 수입니다. 애플리케이션 인스턴스가 40대이고 각 풀이 60이면 백엔드는 최대 2400개의 연결을 받게 됩니다. 백엔드의 파일 디스크립터 한도와 최대 연결 설정이 이를 감당해야 합니다. 데이터베이스라면 특히 위험합니다. Postgres의 max_connections가 200인데 애플리케이션 풀 총합이 2400이면 배포 직후 접속 폭주로 장애가 납니다.

둘째, 유휴 커넥션 유지 비용입니다. 풀을 크게 잡으면 대부분이 유휴 상태로 남고, 그 연결들은 앞에서 본 경쟁 조건의 후보가 됩니다. 필요 이상으로 크게 잡을 이유가 없습니다.

주요 런타임의 기본값도 알아 둘 필요가 있습니다. Go의 http.DefaultTransportMaxIdleConns가 100이지만 호스트당 유휴 연결인 MaxIdleConnsPerHost는 2입니다. 이 값을 올리지 않으면 한 백엔드에 아무리 많이 요청해도 유휴 연결이 2개만 유지되고 나머지는 매번 새로 열립니다. 마이크로서비스에서 가장 자주 놓치는 기본값입니다.

# Go에서 반드시 조정할 값
# t := http.DefaultTransport.(*http.Transport).Clone()
# t.MaxIdleConns        = 200
# t.MaxIdleConnsPerHost = 100
# t.IdleConnTimeout     = 90 * time.Second

실제 재사용률은 이렇게 확인합니다.

# 클라이언트 호스트에서 특정 백엔드로 가는 연결 상태 분포
ss -tan dst 10.0.3.11 | awk 'NR>1 {print $1}' | sort | uniq -c | sort -rn
     58 ESTAB
      3 TIME-WAIT

ESTAB이 안정적으로 유지되고 TIME-WAIT이 적으면 재사용이 잘 되고 있는 것입니다. 반대 모양이면 매 요청 새 연결을 여는 중입니다.

TIME_WAIT이 문제가 되는 조건과 되지 않는 조건

ss -tan | grep TIME-WAIT | wc -l이 수만을 찍는 것을 보고 놀라는 경우가 많습니다. 대부분은 문제가 아닙니다. 언제 문제가 되는지를 정확히 갈라야 합니다.

TIME_WAIT은 연결을 먼저 닫은 쪽, 즉 액티브 클로즈를 한 쪽에만 생깁니다. 리눅스에서는 60초 고정이며 sysctl로 조정할 수 없습니다. 목적은 두 가지입니다. 지연 도착한 옛 세그먼트가 같은 4-튜플의 새 연결에 섞이는 것을 막고, 마지막 ACK가 유실됐을 때 재전송할 수 있게 하는 것입니다.

문제가 되지 않는 경우부터 보겠습니다.

서버가 응답 후 연결을 닫는 구성이라면 TIME_WAIT은 서버 쪽에 쌓입니다. 그런데 이 항목들은 로컬 포트가 전부 443으로 같고 원격 주소와 포트만 다릅니다. 4-튜플이 겹치지 않으므로 포트 고갈이 일어나지 않습니다. 남는 비용은 메모리뿐이며 항목당 수백 바이트 수준입니다. 3만 개여도 수십 메가바이트입니다. 무시해도 됩니다.

문제가 되는 경우는 다릅니다. 아웃바운드 연결을 대량으로 여는 쪽입니다. 프록시, API 게이트웨이, 배치 작업이 여기 해당합니다. 이때는 목적지 IP와 포트가 고정되어 있으므로, 구분되는 것은 로컬 포트뿐입니다.

sysctl net.ipv4.ip_local_port_range
net.ipv4.ip_local_port_range = 32768	60999

사용 가능한 포트가 28232개이고 각 포트가 60초간 묶입니다. 즉 하나의 목적지에 대해 초당 약 470개의 새 연결이 이론적 상한입니다. 그것을 넘으면 이런 오류가 납니다.

dial tcp 10.0.3.11:8080: connect: cannot assign requested address

이 EADDRNOTAVAIL이 포트 고갈의 진짜 신호입니다. TIME_WAIT 개수 자체가 아니라 이 오류가 나는지를 봐야 합니다.

# 목적지별로 TIME-WAIT을 세어 어디가 고갈되는지 본다
ss -tan state time-wait | awk 'NR>1 {print $4}' | cut -d: -f1 | sort | uniq -c | sort -rn | head
  26104 10.0.3.11
    412 10.0.3.12
     88 169.254.169.254

여기서 널리 퍼진 잘못된 처방을 정리해야 합니다.

net.ipv4.tcp_tw_recycle은 쓰면 안 됩니다. NAT 뒤의 여러 클라이언트가 서로 다른 타임스탬프를 보내면 연결이 무작위로 거부되는 심각한 문제가 있었고, 그래서 리눅스 4.12에서 아예 제거됐습니다. 아직도 이 값을 권하는 블로그가 많습니다.

net.ipv4.tcp_tw_reuse는 조건부로 유효합니다. 아웃바운드 연결에만 적용되고 타임스탬프 옵션이 양쪽에서 켜져 있어야 합니다. 서버 쪽에 쌓인 TIME_WAIT에는 아무 효과가 없습니다.

# 아웃바운드가 많은 호스트에서만 의미가 있다
sudo sysctl -w net.ipv4.tcp_tw_reuse=1
sudo sysctl -w net.ipv4.ip_local_port_range="10240 65535"

포트 범위를 넓히면 상한이 약 920으로 올라갑니다. 하지만 이것들은 전부 증상 완화입니다.

진짜 해법은 커넥션을 재사용해서 새 연결 자체를 줄이는 것입니다. 초당 470개의 새 연결이 필요한 이유가 무엇인지 먼저 물어야 합니다. keepalive가 꺼져 있거나, 풀이 없거나, 매 요청마다 클라이언트 객체를 새로 만들고 있을 가능성이 높습니다. 파이썬 requests에서 requests.get()을 직접 호출하는 코드가 대표적입니다. Session 객체를 만들어 재사용하면 이 문제가 통째로 사라집니다.

마치며 — 순서를 맞추고 재사용하십시오

세 문장으로 정리할 수 있습니다.

간헐적 502에 백엔드 로그가 비어 있다면 커넥션 재사용 경쟁을 의심하고, 타임아웃 순서를 확인하십시오. 요청을 보내는 쪽의 유휴 타임아웃이 가장 짧고 받는 쪽이 가장 길어야 합니다. 백엔드 keepalive를 로드밸런서 유휴 타임아웃보다 넉넉히 길게 잡는 것이 이 원칙의 구체적 적용입니다.

지연을 보고할 때는 반드시 구간을 나누십시오. curl의 namelookup, connect, appconnect, starttransfer 네 지점만 있으면 DNS 문제인지 거리 문제인지 TLS 설정 문제인지 서버 처리 문제인지가 즉시 갈립니다. 이 네 숫자 없이 하는 성능 논의는 대부분 추측입니다.

그리고 TIME_WAIT 개수에 놀라기 전에 cannot assign requested address가 실제로 나는지 확인하십시오. sysctl을 만지는 것보다 커넥션을 재사용하는 편이 언제나 낫습니다. 연결을 여는 비용은 원거리 통신에서 응답 시간의 절반을 넘기도 하며, 그 비용을 없애는 것이 대부분의 애플리케이션 최적화보다 효과가 큽니다.