포스트

Circuit Breaker로 외부 API 장애 격리 — Resilience4j + KIS·Yahoo 폴백 체인

외부 API 장애가 전체 시스템을 멈추는 이유

monticker는 KIS WebSocket, Yahoo Finance API, 네이버 뉴스 API, DART API 같은 외부 서비스에 의존한다. 이 중 하나가 느려지거나 장애가 나면 무슨 일이 생길까?

장애 없는 경우:

1
요청 → API 서버 → KIS API → 응답 (10ms)

KIS API 타임아웃:

1
요청 → API 서버 → KIS API → ... 30초 대기 ... → TimeoutException

모든 스레드가 KIS API 응답을 기다리며 30초를 소비한다. 새 요청이 들어와도 처리할 스레드가 없다. 전체 서버가 마비된다.

Circuit Breaker는 이 연쇄 장애(Cascading Failure) 를 방지한다.


Circuit Breaker 상태 머신

Circuit Breaker는 세 가지 상태를 가진다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
    실패율 < 임계값          실패율 ≥ 임계값
CLOSED ──────────────────► OPEN
  ↑                            │
  │ 일부 성공                   │ 대기 시간 초과
  │                            ▼
HALF_OPEN ◄─────────────────────

CLOSED (정상):
  모든 요청 통과. 실패율 측정 중.

OPEN (차단):
  모든 요청 즉시 차단. fallback 반환.
  다음 요청 없이 응답 → 빠른 실패(Fast Fail).

HALF_OPEN (탐색):
  N개의 요청만 통과시켜 서비스 복구 여부 확인.
  성공이 많으면 CLOSED로 전환.
  실패가 많으면 다시 OPEN으로.

Resilience4j 설정

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# application.yml
resilience4j:
  circuitbreaker:
    instances:
      kis-orderbook:
        failure-rate-threshold: 50          # 50% 이상 실패 시 OPEN
        slow-call-rate-threshold: 80        # 80% 이상이 느리면 OPEN
        slow-call-duration-threshold: 3s    # 3초 이상 = 느린 호출
        minimum-number-of-calls: 10         # 최소 10개 호출 후 계산 시작
        wait-duration-in-open-state: 30s    # OPEN 후 30초 대기
        permitted-number-of-calls-in-half-open-state: 3  # HALF_OPEN에서 3개 테스트
        sliding-window-type: COUNT_BASED
        sliding-window-size: 20             # 최근 20개 호출로 실패율 계산

      yahoo-finance:
        failure-rate-threshold: 60
        slow-call-duration-threshold: 5s
        minimum-number-of-calls: 5
        wait-duration-in-open-state: 60s

  retry:
    instances:
      kis-orderbook:
        max-attempts: 3
        wait-duration: 500ms
        retry-exceptions:
          - java.io.IOException
          - java.net.SocketTimeoutException

호가창 Provider 폴백 체인

호가창은 3개의 Provider가 폴백 체인을 이룬다.

1
KIS WebSocket (실시간) → Yahoo Finance (15분 지연) → Mock (시뮬레이션)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@Service
class OrderBookService(
    private val kisProvider: KisOrderBookProvider,
    private val yahooProvider: YahooFinanceOrderBookProvider,
    private val mockProvider: MockOrderBookProvider,
) {
    fun getOrderBook(stockId: Long): OrderBookResponse {
        return tryKis(stockId)
            ?: tryYahoo(stockId)
            ?: mockProvider.getOrderBook(stockId)
    }

    private fun tryKis(stockId: Long): OrderBookResponse? {
        return try {
            kisProvider.getOrderBook(stockId)
        } catch (e: Exception) {
            log.warn("KIS 호가창 조회 실패 [stockId={}]: {}", stockId, e.message)
            null
        }
    }

    private fun tryYahoo(stockId: Long): OrderBookResponse? {
        return try {
            yahooProvider.getOrderBook(stockId)
        } catch (e: Exception) {
            log.warn("Yahoo Finance 호가창 조회 실패 [stockId={}]: {}", stockId, e.message)
            null
        }
    }
}

