Files
document-haness/docs/clean-architecture-backend-template/tech-log-studio/what-a-gate-does-not-prove/case/case-a05-f014-collection-fetch-pagination.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

15 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-f014-collection-fetch-pagination 게이트가 막겠다는 실패가 그 게이트 실행 안에서 일어나고, 단언은 그것을 보지 못한다 what-a-gate-does-not-prove clean-architecture-backend-template 게시 전 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 case:a05-f014-collection-fetch-pagination 2026-09-02 case-a05-f014-collection-fetch-pagination.body.md
key file
a05-f014-collection-fetch-pagination ../../../final/evidence/rendered/a05-f014-collection-fetch-pagination.svg
key file
a05-f014-collection-fetch-pagination-probe ../../../final/evidence/rendered/a05-f014-collection-fetch-pagination-probe.svg
key file
a05-f014-collection-fetch-pagination-lane ../../../final/evidence/rendered/a05-f014-collection-fetch-pagination-lane.svg
../../../final/evidence/raw/a05-f014-collection-fetch-pagination.txt
../../../final/evidence/raw/a05-f014-collection-fetch-pagination-probe.txt
../../../final/evidence/raw/a05-f014-collection-fetch-pagination-lane.txt
원본 분석 절은 `final/document.md#a05` §52 다. 게이트 선언과 테스트 단언의 불일치가 §52.1 에, 생산자 태스크 불일치가 §52.2 에, 검증기가 그것을 잡지 못한다는 것이 §52.4 에, 집계 태스크가 이 테스트를 다른 레인으로 실행한다는 것이 §52.5 에 있다.
제공자 정책 클래스의 javadoc 대조와 해석된 제공자에 대한 실행은 그 절에 없고 이 기록에서 확인했다.

게이트가 막겠다는 실패가 그 게이트 실행 안에서 일어나고, 단언은 그것을 보지 못한다

컬렉션 페치 페이지네이션이 릴리스 게이트로 선언되어 있다. 테스트의 javadoc 은 단언 대상이 생성된 SQL 이라고 적는데, 본문의 단언 둘은 반환된 페이지 크기와 테스트가 방금 만든 객체의 필드다. 해석된 제공자에게 같은 조회를 시키면 상한이 메모리에서 적용되고 그 경고가 뜨는데도 두 단언은 참이다.

관계

  • 빠뜨림이 통과가 되는 게이트는 게이트가 아니다 이 게이트도 막겠다고 적은 실패 모드가 일어나는 중에 통과한다.
  • 계약 테스트는 어댑터가 실제로 돌리는 statement를 실행해야 한다 이 게이트가 확인해야 할 대상을 다룬 규칙이다.
  • 아무도 돌리지 않는 레인의 게이트는 마지막으로 돌린 사람이 본 것을 보고한다 아무도 돌리지 않는 레인의 게이트가 무엇을 보고하게 되는지 적은 규칙이다.

문제

지원 매트릭스의 릴리스 게이트 표에 이 행이 있고, 막을 대상이 적혀 있다. 페이지 단위 컬렉션 페치가 조용히 전체 테이블을 읽고 메모리에서 페이지네이션하는 것이다.

테스트의 javadoc 이 왜 반환된 페이지 크기로는 안 되는지 설명한다. 결과는 어느 쪽이든 같고, 상한이 어디서 적용됐는지는 SQL 만 말한다는 것이다.

결론

그 메서드의 단언은 둘이다. 반환된 페이지가 기대 최대 이하인지, 그리고 기대값의 requiresDatabaseLimit 이 참인지다.

둘째 단언이 보는 값은 테스트가 같은 메서드 첫 줄에서 만든 객체의 필드이고, 그 값은 팩토리에 상수로 적혀 있다.

첫째 단언은 결과가 어느 쪽이든 참이 된다. 락파일이 고정한 제공자에게 게이트와 같은 형태의 컬렉션 페치 조회를 시켜 봤다. 상한을 메모리에서 적용한다는 경고가 뜨고, 프리페어드 스테이트먼트는 하나이며, 반환된 부모는 정확히 스무 개다. 게이트가 막겠다고 적은 실패 모드가 일어나는 중에도 두 단언이 모두 통과한다.

