Files
document-haness/docs/clean-architecture-backend-template/tech-log-studio/what-a-gate-does-not-prove/case/case-a05-f033-jpa-flyway-migration.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

13 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-f033-jpa-flyway-migration 선택된 카드의 생산자 태스크가 지금 빌드를 실패시킨다 what-a-gate-does-not-prove clean-architecture-backend-template 게시 전 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 case:a05-f033-jpa-flyway-migration 2026-09-02 case-a05-f033-jpa-flyway-migration.body.md
key file
a05-f033-jpa-flyway-migration ../../../final/evidence/rendered/a05-f033-jpa-flyway-migration.svg
key file
a05-f033-jpa-flyway-migration-run ../../../final/evidence/rendered/a05-f033-jpa-flyway-migration-run.svg
key file
a05-f033-jpa-flyway-migration-gate ../../../final/evidence/rendered/a05-f033-jpa-flyway-migration-gate.svg
../../../final/evidence/raw/a05-f033-jpa-flyway-migration.txt
../../../final/evidence/raw/a05-f033-jpa-flyway-migration-run.txt
../../../final/evidence/raw/a05-f033-jpa-flyway-migration-gate.txt
원본 분석 절은 `final/document.md#a05` §132 다. 등급은 P1 이다. 생산자가 현재 리비전에서 실패한다는 판정, 두 실패의 원인, 두 스트림의 파일 수, 단언이 마지막으로 갱신된 시점과 그 뒤 들어온 마이그레이션 다섯의 날짜, 그리고 이 레인을 도는 자동 경로가 그 절에 있다.
이 카드를 선행 조건으로 두는 카드 전수와 집계 게이트가 카드 id 를 하드코딩하는 자리, 게이트를 부르는 잡의 방아쇠, 그리고 47행 실패에 가려진 62행 단언은 이 기록에서 확인했다.

선택된 카드의 생산자 태스크가 지금 빌드를 실패시킨다

준비도 카드 하나가 선택 상태다. 그 카드의 생산자 태스크를 돌리면 네 시험 중 둘이 47행과 89행에서 멈추고 빌드가 실패한다. 두 실패 모두 스트림에 마이그레이션이 들어왔는데 그 스트림의 적용 집합을 목록 리터럴로 고정한 단언이 그대로인 것이다.

관계

  • 아무도 돌리지 않는 레인의 게이트는 마지막으로 돌린 사람이 본 것을 보고한다 이 사례가 그 규칙의 형태다.
  • 마이그레이션 스트림은 자기 history 테이블을 갖는다 스트림별 버전 목록을 한 곳에 고정하는 구조다.
  • 컨테이너가 필요한 레인의 실제 결과를 실행으로 확인하지 않았다 이 레인이 그 질문의 대상 중 하나다.

문제

카드마다 증거를 만들 태스크가 이름으로 묶여 있다. 이 카드를 선행 조건으로 두는 카드가 열하나이고 그중 셋이 선택 상태이며, 셋 중 하나가 R2 집계 게이트다.

결론

두 실패는 같은 기제의 두 사례다. 단언이 스트림의 적용 집합을 목록 리터럴로 고정하는데, 그 스트림에 마이그레이션이 들어오면 리터럴이 낡는다. 시나리오는 다르다. 하나는 기존 history 채택이고 다른 하나는 새 코어 스트림 초기화다. 그 차이는 실패에 관여하지 않는다.

기반 스트림은 적용 집합을 1, 3, 4, 5, 6 으로 고정했는데 실제로는 9, 10, 11, 12 가 더 있다. 코어 스트림은 1 로 고정했는데 실제로는 2 가 더 있다.

단언은 7월 31일 이후로 바뀌지 않았다. 그 뒤 두 스트림에 마이그레이션 다섯이 들어왔고, 지금 실패하는 두 단언을 함께 낡게 만든 것은 8월 15일의 커밋 하나다. 그 커밋이 기반 스트림의 V9 와 코어 스트림의 V2 를 동시에 넣었다.

고칠 자리는 셋이다. 47행과 89행은 지금 실패하고, 62행은 47행 실패에 가려 실행되지 않는다. 47행을 고치면 62행이 곧바로 같은 이유로 실패한다.

같은 파일의 네 번째 고정 단언은 통과한다. 그 스트림은 픽스처라 자라지 않았다. 깨지는 것은 스트림이 자란 자리뿐이다.

재발 방지는 같은 소스 세트가 이미 쓰는 방식을 기반 스트림에도 적용하는 것이다. 그 방식은 스트림마다 표 이름과 버전 목록을 한 자리에 두고, 그 목록에 붙은 주석이 목록을 스트림의 계약이라고 못박는다.

검증 환경