@CircuitBreaker 어노테이션 적용

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Service
class KisOrderBookProvider(
    private val redisTemplate: StringRedisTemplate,
    private val stockRepository: StockRepository,
) {
    @CircuitBreaker(name = "kis-orderbook", fallbackMethod = "fallback")
    @Retry(name = "kis-orderbook")
    fun getOrderBook(stockId: Long): OrderBookResponse {
        val stock   = stockRepository.findById(stockId)
        val redisKey = "orderbook:${stock.symbol}"

        val cached = redisTemplate.opsForValue().get(redisKey)
            ?: throw OrderBookNotFoundException("KIS 호가창 캐시 없음: ${stock.symbol}")

        val data = objectMapper.readValue(cached, KisOrderBookData::class.java)
        return data.toOrderBookResponse(source = OrderBookSource.KIS_REALTIME)
    }

    // Circuit Breaker가 OPEN일 때 호출되는 fallback
    fun fallback(stockId: Long, ex: Exception): OrderBookResponse {
        log.warn("KIS Circuit Breaker OPEN, fallback 반환 [stockId={}]", stockId)
        throw OrderBookUnavailableException("KIS 실시간 호가 일시 불가")
    }
}

fallbackMethod가 예외를 throw하면 OrderBookService에서 null을 반환해 다음 Provider로 넘어간다.


Yahoo Finance Provider

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Service
class YahooFinanceOrderBookProvider(
    private val restTemplate: RestTemplate,
    private val stockRepository: StockRepository,
) {
    @CircuitBreaker(name = "yahoo-finance", fallbackMethod = "fallback")
    fun getOrderBook(stockId: Long): OrderBookResponse {
        val stock = stockRepository.findById(stockId)

        // Yahoo Finance v8 API (비공식, 15분 지연)
        val url = "https://query1.finance.yahoo.com/v8/finance/chart/${stock.symbol}?interval=1m&range=1d"

        val response = restTemplate.getForObject(url, Map::class.java)
            ?: throw YahooFinanceException("응답 없음")

        return parseYahooResponse(response)
            .toOrderBookResponse(source = OrderBookSource.YAHOO_FINANCE)
    }

    fun fallback(stockId: Long, ex: Exception): OrderBookResponse {
        throw YahooFinanceUnavailableException("Yahoo Finance 일시 불가")
    }
}

Circuit Breaker 상태 모니터링

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@RestController
@RequestMapping("/api/admin/circuit-breakers")
class CircuitBreakerController(
    private val circuitBreakerRegistry: CircuitBreakerRegistry,
) {
    @GetMapping
    fun getStatus(): List<CircuitBreakerStatus> {
        return circuitBreakerRegistry.allCircuitBreakers.map { cb ->
            val metrics = cb.metrics
            CircuitBreakerStatus(
                name          = cb.name,
                state         = cb.state.name,
                failureRate   = metrics.failureRate,
                slowCallRate  = metrics.slowCallRate,
                callCount     = metrics.numberOfBufferedCalls,
                successCount  = metrics.numberOfSuccessfulCalls,
                failureCount  = metrics.numberOfFailedCalls,
            )
        }
    }
}

호가창 소스 표시

응답에 어떤 소스를 사용했는지 표시한다.

1
2
3
4
5
6
7
8
{
  "stockId": 1,
  "symbol": "005930",
  "source": "YAHOO_FINANCE",
  "sourceNote": "KIS 실시간 호가 일시 불가. 15분 지연 데이터를 표시합니다.",
  "bids": [...],
  "asks": [...]
}

사용자가 지연 데이터를 실시간으로 착각하지 않도록 명시한다.


