커넥션 풀 크기와 운영 지표 | pgBouncer, VACUUM, Table Bloat, 모니터링
작업일2026. 07

쿼리를 튜닝했더라도 트래픽이 몰리면 서비스 전체가 응답하지 않는 경우가 있습니다. 느려서가 아니라 연결을 더 받을 수 없어서입니다. 데이터베이스 연결은 무료가 아니고, 개수에 한계가 있습니다.
아닙니다. 커넥션 풀을 쓰면 DB 연결 20개로 앱 연결 2,000개를 처리할 수 있습니다.
인덱스와 실행계획이 쿼리 하나를 빠르게 만드는 일이라면, 여기서 정리하는 것은 서비스 전체의 처리량을 유지하는 일입니다. 연결을 어떻게 관리하고, 무엇을 보고 이상을 판단하는지입니다.
연결 하나의 값
PostgreSQL은 연결마다 별도의 프로세스를 띄웁니다. 그래서 연결 하나에 대략 5~10MB의 메모리가 붙고, 연결을 맺을 때마다 핸드셰이크 비용도 듭니다.
max_connections 로 상한을 둡니다. 이 값을 넘으면 새 연결이 거부되고, 그 순간 서비스 전체가 응답하지 못합니다.max_connections를 무작정 올리면 더 나빠집니다. 연결이 늘면 메모리를 그만큼 더 쓰고, 프로세스가 많아져 문맥 전환 비용도 커집니다. 연결 수를 늘리는 대신 적은 연결을 돌려쓰는 것이 커넥션 풀입니다.커넥션 풀
커넥션 풀은 미리 만들어 둔 DB 연결을 여러 요청이 번갈아 쓰게 하는 장치입니다. 요청이 끝나면 연결을 끊지 않고 반납해서, 다음 요청이 그 연결을 그대로 씁니다.
PostgreSQL에서 가장 많이 쓰는 것이 pgBouncer입니다. 앱과 데이터베이스 사이에 놓여서, 앱 쪽 연결은 많이 받고 DB 쪽 연결은 적게 유지합니다.
세 가지 풀 모드
연결을 언제 반납하느냐에 따라 모드가 셋으로 갈립니다. 이 선택이 효율과 제약을 동시에 정합니다.
| 모드 | 반납 시점 | 효율 | 제약 |
|---|---|---|---|
| Session | 클라이언트가 연결을 끊을 때 | 낮음 | 없음. 가장 안전하지만 연결을 오래 붙잡습니다 |
| Transaction (권장) |
트랜잭션이 끝날 때마다 | 가장 높음 | 세션에 묶이는 기능을 못 씁니다 |
| Statement | 문장 하나가 끝날 때마다 | 가장 높음 | Prepared Statement를 못 씁니다. 거의 쓰지 않습니다 |
SET으로 바꿔둔 세션 변수, 세션 단위 Advisory Lock이 다음 트랜잭션에는 남아 있지 않습니다. Advisory Lock을 쓴다면 pg_advisory_xact_lock 처럼 트랜잭션 단위인 것을 써야 합니다.설정값
# pgbouncer.ini
[databases]
skala_db = host=127.0.0.1 port=5432 dbname=skala_db
[pgbouncer]
pool_mode = transaction # 트랜잭션 단위로 반납
max_client_conn = 2000 # 앱에서 pgBouncer 로 올 수 있는 최대 연결
default_pool_size = 20 # pgBouncer 가 DB 에 실제로 맺는 연결 수
min_pool_size = 5 # 미리 열어 두는 최소 연결
reserve_pool_size = 5 # 몰릴 때 추가로 쓰는 예비분
server_idle_timeout = 600 # 놀고 있는 DB 연결을 끊기까지의 초
핵심은 max_client_conn과 default_pool_size의 차이입니다. 앞은 앱이 붙을 수 있는 수, 뒤는 데이터베이스가 실제로 감당하는 수입니다. 2,000과 20이면 연결 하나를 100개 요청이 나눠 쓴다는 뜻입니다.
default_pool_size를 정할 때 크게 잡고 싶어지지만, 이 값은 결국 동시에 실행되는 쿼리 수가 됩니다. CPU 코어 수를 크게 넘기면 서로 자원을 다투느라 오히려 느려집니다.
상태 확인
pgBouncer는 자기 상태를 조회하는 전용 데이터베이스를 제공합니다. 기본 포트가 6432입니다.
psql -p 6432 pgbouncer -c "SHOW POOLS;" # 풀별 대기·사용 중 연결 수
psql -p 6432 pgbouncer -c "SHOW CLIENTS;" # 앱 쪽 연결 목록
psql -p 6432 pgbouncer -c "SHOW SERVERS;" # DB 쪽 연결 목록
psql -p 6432 pgbouncer -c "SHOW STATS;" # 쿼리 수와 평균 시간
SHOW POOLS의 cl_waiting이 계속 0보다 크면 풀이 부족하다는 뜻입니다. default_pool_size를 올리기 전에 쿼리가 오래 걸려서 연결을 늦게 반납하는 것은 아닌지 먼저 확인합니다.
MySQL은 ProxySQL, AWS는 RDS Proxy, GCP는 Cloud SQL Proxy가 같은 역할을 합니다.
무엇을 보고 판단하나
운영에서 보는 기본 지표는 셋입니다. 셋이 각각 다른 질문에 답합니다.
| 지표 | 뜻 | 답하는 질문 |
|---|---|---|
| QPS Queries Per Second |
초당 쿼리 수 | 얼마나 많은 일을 처리하는가 |
| TPS Transactions Per Second |
초당 완료된 트랜잭션 수 | 얼마나 많은 트랜잭션이 끝났는가 |
| Latency | 요청부터 응답까지 걸린 시간 | 얼마나 빠른가 |
셋을 따로 봐야 하는 이유가 있습니다. QPS는 높은데 TPS가 낮으면 쿼리는 많이 보내는데 트랜잭션이 안 끝나고 있다는 뜻이고, 대개 롤백이나 대기가 쌓인 상태입니다. QPS는 그대로인데 Latency만 오르면 부하가 늘어난 것이 아니라 어딘가 느려진 것입니다.
Latency는 평균이 아니라 p95나 p99로 봅니다. 평균은 느린 소수를 감춰서, 평균 50ms인데 100명 중 한 명은 3초를 기다리는 상황을 놓칩니다.
이상 징후와 확인 순서
증상별로 어디를 먼저 볼지 정해 두면 장애 시간이 줄어듭니다.
| 증상 | 의심할 것 | 확인 |
|---|---|---|
| 쿼리가 갑자기 느려짐 | 인덱스 누락, 통계가 낡음 | EXPLAIN ANALYZE로 실행계획 확인, ANALYZE 실행 |
| CPU 100% | 느린 쿼리, 풀 스캔 | Slow Query 로그와 pg_stat_statements |
| 연결 소진 | 커넥션 누수, 풀 미사용 | 연결 수 그래프, SHOW POOLS, pg_stat_activity |
| 복제 지연 | 네트워크, 대용량 DML | pg_last_xact_replay_timestamp(), 클라우드면 ReplicaLag 지표 |
| 디스크가 참 | 로그 미삭제, 임시 파일, Table Bloat | df -h, 클라우드면 FreeStorageSpace |
연결 소진은 원인이 둘로 갈립니다. 정말 트래픽이 는 것일 수도 있고, 연결을 반납하지 않는 코드 때문일 수도 있습니다. 후자라면 풀 크기를 올려도 시간만 벌 뿐 곧 또 찹니다.
바로 쓰는 모니터링 쿼리
장애가 났을 때 순서대로 던져 보는 쿼리들입니다. 대시보드가 없어도 이것만 있으면 상황이 파악됩니다.
-- ① 30초 넘게 돌고 있는 쿼리
SELECT pid,
now() - query_start AS duration,
state, wait_event,
LEFT(query, 100) AS query_snippet
FROM pg_stat_activity
WHERE state != 'idle'
AND now() - query_start > INTERVAL '30 seconds'
ORDER BY duration DESC;
-- ② 연결이 어떤 상태로 몇 개나 있는지
SELECT state, COUNT(*) AS cnt
FROM pg_stat_activity
GROUP BY state;
②에서 idle in transaction이 쌓여 있으면 트랜잭션을 열어 놓고 아무것도 안 하는 연결입니다. 연결도 잡아먹고 VACUUM도 막기 때문에 가장 먼저 찾아야 하는 상태입니다.
-- ③ 캐시 히트율 (99% 이상을 목표로 봅니다)
SELECT SUM(blks_hit)::FLOAT
/ NULLIF(SUM(blks_hit) + SUM(blks_read), 0) * 100 AS cache_hit_pct
FROM pg_stat_database
WHERE datname = current_database();
-- ④ 큰 테이블 순위
SELECT relname,
pg_size_pretty(pg_total_relation_size(relname::regclass)) AS size
FROM pg_stat_user_tables
ORDER BY pg_total_relation_size(relname::regclass) DESC
LIMIT 10;
-- ⑤ 한 번도 안 쓰인 인덱스
SELECT indexrelname, relname,
pg_size_pretty(pg_relation_size(indexrelid))
FROM pg_stat_user_indexes
WHERE idx_scan = 0
AND relname NOT LIKE 'pg_%'
ORDER BY pg_relation_size(indexrelid) DESC;
③의 캐시 히트율이 99% 아래로 떨어졌다면 shared_buffers가 부족하거나, 인덱스를 못 타서 필요 없는 페이지까지 읽고 있다는 뜻입니다.
알람을 어디에 걸까
지표를 모아 두기만 하면 아무도 안 봅니다. 기준을 정해 알람으로 걸어야 대응이 됩니다. 다만 순간값에 걸면 알람이 너무 자주 울려서 무시하게 되므로, 지속 시간을 함께 조건에 넣습니다.
| 상황 | 경고 기준 | 먼저 할 것 |
|---|---|---|
| 평균 응답 시간 > 500ms | 5분 지속 | 느린 쿼리부터 분석 |
| 복제 지연 > 30초 | 10분 지속 | 복제 트래픽과 대용량 DML 확인 |
| CPU > 80% | 10분 지속 | 인덱스와 캐시 히트율 점검 |
| 남은 디스크 < 10% | 즉시 | 로그 정리, 용량 증설 |
디스크만 지속 조건이 없습니다. 가득 차면 쓰기가 전부 실패하고 복구도 어려워지기 때문입니다.
Table Bloat와 VACUUM
MVCC는 UPDATE를 할 때 기존 행을 고치지 않고 새 버전을 추가합니다. 남은 구버전이 dead tuple이고, 이것이 쌓여 테이블이 실제 데이터보다 커지는 현상을 Table Bloat라고 합니다.
부풀어 오르면 디스크만 먹는 것이 아니라 순차 스캔이 느려집니다. 읽어야 할 페이지 수가 늘어나기 때문입니다.
| 명령 | 하는 일 | 주의 |
|---|---|---|
VACUUM |
dead tuple을 정리하고 그 공간을 재사용 가능으로 표시 | 테이블 크기 자체는 안 줄어듭니다 |
VACUUM FULL |
테이블을 통째로 새로 써서 실제 크기를 줄입니다 | 배타적 잠금을 잡습니다. 그동안 조회도 막힙니다 |
VACUUM ANALYZE |
정리와 통계 갱신을 같이 합니다 | 실행계획이 이상할 때 같이 돌립니다 |
VACUUM FREEZE |
트랜잭션 ID 순환 문제를 예방합니다 | 보통 autovacuum이 알아서 합니다 |
지금 어느 테이블이 부풀어 있는지는 pg_stat_user_tables로 봅니다.
SELECT relname AS table_name,
pg_size_pretty(pg_total_relation_size(relname::regclass)) AS total_size,
n_dead_tup AS dead_tuples,
n_live_tup AS live_tuples,
ROUND(n_dead_tup::NUMERIC
/ NULLIF(n_live_tup + n_dead_tup, 0) * 100, 1) AS dead_pct,
last_autovacuum
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 10;
last_autovacuum이 며칠째 갱신되지 않았다면 오래 열린 트랜잭션을 의심합니다. VACUUM은 어떤 트랜잭션도 더 이상 보지 않는 구버전만 지울 수 있습니다. 트랜잭션 하나가 몇 시간째 열려 있으면 그동안 쌓인 dead tuple을 하나도 못 지웁니다. pg_stat_activity에서 오래된 세션부터 찾습니다.직접 운영할 때와 클라우드일 때
같은 일을 하는데 도구와 자유도가 다릅니다.
| 항목 | On-Prem | Cloud (DBaaS) |
|---|---|---|
| 로그 접근 | 서버의 파일을 직접 봅니다 | 콘솔이나 로그 서비스로 내보내 봅니다 |
| 리소스 지표 | top, vmstat 등으로 직접 |
자동 수집되어 그래프로 제공 |
| 복제 모니터링 | 쿼리나 스크립트를 직접 만듭니다 | ReplicaLag 지표가 기본 제공 |
| 알람 | Prometheus + Grafana를 직접 구성 | 콘솔에서 몇 번 클릭 |
| 튜닝 자유도 | 높음. 파라미터를 직접 고칩니다 | 제한적. 파라미터 그룹으로 허용된 것만 |
클라우드는 관측과 알람이 쉬운 대신 세밀한 제어가 막혀 있습니다. 특정 파라미터를 바꿔야 하는데 파라미터 그룹에 없으면 방법이 없습니다. 반대로 On-Prem은 다 바꿀 수 있지만 그 지표를 모으는 일부터 직접 해야 합니다.
성능 최적화의 네 단계
여기까지 오면 성능을 다루는 방법이 여러 갈래로 나왔습니다. 이 갈래들은 영향력이 같지 않고, 손을 대는 순서가 정해져 있습니다. 아래로 갈수록 고치기는 쉬워지고 효과는 작아집니다.
순서가 이렇게 정해지는 이유는 되돌리는 비용 때문입니다. 설계는 데이터가 쌓이면 바꾸기 어렵고, 설정은 값 하나 고치고 재기동하면 됩니다. 그래서 설계 단계에서 잘못 정한 것을 설정으로 만회하려 하면 거의 안 됩니다.
shared_buffers를 만지는 것부터 시작하게 되는데, 4단계는 1~3단계를 다 하고 남은 것을 줄이는 자리입니다. 기본키를 UUID로 잡아 인덱스가 부풀어 있는 상태라면 메모리를 두 배로 줘도 그대로입니다.정리하면
- 연결마다 자원이 듭니다. PostgreSQL은 연결마다 프로세스를 띄우고 5~10MB를 씁니다. 상한을 올리는 대신 적은 연결을 돌려쓰는 것이 커넥션 풀입니다.
- pool_mode는
transaction이 기본 선택입니다. 대신 임시 테이블, 세션 변수, 세션 Advisory Lock을 못 씁니다. default_pool_size는 동시에 도는 쿼리 수가 됩니다. CPU 코어 수를 크게 넘기면 오히려 느려집니다.- QPS · TPS · Latency를 따로 봅니다. QPS가 높은데 TPS가 낮으면 트랜잭션이 안 끝나고 있다는 뜻입니다.
- Latency는 평균이 아니라 p95·p99로 봅니다. 평균은 느린 소수를 감춥니다.
VACUUM은 공간을 재사용 표시만 하고 크기는 안 줄입니다. 크기를 줄이는VACUUM FULL은 조회까지 막습니다.- 최적화는 설계 → 쿼리 → 인덱스 → 설정 순서입니다. 설정부터 만지는 것이 가장 흔한 실수입니다.
참고
- pgBouncer: Configuration
- PostgreSQL: Routine Vacuuming
- PostgreSQL: Monitoring Database Activity
- PostgreSQL: Connection Settings