Files
document-haness/docs/clean-architecture-backend-template/tech-log-studio/declared-contract-without-enforcement/case/case-an-order-contract-with-no-implementation.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

14 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 an-order-contract-with-no-implementation 순서 계약을 구현하는 것이 없고, 그것을 검증한다는 테스트는 enum 선언 순서만 본다 declared-contract-without-enforcement clean-architecture-backend-template 게시 전 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 case:an-order-contract-with-no-implementation 2026-09-04 case-an-order-contract-with-no-implementation.body.md
key file
an-order-contract-with-no-implementation ../../../final/evidence/rendered/an-order-contract-with-no-implementation.svg
../../../final/evidence/raw/an-order-contract-with-no-implementation.txt
원본 분석은 evidence/raw/280-transport-spi-lifecycle-unimplemented.txt 에 남은 덤프이고 별도의 분석 절은 없다.

순서 계약을 구현하는 것이 없고, 그것을 검증한다는 테스트는 enum 선언 순서만 본다

MessagingLifecycleShutdownPhase 여덟 상수로 종료 순서를 적어 두었는데 이것을 구현한다고 선언한 클래스가 저장소에 없다. 순서를 검증한다는 시험 다섯은 values() 가 돌려주는 선언 순서를 보고, 사본에서 CLOSE_CONNECTIONS 를 앞으로 옮기면 그중 셋이 깨진다.

관계

  • 8단계 종료 순서 계약과 실제 종료 경로 이 사례가 속한 구조다. 계약은 여덟 단계를 선언하고 실제 종료는 SmartLifecycle 을 구현한 두 클래스가 맡는다.
  • 선언 순서를 단언하는 테스트는 그 순서를 읽는 코드가 있을 때만 게이트다 이 사례가 만든 규칙이다. 인덱스를 읽는 프로덕션 코드가 없으므로 재배열 말고는 아무것도 막지 못한다.
  • 같은 안전 규칙은 하나의 공식과 하나의 강제 시점을 갖는다 그 규칙이 든 예시는 공개 상수와 비공개 복사본과 생성자 인자 셋이다. DEFAULT_DRAIN_DEADLINE 이라는 이름으로 다시 세면 MessagingLifecycle:40OutboxRelayWorker:35DefaultMessagingRuntimeRegistry:28 이 나온다.
  • 드레인 마감 30초가 세 곳에서 독립적으로 결정된다 같은 상수를 먼저 센 기록이다. 여기서는 실제로 주입되는 값이 MessagingSettings:281 의 프로퍼티 기본값이라는 것까지 확인했다.

문제

종료 순서를 열거형으로 적어 두면 순서가 한 파일에 모이고, 그 단계들을 인터페이스로 두면 구현한다고 선언한 클래스가 여덟 단계를 다 채워야 컴파일된다.

이 리프가 그 두 가지를 함께 노린 형태라, 둘 중 어느 쪽이 실제로 성립하는지 확인했다.

결론

인터페이스는 ShutdownPhase 여덟 상수와 start() · shutdown(Duration) · isRunning() 을 선언한다. 자바독 :8~:11 은 단계의 배열이 각 어댑터가 알아서 정할 몫이 아니라고 못 박는다.

구현체가 없다. implements MessagingLifecycle 로 검색하면 0 건이고, 두 이름을 언급하는 파일은 설계 계획 문서와 인터페이스와 그 시험 셋뿐이다. 같은 검색식으로 센 implements MessagingTransport 가 6 건을 내므로 이 0 은 매치를 놓친 결과가 아니다. 그 여섯 중 넷이 어댑터이고 둘은 시험 클래스다.

컴파일 강제도 생기지 않는다. 자바에서 메서드 구현을 요구받는 쪽은 인터페이스가 아니라 그것을 구현한다고 적은 클래스이기 때문이다.

순서를 검증한다는 시험은 MessagingLifecycleTest 안의 다섯이다. 넷은 List.of(ShutdownPhase.values()) 에서 두 상수의 indexOf 를 비교하고, connectionsCloseLastOfAll:30 은 values() 배열의 마지막 원소가 CLOSE_CONNECTIONS 인지 본다.

