사고 개요
2026년 7월 7일, Lynceus 매매 시스템에서 KIS API 호출이 순차적으로 처리되지 않고 동시에 수십 개가 대기 상태에 빠지는 현상이 발생했다.
증상은 이랬다. 개장 직후 매수 시도가 평소보다 느려지기 시작했고, 일정 시간이 지나자 큐에 쌓인 요청들이 한꺼번에 타임아웃으로 실패했다. 매매는 0건. 로그에는 ConnectTimeout 에러가 줄줄이 찍혀 있었다.
원인을 추적하니 httpx 클라이언트 설정 한 줄이었다.
client = httpx.AsyncClient(timeout=60.0) # 문제의 설정
문제 구조
httpx timeout이 하나의 숫자일 때
httpx.AsyncClient(timeout=60.0)는 연결 타임아웃과 읽기 타임아웃을 모두 60초로 설정한다.
KIS API는 정상 상황에서 연결에 12초, 응답에 25초 걸린다. 그런데 개장 직후 09:0009:05 구간에는 연결 지연이 510초까지 늘어나는 경우가 있다.
연결 타임아웃이 60초라면 어떻게 되는가?
- 요청 A가 KIS 서버에 연결을 시도한다
- 연결이 늦어진다 (10초 경과)
- 그 사이 스케줄러가 요청 B, C, D를 생성한다
- A, B, C, D 모두 연결 대기 중
- 60초가 되면 전부
ConnectTimeout으로 실패한다
연결이 1초만 지연됐어도 60초짜리 창이 열리면서 그 안에 들어오는 모든 후속 요청을 흡수한다. 타임아웃이 길수록 적체 창이 커진다.
기존 서킷 브레이커가 막아주지 않는 이유
KIS API 블랙홀 창 대응으로 서킷 브레이커를 도입했지만, 서킷 브레이커는 실패를 감지해야 작동한다. ConnectTimeout이 60초 후에야 발생하니, 서킷이 열리기까지 60초가 걸린다.
그 60초 동안 요청은 계속 들어온다.
진단
# httpx.Timeout 분리 전 기본값 확인
import httpx
t = httpx.Timeout(60.0)
print(t)
# Timeout(connect=60.0, read=60.0, write=60.0, pool=60.0)
timeout=60.0이라는 단순한 숫자 하나가 4개의 타임아웃을 모두 60초로 설정한다는 사실을 간과했다.
실제 시스템에서 연결 타임아웃과 읽기 타임아웃은 성격이 전혀 다르다.
- 연결 타임아웃: 서버와 TCP 핸드셰이크를 맺는 시간. 짧게 유지해야 한다. 연결이 안 되면 빠르게 실패해야 후속 로직이 처리할 수 있다.
- 읽기 타임아웃: 서버가 응답을 보내는 시간. 요청에 따라 길게 잡을 수 있다.
수정
httpx.Timeout을 분리해서 설정했다.
import httpx
# 수정 전
client = httpx.AsyncClient(timeout=60.0)
# 수정 후
client = httpx.AsyncClient(
timeout=httpx.Timeout(
connect=5.0, # 연결: 빠르게 실패
read=30.0, # 읽기: KIS API 응답 여유 보장
write=10.0,
pool=5.0,
)
)
연결 타임아웃을 5초로 줄이니 연결 지연 시 빠르게 실패하고, 서킷 브레이커가 제때 작동한다. 큐 적체 창이 60초에서 5초로 줄었다.
검증
수정 후 개장 직후 구간에서의 동작을 확인했다.
- 연결 지연 발생 시 5초 내
ConnectTimeout발생 - 서킷 브레이커가 OPEN 상태로 전환, 후속 요청을 즉시 차단
- 연결 복구 후 HALF_OPEN → CLOSED로 자동 복구
- 큐 적체 없음
교훈
타임아웃을 길게 잡는 것이 안전하지 않다
“혹시 서버가 느릴 수 있으니 여유 있게"라는 생각으로 timeout을 크게 설정하는 경향이 있다. 하지만 연결 타임아웃이 길수록 장애 시 적체 창이 커진다.
특히 비동기 시스템에서는 하나의 느린 연결이 큐 전체를 막지 않지만, 연결 시도가 계속 생성될 경우 모든 요청이 동시에 타임아웃을 기다리는 상황이 된다.
httpx timeout 분리는 기본값이 되어야 한다
# 권장 패턴
httpx.Timeout(
connect=3.0, # 짧게 — 연결 불가 시 빠르게 실패
read=30.0, # 길게 — API 처리 시간 보장
write=10.0,
pool=5.0,
)
requests 라이브러리도 동일한 문제
requests.get(url, timeout=60) 역시 연결과 읽기 타임아웃을 동일하게 설정한다. timeout=(connect, read) 튜플 형태로 분리할 수 있다.
import requests
response = requests.get(url, timeout=(3.0, 30.0)) # (connect, read)