HTTP Keep-Alive와 stale 커넥션 - idle timeout mismatch가 만드는 장애
외부 API를 호출하는 백엔드 서비스에서 간헐적으로 발생한 발송 실패를 추적하다 만난 stale connection 문제와, 클라이언트/서버 idle timeout mismatch를 해소한 과정을 정리한다.
- 한 줄 요약
- 1. 배경: 어떤 장애였나
- 2. HTTP Keep-Alive = TCP 커넥션 재사용
- 3. Idle Timeout은 양쪽 끝에 각각 존재한다
- 4. 문제는 이 둘의 비대칭(mismatch)이다
- 5. 왜 클라이언트는 “서버가 닫은 걸” 모르나 — half-open
- 6. HTTP 버전별로는 어떤가
- 7. 해결: 클라이언트가 먼저 버리게 만든다
- 8. 실측: 장애가 실제로 어떻게 찍혔나
- 9. 교훈
- 부록 1: 내 HTTP 클라이언트는 몇 버전을 쓰고 있나?
- 부록 2: 실패한 요청과 그 커넥션은 그 뒤 어떻게 되나?
한 줄 요약
서버는 유휴 커넥션을 일정 시간(예: 25분) 뒤에 닫는데, 클라이언트가 그 커넥션을 무한정 붙들고 재사용하면 이미 죽은 커넥션에 요청을 쓰게 된다. 그 결과 응답 없이 타임아웃(또는 connection reset)이 난다. 이것이 stale connection 문제이고, 해결은 “클라이언트가 서버보다 먼저 커넥션을 버리게” 만드는 것이다.
1. 배경: 어떤 장애였나
외부 API를 호출하는 백엔드 서비스에서, 특정 시점부터 요청이 간헐적으로 실패했다. 애플리케이션 로그에는 Connection reset by peer, prematurely closed before response, 그리고 ReadTimeoutException이 섞여 나왔다. 트래픽이 몰릴 때가 아니라 오히려 한동안 조용하다가 다시 요청할 때 실패가 잦았다는 점이 단서였다.
원인은 외부 API 도메인 교체 이후 드러난 HTTP Keep-Alive 커넥션의 stale 재사용이었다. 아래는 그 메커니즘을 처음부터 정리한 것이다.
2. HTTP Keep-Alive = TCP 커넥션 재사용
HTTP 요청 하나를 보낼 때마다 TCP 3-way handshake와 TLS handshake를 새로 하면 지연과 비용이 크다. 그래서 HTTP/1.1은 요청이 끝나도 TCP 커넥션을 닫지 않고 재사용한다(persistent connection). 클라이언트는 이렇게 유지되는 커넥션을 풀(pool) 에 담아두고, 다음 요청 때 꺼내 쓴다.
이때 커넥션이 열려 있지만 아무 요청도 오가지 않는 상태를 idle(유휴) 라고 한다.
3. Idle Timeout은 양쪽 끝에 각각 존재한다
핵심은 커넥션에는 클라이언트와 서버(그리고 중간의 LB/프록시) 라는 두 끝이 있고, 각 끝이 독립적으로 “이 유휴 커넥션을 얼마나 살려둘까”라는 타이머를 가진다는 것이다.
| 주체 | 이름 | 의미 |
|---|---|---|
| 서버 / LB | server idle timeout | “N분 동안 아무것도 안 오면 내가 닫는다” |
| 클라이언트 | client idle timeout (maxIdleTime) | “풀 커넥션이 M초 유휴면 내가 버린다” |
이 둘은 서로 협상하지 않는다. 각자 자기 타이머대로 움직인다.
4. 문제는 이 둘의 비대칭(mismatch)이다
[안전한 규칙] client idle timeout < server idle timeout
→ 서버가 닫기 전에 클라이언트가 먼저 커넥션을 버림
→ 죽은 커넥션을 재사용할 일이 없음
[장애 상황] client idle timeout > server idle timeout (또는 클라 타임아웃이 아예 없음)
→ 서버는 이미 닫았는데 클라이언트는 그 커넥션을 계속 붙들고 있음
→ 오랜 유휴 후 재사용 = 죽은 커넥션에 요청 → 응답 없음
많은 HTTP 클라이언트 라이브러리의 기본 커넥션 풀은 idle timeout이 없다(커넥션을 무한정 유지). 서버 쪽 idle timeout만 존재하면 위의 “장애 상황”이 그대로 성립한다. 그래서 외부 API 제공자들은 종종 “클라이언트 Keep-Alive Idle Timeout을 서버 값보다 작게 설정하라”고 공지한다. 규칙을 지키라는 뜻이다.
5. 왜 클라이언트는 “서버가 닫은 걸” 모르나 — half-open
TCP는 full-duplex라, 한쪽이 닫아도 다른 쪽이 즉시 알지 못한다. 재사용 시 실패가 두 가지 형태로 갈리는 이유다.
- 서버가 FIN을 정상적으로 보낸 경우: 클라이언트가 재사용하려 요청을 쓰면 서버가
RST로 응답한다 →Connection reset by peer(비교적 즉각적인 에러) - 중간 LB/NAT가 세션을 조용히 드롭한 경우 (FIN 없음): 클라이언트는 커넥션이 살아있다고 믿고 요청을 쓰지만, 반대편이 사라졌으므로 응답이 영영 오지 않는다 → 클라이언트의 read/response timeout에 걸려
ReadTimeoutException
즉 같은 stale 문제라도 상대(서버 vs 중간 장비)가 어떻게 커넥션을 정리했느냐에 따라 reset으로도, read timeout으로도 나타난다.
6. HTTP 버전별로는 어떤가
커넥션 재사용은 HTTP/1.1만의 개념이 아니다.
| 버전 | 커넥션 재사용 | 특징 |
|---|---|---|
| 1.0 | 기본 없음, opt-in | Connection: keep-alive 헤더로 명시적 유지 |
| 1.1 | 기본 사용 | persistent connection이 디폴트. TCP 커넥션 여러 개를 풀링 |
| 2 | 구조적 사용 | TCP(+TLS) 1개에 여러 스트림 multiplexing. PING 프레임으로 생존 확인 |
| 3 | 구조적 사용 | QUIC(UDP) 기반. idle timeout이 프로토콜에 내장 |
idle mismatch 문제는 persistent connection을 쓰는 모든 버전에 개념적으로 적용되지만, “커넥션 풀에서 일부가 stale이 되는” 양상은 여러 TCP 커넥션을 굴리는 HTTP/1.1의 전형이다. HTTP/2는 커넥션이 사실상 1개라 양상이 다르고, PING으로 생존을 능동 확인할 수단이 있어 상대적으로 덜 겪는다.
7. 해결: 클라이언트가 먼저 버리게 만든다
Reactor Netty(ConnectionProvider) 기준으로, 세 가지 설정을 함께 걸면 stale 재사용을 구조적으로 차단할 수 있다.
ConnectionProvider.builder("api")
.maxIdleTime(Duration.ofSeconds(3)) // 유휴 3초 넘으면 버림
.maxLifeTime(Duration.ofSeconds(30)) // 생성 후 30초 넘으면 (사용 중이어도) 버림
.maxConnections(50)
.evictInBackground(Duration.ofSeconds(3)) // 3초마다 백그라운드로 능동 청소
.build();
세 옵션의 역할이 각각 다르다.
maxIdleTime: “마지막 사용 후 놀고 있은 시간” 기준으로 버린다. → 서버 idle timeout보다 훨씬 짧게 잡아 죽은 커넥션을 재사용할 일을 없앤다.maxLifeTime: “생성 후 총 수명” 기준으로 버린다. 계속 쓰이고 있어도 늙은 커넥션은 정리한다. → LB가 오래된 커넥션을 재조정/드레인하는 상황 대비.evictInBackground:maxIdleTime만 있으면 “다음에 커넥션을 꺼낼 때”만 검사한다. 근데 꺼내지 않고 방치되는 동안 서버가 먼저 닫아버릴 수 있다. 백그라운드 스레드로 주기적으로 청소하면 죽기 전에 미리 버린다.
이 셋을 합치면 클라이언트가 붙든 커넥션은 항상 서버 idle timeout보다 훨씬 젊고 신선한 상태로 유지되어, mismatch 자체가 사라진다.
8. 실측: 장애가 실제로 어떻게 찍혔나
APM에서 실패 트레이스를 시간순으로 보면 메커니즘이 그대로 드러났다.
- 문제의 요청 직전, 약 49분간 요청이 전혀 없었다 → 커넥션이 49분+ 유휴 (서버 idle timeout 25분을 크게 초과)
T+0요청: 요청을 썼지만 응답 0바이트 → 정확히 5초 뒤 ReadTimeout (클라이언트 responseTimeout 5초에 컷)T+3초다음 요청: 같은 API 호출이 71ms만에 200 성공
“5초 통째로 무응답 → 직후 즉시 성공”은 stale 커넥션의 전형적 지문이다. 서버가 진짜 느렸다면 다음 요청도 느려야 하지만, 새 커넥션을 잡으니 정상 속도로 돌아왔다. 그리고 실패가 RST가 아니라 ReadTimeout이었다는 것은 중간 LB가 오래된 유휴 flow를 FIN 없이 조용히 드롭했음을 시사한다(5절의 두 번째 경우).
9. 교훈
- 커넥션 풀을 쓰는 HTTP 클라이언트라면 idle/lifetime 설정을 반드시 명시한다. 기본값(무한 유지)은 서버 idle timeout과 mismatch를 일으키는 지뢰다.
- 외부 API 제공자의 Keep-Alive/idle timeout 권고값을 확인하고, 클라이언트 값을 그보다 작게 잡는다.
- “트래픽이 몰릴 때가 아니라 뜸했다가 다시 보낼 때 실패한다”는 패턴이 보이면 stale connection을 의심한다.
- 실패가 reset과 read timeout 두 형태로 나타날 수 있음을 안다. 둘 다 같은 뿌리(stale)일 수 있다.
- 커넥션 수명(
maxLifeTime)과 유휴(maxIdleTime)는 다른 축이며, 능동 청소(evictInBackground)까지 있어야 방치된 커넥션이 죽기 전에 정리된다.
부록 1: 내 HTTP 클라이언트는 몇 버전을 쓰고 있나?
stale 대응 방식이 버전마다 다르므로(1.1은 “먼저 버리기”, 2/3는 “keepalive로 살려두고 감지”), 먼저 내 클라이언트가 실제로 어떤 프로토콜로 붙는지를 알아야 한다. 그런데 이걸 확인하는 방법이 은근히 헷갈린다.
프로토콜은 서버가 아니라 “클라이언트 설정”이 결정한다
HTTPS에서 프로토콜은 TLS 핸드셰이크의 ALPN에서 정해진다. 클라이언트가 광고하는 프로토콜 목록과 서버가 지원하는 것의 교집합으로 결정되는데, 클라이언트가 http/1.1만 광고하면 서버가 h2를 지원해도 커넥션은 1.1로 맺힌다. 즉 “서버가 h2 되니까 h2겠지”는 틀린 추론이다.
1) 라이브러리 기본값으로 확인 (가장 확정적)
대부분의 HTTP 클라이언트는 기본이 HTTP/1.1이고 HTTP/2는 opt-in이다.
- Reactor Netty(Spring WebFlux WebClient의 기본 커넥터): 기본 HTTP/1.1. H2를 쓰려면 protocol 옵션(HttpProtocol.H2 / H2C)을 명시해야 한다.
→ 따라서 코드에 protocol 지정이 없으면 1.1로 봐도 된다. 커넥션 생성부에서 프로토콜 지정 여부만 보면 논리적으로 확정된다.
2) 런타임에서 하드하게 확증
- Reactor Netty:
reactor.netty.http.client로거를 DEBUG로 켜거나 wiretap을 걸면 negotiated protocol(ALPN 결과)이 로그에 찍힌다. - 커넥션 파이프라인에 HTTP/2 프레임 핸들러가 있는지 확인해도 된다.
- 서버가 h2를 제공하는지만 볼 거라면 curl에 http2 옵션을 줘서 확인할 수 있다. 단, 이건 서버의 능력이지 내 커넥션이 실제로 h2로 맺혔다는 증거가 아니다.
3) 주의 — APM/트레이스엔 안 나올 수 있다
APM 스팬이 http.version(HTTP flavor)을 기록하지 않는 경우가 많다. host·method·status_code는 있어도 프로토콜 버전은 빠져 있곤 한다. 그러니 “트레이스 보니 1.1이더라”라고 단정하지 말고, 근거는 (1) 라이브러리 기본값 + 코드의 protocol 미지정, 필요하면 (2) 런타임 로그로 둔다.
요약: 프로토콜은 클라 설정이 정한다 → 코드에 H2 opt-in이 없으면 기본 1.1 → 확증이 필요하면 클라이언트 DEBUG/wiretap 로그로 ALPN 결과를 본다. APM만 믿지 말 것.
부록 2: 실패한 요청과 그 커넥션은 그 뒤 어떻게 되나?
stale 커넥션에 걸린 첫 요청(8절의 T+0)을 두고 자주 나오는 질문 두 개.
Q. 그 요청은 성공인가 실패인가 → 실패다
read timeout이 나면 클라이언트의 에러 처리 경로가 타서 그 요청은 실패로 처리된다. 게다가 중간 장비가 flow를 조용히 드롭한 경우라면 요청 바이트가 서버에 도달조차 못 했을 수 있다(진짜 미처리). 즉 “타임아웃 = 애매한 상태”가 아니라, 이 맥락에선 명확한 실패로 봐야 한다.
Q. 실패한 그 커넥션은 재사용되나 → 아니다, 폐기된다
Reactor Netty를 포함한 대부분의 커넥션 풀은 I/O 에러(read timeout·reset·premature close)가 난 커넥션을 풀에 반납하지 않고 닫아버린다. 그래서 바로 다음 요청은 새(또는 다른 정상) 커넥션으로 나가 정상 속도로 성공한다. 8절에서 T+0이 5초 타임아웃, 3초 뒤 요청이 71ms 성공이었던 이유가 이것이다.
Q. 그럼 “에러나면 버리니까 괜찮은 것 아닌가?” → 아니다
핵심은 폐기 시점이 사후(事後) 라는 것이다.
- 커넥션을 버리는 건 이미 그 요청이 실패한 다음이다. 즉 유휴 후 첫 요청은 그대로 1건 실패하고, 그 대가로 커넥션이 청소된다.
- 요청이 뜸할수록 “오래 유휴 → 첫 요청” 상황이 반복되고, 그때마다 실패가 1건씩 쌓인다. (장애 기간에 실패가 누적된 이유)
- 반면 idle eviction 설정(
maxIdleTime등)은 죽은 커넥션을 재사용하기 전에 미리 버린다. 즉 그 “첫 요청 실패”조차 발생하지 않게 하는 사전 예방이다.
정리: 풀의 “에러 시 커넥션 폐기”는 사후 수습이고, idle/lifetime 설정은 사전 예방이다. 유휴 후 첫 요청의 실패까지 0으로 만들려면 후자가 필요하다.