javadoc 은 이 동작을 옛 제공자의 것으로 적지만, 이 저장소가 해석하는 제공자에서도 기본값이다. 그것을 실패로 바꾸는 설정을 이 저장소는 켜 두지 않았다.

같은 모듈의 제공자 정책 클래스가 이 형태를 이름으로 적어 뒀다. 선언해 둔 기준선을 런타임 값처럼 단언하면 테스트가 그 상수를 같은 상수와 비교하게 되므로, 실제 런타임 동작을 확인하지 못한 채 통과한다는 것이다. 그 문장의 경고 대상은 제공자 버전 단언이었고, 이 게이트는 반대편 예시 자리에 있다. 게이트의 둘째 단언이 바로 그 형태다.

증거의 출처도 어긋나 있다. 레지스트리는 이 게이트를 쿼리 플랜 레인에 묶는데, 레인은 태그로 테스트를 고르고 그 레인의 태그는 쿼리 플랜이며 이 테스트의 태그는 계약이다. 그 태그를 단 클래스는 따로 하나 있다.

테스트 자체는 돈다. 그 레인은 PR 과 야간 작업에서 모두 실행되고, 릴리스 집계도 이 레인의 결과를 입력으로 읽는다. 어긋난 것은 실행 여부가 아니라 증거가 어디서 나오느냐다. 게이트 이름으로 지목된 태스크의 JUnit 결과에 이 테스트가 없다.

그 불일치를 지나가게 하는 검증기가 있다. 게이트마다 태스크 경로가 절대 경로인지, 프로젝트가 있는지, 그 이름의 태스크가 있는지, 그것이 Test 타입인지 넷을 본다. 이 게이트에서 넷 다 참이다. 레지스트리를 읽는 단위 테스트의 게이트 단언은 경로가 콜론으로 시작하는지 한 줄이다. 이 게이트를 통과시키는 단언도 선언된 상수를 다시 읽어 비교한다.

레지스트리에 나란히 적힌 blocking 은 아무 코드도 읽지 않는다. 여섯 게이트가 모두 참인데 매니페스트 파서와 게이트 검증기 어느 쪽도 그 필드를 읽지 않는다.

검증 환경

OpenJDK : 21.0.12 Hibernate ORM : 7.2.24.Final 확인 방식 : 단언 대조, 락파일 좌표로 만든 클래스패스에서 같은 조회 실행, 레지스트리·레인·검증기 대조 소스 수정 : x

재현 조건

  1. 매트릭스에서 게이트의 선언과 막는 대상, 테스트의 javadoc 을 읽는다.
  2. 그 메서드의 단언 두 줄과, 둘째 단언이 보는 값을 만드는 팩토리를 확인한다.
  3. 락파일 좌표로 클래스패스를 만들고 게이트와 같은 형태의 조회를 실행한다. 경고와 문장 수와 반환 크기를 본다.
  4. 메모리 페이지네이션을 실패로 바꾸는 설정이 켜져 있는지 센다.
  5. 레지스트리의 태스크와 그 레인의 태그, 테스트의 태그를 비교한다.
  6. 게이트 검증기가 무엇을 보는지와, 레지스트리를 읽는 테스트의 게이트 단언을 읽는다.

본문

지원 매트릭스의 릴리스 게이트 표가 collection-fetch-pagination 행을 두고, 막는 대상을 이렇게 적는다.

a paged collection fetch silently reading the whole table and paginating in memory

javadoc 은 단언 대상을 SQL 이라고 적는다

