상황
Lynceus 매매 시스템이 09:00 KST 개장 직후 연결을 잃었다. 로그에 연속 실패가 찍혔다.
2026-07-08 09:00:03 WARNING kis_circuit_breaker_failure fail_count=1 threshold=5
2026-07-08 09:00:04 WARNING kis_circuit_breaker_failure fail_count=2 threshold=5
2026-07-08 09:00:07 WARNING kis_circuit_breaker_failure fail_count=5 threshold=5
2026-07-08 09:00:07 WARNING kis_circuit_breaker_opened fail_count=5 open_seconds=30
30초가 지났다. 그러나 회복이 오지 않았다.
2026-07-08 09:01:47 WARNING kis_circuit_breaker_failure fail_count=1
2026-07-08 09:01:47 WARNING kis_circuit_breaker_reopened reopen_count=1 open_seconds=30
HALF_OPEN 프로브가 실패해서 다시 OPEN으로 넘어갔다. 09:05가 지나서야 KIS API가 안정됐고, 그제야 첫 매수 주문을 넣을 수 있었다.
발견: KIS API 블랙홀 창
한국투자증권(KIS) API는 개장 직후 09:00~09:05 KST에 연결이 불안정한 구간이 있다. 이 구간을 프로젝트 내에서 블랙홀 창이라고 부른다. 특징이 있다.
- 연결 자체가 끊기거나, 요청이 응답 없이 타임아웃 난다
- 30초 안팎의 불안정 이후 자연 회복된다
- 매일 개장마다 반복된다 — 간헐적이 아니라 예측 가능한 패턴이다
예측 가능하다는 점이 중요하다. 간헐적 장애라면 알람만 울리면 그만이지만, 매일 개장마다 발생하는 구간이라면 시스템이 스스로 버텨야 한다.
문제: 2-상태 서킷 브레이커의 한계
Phase 132 이전 Lynceus는 단순한 2-상태 서킷 브레이커를 사용하고 있었다.
CLOSED ──(n번 실패)──> OPEN ──(대기시간 경과)──> CLOSED
동작은 단순하다. N번 실패하면 OPEN, 일정 시간이 지나면 자동으로 CLOSED로 복귀. 그런데 KIS 블랙홀 창에서 이 구조는 문제가 생긴다.
OPEN 후 30초 대기가 끝나면 서킷이 CLOSED로 돌아온다. 그 시점에 요청이 다시 KIS API로 쏟아진다. KIS API가 아직 불안정하면 다시 N번 실패 → OPEN. 이 사이클이 반복된다.
더 나쁜 경우는 서킷이 닫혔지만 KIS API가 여전히 응답 없이 타임아웃을 내는 경우다. 요청마다 타임아웃 시간만큼 대기가 발생한다. 매매 스케줄러가 뒤로 밀리고, 09:00에 처리했어야 할 주문이 09:01, 09:02에 들어간다.
이것이 silent block이다. 에러도 없고, 알람도 없고, 그냥 느려진다. 가장 다루기 어려운 종류의 장애다.
해결: 3-상태 서킷 브레이커
CLOSED ──(임계 실패)──> OPEN ──(대기 경과)──> HALF_OPEN ──(프로브 성공)──> CLOSED
│
(프로브 실패)
│
└──> OPEN (재오픈, 대기 에스컬레이션)
HALF_OPEN 상태를 추가한다. OPEN 대기가 끝나면 바로 CLOSED가 아니라 HALF_OPEN으로 전환된다. HALF_OPEN에서는 딱 하나의 요청만 통과시킨다. 이것이 **프로브(probe)**다.
- 프로브 성공 → CLOSED. 회복 완료
- 프로브 실패 → OPEN으로 다시 전환. 대기 시간 에스컬레이션
class KISCircuitState(str, Enum):
CLOSED = "CLOSED"
OPEN = "OPEN"
HALF_OPEN = "HALF_OPEN"
핵심은 before_request()다. 요청이 들어올 때마다 호출해서 현재 상태에 따라 차단하거나 통과시킨다.
async def before_request(self) -> None:
async with self._lock:
if self._state == KISCircuitState.CLOSED:
return # 정상 상태, 그냥 통과
if self._state == KISCircuitState.HALF_OPEN:
if not self._probe_in_flight:
self._issue_probe_locked() # 프로브 토큰 발급, 이 요청이 프로브
return
# 이미 프로브 중 → 나머지 요청은 모두 차단
self._raise_open_error()
# OPEN 상태: 대기 시간이 지났으면 HALF_OPEN으로 전환
elapsed = time.monotonic() - self._opened_at
if elapsed < self.open_seconds:
self._raise_open_error(remaining_seconds=self.open_seconds - elapsed)
self._state = KISCircuitState.HALF_OPEN
self._issue_probe_locked()
HALF_OPEN에서 프로브를 하나만 통과시키는 것이 핵심이다. _probe_in_flight 플래그로 동시 요청이 여럿 들어와도 프로브는 하나만 발급된다. 나머지는 차단.
open_seconds_schedule: 에스컬레이션 대기
KIS 블랙홀 창에서 HALF_OPEN 프로브가 실패하면 OPEN으로 재오픈된다. 이때 대기 시간을 어떻게 할 것인가.
매번 같은 30초를 대기하면 블랙홀 창이 2분이라면 4번을 재시도해야 한다. 재시도마다 KIS API에 부하를 준다. 그리고 처음부터 5분 대기를 설정하면 단순한 일시적 장애에서 5분이나 차단된다.
open_seconds_schedule로 해결한다.
DEFAULT_OPEN_SECONDS_SCHEDULE = [30.0, 60.0, 120.0, 300.0]
처음 OPEN: 30초 대기. 프로브 실패로 재오픈: 60초. 한 번 더: 120초. 세 번 더: 300초(5분). 스케줄 인덱스가 증가할 때마다 대기 시간이 늘어난다. 스케줄 끝에 도달하면 마지막 값(300초)이 계속 적용된다.
def _open_circuit(self, *, is_reopen: bool) -> None:
if is_reopen:
self._schedule_index = min(
self._schedule_index + 1,
len(self._open_seconds_schedule) - 1,
)
self._reopen_count += 1
# ...
일시적 장애라면 첫 프로브(30초 후)에서 회복된다. 블랙홀 창처럼 예측 가능한 지속 장애라면 대기 시간이 에스컬레이션되어 프로브 빈도가 줄어든다. API 서버 입장에서도 회복 전에 쏟아지는 프로브 트래픽을 줄일 수 있다.
에피소드: 반복 실패를 하나의 사건으로 묶기
블랙홀 창에서 OPEN → HALF_OPEN → OPEN을 여러 번 반복하면 로그에 독립 이벤트처럼 찍힌다. 몇 번 재오픈했는지, 총 얼마나 차단됐는지 나중에 파악하기 어렵다.
**에피소드(episode)**로 이것을 묶는다. 첫 OPEN 시점에 에피소드가 시작되고, 회복(CLOSED 복귀 후 연속 성공 3회) 또는 마지막 실패로부터 300초가 경과하면 에피소드가 종료된다.
EPISODE_RESET_SECONDS = 300.0
EPISODE_RESET_SUCCESS_COUNT = 3
에피소드 종료 시 요약 로그가 찍힌다.
2026-07-08 09:05:12 INFO kis_circuit_episode_summary
total_open_seconds=312.5
reopen_count=2
probe_count=3
episode_started_at=2026-07-08T09:00:07+09:00
“09:00:07에 시작된 에피소드, 총 312초 차단, 2회 재오픈, 프로브 3회"를 한 줄로 파악할 수 있다.
프로브 타임아웃: 프로브가 응답 없이 멈추는 경우
HALF_OPEN에서 프로브가 KIS API에 요청을 보냈는데, 응답이 아예 오지 않는 경우가 있다. 프로브가 _probe_in_flight = True인 채로 멈춰있다. 이 상태에서 다른 요청들은 HALF_OPEN + _probe_in_flight = True이므로 영원히 차단된다.
프로브 타임아웃으로 해결한다.
if self._probe_started_at is not None:
elapsed = time.monotonic() - self._probe_started_at
if elapsed >= self._probe_timeout_seconds:
# 프로브가 타임아웃. 새 프로브 발급
self._issue_probe_locked()
return
프로브가 probe_timeout_seconds(기본 60초) 이상 응답이 없으면 만료된 것으로 보고 새 프로브를 발급한다. 프로브가 무한정 점유되어 회복 자체가 불가능해지는 상황을 막는다.
한편, 프로브 요청이 실패 처리 없이 취소되거나 연결이 끊겨 on_success()도 on_failure()도 호출되지 않는 경우를 위해 abandon_probe()도 제공한다.
async def abandon_probe(self) -> None:
"""미완료 HALF_OPEN probe 점유를 해제한다."""
async with self._lock:
if self._state != KISCircuitState.HALF_OPEN:
return
self._probe_in_flight = False
self._probe_started_at = None
회귀 가드 테스트
서킷 브레이커는 시간에 의존하는 상태 기계라서 테스트가 까다롭다. time.monotonic()을 목으로 교체해 단위 테스트가 가능하다.
@pytest.mark.asyncio
async def test_half_open_probe_succeeds_then_closed(monkeypatch):
"""HALF_OPEN 프로브 성공 시 CLOSED로 전환된다."""
cb = KISApiCircuitBreaker(fail_threshold=2, open_seconds_schedule=[0.01])
await cb.on_failure()
await cb.on_failure()
assert cb.state == KISCircuitState.OPEN
await asyncio.sleep(0.02) # open_seconds 경과
# before_request()가 HALF_OPEN으로 전환 + 프로브 발급
await cb.before_request()
assert cb.state == KISCircuitState.HALF_OPEN
await cb.on_success()
assert cb.state == KISCircuitState.CLOSED
@pytest.mark.asyncio
async def test_half_open_probe_fail_escalates_open_seconds(monkeypatch):
"""HALF_OPEN 프로브 실패 시 open_seconds가 에스컬레이션된다."""
cb = KISApiCircuitBreaker(
fail_threshold=2,
open_seconds_schedule=[0.01, 0.05, 0.2],
)
await cb.on_failure()
await cb.on_failure()
assert cb.open_seconds == pytest.approx(0.01)
await asyncio.sleep(0.02)
await cb.before_request() # HALF_OPEN으로 전환
await cb.on_failure() # 프로브 실패 → 재오픈
assert cb.state == KISCircuitState.OPEN
assert cb.open_seconds == pytest.approx(0.05) # 에스컬레이션됨
assert cb.reopen_count == 1
에스컬레이션 테스트에서 open_seconds가 0.01 → 0.05로 증가했는지 단언한다. 스케줄 인덱스가 재오픈마다 제대로 증가하는지 확인하는 핵심 케이스다.
일반화: 예측 가능한 불안정 구간이 있는 외부 API
KIS API 블랙홀 창은 외부 API가 특정 시간대에 예측 가능하게 불안정한 케이스의 대표 사례다. 유사한 상황:
- 거래소 API의 청산/정산 시간대
- 야간 배치 후 첫 기동 시 DB 컨넥션 풀 워밍
- 외부 SaaS 의존 서비스의 정기 점검 시간대
공통 패턴을 정리하면:
1. 2-상태로는 부족하다
OPEN → 바로 CLOSED는 “대기가 끝나면 모든 요청을 다시 보낸다"는 의미다. 불안정 구간이 끝났는지 확인 없이 쏟아지는 요청은 API 서버에 부하를 주고, 또 다른 실패를 유발한다.
2. 프로브 하나로 상태를 탐지한다
HALF_OPEN에서 딱 하나의 요청만 통과시킨다. 이 프로브가 성공하면 회복이 확인된 것이다. 실패하면 OPEN으로 돌아간다. 나머지 요청은 프로브 결과를 기다리는 것이 아니라, 즉시 차단 에러를 받는다.
3. 대기 시간은 에스컬레이션이 기본이다
재오픈이 반복될수록 대기 시간을 늘린다. 일시적 장애는 첫 프로브에서 회복되고, 지속 장애는 점점 적게 재시도한다. 고정 대기는 두 케이스 모두에서 최적이 아니다.
4. 프로브 점유 상태가 영원히 유지되지 않도록 한다
프로브가 응답 없이 멈추면 회복 자체가 불가능해진다. 프로브 타임아웃을 두거나, 명시적 abandon_probe()를 제공해 점유 해제를 강제해야 한다.
5. 반복 장애는 에피소드로 묶어 관찰한다
OPEN/HALF_OPEN/OPEN을 독립 이벤트로 보면 상황 파악이 어렵다. 첫 실패부터 회복까지를 하나의 에피소드로 묶어 총 차단 시간, 재오픈 횟수, 프로브 횟수를 집계해야 사후 분석이 가능하다.
정리
| 항목 | 내용 |
|---|---|
| 문제 구간 | KIS API 개장 직후 09:00~09:05 KST |
| 2-상태 한계 | OPEN 후 바로 CLOSED → 불안정 구간에 요청 쏟아짐, silent block |
| 3-상태 해결 | HALF_OPEN 프로브 기반 회복 확인 후 CLOSED 전환 |
| 대기 에스컬레이션 | [30, 60, 120, 300]초 스케줄 — 재오픈마다 인덱스 증가 |
| 프로브 타임아웃 | 60초 미응답 시 새 프로브 발급, 영원한 점유 방지 |
| 에피소드 추적 | 첫 실패~회복을 묶어 요약 로그 → 사후 분석 가능 |
예측 가능한 불안정 구간은 “기다리면 지나간다"는 것을 이미 알고 있다. 그 구간을 조용히 버티고, 지나간 시점을 정확히 탐지해서 회복하는 것이 목표다. 3-상태 서킷 브레이커와 프로브는 그 목표에 맞는 도구다.