--- kind: CASE slug: analysis-finding-a05-f032 title: 포화된 풀의 대기 수를 단언하는 시험이 풀 계약 레인에 없다 topic: multitenancy-isolation project: clean-architecture-backend-template status: 게시 전 sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 rootTreeNode: case:analysis-finding-a05-f032 evidenceCapturedOn: 2026-09-04 body: case-analysis-finding-a05-f032.body.md assets: - key: analysis-finding-a05-f032 file: ../../../final/evidence/rendered/analysis-finding-a05-f032.svg evidence: - ../../../final/evidence/raw/analysis-finding-a05-f032.txt source: - 원본 분석 절은 analysis/05-adapter-outbound-persistence-jpa.md §125 이다. --- # 포화된 풀의 대기 수를 단언하는 시험이 풀 계약 레인에 없다 `jpa-nightly.yml:128` 은 이 레인이 포화된 풀의 대기 수 보고를 검사한다고 적는다. 그 주장을 확인하는 줄은 레인 전체에 `PoolPressureContractTest:31` 하나뿐이고, 거기 들어간 `3` 은 바로 앞줄이 생성자에 써 넣은 값이다. 실제 풀을 띄우는 `HikariPoolSaturationContractTest` 는 `:100` 에서 `getThreadsAwaitingConnection()` 을 읽어 담고도 그 값만 빼고 단언한다. ## 관계 - **빠뜨림이 통과가 되는 게이트는 게이트가 아니다** 워크플로가 적은 셋 가운데 둘째만 실제 풀에 닿지 않는데, 레인이 초록이면 셋 다 확인된 것으로 읽힌다. - **풀 계약 레인이 실행되지 않아 포화 동작이 확인되지 않았다** 그 질문은 이 레인이 세 계약을 검증한다는 것을 사실로 두고 실행만 남았다고 적는다. 이 기록은 그중 대기 수 보고에 단언이 없다는 것을 확인했으므로, 레인을 돌려도 그 미지수는 닫히지 않는다. - **REQUIRES_NEW의 커넥션 비용과 풀 사이징 제약** 그 제약이 이 레인에서 실제 풀로 확인되는 유일한 주장이다. `RequiresNewPoolPressureContractTest:45` 와 `:61` 이 크기 1 과 2 로 나눠 확인한다. ## 문제 야간 워크플로가 이 레인이 검사하는 것을 셋으로 적는다. 같은 문장이 build.gradle 의 태스크 주석에도 있다. 셋 각각에 대응하는 단언이 있는지 확인했다. ## 결론 이 레인은 릴리스에 걸려 있다. build.gradle:309 가 jpaPlatformPoolContractTest 를 jpaPlatformReleaseGate 의 의존으로 넣고, 그 태스크가 도는 소스 세트에는 파일 셋에 시험 여덟이 있다. 셋 중 첫째에는 실제 HikariDataSource 가 있다. RequiresNewPoolPressureContractTest:45 가 크기 1 짜리 풀에서 안쪽 트랜잭션이 커넥션을 못 얻는 것을, :61 이 크기 2 에서는 얻는 것을 잡는다. 셋째는 절반만 그렇다. HikariPoolSaturationContractTest:64~:66 이 재는 것은 대기 시간의 상한뿐이라 즉시 실패해도 통과한다. 옆의 :77 은 아예 대기를 만들지 않는데, :81 이 하나를 놓은 다음에 :83 이 집기 때문이다. 두 번째는 다르다. 레인 안에서 pending() 을 단언하는 줄이 PoolPressureContractTest:31 하나인데, 그 값은 :29 의 new PoolMeasurement(4, 2, 3, Duration.ofMillis(80)) 에 넣은 것이다. HikariDataSource 가 없다. 같은 시험의 나머지 셋도 계산이거나 되읽기다. PoolMeasurement:26 의 total() 은 active + idle, :31 의 saturated() 는 pending > 0 이므로, :33 과 :34 는 손으로 넣은 값에서 유도되는 항등식이다. 실제 풀에서 그 값을 읽는 자리는 HikariPoolSaturationContractTest:100 이다. :95~:101 이 HikariPoolMXBean 에서 세 값을 읽어 PoolMeasurement 를 만드는데, :103~:105 의 단언은 active 와 total 과 커넥션 유효성이다. 그 시험이 쥔 커넥션은 :94 의 하나뿐이고 POOL_SIZE 는 :32 에서 2 다. 기다리는 스레드가 생길 수 없는 조합이다. PoolMeasurement 자체가 testkit 소스 세트에 있고, HikariPoolMXBean 이나 getThreadsAwaitingConnection 을 읽는 프로덕션 자리는 main 파일 4714 개에 0 이다. 잘못된 것은 런타임이 아니라 레인이 자기에 대해 적은 문장이다. ## 검증 환경 OpenJDK : 21.0.12 확인 방식 : 야간 워크플로의 단계 주석과 그 내력 인용, 레인이 도는 소스 세트와 릴리스 게이트 의존 인용, 세 파일의 시험과 단언 전수, 레인 범위에서 pending() 단언 계수, 값 객체 시험과 포화 세 시험의 본문 인용, PoolMeasurement 의 import 자리와 계산 메서드 인용, 프로덕션에서 대기 수를 읽는 자리 계수와 훑은 파일 수 대조 소스 수정 : x ## 재현 조건 1. 워크플로의 단계 주석과 build.gradle 의 같은 문장, 그리고 릴리스 게이트 의존을 인용한다. 2. 레인이 도는 소스 세트의 파일을 나열하고 세 파일의 시험과 단언을 전부 뽑는다. 3. 레인 범위에서 pending() 을 단언하는 줄을 세고 그 시험 본문을 싣는다. 4. 실제 풀을 띄우는 세 시험을 전문으로 싣는다. 5. PoolMeasurement 를 어디서 import 하는지와 그 타입의 계산 메서드를 인용한다. 6. 프로덕션에서 대기 수를 읽는 자리를 세고 훑은 파일 수를 함께 센다. ## 본문 야간 워크플로가 JPA 풀 계약 레인을 부른다. 그 단계의 주석이 레인이 무엇을 검사하는지 셋으로 적는다. ## 야간 워크플로가 적은 셋과 레인의 여덟 시험 :::evidence key="analysis-finding-a05-f032" alt="저장소 루트에서 돌린 정적 검색 출력 271줄. 먼저 jpa-nightly.yml 122~134번 줄이 실린다. 단계 이름은 풀 포화와 REQUIRES_NEW 커넥션 동작을 검증한다는 것이고, 124~126번 주석이 이 단계가 예전에는 명시적 프로퍼티로 단언을 끄고 그 결과를 인증이라 불렀으며 그래서 유일하게 단언된 임계값이 임계값을 단언하지 않는다는 것이었다고 적는다. 127~129번이 지금 검사하는 셋을 적는데 REQUIRES_NEW 가 동시 스레드당 커넥션 둘을 요구한다는 것, 포화된 풀이 자기 대기 수를 보고한다는 것, 호출자가 커넥션 없이 진행하는 대신 기다린다는 것이며, 어느 러너에서나 참이라 끌 것이 없다고 적는다. 이어서 persistence-jpa/build.gradle 281~310번이 실린다. 281~286번 주석이 같은 셋을 다시 적고, 287~296번의 jpaPlatformPoolContractTest 태스크가 jpaPlatformPerformanceTest 소스 세트를 돌리며, 300~310번의 jpaPlatformReleaseGate 가 309번에서 그 태스크를 의존에 넣는다. 그 아래 소스 세트의 시험 파일 셋이 나열되는데 HikariPoolSaturationContractTest 와 PoolPressureContractTest 와 RequiresNewPoolPressureContractTest 다. 다음으로 세 파일의 시험 이름과 단언이 전부 나열되고 시험이 여덟이다. 이어서 레인 범위에서 measurement.pending 을 단언하는 줄이 1 개라고 나오고, 그 줄이 있는 시험이 실린다. PoolPressureContractTest 26~47번인데 29번이 new PoolMeasurement(4, 2, 3, Duration.ofMillis(80)) 을 만들고 31번이 pending 이 3 인지, 32번이 지연이 80밀리초인지, 33번이 total 이 6 인지, 34번이 saturated 가 참인지 확인한다. 37~47번의 두 번째 시험은 동시 스레드 8 과 깊이 1 로 required 를 8 곱하기 2 더하기 1 로 계산해 47번에서 17 과 같은지 확인한다. 그 아래 HikariPoolSaturationContractTest 32번의 POOL_SIZE 가 2 라는 것과 50~118번의 세 시험이 실린다. 52번 시험은 POOL_SIZE 만큼 쥔 뒤 61번에서 한 번 더 요청해 SQLException 이 나는 것과 64~66번에서 기다린 시간이 ACQUIRE_TIMEOUT 더하기 2초보다 작은 것을 확인한다. 77번 시험은 79번과 80번에서 둘을 얻고 81번에서 하나를 놓은 뒤 83번에서 세 번째를 얻어 유효한지 확인한다. 92번 시험은 94번에서 커넥션 하나만 쥐고 95~101번이 HikariPoolMXBean 에서 활성 수와 유휴 수와 getThreadsAwaitingConnection 을 읽어 PoolMeasurement 를 만드는데, 103~105번의 단언은 active 가 1 인지와 total 이 1 이상인지와 쥔 커넥션이 유효한지 셋이다. 110~118번이 그 풀을 만드는 pool 메서드다. 다음으로 RequiresNewPoolPressureContractTest 43~89번이 실린다. 45번 시험이 크기 1 짜리 풀에서 바깥 커넥션을 쥔 채 안쪽을 얻으려 하면 예외가 나는 것을, 61번 시험이 크기 2 에서는 얻어지고 두 커넥션이 다른 객체인 것을 확인하며, 80번 시험은 동시 스레드 1 과 깊이 1 로 required 를 계산해 88번에서 3 과 같은지 확인한다. 마지막으로 PoolMeasurement 를 import 하는 자리가 두 시험 파일이라는 것과 그 타입 6~34번이 실리는데, 9~11번 자바독이 대기 수와 획득 지연을 함께 기록하는 이유를 적으며 표본을 뜨는 순간에 대기 수가 0 으로 보이면서도 호출자들이 늘 기다리는 풀이 있을 수 있다고 적고, 26~28번의 total 이 active 더하기 idle 을, 31~33번의 saturated 가 pending 이 0 보다 큰지를 계산한다. 그 아래 프로덕션에서 대기 수를 읽는 자리가 0 개이고 그 검색이 훑은 main 파일이 4714 개라고 나온다." caption="야간 워크플로가 적은 셋과 그 내력 · 레인이 도는 태스크와 릴리스 게이트 의존 · 세 파일의 시험 여덟과 단언 전수 · 레인 범위의 pending 단언 하나와 그 입력 · 포화를 다루는 세 시험 전문 · REQUIRES_NEW 세 시험 전문 · PoolMeasurement 자바독과 계산 메서드 · 프로덕션의 대기 수 읽기 0 — 271줄 · exit 0" zoom="true" ::: `jpa-nightly.yml:127`\~`:129` 가 셋을 적는다. `REQUIRES_NEW` 가 동시 스레드당 커넥션 둘을 요구한다는 것, 포화된 풀이 자기 대기 수를 보고한다는 것, 호출자가 커넥션 없이 진행하는 대신 기다린다는 것이다. `:124`\~`:126` 은 이 단계의 내력도 적는다. 예전에는 명시적 프로퍼티로 단언을 끄고 그 결과를 인증이라 불렀고, 그래서 유일하게 단언된 임계값이 임계값을 단언하지 않는다는 것이었다. `build.gradle:281`\~`:286` 이 같은 셋을 다시 적고 `:287` 의 태스크가 `jpaPlatformPerformanceTest` 소스 세트를 돌린다. `:300` 의 `jpaPlatformReleaseGate` 가 `:309` 에서 그 태스크를 의존에 넣으므로, 릴리스가 이 셋에 기댄다. 그 소스 세트에 있는 것은 `HikariPoolSaturationContractTest` 와 `PoolPressureContractTest` 와 `RequiresNewPoolPressureContractTest` 셋이고, 그 안의 `@Test` 를 전부 세면 여덟이다. ## 첫 번째는 실제 풀로 확인된다 `RequiresNewPoolPressureContractTest:45` 의 `poolOfOneCannotServeAnInnerTransaction` 은 크기 1 짜리 풀에서 바깥 커넥션을 쥔 채 안쪽을 얻으려 하면 `SQLException` 이 나는 것을 확인한다. `:61` 의 `poolOfTwoServesTheSameNesting` 은 크기 2 에서는 얻어지고 두 커넥션이 다른 객체인 것을 확인한다. 둘 다 실제 `HikariDataSource` 를 띄운다. 같은 파일 `:80` 의 `sizingRuleMatchesTheObservedRequirement` 는 다르다. `:81`\~`:84` 가 `concurrentThreads = 1` 과 `maxRequiresNewDepth = 1` 로 `required = 1 * (1 + 1) + 1` 을 계산하고 `:88` 이 그것을 `3` 과 견준다. `PoolPressureContractTest:39` 도 같은 모양이고 숫자만 8 과 17 이다. ## 세 번째는 절반이다 `HikariPoolSaturationContractTest:52` 의 `saturatedPoolFailsWithinItsTimeout` 이 `POOL_SIZE` 만큼 쥔 뒤 한 번 더 요청한다. `:61` 이 `SQLException` 을, `:64`\~`:66` 이 기다린 시간이 `ACQUIRE_TIMEOUT` 에 2 초를 더한 값보다 작은 것을 확인한다. 상한만 있다. 얼마나 기다렸는지에 대한 하한 단언이 없어서, 즉시 실패해도 이 시험은 통과한다. `:77` 의 `releasingAConnectionLetsTheNextCallerThrough` 는 기다림과 무관하다. `:79` 와 `:80` 이 둘을 얻고 `:81` 이 하나를 놓은 뒤 `:83` 이 세 번째를 얻으므로, 세 번째 호출은 이미 빈 자리를 집는다. ## 두 번째를 단언하는 유일한 줄은 손으로 만든 값이다 레인 안에서 `pending()` 을 단언하는 줄은 `PoolPressureContractTest:31` 하나다. 그 시험 `:29` 가 `new PoolMeasurement(4, 2, 3, Duration.ofMillis(80))` 을 만든다. `:31` 이 `pending()` 이 3 인지, `:32` 가 지연이 80 밀리초인지, `:33` 이 `total()` 이 6 인지, `:34` 가 `saturated()` 가 참인지 확인한다. 앞의 둘은 생성자에 넣은 값을 그대로 되읽는다. 뒤의 둘도 새로운 것을 보지 않는다. `PoolMeasurement:26`\~`:28` 의 `total()` 이 `active + idle` 이고 `:31`\~`:33` 의 `saturated()` 가 `pending > 0` 이므로, `4 + 2 = 6` 과 `3 > 0` 을 확인하는 것이다. `HikariDataSource` 도 데이터베이스도 이 시험에는 없다. ## 실제 풀에서 읽은 대기 수는 단언되지 않는다 `HikariPoolSaturationContractTest:92` 의 `measurementReportsPoolState` 는 실제 풀을 띄우고 `:94` 에서 커넥션 하나를 쥔다. `:95`\~`:101` 이 `HikariPoolMXBean` 에서 활성 수와 유휴 수와 `getThreadsAwaitingConnection()` 을 읽어 `PoolMeasurement` 를 만든다. `:103`\~`:105` 의 단언은 셋이다. `active()` 가 1 인지, `total()` 이 1 이상인지, 쥔 커넥션이 유효한지다. `pending()` 은 없다. 그 풀은 포화 상태도 아니다. `:32` 의 `POOL_SIZE` 가 2 인데 커넥션 하나만 쥐었으므로 기다리는 스레드가 생길 수 없다. `PoolMeasurement:9`\~`:11` 자바독이 이 함정을 직접 적는다. 표본을 뜨는 순간에 대기 수가 0 으로 보이면서도 호출자들이 늘 기다리는 풀이 있을 수 있다는 것이다. ## 이 결함이 있는 곳 `PoolMeasurement` 는 `testkit` 소스 세트의 타입이고, 두 시험 파일만 그것을 `import` 한다. 프로덕션에서 `getThreadsAwaitingConnection` 이나 `HikariPoolMXBean` 을 읽는 자리는 main 파일 4714 개에 0 이다. 런타임이 대기 수를 잘못 다루는 것이 아니라, 레인이 검사한다고 적은 것을 검사하지 않는다. ## 원문에 없는 것 원문은 야간 워크플로가 광고하는 셋 중 하나에 대한 단언이 레인 전체에 없다고 적는다. 여기에 더한 것은 그 주장에 가장 가까운 두 자리가 각각 어떻게 비껴가는지와, 나머지 두 주장에도 같은 모양이 섞여 있다는 점이다. `PoolPressureContractTest:31` 은 값을 손으로 넣고 되읽고, `HikariPoolSaturationContractTest:100` 은 실제 값을 읽어 담고서 그것만 빼고 단언한다. 그리고 첫 번째 주장 쪽 `RequiresNewPoolPressureContractTest:80` 과 `PoolPressureContractTest:39` 는 리터럴 산술의 결과를 리터럴과 견주는 시험이다. ## 확인하지 못한 것 `jpaPlatformPoolContractTest` 를 돌리지 않았다. 여덟 시험의 통과 여부는 코드만 읽고 판단하지 않았다. 커넥션 둘을 모두 쥔 상태에서 그 MXBean 이 무엇을 돌려주는지 직접 재지 않았다. 계수 범위를 레인으로 한정했다. 저장소 전체에는 `src/test` 의 `PoolMeasurementTest` 가 같은 단언을 한 번 더 갖고 있고, 그쪽까지 포함해 전수로 따지지는 않았다. 레인을 실제로 돌려 여덟 시험이 통과하는지 보지 않았다. `getThreadsAwaitingConnection()` 이 포화 상태에서 어떤 값을 내는지 띄워서 재지 않았다.