레이트 리미터 패턴
개요
검색 API를 공개했는데 한 클라이언트가 반복문으로 초당 수백 번을 호출한다고 하자. 요청마다 DB를 조회하므로 그 클라이언트 하나가 처리 능력을 거의 다 쓰고, 다른 클라이언트의 요청까지 느려진다. 처리한 뒤에 응답을 버려도 DB 조회는 이미 끝났으므로 부하는 줄지 않는다. 일을 시작하기 전에 "이 클라이언트가 최근에 얼마나 보냈는가"를 보고 들일지 정해야 한다.
레이트 리미터(rate limiter)는 일정 시간 동안 받아들일 요청 수를 제한하고, 한도를 넘은 요청을 처리 전에 거절한다. 한도를 따로 세는 단위를 파티션 키(partition key)라고 하며 API 키, 사용자 ID, IP 주소 같은 값을 쓴다. 이 문서의 예제는 API 키마다 한도를 따로 두어, 한 클라이언트가 몰아 보내도 다른 클라이언트의 몫이 줄지 않게 한다.
키 하나의 상태를 들고 요청마다 허용과 거절을 정하는 것이 리미터다. 예제는 토큰 버킷(token bucket)을 쓴다. 버킷에는 최대 용량만큼 토큰이 들어 있고 요청 하나가 토큰 하나를 쓰며, 토큰은 정해진 주기마다 하나씩 다시 채워진다. 남은 토큰이 없으면 요청을 거절한다. 리미터는 현재 시각을 시계(clock) 객체에서 읽는데, 이 시계를 바깥에서 넣어 주므로 예제와 테스트는 실제로 기다리지 않고 시각을 정할 수 있다.
거절은 조용히 버리지 않고 응답으로 알린다. HTTP에서는 429 Too Many Requests 상태 코드와 언제 다시 보낼지 알려 주는 Retry-After 헤더를 쓴다. 이유 없이 실패한 클라이언트는 곧바로 재시도해 부하를 더 키우기 쉽다.
위의 예는 서버가 자기를 지키는 쓰임이고, 반대 방향으로 클라이언트가 외부 서비스의 한도를 넘지 않도록 자기 송신 속도를 조절하는 쓰임도 있다. Microsoft Azure Architecture Center는 뒤의 것을 Rate Limiting 패턴으로,1 서버가 사용자별 한도를 넘은 요청을 거절하는 것을 Throttling 패턴의 전략 하나로 설명한다.2 두 방향 모두 같은 리미터를 쓰므로 이 문서는 함께 다루고, 예제는 서버 쪽을 보인다.
구조
RateLimitGate는 API 키마다 TokenBucketLimiter를 하나씩 둔다. 버킷은 IClock에서 현재 시각을 읽어 토큰을 채우고, 허용 여부와 다시 시도할 때까지의 시간을 Decision으로 돌려준다.
t=300 ms에 들어온 alice의 요청은 토큰이 없어 실제 처리에 닿기 전에 거절된다. 게이트는 버킷이 계산한 700 ms를 정수 초로 올려 Retry-After: 1로 보낸다.
역사
토큰 버킷은 네트워크 트래픽 제어 규격에 등장한다. 1992년 RFC 1363은 흐름 명세(flow specification)의 필드로 토큰 버킷 속도와 토큰 버킷 크기를 정의한다. 토큰은 정해진 속도로 가상의 버킷에 쌓이고, 호스트는 패킷 크기만큼 토큰을 꺼내야 패킷을 보낼 수 있으며, 모자라면 토큰이 쌓일 때까지 기다린다. 버킷이 가득 차면 새 토큰은 버려진다. 같은 항목은 버킷 크기가 B, 속도가 R일 때 임의의 구간 T 동안 보낸 데이터가 B + (R * T) 바이트를 넘지 않는다고 정한다.3 통합 서비스(Integrated Services) 네트워크 요소의 공통 파라미터를 정한 1997년 RFC 2215는 트래픽 명세 TOKEN_BUCKET_TSPEC을 토큰 버킷 명세에 최대 속도 같은 값을 더한 형태로 정의하며, 토큰 버킷 명세는 토큰 속도 r과 버킷 깊이 b로 이루어진다.4 1999년 RFC 2697의 단일 속도 3색 표시기는 처음에 가득 찬 토큰 버킷 C와 E를 공통 속도 CIR로 채우되 C를 먼저 채우고 C가 가득 차면 E를 채우며, 패킷 크기만큼 토큰이 남았는지에 따라 패킷을 녹색, 노란색, 빨간색으로 표시한다.5
HTTP에는 2012년 RFC 6585가 429 Too Many Requests를 추가했다. 사용자가 주어진 시간에 너무 많은 요청을 보냈다는 뜻이고, 응답에 기다릴 시간을 알리는 Retry-After 헤더를 넣을 수 있다(MAY). 서버가 사용자를 어떻게 식별하고 요청을 어떻게 세는지는 이 RFC가 정하지 않는다.6 현재 HTTP 의미 규격인 RFC 9110에서 Retry-After 값은 HTTP 날짜이거나 0 이상의 정수 초다.7
Stripe는 2017년 엔지니어링 블로그에서 API에 쓰는 리미터 네 종류를 소개하면서, 요청 속도 제한에 토큰 버킷 알고리즘을 쓰고 Redis로 구현한다고 밝혔다.8 2022년 .NET 블로그는 .NET 7에 들어갈 내장 레이트 리미팅으로 System.Threading.RateLimiting의 ConcurrencyLimiter, TokenBucketRateLimiter, FixedWindowRateLimiter, SlidingWindowRateLimiter와 Microsoft.AspNetCore.RateLimiting의 ASP.NET Core 미들웨어를 발표했다.9
왜 사용하는가?
키마다 한도를 두면 한 사용자가 처리 능력을 독점하지 못한다. 넘친 요청은 DB 조회 같은 실제 일을 하기 전에 거절하므로, 거절 비용이 처리 비용보다 싸다. Azure의 Throttling 문서도 거절을 요청 파이프라인의 가능한 한 앞에서 하라고 권한다.10 429와 Retry-After를 받은 클라이언트는 언제 다시 보낼지 추측하지 않아도 된다.
클라이언트 쪽에 두면 외부 서비스의 한도 아래로 보내 거절과 재전송을 줄인다. Azure의 Rate Limiting 문서는 레코드 10,000개를 보내고 거절된 것만 다시 보내는 방식이 끝날 때까지 30,000건을 보내게 되는 예를 든다.11
병목이 동시 실행 수나 CPU라면 요청 수 한도로는 그 자원을 지키지 못하므로, 동시에 실행 중인 요청 수를 제한하는 벌크헤드나 동시성 리미터를 본다.12 레이트 리미터는 분산 서비스 거부(DDoS) 공격에 대한 완전한 방어도 되지 못한다.13
서버가 여러 대면 카운터를 어디에 둘지 정해야 한다. 서버마다 따로 세면 빠르지만 같은 호출자가 여러 복제본에 나뉘어 닿을 때 덜 세고, Redis 같은 공유 저장소에 두면 모든 요청을 보지만 결정마다 지연이 붙는다.14 공유 저장소를 써도 판정을 여러 명령으로 나누면 경쟁 조건이 생길 수 있다. Redis 문서는 카운터를 올리는 INCR만 실행되고 만료를 거는 EXPIRE가 빠지면 키가 만료되지 않고 남는 경우를 보이고, 두 명령을 Lua 스크립트 하나로 묶어 그 틈을 없앤다.15 어떤 값을 키로 삼는지도 결과를 바꾼다. 클라이언트가 보낸 전달 헤더를 검증 없이 IP로 믿으면 키를 바꿔 한도를 피할 수 있고, 여러 사용자가 한 공인 IP를 공유하면 서로의 한도에도 영향을 준다. 또 검증하지 않은 입력마다 리미터를 만들면 메모리가 계속 는다.16
알고리즘 선택
같은 "초당 N개"라도 세는 방식에 따라 짧은 시간에 몰려 들어오는 요청(burst)을 얼마나 받는지가 다르다. Azure의 Throttling 문서는 네 가지를 이렇게 비교한다.17
고정 창(fixed window): 0초, 1초처럼 정해진 경계에서 개수를 0으로 되돌린다. 구현이 단순하지만 경계 바로 앞뒤에 몰린 요청을 연달아 들인다.
슬라이딩 창(sliding window): 창이 시간에 따라 미끄러지듯 움직여 고정 창의 경계 문제를 줄인다. 대신 상태를 더 들고 있어야 한다.
토큰 버킷: 용량만큼의 몰림을 받고, 그 뒤로는 일정한 보충 속도를 지키게 한다.
리키 버킷(leaky bucket): 일정한 속도로 내보낸다. 들어오는 속도가 일정해야 하는 백엔드 앞에 둔다.
.NET에서는 FixedWindowRateLimiter, SlidingWindowRateLimiter, TokenBucketRateLimiter가 앞의 세 방식을, ConcurrencyLimiter가 동시 실행 수 제한을 맡는다. ASP.NET Core 미들웨어(AddRateLimiter, UseRateLimiter)는 PartitionedRateLimiter로 사용자나 IP별 파티션을 나눌 수 있다.18 이 미들웨어가 거절할 때 쓰는 기본 상태 코드는 503이다.19 429는 RejectionStatusCode로 바꿀 수 있지만 그것만으로는 Retry-After 헤더가 붙지 않는다. 헤더는 OnRejected에서 리스(lease)의 MetadataName.RetryAfter를 읽어 정수 초로 올림해 넣는다.20
예제
용량 3, 1000 ms마다 토큰 1개를 채우는 버킷에 요청 여덟 개를 보낸다. 시각은 주입한 FakeClock으로 정하므로 프로그램은 실제로 기다리지 않는다.
using System;
var clock = new FakeClock();
// 용량 3, 1000 ms마다 토큰 1개를 다시 채운다.
var bucket = new TokenBucketLimiter(capacity: 3, refillPeriodMs: 1000, clock);
long[] requestTimesMs = [0, 100, 200, 300, 500, 1300, 1400, 3500];
foreach (var t in requestTimesMs)
{
clock.NowMs = t;
var d = bucket.TryAcquire();
Console.WriteLine(d.Allowed
? $"t={t,4} ms allowed tokens left {d.TokensLeft}"
: $"t={t,4} ms rejected Retry-After: {RetryAfterSeconds(d.RetryAfterMs)} (next token in {d.RetryAfterMs} ms)");
}
// Retry-After는 정수 초만 담으므로 올림한다. 내림하면 클라이언트가 너무 일찍 다시 온다.
static long RetryAfterSeconds(long ms) => (ms + 999) / 1000;
interface IClock { long NowMs { get; } }
// 예제가 시각을 직접 정하는 가짜 시계다. 실제로 기다리지 않는다.
sealed class FakeClock : IClock { public long NowMs { get; set; } }
readonly record struct Decision(bool Allowed, int TokensLeft, long RetryAfterMs)
{
public static Decision Allow(int tokensLeft) => new(true, tokensLeft, 0);
public static Decision Reject(long retryAfterMs) => new(false, 0, retryAfterMs);
}
sealed class TokenBucketLimiter(int capacity, long refillPeriodMs, IClock clock)
{
private readonly int capacity = capacity;
private readonly IClock clock = clock;
private int tokens = capacity; // 가득 찬 버킷으로 시작한다.
private long lastRefillMs = clock.NowMs;
public Decision TryAcquire()
{
var now = clock.NowMs;
Refill(now);
if (tokens == 0)
{
// 다음 토큰이 들어오는 시각까지 남은 시간을 함께 돌려준다.
return Decision.Reject(lastRefillMs + refillPeriodMs - now);
}
tokens--;
return Decision.Allow(tokens);
}
// 타이머 없이, 요청이 올 때 지난 보충 시각부터 흐른 시간만큼 토큰을 더한다.
private void Refill(long now)
{
var periods = (now - lastRefillMs) / refillPeriodMs;
tokens = (int)Math.Min(capacity, tokens + periods);
// 가득 차면 쌓이던 자투리 시간을 버린다. 가득 찬 버킷에는 토큰이 더 들어가지 않는다.
lastRefillMs = tokens == capacity ? now : lastRefillMs + periods * refillPeriodMs;
}
}t= 0 ms allowed tokens left 2
t= 100 ms allowed tokens left 1
t= 200 ms allowed tokens left 0
t= 300 ms rejected Retry-After: 1 (next token in 700 ms)
t= 500 ms rejected Retry-After: 1 (next token in 500 ms)
t=1300 ms allowed tokens left 0
t=1400 ms rejected Retry-After: 1 (next token in 600 ms)
t=3500 ms allowed tokens left 1처음 세 요청이 버킷을 비운 뒤로는 1000 ms마다 한 요청만 통과하고, 거절된 요청은 다음 토큰까지 남은 시간을 올림한 정수 초로 받는다.
범위 넓힌 예제
API 키마다 버킷을 두는 게이트에 alice와 bob의 요청, 키가 없는 요청을 섞어 보낸다. 뒤쪽에서는 같은 "초당 2개" 한도를 고정 창과 토큰 버킷(용량 2, 500 ms마다 1개)에 함께 걸고, 같은 요청 흐름이 1000 ms 경계를 지날 때 무엇을 들이는지 비교한다.
using System;
using System.Collections.Generic;
var clock = new FakeClock();
var gate = new RateLimitGate(clock, capacity: 3, refillPeriodMs: 1000);
(long At, string? ApiKey)[] trace =
[
(0, "alice"), (100, "alice"), (200, "alice"), (250, "bob"),
(300, "alice"), (350, null), (400, "bob"), (1300, "alice"),
];
Console.WriteLine("== per-client token buckets (capacity 3, 1 token per 1000 ms)");
foreach (var (at, key) in trace)
{
clock.NowMs = at;
Console.WriteLine($"t={at,4} {key ?? "(none)",-6} -> {gate.Handle(new Request(key))}");
}
// 같은 요청 흐름을 "초당 2개" 한도의 두 알고리즘에 동시에 보낸다.
var clock2 = new FakeClock();
var fixedWindow = new FixedWindowLimiter(limit: 2, windowMs: 1000, clock2);
var bucket = new TokenBucketLimiter(capacity: 2, refillPeriodMs: 500, clock2);
long[] burst = [900, 950, 1000, 1050, 1400];
int fixedInSpan = 0, bucketInSpan = 0;
Console.WriteLine();
Console.WriteLine("== limit 2 per second: fixed window vs token bucket");
foreach (var at in burst)
{
clock2.NowMs = at;
var f = fixedWindow.TryAcquire();
var b = bucket.TryAcquire();
if (at <= 1050 && f.Allowed) fixedInSpan++;
if (at <= 1050 && b.Allowed) bucketInSpan++;
Console.WriteLine($"t={at,4} fixed window: {Show(f),-16} token bucket: {Show(b)}");
}
Console.WriteLine($"admitted from 900 to 1050 ms: fixed window {fixedInSpan}, token bucket {bucketInSpan}");
static string Show(Decision d) => d.Allowed ? "allowed" : $"rejected {d.RetryAfterMs} ms";
interface IClock { long NowMs { get; } }
sealed class FakeClock : IClock { public long NowMs { get; set; } }
readonly record struct Decision(bool Allowed, int TokensLeft, long RetryAfterMs)
{
public static Decision Allow(int tokensLeft) => new(true, tokensLeft, 0);
public static Decision Reject(long retryAfterMs) => new(false, 0, retryAfterMs);
}
sealed record Request(string? ApiKey);
sealed record Response(int Status, string Text, long? RetryAfterSeconds)
{
public override string ToString() =>
RetryAfterSeconds is { } s ? $"{Status} {Text}, Retry-After: {s}" : $"{Status} {Text}";
}
sealed class RateLimitGate(IClock clock, int capacity, long refillPeriodMs)
{
// 키마다 버킷을 따로 두어, 한 클라이언트가 몰아 보내도 다른 클라이언트의 토큰은 줄지 않는다.
private readonly Dictionary<string, TokenBucketLimiter> buckets = new();
public Response Handle(Request request)
{
// 키 검증(인증)은 생략했다. 검증하지 않은 값마다 버킷을 만들면 메모리가 끝없이 늘 수 있다.
if (request.ApiKey is null) return new Response(401, "Unauthorized", null);
if (!buckets.TryGetValue(request.ApiKey, out var bucket))
{
bucket = new TokenBucketLimiter(capacity, refillPeriodMs, clock);
buckets[request.ApiKey] = bucket;
}
var d = bucket.TryAcquire();
if (!d.Allowed)
{
// 조용히 버리지 않고 429와 다시 시도할 시각(정수 초, 올림)을 알린다.
return new Response(429, "Too Many Requests", (d.RetryAfterMs + 999) / 1000);
}
return new Response(200, $"OK (tokens left {d.TokensLeft})", null);
}
}
sealed class TokenBucketLimiter(int capacity, long refillPeriodMs, IClock clock)
{
private readonly int capacity = capacity;
private readonly IClock clock = clock;
private int tokens = capacity;
private long lastRefillMs = clock.NowMs;
public Decision TryAcquire()
{
var now = clock.NowMs;
Refill(now);
if (tokens == 0) return Decision.Reject(lastRefillMs + refillPeriodMs - now);
tokens--;
return Decision.Allow(tokens);
}
private void Refill(long now)
{
var periods = (now - lastRefillMs) / refillPeriodMs;
tokens = (int)Math.Min(capacity, tokens + periods);
// 가득 차면 쌓이던 자투리 시간을 버린다. 가득 찬 버킷에는 토큰이 더 들어가지 않는다.
lastRefillMs = tokens == capacity ? now : lastRefillMs + periods * refillPeriodMs;
}
}
sealed class FixedWindowLimiter(int limit, long windowMs, IClock clock)
{
private long windowStartMs;
private int count;
public Decision TryAcquire()
{
var now = clock.NowMs;
// 창은 0, 1000, 2000 ms처럼 고정된 경계에서 시작하고, 경계를 넘으면 개수를 0으로 되돌린다.
var start = now - now % windowMs;
if (start != windowStartMs) { windowStartMs = start; count = 0; }
if (count == limit) return Decision.Reject(windowStartMs + windowMs - now);
count++;
return Decision.Allow(limit - count);
}
}== per-client token buckets (capacity 3, 1 token per 1000 ms)
t= 0 alice -> 200 OK (tokens left 2)
t= 100 alice -> 200 OK (tokens left 1)
t= 200 alice -> 200 OK (tokens left 0)
t= 250 bob -> 200 OK (tokens left 2)
t= 300 alice -> 429 Too Many Requests, Retry-After: 1
t= 350 (none) -> 401 Unauthorized
t= 400 bob -> 200 OK (tokens left 1)
t=1300 alice -> 200 OK (tokens left 0)
== limit 2 per second: fixed window vs token bucket
t= 900 fixed window: allowed token bucket: allowed
t= 950 fixed window: allowed token bucket: allowed
t=1000 fixed window: allowed token bucket: rejected 400 ms
t=1050 fixed window: allowed token bucket: rejected 350 ms
t=1400 fixed window: rejected 600 ms token bucket: allowed
admitted from 900 to 1050 ms: fixed window 4, token bucket 2alice가 429를 받는 동안 bob은 자기 버킷으로 통과한다. 고정 창은 900 ms에서 1050 ms 사이 150 ms 동안 한도의 두 배인 4개를 들였고, 토큰 버킷은 용량인 2개에서 멈췄다. 다만 이것은 150 ms 구간의 이야기다. 토큰 버킷도 1초 동안에는 용량에 그 사이 보충분을 더한 만큼 들이므로, 이 설정에서는 0, 0, 500 ms의 요청 3개가 1초 안에 통과하고 1000 ms 요청까지 포함하면 4개가 통과한다. 또 토큰 버킷은 1400 ms 요청을 들이고 고정 창은 거절해, 어느 쪽이 더 많이 들이는지는 요청 흐름에 따라 다르다. 이 게이트는 한 프로세스의 Dictionary에 상태를 두므로 서버가 여러 대면 각자 따로 센다. 쓰지 않는 키의 버킷을 지우지 않고, 잠금이 없어 여러 스레드에서 동시에 부르면 토큰 수가 틀어질 수 있다. Retry-After는 안내일 뿐이라 지키지 않고 일찍 다시 보낸 클라이언트는 다시 429를 받을 수 있다.
TypeScript 예제
키별 게이트를 TypeScript로 옮긴다. Decision은 allowed 값으로 갈리는 유니온 타입이라, 거절일 때만 retryAfterMs를 읽을 수 있다.
interface IClock { readonly nowMs: number; }
// 예제가 시각을 직접 정하는 가짜 시계다. 실제로 기다리지 않는다.
class FakeClock implements IClock { nowMs = 0; }
type Decision =
| { allowed: true; tokensLeft: number }
| { allowed: false; retryAfterMs: number };
class TokenBucketLimiter {
#tokens: number;
#lastRefillMs: number;
constructor(
private readonly capacity: number,
private readonly refillPeriodMs: number,
private readonly clock: IClock,
) {
this.#tokens = capacity; // 가득 찬 버킷으로 시작한다.
this.#lastRefillMs = clock.nowMs;
}
tryAcquire(): Decision {
const now = this.clock.nowMs;
this.#refill(now);
if (this.#tokens === 0) {
// 다음 토큰이 들어오는 시각까지 남은 시간을 함께 돌려준다.
return { allowed: false, retryAfterMs: this.#lastRefillMs + this.refillPeriodMs - now };
}
this.#tokens--;
return { allowed: true, tokensLeft: this.#tokens };
}
// 타이머 없이, 요청이 올 때 지난 보충 시각부터 흐른 시간만큼 토큰을 더한다.
#refill(now: number): void {
const periods = Math.floor((now - this.#lastRefillMs) / this.refillPeriodMs);
this.#tokens = Math.min(this.capacity, this.#tokens + periods);
// 가득 차면 쌓이던 자투리 시간을 버린다. 가득 찬 버킷에는 토큰이 더 들어가지 않는다.
this.#lastRefillMs = this.#tokens === this.capacity ? now : this.#lastRefillMs + periods * this.refillPeriodMs;
}
}
type RateLimitResponse = { status: number; text: string; retryAfterSeconds?: number };
class RateLimitGate {
// 키마다 버킷을 따로 두어, 한 클라이언트가 몰아 보내도 다른 클라이언트의 토큰은 줄지 않는다.
readonly #buckets = new Map<string, TokenBucketLimiter>();
constructor(
private readonly clock: IClock,
private readonly capacity: number,
private readonly refillPeriodMs: number,
) {}
handle(apiKey: string | null): RateLimitResponse {
// 키 검증(인증)은 생략했다. 검증하지 않은 값마다 버킷을 만들면 메모리가 끝없이 늘 수 있다.
if (apiKey === null) return { status: 401, text: "Unauthorized" };
let bucket = this.#buckets.get(apiKey);
if (bucket === undefined) {
bucket = new TokenBucketLimiter(this.capacity, this.refillPeriodMs, this.clock);
this.#buckets.set(apiKey, bucket);
}
const d = bucket.tryAcquire();
if (!d.allowed) {
// 조용히 버리지 않고 429와 다시 시도할 시각(정수 초, 올림)을 알린다.
return { status: 429, text: "Too Many Requests", retryAfterSeconds: Math.ceil(d.retryAfterMs / 1000) };
}
return { status: 200, text: `OK (tokens left ${d.tokensLeft})` };
}
}
function show(r: RateLimitResponse): string {
return r.retryAfterSeconds === undefined
? `${r.status} ${r.text}`
: `${r.status} ${r.text}, Retry-After: ${r.retryAfterSeconds}`;
}
const clock = new FakeClock();
const gate = new RateLimitGate(clock, 3, 1000);
const trace: Array<[number, string | null]> = [
[0, "alice"], [100, "alice"], [200, "alice"], [250, "bob"],
[300, "alice"], [350, null], [400, "bob"], [1300, "alice"],
];
for (const [at, key] of trace) {
clock.nowMs = at;
console.log(`t=${String(at).padStart(4)} ${(key ?? "(none)").padEnd(6)} -> ${show(gate.handle(key))}`);
}t= 0 alice -> 200 OK (tokens left 2)
t= 100 alice -> 200 OK (tokens left 1)
t= 200 alice -> 200 OK (tokens left 0)
t= 250 bob -> 200 OK (tokens left 2)
t= 300 alice -> 429 Too Many Requests, Retry-After: 1
t= 350 (none) -> 401 Unauthorized
t= 400 bob -> 200 OK (tokens left 1)
t=1300 alice -> 200 OK (tokens left 0)출력은 C# 범위 넓힌 예제의 앞부분과 같다. 상태가 한 Node.js 프로세스의 Map에 있으므로 서버가 여러 대일 때의 한계도 같다.
같이 보기
C# 예제는 각각 독립된 .NET 10 콘솔 프로그램이다.
TypeScript 예제는 ES2022 이상으로 컴파일한 JavaScript를 Node.js에서 실행한다.
각주
- Microsoft Azure Architecture Center. Rate Limiting pattern. 도입부. ↩
- Microsoft Azure Architecture Center. Throttling pattern. Solution 절의 Per-principal rate limits 항목. ↩
- C. Partridge. "A Proposed Flow Specification". RFC 1363 (1992). RFC Editor. Definition of Individual Fields 절의 Token Bucket Rate, Token Bucket Size 항목. ↩
- S. Shenker, J. Wroclawski. "General Characterization Parameters for Integrated Service Network Elements". RFC 2215 (1997). RFC Editor. TOKENBUCKETTSPEC 항목. ↩
- J. Heinanen, R. Guerin. "A Single Rate Three Color Marker". RFC 2697 (1999). RFC Editor. 3절 Metering. ↩
- M. Nottingham, R. Fielding. "Additional HTTP Status Codes". RFC 6585 (2012). RFC Editor. 4절 429 Too Many Requests. ↩
- R. Fielding, M. Nottingham, J. Reschke (편). "HTTP Semantics". RFC 9110 (2022). 10.2.3 Retry-After. ↩
- Paul Tarjan. Scaling your API with rate limiters. Stripe 블로그 (2017). ↩
- Brennan Conroy. Announcing Rate Limiting for .NET. .NET Blog (2022). ↩
- Microsoft Azure Architecture Center. Throttling pattern. Problems and considerations 절의 "Make rejection cheaper than the work that it prevents" 항목. ↩
- Microsoft Azure Architecture Center. Rate Limiting pattern. Context and problem 절. ↩
- Microsoft Azure Architecture Center. Throttling pattern. Problems and considerations 절의 "Align throttling limits with the component that saturates first" 항목. ↩
- Microsoft Learn. Rate limiting middleware in ASP.NET Core. Prevent DDoS attacks 절. ↩
- Microsoft Azure Architecture Center. Throttling pattern. Problems and considerations 절의 "Decide where the counter resides when one limit spans multiple nodes" 항목. ↩
- Redis. INCR. Pattern: rate limiter 절. ↩
- Microsoft Learn. Rate limiting middleware in ASP.NET Core. Testing endpoints with rate limiting 절, Chain limiters in a named policy 절 예제의 주석. ↩
- Microsoft Azure Architecture Center. Throttling pattern. Problems and considerations 절의 "Pick a limiting algorithm intentionally" 표. ↩
- Microsoft Learn. Rate limiting middleware in ASP.NET Core. Rate limiter algorithms, Rate limiting partitions 절. ↩
- Microsoft Learn. RateLimiterOptions.RejectionStatusCode. 기본값 Status503ServiceUnavailable. ↩
- Microsoft Learn. Rate limiting middleware in ASP.NET Core. Choosing what happens when a request is rate limited 절. ↩