두 파일만 /tmp 로 복사해 CLOSE_CONNECTIONS 를 첫 상수로 옮기고 컴파일했더니 여섯 중 셋이 깨졌다. 열거형을 읽는 프로덕션 코드가 0 건이므로 이 재배열의 영향은 그 시험 파일 안에서 끝난다.

이름이 DEFAULT_DRAIN_DEADLINE 인 30 초짜리 선언은 셋이다. 다만 배선된 경로가 쓰는 값은 그 셋이 아니라 MessagingSettings:281 의 프로퍼티 기본값이고, MessagingCoreAutoConfiguration 이 :192·:204·:243 에서 그것을 넘긴다.

검증 환경

OpenJDK : 21.0.12 Gradle : 9.0.0 확인 방식 : 인터페이스와 시험 파일 전문 게재, ShutdownPhase 상수 계수와 인터페이스가 요구하는 메서드 나열, 두 이름의 pathspec 없는 전수 검색, implements 검색을 대조군과 자기시험과 함께 셋으로 제시, DEFAULT_DRAIN_DEADLINE 선언과 사용처, 프로퍼티 기본값과 그것을 넘기는 자리, SmartLifecycle 구현 둘, 두 파일 사본을 /tmp 에서 컴파일해 상수 순서를 바꾸기 전후로 실행 소스 수정 : x

재현 조건

  1. 인터페이스와 시험 파일을 각각 전문으로 싣는다.
  2. 열거형 상수를 세고 인터페이스가 요구하는 메서드를 나열한다.
  3. 경로 한정 없이 저장소 전체에 두 이름을 검색해 언급 파일을 뽑는다.
  4. implements 검색을 세 가지로 건다 — 대상, 없는 이름, 대조 타입.
  5. DEFAULT_DRAIN_DEADLINE 선언과 사용처를 모듈과 소스 세트를 남긴 채 나열한다.
  6. 프로퍼티로 들어오는 드레인 마감과 그것을 받는 자리를 함께 찾는다.
  7. 두 파일을 /tmp 로 복사해 그대로 한 번, 상수 순서를 바꿔 한 번 컴파일하고 시험을 돌린다. 끝나고 저장소 변경이 0 건인지 확인한다.

본문

MessagingLifecycle 은 종료 순서를 타입으로 적어 둔 인터페이스다. ShutdownPhase 열거형이 여덟 단계를 선언하고(:20~:37), 인터페이스 자신은 start()shutdown(Duration)isRunning() 을 요구한다.

ShutdownPhase 여덟 상수와 자바독이 적는 순서 이유

