포스트

Postgres 커넥션 하나의 비용 - 풀을 키우면 대기가 사라지는 게 아니라 볼 수 없는 곳으로 옮겨간다

엔지니어링 요약 커넥션 풀은 당연히 쓰는 것이 됐지만 무엇을 얼마나 아끼는지는 재 본 적이 없었다. 그리고 '풀을 늘렸는데 p99가 나빠진다'는 이야기는 들었어도, 어느 지...

Problem

커넥션 풀은 당연히 쓰는 것이 됐지만 무엇을 얼마나 아끼는지는 재 본 적이 없었다. 그리고 '풀을 늘렸는데 p99가 나빠진다'는 이야기는 들었어도, 어느 지점에서 그렇게 되는지와 그때 대기가 어디로 가는지는 설명하지 못했다.

Decision

커넥션 여는 비용, 유휴 커넥션의 서버 메모리, 풀 크기 1~150 스윕, 준비된 문장 네 가지를 200만 행 테이블에서 쟀다. 스윕은 두 번 돌렸다. 한 번은 서버가 버퍼에서 바로 답하는 점 조회로, 한 번은 서버가 실제로 일하는 범위 집계로. 총 지연뿐 아니라 풀에서 커넥션을 얻기까지의 시간을 따로 기록해 대기가 어디에 있는지 보이게 했다.

Result

커넥션을 여는 데 5.48ms, 열린 커넥션에서 질의는 0.23ms였다. 유휴 커넥션 하나가 서버 메모리 1.5MB를 선형으로 차지했다. 처리량은 점 조회에서 풀 10(3,250 rps), 서버 부하 질의에서 풀 5(1,801 rps)에 정점을 찍고 150에서 각각 15%·39% 낮아졌다. 정점을 넘긴 구간에서 획득 대기 p99는 89.3ms에서 38.8ms로 줄었는데 총 p50은 60.2ms에서 69.8ms로 늘었다. 애플리케이션이 볼 수 있는 대기가 줄고 볼 수 없는 대기가 늘어난 것이다.

커넥션과 세션에서 “풀이 작으면 애플리케이션 쪽에서 대기하고, 크면 DB 안에서 보이지 않게 대기한다”고 썼다. 그 글은 문서를 근거로 썼고 마지막에 네 가지를 재 보자고 적어 뒀다. 이 글이 그 측정이다.

저장소는 data-ops-lab이고 재실행은 한 줄이다.

1
./.venv/bin/python experiments/connection/run.py --seconds 12 --clients 200

커넥션 하나를 여는 값

 중앙값p95
커넥션 열기5.48 ms9.11 ms
이미 열린 커넥션에서 질의0.23 ms-
질의마다 새 커넥션7.32 ms-

여는 값이 질의의 약 24배다. 질의마다 열면 32배다.

그러니 커넥션 풀이 아끼는 것은 질의 시간이 아니다. 질의가 빨라지는 것이 아니라 핸드셰이크(접속 수립과 인증에 드는 왕복)가 사라지는 것이다. 질의가 원래 100ms 걸린다면 풀이 있든 없든 100ms이고, 절약분 5.48ms는 5%다. 반대로 이 실험처럼 질의가 0.23ms면 절약분이 전체의 96%다. 풀의 이득은 질의가 쌀수록 크다.


유휴 커넥션은 공짜가 아니다

아무것도 하지 않는 커넥션을 늘려 가며 컨테이너 메모리를 읽었다.

유휴 커넥션컨테이너 메모리증가커넥션당
0447.1 MB--
10461.9 MB14.8 MB1,515 KB
25484.1 MB37.0 MB1,515 KB
50521.1 MB74.0 MB1,515 KB
100595.4 MB148.3 MB1,519 KB

약 1.5MB, 완전히 선형이다. PostgreSQL은 커넥션마다 백엔드 프로세스를 하나씩 띄우고, 유휴여도 그 프로세스는 그대로 있다. PostgreSQL 문서는 이를 “process per user” 모델이라고 부르고, 접속 요청이 올 때마다 postmaster가 새 백엔드 프로세스를 띄운다고 설명한다(How Connections Are Established).