OpenJDK : 21.0.12 Gradle : 9.0.0 데이터베이스 : PostgreSQL, Testcontainers 확인 방식 : 깨끗한 작업 트리에서 재실행을 강제해 생산자 태스크 실행, 게이트의 태스크 그래프 조회 소스 수정 : x

재현 조건

  1. 준비도 카드 정의에서 이 카드의 상태와 생산자 태스크를 확인한다.
  2. 이 카드를 선행 조건으로 두는 카드를 전수로 열거하고 각각의 상태를 읽는다.
  3. 그 태스크가 고정한 적용 집합 넷을 읽는다.
  4. 두 스트림의 실제 마이그레이션 파일을 나열하고, 단언 파일과 각 마이그레이션의 최종 커밋 날짜를 비교한다.
  5. 깨끗한 작업 트리에서 재실행을 강제해 그 태스크를 돌린다.
  6. 풀 리퀘스트 워크플로가 부르는 게이트의 태스크 그래프에 이 태스크가 있는지 조회한다.

본문

카드 정의는 카드마다 증거를 만드는 태스크를 이름으로 지정한다. jpa-flyway-migration 의 그 태스크가 지금 무엇을 하는지가 이 기록의 전부다.

카드 하나에 걸린 것

:::evidence key="a05-f033-jpa-flyway-migration" alt="준비도 카드 정의에서 이 카드의 상태와 생산자 태스크, 이 카드를 선행 조건으로 두는 카드 전수와 각각의 상태, R2 집계 게이트의 선행 조건 목록과 게이트가 카드 id 를 하드코딩한 자리, 그 태스크가 고정한 적용 집합 네 개, 두 스트림의 실제 마이그레이션 파일, 단언 파일과 각 마이그레이션의 최종 커밋 날짜, 그리고 같은 소스 세트가 이 실패 유형을 적어 둔 주석을 출력한 터미널 기록." caption="카드는 selected, 생산자는 postgresqlMigrationIntegrationTest · 이 카드를 선행 조건으로 두는 카드 열하나, 그중 selected 셋 · 고정 단언 넷은 47·62·89·123행 · 실제 스트림은 아홉 개와 둘 · 단언은 7월 31일 이후 미갱신 — 56줄 · exit 0" zoom="true" :::

카드 정의가 스스로 적는 선행 조건은 둘뿐이다. 반대 방향이 크다. 이 카드를 선행 조건으로 두는 카드가 열하나이고, 그중 jpa-primary-foundation 은 R2 집계 게이트다.

네 시험 중 둘이 47행과 89행에서 멈춘다

:::evidence key="a05-f033-jpa-flyway-migration-run" alt="작업 트리가 깨끗한 것을 확인하고 카드의 생산자 태스크를 재실행 강제로 돌린 결과와 실패한 두 시험의 이름과 줄 번호, 통과·실패 수, 실패한 태스크 이름, 빌드 결과, 그리고 두 실패의 단언 메시지 전문을 출력한 터미널 기록. 채집 스크립트가 파이프와 || true 로 실패를 삼켜 스크립트 자신은 0 으로 끝난다. 빌드는 실패했다." caption="작업 트리 변경 0줄 · 재실행 강제 · 네 시험 중 둘 실패 · BUILD FAILED, 태스크 종료 1 · 기반 스트림은 9·10·11·12 가, 코어 스트림은 2 가 예상 밖 — 27줄 · 채집 스크립트 exit 0" zoom="true" :::

PostgreSqlMigrationIntegrationTest > adoptsImmutableLegacyHistoryThenRunsTheIndependentCoreStream() FAILED
    org.opentest4j.AssertionFailedError at PostgreSqlMigrationIntegrationTest.java:47
PostgreSqlMigrationIntegrationTest > freshCoreStreamInitializesWithoutLegacyHistory() FAILED
    org.opentest4j.AssertionFailedError at PostgreSqlMigrationIntegrationTest.java:89
4 tests completed, 2 failed
> Task :adapter:outbound:persistence-jpa:postgresqlMigrationIntegrationTest FAILED
BUILD FAILED in 31s

늘어난 버전이 단언 메시지에 그대로 찍힌다.

Expecting actual:
  ["1", "3", "4", "5", "6", "9", "10", "11", "12"]
to contain exactly (and in same order):
  ["1", "3", "4", "5", "6"]
but some elements were not expected:
  ["9", "10", "11", "12"]

단언 파일과 마이그레이션 파일의 최종 커밋 날짜를 나란히 놓으면 언제부터인지가 나온다.

2026-07-31  PostgreSqlMigrationIntegrationTest.java
2026-08-15  jpa/core/V2, postgresql/V9   (같은 커밋)
2026-08-18  postgresql/V10
2026-08-28  postgresql/V11, postgresql/V12

