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 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

10 KiB

kind, slug, title, topic, project, status, sourceRevision, rootTreeNode, evidenceCapturedOn, body, assets, evidence, source
kind slug title topic project status sourceRevision rootTreeNode evidenceCapturedOn body assets evidence source
CASE a05-f013-specificationpolicy-specification-unrestricted 널 검사가 술어 검사를 대신하고, 라이브러리는 널 아닌 조건 없음을 준다 what-a-gate-does-not-prove clean-architecture-backend-template 게시 전 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 case:a05-f013-specificationpolicy-specification-unrestricted 2026-09-02 case-a05-f013-specificationpolicy-specification-unrestricted.body.md
key file
a05-f013-specificationpolicy-specification-unrestricted ../../../final/evidence/rendered/a05-f013-specificationpolicy-specification-unrestricted.svg
key file
a05-f013-specificationpolicy-specification-unrestricted-probe ../../../final/evidence/rendered/a05-f013-specificationpolicy-specification-unrestricted-probe.svg
../../../final/evidence/raw/a05-f013-specificationpolicy-specification-unrestricted.txt
../../../final/evidence/raw/a05-f013-specificationpolicy-specification-unrestricted-probe.txt
원본 분석 절은 `final/document.md#a05` §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. 두 정책의 호출자를 레포 전체에서 센다.

본문

SpecificationPolicy 의 클래스 javadoc 은 술어 없는 명세를 이렇게 부른다.

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" :::

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 로 링크하고, 그 메서드의 본문은 두 명령이다.

0: aconst_null
1: areturn

널이 아닌 명세 객체이면서, 술어를 물으면 널을 준다.

Specification.unrestricted() 와 자바 널을 두 메서드에 각각 넣은 결과다.

Specification.unrestricted() 가 널인가 : false
그 명세의 toPredicate 가 돌려주는 값   : null
requireBounded(토큰 없이)  : 통과
requirePredicate           : 통과
requireBounded(널 명세)    : 거절

거절되는 것은 자바 널뿐이다.

페이지 경계는 남아 있다

requireBoundedpageable.isUnpaged() 를 여전히 거절한다. 그래서 이 명세로 통과한 쿼리는 필터가 없는 페이지 읽기이지 전체 테이블 스캔은 아니다. javadoc 이 적은 두 가지 중 한쪽만 무너진다.

requirePredicate 는 다르다. 시그니처에 Pageable 이 없으므로 이 완충이 걸리지 않는다.

Querydsl 쪽 PredicatePolicy

같은 리프의 Querydsl 쪽 정책이 토큰 문자열 allow-unbounded-scan 을 그대로 공유하면서 predicate == null 만 본다.

Querydsl 의 빈 BooleanBuilder 는 널이 아니고 hasValue 도 거짓이다. 넣으면 통과한다.

형제 쪽 진입점은 그 널 아님을 술어로 받아 where 에 넘긴다. 다만 같은 메서드가 항상 offsetlimit 을 붙이므로, 이쪽 결과물도 필터 없는 페이지 읽기다.

지금 이 두 정책을 밟는 코드

SpecificationPolicy 의 두 메서드를 부르는 코드는 레포 전체에 0 이다. 정적 임포트 경유도 없다. 소스에서 이 타입을 언급하는 줄은 자기 클래스 선언과 private 생성자뿐이고, 나머지 언급은 계획 문서와 학습 문서, 그리고 아키텍처 문서의 공개 API 표면 목록이다.

QuerydslJpaSupport.select 는 형제 정책을 부르지만 그 진입점 자신에게 프로덕션 호출자가 없다. 이쪽은 미채택이라기보다 설계다. build.gradle 이 Querydsl 을 compileOnly 로 잡고, 클래스 javadoc 이 출하 런타임 클래스패스가 그것을 싣지 않는다고 적는다. app-bootstrap 의 락파일에도 Querydsl 이 없다.

그러므로 두 정책의 문제는 지금 도는 코드의 결함이 아니라, 채택 시점에 물려받을 계약의 결함이다.

고칠 방향

검사가 봐야 할 것은 toPredicate 의 반환값이다. 그러려면 Criteria 컨텍스트가 필요하므로 검사 시점이 조립에서 실행 직전으로 옮겨진다. Querydsl 쪽은 빌더에 값이 있는지 물으면 된다.

확인하지 못한 것

그 명세로 실제 쿼리를 실행해 필터 없는 페이지 읽기가 되는 것을 관측하지 않았다. 확인한 것은 두 정책이 통과시킨다는 사실과, 통과한 값이 술어를 갖고 있지 않다는 사실이다.