:::evidence key="a05-f014-collection-fetch-pagination" alt="지원 매트릭스의 게이트 행, 계약 테스트의 javadoc 전문과 태그와 픽스처 상수, 그 테스트가 실제로 쓰는 단언 목록, 둘째 단언이 보는 값을 만드는 팩토리, 그리고 같은 모듈의 제공자 정책 클래스가 이 형태를 적어 둔 javadoc 을 출력한 터미널 기록." caption="매트릭스의 게이트 행 · 단언 대상이 SQL 이라는 javadoc · 실제 단언 목록 · 기대값 팩토리가 적어 둔 true · 제공자 정책의 같은 경고 — 51줄 · exit 0" zoom="true" :::

The assertion is therefore on the generated SQL, not on the returned page size.
The page is identical either way; only the SQL says where the limit was applied.

메서드가 실제로 쓰는 단언 둘

assertThat(page).hasSizeLessThanOrEqualTo(expected.maxReturnedParents());
assertThat(expected.requiresDatabaseLimit()).isTrue();

expected 는 같은 메서드 첫 줄에서 만든 값이고, 만드는 팩토리는 이렇게 되어 있다.

public static FetchPaginationExpectation hibernate74PostgreSql(int pageSize) {
  return new FetchPaginationExpectation(pageSize, true, pageSize * 100);
}

둘째 인자가 requiresDatabaseLimit 이다. 이 단언은 팩토리가 34행에 적어 둔 true 를 다시 읽는다.

기대값 레코드의 javadoc 도 그 필드가 반환된 페이지가 아니라 생성된 SQL 에 대해 단언된다고 적는다. 테스트 javadoc 과 레코드 javadoc 이 같은 약속을 적어 두고, 메서드 본문은 그 약속을 실행하지 않는다.

그 실패는 지난 일이 아니다

:::evidence key="a05-f014-collection-fetch-pagination-probe" alt="락파일이 고정한 하이버네이트와 H2 의 버전, 저장소에서 메모리 페이지네이션을 실패로 바꾸는 설정을 켜는 곳의 수, 게이트와 같은 모양의 컬렉션 페치 조회를 그 제공자에게 직접 시켰을 때의 경고와 반환 부모 수와 프리페어드 스테이트먼트 수, 그리고 서버 없이 도는 레인에 있는 같은 형태의 테스트를 출력한 터미널 기록." caption="락파일의 제공자 7.2.24 · 메모리 페이지네이션을 막는 설정 0 · 같은 조회에서 HHH90003004 경고와 문장 1개, 반환 20 · 서버 없는 레인의 같은 형태 — 21줄 · exit 0" zoom="true" :::

javadoc 은 이 동작을 옛 제공자의 것으로 적는다. 락파일이 고정한 제공자는 Hibernate 7.2.24.Final 이고, 게이트와 같은 형태의 조회를 시키면 이렇게 된다.

WARN org.hibernate.orm.query -- HHH90003004: firstResult/maxResults specified with
collection fetch; applying in memory
반환된 부모 수                    : 20
그 조회가 낸 프리페어드 스테이트먼트 : 1
fail_on_pagination_over_collection_fetch : false

메모리 페이지네이션을 실패로 바꾸는 설정을 저장소가 켜지 않는다. 그래서 첫째 단언은 어느 쪽이어도 참이다. SQL 로 밀리면 상한이 붙어 스무 개고, 메모리로 밀리면 하이버네이트가 중복을 걷어낸 리스트를 스무 개까지 잘라 돌려준다. 이 단언이 깨지는 경우는 상한이 아예 무시될 때뿐이고, 그것은 이 게이트가 막겠다고 적은 실패가 아니다.

게이트가 막겠다고 적은 실패 모드는 지난 일이 아니라 이 게이트가 도는 조건이다.

같은 모듈의 javadoc 이 적어 둔 같은 형태

같은 리프의 제공자 정책 클래스가 다른 선택지를 설명하면서 이렇게 적는다.

The alternative — asserting the declared baseline as if it were the runtime one — would
give a green check that proves the constant equals itself while the collection-fetch-
pagination gate runs against a different provider entirely.

이 문장이 경고하는 형태는 제공자 버전 단언이고, 이 게이트는 그 반대편에 예시로 놓여 있다. 그런데 게이트의 둘째 단언이 하는 일이 정확히 그 형태다.

