Files
document-haness/docs/clean-architecture-backend-template/tech-log-studio/multitenancy-isolation/case/case-analysis-finding-a05-f028.md
T
DongHyeonkaandClaude Fable 5.1 b25357c48a docs(clean-architecture-backend-template): fold analysis into final and re-select one topic
- analysis/·source-index·state.json 을 final/document.md 제2부·제3부로 접었다. SSOT 는 하나다
- 파일럿 — commit-ambiguity-as-a-result 를 새 기준으로 재선별. 후보 14 → 글감 5
  (PROMOTE 5 · MERGE_INTO 3 · KEEP_IN_SSOT 4 · 보류 2). 기록 5건을 다시 썼고 그림 1개를
  techviz 로 만들었다
- 재선별이 잡은 것: 제1부 §6.2·§11.1 이 자기 §13.2 와 어긋나 있었다(레인을 안 돌렸다 vs
  돌렸다) — 정정. 이미 답이 나와 있던 Question 을 HEAD 재실행 질문으로 다시 세웠다.
  Concept 이 인용한 코드가 SSOT 에 없어 뺐다
- candidateScope·sourceRepository 기록. 나머지 43개 주제는 재선별 대기(PENDING 905)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:39:20 +09:00

16 KiB

kind, slug, title, topic, project, status, sourceRevision, rootTreeNode, evidenceCapturedOn, body, assets, evidence, source
kind slug title topic project status sourceRevision rootTreeNode evidenceCapturedOn body assets evidence source
CASE analysis-finding-a05-f028 제공자 제출 뒤의 다섯 쓰기 가운데 결과 기록만 조건이 없다 multitenancy-isolation clean-architecture-backend-template 게시 전 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 case:analysis-finding-a05-f028 2026-09-04 case-analysis-finding-a05-f028.body.md
key file
analysis-finding-a05-f028 ../../../final/evidence/rendered/analysis-finding-a05-f028.svg
../../../final/evidence/raw/analysis-finding-a05-f028.txt
원본 분석 절은 final/document.md#a05 §89 이다.

제공자 제출 뒤의 다섯 쓰기 가운데 결과 기록만 조건이 없다

제공자 제출은 NotificationDispatchService:169 이고 그 뒤에 남는 쓰기는 다섯이다. applyNextAction 의 네 분기는 리스를 where 절에 넣는 연산을 쓰고, DispatchOutcomeRecorder:135 만 조건 없는 save 를 쓴다. 그 클래스는 리스를 받지도 않는다.

관계

  • fenced lease — 만료 시각만으로는 부족한 이유 그 개념이 정의하는 펜스 값이 여기서 where 절의 세 번째 조건이다. RecipientDeliveryJpaRepository:80:105idlease_ownerlease_fence 를 함께 요구한다.
  • native claim이 @Version을 올리지 않아 충돌을 보고하지 않는 낙관적 잠금이 됐다 그 기록의 네이티브 청구문과 이 기록의 포트 자바독이 같은 이유를 든다. version 이 잡는 것은 동시 편집이지 밀려난 작업자가 아니다.
  • 조건부 update로 행을 claim하고 읽은 값으로 판단하지 않는다 펜싱하는 쪽이 지키는 규칙이다. RecipientDeliveryJpaRepository:82saveProjectionHeldBy 가 소유자와 펜스를 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 가 나오는 시험 파일과 줄을 전부 나열한다.

본문

RecipientDeliveryStorePort 는 배달 행을 쓰는 다섯 연산을 선언한다. 그중 셋만 리스를 인자로 받는다.

포트가 이 사고를 자기 자바독에 적어 두었다

