커넥션과 세션 - 커넥션 풀이 실제로 아끼는 것
커넥션 풀 크기를 정할 때 “많을수록 좋다”는 직관이 작동하지 않는다. 풀을 늘렸는데 p99가 나빠지는 구간이 있고, 그 이유는 커넥션 하나가 실제로 무엇인지 알아야 설명된다. 풀이 아끼는 것은 생각보다 좁고, 대신 그것이 매우 비싸다.
커넥션 하나를 여는 비용
애플리케이션이 getConnection()을 부르면 일어나는 일.
- TCP 핸드셰이크 — 1 RTT
- TLS 핸드셰이크 — 켜져 있으면 추가 1 RTT 이상
- 프로토콜 시작과 인증 — 몇 번의 왕복. 비밀번호 해시 계산이 포함될 수 있다
- 서버 측 자원 할당 — 여기가 DB마다 크게 다르다
4번이 핵심이다. PostgreSQL은 커넥션마다 OS 프로세스를 fork한다. 프로세스 생성 비용과 수 MB의 메모리가 든다. MySQL과 대부분의 상용 DB는 스레드를 쓰므로 가볍지만 0은 아니다.
그래서 커넥션 풀이 아끼는 것은 이 수립 비용이다. 쿼리 실행이 빨라지는 것이 아니다. 쿼리가 100ms 걸린다면 풀이 있든 없든 100ms다.
세션 상태
커넥션은 상태를 갖는다. 그래서 풀에서 빌려 쓰고 돌려주는 모델이 성립하려면 상태를 초기화해야 한다.
- 트랜잭션 격리 수준
autocommit여부- 임시 테이블, 세션 변수, 준비된 문장
- 잡고 있는 잠금(트랜잭션이 안 끝났으면)
- 검색 경로, 시간대, 문자셋
HikariCP 같은 풀이 반납 시 rollback()을 부르고 설정을 되돌리는 이유다. 애플리케이션이 세션 설정을 직접 바꾸고 되돌리지 않으면, 다음 사용자가 그 설정을 물려받는다. 재현하기 어려운 버그가 여기서 나온다.
풀을 늘리면 왜 나빠지는가
DB가 동시에 처리할 수 있는 양에는 상한이 있다. CPU 코어 수, 디스크 IO 능력, 락 경합이 그 상한을 정한다. 그 상한을 넘겨 커넥션을 주면 DB 내부에서 줄을 서고, 그 줄은 애플리케이션이 볼 수 없는 곳에 있다.
그래서 이런 일이 생긴다.
- 풀이 작으면: 애플리케이션 쪽에서 대기한다.
hikaricp_connections_pending으로 보인다. - 풀이 크면: DB 안에서 대기한다. 보이지 않고, 컨텍스트 스위칭과 락 경합이 늘어 전체가 느려진다.
후자가 더 나쁘다. 대기가 보이지 않을 뿐 아니라, 경합 때문에 총 처리량 자체가 떨어진다. HikariCP 문서가 “작은 풀이 빠르다”고 말하는 근거이고, 큐잉 이론의 곡선이 같은 이야기를 한다. 사용률이 1에 가까워질수록 대기가 발산한다.
크기 추정의 출발점은 L = λ × W다. 초당 100건, 쿼리당 20ms면 L = 2. 여기에 여유를 두면 한 자릿수가 나온다. 수십, 수백이라는 값은 대개 근거 없이 정해진 것이다.
한 요청이 커넥션을 둘 쥘 때
풀 크기보다 먼저 확인할 것이 있다. 한 요청이 커넥션을 몇 개 쥐는가.
@Transactional 안에서 REQUIRES_NEW로 다른 서비스를 부르면 바깥 커넥션을 쥔 채 안쪽 커넥션을 얻는다. 풀이 10인데 동시 요청 10개가 각각 그렇게 하면, 열 개가 바깥을 쥔 채 안쪽을 기다린다. 아무도 내놓지 않으므로 데드락이다. 부하가 풀 크기의 절반만 되어도 멈춘다(트랜잭션 전파).
필요한 풀 크기는 동시 요청 × 중첩 깊이보다 커야 한다. 중첩을 없애는 편이 대개 낫다.
트랜잭션 안의 외부 호출
같은 계열의 더 흔한 문제다. 트랜잭션을 열어 둔 채 외부 API를 부르면 커넥션이 그 응답 시간만큼 묶인다. 외부가 30초 걸리면 커넥션 하나가 30초 동안 쥐여 있다.
Tomcat 스레드 고갈 실험에서 본 것과 같은 구조다. 거기서는 스레드가, 여기서는 커넥션이 묶인다. 그리고 트랜잭션 안에서 외부를 부르면 둘 다 동시에 묶인다. 상한이 두 개인 자원이 함께 마른다.
규칙은 단순하다. 네트워크 호출과 DB 트랜잭션을 섞지 않는다. ParityPay 2편에서 잠금 보유 17ms 중 DB가 실제로 일한 시간이 2.31ms였던 것도 같은 문제의 작은 버전이었다. 트랜잭션 경계 안에 무엇이 들어가는지가 자원 점유 시간을 정한다.
외부 풀러: PgBouncer
PostgreSQL에서 커넥션이 프로세스이므로, 수백 개를 열면 메모리와 스위칭이 부담이다. PgBouncer 같은 풀러를 DB 앞에 두어 실제 커넥션 수를 줄인다.
모드가 셋이고 제약이 다르다.
| 모드 | 커넥션 반납 시점 | 제약 |
|---|---|---|
| session | 클라이언트가 끊을 때 | 제약 없음, 절약도 적음 |
| transaction | 트랜잭션이 끝날 때 | 준비된 문장, 세션 변수, 어드바이저리 락을 쓸 수 없다 |
| statement | 문장마다 | 트랜잭션을 못 쓴다 |
transaction 모드가 가장 널리 쓰이는데, 그 제약이 애플리케이션 동작을 바꾼다. JDBC 드라이버의 서버 측 준비된 문장을 꺼야 하는 경우가 흔하다. 풀러를 넣는 것은 인프라 변경이 아니라 애플리케이션 계약 변경이다.
이 설명이 깨지는 곳
- 커넥션 획득 시간과 쿼리 시간은 다른 지표다. 둘을 합쳐 재면 어디가 느린지 모른다. HikariCP의
connections.acquire타이머를 따로 본다. - 유휴 커넥션도 자원을 쓴다. 최소 풀 크기를 크게 두면 트래픽이 없어도 DB가 그만큼 들고 있다.
- 읽기 복제본을 쓰면 풀이 나뉜다. 각각의 크기를 따로 정해야 한다.
- 가상 스레드가 풀의 필요를 없애지 않는다. 스레드는 늘릴 수 있어도 커넥션은 DB 쪽 자원이다(가상 스레드의 내부).
무엇을 재면 확인되는가
- 풀 크기를 5/10/20/50으로 바꿔 가며 같은 부하에서 처리량과 p99를 본다. 나빠지기 시작하는 지점이 있다.
hikaricp_connections_pending(애플리케이션 대기)과pg_stat_activity의wait_event(DB 내부 대기)를 함께 본다. 대기가 어느 쪽으로 옮겨가는지 보인다.- 트랜잭션 안에 외부 호출을 넣고 커넥션 점유 시간을 잰다.
- 커넥션 수립 비용을 직접 잰다. 풀 없이 매번 여는 것과 비교하면 풀이 아끼는 양이 숫자로 나온다.
1번과 2번이 spring-ops-lab의 S2 시나리오다. “풀을 늘리면 왜 더 나빠지는가”를 격자로 재는 것이 그 실험의 목표이고, S1에서 쓴 하니스를 그대로 쓴다.
정리
- 풀이 아끼는 것은 커넥션 수립 비용이다. 쿼리 실행 시간은 그대로다.
- PostgreSQL은 커넥션마다 프로세스를 만든다. 수립 비용과 메모리가 다른 DB보다 크다.
- 커넥션은 세션 상태를 갖는다. 반납 시 초기화하지 않으면 다음 사용자가 물려받는다.
- 풀이 작으면 대기가 애플리케이션에서 보이고, 크면 DB 안에서 보이지 않게 일어난다. 후자가 더 나쁘다.
- 크기의 출발점은
L = λ × W이고, 대개 한 자릿수다. - 한 요청이 커넥션을 둘 쥐면 풀 크기의 절반에서 데드락이 난다.
- 트랜잭션 안의 외부 호출은 커넥션과 스레드를 동시에 묶는다.
- PgBouncer의 transaction 모드는 애플리케이션 계약을 바꾼다.
댓글
아직 댓글이 없습니다