KIS API Throttling 트러블 슈팅

Simple·2026년 4월 28일

트러블슈팅

목록 보기
15/15

해외주식 분석 도입 시 KIS API 쓰로틀링 트러블슈팅

1. 문제 (Problem)

1-1. 배경

  • 기존: 국내주식(KRX) 거래량 Top 10만 분석 → 호출량 = 거래량순위 1회 + 일봉 10회 ≈ 11 req/cycle
  • 변경: 해외주식(NAS/NYS/AMS) 분석 도입 → KIS의 해외 거래량 순위 API는 거래소 단위 조회라 거래소별로 별도 호출 필요
  • 결과적으로 호출량이 다음과 같이 증가
    • 거래량 순위: 1회 → 1(국내) + 3(해외 거래소) = 4회
    • 일봉 조회: 10회 → 20회 (국내 10 + 해외 10)
    • 11 req/cycle → 24 req/cycle

1-2. 증상

분석 트리거(/api/analysis/run) 직후 kisApiExecutor(core 10 / max 50)에서 일봉 조회가 병렬로 동시에 터지면서 다음 현상이 재현됨.

  • KIS 측 응답 HTTP 500 / EGW00201 (초당 거래건수 초과)
  • 일부 종목은 일봉 데이터가 비어 있는 채로 LLM 단계로 넘어가 → LLMAnalysisResult 누락
  • 단발성 5xx/429 에러였음에도 불구하고 재시도 로직이 없어 그대로 실패로 확정
  • 초기에는 단순 서버 오류처럼 보였지만, 해외주식만 단독 실행할 때는 문제가 없고 국내+해외를 함께 실행할 때만 발생했다. 이를 통해 원인이 API 스펙 오류가 아니라 호출량 증가에 따른 throttling 문제임을 확인했다.

1-3. 제약 조건

  • KIS Open API 실전계좌 기준 초당 최대 20건

2. 해결 (Action)

2-1. Resilience4j 도입 결정

Spring Retry만 도입할 경우 재시도 간격 제어는 가능하지만 선제적 유량 제어가 불가능하다. 따라서 다음과 같이 역할을 분리.

컴포넌트책임
RateLimiter호출 시점 자체를 늦춰 KIS의 20 req/sec 한도를 넘기지 않도록 사전 차단
Retry한도를 넘겨 받은 5xx/429에 대해 지수 백오프 재시도

2-2. 스펙 정의

KIS 한도(20 req/sec)에 헤드룸 25% 를 두고, 해외 분석 도입 후의 호출 패턴을 기준으로 산정.

application.yml

kis:
  api:
    resilience:
      rate-limit:
        limit-for-period: 15        # 1초당 15건 (한도 20 대비 75% 사용)
        refresh-period-ms: 1000
        timeout-ms: 5000            # permit 대기 최대 5초 (캐시 미스 워스트 케이스 커버)
      retry:
        max-attempts: 3             # 최초 호출 + 2회 재시도
        initial-wait-ms: 300        # 1차 대기 300ms
        multiplier: 2.0             # 300ms → 600ms → 1200ms (지수 백오프)

산정 근거:

  • 15 req/sec: 24 req/cycle을 모두 동시에 던져도 약 1.6초 안에 소화 가능. 한도 대비 5건의 여유를 둬 토큰 발급/리프레시 같은 공용 트래픽이 동일 한도를 공유해도 안전.
  • timeout 5s: kisApiExecutorCallerRunsPolicy와 결합해 백프레셔를 끝까지 전달하되, 호출 스레드가 무한 블로킹되지 않도록 상한 설정.
  • 재시도 3회 / 지수 백오프: 단발성 429는 보통 1초 안에 회복되므로 누적 대기 ≈ 0.3 + 0.6 + 1.2 ≈ 2.1초 이내로 종결되어 LLMApiClient.analyzeStock의 60초 orTimeout 안에 충분히 들어옴.

2-3. 재시도 대상 좁히기

무분별한 재시도는 비즈니스 로직 오류까지 다시 호출해 비용을 키우므로, 재시도 가치가 있는 예외만 통과시킨다.

// KisResilienceConfig.java:68
private boolean isRetryable(Throwable throwable) {
    if (throwable instanceof WebClientResponseException responseException) {
        return responseException.getStatusCode().is5xxServerError()
            || responseException.getStatusCode().value() == 429;
    }
    return false;
}
  • 4xx (400/401/403)는 재시도해도 동일 실패 → 즉시 컨슈머로 전파 → DLT 라우팅
  • 5xx / 429만 재시도 → 일시적 쓰로틀링/서버 장애에서 자동 회복

2-4. 데코레이터 적용

KisApiClient.callApi의 실제 HTTP 호출 부분만 데코레이터로 감싸서, JSON 파싱·로깅 등 부가 작업은 재시도 범위에서 제외.

// KisApiClient.java:371
String responseBody = Retry.decorateSupplier(kisApiRetry,
    RateLimiter.decorateSupplier(kisRateLimiter,
        () -> executeApiCall(method, fullUrl, endpoint, trId, accessToken)
    )
).get();

데코레이터 순서를 Retry(바깥) → RateLimiter(안) 로 잡은 이유:

  • 재시도 시에도 다시 RateLimiter를 통과 → 재시도 폭주가 한도를 다시 부수는 것 방지
  • 반대 순서로 했다면 RateLimiter 통과 후 Retry가 동작해 재시도 호출이 그대로 한도를 초과할 수 있음

2-5. 설정 외부화 + 관측 가능성

  • 모든 임계값을 @Value로 외부화 → 운영 중 한도 조정 시 코드 변경 없이 application.yml만 수정
  • RateLimiter.onFailure, Retry.onRetry, Retry.onError 이벤트를 모두 @Slf4j로 로깅 → 언제, 몇 번째 시도에서, 어떤 에러로 재시도했는지 가시화

3. 결과 (Result)

3-1. 정량 효과

지표BeforeAfter
KIS 5xx/429 발생 비율분석 1회당 평균 3~5건0건 (피크 1건 미만)
일봉 누락으로 인한 LLM 분석 스킵 종목평균 2건/cycle0건
분석 1회 평균 소요시간약 18초 (실패→무시)약 22초 (재시도 흡수 포함, 전 종목 성공)
설정 변경 시 재배포 필요 여부코드 수정 필요YAML 수정만

3-2. 부수 효과

  • 재시도와 유량제어의 책임 분리가 명확해져, 향후 LLM API에 동일 패턴을 그대로 이식 가능
  • 데코레이터가 가장 바깥쪽 callApi 한 군데에만 적용되므로 국내/해외, 거래량/일봉 어떤 신규 KIS 엔드포인트가 추가돼도 자동 보호 받음
  • 4xx는 재시도 없이 즉시 실패 → AnalysisConsumerDefaultErrorHandler → DLT 경로가 그대로 살아있어, 회복 가능한 에러는 인라인 재시도, 회복 불가능한 에러는 DLT 로 깔끔하게 분기되는 2단 방어선이 완성됨

3-3. 회고

  • 처음에는 kisApiExecutor의 max pool size를 줄여 동시성 자체를 낮추는 안도 검토했으나, 이는 국내 단독 분석의 응답시간까지 함께 떨어뜨리는 부작용이 있어 기각. RateLimiter는 "호출이 몰릴 때만 직렬화"되므로 평시 성능은 그대로 유지된다.
  • 한도(20)를 그대로 쓰지 않고 15로 잡은 것이 토큰 재발급(fetchNewAccessToken)이 6시간마다 끼어들 때 충돌을 피할 수 있고, 여유분이 있다.
profile
몰입하는 개발자

0개의 댓글