Files
document-haness/docs/clean-architecture-backend-template/tech-log-studio/what-a-gate-does-not-prove/case/case-a05-f013-specificationpolicy-specification-unrestricted.md
T
DongHyeonkaandClaude Opus 5 b2963105a8 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>
2026-09-04 22:51:59 +09:00

157 lines
10 KiB
Markdown

---
kind: CASE
slug: a05-f013-specificationpolicy-specification-unrestricted
title: 널 검사가 술어 검사를 대신하고, 라이브러리는 널 아닌 조건 없음을 준다
topic: what-a-gate-does-not-prove
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a05-f013-specificationpolicy-specification-unrestricted
evidenceCapturedOn: 2026-09-02
body: case-a05-f013-specificationpolicy-specification-unrestricted.body.md
assets:
- key: a05-f013-specificationpolicy-specification-unrestricted
file: ../../../final/evidence/rendered/a05-f013-specificationpolicy-specification-unrestricted.svg
- key: a05-f013-specificationpolicy-specification-unrestricted-probe
file: ../../../final/evidence/rendered/a05-f013-specificationpolicy-specification-unrestricted-probe.svg
evidence:
- ../../../final/evidence/raw/a05-f013-specificationpolicy-specification-unrestricted.txt
- ../../../final/evidence/raw/a05-f013-specificationpolicy-specification-unrestricted-probe.txt
source:
- 원본 분석 절은 `analysis/05-adapter-outbound-persistence-jpa.md` §47 이다. 문서 계약과 널 검사의 불일치, 그리고 4.0.7 이 널 아닌 제한 없음 명세를 제공한다는 바이트코드 확인(§47.1)이 거기 있다.
- §47.2 의 실행 탐침은 손으로 쓴 `(root, query, cb) -> null` 람다를 넣은 것이다. `Specification.unrestricted()` 자체를 두 메서드에 넣은 것, 페이지 경계가 남는다는 구분, 그리고 형제 정책의 같은 구멍은 이 기록에서 새로 확인했다. Querydsl 이 미채택 상태이지 죽은 코드가 아니라는 판정은 같은 문서 §48 에 있다.
---
# 널 검사가 술어 검사를 대신하고, 라이브러리는 널 아닌 조건 없음을 준다
술어 없는 명세를 거절한다는 문서 계약이 있고, 구현은 명세 객체가 널인지만 본다. Spring Data 4.0.7 이 공식으로 주는 제한 없음 명세는 널이 아니면서 술어로 널을 돌려주므로 두 검사를 다 지난다. 페이지 경계 검사는 남아 있어서, 통과한 결과물은 필터 없는 페이지 읽기다.
## 관계
- **@Bean이 있다는 것은 조립 증거가 아니다**
존재를 내용으로 오독한다는 점이 같다.
- **접두사 시작 매칭은 시그니처에는 맞고 스니핑 패턴에는 맞지 않는다**
탐지 방법이 대상의 실제 형태를 못 덮는다는 점이 같다.
- **명령 카탈로그와 admission 아홉 단계**
경계 없는 요청을 어느 단계에서 막아야 하는지 정리한 문서다.
## 문제
클래스 javadoc 은 술어 없는 명세를 빌더의 옷을 입은 전체 테이블 스캔이라 부른다. 선택 필터가 전부 비어서 생기고, 어느 한 줄도 틀리지 않았기 때문에 코드 리뷰에서 무해해 보인다는 설명이 이어진다.
그래서 술어가 있거나 전부 스캔하겠다는 명시적 토큰이 있어야 한다고 요구한다. 페이지 경계를 요구하는 근거도 그 javadoc 안에 같이 있다.
## 결론
검사는 specification == null 이다. Spring Data JPA 4.0.7 의 Specification.unrestricted() 는 널이 아닌 객체를 돌려주면서 toPredicate 로는 널을 주므로, requireBounded 도 requirePredicate 도 이를 거절하지 않는다. 실제로 거절되는 값은 자바 널 하나다.
바이트코드로 확인했다. 그 팩토리는 부트스트랩 0번을 통해 lambda$0 를 돌려주고, 그 메서드의 본문은 aconst_null 과 areturn 두 명령이다.
뚫리는 것은 javadoc 이 적은 두 가지 중 하나다. requireBounded 는 pageable.isUnpaged() 를 여전히 거절하므로 통과한 쿼리는 필터가 없는 페이지 읽기다. requirePredicate 는 Pageable 인자 자체가 없어 이 완충이 없다.
Querydsl 쪽 PredicatePolicy 는 토큰 문자열 allow-unbounded-scan 을 그대로 공유하면서 predicate == null 만 본다. 빈 BooleanBuilder 는 널이 아니고 hasValue 도 거짓인데 그대로 통과한다.
두 정책 다 현재 호출자가 없다. 명세 쪽은 두 메서드를 부르는 코드가 레포 전체에 0 이다. Querydsl 쪽은 진입점이 정책을 부르지만 그 진입점 자신에게 프로덕션 호출자가 없는데, 이쪽은 미채택이라기보다 설계다. Querydsl 은 이 리프에서 compileOnly 이고 출하 런타임 클래스패스에 실리지 않는다고 클래스 javadoc 이 적는다.
그래도 계약은 남는다. 이 클래스는 아키텍처 문서의 공개 API 표면 목록에 실려 있다. 두 메서드를 호출하는 코드가 생기면 술어 없는 명세는 걸러지지 않는다.
검사가 볼 대상은 toPredicate 가 돌려주는 값이다. Criteria 컨텍스트가 있어야 하니 검사 자리가 실행 직전까지 밀린다. Querydsl 쪽은 hasValue 를 물으면 끝난다.
## 검증 환경
OpenJDK : 21.0.12
Spring Data JPA : 4.0.7
Querydsl : 5.1.0
확인 방식 : 해석된 jar 역어셈블, 두 정책에 라이브러리 표현 투입, 호출처 계수
소스 수정 : x
## 재현 조건
1. javadoc 계약과 실제 검사 조건을 나란히 읽는다.
2. 락파일이 고정한 좌표로 클래스패스를 만들고, 제한 없음 명세 팩토리의 부트스트랩 표와 그 람다의 본문을 본다.
3. 그 명세와 자바 널을 두 검사에 각각 넣는다.
4. 페이지 경계 검사가 남아 있는지, requirePredicate 에 Pageable 인자가 있는지 확인한다.
5. Querydsl 의 빈 빌더를 형제 정책에 넣는다.
6. 두 정책의 호출자를 레포 전체에서 센다.
## 본문
<!-- body:start -->
`SpecificationPolicy` 의 클래스 javadoc 은 술어 없는 명세를 이렇게 부른다.
```text
A specification with no predicate is a full table scan wearing a builder's clothing.
```
같은 javadoc 이 페이지 경계도 같은 이유로 요구한다. 막겠다는 것은 두 가지다.
## 검사는 객체가 있는지만 본다
:::evidence key="a05-f013-specificationpolicy-specification-unrestricted" alt="명세 정책의 javadoc 계약 전문과 두 검사 메서드의 구현, 이 타입을 언급하는 레포 전체의 줄과 두 검사 메서드를 부르는 코드 수, 그리고 같은 리프의 형제 정책이 쓰는 같은 토큰 상수와 같은 널 비교, 형제 쪽 호출처와 그것을 부르는 테스트를 출력한 터미널 기록." caption="javadoc 이 요구하는 두 가지와 실제 널 비교 · 레포 전체 언급과 호출 0 · 아키텍처 문서의 공개 API 목록에 등재 · 형제 정책의 같은 토큰과 같은 비교 — 61줄 · exit 0" zoom="true"
:::
```java
if (specification == null && !ALLOW_UNBOUNDED_TOKEN.equals(allowUnboundedToken)) {
```
`requirePredicate` 도 같다. 명세 객체가 있으면 술어가 있는 것으로 다룬다.
## 라이브러리가 널이 아닌 조건 없음을 준다
:::evidence key="a05-f013-specificationpolicy-specification-unrestricted-probe" alt="락파일 좌표에서 만든 클래스패스와 그 jar 목록, 제한 없음 명세 팩토리의 부트스트랩 표와 그것이 가리키는 람다의 본문, 그 명세와 자바 널을 두 검사에 넣은 결과, 페이지 경계 검사와 requirePredicate 의 시그니처, Querydsl 의 빈 빌더를 형제 정책에 넣은 결과, 그리고 형제 쪽 진입점의 limit 부착과 compileOnly 선언을 출력한 터미널 기록." caption="락파일로 만든 클래스패스 · 부트스트랩 0번이 가리키는 람다는 널 반환 · 두 검사 모두 통과, 자바 널만 거절 · 페이지 경계는 유지, requirePredicate 는 완충 없음 · Querydsl 빈 빌더도 통과 — 56줄 · exit 0" zoom="true"
:::
부트스트랩 표가 0번을 `lambda$0` 로 링크하고, 그 메서드의 본문은 두 명령이다.
```text
0: aconst_null
1: areturn
```
널이 아닌 명세 객체이면서, 술어를 물으면 널을 준다.
`Specification.unrestricted()` 와 자바 널을 두 메서드에 각각 넣은 결과다.
```text
Specification.unrestricted() 가 널인가 : false
그 명세의 toPredicate 가 돌려주는 값 : null
requireBounded(토큰 없이) : 통과
requirePredicate : 통과
requireBounded(널 명세) : 거절
```
거절되는 것은 자바 널뿐이다.
## 페이지 경계는 남아 있다
`requireBounded``pageable.isUnpaged()` 를 여전히 거절한다. 그래서 이 명세로 통과한 쿼리는 필터가 없는 페이지 읽기이지 전체 테이블 스캔은 아니다. javadoc 이 적은 두 가지 중 한쪽만 무너진다.
`requirePredicate` 는 다르다. 시그니처에 `Pageable` 이 없으므로 이 완충이 걸리지 않는다.
## Querydsl 쪽 PredicatePolicy
같은 리프의 Querydsl 쪽 정책이 토큰 문자열 `allow-unbounded-scan` 을 그대로 공유하면서 `predicate == null` 만 본다.
Querydsl 의 빈 `BooleanBuilder` 는 널이 아니고 `hasValue` 도 거짓이다. 넣으면 통과한다.
형제 쪽 진입점은 그 널 아님을 술어로 받아 `where` 에 넘긴다. 다만 같은 메서드가 항상 `offset``limit` 을 붙이므로, 이쪽 결과물도 필터 없는 페이지 읽기다.
## 지금 이 두 정책을 밟는 코드
`SpecificationPolicy` 의 두 메서드를 부르는 코드는 레포 전체에 0 이다. 정적 임포트 경유도 없다. 소스에서 이 타입을 언급하는 줄은 자기 클래스 선언과 private 생성자뿐이고, 나머지 언급은 계획 문서와 학습 문서, 그리고 아키텍처 문서의 공개 API 표면 목록이다.
`QuerydslJpaSupport.select` 는 형제 정책을 부르지만 그 진입점 자신에게 프로덕션 호출자가 없다. 이쪽은 미채택이라기보다 설계다. `build.gradle` 이 Querydsl 을 `compileOnly` 로 잡고, 클래스 javadoc 이 출하 런타임 클래스패스가 그것을 싣지 않는다고 적는다. app-bootstrap 의 락파일에도 Querydsl 이 없다.
그러므로 두 정책의 문제는 지금 도는 코드의 결함이 아니라, 채택 시점에 물려받을 계약의 결함이다.
## 고칠 방향
검사가 봐야 할 것은 `toPredicate` 의 반환값이다. 그러려면 Criteria 컨텍스트가 필요하므로 검사 시점이 조립에서 실행 직전으로 옮겨진다. Querydsl 쪽은 빌더에 값이 있는지 물으면 된다.
## 확인하지 못한 것
그 명세로 실제 쿼리를 실행해 필터 없는 페이지 읽기가 되는 것을 관측하지 않았다. 확인한 것은 두 정책이 통과시킨다는 사실과, 통과한 값이 술어를 갖고 있지 않다는 사실이다.
<!-- body:end -->