- 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>
182 lines
12 KiB
Markdown
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 -->
|