:::evidence key="analysis-finding-a05-f028" alt="저장소 루트에서 돌린 정적 검색 출력 250줄. 먼저 RecipientDeliveryStorePort 가 선언한 다섯 쓰기가 나오고 2136번 줄의 saveHeldBy 자바독이 실린다. 그 자바독은 청구도 갱신도 펜싱되는데 완료만 한동안 그렇지 않았다고 적고, 제공자 호출 중에 리스가 만료된 작업자가 돌아와 조건 없는 save 로 새 소유자가 이미 청구한 행 위에 썼다고 적으며, 낙관적 version 컬럼은 동시 편집을 잡지 밀려난 작업자를 잡지 못한다고 적는다. 이어서 RecipientDeliveryJpaRepository 5682번 줄이 실리는데 5665번 자바독이 이 갱신문이 제공자 호출 뒤의 쓰기를 위한 것이며 제출 이전의 작업은 새 소유자가 다시 하면 그만이지만 결과는 그렇지 않다고 적고, 80번 where 절이 id 와 lease_owner 와 lease_fence 를 모두 요구한다. 그다음 세 갱신문의 where 절이 나란히 실린다. saveProjectionHeldBy 의 80번과 transitionHeldBy 의 105번은 세 조건뿐이고, renewLease 의 127134번과 153154번에만 lease_until 이 now 보다 크다는 조건이 붙는다. 이어서 조건 없는 save 가 거치는 구현이 나온다. JpaRecipientDeliveryStore 3252번이 findById 로 엔티티를 읽어 applyProjection 을 부르고 saveAndFlush 하는데, RecipientDeliveryEntity 158182번의 applyProjection 이 받는 인자 열하나는 deliveryState 와 submissionOutcome 과 deliveryOutcome 과 evidenceLevel 과 두 불린과 routeCursor 와 attemptCount 와 lastFailureCategory 와 nextDispatchAt 과 시각이고 리스 필드가 하나도 없다. 그 아래에 DispatchOutcomeRecorder 1419번 클래스 자바독과 108136번 결과 쓰기가 나오는데 109134번이 새 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 158178번 줄이 실려 161번이 leases.stillHeld 를 확인하고 162164번 주석이 그 검사가 부수 효과 직전이며 그것을 막을 수 있는 마지막 순간이라고 적으며, 168169번이 트랜잭션 밖에서 제출하고 171173번이 그 뒤에 레코더를 부르고 176번이 applyNextAction 을 부른다. 이어서 398412번 줄이 실리는데 401411번 자바독이 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 컬럼은 동시 편집을 잡지, 밀려난 작업자를 잡지 못한다. 늦은 작업자의 읽기는 이기기에 충분할 만큼 최근이었다.

saveHeldBytransitionHeldBy 는 둘 다 Optional 을 돌려준다. 리스가 밀려났으면 아무것도 쓰지 않은 채 빈 Optional 이 나온다.

펜싱 갱신문이 where 절에 넣는 것

RecipientDeliveryJpaRepository:80where 절이 id = :id AND lease_owner = :owner AND lease_fence = :fence 다. 셋이 모두 맞아야 한 행이 갱신된다.

그 위 :57~:62 자바독은 이 갱신문이 어느 자리를 위한 것인지 적는다. 제공자 호출 뒤에 일어나는 쓰기이고, 제출 이전의 작업은 새 소유자가 다시 하면 그만이지만 결과는 그렇지 않다는 것이다. 밀려난 리스로 결과를 쓰면 한 작업자의 결과가 다른 작업자의 시도에 보고되고, 둘이 발송 여부에 대해 같은 답을 가질 이유가 없다.

세 갱신문의 where 절을 나란히 놓으면 하나가 더 보인다. saveProjectionHeldBy:80transitionHeldBy:105 는 소유자와 펜스만 본다. lease_until > :now 조건은 renewLease:134:154 에만 있다. 아직 아무도 가져가지 않은 만료된 리스로는 앞의 둘이 성공한다.

applyNextAction 은 네 분기 모두 펜싱을 쓴다

:176 이 부르는 applyNextAction:412RetryDecisionswitch 로 가른다. RetryAfterReconcileStop 세 분기가 transitionHeldBy(:422·:428·:436), Fallback 분기가 saveHeldBy(:434)를 부른다. 한 번 실행에 한 분기만 돌고, 어느 쪽이든 리스가 조건에 들어간다.

그 메서드의 자바독 :402~:407 이 이유를 적는다. 여기의 모든 쓰기가 제공자 호출 뒤에 일어나고, 그 제출이 이 플랫폼이 일부러 트랜잭션 밖에서 시간을 쓰는 유일한 구간이며, 그 사이에 리스가 만료돼 다른 작업자가 일을 가져갈 수 있다는 것이다. 펜싱된 변형은 그런 쓰기가 아무 행에도 맞지 않게 만든다.