:::evidence key="an-order-contract-with-no-implementation" alt="저장소 루트에서 돌린 정적 검색과 /tmp 사본 실행의 출력 181줄. 먼저 MessagingLifecycle.java 67줄이 전문으로 실린다. 클래스 자바독은 ShutdownPhase 의 순서가 계약이지 구현 세부가 아니라고 적고, 열거형이 STOP_PUBLISH_ADMISSION 부터 CLOSE_CONNECTIONS 까지 여덟 상수를 각각의 설명과 함께 선언하며, 40번 줄에 30초짜리 DEFAULT_DRAIN_DEADLINE 이 있다. 상수 개수가 8 개로 세어지고, 인터페이스가 요구하는 메서드로 start 와 shutdown 과 isRunning 셋이 나온다. 이어서 MessagingLifecycleTest.java 59줄이 전문으로 실린다. 시험은 여섯이고, 넷은 List.of 로 만든 목록에서 두 상수의 indexOf 를 비교하며, connectionsCloseLastOfAll 은 values 배열의 마지막 원소가 CLOSE_CONNECTIONS 인지 보고, 여섯째는 상수가 30초인지 본다. 각 시험의 as 설명문도 함께 실린다. 다음으로 두 이름이 나오는 파일이 pathspec 없이 셋으로 나오는데 설계 계획 문서와 인터페이스와 그 시험이다. implements MessagingLifecycle 를 가진 파일은 0 개, 없는 이름으로 건 자기시험도 0 개, 대조로 센 implements MessagingTransport 는 6 개이고 그 여섯이 나열되는데 넷은 main 어댑터이고 둘은 시험 클래스다. DEFAULT_DRAIN_DEADLINE 이라는 이름의 선언은 셋인데 OutboxRelayWorker 35번 줄과 DefaultMessagingRuntimeRegistry 28번 줄과 MessagingLifecycle 40번 줄이고, 앞 둘은 각각 129번과 36번 줄에서 자기 상수를 읽는다. 그 아래에 프로퍼티로 들어오는 드레인 마감이 나오는데 MessagingSettings 281번 줄의 기본값 30초를 MessagingCoreAutoConfiguration 이 192번·204번·243번 줄에서 세 곳에 넘긴다. 오늘 종료를 담당하는 구현으로 MessagingShutdownLifecycle 과 MessagingOutboxRelayLifecycle 이 나오는데 둘 다 SmartLifecycle 을 구현한다. 마지막으로 레포 사본을 /tmp 에서 컴파일한 결과가 나온다. 그대로면 통과 6 실패 0 이고, CLOSE_CONNECTIONS 를 첫 상수로 옮기면 통과 3 실패 3 이 되며 깨지는 시험은 connectionsCloseLastOfAll 과 outboxLeasesAreReleasedBeforeClosing 과 producerConfirmsAreAwaitedBeforeTheConnectionGoesAway 다. 레포 변경은 0 건이다." caption="인터페이스 67줄과 시험 59줄 전문 · implements 0 과 자기시험 0 과 대조 6 및 그 main/test 구분 · 이름이 같은 상수 셋과 실제로 주입되는 프로퍼티 값 · SmartLifecycle 로 구현된 종료 둘 · 사본에서 상수를 재배열했을 때 깨지는 시험 셋 — 181줄 · exit 0" zoom="true" :::

여덟은 선언 순서대로 STOP_PUBLISH_ADMISSION, STOP_NEW_HANDLERS, PAUSE_CONSUMERS, DRAIN_HANDLERS, FLUSH_SETTLEMENTS, AWAIT_PRODUCER_CONFIRMS, RELEASE_OUTBOX_LEASES, CLOSE_CONNECTIONS 다.

클래스 자바독 :8~:11 은 이 순서가 계약이지 구현 세부가 아니라고 적는다. 정착이 전송되기 전에 연결을 닫으면 그 정착을 잃고, 드레인 뒤에 소비자를 멈추면 이미 종료 중인 런타임으로 새 배달이 들어온다는 것이다. 단계를 구현하는 것은 각 어댑터이고 순서는 어느 어댑터도 고르지 않는다고 적는다.

MessagingLifecycle 을 구현한다고 선언한 클래스가 0 개다

두 이름이 나오는 파일을 pathspec 없이 저장소 전체에서 찾으면 셋이다 — 설계 계획 문서 하나, 인터페이스 자신, 그 시험.

implements MessagingLifecycle 을 가진 파일은 0 개다. 같은 검색식에 없는 이름을 넣어도 0 이 나오므로, 0 만으로는 검색이 매치를 놓친 경우와 구별되지 않는다. 그래서 같은 식으로 implements MessagingTransport 를 세면 6 개가 나온다. 검색식은 매치를 찾을 수 있는 상태다.

그 여섯 중 넷은 KafkaMessagingTransport:41 · NatsJetStreamTransport:54 · PulsarMessagingTransport:47 · RabbitMessagingTransport:39 어댑터이고, 나머지 둘은 DefaultMessagePublisherTest:426MessagingRuntimeRegistryTest:230 의 시험 더블이다.

implements 선언이 없으면 컴파일러가 여덟 단계 중 무엇도 요구하지 않는다. 전송 SPI 에서 강제가 걸리는 이유는 그것을 구현한다고 적은 파일이 여섯 있기 때문이고, 그 여섯 각각이 선언한 메서드를 다 채우지 않으면 빌드가 실패한다.

