사고 개요
Lynceus 시스템의 morning_batch는 매일 08:20 KST에 실행된다. 분야별 대장주를 선정하고, 신호를 생성하고, 당일 매매 계획을 세우는 게 이 배치의 역할이다.
2026년 6월, 어느 날 매매 결과를 검토하다가 이상한 패턴을 발견했다. 매매 신호가 없는 날이 늘어나고 있었다. 로그를 보면 morning_batch_completed — 배치는 정상 완료됐다. 그런데 leader_groups count=0. 대장주가 없으니 신호도 없고, 신호가 없으니 매매도 없다.
왜 대장주가 0개일까.
발견 과정
morning_batch 로그를 시간순으로 파봤다.
08:20:00 INFO morning_batch_started
08:20:01 INFO fetch_market_data_batch_started n_symbols=28
08:20:01 INFO market_data_fetched symbol=005930 ...
08:20:01 INFO market_data_fetched symbol=000660 ...
08:20:01 INFO market_data_fetched symbol=035720 ...
08:20:01 INFO market_data_fetched symbol=051910 ...
08:20:01 INFO market_data_fetched symbol=006400 ...
08:20:01 INFO market_data_fetched symbol=028260 ...
08:20:01 WARNING rate_limit_exceeded symbol=000270 retry_after=1.0
08:20:01 WARNING rate_limit_exceeded symbol=207940 retry_after=1.0
...
08:20:03 INFO fetch_market_data_batch_done success=6 failed=22
08:20:03 INFO leader_groups count=0
08:20:03 INFO morning_batch_completed
28개 종목 중 6개만 성공했다. 나머지 22개는 rate limit 오류로 실패했다. 배치는 멈추지 않고 success=6 failed=22를 받아들인 채 계속 진행했다.
원인
비동기 일괄 요청과 rate limit
fetch_market_data_batch()는 내부에서 asyncio.gather()로 전체 종목을 동시에 요청했다.
async def fetch_market_data_batch(symbols: list[str]) -> dict[str, MarketData]:
tasks = [_fetch_single(symbol) for symbol in symbols]
results = await asyncio.gather(*tasks, return_exceptions=True)
return {
sym: result
for sym, result in zip(symbols, results)
if not isinstance(result, Exception)
}
28개 요청이 거의 동시에 KIS paper API로 나간다. KIS paper API는 실전 계좌보다 rate limit이 엄격하다. 짧은 시간에 집중된 요청 다수가 rate limit을 초과해 거부됐고, gather()는 예외를 모아서 반환했다. 예외인 결과는 필터링되어 success=6만 남았다.
빈 결과가 전파되는 구조
문제는 fetch_market_data_batch()가 6개만 반환했을 때, 상위 로직이 이것을 자연스럽게 받아들인다는 점이다.
# morning_batch 내부
market_data = await fetch_market_data_batch(all_symbols)
# 그룹별 강도 계산
group_strengths = []
for group in sector_groups:
members_data = [
market_data[sym]
for sym in group.symbols
if sym in market_data
]
# 유효 멤버가 2개 미만이면 강도 계산 불가
if len(members_data) < MIN_VALID_MEMBERS:
continue
strength = _compute_group_strength(members_data)
group_strengths.append((group, strength))
각 분야 그룹은 보통 5~8개 종목으로 구성된다. rate limit 오류로 22개가 빠지면, 각 그룹에서 유효한 종목 수가 MIN_VALID_MEMBERS(기본값 2) 미만으로 떨어진다. 강도 계산을 건너뛴다. 전체 그룹 강도가 없으니 leader_groups가 빈 리스트로 반환된다.
배치는 멈추지 않는다. leader_groups count=0을 로그에 남기고 morning_batch_completed로 끝낸다.
왜 몰랐나
WARNING rate_limit_exceeded로그가 찍혔지만 08:20의 자동 배치라 실시간 모니터링 없음success=6 failed=22가 로그에 있었지만morning_batch_completed가 정상 완료로 찍혀 알림 미발생- 대장주 0개가 매매 결과에 반영되려면 하루가 지나야 해서 즉각적인 신호가 없음
MIN_VALID_MEMBERS미달이 조용한 skip이지 에러가 아니었음
분석: 왜 아침 배치가 실패하는가
KIS API rate limit 문제는 시간 집중과 비동기 동시성의 조합에서 발생한다.
아침 08:20은 특히 취약하다. 아침 배치만 rate limit 문제를 겪는 건 아니지만, 아침은 조건이 가장 나쁘다:
- 다른 배치 작업과 시간대 겹침: 08:00 이후 여러 자동화 작업이 한꺼번에 API를 호출
- KIS paper API의 엄격한 rate limit: 실전 계좌 대비 허용 요청 수가 적음
- 전체 종목 동시 요청: 28개를
asyncio.gather()로 동시 발사하면 순간 부하가 집중됨
시가총액은 사실 하루 단위로 크게 변하지 않는다. 전날 밤 22:00에 수집해도 당일 아침 기준으로 충분히 유효하다. 그런데 아침마다 실시간으로 조회하는 구조는 이 특성을 활용하지 못하고 있었다.
해결: 야간 배치로 분리
nightly_market_data_job 신설 (22:00 KST)
@scheduler.scheduled_job(
CronTrigger(hour=22, minute=0, timezone=ZoneInfo("Asia/Seoul")),
id="nightly_market_data_job",
)
async def nightly_market_data_job() -> None:
logger.info("nightly_market_data_job_started")
all_symbols = _get_all_sector_symbols()
market_data = await fetch_market_data_batch(
all_symbols,
sequential=True, # 순차 요청
inter_request_delay=0.5, # 요청 간 0.5초 딜레이
)
_save_nightly_market_cache(market_data)
logger.info("nightly_market_data_job_completed", n_symbols=len(market_data))
22:00 KST는 장 외 시간대다. 다른 배치 작업과 경쟁이 없고, sequential 모드로 0.5초 간격을 두면 초당 2 req/s 수준이라 rate limit 여유가 충분하다.
fetch_market_data_batch에 sequential 옵션 추가
async def fetch_market_data_batch(
symbols: list[str],
sequential: bool = False,
inter_request_delay: float = 0.0,
) -> dict[str, MarketData]:
if sequential:
results = {}
for symbol in symbols:
try:
results[symbol] = await _fetch_single(symbol)
except Exception as e:
logger.warning("market_data_fetch_failed", symbol=symbol, error=str(e))
if inter_request_delay > 0:
await asyncio.sleep(inter_request_delay)
return results
else:
# 기존 비동기 일괄 방식
tasks = [_fetch_single(symbol) for symbol in symbols]
results = await asyncio.gather(*tasks, return_exceptions=True)
return {
sym: result
for sym, result in zip(symbols, results)
if not isinstance(result, Exception)
}
morning_batch에 캐시 폴백 추가
아침 배치에서 직접 조회가 실패하거나 빈 결과를 반환하면 야간 캐시를 사용한다.
market_data = await fetch_market_data_batch(all_symbols)
if len(market_data) < MIN_VALID_FOR_CACHE_FALLBACK:
logger.warning(
"morning_batch_market_data_insufficient",
n_fetched=len(market_data),
n_expected=len(all_symbols),
)
cached = _load_nightly_market_cache()
if cached:
logger.info("morning_batch_using_nightly_cache")
market_data = cached
정상 조회 성공 시에는 그 결과를 사용한다. 실패 시에는 전날 밤 야간 캐시로 폴백한다. 야간 배치가 정상 동작하는 한 아침 배치는 항상 유효한 데이터를 가지게 된다.
검증
pytest tests/ -v -k "nightly_market_data or morning_batch"
tests/test_nightly_market_data_job.py::test_nightly_job_sequential_requests PASSED
tests/test_nightly_market_data_job.py::test_nightly_job_saves_cache PASSED
tests/test_morning_batch.py::test_morning_batch_uses_nightly_cache_on_empty_result PASSED
tests/test_morning_batch.py::test_morning_batch_does_not_use_cache_on_success PASSED
...
1777 passed
일반화: Rate Limit과 배치 작업 설계
이 사고에서 추출한 설계 원칙을 정리한다.
1. 동시성은 rate limit의 적이다
asyncio.gather()로 N개를 동시 요청하면 API 입장에서는 거의 동시에 N개의 요청이 도착한다. 단일 요청은 통과했을 rate limit이 동시 요청에서는 초과된다.
외부 API를 다수 종목이나 리소스에 대해 일괄 조회할 때는, 해당 API의 rate limit 특성을 먼저 확인하고 sequential 모드를 기본값으로 설정하는 편이 안전하다.
2. 빈 결과는 에러가 아니다 — 명시적으로 검사해야 한다
fetch_market_data_batch()가 빈 딕셔너리를 반환해도 코드는 계속 실행된다. Python은 빈 리스트나 딕셔너리를 조용히 처리한다. 상위 로직에서 if not market_data 또는 if len(market_data) < expected 같은 명시적인 검사를 해야 한다.
3. 자주 변하지 않는 데이터는 미리 수집한다
시가총액, 섹터 분류, 종목 메타데이터처럼 일 단위 변동이 적은 데이터는 바쁜 시간대에 실시간 조회하지 않아도 된다. 야간 배치로 미리 수집해두면 아침 배치의 API 부담을 크게 줄일 수 있다.
| 데이터 유형 | 수집 전략 |
|---|---|
| 시가총액, 섹터 분류 | 야간 배치 (전날 22:00) + 아침 폴백 |
| 당일 가격, 체결량 | 실시간 또는 아침 배치 직접 조회 |
| 종목 메타데이터 | 주간 배치 또는 수동 갱신 |
4. 배치 작업의 “정상 완료"를 의심한다
morning_batch_completed가 찍혀도 그 안에서 대장주가 0개일 수 있다. 배치 작업 완료 로그에는 핵심 결과 지표를 함께 기록해야 한다.
logger.info(
"morning_batch_completed",
leader_groups=len(leader_groups),
total_symbols_fetched=len(market_data),
total_symbols_expected=len(all_symbols),
)
# leader_groups가 0이면 WARNING으로 올린다
if not leader_groups:
logger.warning("morning_batch_no_leader_groups")
await notify_telegram("[경고] morning_batch leader_groups=0 — 매매 신호 없음")
정리
| 항목 | 내용 |
|---|---|
| 현상 | morning_batch 정상 완료, leader_groups=0, 매매 신호 없음 |
| 직접 원인 | KIS paper API rate limit 초과 — 28개 중 22개 거부 |
| 구조적 원인 | asyncio.gather() 비동기 일괄 요청 + 빈 결과 silent 처리 |
| 왜 몰랐나 | 배치 정상 완료 로그 + 실시간 모니터링 없음 + 결과 지표 미기록 |
| 해결 | 야간 배치(22:00 KST, sequential) + 아침 배치 캐시 폴백 |
| 예방 | 빈 결과 명시 검사, 핵심 지표 로그, 이상 시 Telegram 알림 |
배치가 완료됐다는 것과 결과가 유효하다는 것은 다른 이야기다. “정상 완료"는 에러가 없었다는 뜻이지, 데이터가 충분했다는 뜻이 아니다.