C++ Rate Limiter 구현: 토큰 버킷 vs 슬라이딩 윈도우 vs 고정 윈도우, Redis 분산 제한
들어가며: “API가 1초에 10만 요청 쏟아지면 서버가 죽어요”
Rate Limiter가 필요한 이유
REST API 서버를 운영하다 보면 갑작스러운 트래픽 폭주나 악의적인 요청 때문에 서버가 버티지 못하는 상황을 겪습니다. 정상 사용자는 초당 몇 번 정도만 요청하는데, 봇 하나가 초당 수천 번씩 요청하면 CPU, 메모리, DB 연결이 고갈되어 다른 사용자까지 영향을 받습니다. Rate Limiter는 키(IP, 사용자, API 키)마다 일정 시간 동안 허용할 요청 수를 제한해서 서버를 보호하고 자원을 공정하게 나눕니다.
토큰 버킷, 고정 윈도우, 슬라이딩 윈도우 알고리즘을 C++로 구현해 비교하고, 단일 서버용 구현을 여러 서버가 한도를 공유하는 분산 구조로 확장하고, 성능을 볼 때 무엇을 확인해야 하는지 정리합니다.
요구 환경: 스레드 안전 구현에 std::shared_mutex를 쓰므로 C++17 이상(-std=c++17)으로 컴파일합니다.
Rate Limiter가 필요해지는 상황
API DDoS 공격
공격자가 /api/login에 요청을 쏟아붓습니다. 로그인은 비밀번호 해시 검증(bcrypt, argon2 등) 때문에 요청 하나가 비싸므로, 적은 수의 요청으로도 CPU가 포화되고 DB 연결 풀이 고갈됩니다. IP당 제한을 걸면 소수의 IP에서 오는 공격은 막을 수 있습니다. 다만 수많은 IP에 분산된 봇넷 공격은 IP당 제한만으로는 막기 어렵고, 계정 단위 제한이나 네트워크 앞단(CDN, WAF)의 방어와 함께 써야 합니다.
외부 API 호출 제한 초과
우리 서비스가 외부 결제 API를 호출하는데, 해당 API는 분당 100회 제한이 있습니다. 제한을 넘기면 429 에러와 함께 일시 차단됩니다. Rate Limiter로 우리 쪽에서 미리 제한하면 429를 피하며, 큐에 쌓아 순차 처리할 수 있습니다.
사용자별 요청 할당량
프리미엄 사용자는 분당 1000회, 무료 사용자는 분당 10회로 차등 적용해야 합니다. 사용자 ID를 키로 Rate Limiter를 적용하면 등급에 따라 다른 한도를 둘 수 있습니다.
급격한 트래픽 스파이크
평소 초당 100 요청이 오다가 마케팅 이벤트로 갑자기 요청이 몰립니다. 토큰 버킷은 쌓아 둔 토큰만큼 짧은 버스트는 받아 주고, 그 뒤로는 충전 속도 이상을 거부해 평균 속도를 제한합니다. 사용자가 페이지를 열 때 API 여러 개를 한꺼번에 호출하는 정상적인 버스트를 거부하지 않는다는 점이 토큰 버킷의 장점입니다.
분산 서버 환경
로드 밸런서 뒤에 서버 10대가 있고 각 서버가 사용자당 초당 10회로 따로 제한하면, 요청이 고르게 분산될 경우 사용자는 전체적으로 초당 100회까지 보낼 수 있습니다. 전역 한도를 지키려면 Redis 같은 공유 저장소를 쓰는 분산 Rate Limiter가 필요합니다.
키별 제한을 거는 Rate Limiter 구조
전체 구조
flowchart TB
subgraph Client[클라이언트]
C1[정상 사용자]
C2[봇/공격자]
end
subgraph Gateway[API 게이트웨이]
RL[Rate Limiter]
RL -->|허용| API
RL -->|429 Too Many Requests| Reject[거부]
end
subgraph Backend[백엔드]
API[API 서버]
DB["(DB)"]
end
C1 -->|요청| Gateway
C2 -->|과다 요청| Gateway
API --> DB
Rate Limiter 동작 흐름
sequenceDiagram
participant C as 클라이언트
participant RL as Rate Limiter
participant API as API 서버
C->>RL: 요청 (key: user_id 또는 IP)
alt 허용 (토큰 있음)
RL->>API: 요청 전달
API-->>C: 200 OK
else 거부 (토큰 없음)
RL-->>C: 429 Too Many Requests
Note over RL: Retry-After 헤더 포함
end
핵심 개념
| 용어 | 설명 |
|---|---|
| key | 제한 대상 식별자 (IP, user_id, API key 등) |
| limit | 시간 창 안에서 허용하는 요청 수 |
| window | 시간 창 (1초, 1분 등) |
| burst | 토큰 버킷에서 한 번에 허용할 수 있는 최대 요청 수(버킷 크기) |
알고리즘 비교: Token Bucket vs Sliding Window vs Fixed Window
알고리즘별 특징
| 알고리즘 | 장점 | 단점 | 적합 용도 |
|---|---|---|---|
| Fixed Window | 구현 단순, 키당 카운터 하나 | 경계에서 최대 2배 통과 | 대략적인 제한 |
| Sliding Window (로그) | 경계 버스트 없음, 정확함 | 키당 최대 limit개 타임스탬프 저장 | 로그인 시도처럼 정확한 상한이 중요할 때 |
| Token Bucket | 버스트 허용량과 평균 속도를 따로 조절 | 두 파라미터를 함께 정해야 함 | 일반 API, 네트워크 트래픽 |
슬라이딩 윈도우에는 여기서 구현하는 로그 방식 외에, 이전 윈도우 카운트에 겹치는 비율을 곱해 더하는 근사 방식(sliding window counter)도 있습니다. 근사 방식은 키당 카운터 두 개만 있으면 되어 대규모 서비스에서 많이 씁니다.
Fixed Window 한계 (경계 버스트)
gantt
title Fixed Window 경계 버스트
dateFormat X
axisFormat %L
section 윈도우 1 (0~1초)
허용 10회 :a1, 0, 10
section 윈도우 2 (1~2초)
허용 10회 :a2, 10, 10
section 문제
경계 0.9초~1.1초에 20회 연속 허용 가능! :crit, 9, 2
문제: 0.9초에 10회, 1.0초에 또 10회 → 0.1초 동안 20회 허용됩니다.
Sliding Window 로직
flowchart LR
subgraph "1초 슬라이딩 윈도우"
T1[0.2초 요청]
T2[0.5초 요청]
T3[0.8초 요청]
T4[1.1초 요청]
end
T1 --> Check{"최근 1초 요청 수가 10 미만?"}
Check -->|Yes| Allow[허용]
Check -->|No| Deny[거부]
토큰 버킷 구현
토큰 버킷 개념
버킷에는 최대 capacity개의 토큰이 들어가고, 초당 refill_rate개씩 충전됩니다. 요청이 오면 토큰 1개를 소비하고, 토큰이 없으면 거부합니다. 아래 구현은 타이머로 주기적으로 충전하지 않고, 요청이 올 때 마지막 충전 이후 흐른 시간만큼 한꺼번에 계산해 넣습니다. 그래서 키가 많아도 백그라운드 작업이 필요 없습니다.
// token_bucket.hpp
#pragma once
#include <algorithm>
#include <chrono>
#include <cstdint>
namespace ratelimit {
class TokenBucket {
public:
// capacity: 버킷 최대 토큰 수 (버스트 허용량)
// refill_rate: 초당 충전되는 토큰 수
TokenBucket(size_t capacity, double refill_rate)
: tokens_(static_cast<double>(capacity))
, capacity_(capacity)
, refill_rate_(refill_rate)
, last_refill_(std::chrono::steady_clock::now())
{}
// 요청 1회 허용 여부. true면 허용(토큰 1 소비), false면 거부
bool try_acquire() {
refill();
if (tokens_ >= 1.0) {
tokens_ -= 1.0;
return true;
}
return false;
}
// 다음 토큰까지 남은 시간 (나노초). 0이면 즉시 가능
// 마지막 refill 이후 흐른 시간은 반영하지 않으므로 약간 보수적인 값
std::chrono::nanoseconds retry_after() const {
if (tokens_ >= 1.0) return std::chrono::nanoseconds(0);
double needed = 1.0 - tokens_;
return std::chrono::nanoseconds(
static_cast<int64_t>(needed / refill_rate_ * 1e9)
);
}
private:
void refill() {
auto now = std::chrono::steady_clock::now();
auto elapsed = std::chrono::duration<double>(now - last_refill_).count();
last_refill_ = now;
double added = elapsed * refill_rate_;
tokens_ = std::min(static_cast<double>(capacity_), tokens_ + added);
}
double tokens_;
size_t capacity_;
double refill_rate_;
std::chrono::steady_clock::time_point last_refill_;
};
} // namespace ratelimit
위 기본 구현은 단일 스레드용입니다. refill()과 토큰 차감 사이에 다른 스레드가 끼어들면 토큰 하나를 두 요청이 함께 쓸 수 있으므로, 멀티스레드에서는 아래 TokenBucketSafe처럼 락으로 감싸야 합니다.
스레드 안전 토큰 버킷
// token_bucket_safe.hpp
#pragma once
#include "token_bucket.hpp"
#include <mutex>
namespace ratelimit {
class TokenBucketSafe {
public:
TokenBucketSafe(size_t capacity, double refill_rate)
: bucket_(capacity, refill_rate) {}
bool try_acquire() {
std::lock_guard lock(mutex_);
return bucket_.try_acquire();
}
std::chrono::nanoseconds retry_after() const {
std::lock_guard lock(mutex_);
return bucket_.retry_after();
}
private:
mutable std::mutex mutex_;
TokenBucket bucket_;
};
} // namespace ratelimit
슬라이딩 윈도우 구현
슬라이딩 윈도우 로직
허용한 요청의 시각을 큐에 저장해 두고, 요청이 올 때마다 현재 시각에서 윈도우 크기보다 오래된 시각을 앞에서부터 버립니다. 남은 개수가 limit 미만이면 허용하고 현재 시각을 추가합니다.
// sliding_window.hpp
#pragma once
#include <chrono>
#include <deque>
#include <mutex>
namespace ratelimit {
class SlidingWindowLimiter {
public:
// limit: 윈도우 내 허용 요청 수
// window_ms: 윈도우 크기 (밀리초)
SlidingWindowLimiter(size_t limit, std::chrono::milliseconds window_ms)
: limit_(limit)
, window_ms_(window_ms)
{}
bool try_acquire() {
auto now = std::chrono::steady_clock::now();
std::lock_guard lock(mutex_);
// 윈도우 경계보다 오래된 타임스탬프 제거
auto cutoff = now - window_ms_;
while (!timestamps_.empty() && timestamps_.front() < cutoff) {
timestamps_.pop_front();
}
if (timestamps_.size() < limit_) {
timestamps_.push_back(now);
return true;
}
return false;
}
// 다음 요청 가능 시점
std::chrono::steady_clock::time_point retry_at() const {
std::lock_guard lock(mutex_);
if (timestamps_.size() < limit_) {
return std::chrono::steady_clock::now();
}
return timestamps_.front() + window_ms_;
}
private:
size_t limit_;
std::chrono::milliseconds window_ms_;
mutable std::mutex mutex_;
std::deque<std::chrono::steady_clock::time_point> timestamps_;
};
} // namespace ratelimit
Fixed Window의 경계 버스트가 없다는 것이 장점입니다. 오래된 시각은 앞에서부터 꺼내므로 요청당 비용은 분할 상환 O(1)이지만, 키마다 최대 limit개의 타임스탬프(각 8바이트)를 저장해야 합니다. 한도가 분당 1만 회처럼 크고 키가 많으면 메모리가 부담스러워지므로, 그럴 때는 근사 방식인 sliding window counter나 토큰 버킷을 씁니다.
Fixed Window (참고용)
// fixed_window.hpp
#pragma once
#include <chrono>
#include <atomic>
#include <mutex>
namespace ratelimit {
class FixedWindowLimiter {
public:
FixedWindowLimiter(size_t limit, std::chrono::seconds window)
: limit_(limit)
, window_(window)
, count_(0)
, window_start_(std::chrono::steady_clock::now())
{}
bool try_acquire() {
auto now = std::chrono::steady_clock::now();
std::lock_guard lock(mutex_);
// 새 윈도우 시작?
if (now - window_start_ >= window_) {
count_ = 0;
window_start_ = now;
}
if (count_ < limit_) {
++count_;
return true;
}
return false;
}
private:
size_t limit_;
std::chrono::seconds window_;
size_t count_;
std::chrono::steady_clock::time_point window_start_;
mutable std::mutex mutex_;
};
} // namespace ratelimit
키별 Limiter와 HTTP 미들웨어 통합 예제
키별 Rate Limiter (IP·user_id)
// rate_limiter.hpp
#pragma once
#include "sliding_window.hpp"
#include <unordered_map>
#include <shared_mutex>
#include <memory>
#include <string>
namespace ratelimit {
class KeyedRateLimiter {
public:
using Clock = std::chrono::steady_clock;
KeyedRateLimiter(size_t limit, std::chrono::milliseconds window_ms)
: limit_(limit)
, window_ms_(window_ms)
{}
// key(IP, user_id 등)별로 제한 적용
bool allow(const std::string& key) {
{
// 이미 있는 키: 읽기 락을 잡은 채로 사용
// (락을 풀면 다른 스레드의 삽입이 rehash를 일으켜 반복자가 무효화될 수 있음)
std::shared_lock read_lock(mutex_);
auto it = limiters_.find(key);
if (it != limiters_.end()) {
return it->second->try_acquire();
}
}
// 새 키: 쓰기 락을 잡고 다시 확인 후 삽입
std::unique_lock write_lock(mutex_);
auto [it, inserted] = limiters_.try_emplace(key, nullptr);
if (inserted) {
it->second = std::make_unique<SlidingWindowLimiter>(limit_, window_ms_);
}
return it->second->try_acquire();
}
private:
size_t limit_;
std::chrono::milliseconds window_ms_;
mutable std::shared_mutex mutex_;
std::unordered_map<std::string, std::unique_ptr<SlidingWindowLimiter>> limiters_;
};
} // namespace ratelimit
처음 이 클래스를 작성할 때 흔히 저지르는 실수는 읽기 락 안에서 find로 반복자만 얻고 락을 푼 뒤 그 반복자를 쓰는 것입니다. 그 사이 다른 스레드가 새 키를 삽입하면서 unordered_map이 rehash되면 반복자는 무효화되고, 이 버그는 키가 급격히 늘어나는 순간(즉 공격을 받는 순간)에만 크래시로 나타납니다. 위 코드처럼 limiter를 쓰는 동안 읽기 락을 유지하면 삽입과 정리가 그동안 기다리게 됩니다. 각 limiter는 내부 mutex가 있으므로 여러 스레드가 읽기 락을 함께 잡고 서로 다른 키를 동시에 처리할 수 있습니다.
HTTP 미들웨어 통합 예시
// http_middleware.cpp
#include "rate_limiter.hpp"
#include <iostream>
#include <string>
// 의사 코드: HTTP 요청 처리
struct HttpRequest {
std::string client_ip;
std::string user_id;
std::string path;
};
struct HttpResponse {
int status_code;
std::string body;
std::string retry_after;
};
ratelimit::KeyedRateLimiter limiter(100, std::chrono::seconds(1)); // 초당 100회/IP
HttpResponse handle_request(const HttpRequest& req) {
std::string key = req.client_ip; // 또는 req.user_id (로그인 시)
if (!limiter.allow(key)) {
return HttpResponse{
429,
R"({"error":"Too Many Requests"})",
"1" // Retry-After: 1초
};
}
// 실제 비즈니스 로직
return HttpResponse{200, "OK", ""};
}
완전한 실행 예제 (main)
// main.cpp
#include "token_bucket.hpp"
#include "sliding_window.hpp"
#include "rate_limiter.hpp"
#include <iostream>
#include <chrono>
int main() {
// 예제 1: 토큰 버킷 - 초당 10회, 버스트 20
ratelimit::TokenBucketSafe tb(20, 10.0);
std::cout << "=== Token Bucket (capacity=20, rate=10/s) ===\n";
for (int i = 0; i < 25; ++i) {
bool ok = tb.try_acquire();
std::cout << "Request " << (i + 1) << ": " << (ok ? "OK" : "DENIED") << "\n";
}
std::cout << "\n=== Sliding Window (10/s, 1s window) ===\n";
ratelimit::SlidingWindowLimiter sw(10, std::chrono::seconds(1));
for (int i = 0; i < 15; ++i) {
bool ok = sw.try_acquire();
std::cout << "Request " << (i + 1) << ": " << (ok ? "OK" : "DENIED") << "\n";
}
std::cout << "\n=== Keyed Limiter (IP별 5/s) ===\n";
ratelimit::KeyedRateLimiter keyed(5, std::chrono::seconds(1));
for (int i = 0; i < 8; ++i) {
bool ok1 = keyed.allow("192.168.1.1");
bool ok2 = keyed.allow("192.168.1.2");
std::cout << "IP1: " << (ok1 ? "OK" : "DENIED")
<< ", IP2: " << (ok2 ? "OK" : "DENIED") << "\n";
}
return 0;
}
CMakeLists.txt
cmake_minimum_required(VERSION 3.14)
project(rate_limiter LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
add_executable(rate_limiter_demo
main.cpp
)
target_include_directories(rate_limiter_demo PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
경계 버스트, 키별 메모리 누수, 시계 스킵: 에러 해결
경계에서 버스트 허용 (Fixed Window)
증상: 초당 10회 제한인데 0.99초와 1.01초에 각각 10회씩 허용되어 0.02초 동안 20회가 통과합니다. 원인: Fixed Window는 윈도우 경계에서 카운트를 0으로 되돌리기 때문입니다. 해결법:
// ❌ Fixed Window 사용
FixedWindowLimiter limiter(10, std::chrono::seconds(1));
// ✅ Sliding Window 또는 Token Bucket 사용
SlidingWindowLimiter limiter(10, std::chrono::seconds(1));
// 또는
TokenBucketSafe limiter(10, 10.0); // capacity=10, rate=10/s
메모리 누수 (키별 Limiter)
증상: 장시간 운영하면 KeyedRateLimiter의 limiters_ 맵이 계속 커집니다.
원인: IP나 user_id마다 limiter를 만들기만 하고 삭제하지 않기 때문입니다. 공격자가 IP를 바꿔 가며 요청하면 키가 빠르게 늘어나, Rate Limiter 자체가 메모리 고갈의 원인이 됩니다.
해결법: 엔트리마다 마지막 접근 시각을 기록하고, 백그라운드 스레드에서 주기적으로 오래된 키를 지웁니다. 마지막 접근 시각은 allow()가 읽기 락만 잡은 상태에서 갱신하므로 원자 변수로 둡니다.
// KeyedRateLimiter의 맵 값을 limiter 대신 Entry로 바꾼 형태
struct Entry {
Entry(size_t limit, std::chrono::milliseconds w) : limiter(limit, w) {}
SlidingWindowLimiter limiter;
std::atomic<Clock::rep> last_access{0}; // time_since_epoch().count()
};
std::unordered_map<std::string, std::unique_ptr<Entry>> limiters_;
// allow()에서 엔트리를 쓸 때마다 갱신
// entry->last_access.store(Clock::now().time_since_epoch().count(),
// std::memory_order_relaxed);
// 백그라운드에서 주기적으로 호출
void cleanup(std::chrono::minutes max_idle) {
std::unique_lock lock(mutex_); // allow()의 shared_lock과 배타적
const auto cutoff = (Clock::now() - max_idle).time_since_epoch().count();
for (auto it = limiters_.begin(); it != limiters_.end(); ) {
if (it->second->last_access.load(std::memory_order_relaxed) < cutoff) {
it = limiters_.erase(it);
} else {
++it;
}
}
}
max_idle은 윈도우 크기보다 충분히 길어야 합니다. 윈도우보다 짧으면 아직 제한 중인 키를 지워 버려, 공격자가 잠깐 쉬었다가 다시 한도를 새로 받는 구멍이 생깁니다. 정리 중에는 쓰기 락 때문에 모든 요청이 멈추므로, 키가 수백만 개라면 뒤에서 소개할 샤딩과 함께 써서 한 번에 멈추는 범위를 줄입니다.
시계 스킵 (가상 머신·NTP)
증상: 시계가 앞으로 크게 점프하면 토큰이 한꺼번에 가득 충전되고, 뒤로 돌아가면 경과 시간이 음수가 되어 토큰이 줄어들거나 윈도우 계산이 틀어집니다.
원인: steady_clock 대신 system_clock을 쓰면 NTP 보정, 수동 시간 변경, 가상 머신 일시 정지 후 재개 등으로 벽시계 시간이 바뀔 때 그대로 영향을 받습니다.
해결법:
// ❌ system_clock - NTP 보정 시 변경됨
std::chrono::system_clock::now();
// ✅ steady_clock - monotonic, 스킵 없음
std::chrono::steady_clock::now();
이 글의 예제는 모두 steady_clock을 사용합니다. 단, 분산 환경에서 여러 서버의 시각을 Redis에 저장해 비교할 때는 서버마다 steady_clock의 기준점이 달라 쓸 수 없습니다. 이때는 Redis 서버의 TIME 명령으로 시각을 한 곳에서 얻는 편이 안전합니다.
분산 환경에서 제한 초과
증상: 서버 10대가 각각 초당 100회로 제한하면 사용자는 전체적으로 초당 1000회까지 보낼 수 있습니다. 원인: 각 서버가 자기 로컬 상태만 보고 판단하기 때문입니다. 해결법:
// ✅ Redis 기반 분산 Rate Limiter (의사 코드)
bool allow_distributed(const std::string& key) {
// Redis INCR + EXPIRE 또는 Lua 스크립트로 원자적 연산
// 1. 현재 카운트 조회
// 2. limit 미만이면 INCR, TTL 설정 후 허용
// 3. limit 이상이면 거부
// Redis: INCR rate:{key}, EXPIRE rate:{key} 1
return redis_limiter.allow(key, 100, std::chrono::seconds(1));
}
실제 구현은 아래 “Redis 분산 Rate Limiter” 절의 Lua 스크립트를 참고하세요.
락 경합으로 성능 저하
증상: 요청이 많은 서버에서 Rate Limiter의 락 대기가 프로파일에 보입니다.
원인: 모든 키가 limiters_ 맵 하나와 그 shared_mutex 하나를 공유합니다. 기존 키는 읽기 락이라 병렬로 처리되지만, 새 키 삽입이나 정리 작업이 쓰기 락을 잡는 동안 모든 요청이 멈춥니다. 읽기 락도 내부적으로 공유 카운터를 원자적으로 갱신하므로 코어가 많으면 캐시 라인 경합이 생깁니다.
해결법:
// ✅ 키별로 shard 분리해 락 경합 감소
class ShardedKeyedLimiter {
static constexpr size_t SHARDS = 256;
std::vector<std::unique_ptr<KeyedRateLimiter>> limiters_;
std::hash<std::string> hasher_;
public:
ShardedKeyedLimiter(size_t limit, std::chrono::milliseconds window) {
limiters_.reserve(SHARDS);
for (size_t i = 0; i < SHARDS; ++i)
limiters_.push_back(std::make_unique<KeyedRateLimiter>(limit, window));
}
bool allow(const std::string& key) {
size_t shard = hasher_(key) % SHARDS;
return limiters_[shard]->allow(key);
}
};
429 응답 시 Retry-After 누락
증상: 클라이언트가 언제 재시도해야 할지 몰라 곧바로 다시 요청하고, 거부된 요청이 오히려 부하를 늘립니다.
원인: 429만 반환하고 Retry-After 헤더를 보내지 않았기 때문입니다.
해결법:
// ✅ Retry-After 헤더 포함 (limiter는 retry_after()를 제공하는 TokenBucketSafe)
if (!limiter.try_acquire()) {
auto retry_ns = limiter.retry_after();
// 내림하면 0초가 되어 즉시 재시도를 유도하므로 올림 후 최소 1초
auto retry_sec = std::chrono::ceil<std::chrono::seconds>(retry_ns).count();
response.headers["Retry-After"] = std::to_string(std::max<long long>(1, retry_sec));
return 429;
}
성능을 볼 때 확인할 것
세 알고리즘 모두 allow() 한 번의 계산 자체는 수십 나노초 수준으로 가볍습니다. 실제 서버에서 차이를 만드는 것은 알고리즘보다 다음 요소들입니다.
- 키당 메모리: 고정 윈도우와 토큰 버킷은 키당 숫자 몇 개면 되지만, 슬라이딩 윈도우 로그는 한도만큼 타임스탬프를 저장합니다. 키 수 × 한도가 메모리 사용량을 결정합니다.
- 락 범위: 스레드가 많을수록 전역 맵의 락이 병목이 됩니다. 샤딩으로 맵을 나누면 새 키 삽입과 정리가 다른 샤드의 요청을 막지 않습니다.
- 맵 조회 비용: 키가 문자열이면 요청마다 해시 계산과 비교가 들어갑니다. IP라면 문자열 대신 32/128비트 정수로 키를 만들면 더 가볍습니다.
- 분산 제한의 왕복 지연: Redis를 쓰면 로컬 계산보다 네트워크 왕복이 훨씬 비쌉니다. 요청마다 Redis를 호출할지, 로컬에서 일부 할당량을 미리 받아 쓸지(배치 할당) 결정해야 합니다.
직접 측정한다면 std::chrono로 단일 스레드 처리량을 재기보다, 실제 스레드 수와 키 분포(소수의 키에 몰리는지, 많은 키에 퍼지는지)를 흉내 낸 부하에서 p99 지연을 보는 편이 의미 있습니다.
계층적 제한·Redis 분산 Limiter·Graceful Degradation
계층적 제한 (전역 + 사용자별)
// 전역: 초당 10만 요청
// 사용자별: 초당 100 요청
class TieredRateLimiter {
TokenBucketSafe global_;
KeyedRateLimiter per_user_;
public:
bool allow(const std::string& user_id) {
if (!global_.try_acquire()) return false;
if (!per_user_.allow(user_id)) return false;
return true;
}
};
엔드포인트별 다른 제한
// /api/login: 10/분 (무차별 대입 방지)
// /api/search: 100/초
// /api/upload: 5/분
std::unordered_map<std::string, std::unique_ptr<KeyedRateLimiter>> endpoint_limiters_;
bool allow(const std::string& path, const std::string& key) {
auto it = endpoint_limiters_.find(path);
if (it == endpoint_limiters_.end()) return true;
return it->second->allow(key);
}
Redis 분산 Rate Limiter (Lua 스크립트)
-- Redis Lua: 고정 윈도우 카운터 (INCR와 만료 설정을 원자적으로)
-- KEYS[1]: rate:{key}
-- ARGV[1]: window_ms
-- ARGV[2]: limit
local current = redis.call('INCR', KEYS[1])
if current == 1 then
redis.call('PEXPIRE', KEYS[1], ARGV[1])
end
if current <= tonumber(ARGV[2]) then
return 1 -- 허용
end
redis.call('DECR', KEYS[1])
return 0 -- 거부
이 스크립트는 고정 윈도우입니다. 스크립트 전체가 Redis에서 원자적으로 실행되므로, 여러 서버가 동시에 호출해도 카운트가 어긋나지 않습니다. 슬라이딩 윈도우 로그를 분산으로 구현하려면 sorted set에 요청 시각을 넣고 ZREMRANGEBYSCORE로 오래된 항목을 지운 뒤 ZCARD로 개수를 세는 방식을 씁니다. 거부할 때 DECR로 되돌리는 것은 카운트가 실제로 허용한 요청 수만 나타내게 하려는 것입니다. 되돌리지 않아도 윈도우가 끝나면 키가 만료되므로 제한 동작 자체는 같습니다.
Redis 분산 Limiter C++ 연동 예시 (hiredis)
// redis_rate_limiter.hpp - hiredis 사용
#include <hiredis/hiredis.h>
#include <string>
#include <chrono>
#include <memory>
class RedisRateLimiter {
public:
RedisRateLimiter(const std::string& host, int port, size_t limit,
std::chrono::seconds window)
: limit_(limit)
, window_sec_(window.count())
{
ctx_ = redisConnect(host.c_str(), port);
if (!ctx_ || ctx_->err) {
throw std::runtime_error("Redis connection failed");
}
}
~RedisRateLimiter() { if (ctx_) redisFree(ctx_); }
// 위 Lua 스크립트를 EVAL로 실행 (운영에서는 SCRIPT LOAD 후 EVALSHA 권장)
bool allow(const std::string& key) {
static const char* kScript =
"local c = redis.call('INCR', KEYS[1]) "
"if c == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end "
"if c <= tonumber(ARGV[2]) then return 1 end "
"redis.call('DECR', KEYS[1]) return 0";
std::string redis_key = "rate:" + key;
std::string window_ms = std::to_string(window_sec_ * 1000);
std::string limit = std::to_string(limit_);
auto* reply = static_cast<redisReply*>(redisCommand(ctx_,
"EVAL %s 1 %s %s %s", kScript, redis_key.c_str(),
window_ms.c_str(), limit.c_str()));
if (!reply) return true; // 연결 오류: 정책에 따라 허용/거부 결정
bool ok = reply->type == REDIS_REPLY_INTEGER && reply->integer == 1;
freeReplyObject(reply);
return ok;
}
private:
redisContext* ctx_;
size_t limit_;
int window_sec_;
};
INCR와 EXPIRE를 따로 보내는 구현이 흔한데, 두 명령 사이에 프로세스가 죽거나 연결이 끊기면 만료 시간이 없는 키가 남습니다. 그 키는 영원히 지워지지 않아 해당 사용자가 계속 차단됩니다. 운영 중에 “특정 사용자만 며칠째 429를 받는다”는 문의가 들어오면 가장 먼저 의심할 부분이고, Lua 스크립트로 묶으면 이 문제가 사라집니다. 또 redisContext 하나는 스레드 안전하지 않으므로, 여러 스레드에서 쓰려면 스레드마다 연결을 두거나 연결 풀을 사용해야 합니다.
Graceful Degradation
서버 전체 용량이 부족할 때 모든 요청을 똑같이 거부하기보다, 우선순위가 낮은 요청(배치 작업, 통계 조회 등)부터 거부하면 핵심 기능을 지킬 수 있습니다. 전역 토큰 버킷의 잔량을 보고, 잔량이 일정 비율 아래로 떨어지면 낮은 우선순위 요청을 먼저 거부하는 방식이 단순합니다. 우선순위는 클라이언트가 보낸 헤더를 그대로 믿지 말고 엔드포인트와 인증 정보로 서버가 정해야 합니다.
모니터링 및 알림
// Prometheus 메트릭 (prometheus-cpp)
// rate_limit_allowed_total{endpoint="..."}
// rate_limit_denied_total{endpoint="..."}
// rate_limit_active_keys
class MonitoredRateLimiter {
KeyedRateLimiter limiter_;
prometheus::Counter& allowed_; // 엔드포인트 라벨이 붙은 카운터
prometheus::Counter& denied_;
public:
bool allow(const std::string& key) {
if (limiter_.allow(key)) {
allowed_.Increment();
return true;
}
denied_.Increment();
return false;
}
};
메트릭 라벨에 IP나 사용자 ID 같은 키를 넣으면 안 됩니다. 라벨 값 하나마다 별도의 시계열이 생기므로, 키가 수십만 개면 Prometheus 서버의 메모리가 먼저 터집니다. 라벨은 엔드포인트나 요금제처럼 값의 종류가 적은 것만 쓰고, 어떤 키가 많이 거부되는지는 로그로 남겨 따로 집계합니다.
Rate Limiter 구현 점검 항목
알고리즘 선택
- Fixed Window: 단순 제한, 경계 버스트 허용 가능
- Sliding Window: API 게이트웨이, 공정한 제한
- Token Bucket: 버스트 제어, 네트워크/스트리밍
구현
-
steady_clock사용 (시계 스킵 방지) - 키별 메모리 정리 (TTL/cleanup)
- 스레드 안전성 (mutex/shared_mutex)
- 429 응답 시
Retry-After헤더
분산 환경
- Redis 등 공유 스토어로 전역 제한
- Lua 스크립트로 원자적 연산
- Redis 장애 시 fallback (로컬 제한 또는 전체 허용)
운영
- 엔드포인트별/사용자 등급별 차등 제한
- 메트릭 수집 (allowed/denied)
- 알림 (denied 비율 급증 시)
자주 묻는 질문 (FAQ)
Q. IP별 Rate Limiter를 운영하다 보면 메모리가 계속 늘어나는 이유는 무엇인가요?
A. 요청이 들어올 때마다 IP나 사용자 키별로 limiter 객체를 맵에 만들고 지우지 않으면, 한 번만 접속한 클라이언트의 상태까지 영원히 남아 메모리가 계속 증가합니다. 특히 IP를 바꿔 가며 들어오는 트래픽에서는 키 수가 빠르게 늘어납니다. 각 엔트리에 마지막 접근 시각을 기록해 일정 시간 사용되지 않은 키를 주기적으로 정리하거나, 크기 상한이 있는 LRU 구조로 관리해야 합니다.
Q. Token Bucket vs Sliding Window, 어떤 걸 써야 하나요?
A. 버스트를 어느 정도 허용하면서 평균 속도를 제한하고 싶다면 토큰 버킷이 맞습니다. 페이지 로딩 시 API를 여러 개 동시에 부르는 일반적인 웹 트래픽이 여기에 해당합니다. 로그인 시도나 비밀번호 재설정 요청처럼 “1분에 5회”라는 상한을 정확히 지켜야 한다면 슬라이딩 윈도우가 맞습니다.
Q. Redis 없이 분산 Rate Limit을 할 수 있나요?
A. 방법은 몇 가지가 있습니다. 로드 밸런서에서 같은 키를 항상 같은 서버로 보내는 해시 라우팅을 쓰면 각 서버의 로컬 제한이 곧 전역 제한이 됩니다. 정확도를 조금 포기할 수 있다면 전체 한도를 서버 수로 나눠 각 서버에 배분하는 방법도 있습니다. etcd나 Consul 같은 합의 기반 저장소는 쓰기마다 합의가 필요해 요청마다 호출하는 카운터로는 적합하지 않습니다. 클라이언트 쪽 제한은 정상 클라이언트의 예의일 뿐, 악의적인 클라이언트는 무시하므로 서버 쪽 제한을 대신할 수 없습니다.
Q. Rate Limit 우회를 어떻게 막나요?
A. IP 기반 제한은 VPN이나 프록시로 우회할 수 있으므로 API 키나 로그인 사용자 ID 기반 제한으로 보완합니다. 또 프록시 뒤에서 X-Forwarded-For 헤더를 그대로 믿으면 공격자가 헤더 값을 바꿔 가며 매번 새 IP인 것처럼 보이게 할 수 있습니다. 신뢰하는 프록시가 붙인 값만 사용하도록 설정해야 합니다. 짧은 시간에 여러 IP에서 같은 계정으로 로그인을 시도하는 패턴은 계정 단위 제한과 추가 인증으로 막습니다.
다음 글: C++ 프로파일러 비교: perf 화염 그래프, gprof, Valgrind Callgrind, VTune, Tracy 이전 글: [C++ 실전 가이드 #50-11] 대용량 파일 업로드