--- 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. 게이트웨이가 살아 있는 예약이 없을 때 만드는 행에 만료 조건을 붙인 확정을 돌려 본다. ## 본문 `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 *
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` 하나가 두 의미를 겸하고 있다. 저장소에 만료 조건을 건 확정 문과 걸지 않은 정산 문을 따로 두고, 포트도 확정과 정산으로 나눈다. 지금은 정산이 확정과 같은 문을 쓰기 때문에 조건 하나를 고치면 다른 쪽이 깨진다. ## 확인하지 못한 것 정산 행이 예약됨으로 남았을 때 회수되는지는 확인하지 않았다. 낡은 예약을 정리 대상으로 넣는 코드가 없다는 것까지만 봤다.