증상
2026년 6월 23일, 운영자가 “오늘 또 매매 불가"를 신고했다. 전날(06-22)에도 다른 이유로 매매 불가가 있었던 터라, 같은 원인이 재발한 것처럼 보였다.
/health는 200이었다. 표면적으로는 정상이었다.
GET /system/status를 인증된 요청으로 조회하니 직접 증거가 나왔다.
{
"tradable": false,
"tradable_reason": "kis_ws_disabled",
"kis_ws_disabled_reason": "reconnect_failed",
"kis_ws_reconnect_failures": 5
}
KIS 체결 WebSocket이 죽어 있었고, 5회 재연결을 모두 실패했으며, 이후 복구 없이 그 상태로 장이 끝났다.
새벽 5시에 무슨 일이 있었나
docker 로그에 새벽 05:00:47부터 05:01:46 사이 1분간의 기록이 남아 있었다.
05:00:47 kis_exec_ws_reconnecting error="keepalive ping timeout" failures=1
05:00:57 kis_exec_ws_reconnecting error="[Errno -3] Temporary failure in name resolution" failures=2
05:01:09 kis_exec_ws_reconnecting error="[Errno -3] Temporary failure in name resolution" failures=3
05:01:25 kis_exec_ws_reconnecting error="[Errno -3] Temporary failure in name resolution" failures=4
05:01:46 kis_exec_ws_disabled error="[Errno -3] Temporary failure in name resolution" reconnect_failures=5 max=5
흐름은 이렇다. keepalive ping timeout으로 WebSocket 연결이 끊겼다. 재연결을 시도했는데 이때 DNS가 응답하지 않았다 — Temporary failure in name resolution. openapi.koreainvestment.com의 IP를 얻지 못하니 연결 자체를 시작할 수 없었다. 이 실패가 4회 반복됐고, reconnect_failures >= 5에 도달한 순간 재연결 루프가 종료됐다.
DNS 장애는 새벽 한때였다. 05:02쯤 이미 복구된 것으로 추정된다. 그런데 WebSocket은 06-23 장 개시(09:00)까지도, 장 마감(15:30)까지도 살아나지 않았다.
왜 DNS가 복구된 후에도 WebSocket이 살아나지 않았나
재연결 루프가 종료되는 코드를 보면 이해가 된다.
# KISExecutionWebSocketClient._run_loop (수정 전)
async def _run_loop(self) -> None:
while self._running:
try:
await self._connect_and_receive()
except Exception as exc:
self._reconnect_failures += 1
if self._reconnect_failures >= self._max_reconnect_failures:
self._running = False # 루프 종료
self._disabled_reason = "reconnect_failed" # 영구 차단 플래그
logger.error("kis_exec_ws_disabled", ...)
return # 태스크 종료
await asyncio.sleep(self._backoff_seconds())
_running = False로 설정하고 return한다. 이 태스크는 다시는 실행되지 않는다.
게이팅 로직은 이 상태를 보고 tradable=false를 내린다.
# system.py
kis_ws_disabled = (status["kis_ws_disabled_reason"] is not None)
# disabled_reason이 None이 아닌 한 tradable=false
WebSocket을 다시 살리는 방법이 없었다. 태스크가 종료됐으니 아무것도 시도하지 않는다. disabled_reason이 None이 되려면 코드가 실행되어야 하는데, 코드를 실행할 태스크가 없다. 수동으로 docker restart를 하거나 엔진을 재시작하는 것 외에는 방법이 없는 설계였다.
DNS가 정상으로 돌아와도 WebSocket은 자동으로 살아나지 않는다.
“비활성화"는 자동 복구와 한 쌍이어야 한다
이 설계의 의도는 이해할 수 있다. WebSocket이 죽었을 때 tradable=false로 매매를 막는 것은 올바른 안전장치다. 죽은 WebSocket으로 시세를 받지 못하면서 매매를 계속하는 것보다 낫다.
문제는 “비활성화하고 끝"인 부분이다. 외부 의존성이 일시 장애로 죽었을 때는 자동으로 복구를 시도해야 한다. 자동 복구를 설계하지 않았다면 적어도 즉시 알림이 가야 한다. 이 사례에서는 복구 시도도 없었고, 알림도 없었다.
운영자가 직접 “오늘 매매가 됐나” 확인을 하지 않았다면 종일 매매 없이 지나갔을 것이다.
수정: 느린 backoff 무한 재시도
해결 방향은 “max_failures 이후 죽지 않는 것"이다. 빠른 재시도(1→2→4→8→16→32초)를 5회 소진하면 죽는 대신, 느린 재시도(15→30→60→120→240→480초, 이후 480초 고정)로 전환해 무한 재시도한다.
# KISExecutionWebSocketClient._run_loop (수정 후)
async def _run_loop(self) -> None:
fast_failures = 0
slow_retries = 0
while True: # 루프를 외부에서 종료하지 않는 한 계속
try:
await self._connect_and_receive()
# 성공 시 카운터 리셋
fast_failures = 0
slow_retries = 0
self._reconnect_failures = 0
self._disabled_reason = None # 게이팅 해제
except Exception as exc:
fast_failures += 1
self._reconnect_failures += 1
if fast_failures < self._max_fast_failures:
# 빠른 backoff 구간
delay = self._fast_backoff_seconds(fast_failures)
self._disabled_reason = "reconnect_retrying"
else:
# 느린 backoff 구간 (무한)
slow_retries += 1
delay = self._slow_backoff_seconds(slow_retries)
self._disabled_reason = "reconnect_retrying"
if slow_retries > 3:
logger.critical("kis_exec_ws_retry_critical",
slow_retries=slow_retries)
logger.warning("kis_exec_ws_reconnecting",
failures=self._reconnect_failures,
delay=delay)
await asyncio.sleep(delay)
변경 사항 몇 가지를 더 설명한다.
게이팅 보존: 재시도 중에도 disabled_reason="reconnect_retrying"(non-None)을 유지한다. WebSocket이 죽어 있는 동안 tradable=false를 유지하는 것이 맞다. WebSocket이 죽었는데 tradable=true가 되어 매매가 이루어지면 안 된다.
게이팅 해제: _connect_and_receive가 성공하면 disabled_reason=None을 설정한다. 기존 코드에는 이 부분이 없었다. 재연결에 성공해도 disabled_reason이 None이 되지 않아 tradable=false가 유지되는 버그였다.
approval_key 리셋: 실패 시 _approval_key=None으로 초기화한다. 장시간 재시도 중 KIS approval key가 만료되면, 만료된 키로 영원히 실패할 수 있다. 키를 None으로 만들어 다음 시도에서 재발급을 강제한다.
critical 알림: 느린 재시도가 3회를 초과하면 logger.critical을 발행한다. Telegram 알림으로 전달된다. 자동 복구 중이라도 운영자가 인지해야 한다.
실환경 검증
배포 당일 장 마감 후 KIS가 체결 WebSocket을 닫는 상황에서 전체 사이클을 확인했다.
5회 빠른 실패 → kis_exec_ws_slow_retrying (backoff_s=15, slow_retry_count=1)
→ 15초 후 재연결 성공 → disabled_reason=null, reconnect_failures=0
수정 전에는 이 시점이 kis_exec_ws_disabled 영구 차단 지점이었다. 수정 후에는 느린 재시도로 전환되고, 성공 시 tradable=true로 자동 복구됐다.
kis_ws_running이 True를 유지하는 것까지 확인했다. 기존에는 False로 떨어지고 살아나지 않았다.
회귀 가드 테스트
# tests/test_kis_websocket_lifecycle.py
@pytest.mark.asyncio
async def test_repeated_reconnect_failure_enters_slow_retry():
"""max_fast_failures 소진 후 느린 재시도로 전환되고 disabled가 되지 않는지 검증."""
client = KISExecutionWebSocketClient(max_fast_failures=5)
fail_count = 0
async def always_fail():
nonlocal fail_count
fail_count += 1
raise ConnectionError("DNS failure")
client._connect_and_receive = always_fail
task = asyncio.create_task(client._run_loop())
# 5회 실패 후에도 태스크가 살아 있어야 한다
await asyncio.sleep(0.5)
assert not task.done(), "5회 실패 후 태스크가 종료되었다 — 영구 차단 버그"
assert client._disabled_reason == "reconnect_retrying" # 차단이 아닌 재시도 중
task.cancel()
@pytest.mark.asyncio
async def test_reconnect_success_after_slow_retry_recovers_tradable_gate():
"""느린 재시도 성공 시 disabled_reason이 None이 되는지(게이팅 해제) 검증."""
client = KISExecutionWebSocketClient(max_fast_failures=2)
call_count = 0
async def fail_then_succeed():
nonlocal call_count
call_count += 1
if call_count <= 3:
raise ConnectionError("DNS failure")
# 4번째 호출부터 성공 (무한 루프 방지: 즉시 리턴)
return
client._connect_and_receive = fail_then_succeed
await asyncio.wait_for(client._run_loop(), timeout=5)
assert client._disabled_reason is None, "재연결 성공 후 disabled_reason이 남아 있다"
assert client._reconnect_failures == 0
진단 체크리스트
/health 200은 “매매 가능"을 의미하지 않는다. 매매 시스템에서 정상 판정은 항상 tradable 필드로 해야 한다.
# 매매 가능 여부 확인
curl -H "Authorization: Bearer $TOKEN" \
https://your-service/api/v1/system/status | jq '.tradable, .tradable_reason'
# WebSocket 상태 확인
curl ... | jq '.kis_ws_disabled_reason, .kis_ws_reconnect_failures'
tradable_reason이 "kis_ws_disabled"면 WebSocket이 죽은 것이다. reconnect_failures 값으로 몇 번 실패했는지 확인한다.
정리
| 항목 | 내용 |
|---|---|
| 트리거 | 새벽 05:00 DNS 순간 장애 (약 1분) |
| 직접 원인 | WebSocket 재연결 5회 실패 후 루프 종료 — 자동 복구 경로 없음 |
| 결과 | DNS 복구 후에도 tradable=false 유지, 종일 매매 0건 |
| 즉시 복구 | docker restart + 엔진 start API 수동 호출 |
| 근본 수정 | 5회 빠른 실패 → 느린 backoff 무한 재시도로 전환 |
| 교훈 | 외부 의존성 “비활성화"는 자동 복구 시도 + critical 알림과 항상 한 쌍이어야 한다 |