이 값을 운영 규모로 곱하면 작지 않다. 애플리케이션 인스턴스 10대가 각각 풀 150을 잡으면 커넥션 1,500개이고, 일을 시작하기 전에 2.2GB다. 그리고 max_connections(서버가 받는 최대 동시 접속 수)를 넘으면 그때부터는 메모리 문제가 아니라 접속 거부다.


풀 스윕: 처리량은 정점을 찍고 내려온다

클라이언트 200개 고정, 풀 크기만 바꿔 12초씩. 표의 “획득 p99”는 풀에서 커넥션을 얻기까지 기다린 시간의 99번째 백분위다(백분위 통계).

점 조회: 서버가 병목이 아닌 경우

풀rpsp50p99획득 p99
1768.6219.3647.2644.8
21,885.991.7269.0266.4
52,738.063.2207.5204.4
103,250.760.292.389.3
202,849.267.3180.4163.6
502,930.766.897.075.0
1002,799.968.9109.155.3
1502,751.769.8122.238.8

서버 부하 질의: 서버가 병목인 경우

풀rpsp50p99획득 p99
1669.6278.4535.9533.1
21,298.7147.5259.8257.3
51,801.5102.6240.0232.8
101,635.1117.1179.1174.0
201,610.8118.9176.0163.4
501,474.5128.2205.0168.5
1001,236.7161.1409.5167.6
1501,102.2185.1375.4116.5

두 곡선의 모양이 같고 정점의 위치만 다르다. 점 조회는 풀 10, 서버 부하 질의는 풀 5다. 150에서 각각 15%, 39% 낮다.

질의가 무거울수록 최적 풀이 작다. 서버가 동시에 처리할 수 있는 양이 정해져 있으므로, 건당 비용이 크면 그 양에 도달하는 커넥션 수가 적기 때문이다.


대기는 사라지지 않고 옮겨간다

점 조회에서 풀 10 → 150 구간을 보면 두 값이 반대로 움직인다.

1
2
획득 p99   89.3 ms  →  38.8 ms   (애플리케이션이 보는 대기: 줄었다)
총    p50  60.2 ms  →  69.8 ms   (사용자가 겪는 시간: 늘었다)

풀을 키웠으니 커넥션을 기다리는 시간은 당연히 준다. HikariCP의 hikaricp_connections_pending(커넥션을 기다리는 스레드 수) 같은 지표는 좋아진다. 그런데 총 지연은 늘었다. 줄어든 대기는 커넥션을 받은 뒤 서버 안에서 CPU와 잠금을 두고 줄을 서는 시간으로 옮겨간 것으로 본다. 서버 안의 대기를 직접 재지는 않았고, 두 관측에서 추론한 것이다.

그래서 풀을 키우는 조치는 대시보드를 개선하면서 사용자 경험을 악화시킬 수 있다. 풀 대기 지표만 보고 있으면 그 사실이 보이지 않는다.

그리고 p99가 처리량보다 먼저 나빠진다. 서버 부하 스윕에서 풀 100의 p99는 409.5ms로 풀 10의 179.1ms보다 2.3배인데, 처리량은 24% 떨어졌을 뿐이다. 평균 처리량만 보면 “조금 느려졌다” 수준이지만, 꼬리 지연은 두 배가 넘게 나빠져 있다.


준비된 문장

준비된 문장(prepared statement)은 서버가 한 번 파싱해 둔 질의를 반복 실행할 때 재사용하는 방식이다.

 중앙값p99
서버 측 준비 없음0.254 ms0.47 ms
5회 후 준비0.199 ms0.33 ms

0.25ms짜리 질의에서 중앙값 22%다. 절대값은 작지만 초당 수천 건이면 누적된다. 다만 이 이득은 풀러 구성과 맞물려 있다(커넥션과 세션). PgBouncer(애플리케이션과 DB 사이에서 커넥션을 나눠 쓰게 하는 외부 풀러)의 transaction 모드는 1.21.0(2023-10-16) 전까지 프로토콜 수준의 준비된 문장을 지원하지 않았다. 1.21.0부터는 max_prepared_statements를 0이 아닌 값으로 두면 PgBouncer가 준비된 문장을 추적해 서버 커넥션마다 다시 준비해 준다. 이 설정의 기본값은 1.24.0(2025-01-10)부터 200이다(PgBouncer configuration, changelog).


