# 세 가지 Redis Rate Limit Lua를 코드로 추적하기 > **Redis 코드 상세 시리즈 14/20** · [전체 지도](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-backend-policy-boundary.md) · 이전: [Redis 캐시 한 요청의 전 생애: Generation·Envelope·Soft/Hard TTL](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-cache-code-walkthrough.md) · 다음: [Redis Lease는 왜 Lock이 아닌가: Acquire·Renew·Release 코드 읽기](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-lease-code-walkthrough.md) ## 이 글이 답하는 코드 질문 HTTP 요청 하나가 어떤 식별자를 남기고 Redis의 fixed-window, sliding-counter, token-bucket 중 하나를 실행합니까? `evaluationId`와 `maximumClockRegression`은 실제 Lua에 전달됩니까? timeout 뒤 결과는 어떻게 표현합니까? 현행 production 경로는 HTTP transport bridge부터 Redis Lua까지 조립됩니다. 그러나 계약에 있는 evaluation deduplication과 clock-regression 설정은 이 adapter가 소비하지 않습니다. 이 차이를 먼저 고정해야 코드를 과대평가하지 않습니다. ## 먼저 보는 클래스·리소스 지도 | 코드 | 입력 | 출력 | 다음 호출 | | --- | --- | --- | --- | | [`RateLimitInterceptor.preHandle`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitInterceptor.java:37) | HTTP request | 통과 또는 typed outcome의 HTTP 응답 | `EdgeRateLimitTransportBridge.evaluate` | | [`EdgeRateLimitTransportBridge.evaluate`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/EdgeRateLimitTransportBridge.java:57) | raw HTTP subject | pseudonymous `RateLimitRequest` | `EdgeRateLimitPort.evaluate` | | [`RedisEdgeRateLimitAdapter.evaluate`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/ratelimit/RedisEdgeRateLimitAdapter.java:84) | policy, subject digest, cost, evaluation ID, deadline | `Evaluated`, `Unavailable`, `Incompatible` | SCRIPT lane과 `RateLimitScripts` | | [`RateLimitKeys.counterKey`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/ratelimit/RateLimitKeys.java:45) | policy ID/revision, subject digest | physical key | Lua `KEYS[1]` | | [`RateLimitScripts.evaluate`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/ratelimit/RateLimitScripts.java:148) | policy parameters, cost, caller time | `{allowed, remaining, resetAfterMillis}` | `SCRIPT LOAD`, `EVALSHA` | | [`RateLimitOutcome`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/RateLimitOutcome.java:6) | evaluation/failure | provider-neutral discriminated result | web response mapping | ## 객체 조립과 transport pseudonym `ca-skeleton.capabilities.rate-limit.provider=redis`이고 Redis 전역 switch가 켜져 있으면 `RedisCapabilityConfig.redisEdgeRateLimitPort`가 bean을 만듭니다. 설정의 policy map을 `RateLimitPolicy`로 바꾸고, `RateLimitKeys`, 세 Lua를 가진 `RateLimitScripts`, `Clock`, command timeout, failure retry-after를 주입합니다. [`redisEdgeRateLimitPort`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/redis/RedisCapabilityConfig.java:131) policy map이 비어 있거나 default policy ID가 map에 없으면 startup이 실패합니다. failure policy는 `fail-closed`만 허용됩니다. algorithm 문자열은 `fixed-window`, `sliding-counter`, `token-bucket`만 받습니다. [`policiesOf`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/redis/RedisCapabilityConfig.java:151) HTTP 경계는 principal/API key/client IP와 route operation을 `EdgeRateLimitSubject`로 만든 뒤 `VersionedEdgeSubjectPseudonymizer`로 보냅니다. pseudonymizer는 subject kind, canonical identity, operation ID를 UTF-8 byte length로 framing해 HMAC delegate에 전달하고 `v:`를 만듭니다. [`VersionedEdgeSubjectPseudonymizer.pseudonymize`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/VersionedEdgeSubjectPseudonymizer.java:29) bridge는 server-owned evaluation ID를 새로 만들고 caller deadline을 `clock.instant() + budget`으로 계산합니다. client가 보낸 `Idempotency-Key`나 rate-limit evaluation header는 사용하지 않습니다. cost는 HTTP bridge에서 1로 고정됩니다. [`EdgeRateLimitTransportBridge.evaluate`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/EdgeRateLimitTransportBridge.java:57) ## 요청 시 호출 순서 ```mermaid sequenceDiagram participant H as HTTP interceptor participant B as Transport bridge participant A as RedisEdgeRateLimitAdapter participant L as RateLimitScripts participant R as Redis H->>B: evaluate(request) B->>B: subject resolve + pseudonym + evaluationId B->>A: RateLimitRequest(cost=1, deadline) A->>A: policy/cost/deadline 검사 A->>L: evaluate(key, policy, cost, now) L->>R: SCRIPT LOAD (digest miss) L->>R: EVALSHA key args alt NOSCRIPT L->>R: SCRIPT LOAD L->>R: EVALSHA 한 번 재시도 end R-->>L: allowed, remaining, resetAfter L-->>A: Evaluation A-->>B: Evaluated / Unavailable / Incompatible ``` `RedisEdgeRateLimitAdapter`는 먼저 policy 존재 여부와 `cost <= maximumCost`를 검사합니다. 실패하면 Redis를 호출하지 않고 `Incompatible(STATE_INCOMPATIBLE)`을 반환합니다. caller deadline이 이미 지났으면 `Unavailable(ADMISSION_REJECTED)`입니다. 이후 SCRIPT lane을 빌리고 policy revision과 subject digest가 포함된 단일 counter key를 Lua에 넘깁니다. policy revision이 바뀌면 이전 counter와 새 counter가 섞이지 않습니다. [`RateLimitKeys`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/ratelimit/RateLimitKeys.java:8) ## 세 Lua가 읽고 쓰는 상태 ### fixed-window [`FIXED_WINDOW`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/ratelimit/RateLimitScripts.java:42)는 `windowStart`를 계산하고 hash field 이름으로 씁니다. - `HGET key `로 현재 소비량을 읽습니다. - `current + cost > limit`이면 mutation 없이 deny합니다. - 허용이면 `HSET`으로 소비량을 쓰고 `PEXPIRE key windowMillis*2`를 설정합니다. - 반환값은 allow flag, 남은 budget, 현재 window 끝까지의 milliseconds입니다. 고정 window 경계가 바뀌면 새 field를 사용하므로 budget이 복구됩니다. key TTL은 매 hit마다 다시 설정되지만 두 window 길이로 제한됩니다. ### sliding-counter [`SLIDING_COUNTER`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/ratelimit/RateLimitScripts.java:67)는 current window와 previous window를 `HGET`으로 읽습니다. 이전 window 사용량에 남은 비율을 곱하고 `math.floor`한 뒤 current를 더합니다. - estimated consumption에 cost를 더해 limit을 넘으면 deny합니다. - 허용이면 current field만 `HSET`합니다. - 두 window 전 field를 `HDEL`하고 key에 `windowMillis*3` TTL을 둡니다. - 이 방식은 exact sliding log가 아니므로 decision certainty가 `APPROXIMATE_ALGORITHM`입니다. ### token-bucket [`TOKEN_BUCKET`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/ratelimit/RateLimitScripts.java:96)는 hash의 `tokens`, `updatedAt`을 `HMGET`합니다. - 상태가 없으면 full capacity와 현재 시각으로 시작합니다. - 지난 whole refill period 수만큼 token을 보충합니다. - 부족해도 상태와 TTL을 `HSET`/`PEXPIRE`한 뒤 deny합니다. - 충분하면 cost를 빼고 같은 방식으로 저장합니다. - stored timestamp는 whole period만 전진하므로 partial period를 버리지 않습니다. 세 script 모두 caller `Clock`의 epoch milliseconds를 ARGV로 받으며 Redis `TIME`은 호출하지 않습니다. 다만 이 사실만으로 clock regression bound가 적용되는 것은 아닙니다. ## script 등록과 NOSCRIPT 복구 각 algorithm은 process-local `AtomicReference`에 SHA digest를 cache합니다. digest가 없으면 `SCRIPT LOAD`에 해당하는 `gateway.loadScript`를 먼저 호출하고, 이후 `evaluateRegisteredForList`로 `EVALSHA`를 보냅니다. [`run`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/ratelimit/RateLimitScripts.java:187) failure message가 `NOSCRIPT`로 시작할 때만 digest cache를 비우고 load 후 `EVALSHA`를 한 번 더 보냅니다. `NOSCRIPT`는 script가 실행되지 않았다는 서버 응답이므로 이 재시도는 ambiguous mutation 재시도와 다릅니다. 그 외 exception은 그대로 올립니다. ## 정상·거절·ambiguous 분기 정상 reply는 세 값 이상이어야 합니다. 부족하거나 예상하지 못한 type이면 decoder가 `IllegalStateException`을 던지고 adapter catch-all에서 `Unavailable(NO_MUTATION_CONFIRMED)`가 됩니다. [`evaluationOf`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/ratelimit/RateLimitScripts.java:244) 정상 evaluation은 `RateLimitDecision`으로 변환됩니다. allowed이면 retry-after는 0, denied이면 최소 1ms입니다. sliding counter만 approximate이고 나머지는 certain입니다. [`decisionOf`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/ratelimit/RedisEdgeRateLimitAdapter.java:142) 실패는 모두 fail-closed typed outcome입니다. - unknown policy/oversized cost: `Incompatible(STATE_INCOMPATIBLE)` - expired caller deadline: `Unavailable(ADMISSION_REJECTED)` - non-ambiguous `RedisOperationException`: `Unavailable(UNAVAILABLE_BEFORE_SEND)` - ambiguous metadata, interruption, timeout, 알 수 없는 exception: `Unavailable(NO_MUTATION_CONFIRMED)` 이 adapter는 `RateLimitOutcome.Indeterminate`를 반환하지 않습니다. mutation 여부가 불확실해도 `UnavailableCategory.NO_MUTATION_CONFIRMED`라는 이름을 사용합니다. 따라서 이 category 이름을 “mutation이 없다고 확인됨”으로 해석하면 안 됩니다. 구현 주석은 ambiguous call이 budget을 소비했을 수 있다고 설명합니다. [`RedisOperationException` catch](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/ratelimit/RedisEdgeRateLimitAdapter.java:123) ## 설정·계약이 있지만 소비되지 않는 두 항목 `RateLimitPolicy`는 기본적으로 `RateLimitEvaluationDedupPolicy.enabledDefaults()`를 넣습니다. 기본은 TTL 5초, 최대 256 entries, 논리 stored bytes 65,536입니다. [`RateLimitEvaluationDedupPolicy.enabledDefaults`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/RateLimitEvaluationDedupPolicy.java:47) 그러나 `RedisEdgeRateLimitAdapter`와 `RateLimitScripts`는 `request.evaluationId()`나 `policy.evaluationDedupPolicy()`를 읽지 않습니다. Lua key와 ARGV에도 evaluation ID가 없습니다. response-loss retry dedupe는 현재 구현되지 않았습니다. live test의 “port de-duplicates repeats” 주석도 현행 production body와 맞지 않는 historical/drift 문구입니다. [`LiveRedisSemanticPortsTest.request`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/LiveRedisSemanticPortsTest.java:210) `RateLimitPolicy.maximumClockRegression`도 validation되며 bootstrap 설정에서 채워집니다. 하지만 scripts에 전달되지 않습니다. token bucket은 `updatedAt > now`이면 stored timestamp를 지금으로 낮출 뿐 bound를 비교하거나 `CLOCK_UNSAFE`를 반환하지 않습니다. `RateLimitOutcome.UnavailableCategory.CLOCK_UNSAFE`는 type에 있으나 adapter에서 생성되지 않습니다. ## 테스트가 고정하는 계약 - [`RedisEdgeRateLimitAdapterTest`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/ratelimit/RedisEdgeRateLimitAdapterTest.java:104)는 fixed limit, 새 window, sliding approximate 표시, token refill, unreachable fail-closed, unknown policy, oversized cost, deadline과 subject isolation을 in-memory gateway에서 검사합니다. - [`EdgeRateLimitProviderNeutralContractTest`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/shared-contract/src/edgeRateLimitContractTest/java/dev/caskeleton/shared/ratelimit/EdgeRateLimitProviderNeutralContractTest.java:13)는 세 portable algorithm과 bounded pseudonymous request를 고정합니다. dedupe 실행을 검증하지는 않습니다. - [`EdgeRateLimitTransportBridgeTest`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/ratelimit/EdgeRateLimitTransportBridgeTest.java:35)는 raw subject가 port를 넘지 않고 server-generated evaluation ID와 750ms deadline이 전달됨을 확인합니다. - [`LiveRedisSemanticPortsTest.theRateLimiterEnforcesUnderTheAdvancedAccount`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/LiveRedisSemanticPortsTest.java:172)는 standalone/cluster lane에서 advanced account로 세 번 허용 후 deny되는 fixed window를 검증하도록 태그되어 있습니다. - [`RedisTopologyContractTest.scriptPathIsAdvancedOnly`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/RedisTopologyContractTest.java:197)는 advanced account만 `EVALSHA`를 실행하고 `EVAL`은 누구에게도 열지 않는 ACL 계약을 real server에 묻습니다. ## 현재 한계와 다음 source 순서 1. evaluation ID 생성과 bounded dedupe policy type은 있지만 Redis state/Lua가 이를 소비하지 않습니다. 2. `maximumClockRegression`과 `CLOCK_UNSAFE`도 설정·type만 있고 실행 경로가 소비하지 않습니다. 3. Lua의 TTL 식은 policy의 `cleanupGrace`를 사용하지 않습니다. validation에는 포함되지만 script ARGV에는 전달되지 않습니다. 4. 실패는 fail-closed이지만 ambiguous mutation을 `Indeterminate`로 분리하지 않습니다. 5. 이번 작성에서는 real-server topology lane을 재실행하지 않았습니다. source는 transport bridge → adapter → scripts → adapter test → live semantic test 순으로 읽는 편이 호출 경계를 가장 빨리 드러냅니다. ## 시리즈에서 이어 읽기 - 이전 글: [Redis 캐시 한 요청의 전 생애: Generation·Envelope·Soft/Hard TTL](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-cache-code-walkthrough.md) - 다음 글: [Redis Lease는 왜 Lock이 아닌가: Acquire·Renew·Release 코드 읽기](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-lease-code-walkthrough.md) - 전체 흐름: [Redis를 범용 클라이언트가 아니라 정책 경계로 다루기](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-backend-policy-boundary.md) - 운영 흐름: [Redis를 켠다는 말의 운영적 의미: 단일 활성화 스위치에서 Sentinel 쓰기 손실 검증까지](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-platform-sre-operations.md)