Files
document-haness/docs/clean-architecture-backend-template/tech-log-studio/state-machines-and-ownership/case/case-a05-f025-filequotaservice-commit.md
T
DongHyeonkaandClaude Opus 5 b2963105a8 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>
2026-09-04 22:51:59 +09:00

12 KiB

kind, slug, title, topic, project, status, sourceRevision, rootTreeNode, evidenceCapturedOn, assets, evidence, source
kind slug title topic project status sourceRevision rootTreeNode evidenceCapturedOn assets evidence source
CASE a05-f025-filequotaservice-commit 만료 조건이 연장에는 있고 확정에는 없다 state-machines-and-ownership clean-architecture-backend-template 게시 전 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 case:a05-f025-filequotaservice-commit 2026-09-02
key file
a05-f025-filequotaservice-commit ../../../final/evidence/rendered/a05-f025-filequotaservice-commit.svg
key file
a05-f025-filequotaservice-commit-postgres ../../../final/evidence/rendered/a05-f025-filequotaservice-commit-postgres.svg
../../../final/evidence/raw/a05-f025-filequotaservice-commit.txt
../../../final/evidence/raw/a05-f025-filequotaservice-commit-postgres.txt
원본 분석 절은 `analysis/05-adapter-outbound-persistence-jpa.md` §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 첫 문단에서 네 연산의 조건을 선언한다.

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 은 한 걸음 더 나간다.

 * <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.

두 절이 다 어긋난다. 세 질의 어느 시그니처에도 버전 파라미터가 없고, 만료로 회수됐어야 할 예약을 되살리는 것이 바로 확정 질의다.

연장 질의에는 만료 조건이 있다.

where q.reservationId = :reservationId
  and q.status = 'RESERVED'
  and q.expiresAt > :now

확정 질의의 조건은 둘뿐이다.

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" :::

만료된 예약을 하나 만든다 (expires_at = 한 시간 전)
연장 질의가 바꾼 행 : 0
확정 질의가 바꾼 행 : 1
결과 행           : status=COMMITTED committed_bytes=600

저장소 질의만 놓고 보면 두 javadoc 이 금지한 전이가 그대로 일어난다.

다만 그 질의에 만료된 행을 넘기는 호출자가 없다

포트의 네 메서드 중 main 소스가 부르는 것은 둘이다.

DefaultUploadApplicationService.java:128   quotaService.reserve(scope, reservationBytes, uploadPolicy.reservationTtl());
DefaultUploadApplicationService.java:157   quotaService.release(created.reservation());

확정과 연장은 0곳이다. 업로드 확정이 실제로 지나는 것은 JpaQuotaCommitGateway 이고, 그 게이트웨이는 살아 있는 예약을 먼저 조회한다. 그 조회에 만료 조건이 이미 들어 있다.

and q.status = 'RESERVED'
and q.expiresAt > :now

그래서 이 결함은 포트 계약과 그 구현에 있고, 오늘의 업로드 경로에서 관측되는 사건은 아니다.

정산 행의 만료 시각은 생성 시각이다

게이트웨이는 살아 있는 예약이 없으면 사용량을 새 행으로 만들어 곧바로 확정한다. 업로드가 유효 기간보다 오래 걸렸더라도 실제로 저장된 바이트를 적게 세지 않기 위한 경로다.

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 로 걸러 냈기 때문이다. 걸리는 것은 정산 행 하나다.

게이트웨이의 정산 행은 expires_at = now 로 만들어진다
만료 조건을 붙인 확정이 그 행을 바꾼 수 : 0

걸렸을 때 결과는 조용하다. 게이트웨이는 확정의 반환값을 받지 않으므로 행은 만들어지고 확정만 0행이 된다. 남은 행은 만료된 RESERVED 라서 예약 합계는 만료 조건에 걸려 세지 않고, 확정 합계는 상태가 달라 세지 않는다. 저장된 바이트가 장부 어디에도 잡히지 않는다.

고칠 방향

commit 하나가 두 의미를 겸하고 있다. 저장소에 만료 조건을 건 확정 문과 걸지 않은 정산 문을 따로 두고, 포트도 확정과 정산으로 나눈다. 지금은 정산이 확정과 같은 문을 쓰기 때문에 조건 하나를 고치면 다른 쪽이 깨진다.

확인하지 못한 것

정산 행이 예약됨으로 남았을 때 회수되는지는 확인하지 않았다. 낡은 예약을 정리 대상으로 넣는 코드가 없다는 것까지만 봤다.