Post

System Design. Part04. 처리율 제한 장치의 설계

System Design. Part04. 처리율 제한 장치의 설계

Part04. 처리율 제한 장치의 설계

처리율 제한 장치란?

  • API 서버, 웹 서비스 등에서 지나치게 많은 요청이 들어오는 것을 방지하기 위해 일정 시간 동안 허용하는 요청 수를 제한하는 기능이다.

처리율 제한 방식 3가지 비교

구분🧍 클라이언트서버미들웨어(API Gateway 등)
요청 처리 시점요청 차단서버 내부에서 차단서버 도달 전 차단
장점- 서버 부하 감소
- 외부 API 요금 방지
- 사용자/요금제별 세밀 제어- 과부하 효과적으로 차단
- 인스턴스 간 일관성 유지
단점- 우회 가능 (신뢰 불가)
- 보안 취약
- 서버 리소스 소비 후 거부
- 분산 환경에선 상태 공유 필요
- 구성 복잡성 및 운영 비용 증가
사용 예- 버튼 연타 방지
- 외부 API 호출 제한
- 토큰당 요청 수 제한
- 로그인 시도 제한
- 전체 시스템 트래픽 제한
- IP별 요청 수 제한

처리율 제한 알고리즘

1. 토큰 버킷 알고리즘

원리
  • 토큰은 정해진 속도로 버킷에 지속적으로 추가됨.
  • 요청이 들어오면 버킷에서 토큰을 하나씩 소비함.
  • 토큰이 없으면 요청을 거부하거나 큐에 넣어 대기함.
핵심 요소
  • capacity (버킷 크기): 최대 보유 가능한 토큰 개수 (burst 허용량 결정)
  • refill rate (보충 속도): 단위 시간 당 토큰 추가 속도 (지속 가능한 처리량)
📍 예시
  • capacity: 10개, refill rate: 초당 1개
    • 순간적으로 최대 10개의 요청 처리 가능, 그 후 초당 1개씩 처리 가능
✅ 장점
  • 순간적인 트래픽 증가 허용 (요청이 몰리면 그 버킷에 남아있는 토큰만큼 한꺼번에 소비할 수 있으므로)
  • 버킷 크기(capacity)와 현재 토큰 수(tokens), 마지막 보충 시점(timestamp) 정도만 저장하면 되기 때문에 효율적인 메모리 사용
⚠️ 단점
  • 두개의 인자 (버킷 크기, 토큰 공급률)을 튜닝하는게 까다로움
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
31
32
33
34
class TokenBucketRateLimiter(
    private val capacity: Int, // 버킷이 최대 보유할 수 있는 토큰 수
    private val refillRatePerSecond: Double // 초당 버킷에 보충할 토큰 수
) {
    private var tokens: Double = capacity.toDouble()  / 현재 남아있는 토큰 
    private var lastRefillTimestamp: Long = System.currentTimeMillis() // 마지막 보충 시점
    private val lock = ReentrantLock()

    /**
     * 요청 허용 여부 판단
     */
    fun allowRequest(): Boolean {
        lock.withLock {
            refillTokens() //요청이 들어온 시점에 토큰을 최신 상태로 갱신
            return if (tokens >= 1) {
                tokens -= 1
                true
            } else {
                false
            }
        }
    }

    /**
     * 경과 시간만큼 토큰 보충
     */
    private fun refillTokens() {
        val now = System.currentTimeMillis()
        val elapsedMillis = now - lastRefillTimestamp // 경과 시간 계산
        val tokensToAdd = elapsedMillis / 1000.0 * refillRatePerSecond // 경과 시간 * 시간 당 보충할 토큰 수
        tokens = min(capacity.toDouble(), tokens + tokensToAdd) // 기존 tokens 에 tokensToAdd 를 더하되 capacity를 넘지 않도록 min으로 제한
        lastRefillTimestamp = now
    }
}

2. 누출 버킷

원리
  • 요청이 오면 버킷(queue)에 들어가고, 처리 시스템은 고정된 속도로 처리함.
  • 요청 처리율이 고정되어 있어 버킷이 가득 차면 추가 요청은 즉시 거부됨.
핵심 요소
  • queue size (버킷 크기): 최대 보관 가능한 요청 수
  • leak rate (누출 속도): 요청 처리 속도 (고정됨)
