--- kind: CASE slug: analysis-finding-a05-f024 title: 회수가 페이지 하나를 다 쓰면 남은 바이트를 들고 그대로 끝난다 topic: multitenancy-isolation project: clean-architecture-backend-template status: 게시 전 sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 rootTreeNode: case:analysis-finding-a05-f024 evidenceCapturedOn: 2026-09-04 body: case-analysis-finding-a05-f024.body.md assets: - key: analysis-finding-a05-f024 file: ../../../final/evidence/rendered/analysis-finding-a05-f024.svg evidence: - ../../../final/evidence/raw/analysis-finding-a05-f024.txt source: - 원본 분석 절은 analysis/05-adapter-outbound-persistence-jpa.md §24 이다. --- # 회수가 페이지 하나를 다 쓰면 남은 바이트를 들고 그대로 끝난다 `JpaQuotaReclaimGateway:53`~`:54` 는 확정 행을 `Limit.of(64)` 로 한 번 조회하고 그 목록만 순회한다. 프로브에서 1 바이트 행 200 개에 200 바이트 회수를 요청했더니 차감이 64 회에 그쳤고 나머지 136 은 어디에도 보고되지 않았다. ## 관계 - **쿼터 원장은 사용량을 적기만 하고 승인은 그것을 읽지 않는다** 같은 원장의 다른 결함이다. 그쪽은 기록된 값을 아무도 읽지 않는 것이고, 여기는 그 값이 실제보다 커지는 것이다. - **빠뜨림이 통과가 되는 게이트는 게이트가 아니다** 같은 계열이다. 남은 작업이 있는데 아무 신호 없이 정상 종료한다. - **모르는 것은 성공도 실패도 아닌 세 번째 결과여야 한다** 회수 연산이 결과를 돌려주지 않으므로 다 했는지 못 했는지가 어디에도 표현되지 않는다. ## 문제 물리 삭제가 끝나면 정리 서비스가 회수 게이트웨이를 부른다. 그 게이트웨이가 원장에서 확정 사용량을 줄인다. 요청한 바이트가 여러 행에 걸쳐 있을 때 어떻게 되는지 확인했다. ## 결론 :33 이 페이지 상한을 64 로 둔다. :55~:61 이 그 한 페이지를 순회하며 차감하고, 마지막 원소를 지나면 루프가 그대로 빠져나온다. 재조회도 남은 값 반환도 로그도 없다. 저장소 클래스를 그대로 쓰고 리포지터리만 프록시로 대신해 돌렸다. 원장 행이 64 개일 때는 요청한 만큼 전부 회수된다. 65 개일 때 1 이 남고, 200 개일 때 136 이 남는다. 남는 양은 페이지를 넘는 행 수만큼 늘어난다. 클래스 자바독 :25~:27 은 나머지를 버리는 이유를 적어 두었다. 원장에 흡수할 데가 없을 때의 이야기이고, 그런 상황은 총량이 이미 잘못 적혔다는 신호라는 것이다. 프로브가 만든 나머지는 종류가 다르다. 원장에는 아직 뺄 데가 넉넉한데 한 페이지만 보고 끝냈기 때문이다. 자바독이 허용한 나머지와 상한이 만든 나머지를 코드가 구분하지 않는다. 이 메서드는 아무것도 돌려주지 않는다. 정리 서비스가 회수 결과를 물을 자리가 애초에 없다. 이 경계를 겨냥한 시험도 없다. 페이지 상한과 그 바로 위아래 값을 쓰는 시험 줄이 모두 0 개다. ## 검증 환경 OpenJDK : 21.0.12 Gradle : 9.0.0 확인 방식 : 게이트웨이 전문 게재와 클래스 자바독 인용, 페이지 질의와 차감 질의의 정의와 그 자바독 인용, 회수 호출자 전수, 저장소 게이트웨이를 그대로 쓰고 리포지터리만 프록시로 대신해 원장 행 수와 요청 바이트를 바꿔 가며 페이지 질의 횟수와 차감 횟수와 남은 바이트를 관측, 경계 값을 쓰는 시험 줄 계수와 이름에 회수가 든 파일 나열 소스 수정 : x ## 재현 조건 1. 게이트웨이를 전문으로 싣고 클래스 자바독을 함께 읽는다. 2. 페이지 질의와 차감 질의의 정의를 인용한다. 3. 회수를 부르는 자리를 전부 찾는다. 4. 리포지터리 인터페이스를 프록시로 바꿔 끼우고, 질의가 오면 원장에 넣어 둔 행을 상한까지 잘라 돌려주게 한다. 5. 원장 행 수와 요청 바이트를 64·65·200 으로 바꿔 가며 게이트웨이를 부르고 페이지 질의 횟수와 차감 횟수와 남은 바이트를 적는다. 6. 64 와 65 와 페이지 상한 상수를 쓰는 시험 줄을 센다. ## 본문 `JpaQuotaReclaimGateway` 는 물리 삭제가 끝난 뒤 원장의 확정 사용량을 줄인다. 정리 서비스가 삭제한 바이트 수를 넘겨 부른다. ## 게이트웨이는 확정 행을 한 번만 조회한다 :::evidence key="analysis-finding-a05-f024" alt="저장소 루트에서 돌린 정적 검색과 프로브의 출력 129줄. 먼저 JpaQuotaReclaimGateway 64줄이 전문으로 실린다. 25~27번 줄 클래스 자바독은 어떤 행도 흡수하지 못하는 나머지는 이월하지 않고 버린다고 적으면서, 원장의 바닥이 0 이며 기록된 총량을 넘어서는 회수는 총량이 이미 과소 기록됐다는 뜻이라고 덧붙인다. 33번 줄이 RECLAIM_PAGE 를 Limit.of(64) 로 두고, 44~63번 줄의 reclaim 이 51번에서 시각을 얻고 52번에서 outstanding 을 요청 바이트로 두며 53~54번에서 확정 행을 한 번 조회해 그 결과를 순회한다. 55~57번은 outstanding 이 0 이면 돌아가고, 58번이 차감량을 정하고 59~61번이 갱신 건수가 1 일 때만 뺀다. 루프가 끝나면 62~63번에서 메서드가 그대로 끝난다. 이어서 그 한 번의 질의가 무엇을 돌려주는지 리포지터리 106~117번 줄로 나오는데, 확정 상태이고 바이트가 남은 행을 갱신 시각 내림차순으로 상한만큼 가져온다. 119~139번 줄의 차감 질의는 자바독에서 그 가드가 동시 회수를 안전하게 만든다고 적는다. 회수를 부르는 자리로 정리 서비스 한 곳과 시험 몇 곳이 나온다. 그다음 프로브가 실린다. 리포지터리만 프록시로 대신하고 게이트웨이는 저장소 클래스를 그대로 쓴다. 1바이트 확정 행 64 개에 64 바이트를 요청하면 페이지 질의 1 회에 차감 64 회로 남는 바이트가 0 이다. 65 개에 65 바이트를 요청하면 페이지 질의는 그대로 1 회이고 차감이 64 회에서 멈춰 1 바이트가 남는다. 200 개에 200 바이트를 요청하면 차감이 64 회이고 136 바이트가 남는다. 마지막으로 그 경계를 짚는 시험을 세면 64 와 65 와 RECLAIM_PAGE 를 쓰는 시험 줄이 각각 0 개이고, 이름에 Reclaim 이 든 파일은 게이트웨이와 포트와 시험용 기록기 셋뿐이다." caption="게이트웨이 64줄 전문과 그 클래스 자바독 · 한 번의 페이지 질의가 돌려주는 것과 차감 질의의 가드 · 회수 호출자 · 저장소 게이트웨이를 그대로 부른 세 경우 · 그 경계를 짚는 시험의 부재 — 129줄 · exit 0" zoom="true" ::: `:33` 이 `RECLAIM_PAGE` 를 `Limit.of(64)` 로 둔다. `reclaim:44` 은 음수와 0 을 먼저 걸러 낸다. `:52` 가 `outstanding` 을 요청 바이트로 두고, `:53`\~`:54` 가 그 상한으로 확정 행을 조회해 향상된 for 문으로 순회한다. 루프 안에서 `:55` 가 `outstanding` 이 0 이면 돌아가고, `:58` 이 차감량을 `Math.min` 으로 정하고, `:59` 가 갱신 건수가 1 일 때만 `:60` 에서 뺀다. 목록이 끝나면 `:62`\~`:63` 에서 메서드가 끝난다. `outstanding` 이 얼마든 상관없다. ## 그 한 번의 질의가 무엇을 돌려주는가 `FileserverQuotaRepository:107`\~`:117` 은 확정 상태이고 확정 바이트가 남은 행을 갱신 시각 내림차순으로 상한만큼 가져온다. `:126`\~`:139` 의 차감 질의는 조건부 갱신이다. `:122`\~`:124` 자바독은 그 가드가 동시 회수를 안전하게 만든다고 적는다 — 다른 회수가 이미 낮춰 놓은 행은 0 행을 갱신하고 호출자가 다음 행으로 넘어간다는 것이다. 그 설계는 한 페이지 안에서만 성립한다. 다음 행이 페이지 밖에 있으면 넘어갈 곳이 없다. ## 저장소 게이트웨이를 그대로 부른 결과 리포지터리 인터페이스만 프록시로 대신했다. 프록시는 요청한 만큼의 1 바이트 확정 행을 만들어 페이지 상한까지 돌려주고 차감 호출을 센다. 게이트웨이는 저장소 클래스 그대로다. 1 바이트 행 64 개에 64 바이트를 요청하면 페이지 질의 1 회에 차감 64 회이고 남는 바이트가 0 이다. 65 개에 65 바이트를 요청하면 페이지 질의는 여전히 1 회이고 차감이 64 회에서 멈춘다. 1 바이트가 남는다. 200 개에 200 바이트를 요청하면 차감이 64 회이고 136 바이트가 남는다. 남는 양은 원장에 쌓인 행 수에 따라 늘어난다. ## 자바독이 정당화한 나머지는 다른 나머지다 `:25`\~`:27` 은 나머지를 이월하지 않는 근거를 적는다. 어떤 행도 흡수하지 못하는 나머지라면 원장의 총량이 이미 과소 기록됐다는 뜻이고, 음수 잔액이 그것을 고쳐 주지는 않는다는 것이다. 프로브가 만든 나머지는 그 경우가 아니다. 흡수할 행이 원장에 그대로 남아 있는데 페이지 상한이 그것을 보지 못하게 했다. 코드는 두 나머지를 구분하지 않는다. 둘 다 같은 자리에서 조용히 끝난다. ## 남았다는 사실을 담을 자리가 없다 `reclaim` 의 반환형은 `void` 다. 호출자는 요청한 바이트가 전부 회수됐는지 알 방법이 없다. 64 와 65 와 `RECLAIM_PAGE` 를 쓰는 시험 줄은 각각 0 개다. 이름에 회수가 든 파일은 게이트웨이와 포트와 시험용 기록기 셋이다. ## 원문과 갈리는 자리 원문은 65 개 행에 65 바이트를 요청하면 1 이 남는다고 적었고 프로브가 그대로 재현한다. 원문은 실제 PostgreSQL 로 확인했고 이 기록은 리포지터리를 프록시로 대신했다. 원문이 적지 않은 것이 둘이다. 남는 양이 원장 행 수에 비례해 커진다는 것과, 클래스 자바독이 정당화한 나머지가 페이지 상한이 만드는 나머지와 다른 것이라는 점이다. ## 확인하지 못한 것 데이터베이스를 띄워 확인하지는 않았다. 프록시가 돌려준 목록으로 루프의 종료 조건만 봤다. 운영 중인 원장에서 한 범위가 64 행을 넘기는 일이 있는지 세지 않았다. 차감 질의가 0 행을 갱신해 건너뛰는 경우와 페이지 상한이 겹치면 남는 바이트가 더 늘어나는지 시험하지 않았다. 실제 PostgreSQL 로 돌리지 않았다. 리포지터리를 프록시로 대신해 게이트웨이의 루프만 관측했다. 차감 질의가 0 행을 갱신해 건너뛰는 경우와 페이지 상한이 겹치면 남는 바이트가 더 늘어나는지 시험하지 않았다.