refactor: 문서 개선 중

This commit is contained in:
donghyeon-ka
2026-09-21 14:30:55 +09:00
parent c93cdea150
commit 805a18f486
1497 changed files with 525837 additions and 59152 deletions
@@ -30,7 +30,7 @@ source:
- **로컬이 다른 DB면 로컬 테스트는 다른 시스템에 대한 진술이다**
통과한 레인이 다른 값을 보고 있었다.
- **validator가 요청을 서비스하지 않는 datasource를 검증하고 있었다**
같은 app-bootstrap 모듈에서 시작 검증기가 다른 것을 보고 있던 사례다.
같은 app-bootstrap 모듈에서 startup validator가 실제 바인딩 단계와 다른 파서를 사용해 형식 오류를 잡지 못했다.
## 문제
@@ -42,7 +42,7 @@ source:
프로파일 오버라이드가 형식 결함을 가렸다. 로컬에는 오버라이드가 있었고 나머지에는 없었다.
풀 제약 검증기는 같은 키를 DurationStyle 로 읽어서 5s 를 5000 밀리초로 받는다. 만 이 검증기는 모든 싱글턴이 만들어진 뒤에 도는 자리에 있어서, 이 값으로는 그 전에 빈 생성이 실패한다.
풀 제약 검증기는 같은 키를 `DurationStyle`로 읽어서 `5s`를 5000밀리초로 해석한다. 하지만 이 검증기는 `SmartInitializingSingleton` 단계에서 실행되므로, `HikariDataSource` 빈 바인딩이 먼저 실패하면 검증기까지 도달하지 않는다.
지금은 출하 기본값이 정수로 고쳐져 있고, 밀리초 키 일곱 개의 출하 기본값이 정수인지 확인하는 정적 가드가 있다.
@@ -65,7 +65,7 @@ OpenJDK : 21.0.12
<!-- body:start -->
`spring.datasource.hikari.connection-timeout` `HikariConfig#setConnectionTimeout(long)` 에 바인딩된다. 밀리초 정수만 받는 자리다.
`spring.datasource.hikari.connection-timeout``HikariConfig#setConnectionTimeout(long)`에 바인딩되므로 Spring은 이 값을 `long` 밀리초 값으로 변환해야 한다.
기본값이 `5s` 로 출하됐다. 지금은 `${APP_DATASOURCE_CONNECTION_TIMEOUT:5000}` 로 고쳐져 있다.
@@ -80,11 +80,11 @@ OpenJDK : 21.0.12
:::evidence key="a-five-second-string-that-broke-every-prod-deploy-bind" alt="저장소가 쓰는 Spring Boot 와 HikariCP 산출물로 같은 속성 키에 두 형식을 넣어 실제로 바인딩한 실행 결과. 지속 시간 문자열은 예외 사슬 세 단계로 거절되고 정수는 값이 그대로 들어간다." caption="Spring Boot 4.0.8 · HikariCP 7.0.2 — 같은 키, 두 형식, 실제 바인딩" zoom="true"
:::
예외가 세 단계로 내려간다. 바인딩 실패, 문자열에서 `long` 으로 변환 실패, 그리고 `"5s"` 에 대한 숫자 형식 예외다. 같은 자리`5000` 을 넣으면 값이 그대로 들어간다.
예외 바인딩 실패 문자열 `long`으로 변환하는 실패 `"5s"` 숫자 형식 예외 순으로 이어진다. 같은 프로퍼티`5000`을 넣으면 `long` 값으로 정상 바인딩된다.
## 같은 파일에 같은 이름이 두 형식으로 있다
:::evidence key="a-five-second-string-that-broke-every-prod-deploy" alt="코드베이스에서 출하 설정 파일의 두 네임스페이스에 같은 이름의 키가 다른 형식으로 있는 것과, 앞쪽이 바인딩되는 세터의 long 시그니처, 같은 키를 읽는 시작 검증기의 인터페이스와 파싱 함수, 그리고 그 형식을 막는 정적 가드의 키 목록과 그 가드가 한 번 헛짚었던 자리를 뽑은 출력. 한 파일 안에서 같은 마지막 단어가 정수와 지속 시간 두 형식으로 쓰인다는 것이 그 출력에 그대로 보인다." caption="같은 파일의 두 네임스페이스 · long 세터 · 검증기의 다른 파서 · 정적 가드의 키 목록" zoom="true"
:::evidence key="a-five-second-string-that-broke-every-prod-deploy" alt="코드베이스에서 출하 설정 파일의 두 네임스페이스에 같은 이름의 키가 다른 형식으로 있는 것과, 앞쪽이 바인딩되는 세터의 long 시그니처, 같은 키를 읽는 시작 검증기의 인터페이스와 파싱 함수, 그리고 그 형식을 막는 정적 가드의 키 목록과 그 가드가 한 번 잘못 탐지했던 설정을 뽑은 출력. 한 파일 안에서 같은 마지막 단어가 정수와 지속 시간 두 형식으로 쓰인다는 것이 그 출력에 그대로 보인다." caption="같은 파일의 두 네임스페이스 · long 세터 · 검증기의 다른 파서 · 정적 가드의 키 목록" zoom="true"
:::
`hikari` 아래의 키는 정수를 받고, `tomcat` 아래의 키는 `20s` 로 적혀 있다. 전체 경로는 다르고 마지막 단어만 같다.
@@ -93,7 +93,7 @@ OpenJDK : 21.0.12
풀 제약 검증기가 같은 키를 읽는다. 파서는 `DurationStyle.detectAndParse` 다. 그 규칙에서 `5s` 는 5000 밀리초이고 하한을 넘으므로 유효한 값이다.
다만 이 검증기는 `SmartInitializingSingleton` 이라 모든 싱글턴이 만들어진 뒤에 다. `5s`는 그 전에 `HikariDataSource` 빈 생성에서 바인딩이 죽는다. 검증기가 잘못된 값을 통과시킨 것이 아니라, 이 값에 대해 발언할 수 있는 자리에 있지 않았다.
이 검증기는 `SmartInitializingSingleton`이라 모든 singleton 생성 뒤에 실행된다. `5s`는 그보다 앞선 `HikariDataSource` 빈 생성 바인딩 단계에서 실패하므로 검증기가 값을 판정할 기회가 없다.
## 두 문서가 같은 관용을 반대로 부른다
@@ -109,7 +109,7 @@ OpenJDK : 21.0.12
부팅이 아니라 스캔이라 타입 목록을 손으로 들고 있다. 밀리초 키가 새로 생기면 그 목록에 줄을 더하는 것이 유일한 편입 경로다.
가드 자신의 주석이 초기 버전의 실패도 적어 둔다. 키의 마지막 단어 정규식으로 맞춰서, 진짜 지속 시간`server.tomcat.connection-timeout: 20s` 를 결함으로 보고했다는 것이다. 실제 로더로 평탄화하도록 고친 이유가 그것이다.
가드 주석에는 초기 구현이 키의 마지막 단어 정규식으로 비교해 실제 duration 설정`server.tomcat.connection-timeout: 20s`까지 잘못 거부했다고 적혀 있다. 이후 실제 설정 로더로 값을 펼쳐 검사하도록 바꿨다.
## 확인하지 못한 것
@@ -42,11 +42,9 @@ source:
중첩 깊이 1 이면 스레드당 두 개다. 바깥 하나와 안쪽 하나다.
그래서 풀 크기가 동시 스레드 수보다 커야 한다. 그 제약이 설정 파일 주석에 수식으로 적혀 있다.
그래서 이 프로젝트는 동시 worker 수와 최대 `inNew` 중첩 깊이를 이용한 보수적 풀 사이징 규칙을 설정 주석에 둔다. 이것은 Spring의 보편 불변식이 아니라 이 프로젝트의 concurrency model과 안전 여유를 반영한 운영 규칙이다.
최대 풀 크기가 동시 스레드 수 곱하기 중첩 깊이에 1 을 더한 값 이상, 그리고 거기에 1 을 더한 값 이상이어야 한다는 형태다.
이 수식이 지켜지지 않으면 데드락이 난다. 모든 스레드가 바깥 커넥션을 잡고 안쪽 커넥션을 기다리는 상태가 되고, 아무도 반납하지 않으므로 풀 획득 타임아웃까지 전부 대기한다.
위험은 조건부다. 모든 worker가 outer connection을 점유한 채 inner connection을 기다리고 pool에 추가 connection 여유가 없으면 pool exhaustion이 발생한다. 이 대기가 서로가 놓을 connection을 기다리는 형태가 되면 교착처럼 진행되고 acquisition timeout까지 이어질 수 있다.
같은 주석 블록이 풀 사이징의 다른 근거도 함께 적는다. 작은 풀 공리와 PostgreSQL 의 시작점 수식이다. 코어 수의 두 배에 유효 스핀들 수를 더한 값에서 시작해 부하 테스트로 조정하라는 것이고, 고정 크기 풀을 권장한다.
@@ -78,7 +76,7 @@ Spring Boot : 4.0.8
## 무엇이 금지되고 무엇으로 대신하나
풀 사이징 부등식이 명시돼 있고, 레코드마다 `inNew`를 도는 루프가 금지이며(풀 고갈 + 데드락), 배치로 묶거나 루프를 트랜잭션 밖으로 빼한다.
이 프로젝트의 보수적 풀 사이징 부등식이 명시돼 있고, 레코드마다 `inNew`를 도는 루프는 pool exhaustion과 교착 위험을 키우므로 금지한다. 배치로 묶거나 루프를 트랜잭션 밖으로 빼는 쪽을 택한다.
## 같은 이유의 다른 결정
@@ -35,7 +35,7 @@ source:
<!-- body:start -->
`REQUIRES_NEW`는 바깥 트랜잭션의 커넥션을 **핀한 채로** 새 물리 JDBC 커넥션을 딴다. 그래서 풀 사이징 제약이 곱셈이 된다 — `maximumPoolSize >= (concurrent_threads × (1 + max_inNew_depth)) + 1`.
`REQUIRES_NEW`는 바깥 트랜잭션의 리소스를 유지한 채 독립적인 inner transaction 리소스를 요구할 수 있다. 이 프로젝트는 그 concurrency model을 기준으로 `maximumPoolSize >= (concurrent_threads × (1 + max_inNew_depth)) + 1`을 보수적 sizing rule로 사용한다. Spring API 전체에 성립하는 보편 법칙으로 읽어서는 안 된다.
## 커넥션 비용이 곱셈이 되는 이유
@@ -44,7 +44,7 @@ source:
## 레코드마다 inNew 를 도는 루프가 금지인 이유
풀 고갈과 데드락이다.
모든 worker가 outer connection을 잡은 채 inner connection을 기다리고 추가 여유가 없을 때 pool exhaustion이 생기며, 대기가 서로의 반납을 전제로 하면 교착 형태로 진행할 수 있기 때문이다.
## 같은 곱셈 함정이 database-per-tenant에서 반복된다
@@ -70,13 +70,11 @@ source:
maxPoolSize >= concurrent_threads * (1 + max_inNew_depth) + 1
```
중첩 깊이 1 이면 스레드당 두 커넥션이다. 동시 스레드가 100 이면 최소 201 이 필요하다.
이 프로젝트가 가정한 모델에서 중첩 깊이 1이면 worker 하나가 outer와 inner connection을 동시에 필요로 할 수 있다. 예를 들어 동시 worker 100을 최대 동시성으로 잡고 여유 1을 두는 이 규칙이라면 201을 산정한다. 실제 필요한 값은 transaction manager 동작과 workload의 동시성으로 검증해야 한다.
## 지켜지지 않으면 데드락이
## 여유가 없으면 pool exhaustion과 교착 위험이 생긴
모든 스레드가 바깥 커넥션을 잡고 안쪽 커넥션을 기다린다. 아무도 반납하지 않으므로 전부 풀 획득 타임아웃까지 대기한다.
이 상태는 부하가 임계를 넘는 순간 한꺼번에 나타난다. 그 전까지는 아무 증상이 없다.
모든 worker가 바깥 커넥션을 잡고 안쪽 커넥션을 기다리는 동시에 pool에 남은 connection이 없으면 획득 대기가 누적된다. 이 대기가 서로가 반납해야 할 connection을 기다리는 구조가 되면 acquisition timeout과 교착 형태로 이어질 수 있다. 실제 발생 여부와 임계점은 workload와 pool 설정으로 확인해야 한다.
## 풀 사이징의 다른 근거와 충돌한다
@@ -92,13 +90,13 @@ test). Fixed-size pool recommended (minimumIdle = maximumPoolSize).
:::warning
근거가 같은 손잡이를 반대 방향으로 민다. 작은 풀 공리는 값을 줄이라 하고 중첩 하한은 늘리라 한다. 해소하는 방법은 풀을 키우는 것이 아니라 중첩 깊이를 줄이는 것이다.
기준은 서로 다른 질문에 답한다. 일반적인 pool sizing은 불필요하게 큰 pool을 피하려는 기준이고, 중첩 transaction 규칙은 특정 동시성에서 추가 connection 수요가 생길 수 있음을 반영한다. 먼저 불필요한 `REQUIRES_NEW` 중첩을 줄이고, 남은 동시성 모델을 부하 테스트로 확인한 뒤 `maximumPoolSize`를 정한다.
:::
## 고정 크기 풀
최소 유휴를 최대 크기와 같게 두는 것을 권장한다. 풀이 줄었다가 늘어나는 동안 중첩 하한이 일시적으로 깨지는 것을 막는다.
`minimumIdle = maximumPoolSize`인 fixed-size 설정은 connection creation 지연을 피하고 응답성을 예측하기 쉽게 하려는 운영 선택이다. `minimumIdle`이 더 작다고 `maximumPoolSize` 용량 자체가 사라지는 것은 아니다. `REQUIRES_NEW` 안전 여유는 `maximumPoolSize`와 실제 concurrency model로 판단한다.
## 멀티테넌시에서의 확장
@@ -45,7 +45,7 @@ source:
템플릿 경합이 구조적으로 사라진다.
각 모드가 무엇으로 설정되어 있는지 한 자리에서 보인다.
모드별 템플릿 설정은 하나의 생성 함수에서 구성하므로 mode마다 적용되는 timeout·retry 설정을 같은 코드에서 비교할 수 있다.
## 근거