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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
43bccd08a8
commit
b2963105a8
+155
@@ -0,0 +1,155 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: analysis-finding-a03-f001
|
||||
title: 네 답을 주는 claim 이 있는데 서비스는 있음·없음 두 갈래로 판단한다
|
||||
topic: multitenancy-isolation
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:analysis-finding-a03-f001
|
||||
evidenceCapturedOn: 2026-09-04
|
||||
body: case-analysis-finding-a03-f001.body.md
|
||||
assets:
|
||||
- key: analysis-finding-a03-f001
|
||||
file: ../../../final/evidence/rendered/analysis-finding-a03-f001.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/analysis-finding-a03-f001.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/03-application-core.md §16 P1 이다.
|
||||
---
|
||||
|
||||
# 네 답을 주는 claim 이 있는데 서비스는 있음·없음 두 갈래로 판단한다
|
||||
|
||||
`AdminOperationClaim` 은 `CLAIMED`·`REPLAY`·`IN_PROGRESS`·`CONFLICT` 를 돌려주도록 만들어졌고, 그 넷을 하나로 접으면 안 되는 이유가 클래스 자바독에 적혀 있다. `NotificationAdminApplicationService` 는 `claim` 을 한 줄도 부르지 않고 네 경로 모두 `findByOperationId` 의 `Optional` 이 비었는지로 판단한다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **조건부 update로 행을 claim하고 읽은 값으로 판단하지 않는다**
|
||||
`NotificationAdminApplicationService` 의 네 경로가 어기는 규칙이다. 붙들고 판단하는 연산이 포트에 있는데 서비스는 조회 결과의 있음·없음으로 판단한다.
|
||||
- **CAS 튜플을 where 절에 전부 반복하고 update count를 답으로 쓴다**
|
||||
삽입 건수를 답으로 쓰는 부분을 `JpaAdminOperationStore:40` 이 지킨다. 그 값을 받아 분기하는 코드가 서비스에 없다.
|
||||
- **중복 장치를 찾으면 어느 쪽이 조립됐는지 먼저 확인한다**
|
||||
이 사례가 속한 구조다. 만들어지는 것과 호출되는 것을 나눠 세면 `claim` 은 포트와 JPA 구현이 다 있는데 부르는 코드가 없다.
|
||||
|
||||
## 문제
|
||||
|
||||
관리자 연산은 멱등 키를 받는다. 그 키가 있는 이유를 포트 자바독 첫 줄이 적어 둔다.
|
||||
|
||||
그 키를 원자적으로 붙드는 연산이 포트와 JPA 구현 양쪽에 있다. 서비스가 그것을 부르는지 확인했다.
|
||||
|
||||
## 결론
|
||||
|
||||
포트에는 연산이 셋 있다. 서비스가 부르는 조회 메서드에 붙은 자바독이 그것을 claim 이라고 부르는데, 그 메서드는 아무것도 붙들지 않는다.
|
||||
|
||||
그 자바독은 네 답이 각각 무엇을 요구하는지도 적는다. 재생은 앞선 결과를 돌려주고 실행하지 않아야 하고, 다른 호출자가 들고 있는 동안에는 실행하지도 끝난 척하지도 않아야 하며, 같은 식별자에 다른 명령이 온 것은 멱등한 반복이 아니라 보고할 실수다.
|
||||
|
||||
JPA 구현은 그 자바독대로 붙든다. JpaAdminOperationStore:40 이 부르는 claimOperation 은 ON CONFLICT (operation_id) DO NOTHING 을 붙인 native INSERT 이고, 삽입된 행 수가 1 이면 이 호출자가 붙든 것이다. :48·:55·:62·:69 가 네 답을 나눠 돌려준다.
|
||||
|
||||
NotificationAdminApplicationService 는 claim 을 부르지 않는다. operations 포트에 거는 호출 여덟이 전부 findByOperationId 넷과 save 넷이고, claim 은 0 줄이다. 대조로 센 값이 4 를 내므로 이 계수는 살아 있다.
|
||||
|
||||
네 경로가 그 사이에 부르는 협력자는 서로 다르다. redrive:121 이 recipients.transition, reconcile:172 가 명령이 준 시도마다 reconciliation.reconcile, suppress:229·:236 이 suppressions.remove 또는 upsert, setProviderState:298 이 runtimes.setState 를 부른다.
|
||||
|
||||
부를 준비도 되어 있지 않다. claim 의 두 번째 인자를 채울 다이제스트를 만드는 코드가 저장소 어디에도 없고, 그 이름이 나오는 자리는 파라미터 선언과 컬럼뿐이다.
|
||||
|
||||
V8 마이그레이션 헤더가 실제 증상을 적는다. 두 번째 요청은 결국 저장에서 막혔지만 그때는 파괴적 동작이 이미 두 번 돈 뒤였다.
|
||||
|
||||
그 계약을 고정한 시험은 postgresqlIntegrationTest 소스 세트에 있고 기본 check 에 걸리지 않는다. 다만 @Tag("jpa-contract") 가 jpaPlatformContractTest 레인에 묶여 있고, notification-platform.yml:85 가 그 레인을 pull_request 에서 부르며 경로 필터가 이 서비스 파일을 포함한다. 서비스를 고치는 PR 마다 이 시험이 돈다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
확인 방식 : 포트와 청구 결과 타입 전문 게재, 마이그레이션 헤더 인용, JPA 구현의 네 분기와 리포지터리의 native 문장 인용, 서비스가 포트에 거는 호출 전수와 claim 계수를 없는 이름과 대조 이름으로 함께 계수, commandFingerprint 가 나오는 자리 전수, 협력자 필드 선언에서 정규식을 만들어 네 메서드 창 안의 호출을 뽑음, 시험 태그에서 gradle 레인과 워크플로 경로 필터까지 배선 추적
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 저장소 포트와 청구 결과 타입을 전문으로 싣는다.
|
||||
2. 그 연산을 만든 마이그레이션의 헤더 주석을 읽는다.
|
||||
3. JPA 구현이 무엇을 보내고 네 답을 어디서 나누는지 확인하고, 리포지터리의 실제 문장을 인용한다.
|
||||
4. 서비스가 포트에 거는 호출을 전부 나열하고, claim 계수를 없는 이름과 대조 이름으로 함께 센다.
|
||||
5. commandFingerprint 가 나오는 자리를 전부 찾아 값을 만드는 코드가 있는지 본다.
|
||||
6. 서비스가 선언한 협력자 필드 이름에서 패턴을 유도해 네 메서드 창 안의 호출을 뽑는다. 고정 목록을 손으로 적지 않는다.
|
||||
7. 계약 시험의 태그를 찾아 gradle 레인과 워크플로와 그 경로 필터까지 따라간다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`AdminOperationStorePort` 는 관리자 연산의 멱등 기록을 맡는다. 같은 키로 온 재시도가 파괴적 동작을 두 번 돌리지 못하게 하는 것이 목적이다.
|
||||
|
||||
## 붙드는 것은 \:31 의 claim 인데 \:8 의 자바독은 findByOperationId 를 claim 이라고 적는다
|
||||
|
||||
:::evidence key="analysis-finding-a03-f001" alt="저장소 루트에서 돌린 정적 검색 출력 227줄. 먼저 AdminOperationStorePort 33줄이 전문으로 실린다. 8번 줄 자바독은 findByOperationId 를 연산 식별자를 claim 하거나 앞선 결과를 돌려주는 것으로 적고, 15~30번 줄 자바독은 관리 경로가 조회 다음 부수 효과 다음 저장이었다는 것과 두 호출자가 모두 없음을 읽고 모두 실행하고 모두 저장했다는 것, 그리고 지문이 청구의 일부라는 것을 적는다. 이어서 AdminOperationClaim 5~35번 줄이 실려 네 답 CLAIMED, REPLAY, IN_PROGRESS, CONFLICT 와 넷을 합치면 중요한 구별이 사라진다는 클래스 자바독이 보인다. 다음으로 V8 마이그레이션 헤더 1~11번 줄이 실리는데, operation_id 의 유니크 제약이 이미 있어서 두 번째 저장은 실패했지만 그것은 두 번째 부수 효과가 이미 일어난 뒤였다고 적고, 이제 청구는 삽입 자체이며 ON CONFLICT DO NOTHING 이 정확히 한 호출자만 행을 만들게 한다고 적는다. 그 아래에 JpaAdminOperationStore 30~71번 줄이 실려 claim 이 삽입 건수로 claimed 를 판정하고 48번에서 claimed, 62번에서 지문이 다르면 conflict, 55번과 65번에서 inProgress, 69번에서 replay 를 돌려주는 것이 보인다. AdminAuditJpaRepository 28~45번 줄은 그 문장이 ON CONFLICT (operation_id) DO NOTHING 을 붙인 native INSERT 임을 보여 준다. 서비스가 operations 포트에 거는 호출은 여덟이고 findByOperationId 넷과 save 넷이다. operations.claim 은 0 줄, 없는 이름으로 건 자기시험도 0 줄, 대조로 센 operations.save 는 4 줄이다. commandFingerprint 가 나오는 자리는 파라미터와 컬럼 이름뿐이고 값을 계산하는 코드가 없다. 그 아래에 네 경로가 조회와 저장 사이에 부르는 협력자가 나오는데, 서비스가 선언한 필드 이름에서 패턴을 유도했다. redrive 는 121번에서 recipients.transition, reconcile 은 172번에서 reconciliation.reconcile, suppress 는 229번에서 suppressions.remove 또는 236번에서 suppressions.upsert, setProviderState 는 298번에서 runtimes.setState 를 부른다. 마지막으로 그 경합을 고정한 시험이 어느 레인에서 도는지 나온다. AdminOperationClaimContractTest 는 postgresqlIntegrationTest 소스 세트에 있고 34번 줄이 jpa-contract 태그를 달며, build.gradle 251~252번이 그 태그를 jpaPlatformContractTest 레인에 묶고, jpa-pr.yml 과 notification-platform.yml 이 pull_request 에서 그 태스크를 부르는데 경로 필터가 src/application-core/src/**/notification/** 을 포함한다. 서비스 이름을 파일명에 가진 시험은 0 개다." caption="포트 33줄 전문과 findByOperationId 자바독 · claim 의 네 답 · V8 헤더가 적은 실제 증상 · JPA 구현 30~71 과 ON CONFLICT DO NOTHING INSERT · 서비스의 호출 여덟과 claim 0 · 지문을 계산하는 코드 부재 · 필드에서 유도한 네 경로의 협력자 호출 · 태그에서 워크플로까지의 레인 배선 — 227줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
포트는 `findByOperationId:9` 와 `save:12` 와 `claim:31` 을 선언한다.
|
||||
|
||||
`:8` 의 자바독은 `findByOperationId` 를 "연산 식별자를 claim 하거나 그 식별자의 앞선 결과를 돌려준다" 고 적는다. 실제로 이 메서드는 조회만 하고 아무것도 붙들지 않는다.
|
||||
|
||||
`claim` 의 자바독 `:15`\~`:30` 이 이 연산이 왜 생겼는지 적는다. 관리 경로가 조회 다음 부수 효과 다음 저장이었고, 같은 식별자를 낸 두 호출자가 모두 "없음" 을 읽고 모두 재구동을 실행하고 모두 저장했다는 것이다. 멱등 키가 검사되기만 하고 붙들리지 않아서 반복은 막았지만 경합은 막지 못했다는 것이 그 이유다.
|
||||
|
||||
같은 자바독이 명령 지문도 청구의 조건이라고 적는다. 같은 식별자에 다른 명령이 오면 재생이 아니라 충돌이고, 그것을 앞선 결과로 답하면 두 명령 중 어느 것도 실행되지 않는다.
|
||||
|
||||
## claim 은 네 답을 주고 findByOperationId 는 두 갈래를 준다
|
||||
|
||||
`AdminOperationClaim` 은 `CLAIMED`·`REPLAY`·`IN_PROGRESS`·`CONFLICT` 를 갖는 record 다. 클래스 자바독은 넷을 합치면 중요한 구별이 사라진다고 적는다.
|
||||
|
||||
`JpaAdminOperationStore` 가 그 넷을 나눈다. `:48` 이 `claimed()`, `:55` 와 `:65` 가 `inProgress()`, `:62` 가 지문이 다를 때 `conflict()`, `:69` 가 `replay()` 다.
|
||||
|
||||
서비스가 쓰는 `findByOperationId` 의 반환은 `Optional<AdminOperationResult>` 하나뿐이라 이 네 갈래를 표현할 자리가 없다.
|
||||
|
||||
## JPA 구현은 ON CONFLICT DO NOTHING 의 삽입 건수를 답으로 쓴다
|
||||
|
||||
`JpaAdminOperationStore:40` 이 `audits.claimOperation` 을 부르고 `:39` 가 그 반환값을 `claimed` 에 담는다.
|
||||
|
||||
`AdminAuditJpaRepository:31`\~`:40` 을 보면 그 문장은 `notification_admin_audit` 에 행을 넣는 native INSERT 이고 끝에 `ON CONFLICT (operation_id) DO NOTHING` 이 붙어 있다. `UPDATE` 도 `WHERE` 절도 없다. 원자성은 조건절이 아니라 `operation_id` 의 유니크 제약에서 나온다.
|
||||
|
||||
V8 마이그레이션 헤더가 그 관계를 적는다. 그 제약은 예전에도 있었고 두 번째 저장을 실패시켰지만, 그것은 두 번째 부수 효과가 이미 일어난 뒤였다. 이제 청구가 삽입 자체이므로 정확히 한 호출자만 행을 만든다.
|
||||
|
||||
## 서비스가 operations 포트에 거는 호출 여덟은 findByOperationId 와 save 뿐이다
|
||||
|
||||
`findByOperationId` 가 `:94`·`:154`·`:203`·`:282` 넷이고 `save` 가 `:145`·`:194`·`:272`·`:328` 넷이다.
|
||||
|
||||
`operations.claim` 을 부르는 줄은 0 개다. 이 0 이 검색식 오류가 아닌지 보려고 같은 파일에서 `operations.save` 를 세면 4 가 나온다.
|
||||
|
||||
부를 수 없는 이유도 있다. `claim` 은 `commandFingerprint` 를 요구하는데 그 값을 계산하는 코드가 저장소에 없다. 그 이름이 나오는 자리는 포트 선언과 JPA 구현의 파라미터, 리포지터리의 컬럼 이름, 계약 시험의 고정 문자열뿐이다.
|
||||
|
||||
## 네 경로가 조회와 저장 사이에 부르는 협력자
|
||||
|
||||
부수 효과를 손으로 적은 목록으로 찾으면 이름을 하나 빠뜨렸을 때 조용히 사라진다. 그래서 서비스가 선언한 협력자 필드에서 패턴을 유도했다.
|
||||
|
||||
`redrive:90`\~`:147` 은 `:94` 에서 조회하고 `:117` 의 트랜잭션 안에서 `:121` 의 `recipients.transition` 으로 수신자 배달 상태를 옮기고 `:145` 에서 저장한다.
|
||||
|
||||
`reconcile:150`\~`:195` 는 `:154` 에서 조회하고 `:172` 에서 명령이 준 시도 식별자마다 `reconciliation.reconcile` 을 부른 뒤 `:194` 에서 저장한다. 트랜잭션 안이 아니다.
|
||||
|
||||
`suppress:198`\~`:275` 는 `:203` 에서 조회하고 `:218` 의 트랜잭션 안에서 `:229` 의 `suppressions.remove` 또는 `:236` 의 `suppressions.upsert` 를 부른 뒤 `:272` 에서 저장한다. 둘은 배타적 분기다.
|
||||
|
||||
`setProviderState:278`\~`:329` 는 `:282` 에서 조회하고 `:298` 에서 `runtimes.setState` 로 제공자 런타임 상태를 바꾼 뒤 `:328` 에서 저장한다. 여기도 트랜잭션 밖이다.
|
||||
|
||||
자바독이 과거형으로 적은 조회 → 부수 효과 → 저장 순서가 지금 네 경로에 그대로 있다.
|
||||
|
||||
## 청구 계약 시험은 postgresqlIntegrationTest 소스 세트에 있다
|
||||
|
||||
`AdminOperationClaimContractTest` 는 `adapter/outbound/persistence-jpa` 의 `postgresqlIntegrationTest` 에 있고 `:34` 가 `@Tag("jpa-contract")` 를 단다.
|
||||
|
||||
`persistence-jpa/build.gradle:251`\~`:252` 가 그 태그를 `jpaPlatformContractTest` 레인에 묶는다. `jpa-pr.yml:86` 과 `notification-platform.yml:85` 가 `pull_request` 에서 그 태스크를 부르고, 뒤엣것의 경로 필터 `:21` 이 `src/application-core/src/**/notification/**` 을 포함한다.
|
||||
|
||||
즉 이 시험은 기본 `check` 에는 없지만 서비스를 고치는 PR 마다 돈다. 저장소 계층의 청구는 그렇게 고정돼 있다.
|
||||
|
||||
`NotificationAdminApplicationService` 를 이름에 가진 시험 파일은 0 개다.
|
||||
|
||||
## 원문과 갈리는 자리
|
||||
|
||||
원문은 서비스가 네 경로에서 조회 후 부수 효과 후 저장을 쓴다고 적었고 그것은 그대로다.
|
||||
|
||||
원문이 적지 않은 것이 셋이다. `claim` 이 네 답을 주는데 `findByOperationId` 는 두 갈래뿐이라는 것, 두 번째 저장이 유니크 제약으로 실패한다는 것과 그것이 두 번째 부수 효과 뒤라는 것, 그리고 `commandFingerprint` 를 만드는 코드가 없어서 오늘은 `claim` 을 부를 수도 없다는 것이다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
스레드 둘로 같은 식별자를 밀어 넣어 파괴적 동작이 두 번 도는 장면을 만들지 않았다. 조회와 부수 효과와 저장이 세 연산이라는 것까지다.
|
||||
|
||||
네 협력자의 동작이 두 번 실행됐을 때 각각 어떤 상태가 되는지 구현까지 읽지 않았다. 포트 자바독이 파괴적 연산의 이중 실행을 막으려는 것이라고 적은 것을 근거로 삼았다.
|
||||
|
||||
이 네 메서드를 호출하는 인바운드 어댑터가 있는지 세지 않았다.
|
||||
|
||||
<!-- body:end -->
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: analysis-finding-a04-f002
|
||||
title: 메시징만 성공 로그를 try 밖으로 옮기고 알림은 같은 try 에 남겨 두었다
|
||||
topic: multitenancy-isolation
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:analysis-finding-a04-f002
|
||||
evidenceCapturedOn: 2026-09-04
|
||||
body: case-analysis-finding-a04-f002.body.md
|
||||
assets:
|
||||
- key: analysis-finding-a04-f002
|
||||
file: ../../../final/evidence/rendered/analysis-finding-a04-f002.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/analysis-finding-a04-f002.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/04-adapter-outbound-support.md §5 이다.
|
||||
---
|
||||
|
||||
# 메시징만 성공 로그를 try 밖으로 옮기고 알림은 같은 try 에 남겨 두었다
|
||||
|
||||
`821fe00c` 초기 커밋에서 `OutboundMessagePublisher` 와 `FailOpenNotificationProvider` 는 같은 모양이었다. `2f5d2fc2` 가 메시징 쪽만 `boolean sent` 와 `observeQuietly` 로 갈랐고 알림 쪽은 그대로다. 그래서 알림 쪽에서는 위임 전송이 성공한 뒤 성공 로거가 던지면 그 예외를 전송 실패용 `catch` 가 받는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **시그니처가 payload를 받지 않는데 예외 메시지로 PII가 로그에 남았다**
|
||||
같은 `FailOpenDependencyLogger` 를 다룬다. 그 사례는 `logFailure` 가 예외 메시지를 그대로 로그에 넣는 것을 프로브로 확인했고, 여기서는 그 `logFailure` 를 두 소비자가 각각 어느 `try` 안에서 부르는지를 확인했다.
|
||||
- **중복 장치를 찾으면 어느 쪽이 조립됐는지 먼저 확인한다**
|
||||
그 규칙은 구현이 둘일 때 어느 것이 조립되는지를 묻는다. 여기서는 두 소비자가 모두 조립되고, 갈리는 것은 같은 로거를 부르는 자리다.
|
||||
- **모르는 것은 성공도 실패도 아닌 세 번째 결과여야 한다**
|
||||
그 규칙은 확정되지 않은 결과를 성공이나 실패로 접지 말라고 한다. 여기서는 확정된 성공이 실패로 접힌다.
|
||||
|
||||
## 문제
|
||||
|
||||
메시징 어댑터와 알림 어댑터가 같은 FailOpenDependencyLogger 를 쓴다. 둘 다 실패를 삼키는 fail-open 구조다.
|
||||
|
||||
두 소비자가 그 로거를 어느 자리에서 부르는지, 그리고 언제부터 그렇게 갈렸는지 확인했다.
|
||||
|
||||
## 결론
|
||||
|
||||
821fe00c 이 출하한 메시징 publish 에는 지금의 알림 쪽과 구별되는 점이 없다. 브로커 호출과 성공 로그가 한 블록에 있고 예외 처리기가 실패 로그를 남긴다.
|
||||
|
||||
2f5d2fc2 가 메시징 쪽만 바꿨다. :32 가 boolean sent 를 두고 :33~:35 의 try 가 broker.send 만 감싼다. 관측은 :39 와 :43 에서 observeQuietly 를 거치고 :54~:60 이 그 안에서 RuntimeException 을 삼킨다. FailOpenNotificationProvider 는 초기 커밋 이후 수정된 적이 없다.
|
||||
|
||||
알림 쪽에는 그 플래그가 없다. :38 의 delegate.send 와 :39 의 logSuccess 가 같은 try 안에 있고 :40 의 catch 가 :42 에서 logFailure 를 부른다.
|
||||
|
||||
저장소 클래스를 그대로 로드한 프로브에 slf4j Logger 프록시를 물렸다. debug 에서만 던지게 하면 알림 쪽은 위임 전송이 성공했는데도 warn 이 한 번 불린다. 메시징 쪽은 같은 조건에서 warn 이 0 회다.
|
||||
|
||||
debug 와 warn 양쪽에서 던지게 하면 알림 쪽만 IllegalStateException 이 send 밖으로 나간다. :8~:10 이 선언한 fail-open 계약이 그 경우에 성립하지 않는다.
|
||||
|
||||
RoutingNotifier:120~:122 의 팬아웃 루프에는 예외 처리기가 없다. 데코레이터가 아무것도 전파하지 않는다는 전제 위에 그렇게 쓰였다고 :117~:119 가 밝힌다.
|
||||
|
||||
이름이 회귀를 가리키는 그 시험은 회귀를 막지 못한다. OutboundMessagePublisherTest:141 과 :142 가 단언하는 것은 예외가 나가지 않는다는 것과 브로커가 메시지를 받았다는 것뿐이다. 프로브에서 초기 커밋 구조를 돌려 보니 둘 다 통과했다.
|
||||
|
||||
알림 쪽에서 이 클래스를 조립하는 시험은 열둘이다. 그 시험들이 던지게 만든 것은 위임 제공자다. 메시징 회귀 시험이 로거를 던지게 할 때 쓴 Proxy.newProxyInstance 는 알림 모듈에 한 번도 나오지 않는다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
Gradle : 9.0.0
|
||||
확인 방식 : 같은 로거가 나오는 자리 전수, 두 파일의 커밋 이력과 초기 커밋 시점의 메시징 본문 인용, 지금의 두 본문과 팬아웃 루프 인용, NotificationPort 를 참조하는 main 파일 계수, 메시징 회귀 시험 111~145 전문 인용, 알림 쪽에서 이 클래스를 조립하는 시험과 그 시험들이 던지게 만든 대상 나열 및 두 모듈의 Proxy.newProxyInstance 계수, 저장소 build 산출물에서 로드했음을 출력에 찍은 프로브로 세 구조에 같은 로거를 물려 일곱 행 관측
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 그 로거가 나오는 자리를 저장소에서 모두 찾는다.
|
||||
2. 두 소비자 파일의 커밋 이력을 나란히 뽑고, 초기 커밋 시점의 메시징 본문을 꺼낸다.
|
||||
3. 지금의 두 본문과 알림 데코레이터를 부르는 팬아웃 루프를 인용한다.
|
||||
4. 메시징 회귀 시험을 단언부까지 포함해 전문으로 싣는다.
|
||||
5. 알림 쪽에서 이 클래스를 조립하는 시험을 모두 찾고, 그 시험들이 무엇을 던지게 만들었는지 나열한다.
|
||||
6. 두 모듈에서 Proxy.newProxyInstance 를 센다.
|
||||
7. slf4j Logger 프록시를 만들어 debug 에서만, 그리고 양쪽에서 던지게 한다. 로드한 클래스의 출처를 출력에 찍고, 지금의 두 구조와 초기 커밋 구조에 같은 로거를 물린다.
|
||||
8. 초기 커밋 구조에서 그 회귀 시험의 두 단언이 성립하는지 프로브 안에서 판정한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`FailOpenDependencyLogger` 는 선택적 어댑터들이 공유하는 로그 계약이다. 실패는 항상 WARN 이고, 클래스 자바독은 본문·수신자·payload 를 인자로 받지 않으므로 PII 가 로그에 닿을 수 없다고 적는다. 예외 메시지를 통해서는 닿는다는 것을 다른 기록이 프로브로 확인했다.
|
||||
|
||||
## 같은 로거를 두 어댑터가 쓴다
|
||||
|
||||
:::evidence key="analysis-finding-a04-f002" alt="저장소 루트에서 돌린 정적 검색과 프로브 실행의 출력 209줄. 먼저 FailOpenDependencyLogger 가 나오는 자리 여덟이 실리는데 메시징 어댑터 main 과 그 시험, 알림 어댑터 main 과 그 시험 둘, 지원 모듈 시험, 그리고 두 설정 클래스다. 이어서 두 파일의 git 이력이 나온다. FailOpenNotificationProvider 는 821fe00c 초기 커밋 한 줄뿐이고, OutboundMessagePublisher 는 2f5d2fc2 와 821fe00c 두 줄이다. 다음으로 821fe00c 시점의 메시징 publish 26~37번 줄이 실리는데 try 안에 broker.send 와 logSuccess 가 함께 있고 catch 가 logFailure 를 부른다. 그 아래에 지금의 메시징 publish 26~60번 줄이 실려 28~31번 줄 주석과 32번 줄 sent 플래그와 33~35번 줄 try 와 39번·43번 줄의 관측 호출과 54~60번 줄 observeQuietly 가 보인다. 지금의 알림 send 는 7~11번 줄 클래스 자바독과 35~45번 줄 본문이 실리는데 37번 줄 try 안에 38번 delegate.send 와 39번 logSuccess 가 있고 40번 catch 가 42번에서 logFailure 를 부른다. 그 send 를 부르는 RoutingNotifier 115~123번 줄이 나오는데 117~119번 주석이 데코레이터가 전파하지 않으므로 try/catch 가 필요 없다고 적고 120~122번이 try/catch 없는 팬아웃 루프다. NotificationPort 를 참조하는 main 파일은 NotificationConfig 와 RoutingNotifier 둘이다. 메시징 쪽 회귀 시험 111~145번 줄이 전문으로 실리는데 119~135번이 던지는 로거 프록시를 만들고 141번과 142~144번이 두 단언이다. 알림 쪽에서 그 클래스를 조립하는 시험은 NotificationAdapterTest 네 자리와 RoutingNotifierTest 여덟 자리인데, 그 두 파일이 던지게 만든 것은 각각 79번과 130번의 위임 제공자뿐이고 Proxy.newProxyInstance 는 알림 모듈에 0 개 메시징 모듈에 1 개다. 클래스 이름을 파일명에 가진 시험은 0 개다. 마지막으로 프로브가 실린다. 로드한 세 클래스가 모두 저장소 build 산출물에서 왔다는 것이 먼저 나오고, 알림 쪽은 로거가 던지지 않으면 전송 1 debug 1 warn 0, 성공 로거만 던지면 warn 1, 둘 다 던지면 IllegalStateException 이 나간다. 메시징 쪽은 세 경우 모두 warn 0 이고 나간 예외가 없다. 821fe00c 구조에 성공 로거만 던지게 하면 warn 1 이 되는데, 그 회귀 시험의 두 단언은 그 구조에서도 모두 통과한다." caption="같은 로거를 쓰는 여덟 자리 · 두 파일의 이력과 초기 커밋의 메시징 구조 · 지금의 두 구조 · 팬아웃 루프와 NotificationPort 참조 · 회귀 시험 전문과 두 단언 · 알림 쪽 조립 시험 열둘과 던지는 대상 · 저장소 클래스를 로드한 프로브의 일곱 행 — 209줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
이 타입을 필드로 가진 main 클래스는 `OutboundMessagePublisher:19` 와 `FailOpenNotificationProvider:17` 둘이다.
|
||||
|
||||
## 두 구조는 같은 커밋에서 같은 모양으로 출발했다
|
||||
|
||||
`FailOpenNotificationProvider` 의 커밋 이력은 `821fe00c` 한 줄이다. `OutboundMessagePublisher` 는 `821fe00c` 와 `2f5d2fc2` 두 줄이다.
|
||||
|
||||
`821fe00c` 시점의 메시징 `publish` 를 꺼내 보면 `try` 안에 `broker.send` 와 `logSuccess` 가 함께 있고 `catch (Exception ex)` 가 `logFailure` 를 부른다. 지금의 알림 `send` 와 같은 모양이다.
|
||||
|
||||
설계가 갈린 것이 아니라 한쪽만 옮겨 갔다.
|
||||
|
||||
## OutboundMessagePublisher 는 sent 플래그를 세운 뒤 try 밖에서 관측을 부른다
|
||||
|
||||
`:32` 가 `boolean sent = false` 를 두고, `:33`\~`:35` 의 `try` 가 감싸는 것은 `broker.send(message)` 하나다. 성공하면 `:35` 가 플래그를 세운다.
|
||||
|
||||
실패 관측은 `catch` 안 `:39` 에서, 성공 관측은 `try` 밖 `:42`\~`:45` 에서 일어난다. 둘 다 `observeQuietly` 를 거치고 `:55`\~`:59` 가 그 안에서 던진 `RuntimeException` 을 삼킨다. 그 메서드의 자바독에는 디스크가 찬 appender 가 호출자가 브로커에 대해 믿는 것을 바꾸면 안 되므로 진단은 권위를 갖지 않는다고 적혀 있다.
|
||||
|
||||
`:28`\~`:31` 주석은 이 구조가 무엇을 고친 것인지 적는다. 전송과 성공 로그가 한 `try` 를 공유하던 시절, 브로커가 이미 메시지를 받은 뒤 로거가 던지면 같은 `catch` 가 그것을 발행 실패로 보고했다.
|
||||
|
||||
## 알림 쪽은 전송과 성공 로그가 한 try 안에 있다
|
||||
|
||||
`:37` 의 `try` 안에 `:38` 의 `delegate.send(notification)` 와 `:39` 의 `logSuccess` 가 함께 있다. `:40` 의 `catch (Exception ex)` 가 `:42` 에서 `logFailure` 를 부른다.
|
||||
|
||||
메시징 쪽의 `sent` 같은 플래그가 없어서 `delegate.send` 의 결과와 `logSuccess` 의 결과를 구분하지 않는다.
|
||||
|
||||
클래스 자바독 `:8`\~`:10` 에는 제공자 실패를 기록하고 삼켜서 알림이 코어 유스케이스를 실패시키지 않게 한다고 적혀 있다.
|
||||
|
||||
## 던지는 로거를 세 구조에 넣었을 때의 호출 수
|
||||
|
||||
프로브는 저장소의 build 산출물에서 세 클래스를 로드한다. 출력 첫 세 줄이 그 경로를 찍는다.
|
||||
|
||||
slf4j `Logger` 를 프록시로 만들어 `debug` 나 `warn` 이 불릴 때 던지게 했다. 저장소의 메시징 회귀 시험이 쓰는 방식과 같다. 위임 전송과 브로커 전송은 항상 성공하도록 두었다.
|
||||
|
||||
알림 쪽에서 로거가 던지지 않으면 `delegate.send` 1 회 · `debug` 1 회 · `warn` 0 회다. 성공 로거만 던지면 `warn` 이 1 회가 된다. `debug` 호출이 던져서 SUCCESS 줄은 남지 않고, 위임 전송이 성공한 그 한 번에 대해 실패 쪽 기록이 남는다.
|
||||
|
||||
성공 로거와 실패 로거가 모두 던지면 `IllegalStateException` 이 `send` 밖으로 나간다. 위임 전송은 이미 성공한 뒤다.
|
||||
|
||||
메시징 쪽은 같은 세 경우 모두 `warn` 0 회이고 나가는 예외가 없다.
|
||||
|
||||
## 그 예외가 나가면 남은 제공자도 호출되지 않는다
|
||||
|
||||
`RoutingNotifier:120`\~`:122` 의 팬아웃 루프는 라우트가 지목한 제공자들을 차례로 부른다. `try`/`catch` 가 없다.
|
||||
|
||||
`:117`\~`:119` 주석이 그 이유를 적는다 — `FailOpenNotificationProvider.send` 가 `throws` 를 선언하지 않으므로 `try`/`catch` 가 필요 없다는 것이다.
|
||||
|
||||
`NotificationPort` 를 참조하는 main 파일은 `NotificationConfig` 와 `RoutingNotifier` 둘이다.
|
||||
|
||||
## 메시징 쪽 회귀 시험은 이 회귀를 고정하지 못한다
|
||||
|
||||
`OutboundMessagePublisherTest:114` 의 `aLoggerFailureAfterAConfirmedSendIsNotAPublishFailure` 는 `:119`\~`:135` 에서 `debug` 에만 던지는 로거 프록시를 만든다. 단언은 둘이다 — `:141` 이 `publish` 가 예외를 던지지 않는 것, `:142`\~`:144` 가 브로커가 그 메시지를 받은 것이다.
|
||||
|
||||
프로브에서 `821fe00c` 구조에 같은 로거를 물려 보면 `warn` 이 1 회 불리지만 두 단언은 모두 통과한다. 그 시험은 WARN 이 남았는지를 보지 않는다.
|
||||
|
||||
## 알림 쪽 시험은 위임 제공자만 던지게 한다
|
||||
|
||||
`FailOpenNotificationProvider` 를 조립하는 시험은 `NotificationAdapterTest` 네 자리와 `RoutingNotifierTest` 여덟 자리다.
|
||||
|
||||
그 두 파일이 던지게 만든 것은 각각 `:79` 와 `:130` 의 위임 제공자다. 로거를 던지게 만드는 `Proxy.newProxyInstance` 는 알림 모듈에 0 개이고 메시징 모듈에 1 개다.
|
||||
|
||||
그 클래스 이름을 파일명에 가진 시험 파일은 0 개다.
|
||||
|
||||
## 원문과 갈리는 자리
|
||||
|
||||
원문은 메시징 쪽 회귀 시험이 이 구조를 고정한다고 적었다. 프로브는 그것을 반박한다. 그 시험의 두 단언은 초기 커밋 구조에서도 통과하므로, 되돌려도 초록이다.
|
||||
|
||||
원문은 두 소비자가 같은 로거를 다르게 쓴다고 적었다. 맞지만 이력을 보면 설계가 갈린 것이 아니다. 둘은 같은 커밋에서 같은 모양으로 출발했고 한쪽만 옮겨 갔다.
|
||||
|
||||
알림 쪽에서 성공한 전송에 실패 기록이 남는다는 것과 소스 주석이 과거 버그를 적는다는 것은 원문대로다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
실제 배포에서 어떤 조건이 로거를 던지게 하는지 조사하지 않았다. 프로브는 던지는 상황을 만들어 세 구조를 나란히 놓은 것이다.
|
||||
|
||||
예외가 나갔을 때 같은 라우트의 남은 제공자가 실제로 건너뛰어지는지 실행으로 보지 않았다. 팬아웃 루프에 `try`/`catch` 가 없다는 줄과 그 이유를 적은 주석을 읽었다.
|
||||
|
||||
WARN 줄을 세는 알림 규칙이 저장소 밖 어딘가에 있는지는 찾지 않았다.
|
||||
|
||||
예외가 나갔을 때 같은 라우트의 남은 제공자가 실제로 건너뛰어지는지 실행으로 보지 않았다. 팬아웃 루프에 `try`/`catch` 가 없다는 줄과 그 이유를 적은 주석을 읽었다.
|
||||
|
||||
<!-- body:end -->
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: analysis-finding-a04-f004
|
||||
title: 실패 로거가 던지면 그 예외가 제공자 예외를 밀어내고 호출자로 나간다
|
||||
topic: multitenancy-isolation
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:analysis-finding-a04-f004
|
||||
evidenceCapturedOn: 2026-09-04
|
||||
body: case-analysis-finding-a04-f004.body.md
|
||||
assets:
|
||||
- key: analysis-finding-a04-f004
|
||||
file: ../../../final/evidence/rendered/analysis-finding-a04-f004.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/analysis-finding-a04-f004.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/04-adapter-outbound-support.md §5 이다.
|
||||
---
|
||||
|
||||
# 실패 로거가 던지면 그 예외가 제공자 예외를 밀어내고 호출자로 나간다
|
||||
|
||||
`FailOpenNotificationProvider:40` 의 `catch` 는 `:42` 에서 `logFailure` 를 부르는데 그 호출을 감싸는 `try` 가 없다. 위임 제공자가 `provider is down` 으로 던진 뒤 그 로거까지 던지면, 프로브에서 호출자가 받은 것은 `IllegalStateException : the appender is out of disk` 였고 그 예외의 `cause` 가 없음, `suppressed` 가 0 이었다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **메시징만 성공 로그를 try 밖으로 옮기고 알림은 같은 try 에 남겨 두었다**
|
||||
같은 `FailOpenNotificationProvider` 의 성공 경로를 다룬 기록이다. 그 기록은 `delegate.send` 가 성공한 뒤 `logSuccess` 가 던지는 경우이고, 여기는 `delegate.send` 가 실패한 뒤 `catch` 안의 `logFailure` 가 던지는 경우다.
|
||||
- **모르는 것은 성공도 실패도 아닌 세 번째 결과여야 한다**
|
||||
그 규칙은 확정되지 않은 결과를 성공이나 실패로 접지 말라고 한다. 여기서는 확정된 제공자 실패가 로거 실패에 덮여, 호출자가 받는 예외가 제공자 결과를 가리키지 않는다.
|
||||
|
||||
## 문제
|
||||
|
||||
이 데코레이터의 클래스 자바독은 알림이 어떤 경우에도 업무 흐름을 깨뜨리지 않게 하는 것이 자기 일이라고 적는다.
|
||||
|
||||
그 계약이 관측 자체가 실패하는 경우에도 성립하는지 확인했다.
|
||||
|
||||
## 결론
|
||||
|
||||
send 는 :37 의 try 안에서 위임 전송과 성공 로그를 함께 부른다. 실패를 받는 :40 의 처리기가 :42 에서 로그를 남기는데, 그 자리에서 예외가 나면 잡아 줄 것이 남아 있지 않다.
|
||||
|
||||
메시징 쪽은 같은 자리를 다르게 쓴다. OutboundMessagePublisher:39 의 실패 관측도 observeQuietly 를 거치고 :57 이 RuntimeException 을 삼킨다. :28~:31 주석은 그 구조가 무엇을 고친 것인지 적어 둔다.
|
||||
|
||||
프로브에서 위임 제공자가 항상 던지게 하고 로거 프록시를 물렸다. 로거가 멀쩡한 경우는 계약대로다.
|
||||
|
||||
그 로거까지 던지게 하면 예외가 send 밖으로 나간다. 나간 것에는 원인도 억제된 예외도 붙어 있지 않아서, 제공자가 왜 실패했는지가 호출자 쪽에서 복구되지 않는다.
|
||||
|
||||
이 데코레이터를 부르는 RoutingNotifier 의 루프는 개별 실패를 잡지 않고 지나간다. 그렇게 써도 되는 근거를 :117~:119 가 밝혀 두었는데, 프로브가 그 근거를 무너뜨리는 경우를 하나 보였다.
|
||||
|
||||
이 경우를 붙드는 시험도 없다. 던지는 로거를 만드는 기법이 메시징 모듈에는 한 번 쓰였고 알림 모듈에는 한 번도 쓰이지 않았다. 조립 시험 둘이 던지도록 손본 것은 로거가 아니라 그 아래 제공자다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
Gradle : 9.0.0
|
||||
확인 방식 : 데코레이터의 클래스 자바독과 send 본문 인용, 메시징 쪽 publish 전체와 관측 헬퍼 인용, 팬아웃 루프와 그 서명·주석 인용, NotificationPort 를 참조하는 main 파일 전수, 저장소 클래스를 그대로 써서 위임 제공자가 던지는 경로에 로거 예외를 겹친 두 경우 관측하고 나간 예외의 cause 와 suppressed 까지 출력, 두 모듈의 로거 프록시 계수와 조립 시험이 던지게 만드는 대상 나열
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 데코레이터의 클래스 자바독과 send 본문을 인용한다.
|
||||
2. 메시징 쪽 publish 를 주석과 플래그까지 포함해 싣고 관측 헬퍼를 함께 인용한다.
|
||||
3. 이 데코레이터를 부르는 팬아웃 루프를 메서드 서명부터 인용한다.
|
||||
4. NotificationPort 를 참조하는 main 파일을 전부 찾는다.
|
||||
5. 위임 제공자가 항상 던지도록 만들고 slf4j Logger 프록시를 붙인다.
|
||||
6. warn 이 정상인 경우와 던지는 경우로 나눠 호출 수와 나간 예외를 적고, 그 예외의 cause 와 suppressed 도 함께 출력한다.
|
||||
7. 두 모듈에서 로거 프록시를 만드는 자리를 세고, 조립 시험이 무엇을 던지게 만드는지 나열한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`FailOpenNotificationProvider` 는 제공자 실패를 삼켜서 알림이 코어 유스케이스를 실패시키지 않게 하는 데코레이터다. 그 계약이 클래스 자바독 `:8`\~`:10` 에 적혀 있다.
|
||||
|
||||
## catch 안의 logFailure 호출에는 try 가 없다
|
||||
|
||||
:::evidence key="analysis-finding-a04-f004" alt="저장소 루트에서 돌린 정적 검색과 프로브의 출력 89줄. 먼저 FailOpenNotificationProvider 7~11번 줄의 클래스 자바독이 실려 제공자 실패를 기록하고 삼켜서 알림이 코어 유스케이스를 실패시키지 않는다고 적는다. 35~45번 줄은 send 본문인데 37번 try 안에 38번 delegate.send 와 39번 logSuccess 가 있고 40번 catch 가 42번에서 logFailure 를 부른다. 이어서 메시징 쪽 OutboundMessagePublisher 26~46번 줄이 실리는데 28~31번 주석이 전송과 성공 로그가 한 try 를 공유하던 시절의 버그를 적고 32번이 sent 플래그를 두며 33~35번 try 가 브로커 전송만 감싸고 39번과 43번이 관측을 부른다. 48~60번 줄의 observeQuietly 가 RuntimeException 을 삼킨다. 그 아래에 RoutingNotifier 112~123번 줄이 실려 notify 서명과 117~119번 주석과 예외 처리기 없는 팬아웃 루프가 보인다. NotificationPort 를 참조하는 main 파일은 NotificationConfig 와 RoutingNotifier 둘이다. 마지막으로 프로브가 실린다. 위임 제공자가 항상 던지도록 만들고 로거 프록시를 물렸는데, 실패 로거가 정상일 때는 debug 0 warn 1 이고 호출자에게 나간 예외가 없다. 실패 로거도 던질 때는 debug 0 warn 1 이고 IllegalStateException 이 the appender is out of disk 라는 메시지로 나가며, 그 예외의 cause 가 없음이고 suppressed 가 0 이다. 그 경로를 고정하는 시험으로는 알림 모듈의 Proxy.newProxyInstance 가 0 개 메시징 모듈이 1 개이고, 이 데코레이터를 조립하는 두 시험이 던지게 만드는 것은 각각 79번과 130번의 위임 제공자뿐이다." caption="데코레이터가 자기에게 매긴 계약과 send 본문 · 메시징 쪽 publish 전체와 observeQuietly · notify 서명과 예외 처리기 없는 팬아웃 루프 · 위임 실패에 로거 예외를 겹친 두 경우와 그 예외의 원인 사슬 · 그 경로를 고정하는 시험의 부재 — 89줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
`:37` 의 `try` 안에 `:38` 의 `delegate.send` 와 `:39` 의 `logSuccess` 가 있다. `:40` 의 `catch (Exception ex)` 가 `:42` 에서 `logFailure` 를 부른다.
|
||||
|
||||
`catch` 블록 안에서 던진 예외는 그 `catch` 가 다시 받지 않는다. 메서드 밖으로 나간다.
|
||||
|
||||
## OutboundMessagePublisher 는 실패 관측도 observeQuietly 로 감싼다
|
||||
|
||||
`:28`\~`:31` 주석이 그 구조의 내력을 적는다. 전송과 성공 로그가 한 `try` 를 공유하던 시절, 브로커가 이미 메시지를 받은 뒤 로거가 던지면 같은 `catch` 가 그것을 발행 실패로 보고했다.
|
||||
|
||||
지금은 `:32` 가 `boolean sent` 를 두고 `:33`\~`:35` 의 `try` 가 `broker.send` 만 감싼다. 실패 관측은 `catch` 안 `:39` 에서, 성공 관측은 `try` 밖 `:43` 에서 일어나고 둘 다 `observeQuietly` 를 거친다.
|
||||
|
||||
`:54`\~`:60` 의 그 헬퍼가 `:57` 에서 `RuntimeException` 을 삼킨다. 자바독은 진단이 권위를 갖지 않는다고 적는다.
|
||||
|
||||
## 위임 실패에 로거 예외를 겹쳤을 때
|
||||
|
||||
프로브는 위임 제공자가 항상 `IllegalStateException("provider is down")` 을 던지도록 만든다. 로거는 slf4j `Logger` 프록시다.
|
||||
|
||||
실패 로거가 정상이면 `debug` 0 회 · `warn` 1 회이고 호출자에게 나가는 예외가 없다. 계약대로 삼킨다.
|
||||
|
||||
실패 로거도 던지게 하면 호출 수는 같은데 `IllegalStateException : the appender is out of disk` 가 `send` 밖으로 나간다.
|
||||
|
||||
그 예외의 `cause` 는 없음이고 `suppressed` 는 0 이다. 제공자가 던진 `provider is down` 은 `ex` 변수에 담겨 `logFailure` 의 넷째 인자로만 넘어갔고, 그 호출이 던졌으므로 호출자가 받는 예외에 아무 흔적도 남기지 않았다.
|
||||
|
||||
## RoutingNotifier 의 팬아웃 루프에는 예외 처리기가 없다
|
||||
|
||||
`:113` 의 `notify` 가 `:114` 에서 라우트를 풀고 `:120`\~`:122` 가 제공자들을 차례로 부른다.
|
||||
|
||||
`:117`\~`:119` 주석이 그 이유를 적는다. 개별 제공자 실패는 데코레이터 안에서 관측되고 전파되지 않으므로 하나가 실패해도 남은 팬아웃이 막히지 않는다는 것이다.
|
||||
|
||||
프로브에서는 그 데코레이터가 예외를 전파했다. 주석이 든 전제가 그 경우에는 성립하지 않는다.
|
||||
|
||||
## 오늘 그 예외를 받을 호출자가 없다
|
||||
|
||||
`NotificationPort` 를 참조하는 main 파일은 `NotificationConfig` 와 `RoutingNotifier` 둘이다. 앞엣것은 빈을 조립하고 뒤엣것은 그 포트를 구현한다.
|
||||
|
||||
그 포트를 부르는 프로덕션 코드가 없다. 지금 이 경로를 지나는 요청이 없다는 뜻이고, 이 템플릿을 가져다 호출자를 붙이는 순간 살아난다.
|
||||
|
||||
## 로거가 던지는 경우를 만드는 시험이 없다
|
||||
|
||||
로거를 던지게 만드는 `Proxy.newProxyInstance` 는 알림 모듈에 0 개이고 메시징 모듈에 1 개다.
|
||||
|
||||
`NotificationAdapterTest` 와 `RoutingNotifierTest` 가 던지게 만드는 것은 각각 `:79` 와 `:130` 의 위임 제공자다. 로거는 정상이다.
|
||||
|
||||
## 원문과 갈리는 자리
|
||||
|
||||
원문은 실패 경로에서 경고 로거가 실패하면 그 예외가 호출자까지 전파된다고 적었고 프로브가 그대로 재현한다.
|
||||
|
||||
원문에는 무엇이 전파되는지가 빠져 있다. 나간 예외는 로거의 것이고, `cause` 와 `suppressed` 가 비어 있어 제공자 예외와 연결되지 않는다.
|
||||
|
||||
원문이 확인하지 않은 것도 있다. 이 경로를 지나는 프로덕션 호출자가 지금은 없다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
실제 배포에서 WARN 호출이 던질 조건을 조사하지 않았다.
|
||||
|
||||
진짜 로그 설비가 던지기 직전에 무엇을 남기는지는 보지 않았다. 프록시는 아무 기록 없이 곧바로 던진다.
|
||||
|
||||
수정 위치를 공유 로거 쪽으로 잡을지 이 데코레이터 쪽으로 잡을지 결론 내지 않았다.
|
||||
|
||||
## 등급에 대해
|
||||
|
||||
원본은 P1 이다. 다만 이 리비전에서 그 포트를 참조하는 main 파일은 빈을 조립하는 쪽과 구현하는 쪽 둘뿐이고 실제로 알림을 보내는 코드가 없다. 오늘 이 예외를 받을 자리가 없으므로 등급을 그대로 두되 그 조건을 적어 둔다.
|
||||
|
||||
<!-- body:end -->
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: analysis-finding-a04-f005
|
||||
title: README 의 세 문장 중 둘은 쓰일 때부터 틀렸고 하나만 나중에 어긋났다
|
||||
topic: multitenancy-isolation
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:analysis-finding-a04-f005
|
||||
evidenceCapturedOn: 2026-09-04
|
||||
body: case-analysis-finding-a04-f005.body.md
|
||||
assets:
|
||||
- key: analysis-finding-a04-f005
|
||||
file: ../../../final/evidence/rendered/analysis-finding-a04-f005.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/analysis-finding-a04-f005.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/04-adapter-outbound-support.md §6 이다.
|
||||
---
|
||||
|
||||
# README 의 세 문장 중 둘은 쓰일 때부터 틀렸고 하나만 나중에 어긋났다
|
||||
|
||||
`support/README.md` 와 `support/CLAUDE.md` 는 `821fe00c` 에서 나란히 추가됐다. 그래서 README 가 적은 "아직 별도 CLAUDE.md 를 두지 않았다" 는 쓰인 날부터 거짓이었고, "의존 정책의 SSOT 는 `src/build.gradle` 의 `allowedProjectDependencies`" 도 그때 이미 파생 코드를 가리켰다. 실제로 시간이 지나 어긋난 것은 셋 중 하나뿐이다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **문서의 수치는 세지 말고 파생하거나 게이트로 붙든다**
|
||||
그 규칙은 문서의 서술을 소스에서 파생하거나 검사로 붙들라고 한다. 이 README 의 문장들은 손으로 적혀 있고 소스와 대조하는 검사가 없다.
|
||||
- **과대 진술 문서를 과소보다 먼저 고친다**
|
||||
그 규칙은 문서가 실제보다 많이 약속하는지 적게 약속하는지부터 가르라고 한다. 이 셋의 방향이 서로 다르다.
|
||||
- **다섯 문서가 "exactly 19 leaf"라고 적고 레지스트리는 62다**
|
||||
그 기록에서도 문서가 레지스트리를 따라가지 못했다. 다만 그쪽은 시간이 지나 벌어진 차이이고 여기는 절반이 작성 시점의 오기다.
|
||||
|
||||
## 문제
|
||||
|
||||
이 README 는 이 모듈이 왜 갈라져 나왔고 어떤 의존이 허용되는지를 적어 둔다. 다른 어댑터가 이 모듈에 기댈 때 참고하는 자리다.
|
||||
|
||||
세 문장이 지금 소스와 맞지 않는다. 각각이 언제부터 틀렸는지 커밋으로 되짚었다.
|
||||
|
||||
## 결론
|
||||
|
||||
두 마크다운 파일의 추가 커밋이 같다. 이후 규범 문서만 한 번 더 수정됐고 README 에는 그 뒤 커밋이 없다. 규범 문서가 없다고 적은 문장은 나중에 낡은 것이 아니라 처음부터 사실과 달랐다.
|
||||
|
||||
의존 정책 쪽도 마찬가지다. 그 커밋의 빌드 스크립트는 이미 저장소 바깥 파일에서 맵을 만들어 내고 있었고, 지금의 레지스트리 파일은 나흘 뒤에야 들어왔다. allowedProjectDependencies 가 SSOT 였던 적이 없고, 옮겨 간 것은 레지스트리 파일의 위치다.
|
||||
|
||||
지금도 그 이름은 빌드 스크립트에 세 줄 남아 있지만 셋 다 레지스트리에서 파생하거나 그 결과를 읽는 자리다. 값을 담는 것은 modules.json:44~:48 이고, 같은 디렉터리의 CLAUDE.md:9 가 그것을 Registry SSOT 로 못 박는다.
|
||||
|
||||
셋 중 진짜 드리프트는 하나다. OutboundHttpDependencyLogger 는 821fe00c 에 main·test 두 파일로 추가됐다가 5f10b791 에서 함께 삭제됐다. README 가 쓰였을 때는 맞는 문장이었다.
|
||||
|
||||
그 모듈에는 지금 로거 클래스도, slf4j 를 끌어오는 파일도, ERROR 를 남기는 호출도 없다. 대조 기준 하나가 사라진 것이 아니라 대조할 로깅 자체가 남아 있지 않다.
|
||||
|
||||
같은 자리에 넷째가 있다. 이 문단은 PII 가 로그에 닿을 수 없다고 단언하는데, FailOpenDependencyLogger:47 이 예외 메시지를 형식 문자열에 그대로 채워 넣는다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
확인 방식 : README 의 두 대목과 그 파일의 커밋 이력 인용, 같은 디렉터리 두 마크다운 파일의 추가·수정 이력을 나란히 뽑기, README 가 쓰인 커밋의 build.gradle 에서 레지스트리 파일 경로 확인과 modules.json 의 추가 커밋 확인, 지금의 파생 자리 전수와 레지스트리 항목 인용, CLAUDE.md 머리 열 줄 인용, 없는 로거 이름 계수를 자기시험·대조와 함께 확인하고 그 이름의 추가·삭제 이력 추적, httpclient main 의 로거 파일·slf4j import·error 호출 계수, README 의 PII 주장과 로거 본문 대조
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. README 에서 정본 위치와 규범 문서 존재 여부와 대조 로거 이름과 PII 주장을 적은 대목을 인용한다.
|
||||
2. 그 파일과 같은 디렉터리 규범 문서의 추가·수정 이력을 나란히 뽑는다.
|
||||
3. README 가 쓰인 커밋의 빌드 스크립트에서 레지스트리 파일 경로를 확인하고, 지금 레지스트리 파일이 언제 들어왔는지 본다.
|
||||
4. 지금 그 이름이 나오는 자리를 전부 세고 값을 담는 파일과 규범 문서의 SSOT 선언을 인용한다.
|
||||
5. 대조 로거 이름을 자기시험과 대조 이름과 함께 세고, 그 이름의 추가·삭제 커밋을 뽑는다.
|
||||
6. 그 모듈의 로거 파일 수와 slf4j import 수와 error 호출 수를 센다.
|
||||
7. README 의 PII 주장과 로거의 실패 기록 본문을 나란히 놓는다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`src/adapter/outbound/support/README.md` 는 이 모듈의 존재 이유와 의존 정책을 설명한다. 새 어댑터를 붙이는 사람이 먼저 여는 문서다.
|
||||
|
||||
## README 가 적은 네 문장
|
||||
|
||||
:::evidence key="analysis-finding-a04-f005" alt="저장소 루트에서 돌린 정적 검색 출력 96줄. 먼저 support/README.md 7~11번 줄과 20~25번 줄이 실린다. 앞엣것은 허용·금지 의존 정책의 SSOT 가 src/build.gradle 의 allowedProjectDependencies 항목이며 이 모듈은 아직 별도 CLAUDE.md 를 두지 않았다고 적는다. 뒤엣것은 이 로거가 WARN 을 쓰는 이유를 적으면서 httpclient 모듈의 OutboundHttpDependencyLogger 와 구분되고 시그니처가 본문·수신자·페이로드를 받지 않아 PII 가 로그에 닿지 않는다고 적는다. 그 README 의 커밋 이력은 821fe00c 한 줄뿐이다. 이어서 두 파일이 언제 태어났는지가 나온다. CLAUDE.md 와 README.md 가 821fe00c 에서 나란히 A 로 추가되고 CLAUDE.md 만 b3add016 에서 M 으로 수정된다. README 가 쓰인 커밋의 build.gradle 558번 줄은 레지스트리 파일을 저장소 바깥의 .harness/project/modules.yaml 로 가리키고, src/config/architecture/modules.json 은 나흘 뒤 b3add016 에서 처음 들어온다. 그 아래에 지금의 src/build.gradle 에서 allowedProjectDependencies 가 나오는 세 줄이 전부 실리는데 1419번이 registry.modules 에서 만들어 내는 자리이고, modules.json 41~52번 줄이 이 모듈의 allowed_dependencies 셋과 runtime_memberships 를 담는다. 다음으로 이 디렉터리의 마크다운 파일 둘과 CLAUDE.md 1~10번 줄이 실리는데 9번 줄이 Registry SSOT 를 src/config/architecture/modules.json 으로 못 박는다. 그 파일의 이력도 함께 나온다. 세 번째로 OutboundHttpDependencyLogger 를 가진 파일이 0 개이고, httpclient main 에 이름에 Logger 가 든 파일도 0 개, org.slf4j 를 import 하는 파일도 0 개, log.error 호출도 0 줄이다. 없는 이름 자기시험은 0 개, 대조 FailOpenDependencyLogger 는 12 개다. 그 이름의 이력은 821fe00c 에서 main 과 test 두 파일이 A 로 추가되고 5f10b791 에서 둘 다 D 로 삭제된 것이다. 마지막으로 README 24~25번 줄의 PII 주장과 FailOpenDependencyLogger 36~48번 줄이 나란히 실리는데, 그 logFailure 가 47번 줄에서 cause.getMessage() 를 로그 형식 문자열에 넣는다." caption="README 의 두 대목과 그 파일의 단일 커밋 이력 · 두 파일이 같은 커밋에서 태어난 기록과 초기 레지스트리 위치 · 지금의 파생 자리 셋과 레지스트리의 실제 항목 · CLAUDE.md 가 못 박은 SSOT · 없는 로거의 계수와 자기시험과 대조와 그 삭제 이력 · README 의 PII 주장과 로거가 실제로 넣는 값 — 96줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
`:7`\~`:9` 한 문장에 두 가지가 들어 있다. 허용·금지 의존 정책의 SSOT 가 `src/build.gradle` 의 `allowedProjectDependencies['adapter:outbound:support']` 항목이고, 이 모듈에는 아직 별도 `CLAUDE.md` 가 없다는 것이다.
|
||||
|
||||
`:23`\~`:25` 에 둘이 더 있다. 이 로거가 `httpclient` 모듈의 `OutboundHttpDependencyLogger` 와 구분된다는 것, 그리고 메서드 시그니처가 본문·수신자·페이로드를 받지 않아 PII 가 로그에 닿지 않는다는 것이다.
|
||||
|
||||
이 파일에는 `821fe00c` 이후 커밋이 없다.
|
||||
|
||||
## 두 파일은 같은 커밋에서 태어났다
|
||||
|
||||
`CLAUDE.md` 와 `README.md` 가 `821fe00c` 에서 나란히 `A` 로 추가된다. `CLAUDE.md` 만 `b3add016` 에서 `M` 으로 한 번 더 수정됐다.
|
||||
|
||||
그러므로 "이 모듈은 아직 별도 CLAUDE.md 를 두지 않았다" 는 문장은 갱신을 놓친 것이 아니다. 그것을 적은 커밋이 같은 파일을 함께 넣었다.
|
||||
|
||||
## SSOT 는 그 자리에 있었던 적이 없다
|
||||
|
||||
`821fe00c` 시점의 `build.gradle:558` 은 레지스트리 파일을 `.harness/project/modules.yaml` 로 가리킨다. 저장소 바깥 경로다. `:577` 이 그 레지스트리에서 `allowedProjectDependencies` 를 만들어 낸다.
|
||||
|
||||
`src/config/architecture/modules.json` 은 나흘 뒤 `b3add016` 에 처음 들어온다.
|
||||
|
||||
즉 README 가 쓰인 날에도 `src/build.gradle` 의 그 이름은 파생물이었다. 이후에 바뀐 것은 레지스트리 파일이 저장소 밖에서 안으로 들어온 것이고, 정본의 성격이 옮겨 간 것이 아니다.
|
||||
|
||||
## 지금의 파생 관계
|
||||
|
||||
`src/build.gradle` 에 `allowedProjectDependencies` 가 나오는 줄은 셋이고 `:1419` 가 `registry.modules` 에서 만들어 내는 자리다. 나머지 둘은 그 맵을 읽는다.
|
||||
|
||||
값을 담는 것은 `modules.json:41`\~`:52` 다. 이 모듈의 `allowed_dependencies` 셋과 `runtime_memberships` 가 거기 있다.
|
||||
|
||||
같은 디렉터리의 `CLAUDE.md:9` 가 `Registry SSOT: src/config/architecture/modules.json` 이라고 적는다. README 가 없다고 한 그 파일이 정본 위치를 못 박고 있다.
|
||||
|
||||
## 하나만 시간이 지나 어긋났다
|
||||
|
||||
`OutboundHttpDependencyLogger` 는 `821fe00c` 에서 main 과 test 두 파일로 추가됐다. `5f10b791` 에서 둘 다 `D` 로 삭제됐다.
|
||||
|
||||
README 가 쓰인 날에는 실재하는 클래스였고 그 뒤에 사라졌다. 셋 중 이것만이 문서가 소스를 따라가지 못한 경우다.
|
||||
|
||||
지금 그 이름을 가진 파일은 0 개다. 없는 이름으로 같은 검색을 걸어도 0 이 나오므로, 실재하는 `FailOpenDependencyLogger` 로 같은 검색을 걸어 12 를 받았다.
|
||||
|
||||
`httpclient` main 에는 이름에 `Logger` 가 든 파일이 0 개이고, `org.slf4j` 를 import 하는 파일이 0 개이며, `log.error` 호출이 0 줄이다. 대조 대상만 사라진 것이 아니라 그 모듈의 로깅 자체가 남아 있지 않다.
|
||||
|
||||
## 넷째 문장
|
||||
|
||||
`:24`\~`:25` 는 메서드 시그니처가 본문·수신자·페이로드를 받지 않아 PII 가 로그에 닿지 않는다고 적는다.
|
||||
|
||||
`FailOpenDependencyLogger:37`\~`:48` 의 `logFailure` 는 `Throwable cause` 를 받는다. `:41` 의 형식 문자열이 `error="{}: {}"` 를 담고 `:46`\~`:47` 이 그 자리에 `cause.getClass().getSimpleName()` 과 `cause.getMessage()` 를 넣는다.
|
||||
|
||||
시그니처가 payload 를 받지 않는다는 것은 맞다. 예외 메시지를 통해 닿지 않는다는 것은 그 문장이 보장하지 못한다.
|
||||
|
||||
## 원문과 갈리는 자리
|
||||
|
||||
원문은 정본 위치와 안내 문서 존재 여부와 HTTP 로거 존재 여부 셋이 어긋난다고 적었다. 셋 다 지금 소스와 다르다.
|
||||
|
||||
갈리는 것은 그 셋을 드리프트로 묶은 부분이다. 커밋 이력을 보면 앞의 둘은 문서가 낡은 것이 아니라 작성 시점부터 사실과 달랐다.
|
||||
|
||||
원문이 적지 않은 것은 넷째다. 같은 README 가 PII 가 로그에 닿지 않는다고 적는데 그 로거는 예외 메시지를 형식 문자열에 넣는다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
이 서술을 근거로 잘못된 의존이 들어간 적이 있는지 커밋을 뒤지지 않았다.
|
||||
|
||||
`5f10b791` 이 그 로거를 지운 이유를 커밋 메시지 밖에서 확인하지 않았다.
|
||||
|
||||
여기서 다룬 넷 밖의 서술은 소스와 대조하지 않았다.
|
||||
|
||||
`5f10b791` 이 그 로거를 지운 이유를 커밋 메시지 밖에서 확인하지 않았다.
|
||||
|
||||
README 의 나머지 문장까지 소스와 맞춰 보지는 않았다.
|
||||
|
||||
<!-- body:end -->
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: analysis-finding-a05-f001
|
||||
title: 추정식의 여유분이 상수라서 인코드가 내준 세 크기를 디코드가 거절한다
|
||||
topic: multitenancy-isolation
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:analysis-finding-a05-f001
|
||||
evidenceCapturedOn: 2026-09-04
|
||||
body: case-analysis-finding-a05-f001.body.md
|
||||
assets:
|
||||
- key: analysis-finding-a05-f001
|
||||
file: ../../../final/evidence/rendered/analysis-finding-a05-f001.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/analysis-finding-a05-f001.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/05-adapter-outbound-persistence-jpa.md §6.2 이다.
|
||||
---
|
||||
|
||||
# 추정식의 여유분이 상수라서 인코드가 내준 세 크기를 디코드가 거절한다
|
||||
|
||||
`SignedJsonCursorCodec:127` 의 `encodedLength / 4 * 3 + 3` 은 나눗셈이 버린 그룹을 상수 3 으로 메운다. 실제로 필요한 여유는 인코딩 길이를 4 로 나눈 나머지에 따라 3 이거나 2 이거나 1 이라서, 3 이 필요한 자리에서만 추정값이 상한을 넘고 2046·2047·2048 바이트가 인코드는 통과하고 디코드에서 막힌다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **서명된 커서의 구조와 검증 순서**
|
||||
이 추정이 놓인 검증 순서다. 크기 사전 검사가 서명 확인보다 앞에 있다.
|
||||
- **이진 승인 코덱은 decode 후 재인코딩이 원본과 같아야 한다**
|
||||
이 사례가 어기는 왕복 성질이다. 재인코딩까지 가지도 못하고 디코드가 먼저 막는다.
|
||||
- **커서 서명 검증이 길이·상수시간·순서를 전부 지켜야 했던 이유**
|
||||
같은 코덱의 검증 순서를 다룬 사례다.
|
||||
|
||||
## 문제
|
||||
|
||||
디코드는 페이로드를 풀기 전에 크기를 추정해 상한을 넘으면 거절한다. 큰 토큰이 먼저 메모리에 풀리는 것을 막기 위해서다.
|
||||
|
||||
인코드도 같은 상한을 쓴다. 두 검사가 같은 집합을 가르는지 확인했다.
|
||||
|
||||
## 결론
|
||||
|
||||
추정식의 여유분이 상수다. L / 4 가 마지막 그룹을 버리므로 그만큼을 메워야 하는데, 메울 양이 L % 4 에 따라 셋 중 하나로 달라진다. 코드는 그중 가장 큰 값을 언제나 쓴다.
|
||||
|
||||
프로브가 두 인코딩을 나란히 계산한 표가 그 주기를 보여 준다. 2043 부터 2049 까지 무패딩 초과분이 3, 2, 1, 3, 2, 1, 3 으로 돈다. 초과가 3 인 자리에서만 추정값이 2049 가 되어 2048 을 넘는다.
|
||||
|
||||
그래서 경계가 셋이다. 2046 바이트는 무패딩 인코딩이 2728 자이고 나머지가 0 이라 추정식이 2049 를 낸다. 2047 과 2048 도 같은 값을 받는다. 그 아래 크기들은 2046 을 받아 상한 안에 든다.
|
||||
|
||||
패딩은 원인이 아니다. 같은 표의 패딩 열을 보면 2044 와 2045 도 2049 를 받는다. 그 인코더를 택했다면 오늘 통과하는 두 크기까지 함께 막혔을 것이다. 2046 바이트는 3 의 배수여서 두 인코딩의 길이가 아예 같다.
|
||||
|
||||
두 주석이 서로 다르게 적는다. :125 자바독은 이 값이 최대치라고 적어 옳고, :103~:104 인라인 주석은 정확히 한정한다고 적어 틀렸다. 설계 의도는 절대 과소평가하지 않는 보수적 사전 검사였고, 실패한 것은 건전성이 아니라 정밀도다.
|
||||
|
||||
인코드 쪽 주석은 큰 페이로드를 막는 까닭을 이렇게 적는다 — 되돌려 받지 못할 토큰은 다음 페이지 요청을 깨뜨린다. 같은 까닭이 이 세 크기에도 걸리는데 인코드는 통과시킨다.
|
||||
|
||||
이 코덱은 프로덕션에 배선돼 있지 않다. 생성자를 부르는 자리가 시험 소스 세트에만 있고, 페이로드를 JSON 으로 옮길 구현체도 main 에 없다. 웹과 GraphQL 과 Mongo 쪽 페이지네이션은 각자 다른 코덱을 쓰며 그 셋 중 이 추정식을 가진 파일이 없다.
|
||||
|
||||
시험은 추정식에 도달한다. SignedJsonCursorCodecTest:168 이 상한을 훨씬 넘는 위조 구간으로 사전 검사를 발화시킨다. 빠진 것은 경계 바로 옆에서 왕복을 단언하는 시험이고 그 수가 0 이다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
Gradle : 9.0.0
|
||||
확인 방식 : 인코더·디코더 선언과 두 상한 인용, 인코드의 크기 검사와 그 주석 인용, 디코드의 사전 추정과 추정식과 두 주석 인용, 그 식을 부르는 자리 전수, 이 코덱을 만드는 자리와 페이로드 코덱 구현 계수와 실제 배선된 코덱 셋의 추정식 사용 여부, 경계 값을 쓰는 시험 줄 계수와 추정식에 도달하는 시험 인용, 같은 식을 무패딩과 패딩 인코딩에 각각 적용한 표 계산, 저장소 클래스를 그대로 써서 여섯 크기를 인코드한 뒤 곧바로 디코드
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 인코더와 디코더 선언, 두 상한 상수, 인코드의 크기 검사와 그 주석을 인용한다.
|
||||
2. 디코드의 사전 추정 자리와 추정식, 그리고 그것을 설명하는 주석 둘을 함께 인용한다.
|
||||
3. 이 코덱을 만드는 자리를 소스 세트별로 세고, 페이로드 코덱을 구현한 main 파일과 실제 배선된 코덱을 확인한다.
|
||||
4. 2043 부터 2049 까지 무패딩 길이와 패딩 길이를 각각 계산하고, 같은 식을 둘에 적용해 실제 최대치와의 차를 표로 만든다.
|
||||
5. 페이로드 바이트 수를 그대로 통제하는 코덱을 붙여 여섯 크기를 인코드하고 곧바로 디코드한다.
|
||||
6. 경계 값을 쓰는 시험 줄을 세고, 추정식에 도달하는 시험과 왕복을 단언하는 시험을 나눠 확인한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`SignedJsonCursorCodec` 은 커서를 JSON 으로 만들어 서명하고 Base64URL 로 인코딩한다. 디코드는 서명을 확인하기 전에 페이로드 크기를 먼저 거른다.
|
||||
|
||||
## 여유분이 상수인 추정식
|
||||
|
||||
:::evidence key="analysis-finding-a05-f001" alt="저장소 루트에서 돌린 정적 검색과 왕복 프로브의 출력 115줄. 먼저 SignedJsonCursorCodec 37~38번 줄의 인코더·디코더 선언과 53~58번 줄의 두 상한, 63~72번 줄의 encode 크기 검사와 그 주석, 103~108번 줄의 decode 사전 추정, 125~128번 줄의 추정식이 차례로 실린다. 그 식을 부르는 자리는 106번 줄 하나다. 이어서 이 코덱이 프로덕션에 배선되는지가 나온다. new SignedJsonCursorCodec 이 나오는 여섯 자리가 모두 시험이고, CursorPayloadCodec 을 구현한다고 선언한 main 파일이 0 개이며, 실제로 배선된 커서 코덱은 HmacGraphQlCursorCodec 과 HmacWebCursorCodec 과 MongoKeysetCursorCodec 셋인데 그 셋은 같은 추정식을 쓰지 않는다. 그 아래에 두 주석이 나란히 실린다. 125번 자바독은 이 값이 최대치라고 적고 103~104번 인라인 주석은 정확히 한정한다고 적는다. 다음으로 경계 값을 쓰는 시험을 세면 2044·2045·2046·2047 이 0 개이고 2048 이 17 개 2049 가 1 개다. 추정식에 실제로 도달하는 시험은 SignedJsonCursorCodecTest 166~176번 줄인데 3000 자짜리 위조 구간을 넣어 payload exceeds 를 확인하는 것이고, 왕복을 상한 근처에서 단언하는 시험은 0 개이며 이 코덱을 만드는 자리는 test 소스 세트뿐이다. 마지막으로 프로브가 실린다. 먼저 같은 식을 두 인코딩에 적용한 표가 나오는데 n 이 2043 부터 2049 까지일 때 무패딩 길이와 그 추정값, 패딩 길이와 그 추정값, 실제 최대치, 무패딩 초과분이 함께 나온다. 초과분은 3, 2, 1 이 반복되고 패딩 쪽 추정값이 무패딩 쪽보다 크거나 같다. 이어서 같은 코덱 인스턴스로 여섯 크기를 왕복시킨 결과가 나온다. 2044 와 2045 바이트는 인코드도 디코드도 성공하고 추정식이 2046 을 낸다. 2046·2047·2048 바이트는 인코드는 성공하는데 디코드가 cursor payload exceeds the maximum size 로 거절하고 추정식이 2049 를 낸다. 2049 바이트는 인코드 자체가 거절한다." caption="인코더·디코더 선언과 두 상한 · encode 검사와 decode 사전 추정과 추정식 · 이 코덱을 만드는 자리가 전부 시험이라는 것과 실제 배선된 세 코덱 · 서로 다르게 적는 두 주석 · 경계 값을 쓰는 시험 계수와 추정식에 도달하는 시험 · 같은 식을 두 인코딩에 적용한 표와 여섯 크기 왕복 결과 — 115줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
`:126`\~`:128` 의 `decodedLengthOf` 는 `encodedLength / 4 * 3 + 3` 을 돌려준다.
|
||||
|
||||
`L / 4` 는 정수 나눗셈이라 마지막 그룹을 버린다. 그 그룹이 디코딩하는 바이트 수는 남는 문자 수에 달려 있다 — `L % 4` 가 2 면 1 바이트, 3 이면 2 바이트, 0 이면 그룹이 온전하므로 버린 것이 3 바이트다.
|
||||
|
||||
그래서 필요한 여유는 3 이거나 2 이거나 1 이다. 코드는 언제나 3 을 더한다. 나머지가 2 나 3 인 자리에서는 추정값이 실제보다 1 이나 2 크고, 0 인 자리에서만 3 크다.
|
||||
|
||||
## 초과분이 3, 2, 1 로 돈다
|
||||
|
||||
프로브가 2043 부터 2049 까지를 표로 계산한다. 무패딩 초과분이 3, 2, 1, 3, 2, 1, 3 으로 반복된다.
|
||||
|
||||
상한이 2048 이므로 추정값이 2049 이상이 되는 자리만 막힌다. 그 자리는 초과가 3 인 곳, 즉 `L % 4 == 0` 인 곳이다.
|
||||
|
||||
n = 2046 이 그렇다. 무패딩 길이가 2728 이고 4 로 나누어떨어져서 추정식이 2049 를 낸다. 2047 과 2048 은 길이가 2730 과 2731 인데 `/4*3` 이 같은 2046 을 내므로 +3 이 붙어 역시 2049 가 된다.
|
||||
|
||||
2045 까지는 2046 이 나와 통과한다.
|
||||
|
||||
## 패딩은 원인이 아니다
|
||||
|
||||
같은 표의 패딩 열이 그것을 보여 준다. 패딩을 붙이면 2044 와 2045 의 길이가 2728 이 되어 추정식이 2049 를 낸다. 지금은 통과하는 두 크기가 막히게 된다.
|
||||
|
||||
n = 2046 은 3 의 배수라 패딩 인코딩과 무패딩 인코딩의 길이가 2728 로 같다. 이 경계를 만든 것은 인코더의 패딩 여부가 아니다.
|
||||
|
||||
패딩 인코더로 바꾸면 막히는 구간이 {2046, 2047, 2048} 에서 {2044 … 2048} 로 넓어진다.
|
||||
|
||||
## 두 검사가 가르는 집합
|
||||
|
||||
`encode:66` 은 `json.length` 를 `MAX_PAYLOAD_BYTES` 와 직접 견준다. 바이트 수 그대로다.
|
||||
|
||||
`decode:106` 은 `decodedLengthOf(encodedPayloadLength)` 를 같은 상한과 견준다. 추정값이다.
|
||||
|
||||
같은 코덱 인스턴스로 왕복시키니 2044 와 2045 는 양쪽을 통과하고, 2046·2047·2048 은 인코드만 통과하며, 2049 는 인코드가 거절한다.
|
||||
|
||||
`:67`\~`:68` 주석은 인코드가 큰 페이로드를 막는 근거를 적는다. 코덱이 되돌려 줄 수 없는 크기의 토큰을 만들면 다음 페이지에서 실패하므로, 그것은 호출자의 잘못이 아니라 애플리케이션 자신의 결함이라는 것이다. 그 근거가 세 크기에서 그대로 성립하는데 인코드는 통과시킨다.
|
||||
|
||||
## 자바독은 맞고 인라인 주석은 틀리다
|
||||
|
||||
`:125` 자바독은 이 값을 "at most" 라고 적는다. 보수적 상한이라는 뜻이고 맞는 서술이다.
|
||||
|
||||
`:103`\~`:104` 인라인 주석은 인코딩된 구간 길이가 디코딩 크기를 "exactly" 한정한다고 적는다. 표가 보여 주는 대로 초과분이 1 에서 3 사이로 움직이므로 그렇지 않다.
|
||||
|
||||
설계가 노린 것은 절대 과소평가하지 않는 사전 검사였다. 그 성질은 지켜졌다. 어긋난 것은 정밀도다.
|
||||
|
||||
## 이 코덱은 아직 아무도 쓰지 않는다
|
||||
|
||||
`new SignedJsonCursorCodec` 이 나오는 여섯 자리가 전부 `SignedJsonCursorCodecTest` 다. 이 코덱을 만드는 자리가 있는 소스 세트는 test 뿐이다.
|
||||
|
||||
`CursorPayloadCodec` 을 구현한다고 선언한 main 파일도 0 개다. 페이로드를 JSON 으로 만들 구현이 없다.
|
||||
|
||||
실제 페이지네이션은 `HmacWebCursorCodec` 과 `HmacGraphQlCursorCodec` 과 `MongoKeysetCursorCodec` 이 맡는다. 그 셋에서 같은 추정식을 쓰는 파일은 0 개다.
|
||||
|
||||
## 시험은 추정식에 닿지만 왕복을 묻지 않는다
|
||||
|
||||
`SignedJsonCursorCodecTest:166`\~`:172` 는 3000 자짜리 위조 구간을 넣어 토큰 상한 아래·페이로드 상한 위에서 사전 검사가 먼저 터지는 것을 확인한다. 이 시험은 추정식에 실제로 도달한다.
|
||||
|
||||
없는 것은 상한 근처에서 `decode(encode(x))` 를 단언하는 시험이고, 그런 시험이 0 개다. 2046 과 2047 을 쓰는 시험 줄도 각각 0 개다.
|
||||
|
||||
## 원문과 갈리는 자리
|
||||
|
||||
원문은 2046 부터 2048 까지가 인코드 성공·디코드 거절이라고 적었고 왕복 프로브가 그대로 재현한다.
|
||||
|
||||
원인 설명은 갈린다. 원문은 코덱이 패딩 없는 인코딩을 쓰기 때문에 추정이 어긋난다고 적었다. 표를 계산해 보면 패딩을 붙여도 같은 식이 어긋나고 오히려 막히는 구간이 넓어진다. 어긋나게 만드는 것은 인코딩 방식이 아니라 여유분이 상수라는 점이다.
|
||||
|
||||
원문이 적지 않은 것은 이 코덱이 프로덕션에 배선되지 않았다는 것과, `:125` 자바독이 옳게 적혀 있다는 것이다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
이 공개 타입을 실제로 가져다 쓴 프로젝트가 있는지는 저장소 밖의 일이라 알 수 없다.
|
||||
|
||||
나머지에 맞춰 여유분을 계산하도록 고쳤을 때 무엇이 깨지는지 빌드로 확인하지 않았다.
|
||||
|
||||
`MAX_ENCODED_LENGTH` 검사가 어떤 입력에서 발화하는지는 따져 보지 않았다.
|
||||
|
||||
토큰 전체 길이를 보는 상한이 실제 도달 가능한 값인지 계산하지 않았다.
|
||||
|
||||
## 등급에 대해
|
||||
|
||||
원본은 P2 다. 프로덕션 배선이 없고 실제 페이지네이션 코덱 셋에 같은 결함이 없으므로 오늘의 사고는 아니다. 다만 이 코덱은 공개 API 표면에 있으므로 채택자가 붙이는 순간 살아난다. 등급을 새로 매기지 않고 그 조건을 적어 둔다.
|
||||
|
||||
<!-- body:end -->
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: analysis-finding-a05-f010
|
||||
title: 정본이라던 코디네이터는 그 포트를 구현하지 않고 다른 클래스가 구현한다
|
||||
topic: multitenancy-isolation
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:analysis-finding-a05-f010
|
||||
evidenceCapturedOn: 2026-09-04
|
||||
body: case-analysis-finding-a05-f010.body.md
|
||||
assets:
|
||||
- key: analysis-finding-a05-f010
|
||||
file: ../../../final/evidence/rendered/analysis-finding-a05-f010.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/analysis-finding-a05-f010.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/05-adapter-outbound-persistence-jpa.md §10 이다.
|
||||
---
|
||||
|
||||
# 정본이라던 코디네이터는 그 포트를 구현하지 않고 다른 클래스가 구현한다
|
||||
|
||||
`JpaTransactionAutoConfiguration:37` 주석이 정본 경계로 지목한 메서드는 `PolicyTransactionPort` 의 것인데, 같은 문장이 아래에 만들어지는 재시도 코디네이터로 이어진다. 그 포트를 실제로 구현하는 것은 어댑터 쪽 `SpringTransactionPort` 이고 코디네이터는 자기 실행기와 짝을 이룬다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **중복 장치를 찾으면 어느 쪽이 조립됐는지 먼저 확인한다**
|
||||
이 사례가 속한 구조다. 두 스택이 다 조립되고 어느 쪽이 정본인지가 갈린다.
|
||||
- **재시도 코디네이터는 빈이지만 그것을 어디에도 적용하지 않는다**
|
||||
같은 코디네이터의 배선 공백을 다룬 기록이다.
|
||||
- **같은 개념의 두 어휘가 공존하면 하나를 죽은 것으로 표시한다**
|
||||
이 사례가 어기는 규칙이다. 둘 다 살아 있고 어느 쪽도 죽은 것으로 표시되지 않았다.
|
||||
|
||||
## 문제
|
||||
|
||||
정본 트랜잭션 경계가 어디인지는 이 리프의 구조를 읽는 출발점이다.
|
||||
|
||||
자동설정 주석이 그 경계를 지목한다. 그 지목이 실제 구현 관계와 맞는지 확인했다.
|
||||
|
||||
## 결론
|
||||
|
||||
그 포트를 구현한다고 선언한 파일은 하나뿐이다. 없는 이름으로 같은 검색식을 걸어도 0 이 나오므로, 상위 인터페이스로 같은 검색을 걸어 21 을 받아 검색이 도는 것을 확인했다.
|
||||
|
||||
그 클래스가 자기 자바독에서 설명하는 것은 모드별 템플릿과 격리 수준 고정이다. 재시도라는 낱말이 거기 없다.
|
||||
|
||||
자동설정이 만드는 빈은 여섯이다 — CommitFailureClassifier, SpringJpaTransactionExecutor 둘, FullTransactionRetryCoordinator 둘, RetrySleeper. 그 목록에 포트 구현이 없다.
|
||||
|
||||
즉 주석이 한 문장 안에서 두 스택을 잇고 있다. 앞부분이 지목한 포트는 어댑터 쪽 클래스가 구현하고, 뒷부분이 설명하는 코디네이터는 자동설정 쪽 실행기와 짝을 이룬다.
|
||||
|
||||
네 이름의 main 참조 파일 수가 모두 4 개다. 어느 쪽도 죽어 있지 않다.
|
||||
|
||||
저장소 자신도 이것을 문제로 적어 두었다. docs/reviews/2026-08-14-jpa-module-code-review.md:119 가 트랜잭션 권위와 애플리케이션 계약이 두 벌이라는 것을 P1 로 매기고 PolicyTransactionPort 단일 facade 로 통합하자고 적는다.
|
||||
|
||||
동작이 틀리는 것은 아니다. 두 스택이 각각 돈다. 비용은 다음에 고치는 사람이 어느 쪽을 고쳐야 하는지 주석만으로는 알 수 없다는 데 있다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
확인 방식 : 자동설정 주석 인용, implements PolicyTransactionPort 검색을 없는 이름 자기시험과 대조 이름과 함께 계수, 구현체의 선언과 자바독 인용, 자동설정이 만드는 빈 목록 전수, 네 이름의 main 참조 파일 수 계수와 그중 하나의 파일 목록, 저장소 문서가 같은 것을 어떻게 적었는지 전수 검색
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 자동설정에서 정본 경계를 지목하는 주석을 인용한다.
|
||||
2. 그 포트를 구현한다고 선언한 클래스를 찾고, 없는 이름과 대조 이름으로 같은 검색을 함께 건다.
|
||||
3. 구현체의 선언과 클래스 자바독을 인용한다.
|
||||
4. 자동설정이 만드는 빈을 전부 나열한다.
|
||||
5. 두 스택의 이름 넷을 main 참조 파일 수로 센다.
|
||||
6. 저장소 문서에서 그 포트 이름이 나오는 자리를 전부 찾는다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`JpaTransactionAutoConfiguration` 은 JPA 트랜잭션 스택을 조립한다. 그 파일의 주석이 정본 경계가 어디인지 적어 둔다.
|
||||
|
||||
## 주석이 지목한 경계와 실제 구현 관계
|
||||
|
||||
:::evidence key="analysis-finding-a05-f010" alt="저장소 루트에서 돌린 정적 검색 출력 84줄. 먼저 JpaTransactionAutoConfiguration 28~55번 줄이 실리는데 37번 줄 주석이 PolicyTransactionPort.inTransaction 을 정본 경계로 지목하고 아래의 재시도 코디네이터를 함께 설명한다. 이어서 PolicyTransactionPort 를 구현한다고 선언한 클래스가 SpringTransactionPort 31번 줄 하나로 나오고, 없는 이름으로 건 자기시험이 0 개, 대조로 센 implements TransactionPort 가 21 개다. 그 구현체의 선언과 자바독이 24~40번 줄로 실리는데 모드별로 미리 만든 트랜잭션 템플릿을 쓰고 모두 READ_COMMITTED 로 고정한다고 적는다. 그 아래에 자동설정이 만드는 빈들이 나오는데 CommitFailureClassifier 와 SpringJpaTransactionExecutor 둘과 FullTransactionRetryCoordinator 둘과 RetrySleeper 이고, 기본 최대 재시도 경과 시간이 30 초로 선언돼 있다. 두 스택을 참조하는 main 파일 수는 PolicyTransactionPort 와 FullTransactionRetryCoordinator 와 SpringJpaTransactionExecutor 와 SpringTransactionPort 가 각각 4 개이고, PolicyTransactionPort 를 참조하는 네 파일이 이름으로 나열된다. 마지막으로 문서 쪽 서술이 나오는데 코드 리뷰 문서가 두 벌의 트랜잭션 계약이 공존한다는 것을 P1 로 적고 PolicyTransactionPort 를 단일 facade 로 통합하자고 적는다." caption="자동설정 주석이 지목한 정본 경계 · 그 포트를 구현한 클래스 하나와 자기시험과 대조 · 구현체의 선언과 자바독 · 자동설정이 만드는 빈 목록 · 두 스택의 main 참조 수 · 코드 리뷰 문서가 같은 것을 P1 로 적은 자리 — 84줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
`:37` 주석은 `PolicyTransactionPort.inTransaction(TransactionRequest, Supplier)` 를 지목하고, 같은 문장이 아래의 재시도 코디네이터를 함께 설명한다.
|
||||
|
||||
## 그 포트를 구현한 클래스는 하나다
|
||||
|
||||
`implements PolicyTransactionPort` 를 가진 파일은 `SpringTransactionPort:31` 하나다. 없는 이름으로 같은 검색식을 걸면 0 이 나오므로, 그 1 이 검색 운은 아닌지 확인하려고 `implements TransactionPort` 를 세면 21 이 나온다.
|
||||
|
||||
`SpringTransactionPort` 는 `adapter/outbound/persistence-jpa` 에 있고 `@Component` 다. 자바독 `:26`\~`:28` 은 모드별로 미리 만든 `TransactionTemplate` 을 쓰고 모두 `READ_COMMITTED` 로 고정한다고 적는다. 미리 만드는 이유는 가변 템플릿 경합 때문이라고 README 를 가리킨다.
|
||||
|
||||
재시도는 그 자바독 어디에도 없다.
|
||||
|
||||
## 자동설정이 만드는 것은 다른 스택이다
|
||||
|
||||
빈 목록은 여섯이다. `:61` 의 `CommitFailureClassifier`, `:66` 과 `:82` 의 `SpringJpaTransactionExecutor`, `:99` 와 `:107` 의 `FullTransactionRetryCoordinator`, `:114` 의 `RetrySleeper` 다.
|
||||
|
||||
포트 구현을 만드는 빈이 없다. `SpringTransactionPort` 는 `@Component` 로 스캔되어 들어온다.
|
||||
|
||||
그래서 `:37` 주석은 한 문장 안에서 두 계통을 잇는다. 앞이 가리키는 포트는 어댑터 쪽이 구현하고, 뒤가 설명하는 코디네이터는 같은 파일이 만드는 실행기와 짝이다.
|
||||
|
||||
## 두 스택 다 살아 있다
|
||||
|
||||
`PolicyTransactionPort` 와 `FullTransactionRetryCoordinator` 와 `SpringJpaTransactionExecutor` 와 `SpringTransactionPort` 의 main 참조 파일 수가 모두 4 개다.
|
||||
|
||||
`PolicyTransactionPort` 를 참조하는 넷은 그 인터페이스 자신과 `SpringPolicyTransactionPort` 와 `SpringTransactionPort` 와 `JpaTransactionAutoConfiguration` 이다.
|
||||
|
||||
어느 쪽에도 폐기 표시가 없다.
|
||||
|
||||
## 저장소 자신이 이미 적어 둔 것
|
||||
|
||||
`docs/reviews/2026-08-14-jpa-module-code-review.md:31` 은 애플리케이션이 이미 쓰는 포트와 새 JPA 실행기·AOP 계약이 두 벌로 존재한다고 적는다.
|
||||
|
||||
`:119` 는 그것을 `JPA-007` 로 번호 매겨 P1 · High 로 두고 `PolicyTransactionPort` 단일 facade 로 통합하자고 적는다. `:427` 은 템플릿의 정본 경계를 그 포트의 `inTransaction` 으로 삼자고 적는다.
|
||||
|
||||
즉 이 상태는 발견되지 않은 것이 아니라 결정이 내려지지 않은 것이다.
|
||||
|
||||
## 원문과 갈리는 자리
|
||||
|
||||
원문은 문서와 소스 주석이 코디네이터가 포트를 구현한다고 설명하는데 실제 구현체는 다른 클래스라고 적었다. 실제 주석은 그보다 조금 다르다. `:37` 은 코디네이터가 그 포트를 구현한다고 적지 않고, 정본 경계로 포트를 지목한 뒤 아래 코디네이터를 이어서 설명한다. 두 계통이 한 문장에 붙어 있어서 읽는 사람이 그렇게 이해하게 된다.
|
||||
|
||||
원문이 적지 않은 것은 저장소가 이미 이 중복을 `JPA-007` 로 기록하고 통합 방향까지 정해 두었다는 것이다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
실제 요청이 두 계통 중 어느 쪽을 더 자주 지나는지 세지 않았다.
|
||||
|
||||
그 문장이 쓰였을 때는 맞았는지 커밋을 되짚지 않았다.
|
||||
|
||||
`JPA-007` 이 어느 쪽으로 결정됐는지 추적하지 않았다.
|
||||
|
||||
`JPA-007` 이 어느 쪽으로 결정됐는지 추적하지 않았다.
|
||||
|
||||
<!-- body:end -->
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: analysis-finding-a05-f023
|
||||
title: 쿼터 원장은 사용량을 적기만 하고 승인은 그것을 읽지 않는다
|
||||
topic: multitenancy-isolation
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:analysis-finding-a05-f023
|
||||
evidenceCapturedOn: 2026-09-04
|
||||
body: case-analysis-finding-a05-f023.body.md
|
||||
assets:
|
||||
- key: analysis-finding-a05-f023
|
||||
file: ../../../final/evidence/rendered/analysis-finding-a05-f023.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/analysis-finding-a05-f023.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/05-adapter-outbound-persistence-jpa.md §23 이다.
|
||||
---
|
||||
|
||||
# 쿼터 원장은 사용량을 적기만 하고 승인은 그것을 읽지 않는다
|
||||
|
||||
`DefaultTransferAdmissionController.acquireUpload:45`~`:62` 가 확인하는 것은 파일 크기와 저장소 고수위와 세마포어 둘이다. `JpaFileQuotaService:84` 와 `:89` 가 범위별 예약·확정 바이트를 계산하지만 그 둘을 부르는 main 코드가 정의 파일 밖에 0 줄이다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **선언은 통과하는데 강제하는 주체가 없음 계열**
|
||||
이 사례가 속한 구조다. 값을 계산하는 코드는 있고 그것으로 무언가를 거절하는 코드가 없다.
|
||||
- **tenant별 풀이 개별적으로 합리적이고 그 합이 서버 상한을 넘긴다**
|
||||
같은 계열의 자원 격리 문제다.
|
||||
- **회수가 페이지 하나를 다 쓰면 남은 바이트를 들고 그대로 끝난다**
|
||||
같은 원장의 다른 결함이다. 그쪽은 기록된 값이 실제보다 커지는 것이다.
|
||||
|
||||
## 문제
|
||||
|
||||
파일서버 설계 문서는 쿼터를 범위별 바이트 강제로 정의한다. 승인 컨트롤러도 클래스 설명에서 같은 어휘를 쓴다.
|
||||
|
||||
업로드 요청이 그 상한을 실제로 지나는지 확인했다.
|
||||
|
||||
## 결론
|
||||
|
||||
acquireUpload 는 네 가지를 지난다. :46 이 단일 파일 크기 정책, :47 이 저장소 하드 고수위, :49 가 범위별 업로드 세마포어, :54 가 인스턴스 업로드 세마포어다.
|
||||
|
||||
넷 중 어느 것도 바이트 집계가 아니다. 인자로 받은 바이트 수는 :46 한 곳에서만 쓰이고, 그 범위에 이미 쌓인 양은 아무 검사도 조회하지 않는다.
|
||||
|
||||
JpaFileQuotaService.reserve:38~:52 도 마찬가지다. 음수만 거른 뒤 예약 엔티티를 만들어 조건 없이 저장한다. 상한 조회도 집계도 없다.
|
||||
|
||||
집계를 계산하는 코드는 있다. :84 의 reservedBytes 와 :89 의 committedBytes 가 각각 합계 질의를 부른다. 두 메서드가 불리는 자리를 저장소 전역에서 훑으면 정의한 파일 자신 말고는 통합 시험과 가짜 구현뿐이다. 검색이 도는지 보려고 acquireUpload 를 같은 방식으로 세면 main 2 줄이 나온다.
|
||||
|
||||
설정 쪽에도 없다. 승인 컨트롤러가 읽는 프로퍼티 다섯은 허가 수 셋과 고수위 둘이고, 테넌트나 네임스페이스의 용량을 담은 값이 없다.
|
||||
|
||||
QuotaExceededException 이 던져지는 자리는 :50 하나인데, 그 이유는 바이트 초과가 아니라 범위 업로드 동시성 소진이다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
확인 방식 : 설계 문서의 쿼터 서술과 승인 컨트롤러 클래스 자바독 인용, acquireUpload 본문 전문 인용, 예약 저장 자리 인용, 범위별 바이트 집계를 읽는 자리 전수와 정의 파일 밖 main 호출자 계수를 대조 이름과 함께 확인, 설정의 fileserver 절 인용, 승인 컨트롤러가 읽는 프로퍼티 전수
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 설계 문서에서 쿼터를 바이트 강제로 정의한 줄과 승인 컨트롤러의 클래스 자바독을 인용한다.
|
||||
2. acquireUpload 본문을 전문으로 싣고 무엇을 검사하는지 센다.
|
||||
3. 예약을 저장하는 자리를 인용하고 조건절이 있는지 본다.
|
||||
4. 범위별 예약·확정 바이트를 읽는 자리를 저장소 전역에서 찾고, 정의 파일을 뺀 main 호출자를 센다.
|
||||
5. 같은 방식으로 대조 이름을 세어 검색이 도는지 확인한다.
|
||||
6. 설정의 fileserver 절을 싣고 승인 컨트롤러가 읽는 프로퍼티를 전부 나열한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
파일서버 설계 문서는 쿼터를 단순 회계가 아니라 범위별 바이트 강제로 설명한다. `DefaultTransferAdmissionController` 의 클래스 자바독도 범위가 상한을 넘는 것을 쿼터 초과라고 부른다.
|
||||
|
||||
## acquireUpload 가 실제로 지나는 네 검사
|
||||
|
||||
:::evidence key="analysis-finding-a05-f023" alt="저장소 루트에서 돌린 정적 검색 출력 132줄. 먼저 docs/fileserver/design-deviations.md 에서 쿼터를 다루는 줄들이 실리고, DefaultTransferAdmissionController 1~30번 줄의 클래스 자바독이 나온다. 이어서 44~62번 줄의 acquireUpload 본문이 실리는데, 파일 크기 정책과 고수위를 확인한 뒤 범위 세마포어와 인스턴스 세마포어를 얻고 둘 다 얻으면 허가를 돌려준다. 바이트 집계를 읽는 줄이 없다. 그다음 JpaFileQuotaService 36~53번 줄의 reserve 가 나오는데 음수만 거른 뒤 예약 엔티티를 만들어 조건 없이 저장한다. 범위별 바이트 집계를 읽는 자리를 저장소 전역에서 찾으면 JpaFileQuotaService 84번과 89번 줄의 정의 둘과 postgresqlIntegrationTest 와 시험용 가짜 구현들이 나오고, 정의 파일 밖의 main 호출자는 0 줄이다. 대조로 센 acquireUpload 를 부르는 main 줄은 2 줄이다. 그 아래에 application.yml 810~832번 줄의 fileserver 설정이 실리는데 목적지의 최대 행 수와 최대 인코딩 바이트, 제공자의 루트 디렉터리와 마운트 검사만 있고 범위별 바이트 상한이 없다. 범위별 바이트 상한 이름을 fileserver 안에서 찾은 결과가 나오고, 마지막으로 승인 컨트롤러가 읽는 프로퍼티 다섯이 나오는데 인스턴스 업로드 허가 수, 직접 다운로드 허가 수, 소프트 고수위, 하드 고수위, 범위 업로드 허가 수다." caption="설계 문서의 쿼터 정의와 승인 컨트롤러의 자바독 · acquireUpload 가 실제로 검사하는 넷 · 예약이 조건 없이 저장되는 자리 · 바이트 집계를 읽는 main 호출자 0 과 대조 · 설정의 fileserver 절과 승인이 읽는 프로퍼티 다섯 — 132줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
`:46` 이 단일 파일 크기 정책을 확인하고 `:47` 이 저장소 하드 고수위를 확인한다.
|
||||
|
||||
`:48`\~`:53` 이 범위별 업로드 세마포어를 얻는다. 실패하면 `:50` 이 `QuotaExceededException` 을 던지는데 메시지는 `scope upload concurrency is exhausted` 다. 동시성이지 바이트가 아니다.
|
||||
|
||||
`:54`\~`:59` 가 인스턴스 업로드 세마포어를 얻는다. 실패하면 다른 예외다.
|
||||
|
||||
`requestedBytes` 는 `:46` 에만 쓰인다. 그 범위가 이미 얼마를 예약하고 확정했는지는 어느 검사도 묻지 않는다.
|
||||
|
||||
## 예약은 조건 없이 저장된다
|
||||
|
||||
`JpaFileQuotaService.reserve:38` 은 음수 바이트만 거른다.
|
||||
|
||||
`:43`\~`:51` 이 예약 엔티티를 만들고 `:52` 가 `reservations.save(entity)` 를 부른다. 조회도 조건절도 없다.
|
||||
|
||||
## 집계는 계산되지만 아무도 읽지 않는다
|
||||
|
||||
`:84` 의 `reservedBytes` 가 만료되지 않은 예약 바이트 합계를 부르고 `:89` 의 `committedBytes` 가 확정 바이트 합계를 부른다.
|
||||
|
||||
그 둘을 부르는 자리를 저장소 전역에서 찾으면 정의 파일 밖의 main 호출자가 0 줄이다. 나머지는 `PostgreSqlFileserverMetadataStoreIntegrationTest` 와 `PostgreSqlFileserverReclamationIntegrationTest` 의 단언, 그리고 `FakeFileQuotaService` 같은 시험용 구현이다.
|
||||
|
||||
같은 방식으로 `acquireUpload` 호출을 세면 main 2 줄이 나온다. 검색이 main 을 보고 있다.
|
||||
|
||||
즉 통합 시험은 그 합계가 맞는지 확인한다. 프로덕션 코드는 그 합계를 근거로 무엇도 거절하지 않는다.
|
||||
|
||||
## 설정에도 그 상한이 없다
|
||||
|
||||
`application.yml:810`\~`:832` 의 fileserver 절에는 목적지의 최대 행 수와 최대 인코딩 바이트, 제공자의 루트 디렉터리와 마운트 검사가 있다. 범위별 바이트 상한이 없다.
|
||||
|
||||
승인 컨트롤러가 읽는 프로퍼티는 다섯이다 — `:40` 인스턴스 업로드 허가 수, `:41` 직접 다운로드 허가 수, `:80` 소프트 고수위, `:93` 하드 고수위, `:102` 범위 업로드 허가 수.
|
||||
|
||||
앞의 둘과 마지막은 동시성이고 가운데 둘은 디스크 사용률이다. 어느 것도 테넌트나 네임스페이스의 용량이 아니다.
|
||||
|
||||
## 원문과 갈리는 자리
|
||||
|
||||
원문은 프로덕션 호출 그래프에 그 상한이 없다고 적었고 그대로다.
|
||||
|
||||
원문이 적지 않은 것은 `QuotaExceededException` 이 실제로 던져지는 자리다. 그 예외는 `:50` 에서 한 번 던져지는데 사유가 동시성 소진이다. 로그나 대시보드에서 이 예외를 바이트 초과로 읽으면 실제와 다르다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
한 범위가 실제로 큰 바이트를 쌓을 수 있는지 업로드를 돌려 확인하지 않았다.
|
||||
|
||||
이 상한을 애초에 넣었다가 뺀 이력이 있는지 추적하지 않았다.
|
||||
|
||||
디스크 사용률 상한이 범위별 상한의 대체가 되는지는 판단하지 않았다.
|
||||
|
||||
## 등급에 대해
|
||||
|
||||
원본은 이 항목을 P1 로 매겼다. 원장이 쓰이지 않는다는 사실은 이 리비전에서도 그대로이므로 등급을 새로 매기지 않는다.
|
||||
|
||||
<!-- body:end -->
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: analysis-finding-a05-f024
|
||||
title: 회수가 페이지 하나를 다 쓰면 남은 바이트를 들고 그대로 끝난다
|
||||
topic: multitenancy-isolation
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:analysis-finding-a05-f024
|
||||
evidenceCapturedOn: 2026-09-04
|
||||
body: case-analysis-finding-a05-f024.body.md
|
||||
assets:
|
||||
- key: analysis-finding-a05-f024
|
||||
file: ../../../final/evidence/rendered/analysis-finding-a05-f024.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/analysis-finding-a05-f024.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/05-adapter-outbound-persistence-jpa.md §24 이다.
|
||||
---
|
||||
|
||||
# 회수가 페이지 하나를 다 쓰면 남은 바이트를 들고 그대로 끝난다
|
||||
|
||||
`JpaQuotaReclaimGateway:53`~`:54` 는 확정 행을 `Limit.of(64)` 로 한 번 조회하고 그 목록만 순회한다. 프로브에서 1 바이트 행 200 개에 200 바이트 회수를 요청했더니 차감이 64 회에 그쳤고 나머지 136 은 어디에도 보고되지 않았다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **쿼터 원장은 사용량을 적기만 하고 승인은 그것을 읽지 않는다**
|
||||
같은 원장의 다른 결함이다. 그쪽은 기록된 값을 아무도 읽지 않는 것이고, 여기는 그 값이 실제보다 커지는 것이다.
|
||||
- **빠뜨림이 통과가 되는 게이트는 게이트가 아니다**
|
||||
같은 계열이다. 남은 작업이 있는데 아무 신호 없이 정상 종료한다.
|
||||
- **모르는 것은 성공도 실패도 아닌 세 번째 결과여야 한다**
|
||||
회수 연산이 결과를 돌려주지 않으므로 다 했는지 못 했는지가 어디에도 표현되지 않는다.
|
||||
|
||||
## 문제
|
||||
|
||||
물리 삭제가 끝나면 정리 서비스가 회수 게이트웨이를 부른다. 그 게이트웨이가 원장에서 확정 사용량을 줄인다.
|
||||
|
||||
요청한 바이트가 여러 행에 걸쳐 있을 때 어떻게 되는지 확인했다.
|
||||
|
||||
## 결론
|
||||
|
||||
:33 이 페이지 상한을 64 로 둔다. :55~:61 이 그 한 페이지를 순회하며 차감하고, 마지막 원소를 지나면 루프가 그대로 빠져나온다. 재조회도 남은 값 반환도 로그도 없다.
|
||||
|
||||
저장소 클래스를 그대로 쓰고 리포지터리만 프록시로 대신해 돌렸다. 원장 행이 64 개일 때는 요청한 만큼 전부 회수된다. 65 개일 때 1 이 남고, 200 개일 때 136 이 남는다. 남는 양은 페이지를 넘는 행 수만큼 늘어난다.
|
||||
|
||||
클래스 자바독 :25~:27 은 나머지를 버리는 이유를 적어 두었다. 원장에 흡수할 데가 없을 때의 이야기이고, 그런 상황은 총량이 이미 잘못 적혔다는 신호라는 것이다.
|
||||
|
||||
프로브가 만든 나머지는 종류가 다르다. 원장에는 아직 뺄 데가 넉넉한데 한 페이지만 보고 끝냈기 때문이다. 자바독이 허용한 나머지와 상한이 만든 나머지를 코드가 구분하지 않는다.
|
||||
|
||||
이 메서드는 아무것도 돌려주지 않는다. 정리 서비스가 회수 결과를 물을 자리가 애초에 없다.
|
||||
|
||||
이 경계를 겨냥한 시험도 없다. 페이지 상한과 그 바로 위아래 값을 쓰는 시험 줄이 모두 0 개다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
Gradle : 9.0.0
|
||||
확인 방식 : 게이트웨이 전문 게재와 클래스 자바독 인용, 페이지 질의와 차감 질의의 정의와 그 자바독 인용, 회수 호출자 전수, 저장소 게이트웨이를 그대로 쓰고 리포지터리만 프록시로 대신해 원장 행 수와 요청 바이트를 바꿔 가며 페이지 질의 횟수와 차감 횟수와 남은 바이트를 관측, 경계 값을 쓰는 시험 줄 계수와 이름에 회수가 든 파일 나열
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 게이트웨이를 전문으로 싣고 클래스 자바독을 함께 읽는다.
|
||||
2. 페이지 질의와 차감 질의의 정의를 인용한다.
|
||||
3. 회수를 부르는 자리를 전부 찾는다.
|
||||
4. 리포지터리 인터페이스를 프록시로 바꿔 끼우고, 질의가 오면 원장에 넣어 둔 행을 상한까지 잘라 돌려주게 한다.
|
||||
5. 원장 행 수와 요청 바이트를 64·65·200 으로 바꿔 가며 게이트웨이를 부르고 페이지 질의 횟수와 차감 횟수와 남은 바이트를 적는다.
|
||||
6. 64 와 65 와 페이지 상한 상수를 쓰는 시험 줄을 센다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`JpaQuotaReclaimGateway` 는 물리 삭제가 끝난 뒤 원장의 확정 사용량을 줄인다. 정리 서비스가 삭제한 바이트 수를 넘겨 부른다.
|
||||
|
||||
## 게이트웨이는 확정 행을 한 번만 조회한다
|
||||
|
||||
:::evidence key="analysis-finding-a05-f024" alt="저장소 루트에서 돌린 정적 검색과 프로브의 출력 129줄. 먼저 JpaQuotaReclaimGateway 64줄이 전문으로 실린다. 25~27번 줄 클래스 자바독은 어떤 행도 흡수하지 못하는 나머지는 이월하지 않고 버린다고 적으면서, 원장의 바닥이 0 이며 기록된 총량을 넘어서는 회수는 총량이 이미 과소 기록됐다는 뜻이라고 덧붙인다. 33번 줄이 RECLAIM_PAGE 를 Limit.of(64) 로 두고, 44~63번 줄의 reclaim 이 51번에서 시각을 얻고 52번에서 outstanding 을 요청 바이트로 두며 53~54번에서 확정 행을 한 번 조회해 그 결과를 순회한다. 55~57번은 outstanding 이 0 이면 돌아가고, 58번이 차감량을 정하고 59~61번이 갱신 건수가 1 일 때만 뺀다. 루프가 끝나면 62~63번에서 메서드가 그대로 끝난다. 이어서 그 한 번의 질의가 무엇을 돌려주는지 리포지터리 106~117번 줄로 나오는데, 확정 상태이고 바이트가 남은 행을 갱신 시각 내림차순으로 상한만큼 가져온다. 119~139번 줄의 차감 질의는 자바독에서 그 가드가 동시 회수를 안전하게 만든다고 적는다. 회수를 부르는 자리로 정리 서비스 한 곳과 시험 몇 곳이 나온다. 그다음 프로브가 실린다. 리포지터리만 프록시로 대신하고 게이트웨이는 저장소 클래스를 그대로 쓴다. 1바이트 확정 행 64 개에 64 바이트를 요청하면 페이지 질의 1 회에 차감 64 회로 남는 바이트가 0 이다. 65 개에 65 바이트를 요청하면 페이지 질의는 그대로 1 회이고 차감이 64 회에서 멈춰 1 바이트가 남는다. 200 개에 200 바이트를 요청하면 차감이 64 회이고 136 바이트가 남는다. 마지막으로 그 경계를 짚는 시험을 세면 64 와 65 와 RECLAIM_PAGE 를 쓰는 시험 줄이 각각 0 개이고, 이름에 Reclaim 이 든 파일은 게이트웨이와 포트와 시험용 기록기 셋뿐이다." caption="게이트웨이 64줄 전문과 그 클래스 자바독 · 한 번의 페이지 질의가 돌려주는 것과 차감 질의의 가드 · 회수 호출자 · 저장소 게이트웨이를 그대로 부른 세 경우 · 그 경계를 짚는 시험의 부재 — 129줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
`:33` 이 `RECLAIM_PAGE` 를 `Limit.of(64)` 로 둔다.
|
||||
|
||||
`reclaim:44` 은 음수와 0 을 먼저 걸러 낸다. `:52` 가 `outstanding` 을 요청 바이트로 두고, `:53`\~`:54` 가 그 상한으로 확정 행을 조회해 향상된 for 문으로 순회한다.
|
||||
|
||||
루프 안에서 `:55` 가 `outstanding` 이 0 이면 돌아가고, `:58` 이 차감량을 `Math.min` 으로 정하고, `:59` 가 갱신 건수가 1 일 때만 `:60` 에서 뺀다.
|
||||
|
||||
목록이 끝나면 `:62`\~`:63` 에서 메서드가 끝난다. `outstanding` 이 얼마든 상관없다.
|
||||
|
||||
## 그 한 번의 질의가 무엇을 돌려주는가
|
||||
|
||||
`FileserverQuotaRepository:107`\~`:117` 은 확정 상태이고 확정 바이트가 남은 행을 갱신 시각 내림차순으로 상한만큼 가져온다.
|
||||
|
||||
`:126`\~`:139` 의 차감 질의는 조건부 갱신이다. `:122`\~`:124` 자바독은 그 가드가 동시 회수를 안전하게 만든다고 적는다 — 다른 회수가 이미 낮춰 놓은 행은 0 행을 갱신하고 호출자가 다음 행으로 넘어간다는 것이다.
|
||||
|
||||
그 설계는 한 페이지 안에서만 성립한다. 다음 행이 페이지 밖에 있으면 넘어갈 곳이 없다.
|
||||
|
||||
## 저장소 게이트웨이를 그대로 부른 결과
|
||||
|
||||
리포지터리 인터페이스만 프록시로 대신했다. 프록시는 요청한 만큼의 1 바이트 확정 행을 만들어 페이지 상한까지 돌려주고 차감 호출을 센다. 게이트웨이는 저장소 클래스 그대로다.
|
||||
|
||||
1 바이트 행 64 개에 64 바이트를 요청하면 페이지 질의 1 회에 차감 64 회이고 남는 바이트가 0 이다.
|
||||
|
||||
65 개에 65 바이트를 요청하면 페이지 질의는 여전히 1 회이고 차감이 64 회에서 멈춘다. 1 바이트가 남는다.
|
||||
|
||||
200 개에 200 바이트를 요청하면 차감이 64 회이고 136 바이트가 남는다. 남는 양은 원장에 쌓인 행 수에 따라 늘어난다.
|
||||
|
||||
## 자바독이 정당화한 나머지는 다른 나머지다
|
||||
|
||||
`:25`\~`:27` 은 나머지를 이월하지 않는 근거를 적는다. 어떤 행도 흡수하지 못하는 나머지라면 원장의 총량이 이미 과소 기록됐다는 뜻이고, 음수 잔액이 그것을 고쳐 주지는 않는다는 것이다.
|
||||
|
||||
프로브가 만든 나머지는 그 경우가 아니다. 흡수할 행이 원장에 그대로 남아 있는데 페이지 상한이 그것을 보지 못하게 했다.
|
||||
|
||||
코드는 두 나머지를 구분하지 않는다. 둘 다 같은 자리에서 조용히 끝난다.
|
||||
|
||||
## 남았다는 사실을 담을 자리가 없다
|
||||
|
||||
`reclaim` 의 반환형은 `void` 다. 호출자는 요청한 바이트가 전부 회수됐는지 알 방법이 없다.
|
||||
|
||||
64 와 65 와 `RECLAIM_PAGE` 를 쓰는 시험 줄은 각각 0 개다. 이름에 회수가 든 파일은 게이트웨이와 포트와 시험용 기록기 셋이다.
|
||||
|
||||
## 원문과 갈리는 자리
|
||||
|
||||
원문은 65 개 행에 65 바이트를 요청하면 1 이 남는다고 적었고 프로브가 그대로 재현한다. 원문은 실제 PostgreSQL 로 확인했고 이 기록은 리포지터리를 프록시로 대신했다.
|
||||
|
||||
원문이 적지 않은 것이 둘이다. 남는 양이 원장 행 수에 비례해 커진다는 것과, 클래스 자바독이 정당화한 나머지가 페이지 상한이 만드는 나머지와 다른 것이라는 점이다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
데이터베이스를 띄워 확인하지는 않았다. 프록시가 돌려준 목록으로 루프의 종료 조건만 봤다.
|
||||
|
||||
운영 중인 원장에서 한 범위가 64 행을 넘기는 일이 있는지 세지 않았다.
|
||||
|
||||
차감 질의가 0 행을 갱신해 건너뛰는 경우와 페이지 상한이 겹치면 남는 바이트가 더 늘어나는지 시험하지 않았다.
|
||||
|
||||
실제 PostgreSQL 로 돌리지 않았다. 리포지터리를 프록시로 대신해 게이트웨이의 루프만 관측했다.
|
||||
|
||||
차감 질의가 0 행을 갱신해 건너뛰는 경우와 페이지 상한이 겹치면 남는 바이트가 더 늘어나는지 시험하지 않았다.
|
||||
|
||||
<!-- body:end -->
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: analysis-finding-a05-f028
|
||||
title: 제공자 제출 뒤의 다섯 쓰기 가운데 결과 기록만 조건이 없다
|
||||
topic: multitenancy-isolation
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:analysis-finding-a05-f028
|
||||
evidenceCapturedOn: 2026-09-04
|
||||
body: case-analysis-finding-a05-f028.body.md
|
||||
assets:
|
||||
- key: analysis-finding-a05-f028
|
||||
file: ../../../final/evidence/rendered/analysis-finding-a05-f028.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/analysis-finding-a05-f028.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/05-adapter-outbound-persistence-jpa.md §89 이다.
|
||||
---
|
||||
|
||||
# 제공자 제출 뒤의 다섯 쓰기 가운데 결과 기록만 조건이 없다
|
||||
|
||||
제공자 제출은 `NotificationDispatchService:169` 이고 그 뒤에 남는 쓰기는 다섯이다. `applyNextAction` 의 네 분기는 리스를 `where` 절에 넣는 연산을 쓰고, `DispatchOutcomeRecorder:135` 만 조건 없는 `save` 를 쓴다. 그 클래스는 리스를 받지도 않는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **fenced lease — 만료 시각만으로는 부족한 이유**
|
||||
그 개념이 정의하는 펜스 값이 여기서 `where` 절의 세 번째 조건이다. `RecipientDeliveryJpaRepository:80` 과 `:105` 가 `id` 와 `lease_owner` 와 `lease_fence` 를 함께 요구한다.
|
||||
- **native claim이 @Version을 올리지 않아 충돌을 보고하지 않는 낙관적 잠금이 됐다**
|
||||
그 기록의 네이티브 청구문과 이 기록의 포트 자바독이 같은 이유를 든다. `version` 이 잡는 것은 동시 편집이지 밀려난 작업자가 아니다.
|
||||
- **조건부 update로 행을 claim하고 읽은 값으로 판단하지 않는다**
|
||||
펜싱하는 쪽이 지키는 규칙이다. `RecipientDeliveryJpaRepository:82` 의 `saveProjectionHeldBy` 가 소유자와 펜스를 `where` 에 넣고 `int` 갱신 건수를 돌려주며, 포트의 `saveHeldBy` 는 그 건수를 `Optional` 로 옮긴다.
|
||||
|
||||
## 문제
|
||||
|
||||
리스를 붙든 작업자가 제공자에 제출하는 동안 리스가 만료되면, 그 작업은 다른 작업자에게 넘어간다. 밀려난 작업자가 돌아와 쓰는 것을 막는 것이 펜싱이다.
|
||||
|
||||
제출 뒤에 남은 쓰기가 그것을 쓰는지 확인했다.
|
||||
|
||||
## 결론
|
||||
|
||||
포트는 다섯 쓰기를 선언한다. save 와 transition 과 saveHeldBy 와 transitionHeldBy 와 renewLease 다. 뒤의 셋만 RecipientLease 를 인자로 받고 Optional 을 돌려준다.
|
||||
|
||||
구현은 그 자바독대로다. JpaRecipientDeliveryStore:69 의 saveHeldBy 가 RecipientDeliveryJpaRepository:82 의 saveProjectionHeldBy 를 부르고, 그 갱신문의 where 절 :80 이 id 와 lease_owner 와 lease_fence 를 모두 요구한다.
|
||||
|
||||
stillHeld 검사는 :161 이라 제출 앞이다. 제출 뒤의 쓰기는 두 갈래로 나뉜다.
|
||||
|
||||
한쪽은 :176 이 부르는 applyNextAction 이다. 그 안의 switch 는 분기가 넷인데 :422·:428·:434·:436 이 모두 펜싱 연산이라, 어느 쪽으로 가도 리스가 조건에 들어간다.
|
||||
|
||||
다른 쪽은 :173 이 부르는 DispatchOutcomeRecorder.record 이고 별도 파일에 있다. 그 서명에 RecipientLease 가 없으며 :135 가 recipients.save(updated) 다.
|
||||
|
||||
그 save 가 덮는 것은 소유권이 아니다. JpaRecipientDeliveryStore:39 가 넘기는 값이 전부 투영 필드라, delivery_state 부터 next_dispatch_at 까지 열 개 컬럼이 밀려난 작업자의 값으로 바뀐다.
|
||||
|
||||
펜싱 갱신문 자체도 절반이다. :80 과 :105 는 소유자와 펜스만 보고 lease_until 을 보지 않는다. renewLease:134 와 :154 는 본다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
확인 방식 : 포트가 선언한 다섯 쓰기와 saveHeldBy 자바독 인용, 펜싱 갱신문과 세 where 절 대조, 조건 없는 save 가 거치는 구현과 applyProjection 인자 전수, 결과 쓰기 자리 인용, 연산별 호출자 전수, stillHeld 검사와 제출과 기록의 순서 인용, applyNextAction 자바독과 switch 네 분기 인용, 펜싱 연산이 나오는 시험 자리 전수
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 포트가 선언한 쓰기를 전부 뽑고 saveHeldBy 자바독을 인용한다.
|
||||
2. 펜싱 갱신문을 인용하고 세 갱신문의 where 절을 나란히 놓는다.
|
||||
3. 조건 없는 save 가 거치는 구현과 그것이 부르는 applyProjection 의 인자를 전부 나열한다.
|
||||
4. 결과를 쓰는 메서드를 인용한다.
|
||||
5. 다섯 연산 각각의 호출자를 저장소 전체에서 찾는다.
|
||||
6. stillHeld 검사와 제출과 기록의 순서를 인용하고, applyNextAction 자바독과 switch 를 함께 싣는다.
|
||||
7. saveHeldBy 와 transitionHeldBy 가 나오는 시험 파일과 줄을 전부 나열한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`RecipientDeliveryStorePort` 는 배달 행을 쓰는 다섯 연산을 선언한다. 그중 셋만 리스를 인자로 받는다.
|
||||
|
||||
## 포트가 이 사고를 자기 자바독에 적어 두었다
|
||||
|
||||
:::evidence key="analysis-finding-a05-f028" alt="저장소 루트에서 돌린 정적 검색 출력 250줄. 먼저 RecipientDeliveryStorePort 가 선언한 다섯 쓰기가 나오고 21~36번 줄의 saveHeldBy 자바독이 실린다. 그 자바독은 청구도 갱신도 펜싱되는데 완료만 한동안 그렇지 않았다고 적고, 제공자 호출 중에 리스가 만료된 작업자가 돌아와 조건 없는 save 로 새 소유자가 이미 청구한 행 위에 썼다고 적으며, 낙관적 version 컬럼은 동시 편집을 잡지 밀려난 작업자를 잡지 못한다고 적는다. 이어서 RecipientDeliveryJpaRepository 56~82번 줄이 실리는데 56~65번 자바독이 이 갱신문이 제공자 호출 뒤의 쓰기를 위한 것이며 제출 이전의 작업은 새 소유자가 다시 하면 그만이지만 결과는 그렇지 않다고 적고, 80번 where 절이 id 와 lease_owner 와 lease_fence 를 모두 요구한다. 그다음 세 갱신문의 where 절이 나란히 실린다. saveProjectionHeldBy 의 80번과 transitionHeldBy 의 105번은 세 조건뿐이고, renewLease 의 127~134번과 153~154번에만 lease_until 이 now 보다 크다는 조건이 붙는다. 이어서 조건 없는 save 가 거치는 구현이 나온다. JpaRecipientDeliveryStore 32~52번이 findById 로 엔티티를 읽어 applyProjection 을 부르고 saveAndFlush 하는데, RecipientDeliveryEntity 158~182번의 applyProjection 이 받는 인자 열하나는 deliveryState 와 submissionOutcome 과 deliveryOutcome 과 evidenceLevel 과 두 불린과 routeCursor 와 attemptCount 와 lastFailureCategory 와 nextDispatchAt 과 시각이고 리스 필드가 하나도 없다. 그 아래에 DispatchOutcomeRecorder 14~19번 클래스 자바독과 108~136번 결과 쓰기가 나오는데 109~134번이 새 RecipientDeliveryRecord 를 만들고 135번이 recipients.save 를 부른다. 다음으로 같은 흐름의 쓰기가 연산별로 나열된다. save 는 DispatchOutcomeRecorder 135번 하나이고, transition 은 NotificationAdminApplicationService 121번과 NotificationDispatchService 139번·396번과 NotificationSubmissionService 305번과 ReconciliationService 108번 다섯이며, saveHeldBy 는 NotificationDispatchService 434번, transitionHeldBy 는 같은 파일 422번·428번·436번이다. 그다음 NotificationDispatchService 158~178번 줄이 실려 161번이 leases.stillHeld 를 확인하고 162~164번 주석이 그 검사가 부수 효과 직전이며 그것을 막을 수 있는 마지막 순간이라고 적으며, 168~169번이 트랜잭션 밖에서 제출하고 171~173번이 그 뒤에 레코더를 부르고 176번이 applyNextAction 을 부른다. 이어서 398~412번 줄이 실리는데 401~411번 자바독이 applyNextAction 의 모든 쓰기가 제공자 호출 뒤에 일어나고 그것이 플랫폼이 일부러 트랜잭션 밖에서 시간을 쓰는 유일한 구간이며 그 사이에 리스가 만료돼 다른 작업자가 일을 가져갈 수 있고 펜싱된 변형은 그런 쓰기가 아무것도 맞지 않게 만든다고 적고, 리스를 잃은 것은 보고할 오류가 아니라고도 적는다. 그 아래 417~445번이 그 메서드의 switch 인데 RetryAfter 와 Reconcile 과 Stop 분기가 transitionHeldBy 를, Fallback 분기가 saveHeldBy 를 부르고 마지막에 refreshStatus 가 분기와 무관하게 돈다. 마지막으로 파일명에 Fenc 나 Lease 가 든 시험 파일이 열한 개, DispatchOutcomeRecorder 를 이름에 가진 시험 파일이 0 개라고 나오고, 펜싱 연산 이름이 나오는 시험 자리 여섯이 실린다. LeaseRecoveryServiceTest 306·313번과 PlatformFakes 263·272번은 시험 대역이 그 메서드를 구현하는 선언이고, 실제로 부르는 것은 postgresqlIntegrationTest 소스 세트의 PostgreSqlRecipientLeaseFencingIntegrationTest 93번과 127번이다." caption="포트의 다섯 쓰기와 saveHeldBy 자바독 · 펜싱 갱신문과 세 where 절 대조 · 조건 없는 save 가 덮는 투영 컬럼 · 결과 쓰기 자리 · 연산별 호출자 · stillHeld 검사와 제출과 기록의 순서 · applyNextAction 자바독과 switch 네 분기 · 펜싱 연산이 나오는 시험 자리 — 250줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
`:21`\~`:36` 자바독에는 청구와 갱신은 펜싱되는데 완료만 한동안 그렇지 않았다고 적혀 있다. 제공자 호출 중에 리스가 만료된 작업자가 돌아와 조건 없는 `save` 로, 새 소유자가 이미 청구하고 어쩌면 이미 발송한 행 위에 썼다는 것도 같은 자리에 있다.
|
||||
|
||||
낙관적 `version` 컬럼은 동시 편집을 잡지, 밀려난 작업자를 잡지 못한다. 늦은 작업자의 읽기는 이기기에 충분할 만큼 최근이었다.
|
||||
|
||||
`saveHeldBy` 와 `transitionHeldBy` 는 둘 다 `Optional` 을 돌려준다. 리스가 밀려났으면 아무것도 쓰지 않은 채 빈 `Optional` 이 나온다.
|
||||
|
||||
## 펜싱 갱신문이 where 절에 넣는 것
|
||||
|
||||
`RecipientDeliveryJpaRepository:80` 의 `where` 절이 `id = :id AND lease_owner = :owner AND lease_fence = :fence` 다. 셋이 모두 맞아야 한 행이 갱신된다.
|
||||
|
||||
그 위 `:57`\~`:62` 자바독은 이 갱신문이 어느 자리를 위한 것인지 적는다. 제공자 호출 *뒤에* 일어나는 쓰기이고, 제출 이전의 작업은 새 소유자가 다시 하면 그만이지만 결과는 그렇지 않다는 것이다. 밀려난 리스로 결과를 쓰면 한 작업자의 결과가 다른 작업자의 시도에 보고되고, 둘이 발송 여부에 대해 같은 답을 가질 이유가 없다.
|
||||
|
||||
세 갱신문의 `where` 절을 나란히 놓으면 하나가 더 보인다. `saveProjectionHeldBy:80` 과 `transitionHeldBy:105` 는 소유자와 펜스만 본다. `lease_until > :now` 조건은 `renewLease:134` 와 `:154` 에만 있다. 아직 아무도 가져가지 않은 만료된 리스로는 앞의 둘이 성공한다.
|
||||
|
||||
## applyNextAction 은 네 분기 모두 펜싱을 쓴다
|
||||
|
||||
`:176` 이 부르는 `applyNextAction:412` 은 `RetryDecision` 을 `switch` 로 가른다. `RetryAfter` 와 `Reconcile` 과 `Stop` 세 분기가 `transitionHeldBy`(`:422`·`:428`·`:436`), `Fallback` 분기가 `saveHeldBy`(`:434`)를 부른다. 한 번 실행에 한 분기만 돌고, 어느 쪽이든 리스가 조건에 들어간다.
|
||||
|
||||
그 메서드의 자바독 `:402`\~`:407` 이 이유를 적는다. 여기의 모든 쓰기가 제공자 호출 뒤에 일어나고, 그 제출이 이 플랫폼이 일부러 트랜잭션 밖에서 시간을 쓰는 유일한 구간이며, 그 사이에 리스가 만료돼 다른 작업자가 일을 가져갈 수 있다는 것이다. 펜싱된 변형은 그런 쓰기가 아무 행에도 맞지 않게 만든다.
|
||||
|
||||
`:409`\~`:410` 은 리스를 잃은 것이 보고할 오류가 아니라고도 적는다. 새 소유자가 자기 결과를 기록할 것이고, 밀려난 작업자에게 남은 의무는 멈추는 것뿐이다.
|
||||
|
||||
`:444` 의 `refreshStatus(work)` 는 그 `switch` 밖이라 갱신 건수와 무관하게 돈다.
|
||||
|
||||
## 결과 기록은 다른 클래스에 있고 리스를 받지 않는다
|
||||
|
||||
`recipients.save` 를 부르는 main 자리는 `DispatchOutcomeRecorder:135` 하나다.
|
||||
|
||||
그 클래스는 `NotificationDispatchService` 가 아니라 별도 파일이고, `record` 의 인자에 `RecipientLease` 가 없다. `:109`\~`:134` 가 새 `RecipientDeliveryRecord` 를 만들고 `:135` 가 그것을 저장한다.
|
||||
|
||||
그 `save` 가 무엇을 덮는지는 구현에 있다. `JpaRecipientDeliveryStore:33`\~`:52` 가 `findById` 로 행을 읽어 `applyProjection` 을 부르고 `saveAndFlush` 한다. `RecipientDeliveryEntity:159`\~`:170` 의 `applyProjection` 이 받는 인자 열하나에 리스 필드가 없다. 그래서 늦은 쓰기가 소유권을 빼앗지는 않는다. 덮이는 것은 `delivery_state`·`submission_outcome`·`delivery_outcome`·`evidence_level`·`ambiguous_attempt_exists`·`duplicate_risk`·`route_cursor`·`attempt_count`·`last_failure_category`·`next_dispatch_at` 이다. 새 소유자의 행에 밀려난 작업자의 결과가 실린다.
|
||||
|
||||
`findById` 가 같은 쓰기 트랜잭션 안에서 도는 신선한 읽기라, `@Version` 이 견줄 값은 이미 현재값이다.
|
||||
|
||||
펜싱 없는 `transition` 은 다섯 자리 더 있다. `NotificationAdminApplicationService:121`, `NotificationDispatchService:139` 와 `:396`, `NotificationSubmissionService:305`, `ReconciliationService:108` 이다. `:139` 와 `:396` 은 제출 이전 경로이고 나머지 셋은 관리와 제출과 재조정 흐름이라 이 구간 밖이다.
|
||||
|
||||
## stillHeld 검사는 제출 앞에 있고, 결과 쓰기는 제출 뒤에 있다
|
||||
|
||||
`:161` 이 `leases.stillHeld` 를 확인한다. `:162`\~`:164` 주석이 이 검사가 부수 효과 직전이며 그것을 막을 수 있는 마지막 순간이라고 적는다.
|
||||
|
||||
`:168`\~`:169` 가 트랜잭션 밖에서 제공자에 제출한다. `:171`\~`:173` 이 그 뒤에 레코더를 부른다.
|
||||
|
||||
검사와 제출 사이가 아니라 제출과 기록 사이에 리스가 만료되면, `:161` 은 이미 지난 뒤다.
|
||||
|
||||
## DispatchOutcomeRecorder 를 다루는 시험이 없다
|
||||
|
||||
파일명에 `Fenc` 나 `Lease` 가 든 시험 파일은 열하나다. `DispatchOutcomeRecorder` 를 이름에 가진 시험 파일은 0 개다.
|
||||
|
||||
펜싱 연산 이름이 나오는 시험 자리는 여섯인데 그중 넷은 시험 대역이 그 메서드를 구현하는 선언이다. `LeaseRecoveryServiceTest:306`·`:313` 과 `PlatformFakes:263`·`:272` 다.
|
||||
|
||||
실제로 그 연산을 부르는 것은 `postgresqlIntegrationTest` 소스 세트의 `PostgreSqlRecipientLeaseFencingIntegrationTest:93` 과 `:127` 두 자리이고, 둘 다 `transitionHeldBy` 다.
|
||||
|
||||
## 원문에 없는 것
|
||||
|
||||
원문은 제공자 호출 뒤의 투영 쓰기가 펜싱을 우회한다고 적는다. 여기에 더한 것은 그 우회가 어디서 갈리는지와 대가가 정확히 무엇인지다.
|
||||
|
||||
갈리는 지점은 메서드다. `applyNextAction` 의 네 분기는 모두 펜싱을 쓰고, 별도 클래스에 있는 `record` 만 쓰지 않는다. 대가는 소유권이 아니다. `applyProjection` 에 리스 인자가 없어서 덮이는 것은 투영 컬럼 열이다. 그리고 펜싱 갱신문 둘도 `lease_until` 을 보지 않으므로, 펜싱으로 바꾸는 것만으로 만료 구간이 전부 닫히지는 않는다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
이 결함이 실제 행 위에서 일어나는 것을 데이터베이스로 보지 않았다. 필요한 스키마가 다른 마이그레이션 집합의 테이블을 참조해 단독으로 세워지지 않는다.
|
||||
|
||||
낙관적 잠금이 이 쓰기를 걸러 내지 못한다는 판단은 코드 경로를 읽어 내린 것이다. 실행으로 확인하지 않았다.
|
||||
|
||||
만료됐지만 아직 아무도 가져가지 않은 리스로 두 갱신문이 통과하는 시간이 얼마나 되는지 재지 않았다.
|
||||
|
||||
밀려난 작업자의 늦은 `save` 가 새 소유자의 행을 덮는 장면을 데이터베이스로 재현하지 않았다. V1 이 `capability_schema_registry` 를 읽는데 그 테이블은 다른 마이그레이션 계열에 있어서, 알림 플랫폼 마이그레이션만 빈 데이터베이스에 올리면 거기서 멈춘다.
|
||||
|
||||
<!-- body:end -->
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: analysis-finding-a05-f030
|
||||
title: claim 이 넣은 CLAIMED 행을 완료로 바꾸는 코드가 없다
|
||||
topic: multitenancy-isolation
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:analysis-finding-a05-f030
|
||||
evidenceCapturedOn: 2026-09-04
|
||||
body: case-analysis-finding-a05-f030.body.md
|
||||
assets:
|
||||
- key: analysis-finding-a05-f030
|
||||
file: ../../../final/evidence/rendered/analysis-finding-a05-f030.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/analysis-finding-a05-f030.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/05-adapter-outbound-persistence-jpa.md §91 이다.
|
||||
---
|
||||
|
||||
# claim 이 넣은 CLAIMED 행을 완료로 바꾸는 코드가 없다
|
||||
|
||||
`JpaAdminOperationStore.claim:40` 이 `AdminAuditJpaRepository.claimOperation:41` 을 부르고, 그 native INSERT `:34`~`:39` 가 `phase` 자리에 `'CLAIMED'` 를 넣는다. 그 행을 `COMPLETED` 로 옮기는 프로덕션 코드는 0 줄이고, `save:96` 은 새 식별자로 두 번째 행을 만들려다 유일성 제약에서 멈춘다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **네 답을 주는 claim 이 있는데 서비스는 있음·없음 두 갈래로 판단한다**
|
||||
`NotificationAdminApplicationService` 는 `claim` 을 한 번도 부르지 않는다. 이 기록은 불러도 그 뒤가 이어지지 않는다는 것을 저장소 계층에서 확인했다.
|
||||
- **조건부 update로 행을 claim하고 읽은 값으로 판단하지 않는다**
|
||||
이 청구가 구현하는 규칙이다. 삽입 건수를 답으로 쓰는 부분까지는 그 규칙대로다.
|
||||
- **@Bean이 있다는 것은 조립 증거가 아니다**
|
||||
main 참조 0 이 강한 신호라는 규칙이다. `AdminOperationClaim` 을 참조하는 프로덕션 파일이 포트와 JPA 구현뿐이라 청구 연산은 후보로만 남아 있다.
|
||||
|
||||
## 문제
|
||||
|
||||
V8 마이그레이션은 조회 후 실행 후 저장이 낳은 사고를 헤더에 적고, 청구를 삽입 자체로 바꿨다.
|
||||
|
||||
그 설계가 청구부터 완료까지 이어지는지 확인했다.
|
||||
|
||||
## 결론
|
||||
|
||||
마이그레이션이 세 컬럼을 더한다. command_fingerprint 와 phase 와 claimed_at 이고 phase 의 기본값은 COMPLETED 다. operation_id 의 유일성 제약 uk_notification_admin_operation 은 V3 :61 에 이미 있었다.
|
||||
|
||||
청구는 그 설계대로다. AdminAuditJpaRepository:34~:39 가 ON CONFLICT (operation_id) DO NOTHING 을 붙인 삽입이고 phase 자리에 'CLAIMED' 를 넣는다. 삽입 건수가 1 이면 이 호출자가 붙든 것이다.
|
||||
|
||||
붙든 행을 완료로 옮기는 프로덕션 코드가 0 줄이다. UPDATE notification_admin_audit 과 SET phase 를 저장소 전체에서 세면 한 줄이 나오는데 그것도 시험이고, CHECK 제약이 알 수 없는 값을 거절하는지 보려고 넣은 것이다.
|
||||
|
||||
완료를 기록하는 save 는 앞선 행을 찾지 않는다. :98 이 만든 새 식별자로 엔티티를 하나 더 만들어 :96 이 밀어 넣으므로, 같은 operation_id 가 두 번째로 들어간다.
|
||||
|
||||
그 엔티티가 매핑하는 필드는 아홉인데 commandFingerprint 도 phase 도 claimedAt 도 그중에 없다. V8 이 더한 셋을 JPA 쪽이 아직 모른다.
|
||||
|
||||
PostgreSQL 에 두 삽입을 이어 넣어 확인했다. 두 번째가 uk_notification_admin_operation 위반으로 거절되고, 남는 행은 청구가 넣은 CLAIMED 하나다.
|
||||
|
||||
지금은 그 장면이 배포에서 나지 않는다. NotificationAdminApplicationService 가 operations.claim 을 0 줄 부르고, 대조로 센 operations.save 는 4 줄이다.
|
||||
|
||||
계약 시험의 범위도 청구까지다. @Test 다섯이 배타성과 독립성과 단계 기록과 지문 기록과 단계 제약을 확인하고, COMPLETED 는 그 파일에 한 번도 나오지 않는다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
PostgreSQL : postgres:16-alpine
|
||||
확인 방식 : V8 마이그레이션 헤더와 추가 컬럼 인용, V3 의 유일성 제약 확인, claim 과 native INSERT 와 save 본문 인용, 서비스의 청구 호출 계수를 대조와 함께 확인, 청구 타입을 참조하는 파일 전수, phase 갱신 자리 계수와 전수, 엔티티 필드 전수, 계약 시험의 메서드 이름 전수와 COMPLETED 계수, 실제 데이터베이스에 두 삽입을 이어 실행
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 마이그레이션 헤더와 그것이 더한 컬럼을 인용하고, 유일성 제약이 언제부터 있었는지 확인한다.
|
||||
2. claim 과 그것이 부르는 native INSERT 와 save 본문을 나란히 인용한다.
|
||||
3. 서비스가 청구를 부르는 줄을 세고 대조 이름으로 같은 검색을 건다.
|
||||
4. 청구 결과 타입을 참조하는 파일을 전부 찾는다.
|
||||
5. phase 를 갱신하는 자리를 프로덕션과 전체로 나눠 세고 감사 엔티티의 필드를 전부 나열한다.
|
||||
6. 계약 시험의 메서드 이름을 전부 뽑고 COMPLETED 가 나오는 줄을 센다.
|
||||
7. postgres:16-alpine 에 V3 와 V8 의 해당 DDL 을 적용하고 두 삽입을 이어 실행한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
V8 마이그레이션은 관리 연산의 멱등 청구를 삽입 자체로 바꿨다. 헤더 주석이 그 이전에 무슨 일이 있었는지 적는다.
|
||||
|
||||
## V8 이 더한 세 컬럼과 V3 부터 있던 유일성 제약
|
||||
|
||||
:::evidence key="analysis-finding-a05-f030" alt="저장소 루트에서 돌린 정적 검색과 데이터베이스 실행 출력 182줄. 먼저 V8 마이그레이션 1~29번 줄이 실린다. 헤더 주석은 관리 경로가 조회 다음 부수 효과 다음 저장이었고 두 호출자가 모두 없음을 읽고 모두 실행했으며, operation_id 의 유일성 제약이 이미 있어 두 번째 저장은 실패했지만 그것은 두 번째 부수 효과 뒤였다고 적는다. 이제 청구가 삽입 자체이며 ON CONFLICT DO NOTHING 이 정확히 한 호출자만 행을 만들게 한다고 적고, 12~15번이 command_fingerprint 와 phase 와 claimed_at 을 더하는데 phase 의 기본값이 COMPLETED 다. 17~25번 COMMENT 가 phase 는 붙든 동안 CLAIMED 이고 끝나면 COMPLETED 이며 CLAIMED 로 남은 행은 두 번째 호출자에게 일이 끝난 것이 아니라 진행 중임을 알린다고 적는다. 27~29번이 phase 를 CLAIMED 와 COMPLETED 와 FAILED 로 제한하는 CHECK 다. 그 아래 V3 마이그레이션 61번의 uk_notification_admin_operation 유일성 제약이 나온다. 이어서 JpaAdminOperationStore 30~72번의 claim 이 실려 40번이 audits.claimOperation 을 부르고 47번이 삽입 건수 1 을 청구됨으로 판정하며, AdminAuditJpaRepository 22~47번이 그 native INSERT 인데 34~39번 문자열이 phase 자리에 'CLAIMED' 를 넣고 ON CONFLICT (operation_id) DO NOTHING 으로 끝난다. 그다음 JpaAdminOperationStore 91~108번의 save 가 실리는데 98번 ids.nextId 로 새 식별자를 만들어 AdminAuditEntity 를 새로 생성하고 96번이 saveAndFlush 하며 phase 를 지정하지 않는다. 다음으로 operations.claim 을 부르는 main 줄이 0 이고 대조로 센 operations.save 가 4 줄이며 AdminOperationClaim 을 참조하는 파일이 포트와 그 타입과 JPA 구현과 계약 시험 넷이라는 것이 나온다. 이어서 프로덕션에서 phase 컬럼을 갱신하는 자리가 0 개이고, 시험을 포함해도 그 문장이 나오는 자리는 AdminOperationClaimContractTest 110번 하나인데 'ALMOST' 를 넣어 제약을 시험하는 줄이다. AdminAuditEntity 가 가진 필드 아홉이 나열되는데 id 와 operationId 와 action 과 actorRef 와 reasonCode 와 tenantId 와 attributes 와 dryRun 과 occurredAt 이고 commandFingerprint 도 phase 도 claimedAt 도 없다. 그다음 계약 시험의 메서드가 애너테이션과 함께 나오는데 54~55번이 BeforeEach 의 migrate 이고 68번과 77번과 84번과 94번과 104번이 각각 동시 청구 중 하나만 이긴다는 것, 다른 식별자는 독립적으로 청구된다는 것, 청구가 phase 를 기록한다는 것, 청구가 지문을 기록한다는 것, CHECK 제약이 알 수 없는 단계를 거절한다는 것이다. 그 파일에서 COMPLETED 가 나오는 줄은 0 개이고 phase 를 단언하는 줄은 1 개다. 마지막으로 postgres:16-alpine 에 V3 51~62 와 V8 12~15·27~29 를 적용하고 두 삽입을 이어 실행한 결과가 나온다. 첫 번째 claimOperation 의 삽입은 조용히 통과하고, 두 번째 save 의 삽입은 uk_notification_admin_operation 유일성 제약 위반으로 거절되며 DETAIL 이 operation_id op-1 이 이미 있다고 적는다. 남은 행을 읽으면 op-1 이 phase CLAIMED 로 한 줄뿐이고 id 는 청구가 넣은 값이다." caption="V8 헤더가 적은 설계와 phase COMMENT 와 CHECK 제약 · V3 의 유일성 제약 · claim 이 부르는 native INSERT · save 가 새로 짓는 엔티티 · 청구 호출 0 과 대조 4 · 프로덕션의 phase 갱신 0 과 엔티티 필드 아홉 · 계약 시험 다섯과 BeforeEach · 두 삽입을 이어 실행한 결과 — 182줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
헤더는 관리 경로가 조회 다음 부수 효과 다음 저장이었다고 적는다. 두 호출자가 모두 "없음" 을 읽고 모두 실행했으며, `operation_id` 의 유일성 제약이 이미 있어 두 번째 저장은 실패했지만 그때는 두 번째 부수 효과가 이미 일어난 뒤였다.
|
||||
|
||||
그래서 청구를 삽입으로 바꿨다. `ON CONFLICT DO NOTHING` 이 정확히 한 호출자만 행을 만들게 하고 나머지는 그 호출자가 무엇을 하는지 읽는다.
|
||||
|
||||
`:12`\~`:15` 가 컬럼 셋을 더한다. `command_fingerprint` 와 `phase` 와 `claimed_at` 이다. `phase` 의 기본값은 `COMPLETED` 인데, 기존 행이 전부 완료된 것이기 때문이다.
|
||||
|
||||
`:17`\~`:25` 의 `COMMENT` 가 그 컬럼의 계약을 적는다. 붙든 동안 `CLAIMED` 이고 끝나면 `COMPLETED` 이며, `CLAIMED` 로 남은 행은 두 번째 호출자에게 일이 끝난 것이 아니라 진행 중이라고 알린다는 것이다.
|
||||
|
||||
## claim 은 CLAIMED 행을 넣는다
|
||||
|
||||
`JpaAdminOperationStore:40` 이 `audits.claimOperation` 을 부른다.
|
||||
|
||||
그 메서드는 `AdminAuditJpaRepository:34`\~`:39` 의 native INSERT 다. 컬럼 목록에 `command_fingerprint` 와 `phase` 와 `claimed_at` 이 들어가고, `VALUES` 의 `phase` 자리에 리터럴 `'CLAIMED'` 가 놓이며, 마지막이 `ON CONFLICT (operation_id) DO NOTHING` 이다.
|
||||
|
||||
`JpaAdminOperationStore:47` 이 삽입 건수가 1 일 때 청구됨을 돌려준다. 아니면 기존 행을 읽어 재생이나 진행 중이나 충돌로 나눈다.
|
||||
|
||||
## 그 행을 COMPLETED 로 옮기는 코드가 없다
|
||||
|
||||
`UPDATE notification_admin_audit` 이나 `SET phase` 가 나오는 프로덕션 줄이 0 이다.
|
||||
|
||||
시험까지 넣어도 그 문장이 나오는 자리는 `AdminOperationClaimContractTest:110` 하나인데, `'ALMOST'` 를 넣어 CHECK 제약이 거절하는지 보는 줄이다.
|
||||
|
||||
`JpaAdminOperationStore:98` 의 `save` 는 `ids.nextId()` 로 새 식별자를 만들고 `AdminAuditEntity` 를 새로 생성한다. `:96` 이 그것을 `saveAndFlush` 한다. 앞선 행을 찾지도 갱신하지도 않는다.
|
||||
|
||||
그 엔티티에는 마이그레이션이 더한 세 컬럼에 대응하는 필드가 없다. `id`·`operationId`·`action`·`actorRef`·`reasonCode`·`tenantId`·`attributes`·`dryRun`·`occurredAt` 아홉이다.
|
||||
|
||||
## 두 삽입을 이어 넣으면 두 번째가 거절된다
|
||||
|
||||
`postgres:16-alpine` 에 V3 `:51`\~`:62` 의 테이블과 V8 `:12`\~`:15`·`:27`\~`:29` 를 그대로 적용하고, 두 SQL 문을 코드에 적힌 모양대로 이어 실행했다.
|
||||
|
||||
첫 번째는 조용히 통과한다. 두 번째는 `uk_notification_admin_operation` 위반이고 `DETAIL` 이 `Key (operation_id)=(op-1) already exists` 다.
|
||||
|
||||
남은 행은 하나다. `operation_id` 가 `op-1`, `phase` 가 `CLAIMED`, `id` 는 청구가 넣은 값이다. 청구한 행은 그대로 남고 완료 기록은 만들어지지 않는다.
|
||||
|
||||
## 서비스가 claim 을 한 줄도 부르지 않는다
|
||||
|
||||
`NotificationAdminApplicationService` 에서 `operations.claim` 이 나오는 줄이 0 이다. 같은 파일에서 `operations.save` 를 세면 4 줄이 나오므로, 0 은 검색이 안 걸린 것이 아니라 실제로 호출이 없는 것이다.
|
||||
|
||||
`AdminOperationClaim` 을 참조하는 파일은 `AdminOperationStorePort`, `AdminOperationClaim`, `JpaAdminOperationStore`, `AdminOperationClaimContractTest` 넷이다. 그중 프로덕션 코드는 포트와 구현뿐이고, 그 값을 받아 분기하는 코드는 없다.
|
||||
|
||||
그래서 서비스가 여전히 조회 후 실행 후 저장을 쓰는 동안에는 `save` 가 만나는 행이 청구가 넣은 것이 아니다. 같은 `operation_id` 가 동시에 두 번 저장되는 경우에는 V8 헤더가 적은 대로 두 번째 저장이 제약에서 실패한다.
|
||||
|
||||
## 계약 시험 다섯은 청구까지만 단언한다
|
||||
|
||||
`@Test` 는 다섯이다. 동시 청구 중 하나만 이기는 것(`:68`), 다른 식별자가 독립적으로 청구되는 것(`:77`), 청구가 `phase` 를 기록하는 것(`:84`), 청구가 지문을 기록하는 것(`:94`), CHECK 제약이 알 수 없는 단계를 거절하는 것(`:104`)이다. `:55` 의 `migrate` 는 `@BeforeEach` 로 매 시험 전에 스키마를 다시 만든다.
|
||||
|
||||
그 파일에 `COMPLETED` 는 한 번도 나오지 않는다. 청구가 넣은 행이 나중에 완료가 되는지 보는 시험이 없다.
|
||||
|
||||
## 원문에 없는 것
|
||||
|
||||
원문은 프로덕션 호출 그래프에 청구 호출이 0 이고 완료로 잇는 상태 전이도 이어지지 않는다고 적는다. 여기에 더한 것은 그 단절이 어디까지 굳어 있는지다. `phase` 를 갱신하는 프로덕션 코드가 0 줄이고, `AdminAuditEntity` 에 그 컬럼을 담을 필드가 없으며, 계약 시험에 `COMPLETED` 가 한 번도 나오지 않는다. 세 자리 모두 청구 뒤를 다루지 않는다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
두 SQL 문을 psql 로 직접 넣었다. 하이버네이트가 `saveAndFlush` 를 어떤 문장으로 바꾸는지는 보지 않았다.
|
||||
|
||||
경로마다 트랜잭션이 그 위반을 어디까지 덮는지 나눠 세지 않았다.
|
||||
|
||||
단계를 완료로 옮기는 책임을 어느 계층에 두어야 하는지 결론 내지 않았다.
|
||||
|
||||
<!-- body:end -->
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: analysis-finding-a05-f031
|
||||
title: 오타 난 벤더는 기동을 멈추지만 그 프로퍼티를 지목하지 못한다
|
||||
topic: multitenancy-isolation
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:analysis-finding-a05-f031
|
||||
evidenceCapturedOn: 2026-09-04
|
||||
body: case-analysis-finding-a05-f031.body.md
|
||||
assets:
|
||||
- key: analysis-finding-a05-f031
|
||||
file: ../../../final/evidence/rendered/analysis-finding-a05-f031.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/analysis-finding-a05-f031.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/05-adapter-outbound-persistence-jpa.md §116 이다.
|
||||
---
|
||||
|
||||
# 오타 난 벤더는 기동을 멈추지만 그 프로퍼티를 지목하지 못한다
|
||||
|
||||
`PersistenceVendorSettings:13`~`:16` 은 열거형에 바인딩하는 것이 알 수 없는 벤더를 기동 실패로 만드는 근거라고 적고, 그 바인딩이 없을 때 무엇이 대신 나타나는지도 같은 자리에 적어 둔다. `app-bootstrap` 에서 그 타입은 빈이 아니고, 프로브가 자바독이 예고한 증상을 그대로 냈다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **@Bean이 있다는 것은 조립 증거가 아니다**
|
||||
애너테이션이 후보만 만든다는 규칙이다. `@ConfigurationProperties` 가 붙어 있어도 어느 스캔 범위에 드는지에 따라 바인딩 여부가 배포마다 갈린다.
|
||||
- **"꺼짐"은 조건의 반복이 아니라 구조여야 한다**
|
||||
켜지 않은 능력이 자기 설정을 바인딩하지도 거절하지도 못하게 한다는 점에서 같은 방향이다. 다만 여기서 뺀 것은 컴포넌트 스캔이 아니라 `@ConfigurationPropertiesScan` 이다.
|
||||
- **스캔에서 뺀 다섯 패키지의 컴포넌트 여섯을 두 자동설정 어느 쪽도 소유하지 않았다**
|
||||
그 기록은 `@ComponentScan` 의 정규식 제외라 컴포넌트가 어디에도 없게 됐고, 이 기록은 `@ConfigurationPropertiesScan` 의 basePackages 목록이라 값 검증이 사라졌다.
|
||||
|
||||
## 문제
|
||||
|
||||
벤더 선택기는 프로퍼티 하나로 RDBMS 조합을 고른다. 그 타입의 자바독은 열거형 바인딩이 오타를 기동 실패로 만드는 근거라고 적는다.
|
||||
|
||||
그 바인딩이 어디서 일어나고 어디서 일어나지 않는지, 일어나지 않는 쪽에서 오타가 실제로 무엇을 내는지 확인했다.
|
||||
|
||||
## 결론
|
||||
|
||||
PersistenceVendorSettings:18 에 @ConfigurationProperties 가 붙어 있고, 중첩된 Vendor 열거형 :25~:28 의 값은 POSTGRESQL 과 H2 둘이다. 압축 생성자 :30~:34 가 값이 없으면 POSTGRESQL 로 채운다.
|
||||
|
||||
이 타입을 손으로 켜는 main 줄이 하나도 없고, CaSkeletonApplication:58 이 열거한 스무 개 basePackages 에 persistence 패키지가 빠져 있다. SamplePortfolioApplication:46 은 다르다. basePackages = "dev.caskeleton" 을 제외 없이 걸고, sample-portfolio/build.gradle:37 이 그 리프를 의존에 넣는다.
|
||||
|
||||
제외는 실수가 아니다. PersistenceJpaRootAutoConfiguration:85~:91 과 JpaAdapterComponentsConfig:55~:56 이 각각 그 사실과 목적을 적어 둔다.
|
||||
|
||||
app-bootstrap 이 벤더를 정하는 자리는 PersistenceJpaRootAutoConfiguration:111~:112 의 environment.getProperty 와 :113 의 "postgresql".equalsIgnoreCase(vendor) 다. 허용값 목록이 없어서 mysql 도 postgresq1 도 H2 도 여기서 false 가 된다.
|
||||
|
||||
그 false 는 두 곳으로 간다. JpaDataSourceProfileValidator.validateResolved:52~:55 는 false 를 받으면 곧바로 돌아가므로 PostgreSQL 버전 검사가 함께 꺼진다. 그리고 @ConditionalOnProperty 두 개가 모두 어긋나 벤더 SPI 빈이 하나도 만들어지지 않는다.
|
||||
|
||||
프로브가 그 뒤를 확인했다. 벤더 설정 둘만 올린 조립은 기동에 성공하고 OutboxClaimRepository 빈이 0 이다. 거기에 그 저장소를 요구하는 @Repository 를 얹으면 두 오타 모두 기동이 멎는데, 예외가 없는 빈의 이름만 말하고 프로퍼티 이름은 말하지 않는다. 반대로 그 타입을 바인딩한 조립에서는 같은 값이 ca-skeleton.persistence.vendor 를 지목하며 멎는다.
|
||||
|
||||
PersistenceVendorSelectionTest:52 가 단언하는 것이 뒤쪽이다. :59 가 실패 스택에 프로퍼티 이름이 담기는지 보는데, :92 가 @EnableConfigurationProperties 로 그 타입을 직접 켠 뒤다. app-bootstrap 은 켜지 않으므로 같은 값이 같은 메시지를 내지 않는다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
확인 방식 : 벤더 설정 타입 전문 게재와 그 자바독 인용, 그 타입을 등록하는 main 자리 계수와 이름이 나오는 자리 전수, 두 애플리케이션의 스캔 범위 대조, 합성 루트의 제외 설명과 벤더 판정 자리와 그 불린을 받는 검증기 인용, 조립 경로 인용, ApplicationContextRunner 세 조립 실행, 시험이 그 타입을 켜는 자리 인용
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 벤더 설정 타입을 전문으로 싣고 열거형 바인딩을 근거로 든 자바독을 읽는다.
|
||||
2. 그 타입을 켜는 main 줄을 세고 두 애플리케이션의 @ConfigurationPropertiesScan 범위를 나란히 싣는다.
|
||||
3. 제외를 적어 둔 두 자바독과 벤더 판정 자리와 그 불린을 받는 검증기를 인용한다.
|
||||
4. 무조건 @Import 되는 컴포넌트 스캔과 그 안의 @Repository 생성자를 인용한다.
|
||||
5. /tmp/probe-vendor/VendorProbe.java 를 컴파일해 세 조립에 =mysql 과 =postgresq1 을 넣고 돌린다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`PersistenceVendorSettings` 는 배포가 어떤 RDBMS 조합을 돌릴지 프로퍼티 하나로 고르게 한다.
|
||||
|
||||
## 그 타입이 약속하는 기동 실패
|
||||
|
||||
:::evidence key="analysis-finding-a05-f031" alt="저장소 루트에서 돌린 정적 검색과 프로브 실행 출력 279줄. 먼저 PersistenceVendorSettings 1~35번 줄이 실린다. 13~16번 자바독은 열거형에 바인딩하는 것이 알 수 없는 벤더를 기동 실패로 만드는 근거라고 적고, 문자열로 두면 두 조건부 벤더 설정이 모두 꺼진 채 남아 첫 번째로 없는 SPI 빈이 OutboxClaimRepository 를 지목하는 NoSuchBeanDefinitionException 으로 나타난다고 적는다. 18번이 ConfigurationProperties 애너테이션, 19번이 record 선언, 25~28번이 POSTGRESQL 과 H2 두 값, 30~34번 압축 생성자가 값이 없으면 POSTGRESQL 로 채운다. 다음으로 EnableConfigurationProperties 가 이 타입을 담은 main 줄이 0 개라고 나오고, ConfigurationPropertiesScan 이 붙은 main 자리 둘이 나온다 — app-bootstrap 의 CaSkeletonApplication 58번과 sample-portfolio 의 SamplePortfolioApplication 46번이며 뒤쪽은 basePackages 가 dev.caskeleton 하나다. 이어서 이 타입 이름이 나오는 자리가 전부 나열되는데 main 은 자기 자신과 H2PersistenceConfig 와 PostgreSqlPersistenceConfig 와 PersistenceJpaRootAutoConfiguration 넷이고 나머지는 PersistenceVendorSelectionTest 이며, 그 시험 92번에만 EnableConfigurationProperties 가 있다. 다음으로 두 애플리케이션의 스캔 범위가 나란히 실린다. CaSkeletonApplication 58~80번은 basePackages 를 스무 개 열거하는데 bootstrap 하위 열하나와 adapter.inbound 셋과 adapter.outbound 의 cache.redis·fileserver·objectstorage 와 application·domain·shared 이고 adapter.outbound.persistence 로 시작하는 항목이 없다. SamplePortfolioApplication 44~48번은 46번이 basePackages 를 dev.caskeleton 으로만 두고, 그 아래 sample-portfolio/build.gradle 37번이 persistence-jpa 를 implementation 의존에 넣는다. 이어서 PersistenceJpaRootAutoConfiguration 56~70번이 실려 57~60번 ConditionalOnProperty 와 62~69번 Import 목록이 나오는데 JpaAdapterComponentsConfig 가 그 목록 맨 앞이고 조건이 붙어 있지 않다. 85~115번에서는 85~91번 자바독이 벤더를 Environment 에서 읽는 이유를 적는다 — 그 타입이 여기 등록된 빈이 아니고 합성 루트의 프로퍼티 스캔이 persistence 패키지를 일부러 제외했으며, 켠 적 없는 선택적 기능이 자기 세부 설정을 바인딩하거나 거절하지 못하게 하려는 것이라고 적는다. 111~112번이 프로퍼티를 기본값 postgresql 로 읽고 113번이 postgresql 과 대소문자 무시로 견준 불린을 validateResolved 에 넘긴다. 다음으로 JpaDataSourceProfileValidator 44~60번이 실리는데 50번 서명 뒤 52~55번이 그 불린이 false 면 주석 두 줄을 달고 곧바로 돌아간다. 이어서 조립 경로가 실린다. JpaAdapterComponentsConfig 55~56번 자바독이 ConfigurationPropertiesScan 이 이 나무를 ComponentScan 만큼 일부러 제외한다고 적고, 59~67번 ComponentScan 의 basePackages 여섯 중 하나가 outbox 패키지이며, OutboxStoreAdapter 23번이 Repository 이고 29~30번 생성자가 OutboxEventJpaRepository 와 OutboxClaimRepository 를 요구한다. 그다음 프로브가 나온다. VendorProbe.java 36~47번이 두 조립 클래스를 정의하는데 OutboxConsumer 가 OutboxStoreAdapter 를 빈으로 만들고 BoundSettings 가 EnableConfigurationProperties 로 그 타입을 켠다. 74~81번 main 이 세 조립을 부른다. 그 아래가 실행 결과다. 두 벤더 설정만 올리고 mysql 을 넣으면 기동 실패가 false 이고 OutboxClaimRepository 빈이 0 이다. SPI 소비자를 더하면 mysql 과 postgresq1 모두 기동 실패가 true 인데 ca-skeleton.persistence.vendor 를 지목하는지가 false 이고 OutboxClaimRepository 를 지목하는지가 true 이며 맨 앞 예외가 UnsatisfiedDependencyException 이다. 그 타입을 켜고 mysql 을 넣으면 기동 실패가 true 이고 이번에는 프로퍼티를 지목하는지가 true 이며 맨 앞 예외가 ConfigurationPropertiesBindException 이다. 마지막으로 PersistenceVendorSelectionTest 28~62번과 88~94번이 실리는데, 52번 시험이 mysql 을 넣고 57번에서 실패를, 59번에서 그 스택에 프로퍼티 이름이 담기는지를 단언하며, 92번이 EnableConfigurationProperties 로 그 타입을 켠다." caption="벤더 설정 타입과 그 자바독이 예고한 증상 · 그 타입을 켜는 main 줄 0 과 두 애플리케이션의 스캔 범위 · 제외를 적어 둔 자바독과 문자열 비교와 그 불린을 받는 검증기 · 무조건 Import 되는 스캔과 그 안의 Repository 생성자 · 세 조립에 오타를 넣고 돌린 결과 · 시험이 그 타입을 켜고 단언하는 자리 — 279줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
`:13`\~`:16` 자바독이 열거형 바인딩의 목적을 적는다. 문자열로 두면 두 조건부 벤더 설정이 모두 꺼진 채로 남고, 첫 번째로 없는 SPI 빈이 `OutboxClaimRepository` 를 지목하는 `NoSuchBeanDefinitionException` 으로 나타난다는 것이다. 오타 난 값과 그 예외 사이에는 벤더 설정 둘이 함께 꺼지는 단계와 SPI 빈이 없어 주입이 실패하는 단계가 있어서, 예외를 읽어도 어느 프로퍼티가 잘못됐는지 알 수 없다.
|
||||
|
||||
`:18` 이 `@ConfigurationProperties` 이고 `:25`\~`:28` 이 `POSTGRESQL` 과 `H2` 둘이다. `:30`\~`:34` 압축 생성자는 값이 없으면 `POSTGRESQL` 로 채운다. 이 선택기가 생기기 전의 모든 배포가 PostgreSQL 을 썼기 때문에, 키를 설정하지 않고 올린 배포가 쓰던 데이터스토어를 그대로 유지하게 하려는 것이다.
|
||||
|
||||
## app-bootstrap 의 스캔 목록에 이 패키지가 없다
|
||||
|
||||
`@EnableConfigurationProperties(PersistenceVendorSettings.class)` 를 쓰는 main 줄은 0 이다. 그렇게 쓰는 자리는 `PersistenceVendorSelectionTest:92` 하나이고 시험이다.
|
||||
|
||||
이름이 나오는 main 파일은 넷이다. 자기 자신, `H2PersistenceConfig`, `PostgreSqlPersistenceConfig`, `PersistenceJpaRootAutoConfiguration` 이다. 앞의 둘은 `@ConditionalOnProperty` 의 `prefix` 자리에 `PREFIX` 상수만 쓰고, 마지막 하나는 `import` 와 자바독과 `VENDOR_PROPERTY` 상수만 쓴다.
|
||||
|
||||
`CaSkeletonApplication:58`\~`:80` 의 `@ConfigurationPropertiesScan` 은 basePackages 를 스무 개 열거한다. `dev.caskeleton.adapter.outbound` 로 시작하는 항목은 `cache.redis` 와 `fileserver` 와 `objectstorage` 셋이고 `persistence` 가 없다.
|
||||
|
||||
이름으로 세는 방식은 패키지째 스캔하는 쪽을 잡지 못하므로 두 합성 루트를 따로 봐야 한다. `SamplePortfolioApplication:46` 은 `basePackages = "dev.caskeleton"` 을 제외 없이 걸고, `sample-portfolio/build.gradle:37` 이 `implementation project(':adapter:outbound:persistence-jpa')` 로 그 리프를 클래스패스에 올린다. 그 배포에서는 이 타입이 스캔 범위 안이다.
|
||||
|
||||
## PersistenceJpaRootAutoConfiguration 자바독이 그 제외를 적어 두었다
|
||||
|
||||
`:85`\~`:91` 은 벤더를 `Environment` 에서 읽는 이유를 적는다. 그 타입이 여기서 등록된 빈이 아니고, 합성 루트의 `@ConfigurationPropertiesScan` 이 persistence 패키지를 일부러 제외했기 때문이다.
|
||||
|
||||
목적도 함께 있다. 켠 적 없는 선택적 기능이 자기 세부 설정을 바인딩하거나 거절하지 못하게 하려는 것이다. 선택기를 설정하지 않은 배포가 PostgreSQL 로 가는 것이 `PostgreSqlPersistenceConfig` 의 `matchIfMissing = true` 와 같은 결과라는 것도 같은 자바독에 있다.
|
||||
|
||||
`JpaAdapterComponentsConfig:55`\~`:56` 이 같은 사실을 한 번 더 적는다. 그 패키지들의 `@ConfigurationProperties` 타입을 각자 자기 패키지 안에서 켜야 하는 이유가 이 제외라는 것이다.
|
||||
|
||||
제외는 의도된 것이다. 다만 그 제외 때문에 열거형 바인딩이 하던 값 검증도 `app-bootstrap` 에서는 일어나지 않는다.
|
||||
|
||||
## \:113 이 "postgresql".equalsIgnoreCase 로 벤더를 정한다
|
||||
|
||||
`:111`\~`:112` 가 `environment.getProperty(PersistenceVendorSettings.VENDOR_PROPERTY, "postgresql")` 로 문자열을 읽고 `trim` 한다.
|
||||
|
||||
`:113` 이 `"postgresql".equalsIgnoreCase(vendor)` 를 `validator.validateResolved(dataSource, ...)` 에 넘긴다.
|
||||
|
||||
허용값 목록이 없다. `mysql` 도 `postgresq1` 도 `H2` 도 여기서 `false` 가 되어 같은 값으로 들어간다. 열거형에 바인딩했다면 앞의 둘은 값 변환에서 실패했을 것이고 `H2` 는 통과했을 것이다.
|
||||
|
||||
## 그 false 를 받는 검증기는 곧바로 돌아간다
|
||||
|
||||
`JpaDataSourceProfileValidator.validateResolved:50` 이 그 불린을 `requirePostgreSql` 로 받는다.
|
||||
|
||||
`:52`\~`:55` 가 `false` 면 즉시 `return` 한다. 주석은 로컬 개발이 H2 를 돌리는 것이 설계이고 여기서까지 PostgreSQL 을 요구하면 모든 노트북을 거절하게 된다고 적는다.
|
||||
|
||||
그래서 오타 난 벤더는 `:57`\~`:58` 의 PostgreSQL 버전 검사도 함께 지나친다. 자바독 `:47` 이 이 인자를 "벤더 선택기가 PostgreSQL 을 골랐는지"라고 적는데, 오타는 고르지 않은 것과 구별되지 않는다.
|
||||
|
||||
## 세 조립에 오타를 넣고 돌린 결과
|
||||
|
||||
`PersistenceJpaRootAutoConfiguration:62`\~`:69` 의 `@Import` 는 조건이 없다. 그 목록 맨 앞이 `JpaAdapterComponentsConfig` 이고, 그것이 `@ComponentScan` 하는 여섯 패키지에 `dev.caskeleton.adapter.outbound.persistence.outbox` 가 있다. 그 패키지의 `OutboxStoreAdapter:23` 이 `@Repository` 이며 `:29`\~`:30` 생성자가 `OutboxClaimRepository` 를 요구한다.
|
||||
|
||||
`ApplicationContextRunner` 로 세 조립을 만들어 값을 넣었다.
|
||||
|
||||
두 벤더 설정만 올리고 `=mysql` 을 넣으면 컨텍스트는 성공하고 `OutboxClaimRepository` 빈이 0 이다. `@ConditionalOnProperty` 둘이 모두 어긋난 결과다.
|
||||
|
||||
거기에 `OutboxStoreAdapter` 를 소비자로 더하면 `=mysql` 과 `=postgresq1` 모두 기동이 실패한다. 예외 사슬이 `OutboxClaimRepository` 를 지목하고 `ca-skeleton.persistence.vendor` 는 지목하지 않는다. 맨 앞은 `UnsatisfiedDependencyException` 이다.
|
||||
|
||||
`@EnableConfigurationProperties` 로 그 타입을 켜면 같은 `=mysql` 이 `ConfigurationPropertiesBindException` 을 내고 이번에는 `ca-skeleton.persistence.vendor` 를 지목한다.
|
||||
|
||||
자바독 `:13`\~`:16` 이 적은 증상이 첫째와 둘째이고, 그것이 막겠다던 증상이 셋째다.
|
||||
|
||||
## 시험이 켜는 것을 app-bootstrap 은 켜지 않는다
|
||||
|
||||
`PersistenceVendorSelectionTest:52` 의 `rejectsAnUnknownVendorAtStartupRatherThanComposingNothing` 이 `=mysql` 을 넣고 `:57` 에서 컨텍스트 실패를, `:59` 에서 그 스택에 `VENDOR_PROPERTY` 가 담기는지를 단언한다.
|
||||
|
||||
그 시험이 쓰는 러너는 `:91`\~`:93` 의 `@EnableConfigurationProperties(PersistenceVendorSettings.class)` 를 함께 올린다. 프로브의 셋째 조립과 같은 모양이다.
|
||||
|
||||
그래서 이 시험이 초록이어도 `app-bootstrap` 에서 같은 값이 같은 메시지를 내는지는 말해 주지 않는다. 프로브의 둘째 조립이 그 답이고, 거기서는 프로퍼티 이름이 나오지 않는다.
|
||||
|
||||
## 원문에 없는 것
|
||||
|
||||
원문은 이 타입이 프로덕션에서 설정 프로퍼티 빈으로 등록되지 않는다고 적는다. 여기에 더한 것은 그것이 실수가 아니라는 점과 그 대가가 무엇인지다. 제외는 두 자바독에 적혀 있고 목적도 함께 적혀 있다. 대가는 둘인데, 오타가 기동을 멈추기는 하되 잘못된 프로퍼티를 지목하지 못한다는 것과, `:113` 의 `false` 가 PostgreSQL 버전 검사까지 함께 끈다는 것이다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
프로브가 세운 것은 `ApplicationContextRunner` 위의 부분 조립이고 `app-bootstrap` 전체를 부팅하지 않았다. `OutboxEventJpaRepository` 는 `Proxy` 로 대신했고, 실제 애플리케이션을 오타 난 값으로 띄우지는 않았다.
|
||||
|
||||
`sample-portfolio` 쪽은 스캔 범위와 의존 그래프로만 판단했고 부팅해 보지 않았다.
|
||||
|
||||
제외를 되돌렸을 때 다른 선택적 기능이 무엇을 바인딩하게 되는지 따지지 않았다.
|
||||
|
||||
<!-- body:end -->
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: analysis-finding-a05-f032
|
||||
title: 포화된 풀의 대기 수를 단언하는 시험이 풀 계약 레인에 없다
|
||||
topic: multitenancy-isolation
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:analysis-finding-a05-f032
|
||||
evidenceCapturedOn: 2026-09-04
|
||||
body: case-analysis-finding-a05-f032.body.md
|
||||
assets:
|
||||
- key: analysis-finding-a05-f032
|
||||
file: ../../../final/evidence/rendered/analysis-finding-a05-f032.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/analysis-finding-a05-f032.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/05-adapter-outbound-persistence-jpa.md §125 이다.
|
||||
---
|
||||
|
||||
# 포화된 풀의 대기 수를 단언하는 시험이 풀 계약 레인에 없다
|
||||
|
||||
`jpa-nightly.yml:128` 은 이 레인이 포화된 풀의 대기 수 보고를 검사한다고 적는다. 그 주장을 확인하는 줄은 레인 전체에 `PoolPressureContractTest:31` 하나뿐이고, 거기 들어간 `3` 은 바로 앞줄이 생성자에 써 넣은 값이다. 실제 풀을 띄우는 `HikariPoolSaturationContractTest` 는 `:100` 에서 `getThreadsAwaitingConnection()` 을 읽어 담고도 그 값만 빼고 단언한다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **빠뜨림이 통과가 되는 게이트는 게이트가 아니다**
|
||||
워크플로가 적은 셋 가운데 둘째만 실제 풀에 닿지 않는데, 레인이 초록이면 셋 다 확인된 것으로 읽힌다.
|
||||
- **풀 계약 레인이 실행되지 않아 포화 동작이 확인되지 않았다**
|
||||
그 질문은 이 레인이 세 계약을 검증한다는 것을 사실로 두고 실행만 남았다고 적는다. 이 기록은 그중 대기 수 보고에 단언이 없다는 것을 확인했으므로, 레인을 돌려도 그 미지수는 닫히지 않는다.
|
||||
- **REQUIRES_NEW의 커넥션 비용과 풀 사이징 제약**
|
||||
그 제약이 이 레인에서 실제 풀로 확인되는 유일한 주장이다. `RequiresNewPoolPressureContractTest:45` 와 `:61` 이 크기 1 과 2 로 나눠 확인한다.
|
||||
|
||||
## 문제
|
||||
|
||||
야간 워크플로가 이 레인이 검사하는 것을 셋으로 적는다. 같은 문장이 build.gradle 의 태스크 주석에도 있다.
|
||||
|
||||
셋 각각에 대응하는 단언이 있는지 확인했다.
|
||||
|
||||
## 결론
|
||||
|
||||
이 레인은 릴리스에 걸려 있다. build.gradle:309 가 jpaPlatformPoolContractTest 를 jpaPlatformReleaseGate 의 의존으로 넣고, 그 태스크가 도는 소스 세트에는 파일 셋에 시험 여덟이 있다.
|
||||
|
||||
셋 중 첫째에는 실제 HikariDataSource 가 있다. RequiresNewPoolPressureContractTest:45 가 크기 1 짜리 풀에서 안쪽 트랜잭션이 커넥션을 못 얻는 것을, :61 이 크기 2 에서는 얻는 것을 잡는다.
|
||||
|
||||
셋째는 절반만 그렇다. HikariPoolSaturationContractTest:64~:66 이 재는 것은 대기 시간의 상한뿐이라 즉시 실패해도 통과한다. 옆의 :77 은 아예 대기를 만들지 않는데, :81 이 하나를 놓은 다음에 :83 이 집기 때문이다.
|
||||
|
||||
두 번째는 다르다. 레인 안에서 pending() 을 단언하는 줄이 PoolPressureContractTest:31 하나인데, 그 값은 :29 의 new PoolMeasurement(4, 2, 3, Duration.ofMillis(80)) 에 넣은 것이다. HikariDataSource 가 없다.
|
||||
|
||||
같은 시험의 나머지 셋도 계산이거나 되읽기다. PoolMeasurement:26 의 total() 은 active + idle, :31 의 saturated() 는 pending > 0 이므로, :33 과 :34 는 손으로 넣은 값에서 유도되는 항등식이다.
|
||||
|
||||
실제 풀에서 그 값을 읽는 자리는 HikariPoolSaturationContractTest:100 이다. :95~:101 이 HikariPoolMXBean 에서 세 값을 읽어 PoolMeasurement 를 만드는데, :103~:105 의 단언은 active 와 total 과 커넥션 유효성이다.
|
||||
|
||||
그 시험이 쥔 커넥션은 :94 의 하나뿐이고 POOL_SIZE 는 :32 에서 2 다. 기다리는 스레드가 생길 수 없는 조합이다.
|
||||
|
||||
PoolMeasurement 자체가 testkit 소스 세트에 있고, HikariPoolMXBean 이나 getThreadsAwaitingConnection 을 읽는 프로덕션 자리는 main 파일 4714 개에 0 이다. 잘못된 것은 런타임이 아니라 레인이 자기에 대해 적은 문장이다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
확인 방식 : 야간 워크플로의 단계 주석과 그 내력 인용, 레인이 도는 소스 세트와 릴리스 게이트 의존 인용, 세 파일의 시험과 단언 전수, 레인 범위에서 pending() 단언 계수, 값 객체 시험과 포화 세 시험의 본문 인용, PoolMeasurement 의 import 자리와 계산 메서드 인용, 프로덕션에서 대기 수를 읽는 자리 계수와 훑은 파일 수 대조
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 워크플로의 단계 주석과 build.gradle 의 같은 문장, 그리고 릴리스 게이트 의존을 인용한다.
|
||||
2. 레인이 도는 소스 세트의 파일을 나열하고 세 파일의 시험과 단언을 전부 뽑는다.
|
||||
3. 레인 범위에서 pending() 을 단언하는 줄을 세고 그 시험 본문을 싣는다.
|
||||
4. 실제 풀을 띄우는 세 시험을 전문으로 싣는다.
|
||||
5. PoolMeasurement 를 어디서 import 하는지와 그 타입의 계산 메서드를 인용한다.
|
||||
6. 프로덕션에서 대기 수를 읽는 자리를 세고 훑은 파일 수를 함께 센다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
야간 워크플로가 JPA 풀 계약 레인을 부른다. 그 단계의 주석이 레인이 무엇을 검사하는지 셋으로 적는다.
|
||||
|
||||
## 야간 워크플로가 적은 셋과 레인의 여덟 시험
|
||||
|
||||
:::evidence key="analysis-finding-a05-f032" alt="저장소 루트에서 돌린 정적 검색 출력 271줄. 먼저 jpa-nightly.yml 122~134번 줄이 실린다. 단계 이름은 풀 포화와 REQUIRES_NEW 커넥션 동작을 검증한다는 것이고, 124~126번 주석이 이 단계가 예전에는 명시적 프로퍼티로 단언을 끄고 그 결과를 인증이라 불렀으며 그래서 유일하게 단언된 임계값이 임계값을 단언하지 않는다는 것이었다고 적는다. 127~129번이 지금 검사하는 셋을 적는데 REQUIRES_NEW 가 동시 스레드당 커넥션 둘을 요구한다는 것, 포화된 풀이 자기 대기 수를 보고한다는 것, 호출자가 커넥션 없이 진행하는 대신 기다린다는 것이며, 어느 러너에서나 참이라 끌 것이 없다고 적는다. 이어서 persistence-jpa/build.gradle 281~310번이 실린다. 281~286번 주석이 같은 셋을 다시 적고, 287~296번의 jpaPlatformPoolContractTest 태스크가 jpaPlatformPerformanceTest 소스 세트를 돌리며, 300~310번의 jpaPlatformReleaseGate 가 309번에서 그 태스크를 의존에 넣는다. 그 아래 소스 세트의 시험 파일 셋이 나열되는데 HikariPoolSaturationContractTest 와 PoolPressureContractTest 와 RequiresNewPoolPressureContractTest 다. 다음으로 세 파일의 시험 이름과 단언이 전부 나열되고 시험이 여덟이다. 이어서 레인 범위에서 measurement.pending 을 단언하는 줄이 1 개라고 나오고, 그 줄이 있는 시험이 실린다. PoolPressureContractTest 26~47번인데 29번이 new PoolMeasurement(4, 2, 3, Duration.ofMillis(80)) 을 만들고 31번이 pending 이 3 인지, 32번이 지연이 80밀리초인지, 33번이 total 이 6 인지, 34번이 saturated 가 참인지 확인한다. 37~47번의 두 번째 시험은 동시 스레드 8 과 깊이 1 로 required 를 8 곱하기 2 더하기 1 로 계산해 47번에서 17 과 같은지 확인한다. 그 아래 HikariPoolSaturationContractTest 32번의 POOL_SIZE 가 2 라는 것과 50~118번의 세 시험이 실린다. 52번 시험은 POOL_SIZE 만큼 쥔 뒤 61번에서 한 번 더 요청해 SQLException 이 나는 것과 64~66번에서 기다린 시간이 ACQUIRE_TIMEOUT 더하기 2초보다 작은 것을 확인한다. 77번 시험은 79번과 80번에서 둘을 얻고 81번에서 하나를 놓은 뒤 83번에서 세 번째를 얻어 유효한지 확인한다. 92번 시험은 94번에서 커넥션 하나만 쥐고 95~101번이 HikariPoolMXBean 에서 활성 수와 유휴 수와 getThreadsAwaitingConnection 을 읽어 PoolMeasurement 를 만드는데, 103~105번의 단언은 active 가 1 인지와 total 이 1 이상인지와 쥔 커넥션이 유효한지 셋이다. 110~118번이 그 풀을 만드는 pool 메서드다. 다음으로 RequiresNewPoolPressureContractTest 43~89번이 실린다. 45번 시험이 크기 1 짜리 풀에서 바깥 커넥션을 쥔 채 안쪽을 얻으려 하면 예외가 나는 것을, 61번 시험이 크기 2 에서는 얻어지고 두 커넥션이 다른 객체인 것을 확인하며, 80번 시험은 동시 스레드 1 과 깊이 1 로 required 를 계산해 88번에서 3 과 같은지 확인한다. 마지막으로 PoolMeasurement 를 import 하는 자리가 두 시험 파일이라는 것과 그 타입 6~34번이 실리는데, 9~11번 자바독이 대기 수와 획득 지연을 함께 기록하는 이유를 적으며 표본을 뜨는 순간에 대기 수가 0 으로 보이면서도 호출자들이 늘 기다리는 풀이 있을 수 있다고 적고, 26~28번의 total 이 active 더하기 idle 을, 31~33번의 saturated 가 pending 이 0 보다 큰지를 계산한다. 그 아래 프로덕션에서 대기 수를 읽는 자리가 0 개이고 그 검색이 훑은 main 파일이 4714 개라고 나온다." caption="야간 워크플로가 적은 셋과 그 내력 · 레인이 도는 태스크와 릴리스 게이트 의존 · 세 파일의 시험 여덟과 단언 전수 · 레인 범위의 pending 단언 하나와 그 입력 · 포화를 다루는 세 시험 전문 · REQUIRES_NEW 세 시험 전문 · PoolMeasurement 자바독과 계산 메서드 · 프로덕션의 대기 수 읽기 0 — 271줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
`jpa-nightly.yml:127`\~`:129` 가 셋을 적는다. `REQUIRES_NEW` 가 동시 스레드당 커넥션 둘을 요구한다는 것, 포화된 풀이 자기 대기 수를 보고한다는 것, 호출자가 커넥션 없이 진행하는 대신 기다린다는 것이다.
|
||||
|
||||
`:124`\~`:126` 은 이 단계의 내력도 적는다. 예전에는 명시적 프로퍼티로 단언을 끄고 그 결과를 인증이라 불렀고, 그래서 유일하게 단언된 임계값이 임계값을 단언하지 않는다는 것이었다.
|
||||
|
||||
`build.gradle:281`\~`:286` 이 같은 셋을 다시 적고 `:287` 의 태스크가 `jpaPlatformPerformanceTest` 소스 세트를 돌린다. `:300` 의 `jpaPlatformReleaseGate` 가 `:309` 에서 그 태스크를 의존에 넣으므로, 릴리스가 이 셋에 기댄다.
|
||||
|
||||
그 소스 세트에 있는 것은 `HikariPoolSaturationContractTest` 와 `PoolPressureContractTest` 와 `RequiresNewPoolPressureContractTest` 셋이고, 그 안의 `@Test` 를 전부 세면 여덟이다.
|
||||
|
||||
## 첫 번째는 실제 풀로 확인된다
|
||||
|
||||
`RequiresNewPoolPressureContractTest:45` 의 `poolOfOneCannotServeAnInnerTransaction` 은 크기 1 짜리 풀에서 바깥 커넥션을 쥔 채 안쪽을 얻으려 하면 `SQLException` 이 나는 것을 확인한다.
|
||||
|
||||
`:61` 의 `poolOfTwoServesTheSameNesting` 은 크기 2 에서는 얻어지고 두 커넥션이 다른 객체인 것을 확인한다.
|
||||
|
||||
둘 다 실제 `HikariDataSource` 를 띄운다.
|
||||
|
||||
같은 파일 `:80` 의 `sizingRuleMatchesTheObservedRequirement` 는 다르다. `:81`\~`:84` 가 `concurrentThreads = 1` 과 `maxRequiresNewDepth = 1` 로 `required = 1 * (1 + 1) + 1` 을 계산하고 `:88` 이 그것을 `3` 과 견준다. `PoolPressureContractTest:39` 도 같은 모양이고 숫자만 8 과 17 이다.
|
||||
|
||||
## 세 번째는 절반이다
|
||||
|
||||
`HikariPoolSaturationContractTest:52` 의 `saturatedPoolFailsWithinItsTimeout` 이 `POOL_SIZE` 만큼 쥔 뒤 한 번 더 요청한다. `:61` 이 `SQLException` 을, `:64`\~`:66` 이 기다린 시간이 `ACQUIRE_TIMEOUT` 에 2 초를 더한 값보다 작은 것을 확인한다.
|
||||
|
||||
상한만 있다. 얼마나 기다렸는지에 대한 하한 단언이 없어서, 즉시 실패해도 이 시험은 통과한다.
|
||||
|
||||
`:77` 의 `releasingAConnectionLetsTheNextCallerThrough` 는 기다림과 무관하다. `:79` 와 `:80` 이 둘을 얻고 `:81` 이 하나를 놓은 뒤 `:83` 이 세 번째를 얻으므로, 세 번째 호출은 이미 빈 자리를 집는다.
|
||||
|
||||
## 두 번째를 단언하는 유일한 줄은 손으로 만든 값이다
|
||||
|
||||
레인 안에서 `pending()` 을 단언하는 줄은 `PoolPressureContractTest:31` 하나다.
|
||||
|
||||
그 시험 `:29` 가 `new PoolMeasurement(4, 2, 3, Duration.ofMillis(80))` 을 만든다. `:31` 이 `pending()` 이 3 인지, `:32` 가 지연이 80 밀리초인지, `:33` 이 `total()` 이 6 인지, `:34` 가 `saturated()` 가 참인지 확인한다.
|
||||
|
||||
앞의 둘은 생성자에 넣은 값을 그대로 되읽는다. 뒤의 둘도 새로운 것을 보지 않는다. `PoolMeasurement:26`\~`:28` 의 `total()` 이 `active + idle` 이고 `:31`\~`:33` 의 `saturated()` 가 `pending > 0` 이므로, `4 + 2 = 6` 과 `3 > 0` 을 확인하는 것이다.
|
||||
|
||||
`HikariDataSource` 도 데이터베이스도 이 시험에는 없다.
|
||||
|
||||
## 실제 풀에서 읽은 대기 수는 단언되지 않는다
|
||||
|
||||
`HikariPoolSaturationContractTest:92` 의 `measurementReportsPoolState` 는 실제 풀을 띄우고 `:94` 에서 커넥션 하나를 쥔다.
|
||||
|
||||
`:95`\~`:101` 이 `HikariPoolMXBean` 에서 활성 수와 유휴 수와 `getThreadsAwaitingConnection()` 을 읽어 `PoolMeasurement` 를 만든다.
|
||||
|
||||
`:103`\~`:105` 의 단언은 셋이다. `active()` 가 1 인지, `total()` 이 1 이상인지, 쥔 커넥션이 유효한지다. `pending()` 은 없다.
|
||||
|
||||
그 풀은 포화 상태도 아니다. `:32` 의 `POOL_SIZE` 가 2 인데 커넥션 하나만 쥐었으므로 기다리는 스레드가 생길 수 없다.
|
||||
|
||||
`PoolMeasurement:9`\~`:11` 자바독이 이 함정을 직접 적는다. 표본을 뜨는 순간에 대기 수가 0 으로 보이면서도 호출자들이 늘 기다리는 풀이 있을 수 있다는 것이다.
|
||||
|
||||
## 이 결함이 있는 곳
|
||||
|
||||
`PoolMeasurement` 는 `testkit` 소스 세트의 타입이고, 두 시험 파일만 그것을 `import` 한다.
|
||||
|
||||
프로덕션에서 `getThreadsAwaitingConnection` 이나 `HikariPoolMXBean` 을 읽는 자리는 main 파일 4714 개에 0 이다. 런타임이 대기 수를 잘못 다루는 것이 아니라, 레인이 검사한다고 적은 것을 검사하지 않는다.
|
||||
|
||||
## 원문에 없는 것
|
||||
|
||||
원문은 야간 워크플로가 광고하는 셋 중 하나에 대한 단언이 레인 전체에 없다고 적는다. 여기에 더한 것은 그 주장에 가장 가까운 두 자리가 각각 어떻게 비껴가는지와, 나머지 두 주장에도 같은 모양이 섞여 있다는 점이다.
|
||||
|
||||
`PoolPressureContractTest:31` 은 값을 손으로 넣고 되읽고, `HikariPoolSaturationContractTest:100` 은 실제 값을 읽어 담고서 그것만 빼고 단언한다. 그리고 첫 번째 주장 쪽 `RequiresNewPoolPressureContractTest:80` 과 `PoolPressureContractTest:39` 는 리터럴 산술의 결과를 리터럴과 견주는 시험이다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
`jpaPlatformPoolContractTest` 를 돌리지 않았다. 여덟 시험의 통과 여부는 코드만 읽고 판단하지 않았다.
|
||||
|
||||
커넥션 둘을 모두 쥔 상태에서 그 MXBean 이 무엇을 돌려주는지 직접 재지 않았다.
|
||||
|
||||
계수 범위를 레인으로 한정했다. 저장소 전체에는 `src/test` 의 `PoolMeasurementTest` 가 같은 단언을 한 번 더 갖고 있고, 그쪽까지 포함해 전수로 따지지는 않았다.
|
||||
|
||||
레인을 실제로 돌려 여덟 시험이 통과하는지 보지 않았다.
|
||||
|
||||
`getThreadsAwaitingConnection()` 이 포화 상태에서 어떤 값을 내는지 띄워서 재지 않았다.
|
||||
|
||||
<!-- body:end -->
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: analysis-finding-a05-f034
|
||||
title: 세 카드의 태그를 채우는 시험이 프로덕션 타입을 한 줄도 부르지 않는다
|
||||
topic: multitenancy-isolation
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:analysis-finding-a05-f034
|
||||
evidenceCapturedOn: 2026-09-04
|
||||
body: case-analysis-finding-a05-f034.body.md
|
||||
assets:
|
||||
- key: analysis-finding-a05-f034
|
||||
file: ../../../final/evidence/rendered/analysis-finding-a05-f034.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/analysis-finding-a05-f034.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/05-adapter-outbound-persistence-jpa.md §133 이다.
|
||||
---
|
||||
|
||||
# 세 카드의 태그를 채우는 시험이 프로덕션 타입을 한 줄도 부르지 않는다
|
||||
|
||||
`readiness-cards.yaml` 의 `selected` 카드 일곱 중 셋이 `postgresqlIntegrationTest` 의 시험을 시나리오로 지목하는데, 그 세 파일은 `dev.caskeleton` 을 `import` 하는 줄이 0 이다. 대조로 센 `jpa-transaction-runtime` 의 시험은 15 줄을 `import` 한다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **증거 등급과 provenance — R1과 R2를 가르는 것**
|
||||
그 등급의 조건은 결과가 어디서 왔는지를 보지 무엇을 지났는지를 보지 않는다. 시나리오가 지목한 시험이 프로덕션 코드를 부르는지는 R1 에서도 R2 에서도 검사되지 않는다.
|
||||
- **빠뜨림이 통과가 되는 게이트는 게이트가 아니다**
|
||||
여기서는 빠뜨림이 아니라 지목이 문제다. 태그마다 시나리오가 붙어 있어 레지스트리 검사는 통과하는데, 그 시나리오가 도는 것이 시험이 자기 `@BeforeAll` 로 만든 표다.
|
||||
- **후보 증거는 통과해도 R1에 머무르고 R2는 별도 게이트가 판정한다**
|
||||
그 결정이 후보 검증에 맡긴 것은 건너뛴 시험과 스키마와 내용 해시와 선행 조건이다. 시나리오가 프로덕션 타입을 한 줄도 부르지 않는 것은 그 넷 중 어디에도 걸리지 않는다.
|
||||
|
||||
## 문제
|
||||
|
||||
준비도 카드는 태그마다 그것을 덮는 시나리오나 task-claim 을 적고, 게이트가 그 selector 를 실제로 돌린다.
|
||||
|
||||
그 selector 가 무엇을 지나는지 확인했다.
|
||||
|
||||
## 결론
|
||||
|
||||
selected 일곱 모두에서 no-skip 이 비어 있는데, 이것은 결함이 아니라 설계다. build.gradle:1672~:1674 가 그 태그를 필수로 요구하면서 :1678~:1680 이 클레임 가능한 태그에서 빼고, jpa-evidence.gradle:680~:684 가 JUnit XML 의 실행·건너뜀·실패 건수로 그 태그를 채운다.
|
||||
|
||||
jpa-primary-foundation 의 base-card-manifests 도 생성기가 채운다. 그 카드는 readiness-task 자체가 나머지 여섯을 모으는 롤업이라 시나리오가 0 인 것이 정상이고, support-task 는 다섯 걸려 있다.
|
||||
|
||||
남는 것은 셋이다. jpa-aggregate-store 와 jpa-query-model 은 시나리오가 하나씩이고, jpa-observability-lifecycle 은 둘이다.
|
||||
|
||||
그 셋이 지목하는 세 파일은 dev.caskeleton 을 import 하는 줄이 0 이다. import 없이 쓰는 같은 패키지 타입까지 잡으려고 대문자 이름을 전부 뽑아 다시 셌는데 그것도 0 이다. 대조로 센 PostgreSqlTransactionIntegrationTest 는 두 방식 모두 15 다.
|
||||
|
||||
그 시험들이 읽고 쓰는 표는 자기 @BeforeAll 이 만든 것이다. readiness_aggregate 도 readiness_query 도 프로덕션 소스와 마이그레이션 4714 개 파일 어디에도 없다.
|
||||
|
||||
세 번째 카드의 observability 는 표가 아니라 문자열이다. 견주는 쪽과 견주어지는 쪽이 모두 PostgreSqlLifecycleIntegrationTest 안에 있고, 그 리터럴 postgresql-primary 는 프로덕션에 0 건이다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
확인 방식 : 카드 전체 계수와 selected 카드의 태그·시나리오·task-claim·support-task 계수, 레지스트리가 덮을 수 없는 두 태그의 근거를 빌드 스크립트와 생성기에서 인용, 세 카드의 시나리오 selector 나열, 그 세 시험의 본문 인용, import 계수와 소스 세트 파일 수 대조, 표 이름과 문자열의 프로덕션 등장 계수와 훑은 파일 수 대조, 시나리오 일곱 카드의 import 전수
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 카드 전체를 상태별로 세고, selected 카드마다 필수 태그와 시나리오와 task-claim 과 support-task 를 센다.
|
||||
2. no-skip 과 base-card-manifests 가 레지스트리 밖에서 채워지는 자리를 빌드 스크립트와 생성기에서 인용한다.
|
||||
3. 남은 세 카드의 시나리오 selector 를 나열하고 그 시험 본문을 싣는다.
|
||||
4. 세 파일의 dev.caskeleton import 줄을 세고, 파일이 쓰는 대문자 이름 가운데 프로덕션에 정의된 타입이 몇인지도 센다.
|
||||
5. 두 표 이름과 postgresql-primary 가 프로덕션에 나오는지 세고 훑은 파일 수를 함께 센다.
|
||||
6. 시나리오 일곱을 가진 카드의 시험이 무엇을 import 하는지 나열한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`readiness-cards.yaml` 은 카드마다 필수 증거 태그를 적고, 그 태그를 덮는 시나리오나 task-claim 을 함께 적는다.
|
||||
|
||||
## 태그 커버리지에서 두 태그는 빼고 읽어야 한다
|
||||
|
||||
:::evidence key="analysis-finding-a05-f034" alt="저장소 루트에서 돌린 준비도 레지스트리 분석과 정적 검색 출력 237줄. 먼저 카드가 열일곱이고 implemented-candidate 여섯, not-implemented 넷, selected 일곱이라고 나온다. 그 일곱마다 필수 태그 수와 시나리오 수와 task-claim 수와 support-task 수와 레지스트리가 덮지 않는 태그가 나열되는데, jpa-observability-lifecycle 은 태그 넷에 시나리오 둘, jpa-security-baseline 은 태그 여섯에 시나리오 셋과 task-claim 하나, jpa-flyway-migration 은 태그 넷에 시나리오 넷, jpa-transaction-runtime 은 태그 넷에 시나리오 일곱, jpa-aggregate-store 와 jpa-query-model 은 각각 태그 넷에 시나리오 하나, jpa-primary-foundation 은 태그 넷에 시나리오 0 과 task-claim 둘과 support-task 다섯이다. 덮지 않는 태그는 여섯 카드가 no-skip 하나씩이고 jpa-primary-foundation 만 base-card-manifests 가 더 있다. 이어서 그 두 태그를 레지스트리가 덮을 수 없는 근거가 실린다. build.gradle 1672~1674번이 no-skip 을 필수 태그에 넣으라고 강제하고 1678~1680번이 클레임 가능한 태그 집합에서 no-skip 을 빼며, jpa-evidence.gradle 676~692번이 JUnit XML 의 실행 건수와 건너뜀과 실패와 오류 건수로 noSkipResult 를 계산해 no-skip 을 채우고, cardId 가 jpa-primary-foundation 이고 선행 조건이 모두 매니페스트를 가졌으면 base-card-manifests 를 채운다. 다음으로 남은 세 카드의 시나리오가 나열된다. jpa-aggregate-store 는 필수 태그 real-postgresql 과 mapping 과 optimistic-conflict 와 no-skip 에 시나리오 하나가 앞의 셋을 함께 덮고, jpa-query-model 도 keyset 과 query-plan 을 시나리오 하나가 함께 덮으며, jpa-observability-lifecycle 은 시나리오 둘이 각각 real-postgresql 과 lifecycle 을, observability 를 덮는다. 그 아래에 세 시험의 본문이 실린다. PostgreSqlAggregateIntegrationTest 는 19~30번 BeforeAll 이 Docker 가용성을 확인하고 create table readiness_aggregate 를 실행하며, 36~84번이 JDBC 로 UUID 와 Instant 를 넣고 다시 읽어 값을 견주고, 66~84번이 커넥션 둘을 열어 version 을 조건에 넣은 갱신을 각각 실행해 먼저 커밋한 쪽이 1 이고 두 번째가 0 임을 확인한다. PostgreSqlQueryIntegrationTest 는 22~35번이 readiness_query 표와 인덱스를 만들고 generate_series 로 천 행을 넣으며, 45~80번이 keyset 페이지를 읽고 70번에서 set enable_seqscan=off 를 실행한 뒤 72번 explain format json 의 계획에 인덱스 이름이 들어 있는지 78번에서 확인한다. PostgreSqlLifecycleIntegrationTest 는 51~66번이 크기 2 인 풀을 만들어 커넥션 둘을 쥐고 상태가 SATURATED 인지와 active 와 maximum 이 2 인지를 확인한 뒤 63~65번이 boundedTags 를 component 가 postgresql-primary 이고 state 가 saturated 인 맵과 견주는데, 107~119번의 private record 안 115~117번이 바로 그 맵을 만드는 자리다. 다음으로 세 시험이 dev.caskeleton 을 import 하는 줄이 각각 0 개이고 같은 소스 세트에 파일이 스물일곱이며, 이름 단위로 훑어도 세 파일이 쓰는 대문자 이름 중 프로덕션에 정의된 타입이 각각 0 개인데 대조로 건 PostgreSqlTransactionIntegrationTest 는 15 개라고 나온다. 두 시험이 쓰는 표 이름 readiness_aggregate 와 readiness_query 가 프로덕션 소스와 마이그레이션에 0 건이고 그 검색이 훑은 main 파일이 4714 개이며, postgresql-primary 도 프로덕션에 0 건이다. 마지막으로 대조 카드 jpa-transaction-runtime 의 시나리오 일곱이 각각 어떤 태그를 덮는지 나열되고, 그 시험이 import 하는 dev.caskeleton 타입 열다섯 중 여덟이 실린다." caption="카드 열일곱과 selected 일곱의 태그 커버리지 · no-skip 과 base-card-manifests 를 레지스트리 밖에서 채우는 자리 · 남은 세 카드의 시나리오 · 그 세 시험의 본문 · import 와 이름 단위 계수 대조 · 표 이름과 문자열의 프로덕션 등장 0 · 시나리오 일곱 카드의 대조 — 237줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
카드는 열일곱이고 `selected` 가 일곱이다. 일곱 모두 `no-skip` 이 시나리오로도 task-claim 으로도 덮이지 않는다.
|
||||
|
||||
그것이 설계다. `build.gradle:1672`\~`:1674` 가 모든 카드에 `no-skip` 을 필수 태그로 넣으라고 강제하고, `:1678`\~`:1680` 이 클레임 가능한 태그 집합을 만들 때 그 하나를 뺀다. 카드가 `no-skip` 을 덮겠다고 적으면 검증기가 거절한다.
|
||||
|
||||
대신 `jpa-evidence.gradle:680`\~`:684` 가 매니페스트를 만들 때 JUnit XML 의 실행 건수와 건너뜀·실패·오류 건수를 읽어 `noSkipResult` 를 계산하고 그 태그를 채운다.
|
||||
|
||||
`base-card-manifests` 는 `jpa-primary-foundation` 에만 있는 태그이고, `jpa-evidence.gradle:685`\~`:691` 이 그 카드의 선행 조건 여섯이 모두 매니페스트를 가졌을 때 채운다. `readiness-task` 가 `verifyJpaPrimaryFoundationEvidence` 이고 선행 조건이 나머지 여섯 base 카드 전부라, 이 카드는 자기 시나리오를 갖지 않는 쪽이 맞다.
|
||||
|
||||
## 남는 세 카드
|
||||
|
||||
`jpa-aggregate-store` 는 시나리오 하나가 `real-postgresql` 과 `mapping` 과 `optimistic-conflict` 를 함께 덮는다.
|
||||
|
||||
`jpa-query-model` 도 시나리오 하나가 `real-postgresql` 과 `keyset` 과 `query-plan` 을 함께 덮는다.
|
||||
|
||||
`jpa-observability-lifecycle` 은 둘인데, 하나가 `real-postgresql` 과 `lifecycle` 을, 다른 하나가 `observability` 를 덮는다.
|
||||
|
||||
## 그 시나리오들이 도는 것
|
||||
|
||||
`PostgreSqlAggregateIntegrationTest` 의 `@BeforeAll:19`\~`:30` 이 `assertDockerAvailable()` 뒤에 `create table readiness_aggregate` 를 실행한다. `:36`\~`:64` 가 `UUID` 와 `Instant` 를 JDBC 로 넣고 다시 읽어 값을 견준다. `:66`\~`:84` 는 커넥션 둘을 열어 `where id = ? and version = ?` 갱신을 각각 실행하고, 먼저 커밋한 쪽의 갱신 건수가 1 이고 두 번째가 0 인 것을 확인한 뒤 롤백한다.
|
||||
|
||||
`optimistic-conflict` 를 덮는 것이 그 부분이다. `@Version` 이 아니라 손으로 쓴 조건부 갱신이고, 대상은 이 시험이 만든 표다.
|
||||
|
||||
`PostgreSqlQueryIntegrationTest:22`\~`:35` 가 `readiness_query` 와 인덱스를 만들고 `generate_series` 로 천 행을 넣는다. `:45`\~`:68` 이 keyset 페이지를 읽고, `:70` 이 `set enable_seqscan=off` 를 실행한 뒤 `:72` 의 `explain (format json)` 계획에 인덱스 이름이 들어 있는지를 `:78` 에서 확인한다.
|
||||
|
||||
`query-plan` 태그는 실제 EXPLAIN 계획을 본다. 다만 대안을 끈 뒤에 본 것이고, 대상은 역시 이 시험이 만든 표다.
|
||||
|
||||
`PostgreSqlLifecycleIntegrationTest:51`\~`:66` 은 크기 2 인 풀에 커넥션 둘을 쥐고 상태와 `active` 와 `maximum` 을 확인한다. `observability` 를 덮는 것은 `:63`\~`:65` 의 `boundedTags()` 비교인데, 그 맵을 만드는 `:115`\~`:117` 이 같은 파일 private record 안에 있고 리터럴도 같은 파일에 있다.
|
||||
|
||||
## 세 파일 모두 프로덕션 타입에 닿지 않는다
|
||||
|
||||
`dev.caskeleton` 을 `import` 하는 줄이 세 파일 다 0 이다. 같은 패키지 타입은 `import` 없이 쓸 수 있으므로 파일이 쓰는 대문자 이름을 전부 뽑아 프로덕션에 같은 이름의 `.java` 가 있는지도 셌는데, 그것도 셋 다 0 이다. 같은 소스 세트에 파일이 스물일곱 있다.
|
||||
|
||||
표 이름 둘과 `boundedTags()` 가 견주는 `postgresql-primary` 를 프로덕션 소스와 마이그레이션에서 찾으면 셋 다 0 건이다. 그 검색이 훑은 main 파일은 4714 개다.
|
||||
|
||||
## 시나리오 일곱을 가진 카드는 다르다
|
||||
|
||||
`jpa-transaction-runtime` 은 태그 넷에 시나리오 일곱이고, 하나가 여러 태그를 겸하는 대신 태그마다 여러 시나리오가 붙는다.
|
||||
|
||||
그 시나리오가 가리키는 `PostgreSqlTransactionIntegrationTest` 는 `dev.caskeleton` 을 15 줄 `import` 한다. `PersistenceExceptionTranslator`, `StandardSqlStateErrorMapping`, `PostgreSqlLocalTimeoutConfigurer`, `PostgreSqlSqlStateErrorMapping`, `JpaTransactionSettings`, `SpringTransactionPort` 같은 것들이다. 이름 단위로 세도 15 다.
|
||||
|
||||
## 원문에 없는 것
|
||||
|
||||
원문은 이 세 카드의 시나리오가 프로덕션 경로를 지나지 않는다고 적는다. 여기에 더한 것은 그 판정을 남기기 위해 무엇을 빼야 하는지다.
|
||||
|
||||
`no-skip` 은 일곱 카드 모두에서 비어 있지만 레지스트리가 덮을 수 없는 태그이고, `jpa-primary-foundation` 의 시나리오 0 은 롤업 카드의 정상 상태다. 둘을 빼고 나면 남는 것이 셋이고, 그 셋에 대해서는 `import` 계수와 이름 단위 계수가 같은 답을 낸다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
매니페스트를 생성해 등급이 무엇으로 찍히는지 보지 않았다.
|
||||
|
||||
이름 단위 검사의 판정 기준은 프로덕션 트리에 같은 이름의 `.java` 가 있는지다. 시험 전용 타입과 이름이 겹치면 과대 계수될 수 있다.
|
||||
|
||||
`verifyJpaCandidateEvidence` 가 이 세 카드에 어떤 블로커를 붙이는지 빌드를 돌려 보지 않았다.
|
||||
|
||||
`verifyJpaCandidateEvidence` 가 이 세 카드에 어떤 블로커를 붙이는지 빌드를 돌려 보지 않았다.
|
||||
|
||||
<!-- body:end -->
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: analysis-finding-a06-f002
|
||||
title: 가드가 막겠다는 문장이 실제로 있는 두 파일이 탐색 범위 밖이다
|
||||
topic: multitenancy-isolation
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:analysis-finding-a06-f002
|
||||
evidenceCapturedOn: 2026-09-04
|
||||
body: case-analysis-finding-a06-f002.body.md
|
||||
assets:
|
||||
- key: analysis-finding-a06-f002
|
||||
file: ../../../final/evidence/rendered/analysis-finding-a06-f002.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/analysis-finding-a06-f002.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/06-adapter-outbound-persistence-mongo.md §5 이다.
|
||||
---
|
||||
|
||||
# 가드가 막겠다는 문장이 실제로 있는 두 파일이 탐색 범위 밖이다
|
||||
|
||||
`MongoNamespaceContractTest:25`~`:26` 은 결함을 "문서가 운영자에게 폐기된 키를 쓰라고 말하는 것" 으로 정의한다. 그 가드의 탐색 범위는 두 리프 아래 `/src/main/` 경로의 `.java`·`.yml`·`.properties` 뿐이고, 그 정의에 정확히 들어맞는 `README.md:37` 과 `CLAUDE.md:25` 는 확장자로도 경로로도 걸리지 않는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **빠뜨림이 통과가 되는 게이트는 게이트가 아니다**
|
||||
그 규칙이 요구하는 것을 이 가드는 자바 소스 쪽에서만 지킨다. `:37`~`:39` 가 목록이 비었으면 실패하는데, `.yml` 을 보는 쪽에는 같은 단언이 없다.
|
||||
- **문서와 상수가 서로 일치하는 것으로는 아무것도 증명되지 않는다**
|
||||
그 규칙이 요구하는 검사 경계 명시가 이 가드에 없다. 무엇이 훑이고 무엇이 빠지는지 어디에도 적혀 있지 않아, 초록불이 저장소의 어떤 문서도 폐기된 키를 안내하지 않는다는 뜻으로 읽힌다.
|
||||
- **과대 진술 문서를 과소보다 먼저 고친다**
|
||||
같은 안내가 `README.md:37` 과 `:53` 과 `CLAUDE.md:25` 셋에 있다. 하나만 고치면 나머지 둘이 남는다.
|
||||
|
||||
## 문제
|
||||
|
||||
이 모듈에는 폐기된 프로퍼티 이름의 재유입을 막는 계약 시험이 있다. 그 클래스 자바독에 무엇을 결함으로 볼지가 한 문장으로 적혀 있다.
|
||||
|
||||
그 정의에 해당하는 자리를 탐색 범위가 덮는지 확인했다.
|
||||
|
||||
## 결론
|
||||
|
||||
범위는 :72~:90 이 만든다. repositoryRoot():100~:110 이 src/config/architecture/modules.json 을 찾아 위로 올라가며 정한 루트 아래 adapter/outbound/persistence-mongo 와 app-bootstrap 두 리프를 훑고, 확장자가 맞고 경로에 /src/main/ 이 있고 /build/ 가 없는 파일만 남긴다.
|
||||
|
||||
시험은 둘이다. :34 가 주석을 뗀 자바 소스에 그 키가 없는지 보고, :56 이 출하되는 .yml 과 .properties 에 없는지 본다. :37~:39 는 목록이 비었으면 검색이 헛돈 것이라며 그것부터 단언한다. :56 의 자원 시험에는 같은 단언이 없다.
|
||||
|
||||
그 키가 있는 자리는 시험 소스를 빼면 여섯이다. 설계 계획 문서 셋과 persistence-mongo 의 CLAUDE.md 와 README.md 와 MongoPersistenceSettings.java 다.
|
||||
|
||||
여섯 중 자바 파일 하나만 조건을 통과한다. 그 파일에서 키가 나오는 곳은 :15 이고 자바독 안이며, 이 자바독이 예전에 그 키를 가리켰다는 기록이다. 주석을 떼고 보는 규칙이 정확히 이런 문장을 위해 있다.
|
||||
|
||||
훑는 대상이 없는 것은 아니다. 두 리프의 /src/main/ 아래 .java 와 app-bootstrap 의 .yml 넷을 실제로 읽고, 그 안에서 주석을 떼고 나면 걸 것이 남지 않는다.
|
||||
|
||||
나머지 다섯은 범위 밖이다. 그중 둘이 이 모듈의 운영자용 문서다.
|
||||
|
||||
README.md:37 은 properties 코드 블록 안에 그 키의 완전한 설정 한 줄을 적어 둔다. :53 과 CLAUDE.md:25 는 URI 와 데이터베이스가 그 네임스페이스에서 온다고 산문으로 적는다.
|
||||
|
||||
즉 자바독이 정의한 결함 — 문서가 운영자에게 폐기된 키를 쓰라고 말하는 것 — 이 지금 그 모듈의 두 문서에 그대로 있고, 그것을 막으려고 만든 가드의 범위가 그 둘에 닿지 않는다.
|
||||
|
||||
시험 소스에는 그 키를 산문이 아니라 실제로 바인딩하는 자리도 둘 있다. MongoPersistenceConfigTest:20 과 :64 의 withPropertyValues 다. /src/main/ 조건이 이쪽도 함께 걸러 낸다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
확인 방식 : 가드의 클래스 자바독과 두 시험과 탐색 범위 코드 인용, 폐기된 키가 있는 자리를 시험 소스를 빼고 전수 검색, 각 자리가 그 범위에 드는지 확장자와 경로로 판정, 범위 안 자바 파일에서 그 키가 놓인 줄 인용, 운영자용 두 문서의 해당 줄과 앞뒤 인용
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 가드의 클래스 자바독에서 막으려는 결함의 정의를 인용한다.
|
||||
2. 두 시험이 각각 무엇을 보는지, 그리고 자기 검증을 어떻게 하는지 인용한다.
|
||||
3. 탐색 범위를 만드는 코드를 인용한다.
|
||||
4. 폐기된 키가 있는 자리를 시험 소스를 빼고 전부 찾는다.
|
||||
5. 각 자리가 그 범위의 확장자와 경로 조건에 드는지 판정한다.
|
||||
6. 범위 안에 든 파일에서 그 키가 놓인 줄과, 범위 밖 운영자용 문서의 해당 줄을 인용한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`MongoNamespaceContractTest` 는 폐기된 Mongo 프로퍼티 이름이 되돌아오는 것을 막는다. 클래스 자바독이 그 결함을 한 문장으로 정의한다.
|
||||
|
||||
## 가드가 정의한 결함
|
||||
|
||||
:::evidence key="analysis-finding-a06-f002" alt="저장소 루트에서 돌린 정적 검색 출력 162줄. 먼저 MongoNamespaceContractTest 14~31번 줄이 실린다. 17~18번 자바독은 spring.data.mongodb 로 시작하는 키가 스프링 부트 4 메타데이터에서 오류 수준으로 폐기됐고 정본이 spring.mongodb 라고 적고, 18~22번은 런타임이 한 번도 틀린 쪽에 있지 않았으며 모든 Compose 레인이 SPRING_MONGODB_URI 를 줬는데 MongoPersistenceSettings 의 자바독이 운영자를 폐기된 키로 안내했고 자바독이 그것을 그 표류가 앉기에 가장 나쁜 자리라고 적는다고 옮긴다. 24~26번이 결함을 정의하는데 주석은 제거하고 보며 옛 네임스페이스가 폐기됐다고 기록한 문장은 결함의 반대이고 결함은 문서가 운영자에게 그것을 쓰라고 말하는 것이며, 자원 파일은 통째로 본다고 적는다. 30번이 RETIRED_NAMESPACE 상수다. 이어서 32~65번의 두 시험이 실린다. 34번 시험은 주석을 뗀 자바 소스를 훑고 37~39번이 그 목록이 비지 않았는지 먼저 단언하며, 56번 시험은 yml 과 properties 를 통째로 보는데 같은 단언이 없다. 다음으로 67~110번의 범위 코드가 실린다. 68~70번 withoutJavaComments 가 블록 주석과 줄 주석을 지우고, 72~90번 productionSources 가 73번에서 repositoryRoot 아래 src 를 루트로 잡아 74번의 두 리프를 훑으며 81번이 확장자로, 82번이 경로에 /src/main/ 이 있는지로, 83번이 /build/ 를 빼는 것으로 거른다. 100~110번 repositoryRoot 는 현재 디렉터리에서 src/config/architecture/modules.json 이 나올 때까지 부모로 올라가고 못 찾으면 107번이 예외를 던진다. 이어서 시험 소스를 뺀 그 키의 등장 자리가 나오는데 docs/superpowers 아래 계획과 증거와 설계 문서 셋, persistence-mongo 의 CLAUDE.md 와 README.md, 그리고 MongoPersistenceSettings.java 여섯이다. 그중 범위 안은 마지막 하나뿐이고 나머지 다섯은 범위 밖이다. 다음으로 MongoPersistenceSettings 5~22번이 실리는데 9~12번이 연결 URI 를 여기서 모델링하지 않고 스프링 자신의 spring.mongodb.uri 에서 읽으며 이 클래스는 모듈의 opt-in 스위치만 소유한다고 적고, 14~19번이 정본 네임스페이스와 폐기된 쪽을 대비하면서 이 자바독이 폐기된 쪽을 가리켰던 것이 왜 나쁜 자리였는지와 MongoNamespaceContractTest 가 되돌아가는 것을 막는다고 적는다. 그 키가 나오는 줄은 15번 하나이고 자바독 안이다. 이어서 운영자가 읽는 두 문서가 실린다. README.md 35~38번은 properties 코드 블록인데 36번이 모듈 스위치를 켜고 37번이 spring.data.mongodb.uri 를 mongodb://localhost:27017/portfolio 로 적으며, 52~54번은 URI 와 데이터베이스와 자격증명이 그 표준 설정을 쓴다고 적고, CLAUDE.md 24~26번도 연결 URI 와 데이터베이스가 그 설정에서 온다고 적는다. 마지막으로 시험 소스에 그 키가 나오는 자리가 실리는데, MongoNamespaceContractTest 17번과 30번은 자바독과 상수이고 MongoPersistenceConfigTest 20번과 64번은 withPropertyValues 로 spring.data.mongodb.database 를 실제로 바인딩한다." caption="가드가 결함을 정의하는 자바독 · 두 시험과 자바 소스 쪽에만 있는 자기 검증 · 범위를 만드는 코드와 저장소 루트 탐색 · 그 키가 남은 여섯 파일과 범위 판정 · 범위 안 하나가 자바독인 것 · 범위 밖 두 문서의 원문 · 시험 소스의 실제 바인딩 둘 — 162줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
`:17`\~`:18` 은 `spring.data.mongodb.*` 가 스프링 부트 4 메타데이터에서 오류 수준으로 폐기됐고 정본이 `spring.mongodb.*` 라고 적는다.
|
||||
|
||||
`:18`\~`:22` 는 런타임이 한 번도 틀린 쪽에 있지 않았다고 적는다. 모든 Compose 레인이 `SPRING_MONGODB_URI` 를 준다. 뒤처진 것은 문서 쪽이다. 스위치를 소유한 클래스를 읽은 운영자가 거기 적힌 프로퍼티를 설정하면, 고르지 않은 폐기를 자기가 고르지 않은 채 물려받는다고 자바독은 적는다.
|
||||
|
||||
`:24`\~`:26` 이 결함을 정의한다. 주석은 제거하고 보는데, 옛 네임스페이스가 폐기됐다고 기록한 문장은 결함의 반대이기 때문이다. 결함은 문서가 운영자에게 그것을 쓰라고 말하는 것이다. 자원 파일은 통째로 보는데, YAML 안의 키는 주석일 수 없기 때문이다.
|
||||
|
||||
## 두 시험과 자바 소스 쪽에만 있는 자기 검증
|
||||
|
||||
`:34` 의 `noProductionSourceNamesTheDeprecatedNamespace` 는 주석을 뗀 자바 소스를 훑는다. `:38`\~`:39` 가 목록이 비지 않았는지 먼저 단언하는데, 아무 소스에도 닿지 않은 검색은 모든 참조를 없다고 보고하기 때문이다.
|
||||
|
||||
`:56` 의 `noShippedResourceBindsTheDeprecatedNamespace` 는 `.yml` 과 `.properties` 를 통째로 본다. 이쪽에는 목록이 비지 않았는지 보는 단언이 없다. 자원 스캔이 0 개를 훑어도 조용히 통과한다.
|
||||
|
||||
## 탐색 범위
|
||||
|
||||
`:72`\~`:90` 의 `productionSources` 가 범위를 만든다. `:73` 이 `repositoryRoot()` 아래 `src` 를 루트로 잡고, `:74` 가 `adapter/outbound/persistence-mongo` 와 `app-bootstrap` 둘을 훑으며, `:81` 이 확장자로, `:82` 가 경로에 `/src/main/` 이 있는지로, `:83` 이 `/build/` 를 빼는 것으로 거른다.
|
||||
|
||||
`repositoryRoot():100`\~`:110` 은 현재 작업 디렉터리에서 `src/config/architecture/modules.json` 이 나올 때까지 부모로 올라간다. 못 찾으면 `:107` 이 예외를 던진다.
|
||||
|
||||
확장자는 두 시험이 넘기는 `.java` 와 `.yml` 과 `.properties` 셋이다.
|
||||
|
||||
## 그 키가 남아 있는 여섯 파일
|
||||
|
||||
시험 소스를 빼면 여섯이다. `docs/superpowers` 아래 계획·증거·설계 문서 셋, `persistence-mongo/CLAUDE.md`, `persistence-mongo/README.md`, 그리고 `MongoPersistenceSettings.java` 다.
|
||||
|
||||
범위 안에 드는 것은 마지막 하나다. 나머지 다섯은 확장자가 `.md` 이거나 경로에 `/src/main/` 이 없다. 두 문서는 모듈 루트에 있으므로 둘 다에 해당한다.
|
||||
|
||||
## 범위 안에 든 하나는 MongoPersistenceSettings 의 자바독이다
|
||||
|
||||
`MongoPersistenceSettings:5`\~`:22` 에서 그 키가 나오는 줄은 `:15` 하나다. `:9`\~`:12` 가 연결 URI 를 여기서 모델링하지 않고 스프링 자신의 `spring.mongodb.uri` 에서 읽는다고 적고, 이 클래스는 모듈의 opt-in 스위치만 소유한다고 적는다. 자바독 안이고, 이 자바독이 폐기된 쪽을 가리켰던 과거와 그것이 왜 나쁜 자리였는지를 기록한다. `:19` 는 그 계약 시험이 되돌아가는 것을 막는다고도 적는다.
|
||||
|
||||
가드가 주석을 떼고 보는 이유가 이 문장이다.
|
||||
|
||||
## 범위 밖 두 문서는 결함의 정의에 그대로 들어맞는다
|
||||
|
||||
`README.md:35`\~`:38` 은 `properties` 코드 블록이다. `:36` 이 모듈 스위치를 켜고 `:37` 이 `spring.data.mongodb.uri=mongodb://localhost:27017/portfolio` 를 적는다. 그대로 옮겨 붙일 수 있는 완전한 한 줄이다.
|
||||
|
||||
`:52`\~`:54` 는 URI 와 데이터베이스와 자격증명이 표준 `spring.data.mongodb.*` 설정을 쓴다고 적는다.
|
||||
|
||||
`CLAUDE.md:24`\~`:26` 도 연결 URI 와 데이터베이스가 그 설정에서 온다고 적는다.
|
||||
|
||||
셋 다 폐기 사실을 기록하는 문장이 아니라 그 키를 쓰라는 안내다.
|
||||
|
||||
## 시험 소스에는 실제 바인딩이 둘 있다
|
||||
|
||||
`MongoPersistenceConfigTest:20` 이 `withPropertyValues("spring.data.mongodb.database=portfolio")` 를 부르고, `:64` 가 같은 키를 모듈 스위치와 함께 넘긴다.
|
||||
|
||||
산문이 아니라 실제 프로퍼티 바인딩이다. 범위가 `/src/main/` 을 요구하므로 이쪽도 두 단언에 걸리지 않는다.
|
||||
|
||||
## 원문에 없는 것
|
||||
|
||||
원문은 가드의 탐색 범위가 운영자가 읽는 두 문서를 덮지 않는다고 적는다. 여기에 더한 것은 범위 안에 남은 것이 무엇이고 범위 밖에 또 무엇이 있는지다.
|
||||
|
||||
범위 안의 유일한 등장 `MongoPersistenceSettings:15` 는 이미 고쳐져 기록만 남은 문장이고 `withoutJavaComments` 가 지운다. 그래서 두 단언이 지금 걸 수 있는 문장은 범위 안에 하나도 없다. 범위 밖에는 문서 둘 말고도 시험 소스의 실제 바인딩 둘이 더 있다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
그 안내를 따라 폐기된 키를 넣은 배포가 실제로 있었는지는 저장소 밖의 일이다.
|
||||
|
||||
확장자 조건에 마크다운을 더하면 계획 문서 셋도 걸릴 텐데, 그것을 가르는 방법은 생각해 두지 않았다.
|
||||
|
||||
그 계약 시험을 돌리지 않았다. 단언과 범위 코드를 읽는 데까지다.
|
||||
|
||||
<!-- body:end -->
|
||||
+176
File diff suppressed because one or more lines are too long
+170
File diff suppressed because one or more lines are too long
+157
@@ -0,0 +1,157 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: analysis-finding-a06-f007
|
||||
title: 게이트웨이가 적은 순서의 timeout 단계가 정책에 없다
|
||||
topic: multitenancy-isolation
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:analysis-finding-a06-f007
|
||||
evidenceCapturedOn: 2026-09-04
|
||||
body: case-analysis-finding-a06-f007.body.md
|
||||
assets:
|
||||
- key: analysis-finding-a06-f007
|
||||
file: ../../../final/evidence/rendered/analysis-finding-a06-f007.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/analysis-finding-a06-f007.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/06-adapter-outbound-persistence-mongo.md §25 이다.
|
||||
---
|
||||
|
||||
# 게이트웨이가 적은 순서의 timeout 단계가 정책에 없다
|
||||
|
||||
`PolicyAwareMongoNativeGateway:11`~`:12` 의 일곱 단계와 `README.md:65`~`:67` 의 열한 단계에 타임아웃이 들어 있다. `MongoNativeOperationPolicy.require` 가 던지는 거부는 여섯이고 타임아웃을 보는 것이 없으며, `ApprovedMongoNativeOperation` 이 선언한 두 예산 값은 어디서도 읽히지 않는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **단일 admission point는 우회 경로를 세어야 성립한다**
|
||||
이 게이트웨이는 우회 경로가 아니라 자기가 열거한 단계가 문제다. 정책이 던지는 거부 여섯 가운데 등록 여부와 지원 등급은 두 문서의 목록에 아예 없고, 목록에 있는 타임아웃에는 거부가 없다.
|
||||
- **문서와 상수가 서로 일치하는 것으로는 아무것도 증명되지 않는다**
|
||||
자바독과 README 가 서로 다른 목록을 적는데 둘 다 코드보다 길다.
|
||||
- **명령 카탈로그와 admission 아홉 단계**
|
||||
Redis 쪽도 자바독이 승인 지점의 검사 순서를 이름으로 열거한 기록이다. 그 개념은 순서의 기준이 비용이라고 적는다.
|
||||
|
||||
## 문제
|
||||
|
||||
D3 게이트웨이는 자기 자바독과 모듈 README 양쪽에서 검사 순서를 이름으로 열거한다. 두 목록의 길이가 서로 다르고 둘 다 타임아웃을 담는다.
|
||||
|
||||
그 단계들이 코드에 있는지 확인했다.
|
||||
|
||||
## 결론
|
||||
|
||||
게이트웨이 자신은 세 가지만 한다. :36 이 policy.require(operation) 을 부르고, :39 가 본문을 실행하고, :43 이 감사 기록을 남긴다.
|
||||
|
||||
검사는 전부 정책에 있고 그 안의 거부는 여섯이다. 등록 여부와 능력 일치와 지원 등급과 두 허용 목록과 관리 범주 차단이며, 각각 :56·:64·:75·:80·:85·:92 에서 던진다.
|
||||
|
||||
자바독이 적은 일곱 단계 가운데 대응하는 거부가 없는 것은 타임아웃 하나다. ApprovedMongoNativeOperation:26 이 Duration timeout 을 선언하지만, 그 필드가 쓰이는 곳은 :36 의 널 검사와 :40 의 음수 거절과 :58 의 Duration.ZERO 와 :65 의 hasBody() 넷뿐이고 서버로 나가는 자리가 없다.
|
||||
|
||||
hasBody() 는 정의만 있고 부르는 코드가 없다.
|
||||
|
||||
:27 의 int maxResults 는 :59 가 0 을 넣는 것 말고 등장하지 않는다. 두 접근자를 부르는 줄이 그 타입을 참조하는 main 파일 넷 전체에서 0 개다.
|
||||
|
||||
.timeout() 과 .maxResults() 를 실제로 부르는 줄은 같은 모듈에 열여덟 있는데 수신 타입이 전부 다르다. PolicyAwareMongoAggregationExecutor:78 의 effective, DefaultMongoImperativeExecutor:93 의 context, MongoReactiveCursorPublisher:59 의 budget, SpringMongoTransactionSessionFactory:74 의 profile 같은 것들이다.
|
||||
|
||||
README 쪽 목록에만 있는 다섯 — 연산 이름, 일관성, 결과 제한, 추적, 편집 — 도 마찬가지다.
|
||||
|
||||
지금은 이 게이트웨이를 지나는 호출이 없다. new PolicyAwareMongoNativeGateway 를 부르는 main 줄이 0 이고 시험은 2 이며, MongoNativeCapabilityGateway 와 PolicyAwareMongoNativeGateway 가 main 에 나오는 셋은 전부 선언이다.
|
||||
|
||||
그런데도 README 는 이 클래스를 D3 안전성의 근거로 든다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
확인 방식 : 게이트웨이 자바독과 README 의 단계 목록 인용, 게이트웨이 본문 인용, 정책의 require 전문 인용과 거부 자리 계수, 값 타입의 선언과 압축 생성자와 hasBody 인용, 그 타입을 참조하는 main 파일 전수와 그 안의 접근자 호출 계수, 같은 이름 접근자의 다른 타입 호출 전수, 두 게이트웨이 이름의 등장 자리 전수와 생성 계수
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 게이트웨이 자바독과 README 의 두 단계 목록을 나란히 싣는다.
|
||||
2. 게이트웨이의 execute 본문을 인용한다.
|
||||
3. 정책의 require 를 전문으로 싣고 그 안에서 예외를 던지는 자리를 센다.
|
||||
4. 값 타입의 필드 선언과 압축 생성자와 hasBody 를 인용한다.
|
||||
5. 그 타입을 참조하는 main 파일을 전부 찾고 그 안에서 .timeout() 과 .maxResults() 를 부르는 줄을 센다.
|
||||
6. 같은 이름의 접근자를 부르는 모듈 전체 줄을 나열해 수신 타입을 대조한다.
|
||||
7. 두 게이트웨이 이름이 나오는 자리를 소스 세트와 함께 전부 나열하고 생성 줄을 센다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`PolicyAwareMongoNativeGateway` 는 D3 능력 평면의 게이트웨이다. 클래스 자바독이 검사 순서를 적는다.
|
||||
|
||||
## 두 문서가 적은 순서
|
||||
|
||||
:::evidence key="analysis-finding-a06-f007" alt="저장소 루트에서 돌린 정적 검색 출력 180줄. 먼저 PolicyAwareMongoNativeGateway 8~15번 자바독이 실린다. 이것이 D3 능력 게이트웨이이며 설계가 서술한 순서를 실행하고 첫 거부에서 멈춘다고 적는데 그 순서가 등록과 능력과 데이터베이스 프로파일과 컬렉션 프로파일과 타임아웃과 범주 그리고 실행이고, 감사 기록은 연산 아이디와 결과만 담고 BSON 인자는 절대 담지 않으며 데이터를 흘리는 감사 기록은 통제가 아니라 부담이라고 적는다. 그 아래 README 65~67번이 실리는데 D3 는 원시 클라이언트 탈출구가 아니며 이 게이트웨이가 능력에서 데이터베이스 프로파일과 컬렉션 허용 목록과 연산 이름과 타임아웃과 일관성과 결과 제한과 추적과 편집과 명령 범주를 거쳐 D4 차단까지 순서를 고정한다고 적는다. 이어서 게이트웨이 33~46번의 execute 가 실리는데 35번이 널 검사, 36번이 policy.require, 39번이 본문을 데이터베이스 리졸버에 적용해 실행, 43번이 finally 에서 감사 기록을 남기는 것이 전부다. 다음으로 MongoNativeOperationPolicy 52~100번의 require 가 전문으로 실린다. 54~62번이 등록되지 않은 연산을 거절하며 플랫폼은 미리 등록된 구현만 실행하고 호출자가 준 명령은 절대 실행하지 않는다고 적고, 63~72번이 등록된 능력과 제출된 능력이 다르면 거절하며, 73~78번이 지원 등급이 UNSUPPORTED 면 제약과 함께 거절하고, 79~83번이 데이터베이스 프로파일 허용 목록을, 84~90번이 컬렉션 프로파일 허용 목록을 검사하며, 91~97번이 능력 평면에서 허용되지 않는 범주를 거절하면서 drop 과 collMod 와 샤드와 사용자 관리는 자기 자격증명을 가진 D4 관리 평면에서 돈다고 적는다. 그 아래 require 안에서 예외를 던지는 자리가 6 개라고 나온다. 이어서 ApprovedMongoNativeOperation 20~43번이 실리는데 20~28번 record 헤더가 operationId 와 capability 와 category 와 databaseProfile 과 collectionProfile 과 Duration timeout 과 int maxResults 와 body 여덟을 선언하고, 30~43번 압축 생성자가 널 검사 다섯과 빈 아이디 거절과 40~42번의 음수 타임아웃 거절을 한다. 44~61번에는 unregistered 팩터리가 있는데 45~50번 자바독이 이름만 있고 본문이 없는 연산이며 아무도 등록하지 않은 아이디는 실행될 수 없고 그것을 요청하는 모양이 이것이라 게이트웨이의 거부 경로가 도달 가능하고 시험 가능하게 하려고 존재한다고 적고, 58번이 Duration.ZERO 를 59번이 0 을 넣는다. 62~67번의 hasBody 는 timeout 이 양수인지를 돌려준다. 다음으로 ApprovedMongoNativeOperation 을 참조하는 main 파일이 자기 자신과 MongoNativeCapabilityGateway 와 MongoNativeOperationPolicy 와 PolicyAwareMongoNativeGateway 넷이라고 나오고, 그 파일들 안에서 점 timeout 괄호나 점 maxResults 괄호를 부르는 줄이 0 개이며, timeout 이라는 이름이 나오는 줄은 36번의 널 검사와 41번의 음수 거절과 58번의 Duration.ZERO 와 65번의 양수 검사이고, hasBody 를 부르는 줄이 0 개인데 선언까지 포함하면 1 개다. 이어서 대조로 같은 모듈에서 그 두 접근자를 실제로 부르는 열여덟 줄이 나열되는데 PolicyAwareMongoAggregationExecutor 78번과 84번과 92번과 94번, DefaultMongoImperativeExecutor 83번과 93번과 103번, PolicyAwareMongoQueryBuilder 199번, MongoBudgetEnforcer 32번과 40번, DefaultReactiveMongoExecutor 76번과 108번과 154번, MongoReactiveCursorPublisher 59번, MongoResultBudgetTracker 51번과 54번, SpringMongoTransactionSessionFactory 74번, SpringReactiveMongoTransactionSessionFactory 76번이며 수신자가 effective 와 context 와 registered 와 budget 과 requested 와 profile 로 전부 다른 타입이다. 마지막으로 두 게이트웨이 이름이 나오는 자리 전부가 소스 세트와 함께 나오는데 main 은 MongoNativeCapabilityGateway 10번의 인터페이스 선언과 PolicyAwareMongoNativeGateway 16번의 클래스 선언과 24번의 생성자 셋뿐이고 나머지 여섯은 PolicyAwareMongoNativeGatewayTest 의 것이며, new PolicyAwareMongoNativeGateway 를 부르는 main 줄이 0 개이고 시험에서는 2 개다." caption="게이트웨이 자바독의 일곱 단계와 README 의 열한 단계 · execute 가 하는 세 가지 · require 전문과 거부 여섯 · 값 타입이 선언한 timeout 과 maxResults 와 hasBody · 그 타입을 참조하는 넷 안에서 접근자 호출 0 · 같은 접근자를 실제로 적용하는 다른 타입 열여덟 줄 · main 참조 셋과 생성 0 — 180줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
`:11`\~`:12` 가 일곱을 적는다. 등록, 능력, 데이터베이스 프로파일, 컬렉션 프로파일, 타임아웃, 범주, 그리고 실행이다. 설계가 서술한 순서를 실행하고 첫 거부에서 멈춘다고 적는다.
|
||||
|
||||
`README.md:65`\~`:67` 은 더 길다. 능력에서 시작해 데이터베이스 프로파일과 컬렉션 허용 목록과 연산 이름과 타임아웃과 일관성과 결과 제한과 추적과 편집과 명령 범주를 거쳐 D4 차단까지 열한 단계다.
|
||||
|
||||
## execute 안의 세 줄
|
||||
|
||||
`:34` 의 `execute` 안에서 `:36` 이 `policy.require(operation)` 을 부른다.
|
||||
|
||||
`:39` 가 `operation.body()` 를 데이터베이스 리졸버가 준 `MongoDatabase` 에 적용한다.
|
||||
|
||||
`:43` 이 `finally` 에서 연산 아이디와 성공 여부를 감사 기록기에 넘긴다.
|
||||
|
||||
검사는 전부 정책에 있다.
|
||||
|
||||
## 정책이 던지는 거부는 여섯이다
|
||||
|
||||
`MongoNativeOperationPolicy.require:52`\~`:100` 안에서 `MongoOperationRejectedException` 을 던지는 자리가 여섯이다.
|
||||
|
||||
`:56` 이 등록되지 않은 연산을 거절한다. `:58`\~`:61` 의 메시지가 플랫폼은 미리 등록된 구현만 실행하고 호출자가 준 명령은 절대 실행하지 않는다고 적는다.
|
||||
|
||||
`:64` 가 등록된 능력과 제출된 능력의 불일치를, `:75` 가 `UNSUPPORTED` 등급을, `:80` 이 데이터베이스 프로파일 허용 목록을, `:85` 가 컬렉션 프로파일 허용 목록을 본다.
|
||||
|
||||
`:92` 가 능력 평면에서 허용되지 않는 범주를 거절하면서, `drop` 과 `collMod` 와 샤드와 사용자 관리는 자기 자격증명을 가진 D4 관리 평면에서 돈다고 적는다.
|
||||
|
||||
타임아웃을 보는 자리가 없다.
|
||||
|
||||
## 선언은 돼 있고 읽히지 않는다
|
||||
|
||||
`ApprovedMongoNativeOperation:26` 이 `Duration timeout` 을, `:27` 이 `int maxResults` 를 선언한다.
|
||||
|
||||
`timeout` 이 쓰이는 자리는 넷이다. `:36` 이 널 검사를 하고, `:40`\~`:42` 가 음수를 거절하고, `:58` 이 `unregistered` 팩터리에서 `Duration.ZERO` 를 넣고, `:65` 의 `hasBody()` 가 양수인지를 돌려준다. 값을 검사하거나 채울 뿐 서버에 보내는 자리가 없다.
|
||||
|
||||
`:45`\~`:50` 자바독이 그 팩터리의 목적을 적는다. 아무도 등록하지 않은 아이디는 실행될 수 없고 그것을 요청하는 모양이 이것이라, 게이트웨이의 거부 경로가 도달 가능하고 시험 가능하게 하려는 것이다. `:59` 가 `maxResults` 자리에 `0` 을 넣는다.
|
||||
|
||||
`hasBody()` 를 부르는 줄은 0 개다. 선언까지 포함해도 1 이므로, 그 이름이 나오는 자리는 정의 한 줄뿐이다.
|
||||
|
||||
`maxResults` 는 `:27` 의 선언 이후 한 번도 등장하지 않는다. `ApprovedMongoNativeOperation` 을 참조하는 main 파일 넷 — 자기 자신과 `MongoNativeCapabilityGateway` 와 `MongoNativeOperationPolicy` 와 `PolicyAwareMongoNativeGateway` — 안에서 `.timeout()` 이나 `.maxResults()` 를 부르는 줄이 0 개다.
|
||||
|
||||
## .timeout() 과 .maxResults() 를 부르는 열여덟 줄
|
||||
|
||||
같은 모듈에서 그 두 접근자를 부르는 줄은 열여덟이다. 수신자가 전부 다른 타입이다.
|
||||
|
||||
`PolicyAwareMongoAggregationExecutor:78` 은 `effective.maxResults()` 로 결과 수를 견주고 `:92` 는 `context.timeout()` 을 밀리초로 바꾼다. `DefaultMongoImperativeExecutor:93` 은 경과 시간을 `context.timeout()` 과 견준다. `MongoReactiveCursorPublisher:59` 는 `budget.maxResults()` 로 커서를 제한하고, `MongoResultBudgetTracker:51` 은 그 수를 넘으면 실패시킨다. `SpringMongoTransactionSessionFactory:74` 는 `profile.timeout()` 을 트랜잭션 옵션에 넣는다.
|
||||
|
||||
같은 모듈의 다른 타입들은 두 접근자를 열여덟 줄에서 실제로 적용한다. `ApprovedMongoNativeOperation` 이 선언한 두 값만 어디서도 읽히지 않는다.
|
||||
|
||||
README 가 적은 일관성과 추적과 편집과 연산 이름 단계는 정책에 대응하는 거부가 아예 없다.
|
||||
|
||||
## 지금 조립되지 않는다
|
||||
|
||||
두 이름이 main 에 나오는 줄은 셋이다. `MongoNativeCapabilityGateway:10` 의 인터페이스 선언과 `PolicyAwareMongoNativeGateway:16` 의 클래스 선언과 `:24` 의 생성자다.
|
||||
|
||||
`new PolicyAwareMongoNativeGateway` 를 부르는 main 줄이 0 이다. 나머지 여섯 줄은 `PolicyAwareMongoNativeGatewayTest` 의 것이다.
|
||||
|
||||
그래도 `README.md:65` 는 D3 가 원시 클라이언트 탈출구가 아닌 근거로 이 클래스를 든다. 그 클래스를 만드는 main 줄은 0 이고, 자바독이 적은 일곱 단계 중 타임아웃에 대응하는 거부가 없으며, README 에만 있는 다섯 단계에도 없다.
|
||||
|
||||
## 원문에 없는 것
|
||||
|
||||
원문은 문서가 열거한 단계 중 여럿이 코드에 없고 그중 둘은 값 타입이 필드로 선언까지 해 둔 것이라고 적는다.
|
||||
|
||||
여기에 더한 것은 그 판정을 검색 실패와 가르는 대조다. `.timeout()` 과 `.maxResults()` 는 같은 모듈에서 열여덟 번 불리는데 전부 다른 타입이고, D3 값 타입을 참조하는 파일 넷 안에서만 0 이다. `hasBody()` 도 저장소 전체에서 호출자가 없다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
배선된 뒤에 어느 단계가 꼭 있어야 하는지는 설계 문서를 따로 읽어 판단하지 않았다.
|
||||
|
||||
선언된 타임아웃을 실제로 걸려면 드라이버의 어느 옵션에 실어야 하는지 조사하지 않았다.
|
||||
|
||||
추적과 편집이 다른 계층에서 이미 이뤄지는지는 이 모듈 밖이라 보지 않았다.
|
||||
|
||||
<!-- body:end -->
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: analysis-finding-a06-f008
|
||||
title: 데드라인을 서버로 보내는 접근자를 부르는 프로덕션 코드가 없다
|
||||
topic: multitenancy-isolation
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:analysis-finding-a06-f008
|
||||
evidenceCapturedOn: 2026-09-04
|
||||
body: case-analysis-finding-a06-f008.body.md
|
||||
assets:
|
||||
- key: analysis-finding-a06-f008
|
||||
file: ../../../final/evidence/rendered/analysis-finding-a06-f008.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/analysis-finding-a06-f008.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/06-adapter-outbound-persistence-mongo.md §32 이다.
|
||||
---
|
||||
|
||||
# 데드라인을 서버로 보내는 접근자를 부르는 프로덕션 코드가 없다
|
||||
|
||||
`BoundScopedOperations` 가 질의에 데드라인을 붙이는 자리는 `:53` 과 `:98` 둘이다. 그 타입을 돌려주는 `scoped()` 를 부르는 줄이 저장소 전체에 0 이고, `SpringMongoGeospatialOperations` 와 `MongoAtomicOperationsTemplate` 와 `MongoBulkExecutor` 는 `MongoRawAccessBoundaryTest:20` 이 데드라인이 없다고 적은 `rawOperations()` 를 다섯 번 부른다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **데드라인은 호출 예산에서 시작해 세 단계로 좁힌다**
|
||||
그 규칙의 세 번째 단계인 데이터베이스 로컬 타임아웃이 MongoDB 에서는 질의에 `maxTimeMS` 를 붙이는 것이다. 그 값을 붙이는 타입이 이 리프에 있는데 호출자가 없다.
|
||||
- **쓰기 트랜잭션에는 유한 타임아웃이 필수다**
|
||||
`MongoOperationContext` 가 양수 타임아웃을 요구하므로 선언은 언제나 유한하다. 그 값이 서버까지 가는지는 별개이고, 여기서는 세 경로에서 가지 않는다.
|
||||
- **@Bean이 있다는 것은 조립 증거가 아니다**
|
||||
그 규칙은 프로덕션 참조가 0 이면 조립되지 않은 것으로 본다. `scoped()` 는 참조가 0 은 아니어서 인터페이스 둘과 구현 둘에 선언이 있는데, 그것을 부르는 프로덕션 코드만 없다.
|
||||
|
||||
## 문제
|
||||
|
||||
모든 연산은 타임아웃을 선언해야 한다. 그 값을 maxTimeMS 로 붙여 서버까지 보내는 타입이 BoundScopedOperations 다.
|
||||
|
||||
그 타입에 실제로 도달하는 경로가 있는지 확인했다.
|
||||
|
||||
## 결론
|
||||
|
||||
그 자바독 :19~:23 은 질의에 maxTimeMS 를 함께 보내는지가 데드라인을 실제로 거는 것과 초과를 보고만 하는 것을 가르는 차이라고 적는다. 사후 측정은 초과를 알려 줄 뿐이고 서버가 받은 숫자는 질의를 끊는다는 것이다. :51~:54 의 bounded 가 질의 형태 메서드에 maxTimeMsec 을 붙이고 :95~:99 가 집계에 maxTime 을 붙인다.
|
||||
|
||||
ScopedMongoOperations 를 돌려주는 메서드는 MongoCollectionAccess:32 의 scoped() 하나인데, 자바 파일 6444 개를 훑어도 그것을 부르는 줄이 없다.
|
||||
|
||||
대신 SpringMongoGeospatialOperations:58·:82 와 MongoAtomicOperationsTemplate:84·:92 와 MongoBulkExecutor:69 가 MongoPlatformCollectionAccess:19 의 rawOperations() 를 쓴다.
|
||||
|
||||
그것이 실수가 아니라는 것은 MongoPlatformCollectionAccess:6~:14 와 MongoRawAccessBoundaryTest:24~:26 두 곳에 적혀 있다. 네 실행기가 드라이버 수준 명령을 만들려면 제한 없는 템플릿이 필요하다는 것이고, 그래서 규칙은 그 손잡이를 지우는 것이 아니라 플랫폼 밖에서 부르지 못하게 하는 것이다.
|
||||
|
||||
그 다섯 줄이 받는 MongoOperations 에는 데드라인이 붙지 않는다. MongoRawAccessBoundaryTest:18~:20 이 그 손잡이가 컬렉션 결속도 일관성 템플릿도 데드라인도 결과 예산도 없는 무제한 템플릿을 내준다고 적는다. 세 파일 안에서 시간 제한을 거는 줄도 0 인데, 같은 검색이 BoundScopedOperations 에서는 8 줄을 찾는다.
|
||||
|
||||
서버에 데드라인이 붙는 경로는 따로 셋 있다. PolicyAwareMongoAggregationExecutor:96 이 등록된 예산과 컨텍스트 밀리초의 최솟값을 쓰고, PolicyAwareMongoQueryBuilder:200 과 MongoReactiveCursorPublisher:58 은 등록된 예산 값만 쓴다.
|
||||
|
||||
context.timeout() 을 읽는 자리를 전부 세면 예산을 좁히는 것은 PolicyAwareMongoAggregationExecutor:92 하나다. 나머지는 DefaultMongoImperativeExecutor 의 사후 검사와 DefaultReactiveMongoExecutor 의 클라이언트 쪽 timeout 연산자다. 연산이 선언한 타임아웃이 서버까지 닿는 길은 집계 하나이고, 그것도 등록된 예산과의 최솟값으로만 간다. 나머지 셋에는 사후 검사만 남는데, DefaultMongoImperativeExecutor:94~:96 의 주석이 그 검사가 작업을 끊을 수 없다고 스스로 적는다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
확인 방식 : 그 타입의 자바독과 데드라인을 붙이는 두 자리 인용, 두 접근자의 선언과 raw 접근이 의도된 이유를 적은 자바독 인용, 두 이름을 부르는 줄을 저장소 전체에서 계수하고 등장 자리 전수와 훑은 파일 수 대조, rawOperations() 구현 인용, 집계 실행기의 데드라인 계산과 maxTimeMillis() 와 context.timeout() 을 부르는 자리 전수, 세 실행기의 조립 상태와 그 안의 데드라인 계수와 대조, 경계 가드 시험의 자바독과 금지 목록 인용, 사후 검사 분기와 그 주석 인용
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. BoundScopedOperations 의 클래스 자바독과 maxTime 을 거는 줄을 인용한다.
|
||||
2. scoped() 와 rawOperations() 의 선언을 두 인터페이스에서 인용한다.
|
||||
3. 두 이름을 부르는 줄을 각각 세고, 선언까지 포함해 등장 자리를 전부 나열하고, 훑은 파일 수를 함께 낸다.
|
||||
4. rawOperations() 구현이 무엇을 돌려주는지 인용한다.
|
||||
5. 집계 실행기가 데드라인을 만드는 자리와 maxTimeMillis() 를 부르는 자리를 전부 인용한다.
|
||||
6. 사후 경과 검사 분기와 그 주석을 인용한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`MongoOperationContext` 는 모든 연산에 타임아웃을 선언하게 한다. 그 값이 드라이버 명령에 실려 나가려면 질의에 `maxTimeMS` 로 붙어야 한다.
|
||||
|
||||
## BoundScopedOperations 가 존재하는 이유
|
||||
|
||||
:::evidence key="analysis-finding-a06-f008" alt="저장소 루트에서 돌린 정적 검색 출력 214줄. 먼저 BoundScopedOperations 12~27번 클래스 자바독이 실린다. 13~17번은 이것이 물리 컬렉션 하나 위의 ScopedMongoOperations 이고 모든 메서드가 이 범위가 만들어질 때 받은 컬렉션을 넘기며, 호출자가 다른 것을 줄 수 없으므로 등록된 컬렉션 프로파일과 거기서 파생된 테넌트 경계가 호출자의 협조 없이 성립한다고 적는다. 19~23번은 모든 질의 형태 메서드가 연산의 데드라인을 maxTimeMS 로 함께 보내며 그것이 데드라인과 그것에 대한 보고를 가르는 차이라고 적고, 블로킹 실행기는 콜백이 반환된 뒤에야 경과 시간을 잴 수 있으며 자바 콜백은 드라이버 호출 중간에 끊을 수 없어서 예산을 넘긴 연산은 탐지될 뿐 중단되지 않았지만 같은 숫자를 서버로 보내면 작업이 끝난다고 적는다. 25~26번은 그것이 Query 나 Aggregation 을 받는 메서드에 적용되며 insert 는 붙일 질의가 없다고 적는다. 그 아래 maxTimeMS 가 실제로 붙는 자리로 자바독 19번과 98번의 점 maxTime 괄호 timeout 이 나온다. 이어서 MongoCollectionAccess 24~36번과 MongoPlatformCollectionAccess 10~24번이 실려 각각 scoped 와 rawOperations 선언을 보인다. 다음으로 scoped 를 부르는 줄이 0 개이고 rawOperations 를 부르는 줄이 5 개라고 나오며, 두 이름이 나오는 자리가 소스 세트와 함께 전부 나열된다 — SpringMongoGeospatialOperations 58번과 82번, DefaultMongoImperativeExecutor 184번과 192번의 선언, MongoCollectionAccess 32번과 MongoPlatformCollectionAccess 19번의 선언, MongoAtomicOperationsTemplate 84번과 92번, MongoBulkExecutor 69번, DefaultReactiveMongoExecutor 218번과 ReactiveMongoCollectionAccess 31번의 리액티브 쪽 선언이며 전부 main 이다. 그 검색이 훑은 파일은 491 개다. 이어서 DefaultMongoImperativeExecutor 180~196번이 실려 184번의 scoped 가 BoundScopedOperations 를 만들고 192~194번의 rawOperations 가 경계 없는 MongoOperations 를 그대로 돌려주는 것이 보인다. 다음으로 PolicyAwareMongoAggregationExecutor 88~108번이 실리는데 91번 주석이 컨텍스트의 타임아웃은 호출자의 데드라인이며 maxTimeMS 를 좁힐 수는 있어도 늘릴 수는 없다고 적고, 96번이 registered.maxTimeMillis 와 contextMillis 의 최솟값을 쓰며, 106번이 그 값을 AggregationOptions 의 maxTime 에 넣는다. 그 아래 maxTimeMillis 를 부르는 자리가 전부 나열되는데 PolicyAwareMongoAggregationExecutor 96번과 106번, PolicyAwareMongoQueryBuilder 200번, MongoReactiveCursorPublisher 58번이다. 마지막으로 DefaultMongoImperativeExecutor 85~106번이 실려 88번이 콜백을 부르고 89번이 경과 시간을 재며 93번이 그것을 컨텍스트 타임아웃과 견주고, 94~96번 주석이 컨텍스트가 모든 연산에 데드라인을 선언하는데 아무것도 그것과 견주지 않았으며 콜백은 드라이버 호출 중간에 끊을 수 없어 이것이 작업을 끊지는 못하지만 예산을 넘긴 작업에 성공을 보고하는 것보다는 낫다고 적는다." caption="데드라인을 서버로 보내는 타입의 자바독과 maxTime 을 거는 줄 · 두 접근자의 선언 · scoped 호출 0 과 rawOperations 호출 다섯의 전수 · rawOperations 가 돌려주는 것 · 서버에 데드라인을 붙이는 다른 세 경로 · 사후 경과 검사와 그 주석 — 214줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
`BoundScopedOperations:15`\~`:17` 이 첫 목적을 적는다. 모든 메서드가 이 범위를 만들 때 받은 컬렉션을 넘기므로 호출자가 다른 것을 줄 수 없고, 등록된 컬렉션 프로파일과 거기서 파생된 테넌트 경계가 호출자의 협조 없이 성립한다.
|
||||
|
||||
`:19`\~`:23` 이 두 번째다. 질의 형태 메서드가 연산의 데드라인을 `maxTimeMS` 로 함께 보내는 것이 데드라인과 그것에 대한 보고를 가르는 차이라는 것이다.
|
||||
|
||||
이유도 함께 있다. 블로킹 실행기는 콜백이 반환된 뒤에야 경과 시간을 잴 수 있고 자바 콜백은 드라이버 호출 중간에 끊을 수 없으므로, 예산을 넘긴 연산은 탐지될 뿐 중단되지 않았다. 같은 숫자를 서버로 보내면 작업이 끝난다.
|
||||
|
||||
붙이는 자리는 둘이다. `:51`\~`:54` 의 private `bounded` 가 `query.maxTimeMsec(timeout.toMillis())` 로 질의 형태 메서드에 붙이고, `:95`\~`:99` 가 `AggregationOptions` 의 `maxTime` 으로 집계에 붙인다. `:25`\~`:26` 은 그것이 `Query` 나 `Aggregation` 을 받는 메서드에만 붙는다고도 적는다. `insert` 에는 붙일 질의가 없다.
|
||||
|
||||
## scoped() 와 rawOperations() 가 돌려주는 것
|
||||
|
||||
`MongoCollectionAccess:32` 의 `scoped()` 가 `ScopedMongoOperations` 를 돌려준다. 콜백이 그것을 받으면 데드라인이 붙은 연산을 쓴다.
|
||||
|
||||
`MongoPlatformCollectionAccess:19` 의 `rawOperations()` 는 `MongoOperations` 를 돌려준다.
|
||||
|
||||
그 인터페이스의 자바독 `:6`\~`:11` 이 존재 이유를 적는다. 벌크와 원자적 연산과 집계와 지리 공간 실행기는 드라이버 수준 명령을 만들기 때문에 제한 없는 템플릿이 필요하고, 그것들은 플랫폼 안에서 가드레일을 설치하는 코드라 호출자의 콜백과 위치가 다르므로, raw 손잡이는 모든 콜백이 받는 것이 아니라 이름을 대고 요청하는 타입이라는 것이다.
|
||||
|
||||
`:13`\~`:14` 가 그것을 `MongoCollectionAccess` 밖에 둔 이유를 적는다. 범위 API 와 탈출구를 함께 제공하는 인터페이스는 탈출구를 제공하는 것이다.
|
||||
|
||||
## scoped() 호출 0 과 rawOperations() 호출 다섯
|
||||
|
||||
`.scoped()` 를 부르는 줄이 0 이다. 나오는 자리는 `MongoCollectionAccess:32` 와 `ReactiveMongoCollectionAccess:31` 의 선언, `DefaultMongoImperativeExecutor:184` 와 `DefaultReactiveMongoExecutor:218` 의 구현 넷뿐이다.
|
||||
|
||||
`.rawOperations()` 를 부르는 줄은 다섯이다. `SpringMongoGeospatialOperations:58` 과 `:82`, `MongoAtomicOperationsTemplate:84` 와 `:92`, `MongoBulkExecutor:69` 다.
|
||||
|
||||
같은 검색이 파일 6444 개를 훑었으므로 0 은 검색 대상이 잡히지 않아 나온 값이 아니다. 같은 정규식이 다른 이름에 대해 다섯을 찾았다.
|
||||
|
||||
`DefaultMongoImperativeExecutor:192`\~`:194` 의 `rawOperations()` 는 `:82` 가 일관성 프로파일로 고른 템플릿을 그대로 돌려준다. 컬렉션도 데드라인도 붙지 않는다.
|
||||
|
||||
세 실행기 안에서 `maxTime` 이나 `timeout` 이 나오는 줄도 0 이다. 같은 검색을 `BoundScopedOperations` 에 걸면 8 줄이 나온다.
|
||||
|
||||
## 저장소가 그 손잡이에 대해 아는 것
|
||||
|
||||
`MongoRawAccessBoundaryTest` 가 그 두 탈출구를 막는 아키텍처 가드다. `:37` 이 금지 문자열로 `.rawOperations()` 와 `.executeInternal(` 을 세우고, `:40`\~`:41` 이 허용 경로를 mongo 어댑터의 프로덕션 소스로 한정한다.
|
||||
|
||||
그 자바독 `:18`\~`:20` 이 두 탈출구가 무엇을 내주는지 적는다. 컬렉션 결속도, 일관성 템플릿도, **데드라인도**, 결과 예산도 없는 스프링 데이터의 무제한 `MongoOperations` 다.
|
||||
|
||||
`:24`\~`:26` 은 둘 다 플랫폼 안에서는 정말로 필요하다고 적는다. 지리 공간과 원자적 연산과 벌크가 그것 위에 세워져 있으므로 규칙은 지우라는 것이 아니라 밖에서 부르지 말라는 것이고, 그것은 가시성 문제가 아니라 경계 문제라는 것이다.
|
||||
|
||||
그래서 저장소는 데드라인이 없다는 것을 알고 있고, 그 사실을 경계로만 다룬다. 밖에서 못 부르게 막을 뿐 안에서 부른 다섯 자리가 데드라인 없이 도는 것은 다루지 않는다.
|
||||
|
||||
## 서버에 데드라인을 붙이는 다른 세 경로
|
||||
|
||||
`PolicyAwareMongoAggregationExecutor:96` 이 `Math.min(registered.maxTimeMillis(), contextMillis)` 를 만들고 `:106` 이 그것을 `AggregationOptions` 의 `maxTime` 에 넣는다. `:91` 주석이 컨텍스트의 타임아웃은 `maxTimeMS` 를 좁힐 수는 있어도 늘릴 수는 없다고 적는다.
|
||||
|
||||
`PolicyAwareMongoQueryBuilder:200` 과 `MongoReactiveCursorPublisher:58` 은 `budget.maxTimeMillis()` 만 쓴다.
|
||||
|
||||
`context.timeout()` 과 `narrowedTo` 가 나오는 자리를 전부 세면 예산을 컨텍스트로 좁히는 것은 `PolicyAwareMongoAggregationExecutor:72` 와 `:92` 뿐이다. `DefaultMongoImperativeExecutor:83`·`:93`·`:103` 은 사후 검사이고, `DefaultReactiveMongoExecutor:76`·`:108` 은 Reactor 의 클라이언트 쪽 `timeout` 연산자다.
|
||||
|
||||
그래서 `context.timeout()` 이 서버까지 가는 경로는 집계 하나이고, 그것도 예산과의 최솟값으로만 간다.
|
||||
|
||||
## 지리 공간과 원자적 연산과 벌크에 남는 사후 검사
|
||||
|
||||
`DefaultMongoImperativeExecutor:88` 이 콜백을 부르고 `:89` 가 경과 시간을 재고 `:93` 이 그것을 `context.timeout()` 과 견준다.
|
||||
|
||||
`:94`\~`:96` 주석이 그 검사의 한계를 적는다. 컨텍스트가 모든 연산에 데드라인을 선언하는데 아무것도 그것과 견주지 않았고, 콜백은 드라이버 호출 중간에 끊을 수 없어 이 검사가 작업을 끊지 못하며, 다만 예산을 넘긴 작업에 성공을 보고하는 것보다는 위반을 보고하는 편이 낫다는 것이다.
|
||||
|
||||
`SpringMongoGeospatialOperations` 와 `MongoAtomicOperationsTemplate` 와 `MongoBulkExecutor` 는 그 사후 검사만 받는다. 서버에는 데드라인이 전달되지 않는다.
|
||||
|
||||
## 원문에 없는 것
|
||||
|
||||
원문은 `scoped()` 를 부르는 프로덕션 코드가 0 이고 세 실행기가 `rawOperations()` 를 쓴다고 적으면서, 서버에 데드라인을 붙이는 경로별 어휘를 표로 갈라 놓는다. 그대로다.
|
||||
|
||||
여기에 더한 것은 셋이다. 그 0 이 검색 실패가 아니라는 확인 — 같은 pathspec 이 6444 개 파일을 훑었고 같은 검색이 `rawOperations()` 에 대해 다섯을 찾았다. 데드라인이 붙는 자리가 `:98` 하나가 아니라 `:53` 과 둘이라는 것. 그리고 raw 손잡이가 실수가 아니라 설계라는 저장소 자신의 진술 둘 — `MongoPlatformCollectionAccess:8`\~`:14` 와 `MongoRawAccessBoundaryTest:18`\~`:26` 이다. 뒤쪽은 그 손잡이에 데드라인이 없다는 것까지 적으면서 경계만 지킨다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
`maxTimeMsec` 이 붙은 질의를 서버에 보내 실제로 잘리는 것을 관측하지 않았다.
|
||||
|
||||
세 컴포넌트를 `scoped()` 로 옮기려면 어떤 연산이 더 있어야 하는지 조사하지 않았다.
|
||||
|
||||
`scoped()` 의 의도된 호출자가 누구인지, 포크의 콜백이 그 메서드를 부르도록 설계된 것인지 설계 문서로 확인하지 않았다.
|
||||
|
||||
<!-- body:end -->
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: analysis-finding-a06-f009
|
||||
title: 예산 초과 분기가 한 관측에 success 와 failure 를 차례로 부른다
|
||||
topic: multitenancy-isolation
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:analysis-finding-a06-f009
|
||||
evidenceCapturedOn: 2026-09-04
|
||||
body: case-analysis-finding-a06-f009.body.md
|
||||
assets:
|
||||
- key: analysis-finding-a06-f009
|
||||
file: ../../../final/evidence/rendered/analysis-finding-a06-f009.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/analysis-finding-a06-f009.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/06-adapter-outbound-persistence-mongo.md §33 이다.
|
||||
---
|
||||
|
||||
# 예산 초과 분기가 한 관측에 success 와 failure 를 차례로 부른다
|
||||
|
||||
`DefaultMongoImperativeExecutor:97` 이 `observation.success(outcome)` 를 부른 뒤 `:98` 이 `MongoOperationRejectedException` 을 던진다. 그 예외가 `MongoPersistenceException` 하위 타입이고 같은 `try` 안이라 `:108` 의 `catch` 가 잡아 `:109` 에서 `observation.failure(...)` 를 부른다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **메시징만 성공 로그를 try 밖으로 옮기고 알림은 같은 try 에 남겨 두었다**
|
||||
그 사례도 성공 기록 호출이 `try` 안에 남아 있어서 뒤따르는 예외를 같은 블록의 `catch` 가 받는다. 여기서는 로그가 아니라 관측이고, `catch` 가 성공을 지우는 대신 `failure` 를 덧쓴다.
|
||||
- **데드라인을 서버로 보내는 접근자를 부르는 프로덕션 코드가 없다**
|
||||
같은 메서드의 같은 분기가 두 기록의 출발점이다. 그쪽은 그 검사가 왜 사후일 수밖에 없는지를, 이쪽은 그 검사가 관측에 무엇을 남기는지를 본다.
|
||||
- **port 계약은 동시성 요구를 적는다**
|
||||
그 규칙의 두 번째 항목이 현재 구현이 안전한 것과 계약이 안전을 요구하는 것을 가르라고 적는다. 여기서도 두 구현이 나중 호출로 태그를 덮어써서 결과가 하나로 남을 뿐, `MongoOperationObservation` 자바독에는 두 메서드를 각각 몇 번 부를 수 있는지가 없다.
|
||||
|
||||
## 문제
|
||||
|
||||
예산을 넘긴 연산에 대해 실행기는 거부 예외를 던진다. 그 던짐이 이미 열려 있는 관측에 무엇을 남기는지 확인했다.
|
||||
|
||||
## 결론
|
||||
|
||||
MongoOperationObservation:16 의 success 와 :19 의 failure 가 결과를 기록한다. 어느 쪽을 몇 번 불러야 하는지, 여러 번 불렀을 때 무엇이 남는지는 자바독에 없다.
|
||||
|
||||
DefaultMongoImperativeExecutor:93 이 경과 시간을 context.timeout() 과 견준다. 넘었으면 :97 이 observation.success(outcome) 를 부르고 :98 이 MongoOperationRejectedException 을 던진다.
|
||||
|
||||
그 예외는 MongoPersistenceException 을 상속한다. 던짐이 :87 에서 열린 try 안에 있으므로 :108 의 catch (MongoPersistenceException alreadyTranslated) 가 잡는다.
|
||||
|
||||
그 catch 가 실패를 기록한 뒤 예외를 다시 던진다. 그래서 예산 초과 경로에서만 한 관측 객체가 두 결과를 다 받는다.
|
||||
|
||||
이 저장소에 있는 구현은 둘뿐이고, 둘 다 그 두 번째 호출을 문제로 만들지 않는다. MicrometerMongoOperationObserver:76~:88 의 두 메서드가 outcomeTags 를 통째로 덮어쓰고 :96~:102 의 close() 가 stopped 플래그를 보고 타이머를 한 번만 멈추므로, 나중에 부른 failure 의 태그가 남는다. NoOpMongoOperationObserver:29~:50 은 아무것도 기록하지 않는다.
|
||||
|
||||
그 무해함은 두 구현이 필드를 덮어쓰기 때문이지 인터페이스가 그렇게 하라고 적어 둔 것이 아니다. 호출마다 계수기를 올리는 구현이 들어오면 이 경로의 연산 하나가 두 건이 된다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
확인 방식 : 관측 인터페이스 전문 인용, 예산 초과 분기와 그것을 감싸는 try 와 catch 인용, 거부 예외의 상속 관계 인용, 그 파일에서 두 메서드를 부르는 자리 전수와 범위별 계수, 두 구현의 메서드와 close() 인용, 인터페이스 구현체 계수와 전수
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. MongoOperationObservation 인터페이스를 전문으로 싣는다.
|
||||
2. 실행기의 try 블록을 예산 초과 분기와 catch 절까지 함께 인용한다.
|
||||
3. 거부 예외가 무엇을 상속하는지 인용한다.
|
||||
4. try 범위와 catch 범위에서 success 와 failure 를 부르는 줄을 각각 세고, 그 파일의 두 메서드 호출을 전부 나열한다.
|
||||
5. 두 구현의 메서드와 close() 를 인용한다.
|
||||
6. 그 인터페이스를 구현하는 자리를 세고 전부 나열한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
블로킹 실행기는 콜백이 반환된 뒤 경과 시간을 잰다. 예산을 넘겼으면 거부 예외를 던진다.
|
||||
|
||||
## success 와 failure 의 자바독이 정하지 않은 것
|
||||
|
||||
:::evidence key="analysis-finding-a06-f009" alt="저장소 루트에서 돌린 정적 검색 출력 171줄. 먼저 MongoOperationObservation 6~26번 전문이 실린다. 자바독은 이것이 진행 중인 연산 하나의 관측 범위이고 AutoCloseable 이라 콜백이 플랫폼이 분류한 적 없는 것을 던지는 경로까지 모든 경로에서 닫히며, 성공에서만 닫히는 관측은 시스템이 건강하지 않을 때 정확히 건강해 보이는 지표를 만든다고 적는다. 16번이 success 로 성공적 완료와 쓰기 결과를 기록하고, 19번이 failure 로 실패 컨텍스트가 담을 수 있는 경계 있는 메타데이터만 써서 실패를 기록하며, 22번이 traceId, 25번이 close 다. 둘 중 하나만 불러야 한다거나 마지막 호출이 이긴다는 말은 없다. 이어서 DefaultMongoImperativeExecutor 85~118번이 실린다. 86번이 관측을 열고 87번이 try 를 열며 88번이 콜백을 부르고 89번이 경과 시간을 재고 92번이 결과를 정한다. 93번이 경과 시간을 컨텍스트 타임아웃과 견주고, 94~96번 주석이 컨텍스트가 모든 연산에 데드라인을 선언하는데 아무것도 그것과 견주지 않았으며 콜백은 드라이버 호출 중간에 끊을 수 없어 이것이 작업을 끊지 못하지만 예산을 넘긴 작업에 성공을 보고하는 것보다 위반을 보고하는 편이 낫다고 적는다. 97번이 observation.success 를 부르고 98~104번이 MongoOperationRejectedException 을 던지는데 메시지에 실제 경과 밀리초와 선언된 타임아웃 밀리초가 들어간다. 106번이 정상 경로의 success 이고 107번이 결과를 돌려준다. 108번의 catch 가 MongoPersistenceException 을 잡아 109번에서 observation.failure 를 부르고 110번에서 다시 던지며, 111번과 113번의 catch 가 드라이버 예외와 스프링 예외를 번역으로 보낸다. 다음으로 MongoOperationRejectedException 1~24번이 실리는데 7~12번 자바독이 이것은 서버에 닿기 전에 플랫폼이 로컬에서 거절한 것이고 등록되지 않은 필드와 연산자, 올려 잡은 예산, 읽기 API 안의 쓰기 단계, 깊은 skip, 없는 테넌트 컨텍스트, 런타임 클라이언트의 관리 명령 같은 가드레일 예외이며 언제나 NOT_SENT 이고 재시도 불가라고 적는다. 14번이 그것이 MongoPersistenceException 을 상속한다고 보인다. 이어서 try 블록 안에서 observation.success 를 부르는 줄이 2 개, 같은 try 의 catch 가 observation.failure 를 부르는 줄이 1 개라고 나오고, 그 파일에서 두 메서드를 부르는 자리가 97번과 106번의 success, 109번과 129번의 failure 넷으로 나열된다. 다음으로 MicrometerMongoOperationObserver 70~103번이 실리는데 72번이 outcomeTags 를 result unknown 으로 초기화하고, 76~78번의 success 와 81~88번의 failure 가 둘 다 그 필드를 통째로 덮어쓸 뿐이며, 96~102번의 close 가 stopped 플래그를 보고 한 번만 sample.stop 을 부른다. 마지막으로 MongoOperationObservation 을 구현하는 자리가 2 개라고 나오고 그 둘이 NoOpMongoOperationObserver 29번의 NoOpObservation 과 MicrometerMongoOperationObserver 56번의 MicrometerObservation 이며, 대조로 센 MongoOperationObserver 구현도 2 개다." caption="관측 인터페이스 전문과 두 메서드의 계약 · 예산 초과 분기와 그것을 잡는 catch · 거부 예외가 상속하는 것 · 범위별 호출 계수와 그 파일의 네 자리 · 출하 구현의 두 메서드와 한 번만 도는 close · 구현체 둘 — 171줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
`MongoOperationObservation:9`\~`:11` 은 이 타입이 `AutoCloseable` 인 이유를 적는다. 콜백이 플랫폼이 분류한 적 없는 것을 던지는 경로까지 포함해 모든 경로에서 범위가 닫혀야 하고, 성공에서만 닫히는 관측은 시스템이 건강하지 않을 때 정확히 건강해 보이는 지표를 만들기 때문이다.
|
||||
|
||||
`:16` 의 `success` 는 성공적 완료와 쓰기 결과를 기록한다. `:19` 의 `failure` 는 실패 컨텍스트가 담을 수 있는 경계 있는 메타데이터만 써서 실패를 기록한다.
|
||||
|
||||
둘 중 하나만 불러야 한다는 말도, 여러 번 부르면 나중 호출이 앞선 호출을 덮는다는 말도 없다.
|
||||
|
||||
## 예산 초과 분기
|
||||
|
||||
`DefaultMongoImperativeExecutor:86` 이 관측을 열고 `:87` 이 `try` 를 연다. `:88` 이 콜백을 부르고 `:89` 가 경과 시간을 잰다.
|
||||
|
||||
`:93` 이 그 경과 시간을 `context.timeout()` 과 견준다. 넘었으면 `:97` 이 `observation.success(outcome)` 를 부르고, `:98`\~`:104` 가 실제 경과 밀리초와 선언된 타임아웃 밀리초를 담은 `MongoOperationRejectedException` 을 던진다.
|
||||
|
||||
`:94`\~`:96` 주석이 이 순서의 의도를 적는다. 이 검사가 작업을 끊을 수는 없지만, 예산을 넘긴 작업에 성공을 보고하는 것보다 위반을 보고하는 편이 낫다는 것이다.
|
||||
|
||||
## 거부 예외를 같은 try 의 catch 가 잡는다
|
||||
|
||||
`MongoOperationRejectedException:14` 가 `MongoPersistenceException` 을 상속한다. `:7`\~`:12` 자바독은 이것이 서버에 닿기 전 로컬 거절이며 언제나 `NOT_SENT` 이고 재시도 불가라고 적는다.
|
||||
|
||||
`:108` 의 `catch (MongoPersistenceException alreadyTranslated)` 가 그 예외를 잡는다. `:109` 가 `observation.failure(...)` 를 부르고 `:110` 이 다시 던진다.
|
||||
|
||||
`try` 범위 안에서 `success` 를 부르는 줄이 둘이고 그 `catch` 가 `failure` 를 부르는 줄이 하나다. 예산 초과 경로에서는 같은 관측 객체에 대해 `:97` 의 `success` 가 먼저 불리고 `:109` 의 `failure` 가 이어서 불린다.
|
||||
|
||||
## 두 구현 모두 나중 호출의 태그만 남긴다
|
||||
|
||||
`MicrometerMongoOperationObserver:72` 가 `outcomeTags` 를 `result` `unknown` 으로 초기화한다.
|
||||
|
||||
`:76`\~`:78` 의 `success` 와 `:81`\~`:88` 의 `failure` 가 둘 다 그 필드를 통째로 덮어쓴다. 누적하지 않는다.
|
||||
|
||||
`:96`\~`:102` 의 `close()` 가 `stopped` 플래그를 보고 한 번만 `sample.stop(...)` 을 부른다. 그때 남아 있는 태그는 나중에 부른 `failure` 쪽이다.
|
||||
|
||||
`NoOpMongoOperationObserver:29`\~`:50` 의 구현은 세 메서드 몸통이 전부 비어 있다. 이 저장소에서 `MongoOperationObservation` 을 구현하는 것은 그 둘뿐이다.
|
||||
|
||||
## 인터페이스에 호출 횟수 규칙이 없다
|
||||
|
||||
두 구현 모두 나중 호출이 앞선 것을 덮는 모양이라 지금은 기록되는 결과가 달라지지 않는다.
|
||||
|
||||
그 성질이 인터페이스에 쓰여 있지 않다. `success` 와 `failure` 를 각각 계수하는 구현을 포크가 만들면, 예산을 넘긴 연산 하나가 성공 한 번과 실패 한 번으로 세어진다.
|
||||
|
||||
## 원문이 "출하 구현" 이라 부른 것
|
||||
|
||||
원문이 무해하다고 적은 그 구현은 둘이다. `MicrometerMongoOperationObserver:56` 의 `MicrometerObservation` 과 `NoOpMongoOperationObserver:29` 의 `NoOpObservation` 이고, 저장소에 다른 구현은 없다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
타이머에 실제로 무엇이 찍히는지 연산을 돌려 재현하지 않았다.
|
||||
|
||||
두 호출을 각각 계수하는 구현을 포크가 만들 가능성이 얼마나 되는지는 판단하지 않았다.
|
||||
|
||||
위반을 성공으로 먼저 남기는 이 순서를 바꾸면 무엇이 달라지는지 따져 보지 않았다.
|
||||
|
||||
두 호출을 각각 계수하는 구현을 포크가 만들 가능성이 얼마나 되는지는 판단하지 않았다.
|
||||
|
||||
<!-- body:end -->
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: analysis-finding-a06-f010
|
||||
title: 집계 실행기가 컬렉션 이름을 문자열로 따로 받는다
|
||||
topic: multitenancy-isolation
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:analysis-finding-a06-f010
|
||||
evidenceCapturedOn: 2026-09-04
|
||||
body: case-analysis-finding-a06-f010.body.md
|
||||
assets:
|
||||
- key: analysis-finding-a06-f010
|
||||
file: ../../../final/evidence/rendered/analysis-finding-a06-f010.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/analysis-finding-a06-f010.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/06-adapter-outbound-persistence-mongo.md §42 이다.
|
||||
---
|
||||
|
||||
# 집계 실행기가 컬렉션 이름을 문자열로 따로 받는다
|
||||
|
||||
`MongoCollectionProfileRegistry` 의 클래스 자바독은 이 레지스트리를 지나지 않고 컬렉션 이름이 드라이버까지 가는 경로는 없다고 적는다. 그런 경로가 둘 있다. `PolicyAwareMongoAggregationExecutor:63` 과 `MongoReactiveCursorPublisher:37` 인데, 둘 다 널 검사 말고는 어떤 검사도 거치지 않고 그 문자열을 드라이버 연산에 넘긴다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **단일 admission point는 우회 경로를 세어야 성립한다**
|
||||
이 사례가 그 규칙의 형태다. 레지스트리가 유일한 승인 지점이라는 문장은 그것을 지나지 않는 서명이 하나도 없어야 성립하는데, 여기서는 둘이 있다.
|
||||
- **path·identifier는 등록하고 value는 바인딩한다**
|
||||
컬렉션 이름은 등록 대상이고 값이 아니다. 두 서명은 컬렉션 이름을 `String` 값으로 받는다.
|
||||
- **데드라인을 서버로 보내는 접근자를 부르는 프로덕션 코드가 없다**
|
||||
그 기록이 연산 타임아웃이 서버까지 닿는 유일한 경로로 든 것이 이 집계 실행기다. 같은 `MongoOperationContext` 에서 `timeout()` 은 읽어 `maxTime` 에 반영하면서 `collectionProfile()` 은 한 번도 읽지 않는다.
|
||||
|
||||
## 문제
|
||||
|
||||
컬렉션 프로파일 레지스트리의 자바독이 이 리프의 강한 주장 하나를 편다. 동적 컬렉션 이름 금지를 이 간접이 강제 가능하게 만든다는 주장이다.
|
||||
|
||||
그 주장을 깨는 서명이 있는지 확인했다.
|
||||
|
||||
## 결론
|
||||
|
||||
MongoCollectionProfileRegistry:12~:15 가 근거를 적는다. 물리 이름은 기동 시점에 고정되고, 그 대응을 아는 코드가 이 클래스뿐이라는 것이다.
|
||||
|
||||
MongoOperationContext 도 같은 것을 요구한다. 호출자가 넘겨야 하는 다섯 값 가운데 하나가 :18 의 CollectionProfileName collectionProfile 이다.
|
||||
|
||||
collections.require(...) 를 부르는 main 줄은 셋이다. DefaultMongoImperativeExecutor:81 과 DefaultReactiveMongoExecutor:130 과 SpringReactiveChangeStreamSource:47 이다.
|
||||
|
||||
PolicyAwareMongoAggregationExecutor 의 execute 가 다르다. :63 이 컨텍스트와 별개로 컬렉션 이름을 String 인자로 받고, :66 의 널 검사 말고는 어떤 검사도 거치지 않고 :76 의 operations.aggregate(...) 로 그대로 간다.
|
||||
|
||||
같은 모양이 하나 더 있다. MongoReactiveCursorPublisher:37 도 String collection 을 받고 :44 의 널 검사만 거쳐 :64 의 operations.find(...) 에 넘긴다. 그쪽은 컨텍스트를 아예 읽지 않는다 — 받아서 널인지만 본다.
|
||||
|
||||
그 파일은 MongoCollectionProfileRegistry 를 한 줄도 언급하지 않고 context.collectionProfile() 도 부르지 않는다. import 열둘 가운데 레지스트리가 없다.
|
||||
|
||||
집계 실행기는 같은 컨텍스트에서 timeout() 은 읽는다. :72 가 budgetFor(context, budget) 을 부르고 그 메서드 :92 가 context.timeout() 을 밀리초로 바꿔 서버 데드라인을 좁힌다. collectionProfile() 만 읽지 않는다.
|
||||
|
||||
둘 다 지금 조립되지 않는다. new PolicyAwareMongoAggregationExecutor 와 new MongoReactiveCursorPublisher 를 부르는 main 줄이 각각 0 이다. 대조로 센 new MongoBudgetEnforcer 는 main 에 한 줄 있다. 이름이 걸린 시험조차 집계 실행기를 인스턴스로 만들지 않는다.
|
||||
|
||||
포크가 둘 중 하나를 배선하면 그 강제가 사라진다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
확인 방식 : 레지스트리 자바독과 require 인용, 컨텍스트 타입의 자바독과 필드 인용, require 를 부르는 main 자리 전수, 집계 실행기의 서명과 본문 인용, 그 파일의 레지스트리 언급 계수와 collectionProfile() 계수와 import 전수, 그 실행기의 등장 자리 전수와 생성 계수와 대조 계수
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 레지스트리의 클래스 자바독과 require 를 인용한다.
|
||||
2. MongoOperationContext 의 자바독과 필드를 인용한다.
|
||||
3. collections.require(...) 를 부르는 main 자리를 전부 찾는다.
|
||||
4. 집계 실행기의 execute 서명과 드라이버 호출까지의 본문을 인용한다.
|
||||
5. 그 파일이 레지스트리를 언급하는 줄과 collectionProfile() 을 부르는 줄을 세고 import 를 전부 나열한다.
|
||||
6. 그 실행기의 이름이 나오는 자리를 소스 세트와 함께 전부 나열하고, 생성 줄을 세고 대조 이름으로 같은 검색을 건다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`MongoCollectionProfileRegistry` 는 등록된 컬렉션 프로파일을 물리 이름으로 바꾼다. 클래스 자바독이 그 간접이 무엇을 보장하는지 적는다.
|
||||
|
||||
## 레지스트리 자바독이 적은 보장
|
||||
|
||||
:::evidence key="analysis-finding-a06-f010" alt="저장소 루트에서 돌린 정적 검색 출력 242줄. 먼저 MongoCollectionProfileRegistry 9~45번이 실린다. 9~16번 자바독은 이것이 등록된 컬렉션 프로파일을 물리 컬렉션 이름으로 대응시키며, 이 간접이 동적 컬렉션 이름 금지를 강제 가능하게 만드는 것이고, 애플리케이션 코드는 프로파일을 이름 짓고 물리 이름을 아는 것은 이 레지스트리뿐이며 기동 시점에 고정되고, 따라서 요청 값에서 조립된 컬렉션 이름은 드라이버에 도달할 수 없는데 문자열에서 컬렉션으로 가는 경로 중 여기를 지나지 않는 것이 없기 때문이라고 적는다. 35~40번의 require 가 등록되지 않은 프로파일이면 MongoOperationRejectedException 을 던진다. 이어서 DefaultMongoImperativeExecutor 80~84번이 실려 81번이 collections.require 로 컨텍스트의 컬렉션 프로파일을 물리 이름으로 바꾸는 것이 보인다. 그 아래 require 를 부르는 main 줄이 셋 나오는데 SpringReactiveChangeStreamSource 47번과 DefaultMongoImperativeExecutor 81번과 DefaultReactiveMongoExecutor 130번이다. 다음으로 MongoOperationContext 1~40번이 실린다. 8~13번 자바독은 이것이 모든 플랫폼 연산이 요구하는 불변 실행 컨텍스트이고 실행 경로에 닿는 유일한 길로 일부러 만들어졌으며 호출자에게 연산 이름과 데이터베이스 프로파일과 컬렉션 프로파일과 요청하는 일관성 보장과 양수 타임아웃을 이름 짓게 만들고, 여기에는 문서도 테넌트도 사용자도 식별하는 것이 없어서 컨텍스트 전체를 편집 단계 없이 텔레메트리에 붙일 수 있다고 적는다. 15~20번이 다섯 필드를 선언하는데 그중 하나가 CollectionProfileName collectionProfile 이다. 이어서 PolicyAwareMongoAggregationExecutor 36~76번이 실린다. 59~64번의 execute 서명이 MongoOperationContext 와 MongoAggregationProfile 과 MongoAggregationPlan 과 String collection 과 Class 출력 타입 다섯을 받고, 65~67번이 널 검사를 하며 68번이 계획을 검증하고, 70~72번이 예산을 받아 좁히며, 74~75번이 Aggregation 을 만들고 76번이 operations.aggregate 에 aggregation 과 collection 과 outputType 을 넘긴다. 그 String 인자는 어떤 검사도 거치지 않고 드라이버로 간다. 다음으로 이 파일이 MongoCollectionProfileRegistry 를 언급하는 줄이 0 개이고 context.collectionProfile 을 부르는 줄도 0 개라고 나오며, import 열둘이 전부 나열되는데 MongoOperationContext 와 MongoOperationRejectedException 과 예산 관련 셋과 자바 표준 셋과 스프링 데이터 넷이고 레지스트리가 없다. 마지막으로 PolicyAwareMongoAggregationExecutor 이름이 나오는 자리가 소스 세트와 함께 나오는데 main 은 24번의 클래스 선언과 32번의 생성자 둘뿐이고 나머지 하나는 PolicyAwareMongoAggregationExecutorTest 14번이며, main 에서 그것을 만드는 줄이 0 개이고 대조로 센 new MongoBudgetEnforcer 는 1 개다." caption="레지스트리가 펴는 주장과 require · 그 주장을 지키는 세 자리 · 컨텍스트가 이미 들고 있는 컬렉션 프로파일 · 집계 실행기의 서명과 드라이버 호출 · 그 파일의 레지스트리 언급 0 과 import 전수 · main 참조 둘과 생성 0 — 242줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
`:12`\~`:13` 이 근거를 적는다. 애플리케이션 코드는 프로파일을 이름 짓고, 물리 이름을 아는 것은 이 레지스트리뿐이며, 그 대응은 기동 시점에 고정된다.
|
||||
|
||||
`:14`\~`:15` 가 결론이다. 요청에서 온 값으로 컬렉션 이름을 만들어도 드라이버까지 갈 수 없는데, 문자열에서 컬렉션으로 가는 경로 중 이 레지스트리를 지나지 않는 것이 없기 때문이다.
|
||||
|
||||
`:35`\~`:40` 의 `require` 가 등록되지 않은 프로파일을 `MongoOperationRejectedException` 으로 거절한다.
|
||||
|
||||
## 컨텍스트가 이미 들고 있는 CollectionProfileName
|
||||
|
||||
`MongoOperationContext:10`\~`:13` 은 이 타입이 실행 경로에 닿는 유일한 길로 일부러 만들어졌다고 적는다. 호출자가 연산 이름과 두 프로파일과 일관성 보장과 양수 타임아웃을 넘겨야 한다.
|
||||
|
||||
`:18` 이 `CollectionProfileName collectionProfile` 이다. 문자열이 아니라 프로파일 이름 타입이다.
|
||||
|
||||
## collections.require(...) 를 부르는 세 자리
|
||||
|
||||
그 관문을 지나는 main 줄은 셋이다. `DefaultMongoImperativeExecutor:81` 과 `DefaultReactiveMongoExecutor:130` 이 컨텍스트의 프로파일을 물리 이름으로 바꾸고, `SpringReactiveChangeStreamSource:47` 이 구독의 프로파일로 같은 일을 한다.
|
||||
|
||||
셋 다 컨텍스트나 구독에서 프로파일을 꺼내 레지스트리에 넣는다. 문자열을 받지 않는다.
|
||||
|
||||
## 집계 실행기의 서명
|
||||
|
||||
`PolicyAwareMongoAggregationExecutor:59`\~`:64` 의 인자 목록에는 `MongoOperationContext` 와 `MongoAggregationProfile` 과 `MongoAggregationPlan` 과 출력 타입 말고 `String collection` 이 하나 더 있다.
|
||||
|
||||
`:66` 이 그 문자열에 널 검사만 한다. `:68` 의 `validate(plan, profile)` 은 단계 수와 허용 목록과 `lookup` 대상 컬렉션을 보고 이 인자는 보지 않는다. `:76` 의 `operations.aggregate(aggregation, collection, outputType)` 이 그것을 드라이버에 그대로 넘긴다.
|
||||
|
||||
생성자 `:32`\~`:33` 이 받는 것도 `MongoOperations` 와 예산 레지스트리와 예산 강제기 셋이다. 레지스트리가 없다.
|
||||
|
||||
## 같은 모양이 리액티브 쪽에도 있다
|
||||
|
||||
`MongoReactiveCursorPublisher:32`\~`:39` 의 `stream` 도 `MongoOperationContext` 를 받으면서 `:37` 에서 `String collection` 을 따로 받는다.
|
||||
|
||||
`:44` 가 널 검사를 하고, `:55`\~`:59` 가 질의를 복사해 배치 크기와 데드라인과 결과 상한을 걸고, `:64` 의 `operations.find(bounded, documentType, collection)` 이 그 문자열을 넘긴다.
|
||||
|
||||
그 파일에서 `context` 가 나오는 줄은 `:33` 의 인자와 `:40` 의 널 검사 둘뿐이다. 컬렉션 프로파일을 한 번도 읽지 않는다. 레지스트리를 언급하는 줄도 0 이다.
|
||||
|
||||
컨텍스트와 `String collection` 을 함께 받는 main 파일은 셋인데, `MongoAtomicOperationsTemplate` 은 공개 메서드가 컨텍스트만 받고 `String collection` 이 `:104`·`:123` 의 private 헬퍼 인자라 이 모양이 아니다.
|
||||
|
||||
## 두 파일에는 레지스트리 import 도 collectionProfile() 호출도 없다
|
||||
|
||||
집계 실행기에서 `MongoCollectionProfileRegistry` 를 언급하는 줄이 0 이고 `context.collectionProfile()` 을 부르는 줄도 0 이다. 같은 두 검색을 `DefaultMongoImperativeExecutor` 에 걸면 2 와 1 이 나오므로, 두 0 은 검색이 도는 상태에서 나온 값이다.
|
||||
|
||||
`import` 열둘이 전부 실려 있다. `MongoOperationContext` 와 `MongoOperationRejectedException` 과 예산 관련 셋과 자바 표준 셋과 스프링 데이터 넷이다. 레지스트리가 없다.
|
||||
|
||||
컨텍스트를 아예 안 쓰는 것은 아니다. `:72` 가 `budgetFor(context, budget)` 을 부르고 그 private 메서드 `:89`\~`:98` 의 `:92` 가 `context.timeout()` 을 밀리초로 바꿔 등록된 예산과의 최솟값을 만든다. `:91` 주석이 컨텍스트의 타임아웃은 `maxTimeMS` 를 좁힐 수는 있어도 늘릴 수는 없다고 적는다.
|
||||
|
||||
같은 컨텍스트에서 타임아웃은 꺼내고 컬렉션 프로파일은 꺼내지 않는다.
|
||||
|
||||
## 지금은 조립되지 않는다
|
||||
|
||||
이 클래스 이름이 main 에 나오는 줄은 `:24` 의 클래스 선언과 `:32` 의 생성자 둘뿐이다. 나머지 하나는 `PolicyAwareMongoAggregationExecutorTest:14` 다.
|
||||
|
||||
`new PolicyAwareMongoAggregationExecutor` 를 부르는 main 줄이 0 이고 `new MongoReactiveCursorPublisher` 도 0 이다. 같은 검색을 `new MongoBudgetEnforcer` 로 걸면 1 이 나오므로, 이 리프에도 자동 설정이 만드는 빈은 있다.
|
||||
|
||||
이름이 걸린 `PolicyAwareMongoAggregationExecutorTest` 조차 그 실행기를 만드는 줄이 0 이다.
|
||||
|
||||
그래서 레지스트리 자바독의 문장은 지금 성립한다. 포크가 둘 중 하나를 조립하고 요청 값으로 컬렉션 이름을 만들면 성립하지 않게 된다.
|
||||
|
||||
## 원문이 든 서명 말고 하나가 더 있다
|
||||
|
||||
원문은 집계 실행기의 서명을 그 경로로 든다. `MongoReactiveCursorPublisher:37` 도 같은 모양인데, 그쪽은 컨텍스트를 받아 널 검사만 하고 프로파일을 한 번도 읽지 않는다.
|
||||
|
||||
고치는 비용도 원문에 없다. 컨텍스트가 이미 `CollectionProfileName` 을 들고 있으므로, `String` 인자를 지우고 `context.collectionProfile()` 을 쓰는 것은 새 인자를 만드는 일이 아니다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
이 실행기를 조립한 예시가 저장소에 없어 실제 사용 모양을 보지 못했다.
|
||||
|
||||
그 인자를 프로파일 타입으로 바꿀 때 집계 계획 쪽이 무엇을 더 요구하게 되는지 따져 보지 않았다.
|
||||
|
||||
리액티브 쪽 집계 경로에 같은 서명이 있는지 따로 훑지 않았다.
|
||||
|
||||
<!-- body:end -->
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: analysis-finding-a06-f014
|
||||
title: 인덱스 diff 는 열네 요소 중 keySignature 와 unique 만 비교한다
|
||||
topic: multitenancy-isolation
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:analysis-finding-a06-f014
|
||||
evidenceCapturedOn: 2026-09-04
|
||||
body: case-analysis-finding-a06-f014.body.md
|
||||
assets:
|
||||
- key: analysis-finding-a06-f014
|
||||
file: ../../../final/evidence/rendered/analysis-finding-a06-f014.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/analysis-finding-a06-f014.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/06-adapter-outbound-persistence-mongo.md §57 이다.
|
||||
---
|
||||
|
||||
# 인덱스 diff 는 열네 요소 중 keySignature 와 unique 만 비교한다
|
||||
|
||||
`MongoIndexManifest:22`~`:35` 가 열네 요소를 선언하는데 `MongoIndexDescriptorView:15`~`:20` 은 여섯 컴포넌트만 갖는다. `MongoIndexDiffEngine.compare` 가 두 값을 함께 읽는 곳은 셋인데, `:49`~`:50` 은 양방향이고 `:55` 의 `hidden` 은 한 방향만 본다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **빠뜨림이 통과가 되는 게이트는 게이트가 아니다**
|
||||
이 사례가 그 규칙의 형태다. 견주지 않은 요소가 달라져도 `MongoIndexDiff` 의 네 목록이 전부 빈다. `MongoIndexDiff:9`~`:11` 은 그 결과를 CI 산출물로 설계했다고 적는데, 지금 그것을 만드는 프로덕션 코드는 없다.
|
||||
- **문서와 상수가 서로 일치하는 것으로는 아무것도 증명되지 않는다**
|
||||
그 규칙이 요구하는 검사 경계 명시가 여기에 없다. 그 규칙 3 이 요구하는 검사 경계 명시가 여기에 없다. `MongoIndexDescriptorView:9` 가 축소 사실은 밝히지만, 그 결과 비교되지 않는 아홉 요소를 어디에도 적지 않는다.
|
||||
- **TTL 규칙 셋을 가진 타입들을 부르는 프로덕션 코드가 없다**
|
||||
그 기록의 `expireAfter` 가 이 diff 에서 견주어지지 않는 열 요소 중 하나다. TTL 보존 기간을 바꿔도 빈 보고서가 나온다.
|
||||
|
||||
## 문제
|
||||
|
||||
인덱스 드리프트 보고서는 선언한 인덱스와 서버에 있는 인덱스를 견주어 만든다. 매니페스트는 인덱스 하나에 대해 열네 가지를 선언한다.
|
||||
|
||||
그 열넷 중 무엇이 실제로 견주어지는지 확인했다.
|
||||
|
||||
## 결론
|
||||
|
||||
MongoIndexManifest:22~:35 의 요소는 열넷이고 MongoIndexDescriptorView:15~:20 은 여섯이다. 매니페스트 요소 가운데 뷰에 같은 이름이 있는 것은 name 과 unique 와 hidden 과 metadataOwnership 넷뿐이다.
|
||||
|
||||
MongoIndexDiffEngine.compare:41~:58 이 매니페스트와 뷰를 대조한다. 두 값을 한 줄에서 함께 읽는 곳은 셋인데, :49~:50 이 keySignature 와 unique 로 change 를 정하고 :55 가 declared.hidden() && !actual.hidden() 로 hide 를 정한다.
|
||||
|
||||
그 반대는 검사하지 않는다. 서버가 숨긴 인덱스를 매니페스트가 보인다고 선언한 경우인데, MongoIndexDiff:13~:14 가 적은 폐기·숨김·관측·승인 순서에서 숨김까지 간 인덱스를 다시 쓰기로 바꾸면 그 조합이 남는다. 그때 계획기는 그 인덱스를 쓰지 않는다.
|
||||
|
||||
뷰에 대응 필드가 없는 열 요소는 어느 방향으로도 비교되지 않는다. 그 열에서 keys 하나만 keySignature 로 요약돼 들어가고, 남는 아홉에 expireAfter 가 있다.
|
||||
|
||||
MongoIndexDiff:9~:11 은 이 결과가 CI 산출물이며 실행마다 순서가 바뀌면 이전 실행과 대조할 수 없어서 모든 목록을 정렬한다고 적는다. 네 목록이 모두 비면 render() 가 빈 문자열을 낸다.
|
||||
|
||||
다만 그 산출물을 지금 만드는 프로덕션 코드는 없다. 엔진 이름이 main 에 나오는 줄이 자기 클래스 선언 하나뿐이고, 부르는 것은 시험 넷이다.
|
||||
|
||||
MongoIndexDescriptorView:9~:12 는 그 축소 자체는 밝힌다. 무엇이 감지 불가가 되는지만 적지 않는다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
확인 방식 : 매니페스트와 뷰의 요소 전수 인용, 두 목록의 요소 계수와 자기검증, 요소마다 뷰에 있는지 이름으로 대조, 비교 엔진 본문 인용과 두 값을 함께 읽는 줄 계수와 전수, diff 레코드 전문 인용, 엔진의 main 등장 계수와 시험 호출 대조
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. MongoIndexManifest 와 MongoIndexDescriptorView 의 레코드 헤더를 전문으로 싣는다.
|
||||
2. 두 목록의 요소를 세고, 열넷이 아니면 스크립트가 실패하게 한다.
|
||||
3. 매니페스트 요소마다 같은 이름이 뷰에 있는지 대조해 전부 나열한다.
|
||||
4. MongoIndexDiffEngine.compare 를 인용하고 그 안에서 declared 와 actual 을 한 줄에서 함께 읽는 줄을 세고 전부 뽑는다.
|
||||
5. MongoIndexDiff 의 클래스 자바독을 인용한다.
|
||||
6. MongoIndexDiffEngine 이 main 에 나오는 줄을 세고, 그 엔진의 compare 를 부르는 시험 줄과 대조한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`MongoIndexDiffEngine` 이 매니페스트와 서버에서 읽은 뷰를 대조해 드리프트 보고서를 만든다.
|
||||
|
||||
## 매니페스트가 선언하는 열넷
|
||||
|
||||
:::evidence key="analysis-finding-a06-f014" alt="저장소 루트에서 돌린 정적 검색 출력 192줄. 먼저 MongoIndexManifest 11~45번이 실린다. 12~19번 자바독은 이것이 diff 나 검토에 필요한 모든 것을 담은 선언된 인덱스 하나이고, expectedUsage 가 이 인덱스가 존재하는 연산을 이름 지어 이 인덱스가 아직 필요한지를 프로덕션 통계로 추측하지 않고 답할 수 있게 하며, metadataOwnership 이 누가 만들었는지를 적어 드리프트 정리가 선언하지 않은 암호화나 검색 인덱스를 지우자고 제안하지 못하게 한다고 적고, 키 순서가 표현상의 세부가 아니라 인덱스 정체성의 일부라 보존된다고 적는다. 21~35번의 record 헤더가 name 과 keys 와 unique 와 sparse 와 hidden 과 deprecated 와 partialFilterExpression 과 collationProfile 과 expireAfter 와 wildcardProjection 과 shardKeySupport 와 expectedUsage 와 owner 와 metadataOwnership 열넷을 선언한다. 이어서 MongoIndexDescriptorView 6~20번이 실린다. 7~12번 자바독은 이것이 서버에 실제로 있는 인덱스이며 D4 관리 클라이언트로 읽어 diff 가 견줄 수 있는 필드로 줄인 것이고, 소유권은 매니페스트를 믿는 대신 여기서 추론하는데 diff 의 목적 자체가 매니페스트가 모르는 인덱스를 찾는 것이며 그중 일부는 암호화나 검색에 속해 절대 삭제 후보가 되면 안 되기 때문이라고 적는다. 14~20번이 collection 과 name 과 keySignature 와 unique 와 hidden 과 metadataOwnership 여섯을 선언한다. 다음으로 MongoIndexDiffEngine 25~70번이 실린다. 41~58번 반복문이 선언된 인덱스마다 같은 이름의 뷰를 찾는데, 43~48번이 뷰가 없고 폐기 표시도 없으면 create 에 넣고, 49~50번이 keySignature 가 다르거나 unique 가 다르면 change 에 넣으며 51~52번 주석이 같은 이름에 다른 정의는 MongoDB 가 조용히 다시 만들지 않으므로 질의가 스캔을 시작할 때 발견되는 대신 재빌드가 계획돼야 한다고 적고, 55~57번이 선언은 숨김인데 서버는 아닐 때만 hide 에 넣는다. 60~67번은 매니페스트에 없는 서버 인덱스를 dropCandidates 에 넣는데 애플리케이션 드리프트로 삭제 가능한 소유권만 본다. 그 아래 한 줄에서 declared 값과 actual 값을 견주는 줄이 3 개라고 나오고 그 셋이 49번과 50번과 55번으로 실리며, 참고로 그 반복문에서 declared 나 actual 이 나오는 줄 전부도 함께 나온다. 이어서 매니페스트 요소가 14 개이고 뷰 요소가 6 개라고 나온 뒤, 매니페스트 요소마다 뷰에 있는지가 전부 나열되는데 name 과 unique 와 hidden 과 metadataOwnership 넷만 뷰에 있고 나머지 열은 뷰에 없다. 요소 수가 14 가 아니면 실패하는 자기검증도 함께 찍힌다. 마지막으로 MongoIndexDiff 1~49번이 실리는데 6~15번 자바독이 모든 목록이 정렬돼 있어 같은 입력에 대해 diff 가 바이트 단위로 같으며 실행마다 순서가 바뀌는 CI 산출물은 이전 실행과 견줄 수 없고 그것이 인덱스 드리프트 보고서의 목적 대부분이라고 적고, dropCandidates 는 이름 그대로 폐기와 숨김과 관측과 승인을 통과해야 무엇이든 삭제되는 제안일 뿐이라고 적으며, 31번의 empty 와 36~38번의 isClean 과 41~49번의 render 가 이어진다. 그 아래 MongoIndexDiffEngine 이 main 에 나오는 줄이 1 개인데 그것이 23번의 클래스 선언이고, 대조로 센 시험 쪽 engine.compare 호출은 MongoIndexDiffEngineTest 30·42·49·61번 네 줄이다." caption="매니페스트가 선언하는 열넷과 그 자바독 · 비교용 뷰가 나르는 여섯과 축소를 밝힌 자바독 · 비교 엔진 본문과 실제로 견주는 두 줄 · 요소마다 뷰에 있는지 대조한 전수와 자기검증 · CI 산출물이라 적은 diff 자바독과 호출 계수 — 192줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
`MongoIndexManifest:22`\~`:35` 가 인덱스 하나에 대해 열네 요소를 선언한다. `name`, `keys`, `unique`, `sparse`, `hidden`, `deprecated`, `partialFilterExpression`, `collationProfile`, `expireAfter`, `wildcardProjection`, `shardKeySupport`, `expectedUsage`, `owner`, `metadataOwnership` 이다.
|
||||
|
||||
`:14`\~`:17` 자바독은 그 열넷 가운데 둘을 따로 설명한다. `expectedUsage` 는 이 인덱스가 존재하는 연산을 이름 지어 "아직 필요한가" 를 프로덕션 통계로 추측하지 않고 답할 수 있게 하고, `metadataOwnership` 은 누가 만들었는지를 적어 드리프트 정리가 선언하지 않은 암호화나 검색 인덱스를 지우자고 제안하지 못하게 한다.
|
||||
|
||||
## 비교용 뷰가 선언하는 여섯 필드
|
||||
|
||||
`MongoIndexDescriptorView:15`\~`:20` 이 여섯 컴포넌트를 선언한다. `collection`, `name`, `keySignature`, `unique`, `hidden`, `metadataOwnership` 이다.
|
||||
|
||||
`:9`\~`:12` 자바독이 그 축소를 밝힌다. D4 관리 클라이언트로 읽어 diff 가 비교할 수 있는 필드로 줄였다는 것이다. 소유권을 매니페스트에서 받지 않고 여기서 추론하는 이유도 적는다 — diff 의 목적이 매니페스트가 모르는 인덱스를 찾는 것이고, 그중 일부는 암호화나 검색에 속해 삭제 후보가 되면 안 되기 때문이다.
|
||||
|
||||
그 축소로 어떤 요소가 비교 대상에서 빠지는지는 어디에도 적혀 있지 않다.
|
||||
|
||||
## compare 가 declared 와 actual 을 함께 읽는 세 줄
|
||||
|
||||
`MongoIndexDiffEngine.compare:41` 이 선언된 인덱스마다 같은 이름의 뷰를 찾는다.
|
||||
|
||||
`:43`\~`:48` 이 뷰가 없고 폐기 표시도 없으면 `create` 에 넣는다.
|
||||
|
||||
`:49`\~`:50` 이 `keySignature` 가 다르거나 `unique` 가 다르면 `change` 에 넣는다. `:51`\~`:52` 주석은 같은 이름에 다른 정의를 MongoDB 가 조용히 다시 만들지 않으므로, 질의가 스캔을 시작할 때 발견되는 대신 재빌드가 계획돼야 한다고 적는다.
|
||||
|
||||
`:55`\~`:57` 이 `declared.hidden() && !actual.hidden()` 일 때 `hide` 에 넣는다.
|
||||
|
||||
한 줄에서 선언 값과 서버 값을 함께 읽는 줄은 셋이다. `:49` 와 `:50` 과 `:55` 다. 앞의 둘이 `change` 를 결정하고 `:55` 가 `hide` 를 결정한다.
|
||||
|
||||
## 한 방향만 보는 검사
|
||||
|
||||
`:55` 의 조건은 선언이 숨김이고 서버가 아닌 경우다.
|
||||
|
||||
반대에 대한 분기가 없다. 서버에서는 숨겨져 있는데 매니페스트가 보인다고 선언한 상태다.
|
||||
|
||||
이 상태에서는 매니페스트가 `hidden` 을 거짓으로 선언한 인덱스를 서버가 숨겨 두고 있고, 질의 계획기는 숨겨진 인덱스를 쓰지 않는다. 보고서는 그것을 어느 목록에도 넣지 않는다.
|
||||
|
||||
## 뷰에 없는 열 요소
|
||||
|
||||
매니페스트 요소마다 뷰에 같은 이름이 있는지 대조했다. `name` 과 `unique` 와 `hidden` 과 `metadataOwnership` 넷만 있다. 뷰 요소가 여섯이 아니면 스크립트가 실패한다.
|
||||
|
||||
나머지 열은 없다. `keys`, `sparse`, `deprecated`, `partialFilterExpression`, `collationProfile`, `expireAfter`, `wildcardProjection`, `shardKeySupport`, `expectedUsage`, `owner` 다.
|
||||
|
||||
`keys` 만 `keySignature` 라는 다른 이름으로 요약돼 들어간다. 나머지 아홉에는 뷰에 대응하는 컴포넌트가 없다.
|
||||
|
||||
그 아홉에 `expireAfter` 가 있다. 30일을 1일로 바꾸는 것은 대량 삭제인데, 이 diff 는 그것을 차이로 보고하지 않는다.
|
||||
|
||||
## 네 목록이 모두 비었을 때의 산출물
|
||||
|
||||
`MongoIndexDiff:9`\~`:11` 은 모든 목록을 정렬해 같은 입력에 대해 바이트 단위로 같은 결과를 낸다고 적는다. 실행마다 순서가 바뀌는 CI 산출물은 이전 실행과 견줄 수 없고 그것이 드리프트 보고서의 목적 대부분이기 때문이다.
|
||||
|
||||
`:13`\~`:14` 는 `dropCandidates` 가 이름 그대로 제안일 뿐이며 폐기와 숨김과 관측과 승인을 통과해야 무엇이든 삭제된다고 적는다.
|
||||
|
||||
`:41`\~`:49` 의 `render()` 가 네 목록을 이어 붙인다. 모두 비면 결과가 빈 문자열이다. 비교하지 않는 아홉 요소가 달라져도 같은 결과다.
|
||||
|
||||
다만 지금 그 보고서를 만드는 프로덕션 코드는 없다. `MongoIndexDiffEngine` 이 main 에 나오는 줄이 `:23` 의 클래스 선언 하나뿐이고, 그 엔진의 `compare` 를 부르는 것은 `MongoIndexDiffEngineTest:30`·`:42`·`:49`·`:61` 네 줄이다.
|
||||
|
||||
## 원문과 갈리는 자리
|
||||
|
||||
원문은 비교되는 것이 `keySignature` 와 `unique` 둘이고 `hidden` 이 한 방향이라고 적는다. 그 판정은 그대로다. 다만 한 줄에서 두 값을 함께 읽는 줄로 세면 `:55` 를 포함해 셋이다.
|
||||
|
||||
여기에 더한 것은 둘이다. 하나는 뷰에 무엇이 없는지를 이름으로 전부 맞춰 본 결과다 — 매니페스트 열넷 가운데 뷰에 이름이 있는 것은 넷이고 `keys` 는 `keySignature` 로 형태를 바꿔 들어가므로 감지 불가가 되는 것은 아홉이다. 다른 하나는 그 엔진이 아직 프로덕션에서 불리지 않는다는 것이다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
선언과 서버 상태를 실제로 넣어 빈 보고서가 나오는 것을 프로브로 보이지 않았다.
|
||||
|
||||
이 보고서가 어느 CI 단계의 산출물이 되는지 워크플로 파일을 훑지 않았다.
|
||||
|
||||
`keySignature` 의 생성 규칙을 열어 보지 않았다. 키 순서가 그 서명에 담기는지 확인하지 않았다.
|
||||
|
||||
<!-- body:end -->
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: analysis-finding-a06-f015
|
||||
title: MongoTtlPolicyValidator 를 부르는 프로덕션 코드가 없다
|
||||
topic: multitenancy-isolation
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:analysis-finding-a06-f015
|
||||
evidenceCapturedOn: 2026-09-04
|
||||
body: case-analysis-finding-a06-f015.body.md
|
||||
assets:
|
||||
- key: analysis-finding-a06-f015
|
||||
file: ../../../final/evidence/rendered/analysis-finding-a06-f015.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/analysis-finding-a06-f015.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/06-adapter-outbound-persistence-mongo.md §58 이다.
|
||||
---
|
||||
|
||||
# MongoTtlPolicyValidator 를 부르는 프로덕션 코드가 없다
|
||||
|
||||
`MongoIndexManifest:30` 의 `expireAfter` 에 걸리는 검사는 `:54` 의 음수 거절 하나다. `MongoTtlPolicyValidator` 는 오버로드 셋에 걸쳐 다섯 경우를 거절하는데, `schema/ttl` 밖 main 에서 `MongoTtlPolicy` 와 `MongoTtlIndexDescriptor` 와 `MongoExpirationAccessPolicy` 와 `MongoTtlPolicyValidator` 를 언급하는 줄이 0 이다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **같은 개념의 두 어휘가 공존하면 하나를 죽은 것으로 표시한다**
|
||||
이 사례가 그 규칙의 형태다. 두 표현 사이에 참조가 없어서 어느 쪽이 정본인지 코드가 말하지 않는다.
|
||||
- **diff 가 견주는 것은 열넷 중 keySignature 와 unique 둘이다**
|
||||
그 기록의 감지 불가 목록에 `expireAfter` 가 들어 있다. 매니페스트로 선언한 TTL 은 검증도 받지 않고 드리프트로도 잡히지 않는다.
|
||||
- **중복 장치를 찾으면 어느 쪽이 조립됐는지 먼저 확인한다**
|
||||
그 규칙 2 대로 만들어지는 것과 불리는 것을 나눠 세면 둘 다 0 이다. `MongoIndexManifest` 를 밖에서 만드는 main 줄도, `MongoTtlPolicyValidator` 를 만드는 main 줄도 없다.
|
||||
|
||||
## 문제
|
||||
|
||||
매니페스트의 한 요소와 schema/ttl 패키지가 각각 TTL 을 표현한다.
|
||||
|
||||
둘이 서로를 아는지, 그리고 어느 쪽이 실제로 쓰이는지 확인했다.
|
||||
|
||||
## 결론
|
||||
|
||||
MongoIndexManifest:30 이 Duration expireAfter 를 선언한다. 그 필드에 걸리는 검사는 :54~:56 의 음수 거절 하나다. :72~:73 의 ttl() 이 Optional 을 돌려주고 :77~:78 의 isTtlIndex() 가 널 여부를 돌려준다. 값의 크기를 보는 코드는 없다.
|
||||
|
||||
MongoTtlPolicyValidator 가 거절하는 경우는 다섯이고 세 오버로드에 나뉘어 있다. validate(MongoTtlPolicy) 가 :30·:39·:49 에서 정확한 만료 시각 주장과 질의 시점 검사 누락과 1 분 미만 보존 기간을, validate(MongoTtlIndexDescriptor):68 이 날짜가 아닌 만료 필드 타입을, validate(MongoExpirationAccessPolicy):86 이 읽기 필터 미적용을 거절한다.
|
||||
|
||||
:10~:13 자바독에는 이 클래스가 막는 두 오용이 적혀 있다. 스윕 간격이 명세되지 않은 장치를 스케줄러로 쓰는 것과, 아직 지워지지 않은 문서가 읽히는데도 접근 제어로 기대는 것이다.
|
||||
|
||||
두 표현 사이에 참조가 없다. schema/ttl 밖 main 에서 이 네 타입을 언급하는 줄이 0 이고, 같은 검색이 schema/ttl 안에서는 22 줄을 찾는다. MongoIndexManifest 도 그 네 이름을 한 번도 쓰지 않는다.
|
||||
|
||||
isTtlIndex() 와 ttl() 을 부르는 줄도 0 이다. 두 이름이 나오는 줄은 선언 둘뿐이다.
|
||||
|
||||
그래서 매니페스트 경로로 선언된 TTL 인덱스에는 그 다섯 중 어느 것도 걸리지 않는다. 안전 하한이 1 분으로 선언돼 있는데 매니페스트 쪽에는 그 값을 읽는 코드가 없어서 1 초짜리 보존 기간도 통과한다.
|
||||
|
||||
그리고 어느 쪽도 아직 배선되지 않았다. MongoIndexManifest.named(...) 나 new MongoIndexManifest 를 부르는 main 줄이 :226 의 자기 빌더뿐인데 시험에는 7 이 있고, 검증기를 만드는 main 줄은 0 이며 시험에 1 이 있다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
확인 방식 : 매니페스트의 TTL 요소와 그 검사와 접근자와 빌더 인용, 검증기 파일 전문 인용과 세 오버로드의 다섯 거절 확인, 정책 타입 인용, schema/ttl 밖 main 참조 계수와 안쪽 대조, 매니페스트가 그 네 타입을 언급하는 줄 계수, 두 접근자 호출 계수와 선언 포함 대조, 두 표현의 생성 계수와 시험 대조
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 매니페스트의 expireAfter 선언과 그 필드에 걸리는 검사와 접근자와 빌더 메서드를 인용한다.
|
||||
2. MongoTtlPolicyValidator 를 파일 끝까지 싣고 세 오버로드가 각각 무엇을 거절하는지 본다.
|
||||
3. MongoTtlPolicy 를 인용해 정책이 무엇을 요구하는지 보인다.
|
||||
4. schema/ttl 밖 main 에서 네 타입을 언급하는 줄을 세고, 같은 검색을 schema/ttl 안에 걸어 대조한다.
|
||||
5. 매니페스트가 그 네 타입을 언급하는 줄과 두 접근자를 부르는 줄을 세고, 선언까지 포함한 계수와 대조한다.
|
||||
6. 두 표현을 만드는 main 줄을 각각 세고 시험 쪽 계수와 대조한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
이 범위 안에 TTL 을 표현하는 방법이 둘 있다.
|
||||
|
||||
## MongoIndexManifest 의 expireAfter
|
||||
|
||||
:::evidence key="analysis-finding-a06-f015" alt="저장소 루트에서 돌린 정적 검색 출력 187줄. 먼저 MongoIndexManifest 28~32번이 실려 30번의 Duration expireAfter 선언과 그 앞뒤 필드가 보이고, 50~58번의 압축 생성자에서 54~56번이 expireAfter 가 널이 아니고 음수면 거절한다. 68~80번에는 72~73번의 ttl 접근자가 Optional.ofNullable 을 돌려주고 77~78번의 isTtlIndex 가 널이 아닌지를 돌려준다. 66~80번에 named 팩터리가 있고 186~194번의 빌더 메서드 expireAfter 는 널 검사만 한다. 그 아래 expireAfter 라는 이름이 그 파일에 나오는 줄이 8 개이고 그중 값을 거절하는 줄이 1 개라고 나온다. 이어서 MongoTtlPolicyValidator 7~94번 전문이 실린다. 7~14번 자바독은 TTL 인덱스가 오용되는 두 방식을 거절한다고 적는데, 첫째는 그것을 스케줄러로 다루는 것이며 TTL 모니터의 스윕 간격이 명세되지 않고 부하에 따라 달라져서 아홉 시에 지우라는 것은 약속하지 않는다는 것이고, 둘째는 접근 제어로 기대는 것이며 아직 존재하는 문서는 여전히 읽히므로 만료가 데이터를 가려야 한다면 읽기 쪽이 그것을 말해야 한다는 것이다. 17~21번이 MINIMUM_SAFE_RETENTION 을 1 분으로 두는데 그 아래로 줄이면 한 번의 스윕으로 큰 모집단이 만료돼 설정 변경이 계획되지 않은 대량 삭제가 된다고 적는다. 28~58번의 첫 validate 가 셋을 던지는데 30번이 정확한 업무 전이를 주장하는 정책을, 39번이 질의 시점 만료 검사를 선언하지 않은 정책을, 49번이 최소 보존 기간보다 짧은 것을 거절한다. 60~77번의 두 번째 오버로드는 TTL 인덱스 서술자를 받아 67번에서 정책 검증을 위임하고 68~76번에서 만료 필드가 BSON 날짜로 저장되지 않으면 MongoDB 가 날짜 필드만 만료시키고 나머지는 조용히 무시한다는 메시지로 거절하며, 79~93번의 세 번째 오버로드는 읽기 쪽 술어를 받아 86~92번에서 그것이 적용되지 않으면 만료된 문서가 TTL 모니터가 지울 때까지 계속 보인다는 메시지로 거절한다. 다음으로 MongoTtlPolicy 1~40번이 실린다. 9~12번 자바독은 TTL 모니터가 대략 1 분에 한 번 돌며 배치로 지우므로 만료된 문서가 명세되지 않은 간격 동안 계속 읽히고 부하가 높거나 바쁜 세컨더리에서는 더 길어지는데, 그것이 공간 회수에는 괜찮고 특정 시점에 보이지 않아야 하는 것에는 틀렸다고 적는다. 14~16번은 그래서 두 불린이 선언의 일부이며 physicalCleanupOnly 는 이 TTL 이 공간만 회수한다는 뜻이고 queryChecksLogicalExpiry 는 애플리케이션이 expiresAt 이 현재보다 큰지로 걸러 가시성이 모니터의 타이밍에 달리지 않게 한다는 뜻이라고 적는다. 18~22번이 field 와 retention 과 두 불린 넷을 선언한다. 이어서 schema/ttl 밖 main 에서 그 네 타입을 언급하는 줄이 0 개이고 schema/ttl 안에서는 22 개이며, MongoIndexManifest 가 그 네 타입을 언급하는 줄이 0 개이고, isTtlIndex 나 ttl 을 부르는 줄이 0 개인데 선언까지 포함하면 2 개다. 마지막으로 MongoIndexManifest.named 나 new MongoIndexManifest 를 부르는 main 줄이 1 개인데 그것이 226번의 자기 빌더이고 시험에서는 7 개이며, new MongoTtlPolicyValidator 를 부르는 main 줄이 0 개이고 시험에서는 1 개다." caption="매니페스트의 expireAfter 와 거기 걸리는 음수 검사와 두 접근자와 빌더 · 검증기 전문과 세 규칙과 최소 보존 기간 · 정책 타입이 두 불린으로 요구하는 것 · schema/ttl 밖 참조 0 과 안쪽 22 · 두 접근자 호출 0 과 선언 2 · 두 표현의 생성 계수 — 187줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
`MongoIndexManifest` 는 TTL 을 요소 하나로 담는다. `:30` 의 `Duration expireAfter` 다.
|
||||
|
||||
그 필드에 걸리는 검사는 하나다. `:54`\~`:56` 이 널이 아니면서 음수인 경우를 `IllegalArgumentException` 으로 거절한다.
|
||||
|
||||
`:72`\~`:73` 의 `ttl()` 이 `Optional.ofNullable(expireAfter)` 를 돌려주고, `:77`\~`:78` 의 `isTtlIndex()` 가 널이 아닌지를 돌려준다.
|
||||
|
||||
`:189`\~`:191` 의 빌더 메서드는 널 검사만 한다. 값의 크기를 보지 않는다.
|
||||
|
||||
## MongoTtlPolicyValidator 가 거절하는 다섯 경우
|
||||
|
||||
`MongoTtlPolicyValidator` 는 `validate` 오버로드를 셋 갖고, 그 셋에 걸쳐 다섯 경우를 거절한다.
|
||||
|
||||
`validate(MongoTtlPolicy):28`\~`:58` 이 그중 셋이다.
|
||||
|
||||
`:30` 이 정확한 업무 전이를 주장하는 정책에 `MongoOperationRejectedException` 을 던진다. 메시지가 MongoDB 의 TTL 모니터는 명세되지 않은 간격으로 스윕하므로 정확한 전이에는 자기 스케줄러가 필요하고 TTL 인덱스는 물리적 정리로 남는다고 적는다.
|
||||
|
||||
`:39` 가 질의 시점 만료 검사를 선언하지 않은 정책을 거절한다. 만료된 문서는 모니터가 지울 때까지 계속 읽히므로 읽기가 만료 필드를 현재와 견주어야 한다는 것이다.
|
||||
|
||||
`:49` 가 `MINIMUM_SAFE_RETENTION` 보다 짧은 보존 기간을 거절한다. `:21` 이 그 값을 1 분으로 두고, `:18`\~`:19` 가 그 아래로 줄이면 한 번의 스윕으로 큰 모집단이 만료돼 설정 변경이 계획되지 않은 대량 삭제가 된다고 적는다.
|
||||
|
||||
`:10`\~`:13` 자바독이 두 오용을 이름 짓는다. TTL 을 스케줄러로 다루는 것과 접근 제어로 기대는 것이다.
|
||||
|
||||
나머지 둘은 다른 오버로드에 있다. `validate(MongoTtlIndexDescriptor):65`\~`:77` 이 `:67` 에서 정책 검증을 위임한 뒤 `:68` 에서 만료 필드가 BSON 날짜가 아니면 거절하는데, MongoDB 가 날짜 필드만 만료시키고 나머지는 조용히 무시하기 때문이다. `validate(MongoExpirationAccessPolicy):84`\~`:93` 이 `:86` 에서 읽기가 논리적 만료를 걸지 않으면 거절한다.
|
||||
|
||||
`MongoTtlPolicy:18`\~`:22` 가 그 검증을 받는 값이다. `field` 와 `retention` 말고 `physicalCleanupOnly` 와 `queryChecksLogicalExpiry` 두 불린이 선언의 일부다. `:9`\~`:12` 자바독에는 모니터가 대략 1 분에 한 번 배치로 지우므로 만료된 문서가 명세되지 않은 간격 동안 계속 읽히고 부하가 높거나 바쁜 세컨더리에서는 더 길어진다고 적혀 있다.
|
||||
|
||||
## 둘 사이에 참조가 없다
|
||||
|
||||
`schema/ttl` 밖의 main 에서 `MongoTtlPolicy` 나 `MongoTtlIndexDescriptor` 나 `MongoExpirationAccessPolicy` 나 `MongoTtlPolicyValidator` 를 언급하는 줄이 0 이다.
|
||||
|
||||
같은 검색을 `schema/ttl` 안에 걸면 22 줄이 나오므로, 이 0 은 검색이 대상을 못 찾아서 나온 값이 아니다.
|
||||
|
||||
매니페스트 파일 자체에도 그 네 이름이 한 번도 나오지 않는다.
|
||||
|
||||
반대 방향도 없다. `isTtlIndex()` 나 `ttl()` 을 부르는 줄이 0 이고, 두 이름이 나오는 줄은 `:77` 과 `:72` 의 선언뿐이다.
|
||||
|
||||
## 매니페스트 경로가 지나지 않는 다섯 검사
|
||||
|
||||
매니페스트로 선언한 TTL 인덱스는 그 다섯 중 어느 것도 지나지 않는다.
|
||||
|
||||
`MINIMUM_SAFE_RETENTION` 이 1 분인데 `:189`\~`:191` 의 빌더는 널 검사만 하므로 1 초짜리 값도 그대로 들어간다. 실제로 넣어 보지는 않았다. `expireAfter` 는 앞의 기록에서 확인한 대로 인덱스 diff 가 비교하지 않는 아홉 요소 중 하나이므로, 그 값을 바꿔도 드리프트 보고서가 빈다.
|
||||
|
||||
## 둘 다 밖에서 만들어지지 않는다
|
||||
|
||||
`MongoIndexManifest.named(...)` 나 `new MongoIndexManifest` 를 부르는 main 줄은 1 인데 그것이 `:226` 의 자기 빌더다. 밖에서 만드는 코드는 없고, 같은 검색이 시험에서는 7 을 찾는다.
|
||||
|
||||
`new MongoTtlPolicyValidator` 를 부르는 main 줄은 0 이고 시험에 1 이 있다.
|
||||
|
||||
지금은 두 표현 다 밖에서 만들어지지 않아 이 차이가 드러나지 않는다. 포크가 `MongoIndexManifest` 를 조립하면 `expireAfter` 는 `:54`\~`:56` 의 음수 거절만 지나고 검증기의 다섯 조건은 걸리지 않는다.
|
||||
|
||||
## 원문과 갈리는 자리
|
||||
|
||||
두 표현 사이에 참조가 없고 규칙을 가진 쪽이 쓰이지 않는다는 판정은 그대로다.
|
||||
|
||||
규칙의 목록이 갈린다. 원문이 든 셋은 최소 보존 기간과 만료 필드의 BSON 타입과 읽기의 만료 검사다. 실제 파일에는 그 셋 말고 `:30` 의 정확한 만료 시각 주장 거절이 더 있고, 원문이 든 셋 가운데 BSON 타입은 첫 오버로드가 아니라 `:68` 에 있다. 세 오버로드를 다 세면 다섯이다.
|
||||
|
||||
그리고 그 0 들이 검색 실패가 아니라는 대조를 더했다. 같은 검색이 `schema/ttl` 안에서 22 줄을 찾고, 두 접근자는 선언까지 포함하면 2 줄이 나오며, 매니페스트 생성 검색은 시험에서 7 을 찾는다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
빌더나 매니페스트로 1 초짜리 TTL 을 선언해 실제로 통과하고 만들어지는 것을 프로브로 재현하지 않았다.
|
||||
|
||||
`MongoExpirationAccessPolicy` 자체는 열지 않았다. 확인한 것은 검증기가 그 타입에 대한 오버로드를 갖는다는 데까지다.
|
||||
|
||||
설계가 둘 중 어느 쪽을 정본으로 두었는지 설계 문서를 읽어 판단하지 않았다.
|
||||
|
||||
<!-- body:end -->
|
||||
+145
File diff suppressed because one or more lines are too long
+138
@@ -0,0 +1,138 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: analysis-finding-a06-f021
|
||||
title: 드라이런만 감사 싱크를 직접 불러 번역을 거치지 않는다
|
||||
topic: multitenancy-isolation
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:analysis-finding-a06-f021
|
||||
evidenceCapturedOn: 2026-09-04
|
||||
body: case-analysis-finding-a06-f021.body.md
|
||||
assets:
|
||||
- key: analysis-finding-a06-f021
|
||||
file: ../../../final/evidence/rendered/analysis-finding-a06-f021.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/analysis-finding-a06-f021.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/06-adapter-outbound-persistence-mongo.md §76 이다.
|
||||
---
|
||||
|
||||
# 드라이런만 감사 싱크를 직접 불러 번역을 거치지 않는다
|
||||
|
||||
감사 레코드를 만드는 자리는 넷이다. `:114`·`:117`·`:122` 가 `MongoAdminGateway:127` 의 `audit` 헬퍼를 지나 싱크 실패를 `MongoOperationRejectedException` 으로 바꾸고, `dryRun:147` 만 `auditSink.accept` 를 직접 부른다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **단일 admission point는 우회 경로를 세어야 성립한다**
|
||||
그 규칙 2 가 하위 계층 타입을 직접 참조하는 곳을 세라고 적는다. 감사 레코드를 만드는 네 자리 가운데 `dryRun:147` 하나가 `auditSink` 를 직접 부른다.
|
||||
- **도달성 판정은 단어가 아니라 import로 확인한다**
|
||||
`dryRun` 호출 0 이라는 판정을 같은 pathspec 과 같은 정규식 모양이 `execute` 에 대해 18 줄을 찾은 것과 대조해 확인했다.
|
||||
- **실패 번역 사슬의 순서는 계약이다**
|
||||
`audit` 헬퍼가 싱크의 `RuntimeException` 을 `MongoOperationRejectedException` 으로 바꾸는 단일 번역 지점인데, `dryRun:147` 이 그것을 지나지 않아 싱크가 던진 예외가 그대로 호출자에게 간다.
|
||||
|
||||
## 문제
|
||||
|
||||
관리 게이트웨이는 모든 감사 쓰기를 헬퍼로 보내고, 그 헬퍼가 싱크 실패를 플랫폼 거부 예외로 바꾼다.
|
||||
|
||||
그 헬퍼를 지나지 않는 감사 쓰기가 있는지 확인했다.
|
||||
|
||||
## 결론
|
||||
|
||||
audit:127~:137 이 싱크 호출을 try 로 감싸고 RuntimeException 을 플랫폼 거부 예외로 바꾼다. 거절된 레코드의 단계 이름이 메시지에 들어간다.
|
||||
|
||||
실행 경로의 세 기록이 그 헬퍼를 지난다. :114 의 의도와 :117 의 성공과 :122 의 실패다. :113 이 그 순서를 fail closed 라고 부른다.
|
||||
|
||||
싱크를 직접 부르는 줄은 헬퍼 안의 :129 와 드라이런 안의 :147 둘이고, 뒤쪽만 번역을 거치지 않는다.
|
||||
|
||||
dryRun:145~:149 는 권한 검사 뒤에 싱크를 바로 부른다. 그래서 싱크가 던진 예외가 그대로 호출자에게 도달한다.
|
||||
|
||||
:142~:143 자바독은 드라이런을 고위험 작업의 전제 조건으로 놓는다. 자바독대로면 고위험 명령은 감사가 강제되지 않는 호출을 먼저 지난다.
|
||||
|
||||
시험은 execute 쪽만 고정한다. MongoAdminAuditStateMachineTest:86 이 던지는 싱크로 거부 예외와 본문 미실행을 함께 단언하는데, 같은 파일에 dryRun 을 거는 줄이 0 이다.
|
||||
|
||||
dryRun 을 부르는 줄이 저장소 전체에 0 이다. 같은 게이트웨이의 execute 는 18 줄이 부르고 그중 일곱이 main 이다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
확인 방식 : 감사 헬퍼와 그 번역 인용, 실행 경로가 헬퍼를 부르는 세 자리 인용, auditSink 이름이 나오는 자리 전수와 헬퍼 호출 계수, dryRun 본문과 그 자바독 인용, 감사 실패를 고정하는 시험 인용과 그 파일의 dryRun 계수, 두 진입점의 호출 계수와 다른 게이트웨이 오염분 대조
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. audit 헬퍼와 그것이 던지는 예외를 인용한다.
|
||||
2. 실행 경로가 그 헬퍼를 부르는 세 자리를 인용한다.
|
||||
3. auditSink 라는 이름이 나오는 자리와 audit 헬퍼를 부르는 자리를 각각 전부 찾는다.
|
||||
4. dryRun 본문과 그 자바독을 인용한다.
|
||||
5. 감사 실패를 고정하는 시험을 인용하고, 같은 파일에서 dryRun 이 나오는 줄을 센다.
|
||||
6. dryRun 과 execute 를 부르는 줄을 각각 세어 대조한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
관리 게이트웨이는 감사 쓰기를 헬퍼 하나로 모은다.
|
||||
|
||||
## 감사 실패를 거부로 바꾸는 헬퍼
|
||||
|
||||
:::evidence key="analysis-finding-a06-f021" alt="저장소 루트에서 돌린 정적 검색 출력 145줄. 먼저 MongoAdminGateway 120~137번이 실린다. 120~121번 주석이 종결 레코드가 실패 전파 전에 쓰이고 그 세부가 예외 메시지가 아니라 예외 타입이라고 적고, 122~123번이 실패 레코드를 남긴 뒤 다시 던진다. 127~137번의 private audit 헬퍼가 128~129번에서 auditSink.accept 를 try 로 감싸고 130~136번에서 RuntimeException 을 잡아 MongoOperationRejectedException 으로 바꾸는데, 메시지가 관리 감사 싱크가 해당 단계 레코드를 거절했으며 감사할 수 없는 관리 작업은 실행되지 않는다는 것이다. 이어서 실행 경로 전체인 83~126번이 실린다. 83번 서명이 명령과 승인과 본문 셋을 받고, 87번이 authorization.require 로 권한을 요구하며, 88~93번이 만료된 명령을 거절하는데 오래된 명령은 더 이상 그것이 쓰인 때의 모습이 아닌 클러스터를 서술할 수 있다는 메시지다. 94번이 연산의 highRisk 를 보고 95~102번이 승인이 null 이면 데이터를 파괴하거나 컬렉션을 다시 쓰는 연산은 이 명령에 묶인 승인 아래에서 돌거나 아예 돌지 않는다며 거절하고, 103번이 approval.require 를, 106~110번이 단일 사용을 강제하며 104~105번 주석이 그것이 없으면 한 reshard 에 대한 승인 하나가 같은 모양의 이후 모든 reshard 를 허가한다고 적는다. 113번 주석이 fail closed 라며 의도를 기록할 수 없으면 명령이 돌지 않는다고 적고, 114번이 의도 레코드를, 117번이 성공 레코드를, 122번이 실패 레코드를 헬퍼로 보낸다. 그 아래 auditSink.accept 를 부르는 줄이 2 개이고 audit 헬퍼를 부르는 줄이 3 개라고 나오며, auditSink 라는 이름이 나오는 자리가 26번 필드와 38번 생성자 인자와 42번 대입과 129번과 147번 다섯으로, 헬퍼를 부르는 자리가 114·117·122번 셋으로 나열된다. 다음으로 MongoAdminGateway 139~149번의 dryRun 이 실린다. 140~144번 자바독은 이것이 실행 없이 연산을 검증하고 의도를 기록하는 것이며, 드라이런이 모든 고위험 작업의 전제 조건이라 누군가 기억해서 넘기는 플래그가 아니라 일급 호출이어야 한다고 적는다. 145번 서명 뒤 146번이 권한을 요구하고 147~148번이 auditSink.accept 를 직접 부른다. 헬퍼를 거치지 않는다. 마지막으로 MongoAdminAuditStateMachineTest 84~113번이 실린다. 85번 DisplayName 이 실패하는 감사 싱크가 명령이 도는 것을 막는다고 적고, 86번의 anUnauditableCommandDoesNotRun 이 88~94번에서 감사 컬렉션에 닿을 수 없다며 던지는 싱크로 게이트웨이를 만들고 95번에서 ran 플래그를 두며, 97~107번이 COLL_MOD 일상 명령을 승인 null 로 execute 에 넘기고, 108~109번이 MongoOperationRejectedException 과 cannot be audited 메시지를, 110~112번이 나중에 아무도 재구성할 수 없는 관리 작업은 일어나면 안 된다는 설명과 함께 본문이 돌지 않았다는 것을 단언한다. 그 아래 드라이런의 감사 실패를 보는 시험 줄이 0 개이고, 이 게이트웨이의 dryRun 을 부르는 줄이 저장소 전체에 0 개이며, 대조로 센 같은 게이트웨이의 execute 는 18 개이고 그 호출 자리가 main 일곱과 test 열하나로 나뉘어 전부 나열된 뒤, 같은 정규식이 다른 게이트웨이에서 잡는 줄이 8 개라고 참고로 붙는다." caption="감사 실패를 플랫폼 예외로 바꾸는 헬퍼 · execute 가 감사 앞에서 지나는 네 검사와 세 기록이 헬퍼를 지나는 자리 · auditSink 를 부르는 자리 전수 · 헬퍼를 거치지 않는 dryRun 과 그 자바독 · 감사 실패를 고정하는 시험의 단언 셋과 dryRun 계수 0 · 두 진입점의 호출 대조 — 145줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
`MongoAdminGateway:127`\~`:137` 의 `audit` 이 `auditSink.accept(record)` 를 `try` 로 감싼다.
|
||||
|
||||
`:130` 이 `RuntimeException` 을 잡아 `:131`\~`:135` 에서 `MongoOperationRejectedException` 으로 바꾼다. 메시지는 감사 싱크가 그 단계 레코드를 거절했고, 감사할 수 없는 관리 작업은 실행되지 않는다는 것이다.
|
||||
|
||||
## 실행 경로의 세 기록
|
||||
|
||||
`:114` 가 의도 레코드를 헬퍼로 보낸다. `:113` 주석에는 fail closed 라고, 의도를 기록할 수 없으면 명령이 돌지 않는다고 적혀 있다.
|
||||
|
||||
`:117` 이 성공 레코드를, `:122` 가 실패 레코드를 보낸다. `:120`\~`:121` 주석은 종결 레코드가 실패 전파 전에 쓰이고 그 세부가 예외 메시지가 아니라 예외 타입이라고 적는다.
|
||||
|
||||
셋 다 헬퍼를 지난다. 싱크가 던지면 셋 다 플랫폼 예외가 된다.
|
||||
|
||||
## audit 헬퍼를 지나지 않는 dryRun\:147
|
||||
|
||||
`auditSink.accept` 를 부르는 줄은 둘이다. `:129` 가 헬퍼 안이고 `:147` 이 드라이런 안이다. `audit` 헬퍼를 부르는 줄은 셋이고 전부 실행 경로에 있다.
|
||||
|
||||
`dryRun:145`\~`:149` 는 `:146` 에서 권한을 요구한 뒤 `:147`\~`:148` 에서 `auditSink.accept(...)` 를 바로 부른다.
|
||||
|
||||
싱크가 던지면 그 예외가 번역 없이 호출자에게 간다. `MongoOperationRejectedException` 이 아니라 싱크가 던진 것 그대로다.
|
||||
|
||||
## 자바독이 적는 드라이런의 지위
|
||||
|
||||
`:142`\~`:143` 자바독에는 드라이런이 모든 고위험 작업의 전제 조건이라서, 누군가 기억해서 넘기는 플래그가 아니라 일급 호출이어야 한다고 적혀 있다.
|
||||
|
||||
자바독대로라면 고위험 작업은 드라이런이 먼저 돌아야 하는데, 그 드라이런이 남기는 레코드는 싱크가 거절해도 명령을 멈추지 않는다. `execute` 가 남기는 세 레코드와 다른 점이 이것이다.
|
||||
|
||||
## 시험이 거는 것은 execute 뿐이다
|
||||
|
||||
`MongoAdminAuditStateMachineTest:86` 의 `anUnauditableCommandDoesNotRun` 이 `:88`\~`:94` 에서 던지는 싱크로 게이트웨이를 만들고, `:108`\~`:109` 에서 `MongoOperationRejectedException` 과 `cannot be audited` 메시지를, `:110`\~`:112` 에서 본문이 돌지 않았다는 것을 단언한다.
|
||||
|
||||
같은 파일에서 `dryRun` 을 다루는 줄은 0 이다.
|
||||
|
||||
## dryRun 을 부르는 코드가 없다
|
||||
|
||||
그 메서드를 부르는 줄이 저장소 전체에 하나도 없다. 형제인 `execute` 는 18 줄이 부른다.
|
||||
|
||||
같은 pathspec 과 같은 정규식 모양이 한쪽에서 18 을 찾았으므로, 0 은 검색이 아무것도 훑지 못해 나온 값이 아니다. 그 정규식은 다른 게이트웨이에서도 8 줄을 잡는데 `RedisRawGatewayContractTest` 와 `PolicyAwareMongoNativeGatewayTest` 의 것이라 이 계수에서 뺐다.
|
||||
|
||||
## 원문에 없는 것
|
||||
|
||||
원문은 두 감사 경로 중 하나만 fail-closed 이고 그 차이가 문서화돼 있지 않다고 적는다. 그 판정은 그대로다.
|
||||
|
||||
원문이 적지 않은 것은 `execute` 와 `dryRun` 이 감사 앞에서 지나는 검사의 수다. `execute` 는 `:87` 의 권한과 `:88` 의 만료와 `:94`\~`:102` 의 승인과 `:106` 의 단일 사용 넷을 지난 뒤에 `:114` 로 간다. `dryRun` 은 `:146` 의 권한 하나만 지난다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
던지는 싱크를 넣고 `dryRun` 을 불러 어떤 예외가 나오는지 프로브로 재현하지 않았다.
|
||||
|
||||
포크가 드라이런을 어디서 부르도록 설계된 것인지 설계 문서로 판단하지 않았다.
|
||||
|
||||
`dryRun` 이 남기는 레코드를 승인 사슬이 어떻게 참조하는지 그 소비자를 찾지 않았다.
|
||||
|
||||
던지는 싱크를 넣고 `dryRun` 을 불러 어떤 예외가 나오는지 프로브로 재현하지 않았다.
|
||||
|
||||
`dryRun` 이 남기는 레코드를 승인 사슬이 어떻게 참조하는지 그 소비자를 찾지 않았다.
|
||||
|
||||
<!-- body:end -->
|
||||
+146
File diff suppressed because one or more lines are too long
+148
@@ -0,0 +1,148 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: analysis-finding-a06-f023
|
||||
title: 고위험 관리 작업 넷이 승인을 널로 넘기는 오버로드를 부른다
|
||||
topic: multitenancy-isolation
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:analysis-finding-a06-f023
|
||||
evidenceCapturedOn: 2026-09-04
|
||||
body: case-analysis-finding-a06-f023.body.md
|
||||
assets:
|
||||
- key: analysis-finding-a06-f023
|
||||
file: ../../../final/evidence/rendered/analysis-finding-a06-f023.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/analysis-finding-a06-f023.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/06-adapter-outbound-persistence-mongo.md §85 이다.
|
||||
---
|
||||
|
||||
# 고위험 관리 작업 넷이 승인을 널로 넘기는 오버로드를 부른다
|
||||
|
||||
`MongoAdminGateway:66` 이 승인 자리에 `null` 을 넣은 채 세 인자 오버로드를 부른다. `:94`~`:102` 는 고위험 연산에 승인이 없으면 거절한다. 그 편의 오버로드를 부르는 main 일곱 줄 가운데 넷이 고위험 연산을 넘긴다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **권한이 센 절반이 설정 한 줄로 켜지면 안 된다**
|
||||
그 규칙이 다루는 것이 승인 어휘가 둘일 때의 문제다. 여기서는 편의 오버로드가 승인 없는 쪽을 기본으로 만든다.
|
||||
- **같은 개념의 두 어휘가 공존하면 하나를 죽은 것으로 표시한다**
|
||||
`execute` 가 둘인데 승인을 받는 쪽을 부르는 main 호출이 0 이다.
|
||||
- **두 형태를 나란히 내놓는 포트는 이미 안전하지 않은 쪽을 고른 것이다**
|
||||
그 규칙 2 가 폐기 표시도 이름 차이도 가시성 차이도 없으면 호출자가 짧은 쪽을 고른다고 적는다. `execute` 둘이 이름도 가시성도 같고, main 일곱 호출이 전부 짧은 쪽이다.
|
||||
|
||||
## 문제
|
||||
|
||||
이 평면의 실행 경로는 고위험 연산에 명령별 승인을 강제한다.
|
||||
|
||||
승인을 실제로 넘기는 호출이 있는지 확인했다.
|
||||
|
||||
## 결론
|
||||
|
||||
편의 오버로드가 :63~:67 에서 본 메서드를 부르면서 :66 의 승인 자리에 null 을 넣는다.
|
||||
|
||||
:94~:102 가 그 null 을 거절한다. 고위험 연산에 승인이 없으면 MongoOperationRejectedException 이 나가고, 메시지가 그런 연산은 명령에 묶인 승인 아래에서만 돈다고 적는다.
|
||||
|
||||
그 검사에 걸릴 수 있는 연산은 여덟이다. 그중 넷이 다섯 인자 오버로드로 넘어간다. MongoShardingAdminGateway:64 의 SHARD_COLLECTION, :75 의 REFINE_SHARD_KEY, :86~:87 의 RESHARD_COLLECTION, 그리고 MongoQueryableEncryptionCollectionManager:63~:64 의 MANAGE_ENCRYPTION_KEY 다.
|
||||
|
||||
넷 다 인자를 무엇으로 채워도 완료되지 않는다. 승인 자리가 null 로 고정돼 있어 :95 에서 걸린다.
|
||||
|
||||
MongoShardingAdminGateway:92 의 BALANCER_CONTROL 은 highRisk(false) 라 이 검사에 걸리지 않는다. MongoQueryableEncryptionCollectionManager:42 의 CREATE_COLLECTION 과 :70 의 COLL_MOD 도 그렇다.
|
||||
|
||||
승인 객체를 만드는 프로덕션 코드가 없다. MongoAdminApproval.of(...) 를 부르는 main 줄이 0 이고, 그 타입이 main 에 등장하는 다섯 자리는 전부 자기 선언이거나 게이트웨이 서명이다. 시험에는 그것을 만드는 줄이 넷 있다.
|
||||
|
||||
샤딩 게이트웨이가 받는 ReshardApproval 은 다른 타입이다. main 에 나오는 넷이 전부 자기 선언이거나 파라미터이고, 그것을 MongoAdminApproval 로 옮기는 자리가 없다.
|
||||
|
||||
MongoShardingAdminGateway:49~:63 이 그 앞에서 승인 준비 상태와 지원 인덱스를 검사하지만, 그것을 통과해도 :64 에서 막힌다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
확인 방식 : 샤딩 게이트웨이의 네 메서드 전문 인용과 그 호출 계수, 다섯 인자 오버로드 인용, 세 인자 오버로드의 승인 검사 인용, 연산 열거형의 위험 등급 인용과 계수, main 에 나오는 연산마다 위험 등급과 등장 줄 대조, 고위험을 넘기는 자리 전수 인용, 승인 객체를 만드는 main 계수와 그 이름의 main 등장 전수와 시험 대조, ReshardApproval 의 main 등장 전수
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 샤딩 게이트웨이의 네 메서드를 전문으로 싣고 adminGateway.execute 호출을 센다.
|
||||
2. 다섯 인자 오버로드를 인용해 승인 자리에 무엇이 들어가는지 보인다.
|
||||
3. 실행 경로의 고위험 승인 검사를 인용한다.
|
||||
4. 그 오버로드를 지나는 연산 상수의 위험 등급을 열거형에서 인용하고 전체 상수와 고위험 개수를 함께 센다.
|
||||
5. main 에 나오는 연산 상수마다 위험 등급과 등장 줄을 대조한다.
|
||||
6. 고위험을 다섯 인자 오버로드로 넘기는 자리를 전부 인용한다.
|
||||
7. 승인 객체를 만드는 main 줄을 세고 그 타입이 main 에 등장하는 자리를 전부 나열한 뒤, 시험 쪽 계수를 대조로 낸다.
|
||||
8. ReshardApproval 이 main 에 나오는 자리를 전부 나열해 두 승인 타입을 잇는 코드가 있는지 본다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`MongoAdminOperation:4` 자바독이 이 열거형을 D4 평면이 수행할 수 있는 관리 연산이라고 부른다. 그 평면은 고위험 연산에 명령별 승인을 요구한다. 샤딩 게이트웨이가 네 작업을 그 위에 올린다.
|
||||
|
||||
## 샤딩 게이트웨이의 네 메서드
|
||||
|
||||
:::evidence key="analysis-finding-a06-f023" alt="저장소 루트에서 돌린 정적 검색 출력 215줄. 먼저 MongoShardingAdminGateway 33~93번이 실린다. 38~65번의 shardCollection 이 컬렉션과 샤드 키와 준비도 보고서와 지원 인덱스 필드를 받아 49~53번에서 승인되지 않은 샤드 키를 거절하고 54~63번에서 지원 인덱스가 샤드 키로 시작하지 않으면 거절한 뒤 64번에서 adminGateway.execute 에 SHARD_COLLECTION 을 넘긴다. 68~76번의 refineShardKey 와 79~88번의 reshardCollection 은 ReshardApproval 을 require 한 뒤 각각 75번과 86~87번에서 REFINE_SHARD_KEY 와 RESHARD_COLLECTION 을 넘기고, 91~93번의 controlBalancer 는 92번에서 BALANCER_CONTROL 을 넘긴다. 네 메서드가 부르는 execute 가 4 개다. 이어서 MongoAdminGateway 40~68번이 실리는데 47~56번 자바독이 다섯 인자의 뜻을 적고, 57~62번 서명이 연산과 대상과 운영자와 사유와 본문을 받으며, 63~67번이 MongoAdminCommand.routine 으로 명령을 만들어 66번에서 승인 자리에 null 을, 67번에서 본문을 넘긴다. 다음으로 69~112번의 세 인자 오버로드가 실린다. 70~81번 자바독은 단일 레코드 버전에서 셋이 바뀌었다며 의도가 명령 실행 전에 기록되고 아무것도 주장하지 않으며 무엇이 실제로 일어났는지 적는 종결 레코드가 뒤따르고 실패하는 감사 싱크가 명령을 멈추는데 감사할 수 없는 관리 작업은 나중에 누구도 재구성할 수 없기 때문이고 drop 이나 reshard 에는 그것이 이 평면을 두는 이유 전부라고 적으며, 79번이 승인은 고위험 연산에 필요하고 이 명령에 묶인다고 적는다. 83번 서명이 명령과 승인과 본문 셋을 받고, 87번이 권한을 요구하고 88~93번이 만료된 명령을 거절하며, 94~102번이 고위험 연산에 승인이 null 이면 데이터를 파괴하거나 컬렉션을 다시 쓰는 연산은 이 명령에 묶인 승인 아래에서 돌거나 아예 돌지 않는다는 메시지로 거절하고, 103번이 승인을 검사하며 104~110번이 단일 사용을 강제하면서 그렇지 않으면 한 reshard 에 대한 승인 하나가 같은 모양의 이후 모든 reshard 를 허가하게 된다고 주석에 적는다. 이어서 MongoAdminOperation 3~10번 자바독이 이것이 D4 평면이 수행할 수 있는 관리 연산의 닫힌 열거형이고 모든 상수가 애플리케이션 런타임이 닿으면 안 되는 것이며 여기 모아 두는 것이 경계를 어떤 메서드가 있느냐에서 나오는 성질이 아니라 누군가 검토할 수 있는 목록으로 만든다고 적는다. 13번 CREATE_COLLECTION 과 16번 COLL_MOD 와 46번 BALANCER_CONTROL 이 false 이고 37번 SHARD_COLLECTION 과 40번 REFINE_SHARD_KEY 와 43번 RESHARD_COLLECTION 과 49번 MANAGE_ENCRYPTION_KEY 가 true 이며, 그 일곱 가운데 고위험이 4 개, 열거형 전체에서 고위험이 8 개, 상수 전체가 15 개다. 다음으로 승인을 실제로 넘기는 main 호출이 0 개이고, execute 를 부르는 main 자리 일곱이 나열되는데 MongoQueryableEncryptionCollectionManager 41번과 63번과 70번, MongoShardingAdminGateway 64번과 75번과 86번과 92번이며, 시험에서 세 인자 execute 를 부르는 줄은 10 개이고, MongoAdminApproval 을 만드는 main 줄은 0 개이며 그 이름이 main 에 나오는 자리는 MongoAdminApproval 자신의 19·21·31·33번과 MongoAdminGateway 83번의 서명 다섯이고, 시험에서 그것을 만드는 줄은 4 개다. 이어서 main 에 나오는 연산 상수마다 위험 등급과 등장 줄이 대조되는데 BALANCER_CONTROL 과 COLL_MOD 와 CREATE_COLLECTION 이 false 이고 MANAGE_ENCRYPTION_KEY 와 REFINE_SHARD_KEY 와 RESHARD_COLLECTION 과 SHARD_COLLECTION 이 true 이며 각각 한 줄씩이다. 고위험인데 다섯 인자 오버로드로 넘어가는 자리 넷이 실리는데 MongoShardingAdminGateway 64번과 75번과 86~87번, 그리고 MongoQueryableEncryptionCollectionManager 61~65번의 rotateDataKey 가 64번에서 MANAGE_ENCRYPTION_KEY 를 넘기는 자리이며, 대조로 같은 클래스 34~43번의 createEncryptedCollection 이 41~42번에서 넘기는 CREATE_COLLECTION 은 false 다. 마지막으로 ReshardApproval 이 main 에 나오는 줄이 4 개라고 나오고 그 넷이 MongoShardingAdminGateway 70번과 81번의 파라미터와 ReshardApproval 자신의 15번과 22번이다." caption="샤딩 게이트웨이의 네 메서드와 그 네 호출 · 승인 자리에 null 을 넣는 다섯 인자 오버로드 · 고위험에 승인을 요구하는 세 인자 오버로드 · 연산 열거형의 위험 등급과 계수 · 승인을 넘기는 main 호출 0 과 execute 일곱 자리 · 고위험을 넘기는 넷 · 두 승인 타입을 잇는 코드 부재 — 215줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
`MongoShardingAdminGateway:38` 의 `shardCollection` 이 컬렉션과 샤드 키와 준비도 보고서와 지원 인덱스 필드를 받는다. `:49`\~`:53` 이 승인되지 않은 샤드 키를 거절하고, `:54`\~`:63` 이 지원 인덱스가 샤드 키로 시작하지 않으면 거절한다.
|
||||
|
||||
`:68` 의 `refineShardKey` 와 `:79` 의 `reshardCollection` 은 `ReshardApproval` 을 `require()` 한다. `:91` 의 `controlBalancer` 는 앞선 검사가 없다.
|
||||
|
||||
넷 다 마지막 줄에서 `adminGateway.execute(...)` 를 부른다.
|
||||
|
||||
## 그 오버로드가 승인 자리에 넣는 것
|
||||
|
||||
`MongoAdminGateway` 에 `execute` 가 둘이다. `:57`\~`:62` 의 다섯 인자 오버로드가 연산과 대상과 운영자와 사유와 본문을 받고, `:83` 의 세 인자 오버로드가 `MongoAdminCommand` 와 `MongoAdminApproval` 과 본문을 받는다.
|
||||
|
||||
다섯 인자 쪽이 `:63`\~`:67` 에서 `MongoAdminCommand.routine(...)` 으로 일상 명령을 만들어 세 인자 쪽에 넘긴다. `:66` 이 승인 자리다. `null` 이다.
|
||||
|
||||
## 세 인자 오버로드가 그 null 을 거절한다
|
||||
|
||||
`:87` 이 권한을 요구하고 `:88`\~`:93` 이 만료된 명령을 거절한다.
|
||||
|
||||
`:94` 가 연산이 고위험인지 본다. 고위험이면 `:95` 가 승인이 `null` 인지 보고, 그렇다면 `:96`\~`:102` 가 거절한다. 메시지는 데이터를 파괴하거나 컬렉션을 다시 쓰는 연산은 이 명령에 묶인 승인 아래에서 돌거나 아예 돌지 않는다는 것이다.
|
||||
|
||||
`:103` 이 승인 자체를 검사하고 `:106` 이 단일 사용을 강제한다. `:104`\~`:105` 주석은 그것이 없으면 한 reshard 에 대한 승인 하나가 같은 모양의 이후 모든 reshard 를 허가하게 된다고 적는다.
|
||||
|
||||
승인이 `null` 인 고위험 명령은 `:95` 에서 끝난다.
|
||||
|
||||
## 넷이 그 조합에 걸린다
|
||||
|
||||
`MongoAdminOperation` 상수는 열다섯이고 그중 여덟이 `highRisk(true)` 다.
|
||||
|
||||
main 에서 다섯 인자 오버로드로 넘어가는 연산은 일곱 줄에 나뉘어 있다. `BALANCER_CONTROL` 과 `COLL_MOD` 와 `CREATE_COLLECTION` 은 `false` 이고, `SHARD_COLLECTION` 과 `REFINE_SHARD_KEY` 와 `RESHARD_COLLECTION` 과 `MANAGE_ENCRYPTION_KEY` 는 `true` 다.
|
||||
|
||||
고위험 넷의 위치는 `MongoShardingAdminGateway:64`·`:75`·`:86`\~`:87` 과 `MongoQueryableEncryptionCollectionManager:63`\~`:64` 다. 마지막 것은 `:62` 의 `rotateDataKey` 로, 자바독이 자기 런북과 증거를 요구한다고 적는다.
|
||||
|
||||
넷 다 호출자가 무엇을 넘겨도 `:95` 를 지나지 못한다. 승인 자리가 그 오버로드 안에서 이미 정해져 있기 때문이다.
|
||||
|
||||
## 승인 객체를 만드는 main 코드가 없다
|
||||
|
||||
`MongoAdminApproval.of(...)` 나 `new MongoAdminApproval` 을 부르는 main 줄이 0 이다.
|
||||
|
||||
그 이름이 main 에 나오는 자리는 다섯인데, 넷이 `MongoAdminApproval` 자신의 선언과 팩터리이고 하나가 `MongoAdminGateway:83` 의 서명이다.
|
||||
|
||||
세 인자 `execute` 를 부르는 시험 줄은 `MongoAdminAuditStateMachineTest` 에 10 이고, 승인 객체를 만드는 시험 줄은 4 다. 프로덕션에는 그 짝이 없다.
|
||||
|
||||
`MongoShardingAdminGateway` 의 앞선 검사들은 그 뒤를 바꾸지 못한다. `:49` 의 준비도 검사와 `:54` 의 인덱스 검사와 `:74`·`:85` 의 `ReshardApproval.require()` 를 전부 통과해도 `:64`·`:75`·`:86` 에서 같은 자리에 막힌다.
|
||||
|
||||
`ReshardApproval` 과 `MongoAdminApproval` 은 다른 타입이다. `ReshardApproval` 이 main 에 나오는 줄은 넷인데 `MongoShardingAdminGateway:70`·`:81` 의 파라미터 둘과 `ReshardApproval:15`·`:22` 의 자기 선언 둘이다. `MongoAdminApproval` 을 만드는 자리는 그 안에 없다.
|
||||
|
||||
## 원문에 없는 것
|
||||
|
||||
원문은 샤딩 게이트웨이의 네 작업 중 셋이 어떤 입력으로도 완료될 수 없다고 적는다. 그 셋은 그대로 확인된다.
|
||||
|
||||
네 번째가 있다. `MongoQueryableEncryptionCollectionManager:63`\~`:64` 의 `rotateDataKey` 도 같은 오버로드에 `MANAGE_ENCRYPTION_KEY` 를 넘기고, 그 상수도 `highRisk(true)` 다. 같은 클래스 `:41`\~`:42` 의 `CREATE_COLLECTION` 은 `false` 라 걸리지 않는다.
|
||||
|
||||
다섯 인자 오버로드에 고위험 연산을 넘기는 main 자리를 저장소 전체에서 세면 넷이고, 그 넷이 두 클래스에 나뉘어 있다. 샤딩 리프만의 문제가 아니다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
그 넷을 불러 예외가 나는 것을 프로브로 보이지 않았다.
|
||||
|
||||
`MongoShardingAdminGateway` 와 `MongoQueryableEncryptionCollectionManager` 를 만드는 프로덕션 코드가 있는지 조립 경로를 세지 않았다.
|
||||
|
||||
포크가 승인을 어디서 만들어 넘기도록 설계된 것인지 설계 문서로 판단하지 않았다.
|
||||
|
||||
`ReshardApproval` 을 `MongoAdminApproval` 로 옮기는 코드가 왜 없는지, 두 타입의 관계를 설계 문서로 확인하지 않았다.
|
||||
|
||||
<!-- body:end -->
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: analysis-finding-a06-f024
|
||||
title: promotion 증거 어휘가 둘이고, gate는 하나만 검사한다
|
||||
topic: multitenancy-isolation
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:analysis-finding-a06-f024
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
assets:
|
||||
- key: analysis-finding-a06-f024
|
||||
file: ../../../final/evidence/rendered/analysis-finding-a06-f024.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/analysis-finding-a06-f024.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/06-adapter-outbound-persistence-mongo.md#L1305 이다.
|
||||
---
|
||||
|
||||
# promotion 증거 어휘가 둘이고, gate는 하나만 검사한다
|
||||
|
||||
승격 증거의 필수 범주가 여섯이고 게이트가 그 여섯을 검사한다. 벡터 검색 벤치마크 게이트는 완전히 다른 다섯 범주를 요구하는데 그 집합을 읽는 프로덕션 코드가 없다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **빠뜨림이 통과가 되는 게이트는 게이트가 아니다**
|
||||
이 사례가 그 규칙의 형태다.
|
||||
- **같은 개념의 두 어휘가 공존하면 하나를 죽은 것으로 표시한다**
|
||||
증거 어휘가 둘인 구조다.
|
||||
- **능력 등급은 코드가 아니라 실행된 증거에서 파생한다**
|
||||
승격이 요구하는 증거의 성질이다.
|
||||
|
||||
## 문제
|
||||
|
||||
승격 증거의 필수 범주는 여섯이다. 안정 플랫폼과 실제 토폴로지와 보안과 마이그레이션과 실패와 런북이다.
|
||||
|
||||
승격 게이트가 그 여섯을 전부 검사한다.
|
||||
|
||||
그리고 그 파일에는 고쳐진 결함이 주석으로 남아 있다. 한 범주가 필수 목록에는 있고 게이트에는 없어서, 게이트가 선언한 여섯 중 다섯만 요구했다는 것이다. 주석은 그것을 실행한 것보다 많이 인증하는 게이트의 모양이라고 부른다.
|
||||
|
||||
## 결론
|
||||
|
||||
같은 모양이 모듈 경계를 건너 다시 나타난다.
|
||||
|
||||
벡터 검색 벤치마크 게이트는 완전히 다른 다섯 범주를 요구한다. 인덱스 준비도와 재현율과 지연과 메모리와 실제 토폴로지다.
|
||||
|
||||
겹치는 것은 실제 토폴로지 하나뿐이고, 이 집합을 읽는 프로덕션 코드가 없다. 승격 게이트는 이 집합을 모른다.
|
||||
|
||||
그래서 벡터 검색을 승격하는 경로는 승격 게이트를 통과할 수 있고, 그 통과는 재현율과 지연과 인덱스 메모리에 대해 아무것도 말하지 않는다.
|
||||
|
||||
벤치마크 게이트의 javadoc 이 정확히 그 위험을 적는데도 그렇다. 기능적 성공은 벡터 검색의 증거가 아니며, 근사 인덱스는 어떤 질의에 대해서도 결과를 돌려주고 그것이 옳은 결과인지는 재현율에 달렸다는 것이다.
|
||||
|
||||
방금 한 범주 누락으로 고쳤던 것과 같은 모양이 반복된다. 선언한 것보다 적게 검사하는 게이트다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
확인 방식 : 두 게이트의 요구 범주 대조와 참조 계수
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
원문은 final/evidence/raw/137 계열에 있다.
|
||||
|
||||
1. 승격 증거의 필수 범주 목록을 확인한다.
|
||||
2. 승격 게이트가 검사하는 범주를 확인한다.
|
||||
3. 그 파일의 고쳐진 결함 주석을 읽는다.
|
||||
4. 벤치마크 게이트가 요구하는 범주를 확인한다.
|
||||
5. 그 집합을 읽는 프로덕션 코드를 센다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`MongoAdvancedPromotionEvidence.REQUIRED`는 여섯 범주다 — `stable-platform`, `actual-topology`, `security`, `migration`, `failure`, `runbook`. `MongoAdvancedPromotionGate.verify(...)`가 그 여섯을 전부 검사한다. 그 파일에는 고쳐진 결함이 주석으로 남아 있다 — "`migration` was in `MongoAdvancedPromotionEvidence.REQUIRED` and not here, so the gate demanded five of the six categories it declares… which is the shape MNG-008 names: a gate that certifies more than it ran."
|
||||
|
||||
## MongoAdvancedPromotionEvidence 참조 위치
|
||||
|
||||
:::evidence key="analysis-finding-a06-f024" alt="코드베이스에서 MongoAdvancedPromotionEvidence 를 검색한 출력 3줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="MongoAdvancedPromotionEvidence 코드베이스 검색 — 3줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
## 다른 gate가 완전히 다른 다섯 범주를 반환한다
|
||||
|
||||
`MongoVectorSearchBenchmarkGate.requiredEvidence()`는 `index-readiness`, `recall`, `latency`, `memory`, `actual-topology`를 반환한다. 겹치는 것은 `actual-topology` 하나뿐이고, 이 집합을 읽는 production 코드는 없다(`137-...` §8.3). `MongoAdvancedPromotionGate`는 이 집합을 모른다.
|
||||
|
||||
## 통과가 아무것도 말하지 않는 구간
|
||||
|
||||
vector search를 promotion하는 경로는 `MongoAdvancedPromotionGate.verify`를 통과할 수 있고, 그 통과는 recall·latency·index memory에 대해 아무것도 말하지 않는다 — `MongoVectorSearchBenchmarkGate`의 javadoc이 정확히 그 위험을 적는데도: "Functional success is not evidence for vector search. An approximate index returns results for any query; whether they are the right results depends on recall." 방금 `migration` 누락으로 고쳤던 것과 같은 모양이 모듈 경계를 건너 다시 나타난다. P3.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
벡터 검색 승격을 실제로 시도해 통과하는 것을 재현하지 않았다.
|
||||
|
||||
<!-- body:end -->
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: analysis-finding-a06-f025
|
||||
title: 구현 없는 4개의 계약 중 셋은 그 사실을 적고, 하나는 적지 않는다
|
||||
topic: multitenancy-isolation
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:analysis-finding-a06-f025
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
assets:
|
||||
- key: analysis-finding-a06-f025
|
||||
file: ../../../final/evidence/rendered/analysis-finding-a06-f025.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/analysis-finding-a06-f025.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/06-adapter-outbound-persistence-mongo.md#L1324 이다.
|
||||
---
|
||||
|
||||
# 구현 없는 4개의 계약 중 셋은 그 사실을 적고, 하나는 적지 않는다
|
||||
|
||||
세 인터페이스가 구현이 없다는 사실을 같은 문단으로 명시한다. 네 번째도 구현이 0 인데 그 문단이 없고, 넷 중 오해가 가장 비싼 것이 그것이다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **등급은 네 단계로 나누고 관측보다 높게 적지 않는다**
|
||||
같은 계열의 자기 한정 규칙이다.
|
||||
- **Bean 애너테이션이 있다는 것은 조립 증거가 아니다**
|
||||
인터페이스의 존재가 능력의 증거가 아니라는 규칙이다.
|
||||
- **Hibernate filter는 보안 경계가 아니다**
|
||||
격리 보장의 주체를 확인하는 규칙이다.
|
||||
|
||||
## 문제
|
||||
|
||||
세 인터페이스가 같은 문단을 담는다.
|
||||
|
||||
이 저장소는 구현을 출하하지 않는다. 메서드 시그니처를 사용 가능한 능력이 아니라 명세로 읽어야 한다. 구현이 없는 인터페이스는 주입될 수 없고, 그것을 출하된 동작으로 다루는 것이 플랫폼이 검색을 지원한다는 말이 문서에서는 참이고 배포에서는 거짓이 되는 방식이다.
|
||||
|
||||
훌륭한 자기 한정이고 이 리프에서 반복적으로 필요했던 종류의 정직함이다.
|
||||
|
||||
## 결론
|
||||
|
||||
네 번째 인터페이스도 구현이 0 인데 그 문단이 없다.
|
||||
|
||||
네 인터페이스 모두 구현 검색이 일치를 내지 않는다.
|
||||
|
||||
그리고 넷 중 오해가 가장 비싼 것이 바로 그것이다. javadoc 이 테넌트 술어 없이는 실행될 수 없는 연산이라고 시작하므로 능동적인 안전장치로 읽힌다.
|
||||
|
||||
실제로 그 보장을 제공하는 것은 별도의 술어 주입기이고 그것은 구현이 있다. 이 인터페이스는 어떤 배포가 구현했을 때 그 주입기를 부르게 되는 형태일 뿐이다.
|
||||
|
||||
즉 이름과 첫 문장이 보장을 약속하는데, 그 보장을 만드는 것은 다른 타입이고 이 타입은 비어 있다.
|
||||
|
||||
수정은 같은 자기 한정 문단을 이 인터페이스에도 추가하고, 실제 보장이 어디서 오는지 함께 적는 것이다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
확인 방식 : 네 인터페이스의 구현 검색과 javadoc 대조
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
원문은 final/evidence/raw/137 계열에 있다.
|
||||
|
||||
1. 세 인터페이스의 자기 한정 문단을 읽는다.
|
||||
2. 네 인터페이스 모두에 대해 구현 검색을 수행한다.
|
||||
3. 네 번째 인터페이스의 javadoc 첫 문장을 읽는다.
|
||||
4. 실제로 테넌트 술어를 강제하는 타입을 찾는다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`MongoSearchOperations`·`MongoTimeSeriesOperations`·`MongoVectorSearchOperations`는 모두 동일한 문단을 담는다.
|
||||
|
||||
> **Scaffold.** This repository ships no implementation… Read a method signature as a specification, not as an available capability — an interface with no implementation cannot be injected, and treating it as shipped behaviour is how "the platform supports search" becomes true in a document and false in a deployment.
|
||||
|
||||
## MongoSearchOperations 참조 위치
|
||||
|
||||
:::evidence key="analysis-finding-a06-f025" alt="코드베이스에서 MongoSearchOperations 를 검색한 출력 1줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="MongoSearchOperations 코드베이스 검색 — 1줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
## 넷째에는 그 문단이 없다
|
||||
|
||||
`TenantScopedMongoOperations`도 구현이 0인데(`137-...` §8.3d: 네 interface 모두 `implements` 검색 exit=1) 그 문단이 없다.
|
||||
|
||||
## 하필 오해가 가장 비싼 것이다
|
||||
|
||||
javadoc이 "Operations that cannot run without a tenant predicate"라고 시작하므로 능동적인 안전장치로 읽힌다. 실제로 그 보장을 제공하는 것은 `MongoTenantPredicateInjector`(policy, 구현 있음)이고, 이 interface는 fork가 구현했을 때만 그 injector를 부르게 되는 **형태**일 뿐이다. P3.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
이 인터페이스를 구현했을 때 실제로 주입기가 호출되는 경로가 있는지 확인하지 않았다.
|
||||
|
||||
<!-- body:end -->
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: analysis-finding-a06-f026
|
||||
title: 커버리지 gate 둘이 나란히 있고, 하나는 발화할 수 없다
|
||||
topic: multitenancy-isolation
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:analysis-finding-a06-f026
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
body: case-analysis-finding-a06-f026.body.md
|
||||
assets:
|
||||
- key: analysis-finding-a06-f026
|
||||
file: ../../../final/evidence/rendered/analysis-finding-a06-f026.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/analysis-finding-a06-f026.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/06-adapter-outbound-persistence-mongo.md#L1383 이다.
|
||||
---
|
||||
|
||||
# 커버리지 gate 둘이 나란히 있고, 하나는 발화할 수 없다
|
||||
|
||||
계약 묶음의 javadoc 은 실패한 계약과 한 번도 돌지 않은 계약을 구분한다고 적는다. 구현에서 실행 집합이 전체와 항상 같으므로 누락 항목이 어떤 입력으로도 생성되지 않는다. 같은 테스트킷에 옳게 구현된 형제가 있다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **빠뜨림이 통과가 되는 게이트는 게이트가 아니다**
|
||||
이 사례가 그 규칙의 형태다.
|
||||
- **아무것도 발견하지 못한 레인은 성공이 아니라 실패여야 한다**
|
||||
같은 원칙의 레인 판이다.
|
||||
- **중복 장치를 찾으면 어느 쪽이 조립됐는지 먼저 확인한다**
|
||||
형제 구현과 비교하는 방법이다.
|
||||
|
||||
## 문제
|
||||
|
||||
계약 묶음의 javadoc 이 존재 이유를 적는다.
|
||||
|
||||
보고서는 실패한 계약과 한 번도 돌지 않은 계약을 구분한다. 절반이 건너뛰어졌기 때문에 실패 없음을 보고하는 묶음은 아무것도 인증하지 않는 초록 빌드의 전형이므로, 누락된 계약은 여기서 실패다.
|
||||
|
||||
## 결론
|
||||
|
||||
구현이 그 구분을 만들 수 없다.
|
||||
|
||||
루프가 실행 집합을 무조건 채운다. 각 계약을 실행 집합에 넣고 그다음 검사를 수행한다.
|
||||
|
||||
그러므로 실행 집합은 전체 집합과 언제나 같고, 누락 집합은 언제나 비며, 실행되지 않음 항목은 어떤 입력으로도 생성되지 않는다.
|
||||
|
||||
인증 여부를 판정하는 조건도 마찬가지로 항상 참이다.
|
||||
|
||||
조건부 형제가 같은 테스트킷 안에 있다. 카오스 게이트의 보고 메서드는 같은 일을 옳게 한다. 실행 집합이 명시적 기록 호출로만 채워지는 맵이고, 누락은 전체에서 기록되지 않은 것을 뺀 것이다.
|
||||
|
||||
그리고 그 형제의 테스트가 그것을 증명한다. 열세 시나리오 중 하나만 기록하고 나머지가 실행되지 않음으로 나타나는지 단언한다.
|
||||
|
||||
계약 묶음의 대응 테스트는 그렇게 하지 않는다. 모든 계약에 통과를 주고 나서 실행 집합이 전부를 담는지 단언한다. 구조상 항상 참인 것을 단언하는 것이다.
|
||||
|
||||
판정은 두 인증 레인의 커버리지 주장이 무효라는 것이다.
|
||||
|
||||
수정은 형제를 따르면 된다. 실행 메서드가 실행할 계약 집합을 인자로 받거나, 실제로 호출된 것만 실행 집합에 넣는 것이다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
확인 방식 : 구현 코드 확인과 형제 구현 비교
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
원문은 final/evidence/raw/138 계열에 있다.
|
||||
|
||||
1. 계약 묶음의 javadoc 을 읽는다.
|
||||
2. 보고 메서드의 루프에서 실행 집합이 어떻게 채워지는지 확인한다.
|
||||
3. 누락 집합이 어떻게 계산되는지 확인한다.
|
||||
4. 카오스 게이트의 같은 메서드와 비교한다.
|
||||
5. 두 테스트가 각각 무엇을 단언하는지 비교한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`MongoStableContractSuite`의 javadoc이 존재 이유를 적는다.
|
||||
|
||||
> The report distinguishes a failed contract from a contract that never ran. A suite that reports "no failures" because half of it was skipped is exactly the shape of green build that certifies nothing, **so a missing contract is a failure here.**
|
||||
|
||||
## suite javadoc 이 적은 존재 이유
|
||||
|
||||
:::evidence key="analysis-finding-a06-f026" alt="분석 문서 analysis/06-adapter-outbound-persistence-mongo.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/06-adapter-outbound-persistence-mongo.md 발췌 — 15줄" zoom="true"
|
||||
:::
|
||||
|
||||
## 구현은 그 구분을 만들 수 없다
|
||||
|
||||
```java
|
||||
Set<MongoReplicaSetContract> executed = new LinkedHashSet<>();
|
||||
for (MongoReplicaSetContract contract : MongoReplicaSetContract.all()) {
|
||||
executed.add(contract); // ← 루프가 무조건 채운다
|
||||
if (!contractRunner.test(contract)) { failures.add(...); }
|
||||
}
|
||||
Set<MongoReplicaSetContract> missing = new LinkedHashSet<>(MongoReplicaSetContract.all());
|
||||
missing.removeAll(executed); // ← 항상 비어 있다
|
||||
missing.forEach(contract -> failures.add(... + " (not executed)"));
|
||||
```
|
||||
|
||||
`executed`는 `all()`과 언제나 같으므로 `missing`은 언제나 비고, `(not executed)` 항목은 **어떤 입력으로도 생성되지 않는다**. `certified()`의 `executed.containsAll(all())`(78행)도 항상 참이다. 두 인증 lane(7.0/8.0)의 커버리지 주장이 무효다. P2.
|
||||
|
||||
## 조건부 형제가 같은 testkit 안에 있다
|
||||
|
||||
`MongoChaosGate.report()`는 같은 일을 옳게 한다 — `executed`는 명시적 `record(scenario, passed)` 호출로만 채워지는 map이고, `missing`은 `all()`에서 기록되지 않은 것을 뺀 것이다. 그 test가 그것을 증명한다: `aScenarioThatNeverRanIsAFailureRatherThanASilence`는 13개 시나리오 중 **하나만** 기록하고 나머지가 `(not executed)`로 나타나는지 단언한다. 수정은 형제를 따르면 된다 — `run(...)`이 실행할 contract 집합을 인자로 받거나, runner가 실제로 호출된 것만 `executed`에 넣는 것.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
계약 하나를 실제로 건너뛰게 만들어 보고서가 여전히 깨끗한지 재현하지 않았다. 코드 구조상 그 결과가 나온다.
|
||||
|
||||
<!-- body:end -->
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: analysis-finding-a06-f027
|
||||
title: release gate가 실제로 차단하는 것은 hermetic test 3개이고, mongo용 CI workflow는 없다
|
||||
topic: multitenancy-isolation
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:analysis-finding-a06-f027
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
body: case-analysis-finding-a06-f027.body.md
|
||||
assets:
|
||||
- key: analysis-finding-a06-f027
|
||||
file: ../../../final/evidence/rendered/analysis-finding-a06-f027.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/analysis-finding-a06-f027.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/06-adapter-outbound-persistence-mongo.md#L1410 이다.
|
||||
---
|
||||
|
||||
# release gate가 실제로 차단하는 것은 hermetic test 3개이고, mongo용 CI workflow는 없다
|
||||
|
||||
증거 검증기는 정성껏 만들어져 있다. 그 검증기가 실제로 지키는 차단 목록 셋은 전부 컨테이너가 필요 없는 클래스이고, 실험 셋은 어느 빌드 파일에도 등록되지 않은 태스크를 가리키며, 컨테이너가 필요한 여섯 레인은 차단 목록에 하나도 없다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **아무도 돌리지 않는 레인의 게이트는 마지막으로 돌린 사람이 본 것을 보고한다**
|
||||
이 사례가 그 규칙의 형태다.
|
||||
- **빠뜨림이 통과가 되는 게이트는 게이트가 아니다**
|
||||
같은 계열의 상위 규칙이다.
|
||||
- **인증 레인만 Docker 가드를 달지 않는다**
|
||||
컨테이너 레인을 게이트에 묶는 다른 가족의 결정이다.
|
||||
|
||||
## 문제
|
||||
|
||||
이 리프는 릴리스 증거 장치를 정성껏 만들었다.
|
||||
|
||||
증거 검증기는 종료 코드 대신 테스트 결과 XML 을 읽는다. 묶음 이름이 계약의 클래스와 일치하는지 확인하고, 파일이 실행 시작 시각보다 오래됐으면 거부하며, 전부 건너뛴 레인을 거부한다.
|
||||
|
||||
그 근거도 정확하다. Gradle 테스트 태스크는 테스트를 돌렸을 때도 선택자가 다른 테스트에 맞았을 때도 0 으로 끝나므로, 샤딩 토폴로지가 인증됐다는 주장이 이름에 특정 문자열이 들어간 밀폐 단위 테스트로 충족될 수 있었다는 것이다.
|
||||
|
||||
## 결론
|
||||
|
||||
그 장치가 실제로 지키는 목록을 열어 보면 다르다.
|
||||
|
||||
차단 계약 셋이 전부 토폴로지 없음이다. 즉 컨테이너가 필요 없는 밀폐 클래스다.
|
||||
|
||||
실험 셋은 어느 빌드 파일에도 등록되지 않은 태스크를 가리킨다. 스크립트가 그 사실을 스스로 적는다.
|
||||
|
||||
그리고 컨테이너가 필요한 여섯 레인은 차단 목록에 하나도 없다. 복제 세트와 장애 조치와 마이그레이션과 호환성과 보안 통합과 성능이다.
|
||||
|
||||
이것은 개별 코드 결함이 아니라 이 리프의 검증 지형이다.
|
||||
|
||||
그리고 앞선 발견들이 왜 살아남았는지를 설명한다. 변경 소실과 TLS 미적용과 샤딩 미완료와 펜스 계약 위반을 잡을 레인은 릴리스를 막지 않고 CI 에서 돌지 않는다.
|
||||
|
||||
수정은 두 갈래다. 컨테이너 레인 중 최소한 복제 세트와 마이그레이션과 보안 통합을 차단 계약으로 승격하고, 다른 가족과 같은 형태의 워크플로를 추가하는 것이다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
Gradle : 9.0.0
|
||||
확인 방식 : 릴리스 계약 정의 파일과 빌드 파일 대조, CI 워크플로 계수
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
원문은 final/evidence/raw/138 계열에 있다.
|
||||
|
||||
1. 증거 검증기가 무엇을 검사하는지 읽는다.
|
||||
2. 릴리스 계약 정의 파일에서 차단 목록과 실험 목록을 확인한다.
|
||||
3. 각 항목의 토폴로지 값을 확인한다.
|
||||
4. 실험 목록의 태스크가 빌드 파일에 등록되어 있는지 확인한다.
|
||||
5. 컨테이너가 필요한 레인 목록과 차단 목록을 대조한다.
|
||||
6. CI 워크플로에서 이 리프를 이름에 담은 것을 센다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
release gate가 실제로 차단하는 것은 hermetic test 3개이고, mongo용 CI workflow는 없다.
|
||||
|
||||
## release gate 가 실제로 거는 것
|
||||
|
||||
:::evidence key="analysis-finding-a06-f027" alt="분석 문서 analysis/06-adapter-outbound-persistence-mongo.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/06-adapter-outbound-persistence-mongo.md 발췌 — 15줄" zoom="true"
|
||||
:::
|
||||
|
||||
## 개별 코드 결함이 아니라 검증 지형이다
|
||||
|
||||
앞선 sub-scope들에서 찾은 것들 — §67의 change stream 소실, §75의 TLS 미적용, §85의 sharding 미완료, §56의 fence 계약 — 이 왜 살아남았는지를 설명한다: 그것들을 잡을 lane은 릴리스를 막지 않고 CI에서 돌지 않는다. P2.
|
||||
|
||||
## 수정은 두 갈래다
|
||||
|
||||
(a) 컨테이너 lane 중 최소한 `mongoReplicaSetTest`·`mongoMigrationTest`·`mongoSecurityIntegrationTest`를 blocking contract로 승격하고, (b) JPA와 같은 형태의 workflow를 추가하는 것.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
릴리스 게이트를 실제로 실행하지 않았다. 확인한 것은 그것이 지키는 목록의 구성이다.
|
||||
|
||||
차단 계약 셋 전부가 topology=none, 즉 컨테이너가 필요 없는 hermetic 클래스다. experimental 셋은 어느 build 파일에도 등록되지 않은 task를 가리킨다(grep mongoShardedTest build.gradle → 매치 0; 스크립트가 그 사실을 스스로 적는다: registered by no build file). 그리고 컨테이너가 필요한 여섯 lane — mongoReplicaSetTest·mongoFailoverTest·mongoMigrationTest·mongoCompatibilityTest·mongoSecurityIntegrationTest·mongoPerformanceTest — 은 차단 목록에 하나도 없다.
|
||||
|
||||
<!-- body:end -->
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: analysis-finding-a06-f028
|
||||
title: 소비자가 없는 fixture 셋
|
||||
topic: multitenancy-isolation
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:analysis-finding-a06-f028
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
assets:
|
||||
- key: analysis-finding-a06-f028
|
||||
file: ../../../final/evidence/rendered/analysis-finding-a06-f028.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/analysis-finding-a06-f028.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/06-adapter-outbound-persistence-mongo.md#L1433 이다.
|
||||
---
|
||||
|
||||
# 소비자가 없는 fixture 셋
|
||||
|
||||
테스트킷의 세 타입이 테스트와 테스트킷 양쪽 모두에서 소비자가 0 이다. 그중 하나는 서버를 통과하는 왕복 계약을 실행하도록 만들어진 것이고, 그 계약은 열거된 계약 목록에 들어 있다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **계약 테스트는 어댑터가 실제로 돌리는 statement를 실행해야 한다**
|
||||
왕복 계약이 존재하는 이유다.
|
||||
- **release gate가 실제로 차단하는 것은 hermetic test 3개이고 이 리프용 CI workflow는 없다**
|
||||
같은 리프의 검증 지형을 다룬 사례다.
|
||||
- **Bean 애너테이션이 있다는 것은 조립 증거가 아니다**
|
||||
타입의 존재와 사용을 나눠 세는 규칙이다.
|
||||
|
||||
## 문제
|
||||
|
||||
테스트킷은 계약을 실행할 도구를 담는다. 그 도구들이 실제로 쓰이는지 확인이 필요하다.
|
||||
|
||||
## 결론
|
||||
|
||||
소비자 계수에서 양쪽 모두 0 인 타입이 셋이다.
|
||||
|
||||
로컬 컨테이너 타입은 검색과 벡터 계약의 빠른 피드백용이다. 관련 보고 타입은 테스트 한 곳에서 쓰이지만 실제 컨테이너를 띄우는 곳은 없다.
|
||||
|
||||
청크 이동 제어기는 트래픽 중 청크 이동을 재현하는 유일한 장치다. 재조정 중 프로덕션 요청이 들어오는 상황을 만든다.
|
||||
|
||||
왕복 계약은 자바에서 BSON 으로 서버를 거쳐 원시 BSON 으로 다시 자바로 돌아오는 왕복을 검증한다. javadoc 이 그 이유를 적는다. 왕복의 절반은 아무것도 증명하지 않으며 가운데의 원시 BSON 만이 그것을 보여 준다.
|
||||
|
||||
셋째가 가장 무겁다.
|
||||
|
||||
계약 열거형이 골든 BSON 을 열거된 계약으로 두는데, 그 계약을 실행하도록 만들어진 타입에 호출자가 없다.
|
||||
|
||||
BSON 스냅숏과 그 단언 도우미는 쓰인다. 그래서 정규형 단언은 존재하지만 서버를 통과하는 왕복은 돌지 않는다.
|
||||
|
||||
그리고 그 차이가 정확히 이 클래스가 존재하는 이유다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
확인 방식 : 테스트킷 타입별 소비자 계수
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
원문은 final/evidence/raw/138 계열에 있다.
|
||||
|
||||
1. 테스트킷의 타입 목록을 만든다.
|
||||
2. 각 타입의 테스트와 테스트킷 소비자를 센다.
|
||||
3. 소비자가 0 인 타입의 javadoc 을 읽는다.
|
||||
4. 왕복 계약이 계약 열거형에 있는지 확인한다.
|
||||
5. 정규형 단언 도우미가 쓰이는지 확인한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`138-...` §8.1의 소비자 계수에서 test·testkit 양쪽 모두 0인 타입이 셋이다.
|
||||
|
||||
| 타입 | 무엇을 위한 것인가 |
|
||||
|---|---|
|
||||
| `MongoAtlasLocalContainer` | Atlas Local 컨테이너 — search·vector 계약의 빠른 피드백용. `MongoAtlasCapabilityContractSuite`(report 타입)는 test 1곳에서 쓰이지만, **실제 컨테이너를 띄우는 곳은 없다** |
|
||||
| `MongoChunkMigrationController` | 트래픽 중 청크 이동 — "production hits during a rebalance"를 재현하는 유일한 장치 |
|
||||
| `MongoRoundTripContract` | Java → BSON → **서버** → raw BSON → Java 왕복. javadoc: "Half a round trip proves nothing… only the raw BSON in the middle shows it" |
|
||||
|
||||
## test 와 testkit 양쪽에서 0 인 타입
|
||||
|
||||
:::evidence key="analysis-finding-a06-f028" alt="분석 문서 analysis/06-adapter-outbound-persistence-mongo.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/06-adapter-outbound-persistence-mongo.md 발췌 — 15줄" zoom="true"
|
||||
:::
|
||||
|
||||
## 셋째가 가장 무겁다
|
||||
|
||||
`MongoReleaseContract`의 형제인 `MongoReplicaSetContract`는 `GOLDEN_BSON`을 열거된 계약으로 두는데, 그 계약을 실행하도록 만들어진 타입에 호출자가 없다. `MongoBsonSnapshot`·`MongoBsonSnapshotAssert`는 쓰이므로 **정규형 단언은 존재하지만 서버를 통과하는 왕복은 돌지 않는다** — 그리고 그 차이가 정확히 이 클래스가 존재하는 이유다. P3.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
왕복 계약을 실제로 실행해 원시 BSON 이 기대와 다른지 확인하지 않았다.
|
||||
|
||||
<!-- body:end -->
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: search-path-survived-the-return-to-the-pool
|
||||
title: search_path가 풀로 돌아간 커넥션에 남아 다음 tenant가 상속한다
|
||||
topic: multitenancy-isolation
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:search-path-survived-the-return-to-the-pool
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
assets:
|
||||
- key: search-path-survived-the-return-to-the-pool
|
||||
file: ../../../final/evidence/rendered/search-path-survived-the-return-to-the-pool.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/search-path-survived-the-return-to-the-pool.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/05 §13.2 이다.
|
||||
---
|
||||
|
||||
# search_path가 풀로 돌아간 커넥션에 남아 다음 tenant가 상속한다
|
||||
|
||||
검색 경로는 세션 설정이므로 풀로 돌아간 커넥션이 마지막 테넌트의 스키마를 여전히 들고 있다. 다음 차용자는 어떤 문장도 틀리지 않은 채 그 스키마에서 읽고 쓴다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **격리 설정은 트랜잭션 로컬이어야 한다**
|
||||
이 사례에서 끌어낸 규칙이다.
|
||||
- **세션 스코프 설정은 풀로 돌아간 커넥션에 남는다**
|
||||
같은 성질을 일반화한 규칙이다.
|
||||
- **네 가지 멀티테넌시 전략과 각각의 격리 경계**
|
||||
이 전략의 실패 모드다.
|
||||
|
||||
## 문제
|
||||
|
||||
테넌트별 스키마 전략은 검색 경로로 격리한다. 커넥션마다 그 테넌트의 스키마를 검색 경로에 넣는다.
|
||||
|
||||
검색 경로는 세션 설정이다. 트랜잭션이 끝나도 사라지지 않는다.
|
||||
|
||||
## 결론
|
||||
|
||||
풀로 돌아간 커넥션이 마지막 테넌트의 스키마를 들고 있다.
|
||||
|
||||
다음 차용자는 아마 다른 테넌트이거나 테넌트가 없는 백그라운드 작업이다. 그 차용자가 실행하는 문장은 전부 문법적으로 옳고 의미적으로도 옳다. 다만 다른 테넌트의 스키마에서 읽고 쓴다.
|
||||
|
||||
이 실패에는 오류가 없다. 테이블 이름이 같으므로 조회가 성공하고 결과가 돌아온다.
|
||||
|
||||
해법은 반환 시 검색 경로를 중립 스키마로 되돌리는 것이다. 이 구현은 시스템 카탈로그 스키마를 중립 값으로 쓴다.
|
||||
|
||||
같은 성질이 이 저장소의 다른 곳에도 있다. H2 의 로컬 타임아웃 설정도 세션 스코프라 트랜잭션이 끝나도 남는다. 그쪽은 매 트랜잭션 전에 다시 적용하므로 실무상 가려진다.
|
||||
|
||||
두 대응이 다르다. 하나는 반환 시 되돌리고 다른 하나는 사용 전 덮어쓴다. 후자는 값이 항상 설정되는 경우에만 안전하다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
데이터베이스 : PostgreSQL
|
||||
확인 방식 : 커넥션 제공자의 재설정 구현과 javadoc 확인
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 스키마 멀티테넌트 커넥션 제공자의 재설정 구현을 읽는다.
|
||||
2. 중립 스키마 상수를 확인한다.
|
||||
3. 그 재설정이 커넥션 반환 시점에 호출되는지 확인한다.
|
||||
4. H2 로컬 타임아웃 설정기의 대응 방식과 비교한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`search_path`는 세션 설정이라 풀로 돌아간 커넥션이 여전히 마지막 tenant의 스키마를 들고 있다.
|
||||
|
||||
## search_path 가 세션 설정이라는 것
|
||||
|
||||
:::evidence key="search-path-survived-the-return-to-the-pool" alt="분석 문서 analysis/05-adapter-outbound-persistence-jpa.md 에서 이 기록의 근거 절을 그대로 잘라낸 16줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/05-adapter-outbound-persistence-jpa.md 발췌 — 16줄" zoom="true"
|
||||
:::
|
||||
|
||||
## 다음 borrower 가 아무 오류 없이 거기서 읽고 쓴다
|
||||
|
||||
아마 다른 tenant, 또는 tenant 없는 백그라운드 job이다 — **어떤 statement도 틀리지 않은 채로** 일어난다.
|
||||
|
||||
## 해법과 같은 성질의 다른 자리
|
||||
|
||||
반환 시 `NEUTRAL_SCHEMA = "pg_catalog"`로 되돌린다. 같은 성질이 H2의 로컬 타임아웃 설정에도 있고(세션 스코프라 트랜잭션이 끝나도 남는다) 그쪽은 매 트랜잭션 전 재적용으로 실무상 가려진다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
풀 재사용에서 잔존을 실제로 관측하지 않았다. 이 전략은 실험 플래그 뒤에 있고 이 사이클에서 켜지 않았다.
|
||||
|
||||
<!-- body:end -->
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: tenant-pools-summed-past-the-server-ceiling
|
||||
title: tenant별 풀이 개별적으로 합리적이고 그 합이 서버 상한을 넘긴다
|
||||
topic: multitenancy-isolation
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:tenant-pools-summed-past-the-server-ceiling
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
assets:
|
||||
- key: tenant-pools-summed-past-the-server-ceiling
|
||||
file: ../../../final/evidence/rendered/tenant-pools-summed-past-the-server-ceiling.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/tenant-pools-summed-past-the-server-ceiling.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/05 §13.2 이다.
|
||||
---
|
||||
|
||||
# tenant별 풀이 개별적으로 합리적이고 그 합이 서버 상한을 넘긴다
|
||||
|
||||
테넌트별 데이터베이스 전략은 특정한 방식으로 실패한다. 각 테넌트의 풀은 개별적으로 합리적이고 그 합이 아니다. 실패는 유휴 상태이던 테넌트를 포함해 모든 테넌트에 동시에 도착한다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **REQUIRES_NEW의 커넥션 비용과 풀 사이징 제약**
|
||||
같은 자원 계산의 다른 축이다.
|
||||
- **네 가지 멀티테넌시 전략과 각각의 격리 경계**
|
||||
이 전략의 실패 모드다.
|
||||
- **데드라인은 호출 예산에서 시작해 세 단계로 좁힌다**
|
||||
풀 획득이 예산의 일부라는 점에서 연결된다.
|
||||
|
||||
## 문제
|
||||
|
||||
테넌트별 데이터베이스는 커넥션 자체가 격리 경계다. 각 테넌트가 자기 풀을 갖는다.
|
||||
|
||||
풀 크기는 테넌트마다 정한다. 그 값은 개별적으로는 합리적이다.
|
||||
|
||||
## 결론
|
||||
|
||||
합이 서버 상한을 넘는다.
|
||||
|
||||
테넌트 50 개에 각각 커넥션 10 개면 500 이다. 서버의 최대 커넥션이 100 이면 400 이 거절된다.
|
||||
|
||||
그리고 그 거절은 한 테넌트에만 오지 않는다. 유휴 상태이던 테넌트를 포함해 모든 테넌트에 동시에 커넥션 거절로 도착한다. 서버의 상한은 전역이기 때문이다.
|
||||
|
||||
그래서 예산 타입이 두 상한을 갖는다. 풀 개수와 커넥션 총합이다.
|
||||
|
||||
둘 다 필요한 이유가 javadoc 에 적혀 있다. 풀 개수만으로는 풀 크기 차이를 무시하고, 커넥션 총합만으로는 각자 스레드와 모니터링을 가진 무한한 수의 작은 풀을 허용한다.
|
||||
|
||||
다만 예산 자체에 결함이 있다. 새 풀의 크기를 계산에 넣지 않아 상한을 넘길 수 있다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
확인 방식 : 예산 타입의 두 상한과 javadoc 확인
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 테넌트 풀 예산 타입의 두 상한을 확인한다.
|
||||
2. 각 상한이 필요한 이유를 javadoc 에서 읽는다.
|
||||
3. 새 풀을 승인할 때 그 풀의 크기가 계산에 들어가는지 확인한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
database-per-tenant는 특정한 방식으로 실패한다 — 각 tenant의 풀은 개별적으로 합리적이고 **그 합이 아니다.** 50 tenant × 10 커넥션 = `max_connections`가 100인 서버에 500 커넥션이고, 실패는 **idle이던 것 포함 모든 tenant에 동시에** connection refusal로 도착한다.
|
||||
|
||||
## TenantPoolBudget 참조 위치
|
||||
|
||||
:::evidence key="tenant-pools-summed-past-the-server-ceiling" alt="코드베이스에서 TenantPoolBudget 를 검색한 출력 4줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="TenantPoolBudget 코드베이스 검색 — 4줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
## ceiling 이 둘인 이유
|
||||
|
||||
pool count만으로는 풀 크기 차이를 무시하고, connection total만으로는 각자 스레드와 모니터링을 가진 무한한 수의 작은 풀을 허용한다.
|
||||
|
||||
## 예산이 새 pool 크기를 계산하지 않는다
|
||||
|
||||
`analysis/05` §98이 기록하듯 그래서 ceiling을 넘길 수 있다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
다중 테넌트 풀을 실제로 세워 상한 초과를 재현하지 않았다. 이 전략은 실험 플래그 뒤에 있다.
|
||||
|
||||
<!-- body:end -->
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: three-ways-rls-does-nothing
|
||||
title: RLS가 아무것도 하지 않는 세 가지 방법
|
||||
topic: multitenancy-isolation
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:three-ways-rls-does-nothing
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
assets:
|
||||
- key: three-ways-rls-does-nothing
|
||||
file: ../../../final/evidence/rendered/three-ways-rls-does-nothing.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/three-ways-rls-does-nothing.txt
|
||||
source:
|
||||
- 원본 분석 절은 final/document.md#4-1 · analysis/05 §13.1, §17 P8 이다.
|
||||
---
|
||||
|
||||
# RLS가 아무것도 하지 않는 세 가지 방법
|
||||
|
||||
정책 검증기가 행 수준 보안이 조용히 무력화되는 세 경로를 전부 확인한다. 다만 검증기 자신이 보호되어야 할 테이블의 부재를 성공으로 인정하는 결함을 갖는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **RLS가 성립하기 위한 세 전제**
|
||||
이 검증기가 확인하는 세 조건이다.
|
||||
- **Hibernate filter는 보안 경계가 아니다**
|
||||
같은 격리 문제를 ORM 기능으로 풀려 할 때의 규칙이다.
|
||||
- **tenant 컬럼이 있는 테이블의 모든 unique 제약에 그 컬럼이 들어가야 한다**
|
||||
같은 마이그레이션이 함께 다룬 축이다.
|
||||
|
||||
## 문제
|
||||
|
||||
행 수준 보안은 설정이 올바르게 보이면서 아무것도 하지 않을 수 있다. 그 경로가 셋이다.
|
||||
|
||||
정책이 없거나 테이블에 RLS 가 활성화되지 않았다
|
||||
런타임 롤이 우회 속성을 갖는다
|
||||
런타임 롤이 그 테이블을 소유한다
|
||||
|
||||
셋 다 같은 결과를 만든다. 모든 쿼리가 모든 테넌트의 행을 돌려주면서 정책은 올바르게 설정된 것처럼 보인다.
|
||||
|
||||
## 결론
|
||||
|
||||
검증기가 세 경로를 전부 확인한다.
|
||||
|
||||
시스템 카탈로그에서 테이블별로 RLS 활성 여부와 강제 여부를 읽고, 롤의 우회 속성을 확인하며, 소유 관계를 본다.
|
||||
|
||||
세 번째가 가장 놓치기 쉽다. 소유자는 기본적으로 자기 정책에서 면제되고, 소유자는 흔히 마이그레이션 롤이며, 그것이 사람들이 테스트하는 롤이다.
|
||||
|
||||
같은 축의 문제가 유니크 인덱스에도 있고 마이그레이션 주석이 그것을 적는다. 테넌트 격리가 컬럼에 의존하면 그 테이블의 모든 유일성 요구에 그 컬럼이 들어가야 한다. 값만으로 걸린 유니크 인덱스는 다른 테넌트가 그 값을 이미 썼다는 이유로 한 테넌트의 삽입을 실패시키고, 그것은 버그이자 정보 유출이다.
|
||||
|
||||
다만 검증기 자신에게 결함이 있다. 반드시 보호되어야 할 테이블이 조회 결과에 없을 때 그것을 성공으로 인정한다. 즉 테이블 이름이 바뀌거나 조회 조건이 어긋나면 검증이 통과한다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
데이터베이스 : PostgreSQL
|
||||
확인 방식 : 검증기가 검사하는 세 조건과 마이그레이션 주석 확인
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 정책 검증기의 클래스 javadoc 을 읽는다. 세 경로가 열거되어 있다.
|
||||
2. 검증기가 실행하는 카탈로그 조회를 확인한다.
|
||||
3. 마이그레이션의 유니크 인덱스 주석을 읽는다.
|
||||
4. 보호 대상 테이블이 조회 결과에 없을 때의 처리를 확인한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`RlsPolicyVerifier`가 RLS가 조용히 무력화되는 세 경로를 전부 확인한다 — policy 없음/RLS 미활성 · 런타임 롤이 `BYPASSRLS` 보유 · **런타임 롤이 테이블을 소유**(FORCE 없으면 면제).
|
||||
|
||||
## RlsPolicyVerifier 참조 위치
|
||||
|
||||
:::evidence key="three-ways-rls-does-nothing" alt="코드베이스에서 RlsPolicyVerifier 를 검색한 출력 2줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="RlsPolicyVerifier 코드베이스 검색 — 2줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
## 세 번째가 가장 놓치기 쉽다
|
||||
|
||||
소유자는 정책을 우회하는 것이 아니라 애초에 적용 대상이 아니다.
|
||||
|
||||
## unique index 에도 같은 축의 주석이 있다
|
||||
|
||||
tenant 격리가 컬럼에 의존하면 그 테이블의 **모든 uniqueness 요구**에 그 컬럼이 들어가야 하고, `(value)`만의 unique index는 다른 tenant가 그 값을 썼다는 이유로 insert를 실패시켜 **버그이자 정보 유출**이 된다.
|
||||
|
||||
## verifier 자신에게도 결함이 있다
|
||||
|
||||
`analysis/05` §97이 기록하듯 "반드시 보호돼야 하는 table"의 부재를 성공으로 인정한다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
세 조건을 하나씩 깨서 검증기가 실제로 잡는지 확인하지 않았다. 실제 RLS 환경을 세우지 않았다.
|
||||
|
||||
<!-- body:end -->
|
||||
Reference in New Issue
Block a user