refactor: 문서 개선 중
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
# 세 가지 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<version>:<digest>`를 만듭니다. [`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 <windowStart>`로 현재 소비량을 읽습니다.
|
||||
- `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<String>`에 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)
|
||||
|
||||
Reference in New Issue
Block a user