Understand it step by step (한국어로 이해 → 영어로 말하기)
- 1
먼저 요구사항과 핵심 동작을 정합니다. Rate Limiter는 '이 요청을 허용할까?'라는 질문에 true/false로 답하는 것이 핵심입니다. 그래서 가장 기본이 되는 인터페이스 RateLimiter를 만들고, allow(clientId) 메서드 하나로 시작합니다.
⚖️ Trade-off: 메서드를 boolean을 돌려주는 allow로 할지, 거절 시 예외를 던지게 할지 고민할 수 있습니다.
✅ Fix: 예외는 흐름 제어용으로 비싸고 호출자가 try/catch를 강제당하므로, boolean을 돌려주는 allow로 단순하게 둡니다.
🔧 도구:인터페이스 분리SOLID/SRP
interface RateLimiter { boolean allow(String clientId); }🗣 영어로 말해“I start with a simple RateLimiter interface that returns true or false for each request.”
checking microphone…
- 2
이제 알고리즘이 여러 개라는 점을 반영합니다. 토큰 버킷, 고정 윈도우 같은 서로 다른 알고리즘을 같은 인터페이스 뒤에 숨겨서 교체할 수 있게 합니다. 이것이 Strategy 패턴입니다. 호출하는 쪽은 RateLimiter 타입만 알면 됩니다.
⚖️ Trade-off: 알고리즘마다 if/else로 분기하는 한 클래스로 만들 수도 있습니다.
✅ Fix: if/else는 새 알고리즘이 생길 때마다 그 클래스를 고쳐야 해서 OCP를 깨뜨립니다. 각 알고리즘을 별도 클래스(Strategy)로 분리해 추가에 열려 있고 수정에 닫히게 합니다.
🔧 도구:Strategy 패턴OCP (개방-폐쇄 원칙)
// 두 구현이 같은 인터페이스를 따른다 class TokenBucketLimiter implements RateLimiter { /* ... */ public boolean allow(String clientId) { return true; } } class FixedWindowLimiter implements RateLimiter { /* ... */ public boolean allow(String clientId) { return true; } }🗣 영어로 말해“Each algorithm is a separate strategy behind the same interface, so I can swap them.”
checking microphone…
- 3
클라이언트별로 따로 카운트해야 하므로, 클라이언트마다 자기 상태를 가진 작은 객체가 필요합니다. 토큰 버킷에서는 Bucket이라는 내부 클래스를 만들어 남은 토큰 수(tokens)와 마지막 리필 시각(lastRefillMs)을 담습니다.
✅ Fix: Bucket을 private static 중첩 클래스로 두어 TokenBucketLimiter 안에서만 쓰는 상태 전용 객체임을 분명히 합니다.
🔧 도구:composition-over-inheritance캡슐화
private static final class Bucket { double tokens; long lastRefillMs = System.currentTimeMillis(); Bucket(long capacity) { this.tokens = capacity; } }🗣 영어로 말해“Each client gets its own small Bucket object holding tokens and the last refill time.”
checking microphone…
- 4
클라이언트와 그의 상태를 연결합니다. clientId를 키로, Bucket을 값으로 갖는 맵을 둡니다. 새 클라이언트가 처음 오면 그때 버킷을 만들어줍니다(lazy 생성). computeIfAbsent로 '없으면 만들기'를 한 줄로 처리합니다.
⚖️ Trade-off: 일반 HashMap을 쓸지 ConcurrentHashMap을 쓸지 정해야 합니다.
✅ Fix: 여러 스레드가 동시에 요청하므로 맵 자체가 스레드 안전해야 합니다. ConcurrentHashMap을 써서 맵에 대한 동시 접근을 안전하게 만듭니다.
🔧 도구:ConcurrentHashMaplazy initialization
private final Map<String, Bucket> buckets = new ConcurrentHashMap<>(); Bucket b = buckets.computeIfAbsent(clientId, k -> new Bucket(capacity));
🗣 영어로 말해“A concurrent map links each client id to its bucket, created lazily on first request.”
checking microphone…
- 5
이제 핵심 동작인 토큰 버킷 알고리즘을 채웁니다. 요청이 올 때마다 지난 시간만큼 토큰을 채우고(refill), 토큰이 1개 이상 있으면 하나 쓰고 허용, 없으면 거절합니다. 이렇게 하면 capacity까지는 한꺼번에 몰려도(burst) 허용됩니다.
⚖️ Trade-off: 토큰을 백그라운드 타이머로 주기적으로 채울지, 요청이 올 때 계산할지 선택해야 합니다.
✅ Fix: 타이머 스레드는 복잡하고 자원을 씁니다. 요청 시점에 경과 시간 × 리필 속도로 토큰을 계산하는 lazy refill이 더 단순하고 정확합니다.
🔧 도구:Token Bucket 알고리즘lazy refill
long now = System.currentTimeMillis(); double add = (now - b.lastRefillMs) * refillPerMs; if (add > 0) { b.tokens = Math.min(capacity, b.tokens + add); b.lastRefillMs = now; } if (b.tokens >= 1.0) { b.tokens -= 1.0; return true; } return false;🗣 영어로 말해“On each request I refill tokens by elapsed time, then spend one if available.”
checking microphone…
- 6
동시성 문제를 해결합니다. 한 클라이언트의 버킷을 두 스레드가 동시에 읽고 쓰면 토큰을 중복으로 차감할 수 있습니다(race condition). 그래서 그 버킷 객체 단위로만 synchronized 락을 겁니다. 클라이언트마다 락이 달라서 서로 다른 클라이언트는 막히지 않습니다.
⚖️ Trade-off: 메서드 전체에 synchronized를 걸면 모든 클라이언트가 하나의 락을 두고 줄을 섭니다.
✅ Fix: 전역 락은 처리량을 떨어뜨립니다. 버킷 객체별로 잠그는 fine-grained locking으로 클라이언트 간 병렬성을 살립니다.
🔧 도구:fine-grained locking동시성 (race condition 방지)
synchronized (b) { // refill + 토큰 차감을 한 번에 (원자적) long now = System.currentTimeMillis(); b.refill(now, capacity, refillPerMs); if (b.tokens >= 1.0) { b.tokens -= 1.0; return true; } return false; }🗣 영어로 말해“I lock per bucket, not globally, so different clients never block each other.”
checking microphone…
- 7
확장 지점을 보여줍니다. 두 번째 알고리즘인 FixedWindowLimiter를 같은 RateLimiter 인터페이스로 추가합니다. 현재 시각을 windowMs로 나눠 어느 윈도우인지 구하고, 윈도우가 바뀌면 카운트를 0으로 리셋합니다. 기존 코드를 전혀 안 고치고 새 전략만 추가하면 됩니다.
⚖️ Trade-off: 고정 윈도우는 윈도우 경계에서 한도의 거의 2배 요청이 몰릴 수 있는 약점이 있습니다.
✅ Fix: 더 정밀하게 하려면 sliding window나 token bucket을 고르면 됩니다. 인터페이스가 같으니 호출부 변경 없이 전략만 바꿔 끼웁니다.
🔧 도구:Fixed Window 알고리즘Strategy 확장 (OCP)
public boolean allow(String clientId) { Window w = windows.computeIfAbsent(clientId, k -> new Window()); synchronized (w) { long bucket = System.currentTimeMillis() / windowMs; if (bucket != w.bucket) { w.bucket = bucket; w.count = 0; } if (w.count < limit) { w.count++; return true; } return false; } }🗣 영어로 말해“Adding a fixed-window limiter needs no change to existing code, just a new strategy.”
checking microphone…
7단계 영어를 다 말하면 → 이 메커니즘 전체를 영어로 설명할 수 있게 된다.
Full Java (javac-verified)
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
// Strategy: each algorithm implements this contract.
interface RateLimiter {
boolean allow(String clientId);
}
// Token bucket: refills tokens over time, allows burst up to capacity.
class TokenBucketLimiter implements RateLimiter {
private final long capacity;
private final double refillPerMs;
private final Map<String, Bucket> buckets = new ConcurrentHashMap<>();
TokenBucketLimiter(long capacity, double refillPerSecond) {
this.capacity = capacity;
this.refillPerMs = refillPerSecond / 1000.0;
}
@Override
public boolean allow(String clientId) {
Bucket b = buckets.computeIfAbsent(clientId, k -> new Bucket(capacity));
synchronized (b) {
long now = System.currentTimeMillis();
b.refill(now, capacity, refillPerMs);
if (b.tokens >= 1.0) {
b.tokens -= 1.0;
return true;
}
return false;
}
}
private static final class Bucket {
double tokens;
long lastRefillMs = System.currentTimeMillis();
Bucket(long capacity) { this.tokens = capacity; }
void refill(long now, long capacity, double refillPerMs) {
double add = (now - lastRefillMs) * refillPerMs;
if (add > 0) {
tokens = Math.min(capacity, tokens + add);
lastRefillMs = now;
}
}
}
}
// Fixed window: counts requests per discrete time window.
class FixedWindowLimiter implements RateLimiter {
private final long limit;
private final long windowMs;
private final Map<String, Window> windows = new ConcurrentHashMap<>();
FixedWindowLimiter(long limit, long windowMs) {
this.limit = limit;
this.windowMs = windowMs;
}
@Override
public boolean allow(String clientId) {
Window w = windows.computeIfAbsent(clientId, k -> new Window());
synchronized (w) {
long bucket = System.currentTimeMillis() / windowMs;
if (bucket != w.bucket) {
w.bucket = bucket;
w.count = 0;
}
if (w.count < limit) {
w.count++;
return true;
}
return false;
}
}
private static final class Window {
long bucket = -1;
long count = 0;
}
}
public class Solution {
public static void main(String[] args) {
RateLimiter limiter = new TokenBucketLimiter(3, 1.0);
for (int i = 0; i < 5; i++) {
System.out.println("req " + i + " -> " + limiter.allow("userA"));
}
RateLimiter fixed = new FixedWindowLimiter(2, 1000);
for (int i = 0; i < 4; i++) {
System.out.println("fixed " + i + " -> " + fixed.allow("userB"));
}
}
}