Toss Open API Rate Limiter 적용 TDD
Background
백엔드는 5개의 Toss Gateway(Account, Alert/Stock/Holding/Order용 각 Toss 구현체)를 통해 Toss 증권 Open API를 호출한다. 현재 구현에는 401 재시도만 있고 429(Too Many Requests) 방어 및 연속 실패에 대한 Circuit Breaker가 없다.
Overview
Resilience4j RateLimiter + CircuitBreaker를 공통 레이어에 도입해 Toss API 호출을 보호한다.
각 API 그룹별 TPS 한도를 설정하고, 한도 초과 시 즉시 실패(큐잉 없음)로 상위 레이어에 TossRateLimitException 을 throw한다.
연속 실패율 50% 초과 시 Circuit이 OPEN되어 Toss API 호출 자체를 30초간 차단한다.
Terminology
| 용어 | 설명 |
|---|---|
| RateLimiter | 지정된 주기에 허용된 횟수만큼만 호출을 허용하는 Resilience4j 컴포넌트 |
| CircuitBreaker | 실패율이 임계치를 넘으면 회로를 열어 Fast Fail하는 Resilience4j 컴포넌트 |
| API 그룹 | Toss Open API가 Rate Limit를 적용하는 단위 (MARKET_DATA, STOCK, ACCOUNT, ASSET, ORDER, AUTH) |
| RequestNotPermitted | RateLimiter 한도 초과 시 throw되는 Resilience4j 예외 |
| TossRateLimitException | RequestNotPermitted 를 래핑해 도메인 경계에서 사용하는 애플리케이션 예외 |
| CallNotPermittedException | CircuitBreaker OPEN 시 throw되는 Resilience4j 예외 |
| TossCircuitOpenException | CallNotPermittedException 을 래핑한 애플리케이션 예외 |
Define Problem
AS-IS
Gateway.fetchPrices()
└─ retryOn401 { RestClient.get("/api/v1/prices") }
- 429 응답 → 스택 트레이스 로그 → 정제되지 않은 5xx 반환
- 연속 Toss API 장애 시 매 폴링마다 커넥션 소모
- 폴링 주기 30초 × ACTIVE 알림 N개 → N 요청/30s (현재는 작지만 확장 시 문제)
TO-BE
Gateway.fetchPrices()
└─ CircuitBreaker.executeSupplier {
RateLimiter.executeSupplier {
retryOn401 { RestClient.get("/api/v1/prices") }
}
}
- 한도 초과 →
TossRateLimitException(구조화된 에러) - 연속 실패 → CircuitBreaker OPEN → Fast Fail (Toss API 부하 차단)
- 설정값을
application.yml에서 외부화
Possible Solutions
벤치마킹 참조 제품
| 제품명 | 카테고리 | 참조 패턴 |
|---|---|---|
| Resilience4j | Java/Kotlin 회복탄력성 라이브러리 | RateLimiter + CircuitBreaker 조합 |
| Bucket4j | Java 토큰 버킷 구현 | 토큰 버킷 알고리즘 |
| Spring Cloud Gateway RateLimiter | HTTP Gateway 레벨 | Redis 기반 분산 Rate Limit |
방안 비교
| 방안 | 설명 | 왜 채택 / 미채택 |
|---|---|---|
| Resilience4j (채택) | Gateway 호출 코드에 rateLimiter.executeSupplier { } 래핑 | Spring Boot autoconfigure와 통합, YAML 설정 외부화, CircuitBreaker 조합이 자연스럽다 |
| Bucket4j | 토큰 버킷 알고리즘 직접 구현 | 직접 구현 비용 높음, Resilience4j와 중복 도입 불필요 |
| Spring Cloud Gateway | HTTP 필터 레이어 | 현재 Spring MVC 기반이고, API Gateway 별도 도입은 과도한 복잡도 |
결정: Resilience4j 단일 라이브러리로 RateLimiter + CircuitBreaker 동시 해결. Gateway 구현에 AOP 없이 명시적 래핑을 적용해 테스트 가능성 확보.
Detail Design
처리 흐름
sequenceDiagram participant C as UseCase / Scheduler participant G as TossXxxGateway participant F as TossRateLimiterFacade participant CB as CircuitBreaker(toss) participant RL as RateLimiter(group) participant T as Toss Open API C->>G: fetchPrices(symbols) G->>F: execute(MARKET_DATA) { block } F->>CB: executeSupplier CB->>RL: executeSupplier RL-->>F: RequestNotPermitted → TossRateLimitException CB->>T: RestClient.get("/api/v1/prices") T-->>CB: 200 OK CB-->>G: StockPrice list G-->>C: List<StockPrice>
클래스 의존
flowchart LR subgraph Presentation EH[GlobalExceptionHandler] end subgraph Infrastructure G1[TossStockPriceGateway] G2[TossStockGatewayImpl] G3[TossHoldingGateway] G4[TossOrderGateway] G5[TossAccountGateway] FA[TossRateLimiterFacade] CFG[TossRateLimiterConfig] end subgraph Common_Exception EX1[TossRateLimitException] EX2[TossCircuitOpenException] end G1 --> FA G2 --> FA G3 --> FA G4 --> FA G5 --> FA FA --> CFG FA --> EX1 FA --> EX2 EH --> EX1 EH --> EX2
AS-IS / TO-BE 비교
| 항목 | AS-IS | TO-BE |
|---|---|---|
| 429 처리 | 미처리 (스택 트레이스) | TossRateLimitException throw |
| 연속 실패 방어 | 없음 | CircuitBreaker OPEN |
| 설정 외부화 | 없음 | application.yml resilience4j 섹션 |
| 예외 HTTP 매핑 | 500 | 429 / 503 |
ERD
변경 없음 — DB 스키마 영향 없는 인프라 레이어 변경.
Testing Plan
- MARKET_DATA 그룹 10 req 연속 성공 → 11번째
TossRateLimitExceptionthrow 확인 (infrastructure) - ACCOUNT 그룹 1 req 성공 → 2번째
TossRateLimitExceptionthrow 확인 (infrastructure) - CircuitBreaker 실패율 50% 초과 (10건 중 5건 실패) → OPEN 전이 확인 (infrastructure)
- CircuitBreaker OPEN 상태에서
TossCircuitOpenException즉시 throw 확인 (infrastructure) TossRateLimitException→ HTTP 429,TossCircuitOpenException→ HTTP 503 매핑 확인 (presentation)
Release Scenario
build.gradle.kts에 Resilience4j 의존성 추가 후 빌드 확인application.yml에 resilience4j 설정 추가TossRateLimiterConfig/TossRateLimiterFacade/ 예외 클래스 신규 생성- 각 Gateway에
TossRateLimiterFacade주입 후 래핑 적용 GlobalExceptionHandler에 예외 매핑 추가- 통합 테스트 통과 후 PR 생성
롤백: TossRateLimiterFacade 코드를 제거하고 Gateway 원복 시 기존 동작으로 즉시 복구 가능. 설정만 추가되므로 스키마 롤백 불필요.
Project Information
- 담당자: biuea
- Jira Epic: STK6
Document History
| 날짜 | 변경 내용 | 작성자 |
|---|---|---|
| 2026-06-19 | 초안 작성 | Claude |
| 2026-06-21 | 스킬 포맷 적용 | Claude |