Postgres 커넥션 하나의 비용 - 풀을 키우면 대기가 사라지는 게 아니라 볼 수 없는 곳으로 옮겨간다
엔지니어링 요약
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 ms | 9.11 ms |
| 이미 열린 커넥션에서 질의 | 0.23 ms | - |
| 질의마다 새 커넥션 | 7.32 ms | - |
여는 값이 질의의 약 24배다. 질의마다 열면 32배다.
여기서 풀이 무엇을 아끼는지가 분명해진다. 질의가 빨라지는 것이 아니라 핸드셰이크가 사라지는 것이다. 질의가 원래 100ms 걸린다면 풀이 있든 없든 100ms이고, 절약분 5.48ms는 5%다. 반대로 이 실험처럼 질의가 0.23ms면 절약분이 전체의 96%다. 풀의 이득은 질의가 쌀수록 크다.
유휴 커넥션은 공짜가 아니다
아무것도 하지 않는 커넥션을 늘려 가며 컨테이너 메모리를 읽었다.
| 유휴 커넥션 | 컨테이너 메모리 | 증가 | 커넥션당 |
|---|---|---|---|
| 0 | 447.1 MB | - | - |
| 10 | 461.9 MB | 14.8 MB | 1,515 KB |
| 25 | 484.1 MB | 37.0 MB | 1,515 KB |
| 50 | 521.1 MB | 74.0 MB | 1,515 KB |
| 100 | 595.4 MB | 148.3 MB | 1,519 KB |
약 1.5MB, 완전히 선형이다. PostgreSQL이 커넥션마다 백엔드 프로세스를 fork하기 때문이고, 유휴여도 그 프로세스는 그대로 있다.
실무 산술로 옮기면 무서워진다. 애플리케이션 인스턴스 10대가 각각 풀 150을 잡으면 커넥션 1,500개이고, 일을 시작하기 전에 2.2GB다. 그리고 max_connections를 넘으면 그때부터는 메모리 문제가 아니라 접속 거부다.
풀 스윕: 처리량은 정점을 찍고 내려온다
클라이언트 200개 고정, 풀 크기만 바꿔 12초씩.
점 조회 — 서버가 병목이 아닌 경우
| 풀 | rps | p50 | p99 | 획득 p99 |
|---|---|---|---|---|
| 1 | 768.6 | 219.3 | 647.2 | 644.8 |
| 2 | 1,885.9 | 91.7 | 269.0 | 266.4 |
| 5 | 2,738.0 | 63.2 | 207.5 | 204.4 |
| 10 | 3,250.7 | 60.2 | 92.3 | 89.3 |
| 20 | 2,849.2 | 67.3 | 180.4 | 163.6 |
| 50 | 2,930.7 | 66.8 | 97.0 | 75.0 |
| 100 | 2,799.9 | 68.9 | 109.1 | 55.3 |
| 150 | 2,751.7 | 69.8 | 122.2 | 38.8 |
서버 부하 질의 — 서버가 병목인 경우
| 풀 | rps | p50 | p99 | 획득 p99 |
|---|---|---|---|---|
| 1 | 669.6 | 278.4 | 535.9 | 533.1 |
| 2 | 1,298.7 | 147.5 | 259.8 | 257.3 |
| 5 | 1,801.5 | 102.6 | 240.0 | 232.8 |
| 10 | 1,635.1 | 117.1 | 179.1 | 174.0 |
| 20 | 1,610.8 | 118.9 | 176.0 | 163.4 |
| 50 | 1,474.5 | 128.2 | 205.0 | 168.5 |
| 100 | 1,236.7 | 161.1 | 409.5 | 167.6 |
| 150 | 1,102.2 | 185.1 | 375.4 | 116.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_connections_pending 같은 지표는 좋아진다. 그런데 총 지연은 늘었다. 줄어든 대기가 다른 곳으로 갔기 때문이다. 커넥션을 받은 요청들이 이번엔 서버 안에서 CPU와 잠금을 두고 줄을 선다.
여기가 이 실험에서 가장 실무적인 부분이다. 풀을 키우는 조치는 대시보드를 개선하면서 사용자 경험을 악화시킬 수 있다. 풀 대기 지표만 보고 있으면 그 사실이 보이지 않는다.
그리고 p99가 처리량보다 먼저 나빠진다. 서버 부하 스윕에서 풀 100의 p99는 409.5ms로 풀 10의 179.1ms보다 2.3배인데, 처리량은 24% 떨어졌을 뿐이다. 평균 처리량만 보면 “조금 느려졌네” 수준이고 꼬리는 두 배가 넘게 나빠져 있다.
준비된 문장
| 중앙값 | p99 | |
|---|---|---|
| 서버 측 준비 없음 | 0.254 ms | 0.47 ms |
| 5회 후 준비 | 0.199 ms | 0.33 ms |
0.25ms짜리 질의에서 중앙값 22%다. 절대값은 작지만 초당 수천 건이면 누적된다. 다만 PgBouncer의 transaction 모드를 쓰면 서버 측 준비된 문장을 못 쓰므로, 이 이득은 풀러 구성과 맞물려 있다(커넥션과 세션).
실무로 옮기면
- 풀 크기의 출발점은
L = λ × W다. 초당 100건, 질의 20ms면 2다. 큐잉 이론에서 정리한 그 식이고, 이 실험의 정점(5~10)이 그 근처에 있다. - 풀 대기 지표와 총 지연을 함께 본다. 하나만 보면 옮겨간 대기를 놓친다.
- 정점을 찾는 실험을 한 번은 한다. 계산으로 시작하고 스윕으로 확인한다. 정점은 질의 무게와 서버 CPU에 달려 있어 남의 숫자를 가져올 수 없다.
- 유휴 커넥션 수 × 1.5MB를 용량 계획에 넣는다. 인스턴스 수를 곱하는 것을 잊지 않는다.
- 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를 선형으로 쓴다. PostgreSQL은 커넥션마다 프로세스를 만든다.
- 처리량은 정점을 찍고 내려온다. 질의가 무거울수록 최적 풀이 작다(점 조회 10, 서버 부하 5).
- 정점을 넘기면 획득 대기는 줄고 총 지연은 는다. 대기가 보이는 곳에서 안 보이는 곳으로 옮겨간다.
- p99가 처리량보다 먼저 나빠진다. 평균만 보면 꼬리의 악화를 놓친다.
- 준비된 문장은 중앙값 22%를 줄이지만 풀러 구성이 그것을 무효로 만들 수 있다.
참고
- data-ops-lab — 원본은
reports/data/t10-connection.json, 표는reports/01-experiment-report.md - 커넥션과 세션 — 이 실험의 개념 짝
- 큐잉 이론 한 조각
- HikariCP: About Pool Sizing
댓글
아직 댓글이 없습니다