같은 클래스의 다른 단언들

두 번째와 세 번째 테스트는 하이버네이트 통계를 읽는다. 준비된 문장이 둘 이하인지, 페치 조인 없이는 컬렉션 페치가 하나를 넘는지다. 둘 다 제공자 동작을 실제로 측정한다.

네 번째는 행 증폭 상한을 본다. 픽스처는 부모 50 에 부모당 자식 4 이므로 자식 행이 200 이고, 상한은 페이지 크기 20 의 100 배다.

같은 형태가 서버를 요구하지 않는 레인에도 하나 있다. FetchPaginationExpectationTest 의 「one collection page requires a database-level parent bound」는 팩토리가 만든 객체의 maxReturnedParents 가 20 인지와 requiresDatabaseLimit 이 참인지를 본다. 이쪽은 PostgreSQL 없이 매 빌드마다 돈다.

레지스트리가 지목한 생산자와 실제 생산자

:::evidence key="a05-f014-collection-fetch-pagination-lane" alt="릴리스 레지스트리에서 이 게이트에 붙은 생산자 태스크와 blocking 표기, 그 필드를 읽는 코드 수, 프로젝트가 등록하는 레인들과 각 레인의 태그와 게이트 테스트의 태그, 게이트 검증기가 위반으로 보는 네 가지, 레지스트리를 읽는 단위 테스트의 게이트 단언, 그리고 계약 레인이 도는 워크플로와 태스크 의존을 출력한 터미널 기록." caption="게이트의 생산자는 쿼리 플랜 레인, 테스트 태그는 jpa-contract · blocking 을 읽는 코드 0 · 검증기가 보는 네 가지에 태그 없음 · 게이트 단언은 콜론 시작 여부 한 줄 · 계약 레인은 PR·야간·릴리스에서 돎 — 46줄 · exit 0" zoom="true" :::

레지스트리는 이 게이트를 jpaPlatformQueryPlanTest 에 묶는다. 레인은 태그로 테스트를 고르고, 그 레인의 태그는 jpa-queryplan 이며 이 테스트의 태그는 jpa-contract 다. 쿼리 플랜 태그를 단 클래스는 다른 하나뿐이다.

나란히 적힌 blocking: true 는 아무 코드도 읽지 않는다. 여섯 게이트 전부 참이고, 매니페스트 파서도 게이트 검증기도 그 필드를 보지 않는다.

그렇다고 이 테스트가 안 도는 것은 아니다. jpa-contract 를 고르는 레인이 PR 워크플로와 야간 워크플로에서 돌고, 릴리스 집계 태스크도 그 레인을 의존한다. 틀린 것은 실행 여부가 아니라 증거의 출처다. 게이트 이름으로 지목된 태스크의 JUnit 결과에는 이 테스트가 들어 있지 않고, 게이트 태스크만 단독으로 재검증하면 대상 시나리오는 한 번도 실행되지 않는다.

이 불일치를 지나가게 하는 검증기

레지스트리에는 검증기가 붙어 있다. 게이트마다 네 가지를 본다.

gate task must be an absolute Gradle path
no project at '<projectPath>' for gate task
no task '<taskName>' in '<projectPath>'
'<path>' is not a Test task, so it produces no JUnit evidence

넷 다 이 게이트에서 참이다. 태스크가 어떤 테스트를 고르는지는 검사 항목에 없다.

레지스트리를 읽는 단위 테스트의 게이트 단언은 한 줄이다.

assertThat(manifest.taskFor(gate)).startsWith(":");

이 사례가 고발하는 형태와 같은 형태의 단언이 이 게이트를 지키고 있다.

확인하지 못한 것

실제 PostgreSQL 에서 같은 조회를 재지 않았다. 탐침은 H2 위에서 돌렸고, 상한이 SQL 로 갔는지는 제공자 경고와 문장 수로 판정했다.

계약 레인을 실제로 돌려 JUnit 결과를 확인하지도 않았다. 워크플로와 태스크 의존을 읽었다.