Files
document-haness/docs/clean-architecture-backend-template/tech-log-studio/caching-and-redis/case/case-a10-f005-hyperloglog-merge.md
T
DongHyeonkaandClaude Fable 5.1 b25357c48a docs(clean-architecture-backend-template): fold analysis into final and re-select one topic
- analysis/·source-index·state.json 을 final/document.md 제2부·제3부로 접었다. SSOT 는 하나다
- 파일럿 — commit-ambiguity-as-a-result 를 새 기준으로 재선별. 후보 14 → 글감 5
  (PROMOTE 5 · MERGE_INTO 3 · KEEP_IN_SSOT 4 · 보류 2). 기록 5건을 다시 썼고 그림 1개를
  techviz 로 만들었다
- 재선별이 잡은 것: 제1부 §6.2·§11.1 이 자기 §13.2 와 어긋나 있었다(레인을 안 돌렸다 vs
  돌렸다) — 정정. 이미 답이 나와 있던 Question 을 HEAD 재실행 질문으로 다시 세웠다.
  Concept 이 인용한 코드가 SSOT 에 없어 뺐다
- candidateScope·sourceRepository 기록. 나머지 43개 주제는 재선별 대기(PENDING 905)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:39:20 +09:00

216 lines
14 KiB
Markdown

---
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:
- 원본 분석 절은 final/document.md#a10#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 -->