실무로 옮기면

  1. 풀 크기의 출발점은 L = λ × W다. 초당 100건, 질의 20ms면 2다. 큐잉 이론에서 정리한 그 식이고, 이 실험의 정점(5~10)이 그 근처에 있다. HikariCP 위키가 출발점으로 제시하는 connections = ((core_count * 2) + effective_spindle_count)도 같은 방향이다. 이 실험의 서버는 CPU 2개이므로 이 식으로는 4에 디스크 몫을 더한 값이 된다(About Pool Sizing).
  2. 풀 대기 지표와 총 지연을 함께 본다. 하나만 보면 옮겨간 대기를 놓친다.
  3. 정점을 찾는 실험을 한 번은 한다. 계산으로 시작하고 스윕으로 확인한다. 정점은 질의 무게와 서버 CPU에 달려 있어 남의 숫자를 가져올 수 없다.
  4. 유휴 커넥션 수 × 1.5MB를 용량 계획에 넣는다. 인스턴스 수를 곱하는 것을 잊지 않는다.
  5. p99를 기준으로 고른다. 처리량이 정점인 지점과 p99가 최선인 지점이 다를 수 있다. 이 실험의 점 조회에서는 둘 다 풀 10이었지만, 서버 부하 질의에서는 처리량 정점이 5이고 p99 최선은 20(176.0ms)이었다.

이후 ParityPay 2편에서는 잠금 보유 17ms 중 DB가 실제로 일한 시간이 2.31ms라는 것을 보고 고칠 곳을 찾았다. 여기서도 같은 방식이다. 총 지연을 “기다린 시간”과 “일한 시간”으로 나누기 전까지는 풀을 키울지 줄일지 알 수 없다.


한계

  • PostgreSQL에 CPU 2개다. 정점의 위치는 그 상한의 함수이고, 큰 서버에서는 오른쪽으로 간다. 옮겨갈 수 있는 것은 숫자가 아니라 곡선의 모양이다.
  • TLS가 없다. 켜면 5.48ms에 왕복이 최소 하나 더 붙는다.
  • 클라이언트가 파이썬 스레드다. 3,000 rps 구간에서는 측정된 지연 일부가 클라이언트 자신의 스케줄링이다. 풀 크기 사이의 비교는 클라이언트가 동일하므로 유효하다.
  • 메모리는 docker stats 값이다. 공유 버퍼와 페이지 캐시 귀속이 섞여 있어 커넥션당 값은 증분이지 정확한 개별 메모리가 아니다.
  • 스윕마다 질의가 한 종류다. 섞인 워크로드는 두 곡선 사이 어딘가에 있다.
  • PgBouncer를 재지 않았다. 외부 풀러가 정점을 옮기는지는 별도 측정이다.

정리

  • 풀이 아끼는 것은 핸드셰이크다(5.48ms 대 0.23ms). 유휴 커넥션 하나는 1.5MB를 선형으로 쓴다.
  • 질의가 무거울수록 최적 풀이 작다(점 조회 10, 서버 부하 5).
  • 정점을 넘기면 획득 대기는 줄고 총 지연은 는다. 풀 대기 지표만 보면 이 악화가 보이지 않는다.
  • p99가 처리량보다 먼저 나빠진다. 평균만 보면 꼬리의 악화를 놓친다.
  • 준비된 문장의 이득(중앙값 22%)은 PgBouncer 버전과 설정에 따라 사라질 수 있다.
참고한 자료외부 출처 5 · 블로그 글 2

외부 출처

이 블로그의 관련 글

데이터베이스 내부와 트랜잭션 장애 대응과 관측
이 글은 저작권자의 CC BY 4.0 라이선스를 따릅니다.

댓글

아직 댓글이 없습니다