이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- idle timeout 불일치가 왜 간헐적인 reset, 502, WebSocket 끊김으로 나타나는가?
- client, proxy, LB, application의 timeout은 어떤 순서로 정렬해야 하는가?
- SSE, WebSocket, long polling 같은 장기 연결은 어떤 heartbeat와 관측이 필요한가?
개요
Idle timeout은 “요청이나 데이터가 오가지 않는 연결을 얼마나 유지할 것인가”를 정하는 값이다. 이 값은 client, CDN, Load Balancer, Reverse Proxy, Application Server, HTTP client, External API에 각각 존재한다.
문제는 각 계층이 같은 연결을 다르게 기억한다는 점이다. client는 연결이 살아 있다고 믿고 재사용했는데 중간 LB가 이미 idle timeout으로 닫았다면, 다음 요청에서 Connection reset by peer, Broken pipe, 간헐적 502가 발생할 수 있다. WebSocket이나 SSE처럼 오래 열어 두는 연결에서는 이 문제가 더 직접적으로 보인다.
왜 백엔드 개발자가 알아야 하는가
idle timeout 장애는 재현이 어렵다. 요청 직후에는 정상이고, 몇십 초 또는 몇 분 동안 쉬었다가 다시 요청할 때만 실패한다. 그래서 코드만 보면 예외 위치가 낮은 네트워크 라이브러리처럼 보이고, 인프라만 보면 연결 종료가 정책대로 일어난 것처럼 보인다.
- 결제 API 호출이 가끔 첫 재시도에서만 실패한다.
- WebSocket이 정확히 60초 또는 300초 근처에서 끊긴다.
- NGINX는 499/502를 남기고 app log에는 정상 종료가 없다.
- LB idle timeout 이후 client pool이 stale connection을 재사용한다.
- streaming 응답이 proxy buffering이나 read timeout 때문에 중간에 닫힌다.
백엔드 개발자는 “누가 먼저 연결을 닫아야 안전한가”를 설계해야 한다.
요청 흐름에서의 위치
idle timeout은 연결을 유지하는 모든 경계에 있다.
Browser / Mobile / Server HTTP Client
→ CDN / WAF
→ Load Balancer
→ Reverse Proxy
→ Application Server
→ Downstream API장애가 나는 위치는 inbound일 수도 있고 outbound일 수도 있다. 사용자 브라우저와 우리 API 사이에서 끊길 수도 있고, 우리 애플리케이션과 외부 API 사이에서 stale connection이 터질 수도 있다.
원리
안전한 timeout 설계의 핵심은 “더 바깥쪽이 기다리는 시간”과 “더 안쪽이 처리하거나 닫는 시간”의 관계를 맞추는 것이다.
| 구간 | 너무 짧을 때 | 너무 길 때 |
|---|---|---|
| client timeout | 정상 응답 전 client가 포기 | 사용자가 오래 기다림 |
| LB idle timeout | 장기 연결이 중간에서 끊김 | idle connection 자원 점유 |
| proxy read timeout | upstream 처리 중 proxy가 닫음 | 장애 감지가 늦어짐 |
| app keepalive timeout | client 재사용 전에 app이 닫음 | server socket 점유 |
| HTTP client max idle time | stale connection 재사용 가능 | 재연결 증가 |
| downstream timeout | 호출자가 기다리는 시간과 불일치 | cascade 지연 |
일반적으로 client의 idle connection 보관 시간은 중간 proxy/LB가 닫는 시간보다 짧게 잡는 편이 안전하다. 그래야 client가 이미 죽은 연결을 재사용할 가능성이 줄어든다. 장기 연결은 반대로 중간 계층의 timeout보다 짧은 주기의 heartbeat를 보내 연결이 idle로 보이지 않게 해야 한다.
증상별로 나누기
| 증상 | 자주 보는 원인 | 먼저 확인할 것 |
|---|---|---|
| 일정 시간 후 WebSocket 끊김 | LB/proxy idle timeout, heartbeat 없음 | 끊기는 주기, proxy read timeout, ping/pong |
| 첫 요청은 실패하고 재시도 성공 | stale pooled connection | client max idle time, LB idle timeout |
| NGINX 504 | upstream response가 proxy timeout보다 늦음 | upstream_response_time, app 처리 시간 |
| NGINX 499 | client가 먼저 연결 종료 | client timeout, 모바일 네트워크, browser abort |
| ALB 502/504 | target 연결 실패 또는 응답 timeout | target status, target response time, health |
| SSE 중간 끊김 | buffering, idle timeout, heartbeat 없음 | proxy_buffering, curl -N, heartbeat interval |
status code만으로 판단하지 말고 “끊기는 시간이 특정 timeout 값과 일치하는가”를 본다. 정확히 60초, 120초, 300초 근처에서 끊기면 설정값 냄새가 강하다.
실무에서 자주 만나는 문제
- client pool의 max idle time이 LB idle timeout보다 길다.
- client는 연결을 재사용하지만 LB는 이미 닫은 상태다.
- client idle time을 LB보다 짧게 하거나 stale connection validation을 켠다.
- proxy timeout이 app의 정상 처리 시간보다 짧다.
- app은 처리 중인데 proxy가 504를 반환한다.
- 긴 작업은 timeout을 늘리기보다 비동기 job, 202 Accepted, polling으로 바꾸는 편이 나을 수 있다.
- WebSocket/SSE에 heartbeat가 없다.
- 중간 장비는 데이터가 없는 연결을 idle로 본다.
- ping/pong 또는 comment heartbeat를 timeout보다 짧은 주기로 보낸다.
- 모바일 네트워크 전환을 서버 장애로 본다.
- Wi-Fi와 LTE 전환, 백그라운드 진입, OS 절전 정책은 장기 연결을 끊을 수 있다.
- client reconnect와 idempotency를 함께 설계한다.
디버깅 방법
먼저 timeout 표를 만든다.
Client request timeout:
Client connection max idle time:
CDN idle timeout:
LB idle timeout:
Proxy read/send timeout:
Application keep-alive timeout:
Application request timeout:
Downstream API timeout:
Heartbeat interval:그다음 실패 시간이 어떤 값과 일치하는지 본다.
# SSE처럼 응답이 끊기지 않고 흐르는지 본다.
curl -N -v https://example.com/api/events
# 일반 API의 구간별 시간 확인
curl -sS -o /dev/null \
-w "code=%{http_code}\nconnect=%{time_connect}\ntls=%{time_appconnect}\nfirst_byte=%{time_starttransfer}\ntotal=%{time_total}\n" \
https://example.com/api/slowNGINX access log에 아래 값이 있으면 원인 분리가 쉬워진다.
log_format timing
'$remote_addr "$request" status=$status '
'request_time=$request_time upstream_status=$upstream_status '
'upstream_connect_time=$upstream_connect_time '
'upstream_header_time=$upstream_header_time '
'upstream_response_time=$upstream_response_time';request_time은 긴데 upstream_response_time이 비어 있으면 upstream 연결 전 문제가 있을 수 있다. upstream_response_time이 proxy timeout과 비슷하게 끝나면 app 또는 downstream 처리 지연을 의심한다.
코드로 확인하기
WebSocket은 proxy가 upgrade 요청과 긴 read timeout을 지원해야 한다.
location /ws/ {
proxy_pass http://app_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 1h;
proxy_send_timeout 1h;
}SSE는 데이터가 없을 때도 heartbeat를 보내 중간 장비가 idle로 보지 않게 한다.
@GetMapping(path = "/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> events() {
Flux<ServerSentEvent<String>> heartbeat = Flux.interval(Duration.ofSeconds(25))
.map(tick -> ServerSentEvent.<String>builder()
.comment("heartbeat")
.build());
Flux<ServerSentEvent<String>> messages = eventService.stream()
.map(message -> ServerSentEvent.builder(message).event("message").build());
return Flux.merge(heartbeat, messages);
}heartbeat 주기는 LB/proxy idle timeout보다 짧아야 한다. 또한 heartbeat가 너무 잦으면 connection 수가 많은 서비스에서 불필요한 traffic과 CPU를 만든다.
주니어가 자주 하는 오해
- timeout은 하나만 있다고 생각한다.
- 요청 경로의 거의 모든 계층에 timeout이 있고, 서로 다른 실패를 만든다.
- WebSocket 연결이 끊기면 서버 코드 exception부터 찾는다.
- 중간 LB/proxy idle timeout이나 client 네트워크 전환이 더 흔한 원인일 수 있다.
- timeout을 길게 늘리면 장애가 줄어든다고 생각한다.
- 길어진 timeout은 thread, connection, memory를 오래 붙잡아 장애 감지를 늦출 수 있다.
- 504가 나오면 항상 app이 죽었다고 본다.
- app은 처리 중이었고 proxy가 기다리다 먼저 포기했을 수 있다.
시니어의 설계 판단 기준
- timeout은 “사용자 대기 시간”, “서버 자원 보호”, “downstream 보호” 사이의 trade-off다.
- client idle time은 중간 LB/proxy idle timeout보다 짧게 잡아 stale connection을 줄인다.
- 장기 연결은 heartbeat, reconnect, resume, idempotency를 함께 설계한다.
- 오래 걸리는 작업은 연결을 붙잡는 API보다 비동기 작업 모델이 더 안전한지 검토한다.
- timeout 값을 바꿀 때는 error rate만 아니라 connection 수, thread 수, p99 latency, retry count도 같이 본다.
인프라 협업 포인트
- LB idle timeout, client keepalive duration, target response timeout의 현재 값을 요청한다.
- CDN과 WAF가 streaming, WebSocket, HTTP/2를 어떻게 처리하는지 확인한다.
- NGINX/Envoy의
proxy_read_timeout,stream_idle_timeout,idle_timeout계열 설정을 앱 timeout과 맞춘다. - 장기 연결 수가 많다면 file descriptor, pod connection limit, node conntrack, NAT port까지 같이 본다.
- 인프라 팀에는 “정확히 60초 근처에서 SSE가 끊기며 app log에는 cancel이 보입니다. LB/proxy idle timeout과 buffering 설정 확인이 필요합니다”처럼 말한다.
실전 팁
- timeout 표는 운영 runbook에 남겨 둔다. 장애 중에 각 계층 기본값을 찾기 시작하면 이미 늦다.
- 장기 연결 endpoint는 별도 route와 timeout 정책을 둔다.
- 외부 API client는 LB idle timeout보다 짧은 max idle time을 잡고, 재시도는 idempotent 요청에만 제한한다.
- 모바일 앱은 reconnect storm을 만들 수 있으므로 jitter와 backoff를 반드시 둔다.
- 개인 프로젝트에서도 WebSocket/SSE를 쓰면 1시간 켜 두는 수동 테스트보다 idle timeout 경계값 테스트를 자동화한다.
위험 신호!
- timeout 값을 늘린 뒤 thread와 connection 사용량을 보지 않는다.
- WebSocket/SSE endpoint가 일반 REST endpoint와 같은 proxy timeout을 사용한다.
- 특정 초 단위로 끊기는데 설정값 목록이 없다.
Connection reset by peer를 모두 상대 서비스 버그로 처리한다.- client, LB, proxy, app 중 누가 먼저 닫아야 하는지 팀 안에서 합의되어 있지 않다.
확인 질문
확인 질문
- client max idle time이 LB idle timeout보다 길면 왜 위험한가?
- client는 연결이 살아 있다고 보고 재사용하지만 LB는 이미 닫았을 수 있어 stale connection reset이 발생할 수 있기 때문이다.
- WebSocket이나 SSE에서 heartbeat가 필요한 이유는 무엇인가?
- 중간 proxy나 LB가 데이터가 없는 연결을 idle로 판단해 닫지 않도록 주기적으로 activity를 만들기 위해서다.
- timeout을 길게 늘리는 것이 항상 좋은 해결책이 아닌 이유는 무엇인가?
- 느린 요청이 thread, connection, memory를 더 오래 점유하고 장애 감지와 완화를 늦출 수 있기 때문이다.