정리

  • Circuit Breaker는 CLOSED → OPEN → HALF_OPEN 상태를 오가며 외부 API 장애를 격리한다.
  • 폴백 체인(KIS → Yahoo → Mock)으로 최상의 데이터를 우선 서빙하고 단계적으로 저하된다.
  • Resilience4j의 slow-call-duration-threshold로 느린 호출도 실패로 간주한다.
  • 응답에 source 필드를 포함해 사용자가 데이터 신선도를 인지할 수 있게 한다.

시리즈를 마치며

monticker 기술 블로그 시리즈 22편이 마무리됩니다.

이 프로젝트에서 다룬 기술들을 요약하면:

영역기술
수집Go goroutine, kafka-go, pgx
메시지 버스Apache Kafka (KRaft), 파티셔닝, at-least-once
브로드캐스트Netty NioEventLoopGroup, WebSocket
이상 탐지EMA, 적응형 임계값
체결 엔진CLOB, TreeMap, 가격/시간 우선
리스크VaR, 집중도, 동기 게이트
원장이벤트 소싱, 스냅샷 가속
Quant룰 DSL, 백테스트, SHA-256 보호
AnalyticsMarkowitz, Kelly, ZigZag, ADX
테스트MockK, SQL 부분 매칭, 311 tests
관측가능성OpenTelemetry, Jaeger, Micrometer
신뢰성Resilience4j Circuit Breaker

monticker 소스코드: github.com/polynomeer/monticker

  1. 1 가격이 아니라 이벤트를 팔자 — monticker 설계 철학
  2. 2 모듈식 모놀리스를 선택한 이유 — MSA의 유혹을 거부하기
  3. 3 TimescaleDB를 시계열 DB로 고른 이유 — Hypertable과 연속 집계
  4. 4 Go goroutine으로 202개 종목 동시 수집하기 — Market Gateway 설계
  5. 5 Kafka로 시세 파이프라인 분리하기 — 토픽 설계와 at-least-once
  6. 6 Netty로 수만 연결에 시세 브로드캐스트하기 — NioEventLoopGroup 리액터 패턴
  7. 7 EMA 기반 이상 탐지 — 가격 급등과 거래량 서지 실시간 감지
  8. 8 TreeMap으로 CLOB 호가창 구현하기 — 가격/시간 우선 매칭과 슬리피지
  9. 9 주문 전 동기 리스크 게이트 설계 — VaR, 집중도, 일일손실 5가지 규칙
  10. 10 잔고를 저장하지 말고 재구성하라 — 이벤트 소싱 원장 설계
  11. 11 감정 태그 × 수익률 — 투자 습관을 데이터로 기록하기
  12. 12 룰 엔진: RSI·MACD 조건식을 JSON DSL로 — Quant Lab 설계
  13. 13 백테스트 엔진: look-ahead 없는 시뮬레이션 — Sharpe·MDD·PF 계산
  14. 14 전략 지문(SHA-256)으로 룰셋 보호하기 — 서버 사이드 실행과 역공학 방어
  15. 15 Markowitz 최적화를 솔버 없이 구현하기 — 프로젝션 경사하강법
  16. 16 Kelly Criterion: 수학적 파산 방지 베팅 비율 — Half Kelly와 백테스트 연동
  17. 17 ZigZag + 패턴 템플릿 매칭으로 차트 패턴 감지 — 헤드앤숄더·이중바닥
  18. 18 ADX로 시장 국면 분류하기 — BULL·BEAR·SIDEWAYS·HIGH_VOL
  19. 19 손익통산으로 세금 줄이기 — Tax-Loss Harvesting 시뮬레이션
  20. 20 MockK로 JdbcTemplate 목킹하기 — 311개 테스트 작성 경험
  21. 21 OpenTelemetry + Jaeger로 분산 추적 — 시세 파이프라인 지연 측정
  22. 22 Circuit Breaker로 외부 API 장애 격리 — Resilience4j + KIS·Yahoo 폴백 체인
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

댓글

아직 댓글이 없습니다