외부 API를 프록시하는 서버에서 동시 요청이 몰릴 때 429를 근본적으로 차단하는 방법을 정리한다.
배경: 모의투자 앱의 캔들 차트
모의투자 서비스에서 종목 상세 화면의 캔들 차트는 과거 봉 데이터가 필요하다. 유저가 차트를 왼쪽으로 스크롤(페이지네이션)하면 서버가 Upbit REST API에서 과거 캔들을 가져와 DB에 저장한 뒤 응답한다.
클라이언트 → 우리 서버 → Upbit API (캔들 조회)
↓
DB 저장 (캐시)
↓
클라이언트에 응답
한 번 DB에 저장된 구간은 이후 요청에서 Upbit를 거치지 않고 바로 응답한다. 문제는 아직 적재되지 않은 구간을 여러 유저가 동시에 요청할 때다.
클라이언트에서 직접 호출하면 안 되나?
"서버가 병목이면 클라이언트가 Upbit API를 직접 호출하면 되지 않나?"라는 생각이 들 수 있다. 하지만 이건 정책적으로 불가능하다.
Upbit의 Origin 헤더 정책
Upbit API는 요청에 Origin HTTP 헤더가 포함되어 있으면 10초당 1회로 제한한다.
| 요청 출처 | Origin 헤더 | Rate Limit |
|---|---|---|
| 서버 (Java, Python 등) | 없음 | 초당 10회 |
| 브라우저 / WebView / React Native | 자동 포함 | 10초당 1회 |
브라우저 기반 환경에서 fetch()를 호출하면 Origin 헤더가 자동으로 붙는다. 이건 제거할 수 없는 브라우저 보안 메커니즘이다. 서버 경유 대비 100배 느린 제한이 걸리므로, 클라이언트 직접 호출은 현실적으로 사용할 수 없다.
여기에 CORS 문제까지 더해진다. Upbit API 서버가 우리 앱의 Origin을 허용할 리 없으므로 preflight 자체가 실패한다.
결론: 서버 프록시 구조는 유지해야 한다. 문제는 서버에서 Upbit 호출을 어떻게 제어하느냐다.
기존 방식: Semaphore
처음에는 Java의 Semaphore로 동시 요청 수를 제한했다.
private static final Semaphore upbitCandleThrottle = new Semaphore(9, true);
private boolean doBackfill(...) {
if (!upbitCandleThrottle.tryAcquire(12, TimeUnit.SECONDS)) {
return false; // 포기
}
try {
return callUpbitApi(...);
} finally {
upbitCandleThrottle.release();
}
}
Semaphore(9)는 "동시에 최대 9개"를 의미한다. 얼핏 보면 초당 10회 제한을 지키는 것 같지만, 동시성(concurrency)과 속도(rate)는 다른 개념이다.
Semaphore의 문제

t=0ms 9개 요청이 동시에 semaphore 획득 → 9개가 "같은 밀리초"에 Upbit 호출
t=50ms 9개 모두 응답 완료 → semaphore 반환
t=50ms 대기 중이던 9개가 다시 동시 획득 → 또 9개 동시 호출
Semaphore는 "한 번에 몇 개까지"를 제어하지, "1초에 몇 개까지"를 제어하지 않는다. Upbit API 응답이 빠르면(50ms 이내) 1초 안에 수십 개 요청이 나갈 수 있다.
Semaphore: 동시에 9개까지 허용 (burst 가능)
Rate Limit: 1초에 9개까지 허용 (균일 분배)
Upbit의 제한은 초당 횟수(rate)이므로, Semaphore로는 정확히 맞출 수 없다.
개선 방식: Queue + Rate Limiter
핵심 아이디어는 단순하다:
- 모든 backfill 요청을 큐에 넣고
- 단일 consumer가 111ms 간격으로 하나씩 꺼내서 실행하고
- 각 요청자는 CompletableFuture로 결과를 대기한다
전체 흐름

