Step 2. 토큰 버킷 Rate Limiter 구현
Step 2. 토큰 버킷 Rate Limiter 구현
이 문서에서 다루는 내용: 네이버 광고 API 호출 빈도를 안전하게 제어하기 위해 토큰 버킷(Token Bucket) 알고리즘 기반의 Rate Limiter를 구현합니다. Promise를 활용한 비동기 큐 패턴, 토큰 보충 로직, 그리고 외부 API 연동 시 Rate Limiting이 왜 필수인지를 다룹니다.
1. 요구사항
PM 김도연:
"이준혁 님, Step 1에서 타입 설계까지 잘 마무리됐는데요. 이제 실제 네이버 키워드 API를 호출해서 데이터를 수집해야 합니다."
"그런데 한 가지 걱정이 있어요. 시드 키워드가 수십 개가 될 수 있고, 키워드 하나당 API를 한 번씩 호출해야 하니까 짧은 시간에 요청이 수십 건 나갈 수 있거든요. 네이버 광고 API에 호출 제한이 있는 걸로 아는데, 그냥 for문으로 연달아 보내면 안 되나요?"
"API 호출 속도를 적절히 조절하는 장치가 필요할 것 같은데, 이 부분을 먼저 만들어주시면 다음 단계에서 API 클라이언트 붙일 때 편할 것 같습니다."
2. 시니어의 접근 방식
시니어 이준혁 (8년차):
"좋은 질문이야. 결론부터 말하면, for문으로 연달아 보내면 확실히 차단당해. 네이버뿐만 아니라 대부분의 외부 API는 초당 또는 분당 호출 횟수에 제한을 둬. 이걸 Rate Limit이라고 하는데, 이걸 넘기면 429 Too Many Requests 응답이 돌아오거나, 심하면 IP 자체가 차단돼."
"그러면 어떻게 해야 하느냐. 단순하게는 요청 사이에 setTimeout을 넣어서 간격을 두는 방법이 있어. 근데 이 방식은 문제가 있어. API가 초당 5회까지 허용한다고 치자. 매 요청마다 200ms씩 기다리면 되지 않느냐고? 실제로는 네트워크 지연 때문에 요청 완료 시간이 들쭉날쭉해. 어떤 요청은 50ms 만에 끝나고 어떤 건 500ms 걸려. 고정 딜레이로는 정확하게 제어가 안 돼."
"그래서 나는 토큰 버킷(Token Bucket) 알고리즘을 쓸 거야. 이게 뭐냐면, 비유로 설명해볼게."
"놀이공원 매표소를 생각해봐. 입장 토큰이 담긴 바구니가 하나 있어. 바구니에는 최대 5개의 토큰이 들어갈 수 있고, 직원이 1초에 5개씩 토큰을 채워 넣어. 손님(API 요청)이 오면 바구니에서 토큰을 하나 꺼내고 입장해. 만약 바구니가 비어 있으면? 직원이 토큰을 채워줄 때까지 줄 서서 기다려야 해. 토큰이 채워지는 즉시 줄 맨 앞 사람이 들어가."
"이 비유에서 핵심 개념을 뽑으면 이렇게 돼:"
| 비유 | 코드 개념 |
|---|---|
| 바구니 | tokens (현재 사용 가능한 토큰 수) |
| 바구니 최대 용량 | maxTokens (토큰 상한선) |
| 직원의 토큰 보충 속도 | refillPerSecond (초당 보충 토큰 수) |
| 줄 서 있는 손님들 | queue (대기 중인 요청 배열) |
| 토큰을 꺼내는 행위 | acquire() (토큰 획득 메서드) |
"이 알고리즘의 좋은 점이 뭐냐면, 순간적인 버스트를 허용하면서도 전체 평균 속도를 제어할 수 있다는 거야. 바구니에 토큰이 5개 쌓여 있으면 5개 요청이 동시에 바로 나갈 수 있어. 대신 그 뒤에는 토큰이 보충될 때까지 기다려야 하지. 고정 딜레이 방식보다 훨씬 효율적이야."
"구현 포인트를 정리하면 세 가지야:"
- 토큰 보충(refill) -- 마지막 보충 시점부터 경과한 시간에 비례해서 토큰을 채운다
- 토큰 획득(acquire) -- 요청이 들어오면 대기 큐에 넣고, 토큰이 있을 때 꺼내준다
- 큐 처리(processQueue) -- 대기 중인 요청들을 순서대로 처리하되, 토큰이 없으면 보충을 기다린다
"자, 코드로 들어가자."
3. 구현
3.1 Rate Limiter 클래스 전체 구조
먼저 전체 코드를 보고, 이후 각 부분을 상세히 분석합니다.
// src/lib/core/naver/rate-limiter.ts
/**
* 책임: 네이버 광고 API - 토큰 버켓 알고리즘 기반의 큐로 Rate Limit 관리
* - tokens bucket algorithm 기반
*/
export class RateLimiter {
private readonly maxTokens: number;
private readonly refillPerSecond: number;
private tokens: number;
private lastRefill: number = Date.now();
private queue: Array<() => void> = [];
private processing: boolean = false;
constructor(maxTokens: number, refillPerSecond: number) {
this.maxTokens = maxTokens;
this.refillPerSecond = refillPerSecond;
this.tokens = maxTokens;
}
async acquire(): Promise<void> {
return new Promise((resolve) => {
this.queue.push(resolve);
this.processQueue();
});
}
async processQueue() {
if (this.processing) {
return;
}
this.processing = true;
while (this.queue.length > 0) {
this.refill();
if (this.tokens < 1) {
const waitTime = ((1 - this.tokens) / this.refillPerSecond) * 1000;
await new Promise((r) => setTimeout(r, waitTime));
this.refill();
}
this.tokens -= 1;
const resolve = this.queue.shift()!;
resolve();
}
this.processing = false;
}
private refill(): void {
const now = Date.now();
const elapsed = (now - this.lastRefill) / 1000;
this.tokens = Math.min(this.maxTokens, this.tokens + elapsed * this.refillPerSecond);
this.lastRefill = now;
}
}이 하나의 클래스가 전부입니다. 60줄도 안 되는 코드로 완전한 Rate Limiter가 만들어집니다. 각 부분을 뜯어봅시다.
3.2 프로퍼티 선언과 생성자
// src/lib/core/naver/rate-limiter.ts (프로퍼티 부분)
export class RateLimiter {
private readonly maxTokens: number;
private readonly refillPerSecond: number;
private tokens: number;
private lastRefill: number = Date.now();
private queue: Array<() => void> = [];
private processing: boolean = false;
constructor(maxTokens: number, refillPerSecond: number) {
this.maxTokens = maxTokens;
this.refillPerSecond = refillPerSecond;
this.tokens = maxTokens;
}
// ...
}이준혁: "프로퍼티를 두 그룹으로 나눠서 봐."
설정값 (불변):
maxTokens-- 토큰 바구니의 최대 용량.readonly로 선언해서 생성 이후 변경을 차단했어. 네이버 API가 초당 5회 허용이면5를 넣으면 돼.refillPerSecond-- 초당 토큰 보충 속도. 역시readonly. 초당 5개씩 보충하려면5를 넣는 거야.
상태값 (가변):
tokens-- 현재 사용 가능한 토큰 수. 실수(float)일 수 있어. 왜냐면 0.3초가 지나면 토큰이 1.5개 채워지는 식으로 연속적으로 변하거든.lastRefill-- 마지막 토큰 보충 시점의 타임스탬프.Date.now()로 초기화.queue-- 대기 중인 요청들의 배열. 타입이Array<() => void>인데, 이건 "인자 없고 반환값 없는 함수들의 배열"이라는 뜻이야. 곧 이게 왜 이런 타입인지 알게 될 거야.processing-- 현재 큐를 처리 중인지를 나타내는 플래그. 동시 처리 방지용이야.
이준혁: "생성자에서
this.tokens = maxTokens로 초기화하는 부분 보여? 이게 중요해. Rate Limiter가 생성되는 순간 바구니가 가득 찬 상태에서 시작한다는 거야. 첫 번째 요청 배치는 대기 없이 바로 나갈 수 있다는 뜻이지."
3.3 acquire() -- 토큰 획득 요청
// src/lib/core/naver/rate-limiter.ts (acquire 메서드)
async acquire(): Promise<void> {
return new Promise((resolve) => {
this.queue.push(resolve);
this.processQueue();
});
}이 메서드가 Rate Limiter의 공개 인터페이스입니다. 외부에서는 이 메서드만 호출합니다.
이준혁: "이 3줄이 이 클래스에서 제일 영리한 부분이야. 잘 봐."
acquire()가 호출되면 새로운 Promise를 하나 만들어. 그런데 이 Promise의 resolve 함수를 바로 호출하지 않아. 대신 큐에 집어넣어. 그리고 processQueue()를 호출해서 큐 처리를 시작해.
이게 무슨 뜻이냐면, acquire()를 호출한 쪽에서 await하고 있으면, 큐에서 자기 차례가 와서 resolve()가 호출될 때까지 멈춰 있는 거야. 토큰이 있으면 바로 풀리고, 없으면 토큰이 채워질 때까지 기다리게 돼.
// 사용 예시 -- acquire()가 어떻게 쓰이는지
const limiter = new RateLimiter(5, 5); // 최대 5토큰, 초당 5개 보충
async function callNaverAPI(keyword: string) {
await limiter.acquire(); // 토큰이 생길 때까지 여기서 대기
// 토큰 획득 완료 -- 이제 API 호출 가능
const response = await fetch(`/api/naver?keyword=${keyword}`);
return response.json();
}이준혁: "아까
queue의 타입이Array<() => void>라고 했잖아. 이제 이해가 되지? 큐에 들어가는 건 Promise의resolve함수야.resolve는 인자 없이 호출되고 반환값도 없으니까() => void타입인 거야. Promise의 resolve를 데이터 구조에 저장해뒀다가 나중에 호출하는 패턴 -- 이걸 Deferred Pattern이라고도 해."
3.4 processQueue() -- 큐 순차 처리
// src/lib/core/naver/rate-limiter.ts (processQueue 메서드)
async processQueue() {
if (this.processing) {
return;
}
this.processing = true;
while (this.queue.length > 0) {
this.refill();
if (this.tokens < 1) {
const waitTime = ((1 - this.tokens) / this.refillPerSecond) * 1000;
await new Promise((r) => setTimeout(r, waitTime));
this.refill();
}
this.tokens -= 1;
const resolve = this.queue.shift()!;
resolve();
}
this.processing = false;
}이 메서드가 Rate Limiter의 핵심 엔진입니다. 단계별로 분석합니다.
1단계: 동시 실행 방지 (Guard)
if (this.processing) {
return;
}
this.processing = true;이준혁: "여러 곳에서
acquire()가 거의 동시에 호출되면,processQueue()도 여러 번 호출돼. 그런데 큐를 처리하는 루프는 하나만 돌아야 해. 두 개가 동시에 돌면 같은 토큰을 두 번 소비하는 레이스 컨디션이 생기거든.processing플래그가 이걸 막아주는 거야."
2단계: 토큰 보충 시도
this.refill();루프를 돌 때마다 가장 먼저 refill()을 호출해서 경과 시간만큼 토큰을 채워 넣습니다. 토큰 보충 로직은 바로 다음 섹션에서 다룹니다.
3단계: 토큰 부족 시 대기
if (this.tokens < 1) {
const waitTime = ((1 - this.tokens) / this.refillPerSecond) * 1000;
await new Promise((r) => setTimeout(r, waitTime));
this.refill();
}이준혁: "여기가 핵심이야. 토큰이 1개 미만이면 부족한 만큼 기다려.
waitTime계산식을 분해해볼게."
waitTime 계산을 구체적인 숫자로 추적해봅시다:
- 현재
tokens가0.3이고,refillPerSecond가5라고 가정 - 부족한 토큰:
1 - 0.3 = 0.7 - 초당 5개를 보충하니까, 0.7개를 채우는 데 걸리는 시간:
0.7 / 5 = 0.14초 - 밀리초로 변환:
0.14 * 1000 = 140ms
즉, 정확히 필요한 만큼만 기다린다는 뜻입니다. 1초를 통째로 기다리는 게 아니라, 토큰 0.7개가 보충될 시간인 140ms만 기다리는 거죠. 이게 토큰 버킷의 효율성입니다.
대기 후에 refill()을 다시 호출해서 실제 경과 시간을 반영합니다.
4단계: 토큰 소비 및 요청 해제
this.tokens -= 1;
const resolve = this.queue.shift()!;
resolve();토큰을 1개 차감하고, 큐 맨 앞에서 resolve 함수를 꺼내서 호출합니다. 이 resolve()가 호출되는 순간, 해당 요청의 await limiter.acquire()가 풀리면서 다음 코드로 넘어갑니다.
이준혁: "
this.queue.shift()!에서!(non-null assertion)을 쓴 거 보이지?while (this.queue.length > 0)안에서 실행되니까shift()가undefined를 반환할 일이 없거든. TypeScript한테 '나 이거 확실하다'고 알려주는 거야."
3.5 refill() -- 토큰 보충 로직
// src/lib/core/naver/rate-limiter.ts (refill 메서드)
private refill(): void {
const now = Date.now();
const elapsed = (now - this.lastRefill) / 1000;
this.tokens = Math.min(this.maxTokens, this.tokens + elapsed * this.refillPerSecond);
this.lastRefill = now;
}이준혁: "이 메서드가 토큰 버킷의 수학적 핵심이야. 실제로 타이머를 돌려서 1초마다 토큰을 넣는 게 아니라, 호출될 때마다 경과 시간을 계산해서 한 번에 보충하는 방식이야. 이걸 '게으른 보충(lazy refill)'이라고 해."
작동 과정을 시간 순서대로 추적해봅시다:
시각 0.0초: RateLimiter 생성 (tokens = 5, lastRefill = 0)
시각 0.0초: acquire() 5번 호출 -> tokens = 0 (5개 모두 소비)
시각 0.3초: acquire() 1번 호출
-> refill() 실행
-> elapsed = 0.3초
-> 보충량 = 0.3 * 5 = 1.5
-> tokens = min(5, 0 + 1.5) = 1.5
-> 토큰이 1 이상이므로 대기 없이 즉시 통과
-> tokens = 1.5 - 1 = 0.5
시각 0.3초: acquire() 또 호출
-> refill() 실행
-> elapsed = 거의 0초 (직전에 refill했으므로)
-> 보충량 = 약 0
-> tokens = 약 0.5
-> 토큰이 1 미만이므로 대기
-> waitTime = (1 - 0.5) / 5 * 1000 = 100ms
-> 100ms 대기 후 통과Math.min(this.maxTokens, ...)은 오버플로우 방지입니다. 아무도 API를 안 부르는 동안 토큰이 무한히 쌓이지 않게, 최대 용량으로 캡을 씌워 놓은 것입니다.
3.6 전체 흐름 시각화
토큰 버킷 Rate Limiter의 전체 동작을 시각적으로 정리하면 다음과 같습니다:
요청 A ──┐
요청 B ──┼──> acquire() ──> queue에 resolve 추가
요청 C ──┘ │
v
processQueue() 시작
│
┌───────────┘
v
refill() ← 경과 시간만큼 토큰 보충
│
v
tokens >= 1 ?
/ \
Yes No
│ │
v v
tokens -= 1 waitTime 계산
│ │
v v
resolve() setTimeout(waitTime)
│ │
v v
요청 A 통과 refill() 재호출
│
v
tokens -= 1
│
v
resolve()
│
v
요청 B 통과
│
v
... (반복)4. 핵심 포인트 정리
-
외부 API 호출에는 반드시 Rate Limiting이 필요하다. 호출 제한을 넘기면
429 Too Many Requests응답이나 IP 차단을 받게 된다. 프로덕션 환경에서 API 연동 시 Rate Limiter는 선택이 아니라 필수다. -
토큰 버킷 알고리즘은 "순간 버스트 허용 + 평균 속도 제어"를 동시에 달성한다. 고정 딜레이 방식(
setTimeout(200))과 달리, 토큰이 쌓여 있으면 여러 요청을 한꺼번에 보낼 수 있어서 효율적이다. -
게으른 보충(lazy refill) 전략이 핵심이다. 실제 타이머를 설정하지 않고,
refill()이 호출될 때마다 경과 시간을 계산해서 한 번에 보충한다. 이 방식은 불필요한 타이머를 피하면서도 정확한 시간 계산을 보장한다. -
Promise의
resolve를 큐에 저장하는 패턴(Deferred Pattern)으로 비동기 대기 큐를 구현한다.acquire()가 반환하는 Promise는 큐에서 차례가 올 때까지 pending 상태로 유지되며,processQueue()에서resolve()를 호출하는 순간 fulfilled로 전환된다. -
processing플래그로 큐 루프의 단일 실행을 보장한다. 여러acquire()호출이 동시에processQueue()를 트리거하더라도, 실제 큐 처리 루프는 항상 하나만 동작하여 토큰 이중 소비를 방지한다. -
readonly와private접근 제한자로 클래스 내부 상태를 보호한다. 외부에서는acquire()만 호출할 수 있고, 토큰 수를 직접 조작하거나 설정값을 변경할 수 없다. 이것이 캡슐화의 실무적 적용이다.
5. 다음 Step 예고
Step 3: 네이버 광고 API 클라이언트 구축
Rate Limiter가 준비됐으니, 이제 실제 네이버 광고 API와 통신하는 클라이언트를 만들 차례입니다. API 인증 헤더 구성, HMAC-SHA256 서명 생성, 그리고 방금 만든 Rate Limiter를 클라이언트에 통합하여 안전하게 키워드 데이터를 수집하는 방법을 다룹니다.