📍 예시
  • queue size: 10, leak rate: 초당 1개
    • 초당 1개씩 요청 처리, 요청이 초당 2개씩 오면 점차 차서 결국 차단됨
✅ 장점
  • 큐의 크기가 제한되어 있어 효율적인 메모리 사용
  • 고정된 처리율을 갖고 있어 안정적인 처리율로 서버 보호에 탁월
⚠️ 단점
  • 순간적인 트래픽 허용 어려움
  • queue에 적체될 경우 latency(지연 시간) 증가 가능
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
31
32
33
34
35
36
37
38
39
40
/**
 * 요청을 큐에 저장하고 고정 속도로 처리 (누출)
 * 큐가 가득 차면 추가 요청은 거절
 */
class LeakyBucketRateLimiter(
    private val queueSize: Int, // 큐(버킷)가 보관할 최대 요청 수
    leakRatePerSecond: Int, // 초당 처리(누출)할 요청 수
) {
    private val queue: Queue<Long> = LinkedList()
    private val leakIntervalMillis = 1000L / leakRatePerSecond

    init {
        fixedRateTimer(period = leakIntervalMillis, daemon = true) {
            processOne()
        }
    }

    /**
     * 요청 추가 시도
     * @param requestId 요청 식별자
     */
    @Synchronized
    fun allowRequest(requestId: Long): Boolean {
        return if (queue.size < queueSize) {
            queue.add(requestId)
            true
        } else {
            false
        }
    }

    /** 하나의 요청을 큐에서 꺼내 처리 */
    @Synchronized
    private fun processOne() {
        queue.poll()?.let { id ->
            println("Processing request: $id")
        }
    }
}

3. 고정 윈도 카운터

원리
  • 일정한 시간 간격의 윈도(예: 매 1분)를 정해두고, 그 안에서 요청 수를 세서 제한함.
  • 윈도가 끝나면 카운트 리셋
핵심 요소
  • window size: 윈도의 시간 간격
  • threshold: 윈도 내 최대 허용 요청 수
📍 예시
  • window size: 1분, request limit: 100
    • → 매 분당 100개의 요청만 허용. 윈도가 끝나면 카운트 0으로 초기화
✅ 장점
  • 구현 가장 간단하고 메모리 소모 최소
  • 빠르고 간단한 제어
⚠️ 단점
  • 카운터 초기화 시점이 고정된 구간 경계에만 일어나기때문에 윈도 경계 문제 발생 (e.g. 59초~60초 사이 많은 요청이 몰리면, 최대 2배 요청 허용 가능)
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
31
32
33
/**
 * 고정된 시간 윈도우마다 카운트를 초기화하고 제한
 */
class FixedWindowRateLimiter(
    private val windowSizeMillis: Long, // 윈도우 크기 (ms)
    private val maxRequests: Int, // 윈도우 내 허용 최대 요청 수
) {
    // 현재 윈도우 시작 시각 (초기에는 인스턴스 생성 시점)
    private var windowStart: Long = System.currentTimeMillis()
    private var count: Int = 0

    /**
     * 요청 허용 여부 판단
     */
    @Synchronized
    fun allowRequest(): Boolean {
        val now = System.currentTimeMillis()

        // 1 현재 시각이 윈도우 시작 시각(windowStart)에서 windowSizeMillis만큼 지났는지 확인
        if (now - windowStart >= windowSizeMillis) {
            // 윈도우가 만료됐으므로 새로운 윈도우 시작 시각을 현재 시각으로 갱신
            // count를 0으로 초기화
            windowStart = now
            count = 0
        }
        return if (count < maxRequests) {
            count++
            true
        } else {
            false
        }
    }
}

4. 이동 윈도 로그(Sliding Window Log)

원리
  • 요청 발생 시 타임스탬프를 모두 로그에 기록함.
  • 요청 제한 체크 시, 현재 시간에서 윈도 크기만큼 과거의 요청 로그만 확인하여 판단.
핵심 요소
  • window size: 판단에 쓰일 시간 윈도 크기 (e.g. 최근 1분)
  • log storage: 모든 요청의 타임스탬프 저장
📍 예시
  • window size: 1분, request limit: 100
    • → 요청이 들어올 때마다 최근 1분 로그를 체크해서, 100건 초과면 차단
✅ 장점
  • 정밀도 가장 높음