유저A ─┐
유저B ─┼─ submit() → 큐 진입 → future.get()으로 대기 (병렬)
유저C ─┘
┌──────────────────────────────────────┐
│ Drainer (단일 Virtual Thread) │
│ │
│ 111ms 간격으로: │
│ 큐에서 1개 꺼냄 │
│ → Upbit API 호출 │
│ → DB 저장 │
│ → future.complete() → 유저에게 응답 │
└──────────────────────────────────────┘
페이스 조절: 직접 구현한 Rate Limiter
Guava 같은 외부 의존 없이, 이전 dispatch 시각과의 차이만으로 페이스를 조절한다.
private static final long INTERVAL_NANOS = 1_000_000_000L / 9; // ~111ms
private volatile long lastDispatchNanos;
private void paceBeforeNext() throws InterruptedException {
long now = System.nanoTime();
long elapsed = now - lastDispatchNanos;
long sleepNanos = INTERVAL_NANOS - elapsed;
if (sleepNanos > 0) {
Thread.sleep(Duration.ofNanos(sleepNanos));
}
lastDispatchNanos = System.nanoTime();
}
이전 호출로부터 111ms가 지나지 않았으면 남은 시간만큼 sleep한다. 결과적으로 정확히 초당 9회 페이스가 유지된다.
Coalescing: 같은 요청 합치기
50명이 동시에 BTC 1분봉 같은 구간을 요청하면? 50번 Upbit를 호출할 필요 없다.
public CompletableFuture<Boolean> submit(String market, String interval,
Instant to, ...) {
var key = new CoalesceKey(market, interval, bucketedTo);
var myFuture = new CompletableFuture<Boolean>();
pending.compute(key, (k, existing) -> {
if (existing != null) {
// 이미 같은 키가 큐에 있으면 → future를 체이닝
existing.canonicalFuture()
.thenAccept(myFuture::complete);
return existing;
}
// 새 키 → 큐에 넣기
queue.offer(key);
return new BackfillRequest(k, to, count, origin, myFuture);
});
return myFuture;
}
ConcurrentHashMap.compute()로 원자적으로 처리한다:
- 같은 키가 이미 있으면 → 기존 Future에 체이닝 (Upbit 호출 추가 안 함)
- 새 키면 → 큐에 넣고 새 Future 생성
Drainer가 해당 키를 처리하면, 체이닝된 모든 Future가 동시에 완료되어 50명 모두 응답을 받는다.
호출자 측: 기존 인터페이스 유지
CandleBackfillPort 인터페이스는 그대로 boolean을 반환한다. CompletableFuture는 내부 구현으로만 사용한다.
// 기존과 동일한 시그니처
public boolean backfillIfNeeded(String market, String interval, Instant to, ...) {
// ... 기존 검증 로직 (warmup gate, exhausted 체크 등) ...
CompletableFuture<Boolean> future = rateLimiter.submit(market, interval, to, ...);
return future.get(15, TimeUnit.SECONDS); // VT에서 블로킹해도 OS 스레드 점유 없음
}
Virtual Thread 환경이므로 future.get() 블로킹이 OS 스레드를 점유하지 않는다. 수백 명이 동시에 대기해도 문제없다.
비교 정리
| Semaphore (기존) | Queue + Rate Limiter (개선) | |
|---|---|---|
| 제어 대상 | 동시 요청 수 (concurrency) | 초당 요청 수 (rate) |
| burst | 9개 동시 발사 가능 | 절대 동시 발사 없음 |
| 초당 실제 요청 | 0~수십 (불규칙) | 정확히 9 (균일) |
| 중복 요청 | per-key ReentrantLock | coalescing (Future 공유) |
| 429 위험 | burst 시 발생 가능 | 원천 차단 |
| 요청 유실 | 12초 내 acquire 실패 시 포기 | 큐에서 대기 (15초 타임아웃) |
타임아웃과 실패 처리
큐가 밀려서 15초 내에 처리되지 못하면?
try {
return future.get(15, TimeUnit.SECONDS);
} catch (TimeoutException te) {
return false; // backfill 실패로 처리
}
false가 반환되면 MarketReadService는 DB에 있는 만큼만 유저에게 응답한다. 200봉을 요청했는데 50봉만 있으면 50봉이 내려가고, 유저가 다음 페이지네이션을 시도하면 그때 다시 큐에 진입한다. 한편, 타임아웃된 요청도 큐에 남아서 결국 처리되므로, 다음 요청 시에는 DB에서 바로 응답할 가능성이 높다.
Virtual Thread와의 궁합
이 설계는 Java 21+ Virtual Thread 환경에서 특히 잘 맞는다.
- Drainer:
Thread.ofVirtual().start(this::drain)— OS 스레드 1개를 점유하지 않음 - Caller blocking:
future.get()— VT에서 블로킹해도 carrier thread 반환 - Sleep:
Thread.sleep(Duration.ofNanos(...))— VT sleep은 OS 스레드를 풀어줌
Platform Thread였다면 수백 명이 future.get()으로 블로킹하는 건 스레드 풀 고갈 위험이 있다. VT에서는 수천 개가 동시에 블로킹해도 문제없다.
핵심 교훈
- Semaphore != Rate Limiter — 동시성 제어와 속도 제어는 다른 문제다. 외부 API의 "초당 N회" 제한에는 rate limiter가 맞다.
- 큐 + 단일 consumer = 가장 단순한 rate limiter — 외부 라이브러리 없이도
BlockingQueue+sleep조합으로 정밀한 페이스 조절이 가능하다. - Coalescing으로 중복 호출 제거 — 같은 데이터를 여러 명이 동시에 요청하면, 외부 API 호출은 1번만 하고 결과를 공유하면 된다.
- 클라이언트 직접 호출은 항상 가능한 게 아니다 — 외부 API의 Origin 헤더 정책, CORS, 이용약관을 반드시 확인해야 한다. 서버 프록시가 필요한 이유가 있다.
'API Transaction > Upbit' 카테고리의 다른 글
| [파이썬] 업비트 자동매매 봇 만들기 9 - 발생하는 각종 오류, 수정사항들 (0) | 2025.03.12 |
|---|---|
| [파이썬] 업비트 자동매매 봇 만들기 8 - 1차 완료 후 정리. 예외 사항 처리 및 추후 구현 가능 사항들. (0) | 2025.02.13 |
| [파이썬] 업비트 자동매매 봇 만들기 7 - private websocket, Critical Section, 비동기 Lock (Mutex) (0) | 2025.02.12 |
| [파이썬] 업비트 자동매매 봇 만들기 6 - 거래중인 코인 갯수 유지(무분별한 코인 거래 방지) (1) | 2025.02.10 |
| [파이썬] 업비트 자동매매 봇 만들기 5 - asyncio, async, await, 비동기 작업 (1) | 2025.02.08 |