Files
document-haness/docs/clean-architecture-backend-template/tech-log-studio/state-machines-and-ownership/case/case-a05-f025-filequotaservice-commit.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

182 lines
12 KiB
Markdown

---
kind: CASE
slug: a05-f025-filequotaservice-commit
title: 만료 조건이 연장에는 있고 확정에는 없다
topic: state-machines-and-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a05-f025-filequotaservice-commit
evidenceCapturedOn: 2026-09-02
assets:
- key: a05-f025-filequotaservice-commit
file: ../../../final/evidence/rendered/a05-f025-filequotaservice-commit.svg
- key: a05-f025-filequotaservice-commit-postgres
file: ../../../final/evidence/rendered/a05-f025-filequotaservice-commit-postgres.svg
evidence:
- ../../../final/evidence/raw/a05-f025-filequotaservice-commit.txt
- ../../../final/evidence/raw/a05-f025-filequotaservice-commit-postgres.txt
source:
- 원본 분석 절은 `final/document.md#a05` §81 이다. 등급은 P2 이고 판정 문구는 프로덕션 API 계약 결함이다. 확정 질의에 만료 조건이 없다는 판정과 그 실행 탐침, 그리고 게이트웨이의 정산 경로가 수정 경계라는 지적이 그 절에 있다.
- 포트의 네 메서드 중 확정을 부르는 프로덕션 호출자가 0 이라는 것, 저장소 인터페이스 javadoc 의 두 절이 어긋난다는 것, 정산 행의 만료 시각이 생성 시각과 같다는 것은 이 기록에서 확인했다.
---
# 만료 조건이 연장에는 있고 확정에는 없다
저장소 인터페이스의 javadoc 은 연장과 확정과 해제가 모두 예약이 아직 살아 있기를 요구하므로 만료로 회수된 예약은 되살릴 수 없다고 적는다. 확정 질의에는 만료 조건이 없고, 실제 PostgreSQL 에서 만료된 예약을 확정하면 1행이 바뀐다. 다만 그 확정 메서드를 부르는 프로덕션 호출자는 없다.
## 관계
- **조건부 update로 행을 claim하고 읽은 값으로 판단하지 않는다**
읽은 값으로 판단하지 말고 조건부 갱신의 결과로 판단하라는 규칙이다.
- **만료된 claim과 만료된 실행은 다르게 다뤄야 한다**
만료 처리를 갈라야 하는 이유다.
- **리스가 만료 시각만 기록하고 소유자를 기록하지 않아 최종 상태를 되돌릴 수 있었다**
같은 리프 계열의 만료 처리 사례다.
## 문제
JpaFileQuotaService 는 클래스 javadoc 에서 네 연산이 조건부 문장이므로 만료되거나 해제된 예약은 연장도 확정도 될 수 없다고 선언한다.
저장소 인터페이스 javadoc 쪽은 여기서 더 나아간다. 연장과 확정과 해제가 예약이 기대한 버전에서 아직 예약됨 상태이기를 요구하므로 만료로 회수된 예약은 되살릴 수 없다는 것이다.
## 결론
연장 질의에는 expiresAt > :now 가 있다. 확정 질의의 조건은 예약 식별자와 상태뿐이다. 만료와 해제 두 사유에 연장과 확정 두 연산을 곱한 네 조합 중 0행을 돌려주지 않는 것은 만료된 예약의 확정 하나다.
저장소 javadoc 의 다른 절반도 어긋난다. 세 질의 어느 시그니처에도 버전 파라미터가 없다.
만료된 사실이 어디에도 기록되지 않는다. 예약 상태 enum 이 만료됨을 선언해 두었는데 main 에서 그 값을 쓰지 않고, 낡은 예약을 정리 대상으로 삼는 코드도 없다. 만료된 예약은 계속 예약됨으로 남으므로 상태만 보는 질의는 둘을 구분할 방법이 없다.
실제 PostgreSQL 16 에서 만료 시각이 한 시간 전인 예약을 만들고 두 질의를 돌렸다. 연장은 0행, 확정은 1행이다. 그 행은 확정됨이 되고 바이트가 기록된다.
그 확정을 부르는 프로덕션 호출자는 없다. 포트의 네 메서드 중 main 코드가 부르는 것은 예약과 해제 둘뿐이다. 업로드 확정이 실제로 지나는 것은 별도 게이트웨이이고, 그 게이트웨이는 살아 있는 예약만 이 질의에 넘긴다. 그래서 결함은 포트 계약과 그 구현 쪽에 있다.
수정에는 경계가 있다. 살아 있는 예약이 없으면 게이트웨이는 사용량을 새 행으로 만들고 바로 확정한다. 그 행의 만료 시각이 생성 시각이므로, 확정 질의에 expiresAt > :now 를 무조건 붙이면 등호 하나 차이로 이 행만 걸린다. 조회와 확정이 같은 시각을 쓰기 때문에 살아 있는 예약 쪽은 걸리지 않는다.
걸렸을 때 나타나는 결과가 조용하다. 확정의 반환값을 게이트웨이가 받지 않아서, 행은 만들어지고 확정만 0행으로 끝난다. 남은 행은 만료된 예약됨이라 예약 합계에도 확정 합계에도 잡히지 않는다.
## 검증 환경
OpenJDK : 21.0.12
데이터베이스 : PostgreSQL 16.15, 실제 실행
확인 방식 : 두 질의의 조건 대조, 포트 호출자 계수, 실제 PostgreSQL 에 만료된 예약과 정산 행을 만들어 질의 실행
소스 수정 : x
## 재현 조건
1. 서비스와 저장소 인터페이스의 javadoc 을 나란히 읽는다.
2. 연장 질의와 확정 질의의 조건, 그리고 세 질의의 시그니처를 확인한다.
3. 예약 상태 enum 의 만료됨을 쓰는 코드를 센다.
4. 포트의 네 메서드를 부르는 main 소스 호출자를 각각 센다.
5. 실제 PostgreSQL 에 파일서버 마이그레이션을 적용하고 만료 시각이 과거인 예약을 만든다.
6. 두 질의를 그 행에 돌려 바뀐 행 수와 최종 상태를 본다.
7. 게이트웨이가 살아 있는 예약이 없을 때 만드는 행에 만료 조건을 붙인 확정을 돌려 본다.
## 본문
<!-- body:start -->
`JpaFileQuotaService` 는 클래스 javadoc 첫 문단에서 네 연산의 조건을 선언한다.
```text
Reservation, extension, commit, and release are conditional statements, so a reservation
that already expired or was released can never be extended or committed.
```
## 연장에는 있고 확정에는 없는 조건
:::evidence key="a05-f025-filequotaservice-commit" alt="서비스와 저장소 인터페이스의 javadoc, 세 질의 시그니처의 버전 파라미터 수, 연장 질의와 확정 질의 전문, 예약 상태 enum 의 만료됨을 쓰는 코드와 낡은 예약을 정리 대상으로 넣는 코드 수, 포트의 네 메서드를 부르는 프로덕션 호출자 수와 실제 호출 두 줄, 프로덕션 확정이 지나는 게이트웨이와 그 조회 조건, 살아 있는 예약이 없을 때 만드는 행, 그리고 그 행을 만드는 생성자의 인자 순서를 출력한 터미널 기록." caption="두 javadoc 의 선언 · 세 질의에 버전 파라미터 0 · 연장에는 만료 조건, 확정에는 없음 · EXPIRED 를 쓰는 코드 0 · 포트 호출자는 예약과 해제뿐, 확정 0 · 게이트웨이는 살아 있는 예약만 넘김 — 93줄 · exit 0" zoom="true"
:::
같은 리프의 저장소 인터페이스 javadoc 은 한 걸음 더 나간다.
```text
* <p>Extend, commit, and release all require the reservation to still be {@code RESERVED} at the
* expected version, so a reservation reclaimed by expiry cannot be resurrected.
```
두 절이 다 어긋난다. 세 질의 어느 시그니처에도 버전 파라미터가 없고, 만료로 회수됐어야 할 예약을 되살리는 것이 바로 확정 질의다.
연장 질의에는 만료 조건이 있다.
```sql
where q.reservationId = :reservationId
and q.status = 'RESERVED'
and q.expiresAt > :now
```
확정 질의의 조건은 둘뿐이다.
```sql
where q.reservationId = :reservationId
and q.status = 'RESERVED'
```
만료는 상태로 남지 않는다. 예약 상태 enum 에 `EXPIRED` 가 선언되어 있지만 그 값을 쓰는 main 코드가 없고, 낡은 예약을 정리 대상으로 넣는 코드도 없다. 만료된 예약은 계속 `RESERVED` 다.
## 만료된 예약에서 0행과 1행이 갈린다
:::evidence key="a05-f025-filequotaservice-commit-postgres" alt="실제 PostgreSQL 컨테이너에 파일서버 마이그레이션을 적용해 쿼터 예약 테이블의 상태와 만료 열을 확인하고, 만료 시각이 한 시간 전인 예약에 저장소의 연장 질의와 확정 질의를 각각 돌려 바뀐 행 수와 최종 상태를 본 결과, 그리고 게이트웨이가 만드는 정산 행과 같은 모양의 행에 만료 조건을 붙인 확정을 돌린 결과를 출력한 터미널 기록." caption="PostgreSQL 16.15 에 파일서버 마이그레이션 적용 · 만료된 예약에 연장 0행, 확정 1행 · 결과는 COMMITTED 600 · 만료 시각이 생성 시각인 행에 조건을 붙이면 0행 — 11줄 · exit 0" zoom="true"
:::
```text
만료된 예약을 하나 만든다 (expires_at = 한 시간 전)
연장 질의가 바꾼 행 : 0
확정 질의가 바꾼 행 : 1
결과 행 : status=COMMITTED committed_bytes=600
```
저장소 질의만 놓고 보면 두 javadoc 이 금지한 전이가 그대로 일어난다.
## 다만 그 질의에 만료된 행을 넘기는 호출자가 없다
포트의 네 메서드 중 main 소스가 부르는 것은 둘이다.
```text
DefaultUploadApplicationService.java:128 quotaService.reserve(scope, reservationBytes, uploadPolicy.reservationTtl());
DefaultUploadApplicationService.java:157 quotaService.release(created.reservation());
```
확정과 연장은 0곳이다. 업로드 확정이 실제로 지나는 것은 `JpaQuotaCommitGateway` 이고, 그 게이트웨이는 살아 있는 예약을 먼저 조회한다. 그 조회에 만료 조건이 이미 들어 있다.
```sql
and q.status = 'RESERVED'
and q.expiresAt > :now
```
그래서 이 결함은 포트 계약과 그 구현에 있고, 오늘의 업로드 경로에서 관측되는 사건은 아니다.
## 정산 행의 만료 시각은 생성 시각이다
게이트웨이는 살아 있는 예약이 없으면 사용량을 새 행으로 만들어 곧바로 확정한다. 업로드가 유효 기간보다 오래 걸렸더라도 실제로 저장된 바이트를 적게 세지 않기 위한 경로다.
```java
QuotaReservationEntity settled =
new QuotaReservationEntity(
UUID.randomUUID(), scope.type(), scope.value(), actualBytes, now, "RESERVED", now);
reservations.save(settled);
reservations.commit(settled.getReservationId(), actualBytes, now);
```
생성자의 다섯째 인자가 만료 시각이고 거기 들어간 값이 `now` 다. 저장과 확정이 같은 `now` 를 쓰므로 이 행은 `expiresAt > :now` 를 등호 하나 차이로 통과하지 못한다.
같은 이유로 살아 있는 예약을 확정하는 쪽은 엄격한 조건을 붙여도 통과한다. 조회가 이미 같은 `now` 로 걸러 냈기 때문이다. 걸리는 것은 정산 행 하나다.
```text
게이트웨이의 정산 행은 expires_at = now 로 만들어진다
만료 조건을 붙인 확정이 그 행을 바꾼 수 : 0
```
걸렸을 때 결과는 조용하다. 게이트웨이는 확정의 반환값을 받지 않으므로 행은 만들어지고 확정만 0행이 된다. 남은 행은 만료된 `RESERVED` 라서 예약 합계는 만료 조건에 걸려 세지 않고, 확정 합계는 상태가 달라 세지 않는다. 저장된 바이트가 장부 어디에도 잡히지 않는다.
## 고칠 방향
`commit` 하나가 두 의미를 겸하고 있다. 저장소에 만료 조건을 건 확정 문과 걸지 않은 정산 문을 따로 두고, 포트도 확정과 정산으로 나눈다. 지금은 정산이 확정과 같은 문을 쓰기 때문에 조건 하나를 고치면 다른 쪽이 깨진다.
## 확인하지 못한 것
정산 행이 예약됨으로 남았을 때 회수되는지는 확인하지 않았다. 낡은 예약을 정리 대상으로 넣는 코드가 없다는 것까지만 봤다.
<!-- body:end -->