넷 중 셋이 낡았다

같은 파일에 구조가 똑같은 고정 단언이 넷 있다.

47:        .containsExactly("1", "3", "4", "5", "6");
62:    assertThat(appliedVersions(postgres, "flyway_jpa_core_history")).containsExactly("0", "1");
89:      assertThat(appliedVersions(fresh, "flyway_jpa_core_history")).containsExactly("1");
123:      assertThat(appliedVersions(interrupted, "flyway_readiness_interrupted")).containsExactly("1");

62행은 47행과 같은 시험 안에 있어서, 47행이 죽으면 실행되지 않는다. 그 시험은 코어 스트림을 0 으로 baseline 한 뒤 migrate 하고 조회 도우미는 baseline 행까지 읽는다. 코어 스트림에는 V1 과 V2 가 있으므로 62행이 받을 값은 0, 1, 2 다.

123행은 통과한다. flyway_readiness_interrupted 는 픽스처 스트림이라 마이그레이션이 늘지 않았다.

같은 소스 세트가 이 실패를 이미 적어 두었다

// This list is the stream's contract, not a note about its length: the lifecycle below
// disables, re-enables and interrupts the stream and asserts the applied set is unchanged
// each time. So a migration added to the stream belongs here, and the version that landed
// without being added is why the lane failed the first time anybody ran it.
List.of("0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10"),

PostgreSqlOptionalStreamLifecycle 은 같은 소스 세트에 있고 스트림마다 표 이름과 버전 목록을 한 자리에 둔다. 마이그레이션을 더할 때 고칠 자리가 그 목록 하나다. 기반 스트림 쪽은 그 자리가 시험 본문 세 곳에 흩어져 있다.

이름으로 부르는 워크플로는 없지만 그래프에는 있다

:::evidence key="a05-f033-jpa-flyway-migration-gate" alt="이 태스크를 실행하는 워크플로 둘과 각각의 방아쇠, 각 워크플로가 부르는 게이트 태스크, 매니페스트 태스크가 활성 카드의 준비도 태스크에 의존하는 코드, 풀 리퀘스트 게이트의 실제 태스크 그래프에 이 준비도 태스크가 들어 있음을 보이는 dry-run 출력, 그리고 R2 집계 게이트가 이름으로 요구하는 카드 일곱을 출력한 터미널 기록." caption="ci-quality-gates 는 pull_request 와 main push 에서 돌고 jpa-r2-evidence 는 수동 실행뿐 · 매니페스트 태스크가 활성 카드의 준비도 태스크에 dependsOn · dry-run 그래프에 postgresqlMigrationIntegrationTest 존재 — 37줄 · exit 0" zoom="true" :::

이 태스크를 이름으로 부르는 워크플로는 없다. 태그 레인 다섯 어디에도 속하지 않고 릴리스 게이트에도 없다. 대신 매니페스트를 만드는 태스크가 활성 카드마다 그 카드의 준비도 태스크에 의존하고, 이 태스크가 그중 하나다.

그래서 verifyJpaCandidateEvidence 의 그래프에 이 태스크가 들어온다. 그 게이트를 부르는 잡은 풀 리퀘스트마다, 그리고 main 으로 push 할 때마다 돈다.

R2 집계 게이트 쪽은 수동 실행뿐이고, 그 게이트는 카드 일곱의 매니페스트가 전부 R2 여야 통과한다. 그 일곱에 이 카드가 이름으로 들어 있다. 다만 게이트는 그 검사에 닿기 전에 멈춘다. 매니페스트를 만드는 태스크가 먼저 실패하기 때문이다.

확인하지 못한 것

47행을 고친 뒤 62행이 실패하는 것을 실행으로 보지는 않았다. 소스를 고치지 않는 조건이라, 62행이 받을 값은 같은 스트림을 baseline 없이 도는 다른 시험이 실제로 낸 1, 2 와 조회 도우미가 baseline 행까지 읽는다는 것에서 도출했다.

같은 준비도 묶음의 다른 레인 하나는 이 컨테이너의 네트워크 구성 때문에 실패한다. 인증서가 로컬 이름으로 발급되어 있고 검증 모드가 전체 검증이므로, 컨테이너의 매핑 포트가 시험 JVM 의 루프백에서 열려 있어야 한다. 이 분석은 Docker 소켓을 공유하는 형제 컨테이너에서 실행돼 매핑 포트가 브리지 주소에만 열렸고 실패는 연결 예외다.

그 레인은 건너뛰지 않고 실패하도록 설계되어 있으므로 동작 자체는 의도대로다. 다만 건너뛰지 않는다는 선택의 대가로 Docker 호스트와 시험 JVM 이 루프백을 공유하는 환경이 그 레인의 암묵적 전제가 된다.