docs(keycloak-session-store): import the session-storage lab as a new project
The keycloak project ended with four open questions that design could not
settle. A two-VM lab was built to answer them by measurement, and this is
that material: 26 experiments, 125 raw command outputs, 22 browser captures.
Follows the import procedure in README.md.
source/ the originating repository verbatim — 78 documents, 28 SVGs,
8 manifests, plus .source-revision recording the commit
final/ the SSOT
document.md 729 lines written from the 29 experiment documents, not
concatenated: what was predicted, what was measured, and
where the measurement itself was wrong
evidence/raw 125 outputs, flattened to <experiment>__<file> because
the originals collided (01-baseline.txt appeared three
times) and the audit only globs the top level
evidence/meta one per raw file; command and exitCode are null and the
README says why rather than inventing them
evidence/browser 22 captures
assets/ three diagrams through techviz
.techviz/ their VizSpecs
A separate project rather than an addition to keycloak: the B-layer answers
that project's four questions, but the A, C and D layers are about cluster
failure, SSO and operations, and one document.md should hold one subject.
The four question records there can point here through 관계.
Recorded rather than papered over: only three of the 28 diagrams were
remade. The repository forbids hand-drawn SVG and forbids titles inside the
canvas; all 28 originals carry both, so converting them is redrawing, not
reformatting. They stay in source/ and the gap is written into the document.
verify-pipeline.py passes. audit-records.py reports no issues.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
43bccd08a8
commit
b2963105a8
+224
@@ -0,0 +1,224 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: a10-f001-readme
|
||||
title: 표가 증거로 지목한 시험이 그 표를 반증한다
|
||||
topic: caching-and-redis
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:a10-f001-readme
|
||||
evidenceCapturedOn: 2026-09-02
|
||||
body: case-a10-f001-readme.body.md
|
||||
assets:
|
||||
- key: a10-f001-readme
|
||||
file: ../../../final/evidence/rendered/a10-f001-readme.svg
|
||||
- key: a10-f001-readme-counts
|
||||
file: ../../../final/evidence/rendered/a10-f001-readme-counts.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/a10-f001-readme.txt
|
||||
- ../../../final/evidence/raw/a10-f001-readme-counts.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/10-adapter-outbound-cache-redis.md#L121 이다. 등급은 P2 다. 표 네 행 중 셋이 사실과 다르다는 판정, 패키지별 파일과 줄 수, `@Bean` 메서드 일곱, 빌드 파일 주석의 임포트 계수, 그리고 무겁게 보는 세 이유가 그 절에 있다.
|
||||
- 그 절은 이 수치를 LOC 라고 적지만 실제로 센 것은 물리 줄 수여서, 여기서는 줄 수라고만 적는다.
|
||||
- 표가 둘째 축의 증거로 지목한 시험이 표를 반증한다는 것, 같은 README 의 산문이 세 줄 뒤에서 표와 어긋난다는 것, 다섯 포트 중 세션만 표가 맞다는 것, 일곱째 `@Bean` 이 조건부라는 것, 그리고 주석의 주어절은 맞고 괄호만 틀렸다는 것은 이 기록에서 확인했다.
|
||||
---
|
||||
|
||||
# 표가 증거로 지목한 시험이 그 표를 반증한다
|
||||
|
||||
리프 README 의 준비도 표와 그 아래 두 문단이 이 리프의 상태를 없음으로 적는다. 표가 둘째 열의 증거로 이름을 대 놓은 시험이 그 빈들이 조립된다고 단언하고, 같은 README 의 산문이 세 줄 뒤에서 같은 기능을 제공한다고 적는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **문서 계약 테스트의 단언 경계 밖에 발견된 드리프트 세 건이 전부 있었다**
|
||||
그 테스트가 단언하는 범위 밖에서 문서와 코드가 어긋났다.
|
||||
- **README의 세 가지 사실 오류**
|
||||
같은 README 에서 확인한 다른 사실 오류다.
|
||||
- **산문이 선언한 게이트는 빌드에 있는 게이트가 아니다**
|
||||
문서와 빌드를 대조하는 규칙이다.
|
||||
|
||||
## 문제
|
||||
|
||||
이 리프의 README 는 준비도 보고를 자기 주제로 삼는다. 세 질문을 합치지 말라고 못 박고, 축마다 증거를 지정한 다음 표를 놓는다.
|
||||
|
||||
## 결론
|
||||
|
||||
둘째 축의 증거로 이름을 댄 시험이 RedisSdkAutoConfigurationTest 다. 그 시험이 RedisRuntimeClient 와 RedisRuntimeOwner 를 hasSingleBean 으로 단언한다. 표가 그 둘을 조립되지 않는다고 적은 자리다.
|
||||
|
||||
같은 README 도 자기와 어긋난다. 표에서 세 줄 뒤 산문이 이 모듈은 Lettuce 연결 수명과 명령 타임아웃과 재연결 재생 차단을 제공한다고 적는다. 표 둘째 행이 없음이라고 적은 바로 그것이다.
|
||||
|
||||
수를 세면 이렇다.
|
||||
|
||||
sdk/lettuce/connection 에 아홉 파일 1,473 줄이 있다. RedisTopologyClientFactory 600, RedisRuntimeOwner 316, SentinelFailoverObserver 153, RedisConnectionRegistry 142 줄이다.
|
||||
|
||||
의미 포트 행은 다섯 이름을 한 칸에 묶는다. 그중 넷은 아홉 파일 2,598 줄로 있고 세션 하나만 표가 맞다. 세션 쪽은 패키지 자체가 없고, 남은 여덟 건은 코드가 아니라 문장과 선택자 값이다.
|
||||
|
||||
상태 기여자 행도 틀렸다. RedisHealthContributor 88 줄과 RedisCorrectnessRoles 62 줄이 있다.
|
||||
|
||||
자동 설정에는 @Bean 메서드가 일곱 있다. 여섯은 조건 없이 조립되고, 일곱째 redisRequired 에는 @Conditional 이 붙어 세션·멱등·레이트리밋·리스 중 하나가 Redis 를 고를 때만 생긴다. 조립되는 자리를 못 찾은 것은 게이트웨이와 의미 어댑터다.
|
||||
|
||||
빌드 파일 주석은 절반만 틀렸다. 주석의 주어는 SDK 이고, sdk 패키지의 어떤 파일도 그 간선들을 임포트하지 않는다. 괄호 안의 일반화가 틀렸다. 메인 소스 전체로 넓히면 애플리케이션 코어를 일곱 파일이, 공유 계약을 세 파일이 임포트한다. 등록된 간선 셋 중 adapter:outbound:support 만 실제로 0 이다.
|
||||
|
||||
같은 주석은 그 어댑터들이 제거됐다고도 적는다. 위에서 센 파일들이 그것이다. 그리고 간선을 남겨 두는 이유로 든 문장 — 여기서 무언가가 그것에 대해 컴파일되기 때문이 아니라는 것 — 도 뒤집힌다.
|
||||
|
||||
스무 줄 뒤에 있는 별개 주석 쪽은 맞다. spring-data-redis 와 io.micrometer 임포트는 실제로 0 이다.
|
||||
|
||||
판정은 P2 다. 코드 결함이 아니라 문서 결함인데 이 저장소 기준으로는 무겁다.
|
||||
|
||||
첫째, 이 문서는 정직한 준비도 보고를 자기 주제로 삼고, 축마다 증거를 지정하기까지 한다. 그 증거가 문서를 반증한다.
|
||||
|
||||
둘째, 방향이 이례적이다. 보통의 표류는 없는 것을 있다고 하는데 여기는 있는 것을 없다고 한다. 포크한 쪽은 있는 것을 다시 만들거나, 있는 줄도 모른 채 지나친다.
|
||||
|
||||
셋째 근거는 자동 설정 안의 문장이다. 이 클래스가 생기기 전까지 설정 검증 메서드에 프로덕션 호출자가 없었다고 적는다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
확인 방식 : 패키지별 파일과 줄 수 계수, @Bean 메서드와 조건 계수, 임포트 계수
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. README 가 세 축마다 지정한 증거를 읽는다.
|
||||
2. 둘째 축의 증거로 지목된 시험이 무엇을 단언하는지 읽는다.
|
||||
3. 준비도 표 네 행과 그 아래 두 문단을 읽고, 세 줄 뒤 산문까지 이어 읽는다.
|
||||
4. 표가 없다고 적은 자리마다 패키지의 파일과 줄 수를 센다.
|
||||
5. 의미 포트 행이 든 다섯 이름과 실제 패키지 이름을 대조하고, 행에 없는 패키지는 합계에서 뺀다.
|
||||
6. 세션을 리프 메인 전체에서 문자열로 검색한다.
|
||||
7. 자동 설정의 @Bean 메서드를 조건 애너테이션까지 함께 읽는다.
|
||||
8. 빌드 파일이 등록한 간선 셋을 각각 임포트하는 파일을 세고, 주석의 주어인 sdk 패키지만으로도 센다.
|
||||
9. 스무 줄 뒤의 별개 주석과 그 주장을 확인한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
README 는 준비도가 서로 다른 세 질문이며 하나로 합치면 안 된다는 문장으로 시작하고, 축마다 무엇을 증거로 삼는지까지 적는다.
|
||||
|
||||
## 표가 지목한 증거
|
||||
|
||||
:::evidence key="a10-f001-readme" alt="README 가 준비도 세 축마다 지정한 증거, 준비도 표 네 행, 표 아래 두 문단. 표에서 세 줄 뒤 같은 README 의 산문이 이 모듈이 제공한다고 적는 목록. 그리고 둘째 축의 증거로 지목된 시험이 어떤 빈들을 단언하는지 출력한 터미널 기록." caption="표는 연결 수명과 의미 포트와 상태 기여자를 없음으로 적음 · 세 줄 뒤 산문은 같은 모듈이 Lettuce 연결 수명을 제공한다고 적음 · 표가 증거로 지목한 시험은 RedisRuntimeClient 와 RedisRuntimeOwner 를 hasSingleBean 으로 단언 — 46줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
```text
|
||||
| **Spring composition 구현** | `APP_REDIS_ENABLED=true`에서 실제 bean이 조립된다 | `RedisSdkAutoConfigurationTest` |
|
||||
```
|
||||
|
||||
그 시험이 무엇을 단언하는지 보면 이렇다.
|
||||
|
||||
```java
|
||||
assertThat(context).hasSingleBean(RedisSdkSettings.class);
|
||||
...
|
||||
assertThat(context).hasSingleBean(RedisRuntimeClient.class);
|
||||
...
|
||||
assertThat(context).hasSingleBean(RedisRuntimeOwner.class);
|
||||
```
|
||||
|
||||
표는 같은 둘을 조립되지 않는다고 적는다.
|
||||
|
||||
```text
|
||||
| Topology client / connection lifecycle | 없음 | 없음 | 없음 |
|
||||
| cache / session / idempotency / rate limit / lease semantic port | 없음 | 없음 | 없음 |
|
||||
| role-aware health·readiness contributor | 없음 | 없음 | 없음 |
|
||||
```
|
||||
|
||||
## 같은 README 가 세 줄 뒤에서
|
||||
|
||||
```text
|
||||
모듈은 Lettuce connection lifecycle,
|
||||
finite command timeout, reconnect replay 차단, finite request queue/admission, positive/negative
|
||||
TTL, absolute soft/hard expiry, deterministic bounded TTL jitter, digest-protected v2 binary
|
||||
envelope, HMAC physical key,
|
||||
invalidation, closed-catalog
|
||||
`EVALSHA -> NOSCRIPT -> SCRIPT LOAD -> digest verify -> EVALSHA` recovery를 제공한다.
|
||||
```
|
||||
|
||||
표 둘째 행이 없음이라고 적은 것을 산문이 제공한다고 적는다. 어긋난 것은 문서 전체가 아니라 표와 그 아래 두 문단이다.
|
||||
|
||||
## 수를 세면
|
||||
|
||||
:::evidence key="a10-f001-readme-counts" alt="표가 없다고 적은 자리의 파일 수와 줄 수. 의미 포트 행이 든 다섯 이름 중 구현이 있는 넷의 합계와, 그 행에 이름이 없어 합계에서 뺀 두 패키지. 세션 문자열의 리프 전체 계수. 상태 기여자의 줄 수. 자동 설정의 `@Bean` 메서드 전부와 거기 붙은 조건 애너테이션. 빌드 파일이 등록한 간선 셋과 앞 주석, 그 간선들을 임포트하는 파일 수와 주석의 주어인 sdk 패키지만의 계수, 그리고 스무 줄 뒤의 별개 주석을 출력한 터미널 기록." caption="연결 패키지 9 파일 1,473 줄 · 표가 든 다섯 포트 중 넷이 9 파일 2,598 줄이고 세션만 없음 · @Bean 일곱 중 하나에 @Conditional · 등록 간선 셋 중 둘은 열 파일이 임포트하고 support 만 0 · 주석의 주어인 sdk 패키지만 보면 0 — 58줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
```text
|
||||
# sdk/lettuce/connection : 9 파일 1473 줄
|
||||
600 sdk/lettuce/connection/RedisTopologyClientFactory.java
|
||||
316 sdk/lettuce/connection/RedisRuntimeOwner.java
|
||||
153 sdk/lettuce/connection/SentinelFailoverObserver.java
|
||||
142 sdk/lettuce/connection/RedisConnectionRegistry.java
|
||||
# 표가 든 다섯 포트 중 구현이 있는 넷
|
||||
cache 2 파일 641 줄
|
||||
idempotency 2 파일 835 줄
|
||||
ratelimit 3 파일 516 줄
|
||||
lease 2 파일 606 줄
|
||||
합계 9 파일 2598 줄
|
||||
# 다섯째 session : 패키지 없음, main 전체에서 session 문자열 8
|
||||
# 그 행에 이름이 없어 뺀 패키지 : realtime 591 줄, keyspace 106 줄
|
||||
# 상태 기여자 : 88 + 62
|
||||
```
|
||||
|
||||
의미 포트 행은 다섯 이름을 한 칸에 묶는데 그중 하나만 맞다. 세션은 패키지가 없고, 리프 메인 전체에서 그 문자열 여덟 건이 전부 javadoc 산문과 역할 선택자 값이다. `realtime` 과 `keyspace` 는 그 행에 없는 이름이라 합계에서 뺐다.
|
||||
|
||||
## `@Bean` 은 일곱, 무조건은 여섯
|
||||
|
||||
```text
|
||||
273: @Bean(destroyMethod = "close")
|
||||
274- public RedisRuntimeOwner redisRuntimeOwner(RedisRuntimeClient client, RedisSdkSettings settings) {
|
||||
297: @Bean(RedisCorrectnessRoles.OPTIONAL_HEALTH_CONTRIBUTOR)
|
||||
298- public HealthIndicator redisOptional(RedisRuntimeOwner owner, RedisSdkSettings settings) {
|
||||
324: @Bean(RedisCorrectnessRoles.REQUIRED_HEALTH_CONTRIBUTOR)
|
||||
325- @Conditional(RedisCorrectnessRoleBound.class)
|
||||
326- public HealthIndicator redisRequired(RedisRuntimeOwner owner, RedisSdkSettings settings) {
|
||||
```
|
||||
|
||||
일곱째만 조건부다. 세션·멱등·레이트리밋·리스 중 하나가 Redis 를 고를 때 생긴다. 나머지 여섯은 스위치 하나로 조립된다. 어디서도 조립되지 않는 것은 게이트웨이와 의미 어댑터 둘이다.
|
||||
|
||||
## 빌드 파일의 의존성 주석
|
||||
|
||||
```groovy
|
||||
// Registered edges the semantic port adapters need. The SDK itself imports nothing from them
|
||||
// today (0 imports across main source) — the semantic cache/session/idempotency/rate-limit
|
||||
// adapters that did were removed and are restored by Phase E of
|
||||
// …
|
||||
// because that restoration is the module's stated responsibility, not because anything here
|
||||
// compiles against them.
|
||||
implementation project(':application-core')
|
||||
implementation project(':shared-contract')
|
||||
implementation project(':adapter:outbound:support')
|
||||
```
|
||||
|
||||
주어절은 맞다.
|
||||
|
||||
```text
|
||||
dev.caskeleton.application 7 파일
|
||||
dev.caskeleton.shared 3 파일
|
||||
dev.caskeleton.adapter.outbound.support 0 파일
|
||||
# 주석의 주어인 sdk 패키지만 보면 : 0
|
||||
```
|
||||
|
||||
`sdk` 패키지는 세 간선 어디에서도 임포트하지 않는다. 틀린 것은 괄호 안의 일반화다. 메인 소스로 넓히면 열 파일이 임포트하고, 셋 중 `support` 만 주석대로 0 이다.
|
||||
|
||||
같은 주석이 그 어댑터들은 제거됐다고 적는데, 위에서 센 파일들이 그것이다. 간선을 남겨 두는 이유로 든 문장도 뒤집힌다 — 여기서 무언가가 그것에 대해 컴파일되기 때문이 아니라고 적혀 있는데, 컴파일된다.
|
||||
|
||||
스무 줄 뒤의 별개 주석은 맞다.
|
||||
|
||||
```groovy
|
||||
// Deliberately absent:
|
||||
// org.springframework.data:spring-data-redis — … Zero imports.
|
||||
// io.micrometer:micrometer-core — … Zero imports.
|
||||
```
|
||||
|
||||
## 어긋난 방향
|
||||
|
||||
표가 없다고 적은 자리마다 코드가 있다. 보통의 표류는 반대 방향이다. 이 문서를 읽고 분기하는 쪽은 이미 있는 코드를 다시 구현하거나, 조립되지 않은 채 존재하는 코드의 존재 자체를 모른다.
|
||||
|
||||
순서를 시사하는 문장이 자동 설정 안에 있다.
|
||||
|
||||
```java
|
||||
* RedisSdkSettings#validate()} the fail-fast its own documentation claims — until this class
|
||||
* existed the method had no production caller at all.
|
||||
```
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
README 가 마지막으로 갱신된 시점과 자동 설정이 추가된 시점을 이력에서 대조하지 않았다. 자동 설정 javadoc 의 문장이 순서를 시사할 뿐이다.
|
||||
|
||||
<!-- body:end -->
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: a10-f004-pub-sub
|
||||
title: 상한을 주입받는 자리는 있고 주입하는 곳은 없다
|
||||
topic: caching-and-redis
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:a10-f004-pub-sub
|
||||
evidenceCapturedOn: 2026-09-02
|
||||
body: case-a10-f004-pub-sub.body.md
|
||||
assets:
|
||||
- key: a10-f004-pub-sub
|
||||
file: ../../../final/evidence/rendered/a10-f004-pub-sub.svg
|
||||
- key: a10-f004-pub-sub-bound
|
||||
file: ../../../final/evidence/rendered/a10-f004-pub-sub-bound.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/a10-f004-pub-sub.txt
|
||||
- ../../../final/evidence/raw/a10-f004-pub-sub-bound.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/10-adapter-outbound-cache-redis.md#L263 이다. 등급은 P3 이다. 세 타입 중 하나만 크기 검사를 부른다는 표, 채널 이름이 Redis 에서 키와 같은 문자열 공간을 쓰고 같은 상한을 받는다는 지적, 구성 요소가 각자 토큰 길이를 제한하므로 현실적인 초과는 어렵다는 단서, 그리고 두 채널 타입의 렌더 본문이 같다는 관찰이 그 절에 있다. 원본이 P3 의 근거로 든 것은 검사가 패턴에는 있고 채널에는 없다는 비대칭 자체다.
|
||||
- 이 기록이 더한 것은 셋이다. 원본이 「현실적인 초과는 어렵다」고만 적은 것에 수를 붙였다 — 최대 388 바이트, 기본 이름공간에서 221 바이트다. 같은 규칙을 받은 조각들로 만든 키가 슬롯 태그가 붙으면 519 바이트가 되어 같은 검사에 거부된다. 그리고 그 검사가 보는 상한이 주입 인자인데 저장소에 주입하는 곳이 없다.
|
||||
- 원본 backlog 가 reachability 로 적어 둔 「긴 namespace/entity/identifier 조합」은 388 바이트가 답이다. 셋을 최대로 채워도 상수 상한을 넘지 않는다. 넘는 경로는 슬롯 태그 쪽이고, 그것도 채널이 아니라 키에서 일어난다.
|
||||
---
|
||||
|
||||
# 상한을 주입받는 자리는 있고 주입하는 곳은 없다
|
||||
|
||||
조립된 문자열이 최대 바이트를 넘지 않는지 보는 검사가 같은 성격의 세 타입 중 하나에만 있다. 검사를 부르는 다른 한 곳은 키 렌더러이고, 키 렌더러는 상수가 아니라 생성자로 받은 상한을 본다. 그 인자를 채우는 코드가 저장소에 없다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **채워 넣은 상한은 자기가 잴 요청에서 값을 가져온다**
|
||||
같은 리프에서 상한 값을 어디서 가져오는지가 문제가 된 다른 사례다.
|
||||
- **R1과 R2의 설정 취급이 비대칭이고, 검증된 쪽은 하나뿐이다**
|
||||
두 사례 모두 같은 성격의 두 자리 중 한쪽에만 검사가 있다.
|
||||
- **그 속성을 보일 수 없는 대역 위에서 통과한 테스트는 증인이 아니다**
|
||||
경계가 어디서 지켜지는지 묻는 규칙이다.
|
||||
|
||||
## 문제
|
||||
|
||||
키 규칙에 조립된 문자열의 렌더 크기를 검사하는 메서드가 있다. Pub/Sub 쪽에는 같은 성격의 타입이 셋 있고, 그중 하나만 그 검사를 부른다.
|
||||
|
||||
## 결론
|
||||
|
||||
부르는 쪽은 패턴 타입이다. 컴팩트 생성자에서 이름공간 접두와 접미를 이어 놓고 크기를 잰다. 재는 때가 렌더보다 한 발 앞이다.
|
||||
|
||||
두 채널 타입은 부르지 않는다. 널 검사만 하고 렌더에서 세 조각을 잇는다.
|
||||
|
||||
상한이 상수 512 인 한 그 빠진 검사가 발화할 입력이 없다. 조각 셋이 모두 같은 규칙을 먼저 통과한 값이다. 이름공간의 세 토큰과 개체 토큰은 최대 64자, 식별자는 최대 128자다. 두 정규식은 ASCII 만 받으므로 길이가 곧 바이트다. 세 토큰을 모두 최대로 채운 렌더가 388 바이트다. 설정 기본 이름공간에서는 221 바이트다.
|
||||
|
||||
패턴은 다르다. 접미에 붙는 규칙은 공백이 아닐 것과 구분자를 넘지 않을 것 둘뿐이고 길이 제한이 없다. 이름공간을 최대치로 잡으면 접미 317자까지 512 바이트로 통과하고 318자에서 513 바이트가 되어 생성자가 거부한다.
|
||||
|
||||
원본의 판단은 조각마다 길이 제한이 있어 현실적인 초과가 어렵다는 것이었다. 그 반례가 같은 패키지의 키 경로에 있다. 키 렌더러는 이름공간과 개체와 식별자 사이에 슬롯 태그를 하나 더 넣는데, 그 태그도 식별자 규칙을 받아 최대 128자다. 넷을 최대로 채우면 519 바이트가 되고 같은 검사가 거부한다. 조각이 규칙을 받는다는 것과 합이 상한 안에 있다는 것은 다른 말이다. 다만 이것도 이름공간이 194 바이트일 때의 값이고, 기본 이름공간에서는 같은 태그를 붙여도 352 바이트다.
|
||||
|
||||
두 번째 차이는 상한을 어디서 가져오느냐다. 키 렌더러가 보는 상한은 생성자 인자이고 1 부터 512 까지 받는다. 패턴은 인자를 받지 않고 상수를 본다. 채널은 아무것도 보지 않는다. 상한 256 으로 만든 렌더러는 388 바이트짜리 키를 거부하는데, 같은 크기의 채널 이름은 그대로 나간다.
|
||||
|
||||
그런데 그 인자를 채우는 배선이 없다. 저장소의 src/main 전체에서 렌더러를 만드는 곳이 0 이고, 만드는 곳 열둘은 전부 시험 소스다. 설정 쪽도 마찬가지다. max-key-bytes 쪽도 같다. 등록과 범위 검증은 지나는데 읽는 자리가 없다.
|
||||
|
||||
그래서 지금 배포에서는 상수 512 가 셋을 다 덮는다. 셋이 갈리는 것은 그 인자가 설정과 이어지는 날이다.
|
||||
|
||||
판정은 P3 이다. 세 타입이 같은 문자열 공간을 쓰는데 상한을 하나는 주입받고 하나는 상수로 박고 하나는 아예 보지 않는다.
|
||||
|
||||
두 채널 타입의 렌더 본문은 서로 완전히 같다. 갈린 이유는 전송 경로이지 렌더링이 아니다 — 군집에서 슬롯을 소유한 샤드로만 전달된다. 타입을 나눈 것은 맞고, 두 벌이 된 것은 렌더 규칙 쪽이다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
확인 방식 : 세 타입의 생성자와 렌더 대조, 구성 요소 규칙 확인, 배선 탐색, 실행 탐침
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 세 타입의 컴팩트 생성자와 렌더 메서드를 각각 읽는다.
|
||||
2. 렌더 크기 검사가 나오는 곳을 저장소 전체에서 센다. 선언과 호출을 구분한다.
|
||||
3. 채널 구성 요소가 받는 토큰과 식별자 규칙, 그리고 상한 상수를 읽는다.
|
||||
4. 키 렌더러가 조각을 어떤 순서로 잇는지, 상한을 어디서 받는지 읽는다.
|
||||
5. 그 렌더러를 만드는 곳과 설정값을 읽는 곳을 src/main 과 src/test 로 나눠 센다.
|
||||
6. 구성 요소를 최대로 채운 채널과 키를, 그리고 기본 이름공간의 채널과 키를 각각 만들어 길이를 잰다.
|
||||
7. 슬롯 태그를 붙인 키를 만들어 같은 검사가 거부하는지 본다.
|
||||
8. 상한 256 으로 만든 렌더러에 같은 키를 넣고, 같은 크기의 채널과 대조한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
키 규칙에 조립된 문자열의 렌더 크기를 재는 메서드가 있고, Pub/Sub 쪽 세 타입 중 하나만 그것을 부른다.
|
||||
|
||||
## 부르는 하나와 부르지 않는 둘
|
||||
|
||||
:::evidence key="a10-f004-pub-sub" alt="Pub/Sub 세 타입의 컴팩트 생성자와 렌더 메서드, 그중 두 채널 타입의 렌더 본문이 같다는 것. 렌더 크기 검사가 나오는 세 줄 — 선언 하나와 호출 둘. 채널 조각이 받는 토큰·식별자 정규식과 상한 상수, 그 규칙을 부르는 세 타입. 키를 렌더하는 메서드가 조각 사이에 슬롯 태그를 넣는 줄과 그 상한을 생성자로 받는 줄. 그 생성자를 부르는 곳을 src/main 과 src/test 로 나눠 센 수, 설정값을 읽는 src/main 코드, 그리고 기본 이름공간 값을 출력한 터미널 기록." caption="크기 검사를 부르는 곳은 키 렌더러와 패턴 생성자 둘 · 두 채널 타입은 널 검사만 하고 렌더 본문이 동일 · 조각은 토큰 64자와 식별자 128자 규칙을 받고 슬롯 태그도 같은 규칙 · 렌더러 생성은 src/main 0건 src/test 12건이고 max-key-bytes 를 읽는 src/main 코드도 없음 — 89줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
```java
|
||||
public PubSubPattern {
|
||||
...
|
||||
if (suffixPattern.isBlank() || suffixPattern.indexOf(':') >= 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"a pattern suffix must be non-blank and must not cross a namespace separator");
|
||||
}
|
||||
RedisKeyRules.requireRenderedSize(
|
||||
namespace.prefix() + ':' + suffixPattern, RedisKeyRules.MAX_KEY_BYTES);
|
||||
}
|
||||
```
|
||||
|
||||
렌더 시점이 아니라 생성 시점에 잰다. 두 채널 타입의 생성자에는 널 검사만 있다.
|
||||
|
||||
```text
|
||||
sdk/api/key/RedisKeyRules.java:89: public static String requireRenderedSize(String rendered, int maxKeyBytes) {
|
||||
sdk/api/key/RedisKeyRenderer.java:45: return RedisKeyRules.requireRenderedSize(rendered.toString(), maxKeyBytes);
|
||||
sdk/api/operations/PubSubPattern.java:30: RedisKeyRules.requireRenderedSize(
|
||||
```
|
||||
|
||||
첫 줄은 선언이고 부르는 곳은 아래 둘이다. 시험 소스까지 포함해 이게 전부다.
|
||||
|
||||
## 조각이 받는 규칙
|
||||
|
||||
```text
|
||||
19: public static final int MAX_KEY_BYTES = 512;
|
||||
21: private static final Pattern TOKEN = Pattern.compile("^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$");
|
||||
23: private static final Pattern IDENTIFIER = Pattern.compile("^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$");
|
||||
91: if (maxKeyBytes < 1 || maxKeyBytes > MAX_KEY_BYTES) {
|
||||
92: throw new IllegalArgumentException("maximum key bytes must be in 1.." + MAX_KEY_BYTES);
|
||||
RedisNamespace.java:16: RedisKeyRules.requireToken("environment", environment);
|
||||
RedisNamespace.java:17: RedisKeyRules.requireToken("service", service);
|
||||
RedisNamespace.java:18: RedisKeyRules.requireToken("domain", domain);
|
||||
RedisKeyName.java:12: RedisKeyRules.requireToken("entity", entity);
|
||||
RedisKeyName.java:13: RedisKeyRules.requireIdentifier(identifier);
|
||||
RedisSlotTag.java:15: RedisKeyRules.requireIdentifier(value);
|
||||
```
|
||||
|
||||
채널이 잇는 세 조각은 전부 이 규칙을 통과한 값이다. 두 정규식이 ASCII 밖을 받지 않아 바이트 수가 자 수와 같다. 패턴이 잇는 접미는 규칙을 받지 않는다 — 공백이 아닐 것과 구분자를 넘지 않을 것뿐이다.
|
||||
|
||||
## 최대로 채워 보면
|
||||
|
||||
:::evidence key="a10-f004-pub-sub-bound" alt="이름공간 세 토큰과 개체 토큰과 식별자를 각 규칙의 최대치로 채워 만든 두 채널 타입의 렌더 길이, 같은 조각으로 만든 키와 거기에 슬롯 태그를 더했을 때의 결과, 설정 기본 이름공간으로 만든 같은 둘의 길이, 접미 길이를 64·317·318 로 바꿔 가며 만든 패턴의 결과, 그리고 상한 256 으로 만든 렌더러가 같은 키와 같은 크기의 채널을 각각 어떻게 처리하는지 출력한 터미널 기록." caption="최대로 채운 채널 렌더는 388 바이트, 기본 이름공간에서는 221 바이트 · 같은 조각에 슬롯 태그를 더한 키는 519 바이트로 거부되지만 기본 이름공간에서는 352 바이트 · 상한 256 렌더러는 388 바이트 키를 거부하고 같은 크기 채널은 검사 자체가 없음 — 28줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
```text
|
||||
[구성 요소의 상한] 토큰 64, 식별자 128, 슬롯 태그 128
|
||||
namespace.prefix() 길이 : 194
|
||||
|
||||
[채널] 구성 요소를 최대로 채운 렌더
|
||||
PubSubChannel 렌더 388 바이트 여유 124
|
||||
ShardedPubSubChannel 렌더 388 바이트 여유 124
|
||||
```
|
||||
|
||||
이 388 바이트는 이름공간 세 토큰을 모두 64자로 채웠을 때의 값이다. 설정 기본값인 `local:sample-service:shared` 는 27 바이트라 같은 조각으로 만든 채널이 221 바이트에 그친다.
|
||||
|
||||
패턴은 같은 최대 이름공간에서 접미 317자까지 512 바이트로 통과하고 318자에서 넘긴다.
|
||||
|
||||
## 조각이 규칙을 받는다는 말의 한계
|
||||
|
||||
원본은 조각이 각자 길이를 제한하므로 현실적인 초과가 어렵다고 봤다. 같은 규칙을 받은 조각들이 상한을 넘는 경우가 같은 패키지에 있다.
|
||||
|
||||
```java
|
||||
rendered.append(key.namespace().prefix()).append(':');
|
||||
key.slotTag().ifPresent(tag -> rendered.append('{').append(tag.value()).append("}:"));
|
||||
rendered.append(key.name().entity()).append(':').append(key.name().identifier());
|
||||
return RedisKeyRules.requireRenderedSize(rendered.toString(), maxKeyBytes);
|
||||
```
|
||||
|
||||
키 렌더러는 조각 사이에 슬롯 태그를 하나 더 넣는다. 그 태그도 식별자 규칙을 받아 최대 128자다.
|
||||
|
||||
```text
|
||||
[키] 같은 규칙을 받은 조각들, 슬롯 태그 하나가 더 붙는다
|
||||
태그 없음 -> 렌더 388 바이트
|
||||
태그 있음 -> rendered key is 519 bytes and exceeds the configured 512
|
||||
|
||||
[기본 이름공간] 설정 기본값 local:sample-service:shared
|
||||
prefix 길이 : 27
|
||||
채널 렌더 : 221 바이트
|
||||
태그 붙인 키 : 352 바이트
|
||||
```
|
||||
|
||||
519 바이트도 이름공간이 194 바이트일 때의 값이다. 기본 이름공간에서는 같은 태그를 붙여도 352 바이트로 통과한다.
|
||||
|
||||
## 상한을 어디서 가져오는가
|
||||
|
||||
```text
|
||||
[상한을 어디서 가져오는가]
|
||||
RedisKeyRenderer : 생성자 인자 (1..512)
|
||||
PubSubPattern : RedisKeyRules.MAX_KEY_BYTES 상수
|
||||
상한 256 렌더러에 키 -> rendered key is 388 bytes and exceeds the configured 256
|
||||
같은 배포의 채널 388 바이트 -> 검사 없음
|
||||
```
|
||||
|
||||
키 렌더러만 상한을 주입받는다. 그런데 주입하는 곳이 없다.
|
||||
|
||||
```text
|
||||
src/main 에서 new RedisKeyRenderer( : 0 건
|
||||
src/test 에서 new RedisKeyRenderer( : 12 건
|
||||
getMaxKeyBytes / getLimits 를 부르는 src/main 코드
|
||||
sdk/config/RedisSdkSettings.java:272: public int getMaxKeyBytes() {
|
||||
sdk/config/RedisSdkSettings.java:908: public Limits getLimits() {
|
||||
```
|
||||
|
||||
두 줄 다 선언 자신이다. `max-key-bytes` 는 환경 키 레지스트리에 등록되어 있고 부팅 검증이 1..512 범위까지 보지만, 읽는 코드가 없다.
|
||||
|
||||
그래서 이 대비는 지금 배포에서 일어나는 일이 아니다. 그 인자가 설정과 이어지는 날 일어날 일이다.
|
||||
|
||||
채널 이름이 렌더러를 지나가는 일도 없다. Pub/Sub 요청은 `channel.render()` 를 직접 부르고 키 목록으로 빈 리스트를 넘긴다.
|
||||
|
||||
## 남는 것
|
||||
|
||||
두 채널 타입은 렌더 본문이 한 글자도 다르지 않다.
|
||||
|
||||
```java
|
||||
public String render() {
|
||||
return namespace.prefix() + ':' + name.entity() + ':' + name.identifier();
|
||||
}
|
||||
```
|
||||
|
||||
갈린 이유는 전송 경로다. 군집에서 슬롯을 소유한 샤드로만 전달된다는 성질이지 렌더링이 아니다. 분리 자체는 옳고, 렌더 규칙만 두 벌이라 한 곳에서 고칠 수 없다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
상한을 넘는 채널 이름을 브로커에 보내면 어떻게 되는지 확인하지 않았다. 상수 상한을 넘는 이름은 이 타입들로 만들 수 없고, 상한을 낮춘 렌더러로 만든 키는 애초에 나가지 못한다.
|
||||
|
||||
<!-- body:end -->
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: a10-f005-hyperloglog-merge
|
||||
title: 채워 넣은 상한은 자기가 잴 요청에서 값을 가져온다
|
||||
topic: caching-and-redis
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:a10-f005-hyperloglog-merge
|
||||
evidenceCapturedOn: 2026-09-02
|
||||
body: case-a10-f005-hyperloglog-merge.body.md
|
||||
assets:
|
||||
- key: a10-f005-hyperloglog-merge
|
||||
file: ../../../final/evidence/rendered/a10-f005-hyperloglog-merge.svg
|
||||
- key: a10-f005-hyperloglog-merge-scope
|
||||
file: ../../../final/evidence/rendered/a10-f005-hyperloglog-merge-scope.svg
|
||||
- key: a10-f005-hyperloglog-merge-budget
|
||||
file: ../../../final/evidence/rendered/a10-f005-hyperloglog-merge-budget.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/a10-f005-hyperloglog-merge.txt
|
||||
- ../../../final/evidence/raw/a10-f005-hyperloglog-merge-scope.txt
|
||||
- ../../../final/evidence/raw/a10-f005-hyperloglog-merge-budget.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/10-adapter-outbound-cache-redis.md#L277 이다. 등급은 P3 이다. 다중 키 fan-in 표 다섯 줄 중 확률적 집계 둘만 서명에 예산 인자가 없다는 표, 두 명령의 비용이 입력 레지스터 수에 비례한다는 지적, 레지스터가 12KB 고정이라 폭발 범위가 좁다는 판단, 그리고 규칙의 예외가 이유 없이 존재한다는 문장이 그 절에 있다. 그 표의 첫 줄이 집합 대수 셋을 묶은 것이라 연산 수로는 일곱이다.
|
||||
- 이 기록에서 확인한 것은 셋이다. 두 명령도 예산 없이는 관문을 통과하지 못하고, 그 예산은 SDK가 채우며, 채운다는 설계는 상한 타입의 첫 문단에 적혀 있다.
|
||||
- 남는 문제는 원본이 든 것과 다르다. 예산을 만드는 세 메서드가 요청 바이트 상한을 잴 대상에서 그대로 가져오므로, 이 두 명령뿐 아니라 SDK가 예산을 채우는 R2 명령 전부에서 관문의 요청 바이트 검사가 발화하지 못한다.
|
||||
---
|
||||
|
||||
# 채워 넣은 상한은 자기가 잴 요청에서 값을 가져온다
|
||||
|
||||
여러 키를 읽어 하나에 쓰는 연산 일곱 중 다섯은 호출자에게 비용 상한을 받고 둘은 받지 않는다. 그 둘도 예산 없이는 관문을 통과하지 못한다. SDK가 대신 만들어 넣는데, 그 상한의 요청 바이트 항목이 자기가 잴 요청의 크기에서 값을 가져온다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **상한을 주입받는 자리는 있고 주입하는 곳은 없다**
|
||||
같은 리프에서 상한을 받을 자리는 있는데 넣어 주는 코드가 없다.
|
||||
- **미배선 인터셉터는 누락이 아니라 중복이다**
|
||||
두 사례 모두 빠진 것으로 읽은 자리에 실제로는 다른 형태의 구현이 있었다.
|
||||
- **타입이 문서화한 불변식은 타입이 강제한다**
|
||||
예산 타입은 기본값으로 채워지지 않는다고 적어 두고 강제하지 않는다.
|
||||
|
||||
## 문제
|
||||
|
||||
여러 키를 읽어 하나에 쓰고 비용이 입력 크기에 비례하는 연산은 이 계층에 일곱이다. 원본 표는 집합 대수 셋을 한 줄로 묶어 다섯 줄로 적었다. 그중 다섯에는 호출자가 비용 상한을 건네도록 서명에 인자를 두고, 확률적 집계 계열 둘에는 두지 않는다.
|
||||
|
||||
인자가 없다는 것과 상한이 없다는 것이 같은 말인지 확인했다.
|
||||
|
||||
## 결론
|
||||
|
||||
같은 말이 아니다.
|
||||
|
||||
정책 카탈로그에서 두 명령은 R2 다. 관문은 R2 요청의 예산이 비어 있으면 거부한다. 예산 없이는 실행 자체가 안 된다.
|
||||
|
||||
빌더가 예산을 직접 조립해 요청에 얹는다. 두 곳 모두 요소 수와 요청 바이트를 인자로 넘긴다.
|
||||
|
||||
채워 넣는다는 것 자체는 적혀 있다. 상한을 모아 둔 타입의 첫 문단이 서명에 예산이 없을 때 적용하는 천장이라고 말하고, 호출자가 건넨 쪽이 언제나 이긴다고 덧붙인다.
|
||||
|
||||
그 문단은 R2 전체를 설명하지 않는다. 비교 대상 셋과 해시 전체 읽기는 허가와 예산을 둘 다 호출자에게 받는다. 반대로 WATCH 와 BLPOP 은 둘 다 SDK가 자기에게 발급한다. 확률적 집계는 SMOVE 나 RENAME 이나 MGET 과 같은 자리에 있다. 허가만 호출자에게 받는 쪽이다.
|
||||
|
||||
이것이 두 곳만의 방식도 아니다. src/main 에 이름이 나오는 R2 명령 쉰여섯 중 서른넷이 SDK 쪽이고 열넷이 호출자 서명 쪽이다. 나머지 여덟은 예산을 다른 파일에서 조립하거나 직접 만들어 이 스캔으로는 가리지 못했다.
|
||||
|
||||
실행해 보면 요청 바이트 항목은 어떤 크기에서도 통과한다. 상한을 요청 크기에서 그대로 가져오기 때문이다. 렌더된 키는 타입 상한인 512 바이트를 넘지 못하고 기본 설정의 요소 천장이 1000 이라 이 경로의 요청은 512000 바이트 아래인데, 그 상한선에서도 예산의 상한은 같은 512000 이다.
|
||||
|
||||
관문은 요소 수를 보지 않는다. 요소 수를 막는 것은 예산을 만드는 쪽이고, 그것도 예산이 만들어지기 전에 던진다. 관문에 도착한 예산이 실제로 기여하는 것은 타임아웃 하나다.
|
||||
|
||||
같은 패키지에 반대 문장이 있다. 예산 타입의 첫 문단은 모든 R2 API가 예산을 요구하며 기본값으로 채워지지 않는다고 적는다. 두 문장 중 코드가 지키는 쪽은 상한 타입이다.
|
||||
|
||||
판정은 P3 이되 이유가 다르다. 원본은 인자가 없는 것을 이유로 들었는데, 실제로 남는 문제는 채워 넣은 상한이 관문의 요청 바이트 검사를 발화시킬 수 없다는 것이고, 그것은 이 두 명령만의 일이 아니다. 예산을 만드는 세 메서드가 전부 같은 식을 쓴다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
확인 방식 : 서명과 정책 카탈로그 대조, 요청 빌더 추적, 실행 탐침
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 여러 키를 읽어 하나에 쓰는 연산 일곱의 서명을 나란히 읽는다.
|
||||
2. 정책 카탈로그에서 그 명령들의 위험 등급을 확인한다.
|
||||
3. 관문이 R2 요청의 빈 예산을 어떻게 처리하는지 읽는다.
|
||||
4. 확률적 집계 요청 빌더가 예산을 어디서 얻는지 따라간다.
|
||||
5. 예산을 만드는 세 메서드가 요청 바이트 항목에 무엇을 넣는지 읽는다.
|
||||
6. 그 메서드를 여러 요청 크기로 불러 상한과 판정을 출력한다.
|
||||
7. R2 명령마다 예산이 호출자 서명에서 오는지 SDK가 만드는지 가려 센다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
확률적 집계 계열의 다중 키 연산 둘은 호출자에게 비용 상한을 받지 않는다. 같은 성격의 다른 다섯은 받는다.
|
||||
|
||||
## 인자가 없는 것과 상한이 없는 것
|
||||
|
||||
:::evidence key="a10-f005-hyperloglog-merge" alt="여러 키를 읽어 하나에 쓰는 연산 일곱의 서명 — 다섯에는 비용 상한 인자가 있고 둘에는 없다. 정책 카탈로그가 그 명령들에 매긴 위험 등급, 관문이 R2 요청의 빈 예산을 거부하는 구문, 확률적 집계 요청 빌더가 예산을 만들어 넣는 두 줄과 그 메서드가 상한을 정하는 세 줄, 같은 식을 쓰는 다른 두 메서드의 줄, 그리고 이 설계를 적어 둔 문단과 같은 패키지에서 반대로 적어 둔 문단을 출력한 터미널 기록." caption="일곱 중 다섯의 서명에만 OperationBudget 인자가 있고 확률적 집계 둘은 없다 · 카탈로그 등급은 전부 R2 · 관문은 R2 요청의 빈 예산을 거부하므로 요청 빌더가 예산을 만들어 넣는다 · 요청 바이트 상한을 요청 크기에서 가져오는 식이 세 메서드에 모두 있다 — 112줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
```java
|
||||
long count(Collection<? extends HyperLogLogKey<?>> keys, MultiKeyPermit permit);
|
||||
void merge(
|
||||
HyperLogLogKey<?> destination,
|
||||
Collection<? extends HyperLogLogKey<?>> sources,
|
||||
MultiKeyPermit permit);
|
||||
```
|
||||
|
||||
앞의 다섯에는 `OperationBudget budget` 이 마지막 인자로 붙어 있다. 이 둘에는 없다. 그런데 카탈로그에서 두 명령은 `R2` 이고, 관문은 이렇게 한다.
|
||||
|
||||
```java
|
||||
if (request.budget().isEmpty()) {
|
||||
throw new RedisCommandRejectedException(
|
||||
"R2 command requires permit and budget", metadata(policy, OptionalInt.empty()));
|
||||
}
|
||||
```
|
||||
|
||||
예산이 비면 실행되지 않는다. 빌더가 직접 조립해 얹는다.
|
||||
|
||||
```text
|
||||
69: Optional.of(context.collectionBudget(rendered.qualified().size(), rendered.requestBytes())),
|
||||
93: Optional.of(context.collectionBudget(qualified.size(), size)),
|
||||
```
|
||||
|
||||
## 채워 넣는다고 적힌 곳
|
||||
|
||||
```text
|
||||
/**
|
||||
* The ceilings the typed operations apply when the public signature does not carry a budget.
|
||||
*
|
||||
* <p>Design section 10 gives some R2 methods a caller-supplied {@code OperationBudget} and others a
|
||||
* caller-supplied permit, but {@code CommandPolicyGuard} requires both for every R2 command. These
|
||||
* limits are what the SDK fills in for the half the signature omits, so an R2 command is never
|
||||
* admitted with an unbounded cost. The caller-supplied half always wins; this only supplies what
|
||||
* the caller had no way to pass.
|
||||
*
|
||||
```
|
||||
|
||||
확률적 집계는 허가만 호출자에게 받고 예산은 SDK가 채운다.
|
||||
|
||||
이 문단을 R2 전체의 규칙으로 읽으면 틀린다. 집합 대수와 비트 연산과 지리 검색 저장과 해시 전체 읽기는 허가와 예산을 둘 다 호출자에게 받는다. 반대로 `WATCH` 와 `BLPOP` 은 둘 다 SDK가 자기에게 발급한다 — `BLPOP` 의 서명에는 허가 인자가 아예 없다. `BLMOVE` 는 둘 다인 것처럼 보이지만 아니다. 호출자의 다중 키 허가를 문맥이 먼저 검증하고, 관문에는 정책 이름이 맞는 SDK 허가를 대신 건넨다.
|
||||
|
||||
같은 방식이 R2 전반에 있다.
|
||||
|
||||
:::evidence key="a10-f005-hyperloglog-merge-scope" alt="src/main 에 이름이 문자열로 나오는 R2 명령마다, 요청을 만드는 메서드의 서명이 비용 상한을 인자로 받는지 아니면 그 메서드 또는 같은 파일의 헬퍼에서 SDK가 만들어 넣는지 가려 센 터미널 기록. 두 단계까지 따라가고도 출처를 못 가린 명령은 따로 적는다." caption="R2 명령 56개 중 34개는 SDK가 예산을 만들고 14개는 호출자 서명이 받는다 · 나머지 8개는 예산이 다른 파일에 있어 미판정 — 25줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
## 채워 넣은 예산이 막는 것
|
||||
|
||||
:::evidence key="a10-f005-hyperloglog-merge-budget" alt="예산을 만드는 메서드를 세 가지 요청 크기로 불러 얻은 요청 바이트 상한과 그 요청에 대한 판정, 이 경로의 요청이 가질 수 있는 최대치를 키 상한과 요소 천장에서 계산한 값, 같은 크기를 거부하는 호출자 예산 하나, 요소 수를 천장과 천장 초과로 부른 결과, 그리고 두 명령이 선언하는 예상 회신 크기에 대한 판정을 출력한 터미널 기록. 실행에 쓴 자바 판을 첫 줄에 함께 적는다." caption="요청 바이트 상한이 요청 크기와 같아 이 경로의 상한선 512000 바이트에서도 통과 · 호출자가 건넨 상한 4096 은 8192 요청을 거부 · 실제로 거부되는 것은 요소 천장 초과뿐이고 그 거부는 KEY 계열로 기록된다 — 16줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
예산을 만드는 메서드는 요청 바이트 상한을 이렇게 정한다.
|
||||
|
||||
```java
|
||||
long replyCeiling = (long) elements * limits.maxReplyBytesPerElement();
|
||||
return new OperationBudget(
|
||||
elements, Math.max(1L, requestBytes), replyCeiling, limits.collectionTimeout());
|
||||
```
|
||||
|
||||
상한이 요청 크기다. 관문이 그 둘을 비교한다.
|
||||
|
||||
```text
|
||||
요청 512 -> maxRequestBytes 512 allowsRequestBytes(요청) true
|
||||
요청 4096 -> maxRequestBytes 4096 allowsRequestBytes(요청) true
|
||||
요청 512000 -> maxRequestBytes 512000 allowsRequestBytes(요청) true
|
||||
이 경로의 요청 최대 : 렌더된 키 512 바이트 x 요소 천장 1000 = 512000 바이트
|
||||
```
|
||||
|
||||
기본 설정에서 이 경로가 만들 수 있는 가장 큰 요청에서도 통과한다. 호출자가 건넨 상한은 다르다.
|
||||
|
||||
```text
|
||||
maxRequestBytes 4096, 요청 8192 -> allowsRequestBytes false
|
||||
```
|
||||
|
||||
회신 검사도 지나가는데, 이쪽은 맞는 값이다. `PFCOUNT` 는 정수 하나, `PFMERGE` 는 `OK` 하나라 두 요청이 선언하는 `0L` 이 실제 크기다.
|
||||
|
||||
거부되는 것은 요소 수 하나다.
|
||||
|
||||
```text
|
||||
요소 1001개 -> RedisCommandRejectedException: operation over 1001 elements exceeds the configured ceiling of 1000 [command=KEY, mode=STANDALONE, ambiguous=false]
|
||||
```
|
||||
|
||||
그 거부는 관문이 아니라 예산을 만드는 쪽에서, 예산이 만들어지기 전에 나온다. 그리고 `command=KEY` 다. 계열 이름이 `"KEY"` 로 고정돼 있어서, 병합 하나가 천장을 넘긴 일이 운영자에게는 키 연산으로 기록된다. 이 자리를 계열 이름으로 먼저 거르는 검사가 확률적 집계에는 없기 때문이다.
|
||||
|
||||
관문까지 간 예산에서 실제로 쓰이는 것은 타임아웃뿐이다.
|
||||
|
||||
## 두 명령만의 일이 아니다
|
||||
|
||||
```text
|
||||
313: elements, Math.max(1L, requestBytes), replyCeiling, limits.collectionTimeout());
|
||||
336: Math.max(1L, requestBytes),
|
||||
349: 1, Math.max(1L, requestBytes), limits.maxReplyBytesPerElement(), limits.scriptTimeout());
|
||||
```
|
||||
|
||||
예산을 만드는 메서드가 셋이고 셋 다 같은 식을 쓴다. 그러니 이 성질은 SDK가 예산을 채우는 R2 명령 서른넷 전부에 있다.
|
||||
|
||||
## 반대로 적어 둔 곳
|
||||
|
||||
```text
|
||||
/**
|
||||
* Explicit bound a caller accepts for one advanced operation.
|
||||
*
|
||||
* <p>Every R2 API requires a budget. The budget is never optional and never defaulted, because the
|
||||
* whole point is that the caller states the cost it is prepared to pay before Redis is asked.
|
||||
*/
|
||||
```
|
||||
|
||||
구현이 따르는 것은 상한 타입 쪽이다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
실제 서버에 큰 병합을 보내 지연이 얼마나 커지는지 재지 않았다.
|
||||
|
||||
512000 은 기본 설정에서의 상한선이다. 요소 천장은 설정에서 올릴 수 있고, 이 리비전에서 요청 문맥을 만드는 곳은 테스트뿐이라 배포에서 실제로 쓰이는 천장은 아직 없다.
|
||||
|
||||
예산의 출처를 못 가린 여덟 명령은 확인하지 않았다. 그 여덟은 예산을 다른 파일의 공용 실행기에서 받거나 등록된 함수의 자체 한도로 직접 만든다.
|
||||
|
||||
<!-- body:end -->
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: a10-f006-requireidentifier
|
||||
title: 지워도 test가 초록인 검사가 셋이다
|
||||
topic: caching-and-redis
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:a10-f006-requireidentifier
|
||||
evidenceCapturedOn: 2026-09-02
|
||||
body: case-a10-f006-requireidentifier.body.md
|
||||
assets:
|
||||
- key: a10-f006-requireidentifier
|
||||
file: ../../../final/evidence/rendered/a10-f006-requireidentifier.svg
|
||||
- key: a10-f006-requireidentifier-branch
|
||||
file: ../../../final/evidence/rendered/a10-f006-requireidentifier-branch.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/a10-f006-requireidentifier.txt
|
||||
- ../../../final/evidence/raw/a10-f006-requireidentifier-branch.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/10-adapter-outbound-cache-redis.md#L375 이다. 등급은 P3 이다. 문자 클래스가 메일과 전화 형태의 필수 문자를 이미 배제하므로 두 분기가 도달 불가라는 관찰, 대응 test 가 예외 타입만 보므로 그 사실을 가리지 않는다는 지적, 보안 효과는 그대로이고 잃는 것은 진단 품질과 검증 겹수의 착시라는 판단, 그리고 두 분기를 지우거나 검사 순서를 뒤집으라는 제안이 그 절에 있다.
|
||||
- 이 기록이 더한 것은 셋이다. 두 입력에 실제로 돌아오는 메시지가 문자 클래스 메시지라는 실행 결과. 웹 토큰 분기는 도달하지만 그것을 겨냥한 test 입력이 접두 검사에도 걸려 지워도 초록이고, 혼자 잡는 것은 실제 토큰이 아니라 합성 값이라는 것. 그리고 네 메시지를 단언하는 곳이 저장소에 하나도 없다는 것이다. 원본이 도달 불가로 센 것은 둘이고, 지워도 test 가 초록인 것은 셋이다.
|
||||
---
|
||||
|
||||
# 지워도 test가 초록인 검사가 셋이다
|
||||
|
||||
식별자 검증이 다섯 겹으로 보인다. 그중 둘은 앞선 문자 클래스 검사가 이미 걸러내 도달하지 않고, 하나는 도달하지만 그것을 겨냥한 test 입력이 뒤 검사에도 걸린다. 셋 다 지워도 test 는 초록이다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **커버리지 gate 둘이 나란히 있고, 하나는 발화할 수 없다**
|
||||
두 사례 모두 조건이 성립할 수 없어 그 분기가 실행되지 않는다.
|
||||
- **그 속성을 보일 수 없는 대역 위에서 통과한 테스트는 증인이 아니다**
|
||||
예외 타입만 보는 단언은 어느 검사가 던졌는지 구분하지 않는다.
|
||||
- **상한을 주입받는 자리는 있고 주입하는 곳은 없다**
|
||||
같은 리프의 다른 검증 사례다.
|
||||
|
||||
## 문제
|
||||
|
||||
식별자 검증에 검사가 다섯 있다. 문자 클래스 하나와 구체적 형태 넷이다. 메일 주소, 웹 토큰, 국제 전화번호, 인증 재료 접두다.
|
||||
|
||||
각 검사가 실제로 발화하는지, 그리고 발화한다면 어느 test 가 그것을 붙들고 있는지 확인했다.
|
||||
|
||||
## 결론
|
||||
|
||||
문자 클래스는 첫 문자로 영숫자를 요구하고 이후 문자로 영숫자와 점과 물결과 밑줄과 붙임표를 허용한다. 길이는 128 이하다.
|
||||
|
||||
거기에 골뱅이도 더하기도 없다.
|
||||
|
||||
그래서 메일 분기는 도달하지 않는다. 골뱅이를 포함한 값은 첫 검사에서 탈락한다. 전화 분기도 도달하지 않는다. 국제 전화번호 패턴이 반드시 더하기로 시작하는데 더하기는 첫 문자로도 이후 문자로도 허용되지 않는다.
|
||||
|
||||
실행으로 확인했다. 두 형태를 넣으면 돌아오는 메시지가 문자 클래스 메시지다. 무작위 입력 20만 개에서도 이 두 검사에서 갈린 값이 하나도 없다.
|
||||
|
||||
웹 토큰 분기는 도달한다. 웹 토큰 패턴이 쓰는 문자를 문자 클래스가 모두 허용하므로, 26자에서 128자 사이이고 영숫자로 시작하는 토큰 형태는 첫 검사를 통과해 전용 검사에 닿는다.
|
||||
|
||||
다만 그것이 잡는 것은 진짜 토큰이 아니다. 진짜 토큰은 늘 같은 세 글자로 시작하고 길이도 128자를 넘긴다. 그런 값은 접두 검사나 문자 클래스가 먼저 잡는다. 혼자 걸리는 값은 토큰 흉내를 낸 합성 문자열뿐이다.
|
||||
|
||||
그리고 그것을 겨냥한 test 입력 하나가 다음 검사에도 걸린다. 그 값이 eyJ 로 시작해서 접두 검사가 같은 타입의 예외를 낸다. 웹 토큰 분기를 지워도 그 test 는 초록이다.
|
||||
|
||||
남는 둘은 붙들려 있다. 문자 클래스 검사는 128자 초과 입력과 구분자 주입을 거부하는 test 가 붙들고, 접두 검사는 웹 토큰 형태가 아닌 두 입력이 붙든다. 둘 중 하나를 지우면 대응 test 가 빨개진다.
|
||||
|
||||
저 네 메시지는 저장소에서 던지는 자리에만 있다. 단언하는 곳이 없다.
|
||||
|
||||
판정은 P3 이다.
|
||||
|
||||
거부는 그대로다. 세 형태는 여전히 전부 거부된다.
|
||||
|
||||
잃는 것은 둘이다. 운영자가 받는 메시지가 구체 형태에서 문자 클래스로 내려앉는다. 그리고 세 분기가 test 에 붙들리지 않은 채 검증이 다섯 겹인 것처럼 보이게 만든다.
|
||||
|
||||
원본은 두 분기를 지우고 문자 클래스 메시지에 그 의도를 포함시키거나, 검사 순서를 뒤집어 구체적 형태를 먼저 판정하는 수정을 제안했다. 뒤집는 쪽을 고르면 세 분기가 전부 발화한다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
확인 방식 : 문자 클래스와 후속 분기 대조, 실행 탐침, test 단언 대상 확인
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 식별자 검증의 다섯 검사를 순서대로 읽는다.
|
||||
2. 각 검사가 쓰는 패턴 넷을 읽는다.
|
||||
3. 각 형태 검사가 요구하는 필수 문자가 문자 클래스 안에 있는지 본다.
|
||||
4. 이 메서드에 값을 넣는 test 를 모아 무엇을 단언하는지 확인한다.
|
||||
5. 그 입력들을 그대로 넣고 실제로 돌아오는 메시지를 본다.
|
||||
6. 각 입력이 다섯 중 어느 검사에 걸리는지 전부 표시한다.
|
||||
7. 웹 토큰 분기에만 걸리는 값과, 실제 크기의 토큰을 각각 넣어 본다.
|
||||
8. 무작위 입력으로 각 검사에서 갈린 수를 센다.
|
||||
9. 저장소 전체에서 네 메시지가 나오는 곳을 센다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
검사는 아래 순서로 돈다.
|
||||
|
||||
## 다섯 검사
|
||||
|
||||
:::evidence key="a10-f006-requireidentifier" alt="식별자 검증 메서드의 다섯 검사 전체와 그 검사들이 쓰는 정규식 넷, 이 메서드에 값을 넣는 test 다섯 개가 무엇을 단언하는지, 그리고 저장소 전체에서 네 예외 메시지가 나오는 곳을 파일 형식 제한 없이 센 터미널 기록." caption="검사 다섯은 문자 클래스·메일·웹 토큰·전화·접두 순 · test 다섯 개의 단언은 모두 예외 타입뿐 · 네 메시지는 던지는 자리 넷에만 있고 단언하는 곳이 없다 — 78줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
```java
|
||||
if (value == null || !IDENTIFIER.matcher(value).matches()) {
|
||||
throw new IllegalArgumentException(
|
||||
"identifier must be 1..128 characters of [A-Za-z0-9._~-] and must not contain a"
|
||||
+ " key separator");
|
||||
}
|
||||
String lowerCase = value.toLowerCase(Locale.ROOT);
|
||||
if (value.indexOf('@') >= 0) {
|
||||
throw new IllegalArgumentException("identifier must not contain a mail address");
|
||||
}
|
||||
if (JSON_WEB_TOKEN.matcher(value).matches()) {
|
||||
throw new IllegalArgumentException("identifier must not contain a JSON web token");
|
||||
}
|
||||
if (INTERNATIONAL_PHONE.matcher(value).matches()) {
|
||||
throw new IllegalArgumentException("identifier must not contain a phone number");
|
||||
}
|
||||
if (lowerCase.startsWith("bearer") || lowerCase.startsWith("eyj")) {
|
||||
throw new IllegalArgumentException("identifier must not contain authentication material");
|
||||
}
|
||||
```
|
||||
|
||||
첫 검사가 쓰는 문자 클래스에는 골뱅이도 더하기도 없다.
|
||||
|
||||
```text
|
||||
private static final Pattern IDENTIFIER = Pattern.compile("^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$");
|
||||
|
||||
private static final Pattern JSON_WEB_TOKEN =
|
||||
Pattern.compile("^[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}$");
|
||||
|
||||
private static final Pattern INTERNATIONAL_PHONE = Pattern.compile("^\\+\\d[\\d.~-]{7,}$");
|
||||
```
|
||||
|
||||
## 넣어 보면 어느 메시지가 오는가
|
||||
|
||||
:::evidence key="a10-f006-requireidentifier-branch" alt="test 가 쓰는 여섯 입력을 실제 검증 메서드에 넣어 돌아온 메시지와, 각 입력이 다섯 검사 중 어디에 걸리는지 전부 표시한 표. 웹 토큰 분기에만 걸리는 합성 값, 밑줄로 시작하는 값, 실제 크기의 토큰. 그리고 무작위 입력 20만 개로 옮겨 적은 정규식과 실제 메시지를 대조하고 각 검사에서 갈린 수를 함께 센 터미널 기록." caption="메일·전화 입력이 받는 메시지는 문자 클래스 메시지 · 웹 토큰 test 입력은 접두 검사에도 걸리고 실제 크기 토큰은 문자 클래스에서 먼저 걸린다 · 무작위 20만 개에서 메일·전화 검사에서 갈린 입력은 0 — 27줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
```text
|
||||
입력 길이 걸리는 검사 전부 | requireIdentifier 가 낸 메시지
|
||||
test: 메일 18 문자클래스 메일 | identifier must be 1..128 characters of [A-Za-z0-9._~-] and must not contain a key separator
|
||||
test: 전화 13 문자클래스 전화 | identifier must be 1..128 characters of [A-Za-z0-9._~-] and must not contain a key separator
|
||||
test: eyj 20 접두 | identifier must not contain authentication material
|
||||
test: bearer 19 접두 | identifier must not contain authentication material
|
||||
test: JWT 57 JWT 접두 | identifier must not contain a JSON web token
|
||||
test: 129자 129 문자클래스 | identifier must be 1..128 characters of [A-Za-z0-9._~-] and must not contain a key separator
|
||||
```
|
||||
|
||||
메일과 전화 입력은 전용 검사 앞에서 이미 걸린다. 문자 클래스가 골뱅이와 더하기를 빼 놓았으니 그 둘에 도달할 값 자체가 없다.
|
||||
|
||||
웹 토큰 입력은 전용 검사에 닿는다. 다만 같은 값이 다음 검사에도 걸린다 — `eyJ` 로 시작하기 때문이다.
|
||||
|
||||
## 웹 토큰 분기가 혼자 잡는 것
|
||||
|
||||
```text
|
||||
합성 JWT 26자 26 JWT | identifier must not contain a JSON web token
|
||||
_로 시작 26 문자클래스 JWT | identifier must be 1..128 characters of [A-Za-z0-9._~-] and must not contain a key separator
|
||||
실제 크기 JWT 238 문자클래스 JWT 접두 | identifier must be 1..128 characters of [A-Za-z0-9._~-] and must not contain a key separator
|
||||
```
|
||||
|
||||
혼자 걸리려면 26자에서 128자 사이이고 영숫자로 시작하며 `eyJ` 로도 `bearer` 로도 시작하지 않아야 한다. 실제 토큰은 헤더가 `{"` 로 시작해 base64url 로 늘 `eyJ` 가 되고, 길이도 128자를 넘기 일쑤다. 그러니 이 분기가 혼자 잡는 것은 토큰을 닮은 합성 값이다.
|
||||
|
||||
## 어느 검사를 지우면 test 가 빨개지는가
|
||||
|
||||
웹 토큰 분기는 일을 하지만 붙들고 있는 test 가 없다. 저 test 입력은 분기를 지워도 접두 검사가 같은 타입의 예외를 낸다.
|
||||
|
||||
문자 클래스 검사와 접두 검사는 다르다. 129자 입력과 `1:2` 는 문자 클래스 검사가 없으면 아무 검사에도 안 걸리고, `eyJhbGciOiJIUzI1NiJ9` 와 `bearer-abcdefabcdef` 는 접두 검사 말고 걸리는 데가 없다.
|
||||
|
||||
## 왜 test 가 이것을 못 잡는가
|
||||
|
||||
```java
|
||||
assertThatThrownBy(() -> new RedisKeyName("user", "person@example.com"))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
```
|
||||
|
||||
단언 대상이 예외 타입뿐이다. 어느 검사가 던졌는지 보지 않는다.
|
||||
|
||||
```text
|
||||
sdk/api/key/RedisKeyRules.java:67: throw new IllegalArgumentException("identifier must not contain a mail address");
|
||||
sdk/api/key/RedisKeyRules.java:70: throw new IllegalArgumentException("identifier must not contain a JSON web token");
|
||||
sdk/api/key/RedisKeyRules.java:73: throw new IllegalArgumentException("identifier must not contain a phone number");
|
||||
sdk/api/key/RedisKeyRules.java:76: throw new IllegalArgumentException("identifier must not contain authentication material");
|
||||
```
|
||||
|
||||
저장소 전체를 파일 형식 제한 없이 훑어도 네 메시지가 나오는 곳은 던지는 자리 넷뿐이다.
|
||||
|
||||
## 옮겨 적은 정규식을 믿어도 되는가
|
||||
|
||||
표의 「걸리는 검사」 열은 소스에서 옮겨 적은 정규식으로 계산한다. 그 사본이 실제와 같은지 무작위 입력으로 대조했다.
|
||||
|
||||
```text
|
||||
200000 / 200000 일치
|
||||
문자클래스 에서 갈린 입력 144166
|
||||
메일 에서 갈린 입력 0
|
||||
JWT 에서 갈린 입력 23462
|
||||
전화 에서 갈린 입력 0
|
||||
접두 에서 갈린 입력 30843
|
||||
통과 1529
|
||||
```
|
||||
|
||||
문자 클래스와 웹 토큰과 접두는 각각 수만 번씩 갈렸다. 메일과 전화는 0 인데, 그게 이 기록의 결론이다. 도달할 값이 없으니 대조할 방법도 없다.
|
||||
|
||||
## 잃는 것
|
||||
|
||||
거부는 그대로다. 세 형태 모두 거부된다.
|
||||
|
||||
운영자가 보는 메시지가 달라진다. 메일 주소를 담지 말라는 문장 대신 문자 클래스 문장이 온다.
|
||||
|
||||
그리고 세 분기가 검증을 다섯 겹처럼 보이게 만든다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
세 분기를 실제로 지운 빌드로 전체 test 를 돌리지 않았다. 소스를 고치지 않는 것이 이 작업의 조건이다. 대신 각 입력이 걸리는 검사를 전부 표시해, 지운 뒤에도 같은 타입의 예외를 낼 검사가 남는지 확인했다.
|
||||
|
||||
분기와 함께 쓰이지 않게 되는 패턴 필드까지 지운 빌드가 경고 없이 컴파일되는지도 확인하지 못했다.
|
||||
|
||||
<!-- body:end -->
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: analysis-finding-a10-f003
|
||||
title: SDK가 선언한 두 진입점에 구현이 없다
|
||||
topic: caching-and-redis
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:analysis-finding-a10-f003
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
body: case-analysis-finding-a10-f003.body.md
|
||||
assets:
|
||||
- key: analysis-finding-a10-f003
|
||||
file: ../../../final/evidence/rendered/analysis-finding-a10-f003.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/analysis-finding-a10-f003.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/10-adapter-outbound-cache-redis.md#L251 이다.
|
||||
---
|
||||
|
||||
# SDK가 선언한 두 진입점에 구현이 없다
|
||||
|
||||
동기와 반응형 진입점 인터페이스가 각각 열두 접근자를 선언한다. 개별 표면은 사십삼 종이 모두 구현되어 있는데 두 진입점을 구현하는 클래스는 하나도 없다. 대칭 테스트는 인터페이스끼리만 비교하므로 이것을 가리지 못한다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **README readiness 표가 있는 것을 없다고 적는다**
|
||||
같은 리프의 반대 방향 사례이고 원인은 같다.
|
||||
- **미배선 경계가 문서에만 있고 compile 경로에서 닫히지 않는다**
|
||||
같은 형태의 절반 조립이다.
|
||||
- **인터페이스끼리 비교하는 test는 구현의 부재를 못 본다**
|
||||
이 사례가 그 규칙의 형태다.
|
||||
|
||||
## 문제
|
||||
|
||||
동기 진입점 인터페이스가 자신을 형 있는 API 로의 동기 진입점이라 소개하고, 반응형 인터페이스가 그 짝이다.
|
||||
|
||||
두 인터페이스는 각각 열두 접근자를 선언한다. 값과 해시와 리스트와 집합과 정렬 집합과 비트맵과 비트 필드와 확률적 집계와 지리와 스트림과 키와 배치다.
|
||||
|
||||
## 결론
|
||||
|
||||
둘 다 구현체가 없다.
|
||||
|
||||
리프 전체의 구현 선언을 전수 조사했다. 개별 표면은 전부 구현되어 있다. 동기 스물여섯 종과 반응형 열일곱 종이다.
|
||||
|
||||
그런데 두 진입점을 구현한다고 선언한 클래스는 0 건이다.
|
||||
|
||||
main 안에서 두 타입을 이름으로 부르는 곳도 없다. 유일한 참조가 반응형 인터페이스 자바독의 링크 하나와 대칭 테스트의 반사 두 줄이다.
|
||||
|
||||
결과적으로 이 SDK 를 쓰는 코드는 진입점을 얻을 수 없다.
|
||||
|
||||
열두 표면을 각각 어디선가 따로 받아야 하고, 진입점이 약속하는 하나의 객체에서 형 있는 표면 전체는 존재하지 않는다.
|
||||
|
||||
접근자를 추가하고 반응형 짝을 맞추는 규율은 실행되고 있다. 그 규율이 만드는 대상을 실제로 만드는 코드가 없다.
|
||||
|
||||
판정은 P2 다.
|
||||
|
||||
데이터 위험은 없다. 없는 타입은 잘못된 답을 주지 않는다.
|
||||
|
||||
위험은 API 계약의 신뢰다. 이 리프의 공개 표면 중 가장 먼저 읽히는 두 타입이 구현되지 않은 상태이고, 대칭 테스트가 그 사실을 가리지 못한다. 인터페이스끼리만 비교하기 때문이다.
|
||||
|
||||
같은 리프의 준비도 표 사례와 방향이 반대이면서 원인은 같다. 조립이 절반이다.
|
||||
|
||||
수정은 이미 존재하는 구현들을 묶는 두 클래스를 추가하고, 대칭 테스트에 두 진입점이 구현을 가진다는 검사를 더하는 것이다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
확인 방식 : 구현 선언 전수 조사와 이름 참조 검색
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
원문은 final/evidence/raw/160 계열에 있다.
|
||||
|
||||
1. 두 진입점 인터페이스의 접근자 목록을 확인한다.
|
||||
2. 리프 전체에서 구현 선언을 전수 조사한다.
|
||||
3. 두 진입점을 구현하는 클래스가 있는지 센다.
|
||||
4. main 안에서 두 타입 이름을 검색한다.
|
||||
5. 대칭 테스트가 무엇과 무엇을 비교하는지 확인한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
SDK가 선언한 두 진입점에 구현이 없다.
|
||||
|
||||
## SDK 가 선언한 두 진입점
|
||||
|
||||
:::evidence key="analysis-finding-a10-f003" alt="분석 문서 analysis/10-adapter-outbound-cache-redis.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/10-adapter-outbound-cache-redis.md 발췌 — 15줄" zoom="true"
|
||||
:::
|
||||
|
||||
## 데이터 위험은 없다
|
||||
|
||||
없는 타입은 잘못된 답을 주지 않는다. P2.
|
||||
|
||||
## 위험은 API 계약의 신뢰다
|
||||
|
||||
이 leaf의 공개 표면 중 가장 먼저 읽히는 두 타입이 구현되지 않은 상태이고, 대칭 test가 그 사실을 가리지 못한다 — 인터페이스끼리만 비교하기 때문이다. sub-scope 01의 §5와 방향이 반대이면서 원인은 같다: 조립이 절반이다.
|
||||
|
||||
## 수정
|
||||
|
||||
이미 존재하는 26개 구현을 묶는 `LettuceRedisOperations` / `LettuceReactiveRedisOperations` 두 클래스를 추가하고, `ApiParityTest`에 "두 facade는 구현을 가진다"는 검사를 더하는 것이다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
두 진입점이 과거에 구현체를 가졌는지 이력에서 확인하지 않았다.
|
||||
|
||||
<!-- body:end -->
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: analysis-finding-a10-f007
|
||||
title: 패턴 구독의 R2 승인만 호출자가 아니라 배포에 대해 이루어진다
|
||||
topic: caching-and-redis
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:analysis-finding-a10-f007
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
body: case-analysis-finding-a10-f007.body.md
|
||||
assets:
|
||||
- key: analysis-finding-a10-f007
|
||||
file: ../../../final/evidence/rendered/analysis-finding-a10-f007.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/analysis-finding-a10-f007.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/10-adapter-outbound-cache-redis.md#L485 이다.
|
||||
---
|
||||
|
||||
# 패턴 구독의 R2 승인만 호출자가 아니라 배포에 대해 이루어진다
|
||||
|
||||
같은 위험 등급의 연산 대부분은 호출자가 허가를 들고 오도록 서명이 요구한다. 패턴 구독만 서명에 허가 인자가 없고, SDK 가 자기 자신에게 발급한 허가를 쓴 뒤 버린다. 실제 효과는 배포가 그 정책을 켰는지 확인하는 것이다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **같은 위험 등급에 두 승인 모델이 있으면 차이를 문서가 적어야 한다**
|
||||
이 사례가 그 규칙의 형태다.
|
||||
- **permit 정책 이름이 세 곳에 문자열로 존재하고 교차 검사가 없다**
|
||||
같은 리프의 허가 체계 사례다.
|
||||
- **식별자 검증의 다섯 검사 중 둘은 도달할 수 없다**
|
||||
같은 리프의 다른 검증 사례다.
|
||||
|
||||
## 문제
|
||||
|
||||
이 리프는 위험한 연산을 등급으로 나누고, 상위 등급 연산에 명시적 승인을 요구한다.
|
||||
|
||||
그 승인이 어디서 오는지 연산별로 대조했다.
|
||||
|
||||
## 결론
|
||||
|
||||
패턴 구독만 다르다.
|
||||
|
||||
집합 연산 셋은 서명이 고급 연산 허가를 요구한다. 호출자가 들고 온다.
|
||||
|
||||
키 훑기와 해시 항목 조회도 마찬가지다.
|
||||
|
||||
비트 필드 실행은 예산이 필수이고 허가는 가드가 목록의 필수 정책으로 요구한다.
|
||||
|
||||
패턴 구독은 서명에 허가 인자가 없다.
|
||||
|
||||
대상을 계산하는 쪽이 부르는 것은 SDK 가 자기 자신에게 발급하는 경로다.
|
||||
|
||||
발급 구현은 정책 이름이 배포의 활성 정책 목록에 없으면 던진다. 그러므로 실제 효과는 이 배포가 패턴 구독을 켰는지 확인하는 것이다.
|
||||
|
||||
그리고 반환된 허가는 버려진다.
|
||||
|
||||
즉 다른 상위 등급 연산은 호출 지점이 승인을 증명하는데, 패턴 구독은 배포가 켜 두었는지만 본다.
|
||||
|
||||
자바독이 허가가 필요한 이유는 적는다. 그 확산 범위를 서버가 정한다는 것이다.
|
||||
|
||||
그런데 그 허가가 호출자가 아니라 SDK 가 스스로 발급한 것이라는 약해진 보증은 적지 않는다.
|
||||
|
||||
고급 연산 허가의 계약이 상위 등급 연산이 명시적으로 승인되었음을 증명한다는 것과 견주면 차이가 있다.
|
||||
|
||||
판정은 P3 다.
|
||||
|
||||
배포 수준 게이트는 실재하고 이름공간 봉쇄도 있으므로 열린 구멍은 아니다.
|
||||
|
||||
기록하는 이유는 같은 위험 등급에 두 가지 다른 승인 모델이 적용되고 그 차이가 문서화되어 있지 않기 때문이다.
|
||||
|
||||
수정은 둘 중 하나다. 패턴 구독 서명에 고급 연산 허가를 추가하거나, 자바독에 배포 수준 승인임을 명시하는 것이다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
확인 방식 : 연산별 서명과 허가 발급 경로 대조
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
원문은 final/evidence/raw/163 계열에 있다.
|
||||
|
||||
1. 상위 등급 연산 목록을 만든다.
|
||||
2. 각 연산의 서명에 허가 인자가 있는지 확인한다.
|
||||
3. 패턴 구독이 부르는 발급 경로를 확인한다.
|
||||
4. 그 발급 구현이 무엇을 검사하는지 읽는다.
|
||||
5. 반환된 허가가 어디에 쓰이는지 확인한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
R2 연산마다 승인 모델이 다르다.
|
||||
|
||||
| R2 연산 | 호출자가 permit을 들고 오는가 |
|
||||
|---|---|
|
||||
| `sets.difference/intersection/union` | **예** — 서명이 `AdvancedOperationPermit`을 요구 |
|
||||
| `keys.scan` · `hashes.entries` | **예** |
|
||||
| `bitFields.execute` | budget 필수, permit은 guard가 catalog의 `required-policy`로 요구 |
|
||||
| `pubSub.patternSubscribe` | **아니오** — 서명에 permit 인자가 없다 |
|
||||
|
||||
## AdvancedOperationPermit 참조 위치
|
||||
|
||||
:::evidence key="analysis-finding-a10-f007" alt="코드베이스에서 AdvancedOperationPermit 를 검색한 출력 31줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="AdvancedOperationPermit 코드베이스 검색 — 31줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
## SDK가 자기 자신에게 발급하고 그 permit을 버린다
|
||||
|
||||
`patternTargets`가 부르는 `context.sdkPermit(PATTERN_SUBSCRIBE)`는 SDK가 자기 자신에게 발급하는 경로다. `ConfiguredRedisPolicyAuthority.issueAdvanced`는 정책 이름이 배포의 `enabledPolicies`에 없으면 던지므로 실제 효과는 "이 배포가 `pattern-subscribe`를 켰는가"를 확인하는 것이고, 반환된 permit은 **버려진다**.
|
||||
|
||||
## javadoc이 적지 않는 것
|
||||
|
||||
permit이 필요한 이유("its fan-out is decided by the server")는 적지만, 그 permit이 호출자가 아니라 SDK가 스스로 발급한 것이라는 **약해진 보증**은 적지 않는다. `AdvancedOperationPermit`의 계약이 "proving that an R2 operation was explicitly approved"인 것과 견주면 차이가 있다.
|
||||
|
||||
## 열린 구멍은 아니다
|
||||
|
||||
배포 수준 게이트는 실재하고 네임스페이스 봉쇄도 있다. 기록하는 이유는 같은 위험 등급에 두 가지 다른 승인 모델이 적용되고 그 차이가 문서화돼 있지 않기 때문이다. 수정은 `patternSubscribe` 서명에 `AdvancedOperationPermit`을 추가하거나, javadoc에 "배포 수준 승인"임을 명시하는 것이다. P3.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
정책을 끈 배포에서 패턴 구독이 실제로 거부되는지 실행하지 않았다. 발급 구현상 그 결과가 나온다.
|
||||
|
||||
<!-- body:end -->
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: analysis-finding-a10-f008
|
||||
title: permit 정책 이름이 세 곳에 문자열로 존재하고 교차 검사가 없다
|
||||
topic: caching-and-redis
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:analysis-finding-a10-f008
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
body: case-analysis-finding-a10-f008.body.md
|
||||
assets:
|
||||
- key: analysis-finding-a10-f008
|
||||
file: ../../../final/evidence/rendered/analysis-finding-a10-f008.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/analysis-finding-a10-f008.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/10-adapter-outbound-cache-redis.md#L502 이다.
|
||||
---
|
||||
|
||||
# permit 정책 이름이 세 곳에 문자열로 존재하고 교차 검사가 없다
|
||||
|
||||
허가 정책 이름의 출처가 셋이다. 두 집합의 차분은 정확히 둘이고 양쪽 다 설명이 있다. 문제는 차분이 아니라 차분을 감지하는 장치가 없다는 것이다. 어느 쪽 오타도 빌드를 깨지 않는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **패턴 구독의 승인만 호출자가 아니라 배포에 대해 이루어진다**
|
||||
같은 리프의 허가 체계 사례다.
|
||||
- **문자열로 이어진 두 세계는 오타에서 조용히 갈라진다**
|
||||
이 사례가 그 규칙의 형태다.
|
||||
- **catalog drift gate는 서버 메타데이터와 대조하지 Java 상수와 대조하지 않는다**
|
||||
감지 장치가 없는 이유다.
|
||||
|
||||
## 문제
|
||||
|
||||
허가 정책 이름의 출처가 셋이다.
|
||||
|
||||
연산 문맥의 공개 상수 열여덟 개, 명령 정책 설정 파일의 필수 정책 값 열여덟 개, 검색 확장의 비공개 상수 하나다.
|
||||
|
||||
두 집합이 일치하는지 확인했다.
|
||||
|
||||
## 결론
|
||||
|
||||
두 집합의 차분은 정확히 둘이고 양쪽 다 설명이 있다.
|
||||
|
||||
지속 키 정책은 자바에만 있다. 해당 명령들이 하위 등급이라 목록의 필수 정책이 아니라 연산 문맥의 전용 요구 메서드가 강제한다.
|
||||
|
||||
검색 인덱스 정책은 설정에만 있다. 색인 생성 명령의 필수 정책이고, 자바 쪽 짝은 연산 문맥이 아니라 검색 확장 패키지의 비공개 상수다.
|
||||
|
||||
즉 차분 자체는 설명된다.
|
||||
|
||||
문제는 다른 데 있다. 차분을 감지하는 장치가 없다.
|
||||
|
||||
설정에 오타가 들어가면 그 명령은 아무도 발급받을 수 없는 정책을 요구하게 된다.
|
||||
|
||||
자바 상수 쪽에 오타가 들어가면 발급 구현이 정책이 활성화되지 않았다고 던진다.
|
||||
|
||||
어느 쪽도 빌드를 깨지 않는다.
|
||||
|
||||
목록 표류 게이트는 설정을 서버 메타데이터와 대조한다. 자바 상수 집합과 대조하지 않는다.
|
||||
|
||||
판정은 P3 다.
|
||||
|
||||
확정은 다음 하위 범위로 이월한다. 정책 적재기 테스트가 정책 이름 집합을 검사하는지 그 범위에서 확인한다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
확인 방식 : 세 출처의 문자열 집합 대조
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
원문은 final/evidence/raw/162 계열에 있다.
|
||||
|
||||
1. 연산 문맥의 정책 상수를 모은다.
|
||||
2. 명령 정책 설정 파일의 필수 정책 값을 모은다.
|
||||
3. 두 집합의 차분을 계산한다.
|
||||
4. 각 차분 항목의 이유를 확인한다.
|
||||
5. 목록 표류 게이트가 무엇과 무엇을 대조하는지 확인한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
정책 이름의 출처가 셋이다.
|
||||
|
||||
| 출처 | 개수 |
|
||||
|---|---|
|
||||
| `RedisOperationContext`의 `public static final String` 상수 | 18 |
|
||||
| `redis-command-policy.yml`의 `required-policy:` 값 | 18 |
|
||||
| `LettuceRedisSearchOperations:31`의 private 상수 `SEARCH_INDEX` | 1 |
|
||||
|
||||
## RedisCommandPolicyLoaderTest 참조 위치
|
||||
|
||||
:::evidence key="analysis-finding-a10-f008" alt="코드베이스에서 RedisCommandPolicyLoaderTest 를 검색한 출력 1줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="RedisCommandPolicyLoaderTest 코드베이스 검색 — 1줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
## 두 집합의 차분에는 설명이 있다
|
||||
|
||||
`162-...` §8.3 — `persistent-key`는 Java에만 있다(해당 명령들이 R1이라 catalog의 `required-policy`가 아니라 `RedisOperationContext.requirePersistentKeyPermit`이 강제한다, §34). `search-index`는 YAML에만 있다(`FT.CREATE`의 `required-policy`이고, Java 쪽 짝은 `sdk/extensions/search`의 private 상수다).
|
||||
|
||||
## 문제는 차분이 아니라 감지 장치의 부재다
|
||||
|
||||
YAML에 `required-policy: bounded-collectoin-read`처럼 오타가 들어가면 그 명령은 아무도 발급받을 수 없는 정책을 요구하게 되고, Java 상수 쪽에 오타가 들어가면 `issueAdvanced`가 "policy is not enabled"로 던진다. 어느 쪽도 빌드를 깨지 않는다. catalog drift gate는 YAML을 **서버 메타데이터**와 대조하지, Java 상수 집합과 대조하지 않는다. P3 — 확정은 sub-scope 05로 이월한다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
정책 적재기 테스트가 이름 집합을 검사하는지 확인하지 않았다. 다음 하위 범위로 이월한다.
|
||||
|
||||
<!-- body:end -->
|
||||
Reference in New Issue
Block a user