:409~:410 은 리스를 잃은 것이 보고할 오류가 아니라고도 적는다. 새 소유자가 자기 결과를 기록할 것이고, 밀려난 작업자에게 남은 의무는 멈추는 것뿐이다.

:444refreshStatus(work) 는 그 switch 밖이라 갱신 건수와 무관하게 돈다.

결과 기록은 다른 클래스에 있고 리스를 받지 않는다

recipients.save 를 부르는 main 자리는 DispatchOutcomeRecorder:135 하나다.

그 클래스는 NotificationDispatchService 가 아니라 별도 파일이고, record 의 인자에 RecipientLease 가 없다. :109~:134 가 새 RecipientDeliveryRecord 를 만들고 :135 가 그것을 저장한다.

save 가 무엇을 덮는지는 구현에 있다. JpaRecipientDeliveryStore:33~:52findById 로 행을 읽어 applyProjection 을 부르고 saveAndFlush 한다. RecipientDeliveryEntity:159~:170applyProjection 이 받는 인자 열하나에 리스 필드가 없다. 그래서 늦은 쓰기가 소유권을 빼앗지는 않는다. 덮이는 것은 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 검사는 제출 앞에 있고, 결과 쓰기는 제출 뒤에 있다

:161leases.stillHeld 를 확인한다. :162~:164 주석이 이 검사가 부수 효과 직전이며 그것을 막을 수 있는 마지막 순간이라고 적는다.

:168~:169 가 트랜잭션 밖에서 제공자에 제출한다. :171~:173 이 그 뒤에 레코더를 부른다.

검사와 제출 사이가 아니라 제출과 기록 사이에 리스가 만료되면, :161 은 이미 지난 뒤다.

DispatchOutcomeRecorder 를 다루는 시험이 없다

파일명에 FencLease 가 든 시험 파일은 열하나다. DispatchOutcomeRecorder 를 이름에 가진 시험 파일은 0 개다.

펜싱 연산 이름이 나오는 시험 자리는 여섯인데 그중 넷은 시험 대역이 그 메서드를 구현하는 선언이다. LeaseRecoveryServiceTest:306·:313PlatformFakes:263·:272 다.

실제로 그 연산을 부르는 것은 postgresqlIntegrationTest 소스 세트의 PostgreSqlRecipientLeaseFencingIntegrationTest:93:127 두 자리이고, 둘 다 transitionHeldBy 다.

원문에 없는 것

원문은 제공자 호출 뒤의 투영 쓰기가 펜싱을 우회한다고 적는다. 여기에 더한 것은 그 우회가 어디서 갈리는지와 대가가 정확히 무엇인지다.

갈리는 지점은 메서드다. applyNextAction 의 네 분기는 모두 펜싱을 쓰고, 별도 클래스에 있는 record 만 쓰지 않는다. 대가는 소유권이 아니다. applyProjection 에 리스 인자가 없어서 덮이는 것은 투영 컬럼 열이다. 그리고 펜싱 갱신문 둘도 lease_until 을 보지 않으므로, 펜싱으로 바꾸는 것만으로 만료 구간이 전부 닫히지는 않는다.

확인하지 못한 것

이 결함이 실제 행 위에서 일어나는 것을 데이터베이스로 보지 않았다. 필요한 스키마가 다른 마이그레이션 집합의 테이블을 참조해 단독으로 세워지지 않는다.

낙관적 잠금이 이 쓰기를 걸러 내지 못한다는 판단은 코드 경로를 읽어 내린 것이다. 실행으로 확인하지 않았다.

만료됐지만 아직 아무도 가져가지 않은 리스로 두 갱신문이 통과하는 시간이 얼마나 되는지 재지 않았다.

밀려난 작업자의 늦은 save 가 새 소유자의 행을 덮는 장면을 데이터베이스로 재현하지 않았다. V1 이 capability_schema_registry 를 읽는데 그 테이블은 다른 마이그레이션 계열에 있어서, 알림 플랫폼 마이그레이션만 빈 데이터베이스에 올리면 거기서 멈춘다.