포스트

커넥션과 세션 - 커넥션 풀이 실제로 아끼는 것

커넥션 풀 크기를 정할 때 “많을수록 좋다”는 직관이 작동하지 않는다. 풀을 늘렸는데 p99가 나빠지는 구간이 있고, 그 이유는 커넥션 하나가 실제로 무엇인지 알아야 설명된다. 풀이 아끼는 것은 생각보다 좁고, 대신 그것이 매우 비싸다.

커넥션 하나를 여는 비용

애플리케이션이 getConnection()을 부르면 일어나는 일.

  1. TCP 핸드셰이크 — 1 RTT
  2. TLS 핸드셰이크 — 켜져 있으면 추가 1 RTT 이상
  3. 프로토콜 시작과 인증 — 몇 번의 왕복. 비밀번호 해시 계산이 포함될 수 있다
  4. 서버 측 자원 할당 — 여기가 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 쪽 자원이다(가상 스레드의 내부).

무엇을 재면 확인되는가

  1. 풀 크기를 5/10/20/50으로 바꿔 가며 같은 부하에서 처리량과 p99를 본다. 나빠지기 시작하는 지점이 있다.
  2. hikaricp_connections_pending(애플리케이션 대기)과 pg_stat_activity의 wait_event(DB 내부 대기)를 함께 본다. 대기가 어느 쪽으로 옮겨가는지 보인다.
  3. 트랜잭션 안에 외부 호출을 넣고 커넥션 점유 시간을 잰다.
  4. 커넥션 수립 비용을 직접 잰다. 풀 없이 매번 여는 것과 비교하면 풀이 아끼는 양이 숫자로 나온다.

1번과 2번이 spring-ops-lab의 S2 시나리오다. “풀을 늘리면 왜 더 나빠지는가”를 격자로 재는 것이 그 실험의 목표이고, S1에서 쓴 하니스를 그대로 쓴다.

정리

  • 풀이 아끼는 것은 커넥션 수립 비용이다. 쿼리 실행 시간은 그대로다.
  • PostgreSQL은 커넥션마다 프로세스를 만든다. 수립 비용과 메모리가 다른 DB보다 크다.
  • 커넥션은 세션 상태를 갖는다. 반납 시 초기화하지 않으면 다음 사용자가 물려받는다.
  • 풀이 작으면 대기가 애플리케이션에서 보이고, 크면 DB 안에서 보이지 않게 일어난다. 후자가 더 나쁘다.
  • 크기의 출발점은 L = λ × W이고, 대개 한 자릿수다.
  • 한 요청이 커넥션을 둘 쥐면 풀 크기의 절반에서 데드락이 난다.
  • 트랜잭션 안의 외부 호출은 커넥션과 스레드를 동시에 묶는다.
  • PgBouncer의 transaction 모드는 애플리케이션 계약을 바꾼다.

참고

데이터베이스 내부와 트랜잭션 Spring과 JVM 백엔드 장애 대응과 관측
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

댓글

아직 댓글이 없습니다