MessagingLifecycleTest 의 여섯 시험이 비교하는 값

시험은 여섯이고 그중 다섯이 순서를 다룬다. 넷은 List.of(ShutdownPhase.values()) 로 목록을 만들어 두 상수의 indexOf 를 비교한다. 나머지 하나인 connectionsCloseLastOfAll:30ShutdownPhase.values() 를 배열로 받아 order[order.length - 1]CLOSE_CONNECTIONS 인지 본다. 여섯째 theDefaultDrainDeadlineMatchesTheDesign:56 은 상수가 30 초인지 본다.

다섯이 한 번도 언급하지 않는 상수는 STOP_NEW_HANDLERS 하나다.

각 시험의 .as() 설명문은 시스템 동작을 적는다. handlersDrainBeforeTheirSettlementsAreFlushed:25 는 핸들러가 끝나기 전에 플러시하면 그 핸들러가 만들 정착을 잃는다고 적고, producerConfirmsAreAwaitedBeforeTheConnectionGoesAway:43 은 닫은 뒤에 도착한 확인은 관측할 수 없어 발행이 미결로 남는다고 적는다. 그 시험을 통과시키는 조건은 두 상수의 선언 위치뿐이다.

상수를 재배열하면 시험 셋이 깨지고 그 밖에는 아무것도 깨지지 않는다

인터페이스와 시험 두 파일을 /tmp 로 복사해 저장소를 건드리지 않고 컴파일했다. 사본 그대로는 여섯이 모두 통과한다.

CLOSE_CONNECTIONS 를 첫 상수로 옮기고 다시 컴파일하니 셋이 깨졌다 — connectionsCloseLastOfAll, outboxLeasesAreReleasedBeforeClosing, producerConfirmsAreAwaitedBeforeTheConnectionGoesAway. 나머지 셋은 그대로 통과한다.

ShutdownPhase 를 자기 파일 밖에서 읽는 프로덕션 코드가 없으므로, 이 재배열이 바꾸는 것은 이 시험 파일의 통과 여부뿐이다.

DEFAULT_DRAIN_DEADLINE 이라는 이름이 세 곳에 따로 선언된다

MessagingLifecycle:40 의 공개 상수, OutboxRelayWorker:35public static final, DefaultMessagingRuntimeRegistry:28private static final 이 각각 Duration.ofSeconds(30) 을 적는다. 뒤 둘은 앞의 것을 참조하지 않고 :129:36 에서 자기 상수를 읽는다.

다만 실제로 주입되는 값은 넷째 자리에 있다. MessagingSettings:281drainDeadline 기본값이 30 초이고, MessagingCoreAutoConfiguration:192:204:243 에서 그 값을 세 군데로 넘긴다. DefaultMessagingRuntimeRegistry:28 의 복사본은 무인자 생성자(:36)로만 닿는다.

인터페이스의 상수를 읽는 자리는 MessagingLifecycleTest:57 하나다.

오늘 종료를 담당하는 것은 다른 인터페이스다

MessagingShutdownLifecycle:30MessagingOutboxRelayLifecycle:23 이 Spring 의 SmartLifecycle 을 구현한다. 여덟 단계가 아니라 그쪽이 실제 종료 순서를 정한다.

이 기록은 그 둘이 여덟 중 무엇을 밟는지까지 세지 않았다.

확인하지 못한 것

SmartLifecycle 을 구현한 MessagingShutdownLifecycleMessagingOutboxRelayLifecycle 이 여덟 단계 중 몇을 실제로 밟는지 세지 않았다. 계약을 구현한다고 선언한 타입이 없다는 데까지 확인했다.

재배열은 두 파일만 복사한 사본에서 돌렸다. 전체 빌드로 확인하지는 않았다.

STOP_NEW_HANDLERS 만 시험에 나오지 않는 이유는 찾지 않았다.