⚠️ 단점
  • 요청 수 많을 시 메모리 및 처리 비용 증가 (로그 관리 부담)
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
31
32
33
34
35
/**
 * 모든 요청 타임스탬프를 기록하고, 윈도우 내 로그 수로 제한
 */
class SlidingWindowLogRateLimiter(
    private val windowSizeMillis: Long, // 윈도우 크기
    private val maxRequests: Int, // 윈도우 내 허용할 최대 요청 수
) {
    private val timestamps: Queue<Long> = LinkedList()  // 요청이 들어온 시각들을 저장하는 큐
    private val lock = ReentrantLock()

    fun allowRequest(): Boolean {
        lock.withLock {
            val now = System.currentTimeMillis()

            // 윈도우 바깥(= now - windowSizeMillis 이전) 요청들 제거
            while (timestamps.isNotEmpty() && now - timestamps.peek() > windowSizeMillis) {
                timestamps.poll()
            }
            
            // 처리 방식에는 2가지 방식이 있는 것 같다.

            // 1번. 항상 기록 (카운트 포함 → 처리율 감소 (보수적))
            timestamps.add(now)
            return timestamps.size <= maxRequests

            // 2번. 거부된 시도 미포함 → 처리율 유지
            if (timestamps.size < maxRequests) {
                timestamps.add(now)
                return true
            } else {
                return false
            }
        }
    }
}

5. 이동 윈도 카운터(Sliding Window Counter)

원리
  • 전체 윈도를 고정 구간으로 나누지 않고, 현재 윈도와 직전 윈도의 카운트를 가중합산해 지금 기준 지난 시간 만큼의 요청량을 추정
핵심 요소
  • window size (전체 윈도)
  • max request (윈도 내 허용할 최대 요청 수)
📍 예시
  • 이전 윈도(1분 전~0분 전)에 5건, 현재 윈도(지금까지) 3건
  • 새 요청이 현재 윈도의 30% 경과 시점에 도착 → overlapRatio = 1 – 0.3 = 0.7
  • 추정 요청량 = 3 + 5 × 0.7 = 6.5 → 내림하면 6 < 7 → 허용
✅ 장점
  • 고정 윈도보다 정확하고, 로그 방식보다 효율적
⚠️ 단점
  • 약간의 계산 복잡성 존재
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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
class WeightedSlidingWindowRateLimiter(
    private val windowSizeMillis: Long, // 윈도우 크기
    private val maxRequests: Int, // 윈도우 내 허용 최대 요청 수
) {
    // 윈도우 시작 시각(밀리초) → 카운터 맵 (고정 윈도우 방식 사용)
    private val counters = ConcurrentHashMap<Long, AtomicInteger>()
    private val lock = ReentrantLock()

    /**
     * 요청 허용 여부 판단 (이동 윈도 가중치 계산 방식)
     *
     * 1) 현재 윈도우와 바로 이전 윈도우의 카운트 가져오기
     * 2) 이전 윈도우가 남아 있는 비율만큼 가중치 적용
     * 3) 두 값을 합산한 후 한도 비교
     */
    fun allowRequest(): Boolean {
        val now = System.currentTimeMillis()

        // 1) 윈도우 경계 계산
        val currentWindowStart = (now / windowSizeMillis) * windowSizeMillis
        val previousWindowStart = currentWindowStart - windowSizeMillis

        lock.withLock {
           // 현재와 이전 윈도우를 제외한 모든 키를 제거
            counters.keys.removeIf { it < previousWindowStart }
            
            // 2) 현재 및 이전 윈도우 요청 수 조회
            val currentCount = counters[currentWindowStart]?.get() ?: 0
            val previousCount = counters[previousWindowStart]?.get() ?: 0

            // 3) 윈도우 내 경과/잔여 시간 계산
            val elapsedInCurrent = now - currentWindowStart
            val remainingInCurrent = windowSizeMillis - elapsedInCurrent

            // 4) 이전 윈도우의 가중치 비율 (잔여 시간 비율)
            val overlapRatio = remainingInCurrent.toDouble() / windowSizeMillis

            // 5) 가중 합산
            val estimatedRequests = currentCount + (previousCount * overlapRatio)

            // 6) 허용 여부 판단
            return if (estimatedRequests < maxRequests) {
                counters
                    .computeIfAbsent(currentWindowStart) { AtomicInteger(0) }
                    .incrementAndGet()
                true
            } else {
                false
            }
        }
    }
}
This post is licensed under CC BY 4.0 by the author.