- 들어가며 — 풀 크기를 늘렸는데 왜 더 느려졌나
- 커넥션은 왜 비싼 자원인가
- 풀을 키울수록 느려지는 원리 — 대기열은 데이터베이스 밖에 세워라
- 자주 인용되는 공식의 근거와 한계
- 인스턴스 수 곱하기 풀 크기 — 마이크로서비스의 전형적 사고
- PgBouncer의 세 가지 모드와 트랜잭션 모드에서 못 쓰는 것
- 커넥션 누수 진단과 서버리스의 특수성
- 마치며 — 공식이 아니라 측정, 그리고 대기열의 위치
들어가며 — 풀 크기를 늘렸는데 왜 더 느려졌나
부하 테스트에서 응답 시간이 튀고 로그에 커넥션 획득 타임아웃이 찍힙니다. 자연스러운 대응은 풀 크기를 늘리는 것입니다. 20에서 50으로, 그래도 안 되면 100으로.
그런데 처리량은 그대로거나 오히려 떨어지고, p99 지연은 더 나빠집니다. 데이터베이스 CPU 사용률은 100퍼센트에 붙어 있는데 초당 처리 건수는 늘지 않습니다.
이 현상은 튜닝 실패가 아니라 예정된 결과입니다. 커넥션 풀은 성능을 늘리는 장치가 아니라 동시성을 제한하는 장치이기 때문입니다. 풀을 키운다는 것은 제한을 푸는 것이고, 제한을 풀면 대기열이 풀에서 데이터베이스 안으로 옮겨 갑니다. 대기열의 위치가 바뀔 뿐 일의 총량은 그대로인데, 데이터베이스 안의 대기열은 훨씬 비쌉니다.
이 글은 그 이유를 설명하고, 크기를 정하는 실제 절차와 자주 인용되는 공식의 한계, 그리고 PgBouncer와 서버리스 환경에서 달라지는 것들을 정리합니다.
커넥션은 왜 비싼 자원인가
PostgreSQL은 연결마다 운영체제 프로세스를 하나씩 만듭니다. 스레드가 아니라 프로세스입니다. 이 설계는 안정성 면에서 장점이 있지만 비용도 분명합니다.
-- 커넥션 하나가 실제로 프로세스 하나인지 확인
SELECT pid, backend_type, application_name, state
FROM pg_stat_activity
WHERE backend_type = 'client backend'
LIMIT 5;
# 같은 pid가 OS 프로세스 목록에 그대로 있다
ps -o pid,rss,command -p 24188
# PID RSS COMMAND
# 24188 11284 postgres: api shop 10.0.3.21(52144) idle
비용은 세 층위로 나뉩니다.
첫째, 생성 비용입니다. 새 연결마다 fork, 인증, 카탈로그 캐시 초기화가 일어납니다. 로컬에서도 수 밀리초, 네트워크와 TLS 핸드셰이크를 포함하면 수십 밀리초입니다. 풀을 쓰는 첫 번째 이유가 이것입니다.
둘째, 메모리입니다. 유휴 백엔드도 카탈로그 캐시와 실행 계획 캐시 때문에 수 메가바이트를 차지하고, 오래 살아 있으면서 다양한 쿼리를 처리한 백엔드는 훨씬 커집니다. 여기에 work_mem이 곱해집니다. work_mem은 커넥션당이 아니라 쿼리 안의 정렬이나 해시 연산 하나당 할당되므로, 커넥션 200개에 work_mem 64MB이고 쿼리마다 정렬이 두 번이면 이론상 25GB까지 갈 수 있습니다.
셋째, 커넥션 수에 비례하는 내부 비용입니다. 스냅샷을 만들 때 실행 중인 트랜잭션 목록을 순회해야 하고, 잠금 관리 자료구조도 커집니다. PostgreSQL 14에서 스냅샷 획득 경로가 크게 개선되어 유휴 커넥션의 부담은 많이 줄었지만, 활성 커넥션 수에 따른 비용은 여전히 남아 있습니다. 여기에 운영체제의 컨텍스트 스위칭이 더해집니다. 실행 가능한 프로세스가 코어 수를 크게 넘어서면 CPU는 일하는 대신 전환에 시간을 씁니다.
# 컨텍스트 스위칭이 급증하는지 관찰
vmstat 1 5
# procs -----------memory---------- ---system-- ------cpu-----
# r b swpd free buff cache in cs us sy id wa st
# 68 0 0 2104928 88420 9821004 42118 318442 71 26 3 0 0
r 열이 코어 수를 크게 넘고 cs가 수십만이라면 데이터베이스가 일이 아니라 스케줄링을 하고 있는 상태입니다.
풀을 키울수록 느려지는 원리 — 대기열은 데이터베이스 밖에 세워라
원리는 대기열 이론 하나로 설명됩니다. 리틀의 법칙에 따르면 평균 동시 처리 건수는 처리량과 평균 응답 시간의 곱입니다.
동시 처리 건수(L) = 처리량(X) × 응답 시간(W)
데이터베이스의 물리적 처리 능력은 코어 수와 디스크 대역폭으로 정해져 있습니다. 이 한계에 도달한 뒤에 동시 요청을 늘리면 처리량 X는 더 이상 늘지 않고, 대신 응답 시간 W가 비례해서 늘어납니다. 즉 같은 일을 하면서 모든 요청의 지연만 커집니다.
여기에 더해 동시성이 높아지면 실제로는 처리량이 감소하기 시작합니다.
- 컨텍스트 스위칭이 유효 CPU 시간을 잠식합니다.
- 같은 행과 인덱스 페이지를 두고 잠금 경합이 늘어납니다. 경합은 동시성의 제곱에 가깝게 증가합니다.
- 버퍼 캐시를 두고 경쟁하면서 캐시 적중률이 떨어집니다.
- 데드락과 직렬화 실패가 늘어 재시도 부하가 붙습니다.
그래서 대기열은 어딘가에 반드시 생기고, 문제는 위치입니다. 풀에서 기다리면 아무 자원도 소모하지 않고 순서대로 처리되며, 대기 시간이 지표로 노출됩니다. 데이터베이스 안에서 기다리면 프로세스와 메모리와 잠금을 쥔 채로 기다리고, 서로를 느리게 만듭니다.
핵심 문장 하나로 요약하면 이렇습니다. 풀 크기는 데이터베이스가 동시에 잘 처리할 수 있는 건수여야 하고, 애플리케이션이 보내고 싶은 건수여서는 안 됩니다.
실제 관찰 방법은 간단합니다. 풀 크기를 고정하고 부하를 올리면서 두 지표를 함께 봅니다.
-- 데이터베이스가 실제로 동시에 무엇을 하고 있는가
SELECT state, wait_event_type, count(*)
FROM pg_stat_activity
WHERE backend_type = 'client backend'
GROUP BY 1, 2
ORDER BY 3 DESC;
state | wait_event_type | count
--------+-----------------+-------
active | LWLock | 41
active | | 8
active | Lock | 22
idle | Client | 64
active인데 LWLock이나 Lock을 기다리는 백엔드가 실제로 일하는 백엔드보다 훨씬 많다면, 풀이 이미 너무 큽니다. 여기서 풀을 더 키우면 이 비율이 더 나빠집니다.
자주 인용되는 공식의 근거와 한계
가장 널리 인용되는 식은 이것입니다.
풀 크기 = (코어 수 × 2) + 유효 디스크 축(spindle) 수
근거는 명확합니다. 코어 수만큼은 실제로 계산을 하고, 그 두 배를 잡는 것은 디스크나 네트워크 대기 때문에 코어가 놀 때 다른 요청이 채우도록 하는 여유분입니다. 마지막 항은 병렬 디스크 입출력 능력을 반영합니다. 8코어 서버라면 대략 20 근처가 나옵니다.
이 식이 주는 메시지는 숫자 자체가 아니라 자릿수입니다. 정답은 수백이 아니라 수십이라는 것. 8코어 데이터베이스에 풀 200개를 붙이고 있다면 그 설정은 거의 확실히 틀렸습니다.
다만 공식을 그대로 쓸 수 없는 조건이 몇 가지 있습니다.
첫째, 애플리케이션과 데이터베이스 사이의 왕복 지연입니다. 같은 가용 영역이면 0.2ms 수준이지만 영역을 넘으면 1ms를 넘습니다. 트랜잭션 하나에 문장이 열 개면 왕복만 10ms가 되고, 그 시간 동안 커넥션은 점유되어 있지만 데이터베이스는 놀고 있습니다. 이런 워크로드는 공식보다 큰 풀이 필요합니다. 정확히는 풀을 키울 것이 아니라 왕복 수를 줄여야 합니다.
둘째, 트랜잭션 안에서 애플리케이션이 하는 일입니다. 외부 API 호출이나 파일 처리가 트랜잭션 중간에 들어가면 커넥션 점유 시간이 폭증합니다. 이 경우 필요한 풀 크기는 공식과 무관해지고, 해법도 풀 크기 조정이 아닙니다.
셋째, 워크로드 혼재입니다. OLTP와 분석 쿼리가 한 풀을 쓰면 무거운 쿼리 몇 개가 풀 전체를 막습니다. 이때는 크기 조정이 아니라 풀 분리가 답입니다.
# 용도별로 풀을 나눈다
pools:
web: { size: 12, timeout: 3s } # 사용자 응답 경로
batch: { size: 4, timeout: 60s } # 야간 배치
readonly: { size: 8, timeout: 10s } # 레플리카 조회
그래서 실제 절차는 공식으로 시작해서 측정으로 끝내야 합니다.
- 공식으로 초기값을 잡습니다. 8코어라면 20 근처입니다.
- 목표 부하를 걸고 풀 대기 시간의 p99와 전체 처리량을 기록합니다.
- 풀 크기를 절반으로 줄여 봅니다. 처리량이 유지되면 원래 값이 너무 컸다는 뜻입니다.
- 처리량이 늘지 않는 지점을 찾을 때까지 늘려 봅니다. 처리량이 평평해지기 시작한 값보다 조금 작은 곳이 적정입니다.
- 그 값에서 풀 대기 시간이 여전히 크다면 풀이 아니라 쿼리를 고쳐야 합니다.
5번이 특히 중요합니다. 커넥션 대기가 길다는 것은 커넥션 점유 시간이 길다는 뜻이고, 그것은 대개 느린 쿼리이거나 긴 트랜잭션입니다. 풀 크기는 그 증상을 잠시 가릴 뿐입니다.
인스턴스 수 곱하기 풀 크기 — 마이크로서비스의 전형적 사고
단일 애플리케이션에서는 잘 맞춘 설정이 서비스가 늘면서 조용히 무너집니다.
서비스 A: 인스턴스 12개 × 풀 20 = 240
서비스 B: 인스턴스 8개 × 풀 15 = 120
서비스 C: 인스턴스 6개 × 풀 10 = 60
배치 워커: 4개 × 풀 5 = 20
합계 = 440
max_connections = 200
각 서비스의 설정은 모두 합리적으로 보입니다. 그런데 합이 최대 커넥션의 두 배를 넘습니다. 평상시에는 모든 풀이 가득 차지 않아 문제가 드러나지 않다가, 트래픽이 몰리거나 롤링 배포로 인스턴스가 일시적으로 두 배가 되는 순간 터집니다.
FATAL: sorry, too many clients already
FATAL: remaining connection slots are reserved for non-replication superuser connections
더 나쁜 것은 이 오류가 헬스 체크와 모니터링 에이전트까지 막는다는 점입니다. 장애 상황에서 관측 수단이 함께 사라집니다.
예산으로 관리해야 합니다. 계산에서 빠뜨리기 쉬운 항목들이 있습니다.
SHOW max_connections; -- 200
SHOW superuser_reserved_connections; -- 3
SHOW max_wal_senders; -- 10 (레플리카, max_connections와 별도)
-- 지금 누가 얼마나 쓰고 있는가
SELECT application_name,
count(*) AS total,
count(*) FILTER (WHERE state = 'active') AS active,
count(*) FILTER (WHERE state = 'idle') AS idle,
count(*) FILTER (WHERE state = 'idle in transaction') AS idle_in_tx
FROM pg_stat_activity
WHERE backend_type = 'client backend'
GROUP BY 1
ORDER BY 2 DESC;
application_name | total | active | idle | idle_in_tx
------------------+-------+--------+------+------------
order-service | 88 | 6 | 79 | 3
user-service | 42 | 3 | 39 | 0
datadog-agent | 6 | 1 | 5 | 0
flyway | 2 | 0 | 2 | 0
이 출력이 전형적인 상태입니다. 88개를 쥐고 있지만 실제로 일하는 것은 6개입니다. 나머지 79개는 메모리와 프로세스 슬롯만 차지합니다.
예산 배분 원칙은 이렇습니다.
- 사용자 경로 서비스에 먼저 배분하고, 배치와 관리 도구는 최소로 잡습니다.
- 롤링 배포 중 인스턴스가 일시적으로 늘어나는 것을 고려해 여유를 둡니다. 최대 인스턴스 수 기준으로 계산해야 합니다.
- 마이그레이션 도구, 모니터링 에이전트, 관리자 접속용으로 몇 개를 남겨 둡니다.
- 서비스별 데이터베이스 사용자를 분리하고 사용자 단위로 상한을 겁니다.
-- 사용자 단위 커넥션 상한. 한 서비스가 전체를 잠식하는 것을 구조적으로 막는다.
ALTER ROLE batch_worker CONNECTION LIMIT 10;
ALTER ROLE order_service CONNECTION LIMIT 60;
풀 크기를 줄여도 서비스 수가 계속 늘어난다면 그때가 커넥션 풀러를 도입할 시점입니다.
PgBouncer의 세 가지 모드와 트랜잭션 모드에서 못 쓰는 것
PgBouncer는 애플리케이션과 PostgreSQL 사이에 서서 다수의 클라이언트 연결을 소수의 서버 연결에 다중화합니다. 모드가 세 가지이고, 차이는 서버 커넥션을 언제 회수하는가입니다.
| 모드 | 서버 커넥션 반환 시점 | 다중화 효율 | 사용할 수 없는 기능 |
|---|---|---|---|
| session | 클라이언트 연결 종료 | 낮음 | 없음. 연결 생성 비용만 절약된다 |
| transaction | 트랜잭션 종료 | 높음 | 세션 변수, 세션 수준 advisory lock, LISTEN과 NOTIFY, WITH HOLD 커서, 임시 테이블 |
| statement | 문장 종료 | 가장 높음 | 여러 문장으로 이루어진 트랜잭션 자체 |
실무에서 의미 있는 선택지는 사실상 transaction 모드입니다. 클라이언트 1000개를 서버 커넥션 20개로 받아 낼 수 있습니다.
[databases]
shop = host=10.0.1.10 port=5432 dbname=shop
[pgbouncer]
pool_mode = transaction
max_client_conn = 2000
default_pool_size = 20
reserve_pool_size = 5
reserve_pool_timeout = 3
server_idle_timeout = 60
max_prepared_statements = 200
그 대가로 포기해야 하는 것들이 명확합니다. 트랜잭션이 끝나는 순간 다른 클라이언트가 그 서버 커넥션을 쓰기 때문에, 트랜잭션 경계를 넘어 유지되는 세션 상태는 모두 깨집니다.
첫째, 세션 변수입니다. SET search_path 나 SET timezone 을 트랜잭션 밖에서 실행하면 다음 요청에 남아 있지 않습니다. 멀티테넌시를 search_path로 구현했다면 트랜잭션 모드에서 조용히 다른 테넌트의 스키마를 읽을 수 있습니다.
-- 트랜잭션 모드에서 안전한 방식
BEGIN;
SET LOCAL search_path = tenant_42, public;
SELECT ...;
COMMIT;
SET LOCAL은 트랜잭션 종료 시 자동으로 되돌아가므로 안전합니다. 행 수준 보안에 쓰는 SET LOCAL app.current_user_id 같은 패턴도 마찬가지입니다.
둘째, advisory lock입니다. pg_advisory_lock은 세션 단위라서 트랜잭션이 끝나도 해제되지 않고, 그 커넥션이 다른 클라이언트에게 넘어가면 영영 풀리지 않습니다.
-- 위험: 세션 단위 잠금
SELECT pg_advisory_lock(12345);
-- 안전: 트랜잭션 종료 시 자동 해제
SELECT pg_advisory_xact_lock(12345);
셋째, prepared statement입니다. 오랫동안 트랜잭션 모드의 가장 큰 제약이었고, JDBC나 asyncpg처럼 기본으로 준비된 문장을 쓰는 드라이버에서 오류가 났습니다. PgBouncer 1.21부터 max_prepared_statements 설정으로 프로토콜 수준 prepared statement를 지원하므로, 최신 버전이라면 이 제약은 사라졌습니다. 오래된 버전을 쓰고 있다면 드라이버 쪽에서 비활성화해야 합니다.
# JDBC
prepareThreshold=0
# asyncpg
statement_cache_size=0
넷째, LISTEN과 NOTIFY입니다. 구독은 세션에 묶여 있으므로 트랜잭션 모드에서는 동작하지 않습니다. 알림을 쓰려면 그 용도만 session 모드 풀을 따로 두거나 PgBouncer를 우회해야 합니다.
한 가지 더. PgBouncer를 넣었다고 해서 애플리케이션 쪽 풀을 없애면 안 됩니다. 두 층을 함께 쓰되, 애플리케이션 풀은 작게 잡고 PgBouncer가 다중화를 담당하게 하는 것이 일반적인 구성입니다.
커넥션 누수 진단과 서버리스의 특수성
커넥션 누수는 두 가지 형태로 나타납니다.
첫째, 반환되지 않은 커넥션입니다. 예외 경로에서 close를 빠뜨린 코드가 원인이고, 풀 사용량이 시간에 따라 단조 증가하는 그래프로 드러납니다.
둘째, 더 위험한 idle in transaction입니다. 트랜잭션을 열어 둔 채 애플리케이션이 다른 일을 하는 상태입니다. 커넥션을 점유하는 것에 그치지 않고 그 트랜잭션의 스냅샷 때문에 VACUUM이 죽은 튜플을 정리하지 못합니다. 테이블이 부풀고 전체 성능이 서서히 나빠집니다.
-- 오래된 idle in transaction 찾기
SELECT pid,
application_name,
state,
now() - xact_start AS tx_age,
now() - state_change AS idle_age,
left(query, 60) AS last_query
FROM pg_stat_activity
WHERE state = 'idle in transaction'
AND now() - state_change > interval '30 seconds'
ORDER BY xact_start;
pid | application_name | state | tx_age | idle_age | last_query
-------+------------------+---------------------+--------------+--------------+-------------------------------
24193 | order-service | idle in transaction | 00:18:44.221 | 00:18:42.008 | SELECT * FROM orders WHERE ...
18분째 열려 있습니다. last_query가 원인 코드를 가리키므로 여기서 바로 추적할 수 있습니다. 그리고 안전망을 걸어 둡니다.
ALTER SYSTEM SET idle_in_transaction_session_timeout = '30s';
ALTER SYSTEM SET idle_session_timeout = '10min'; -- PostgreSQL 14 이상
SELECT pg_reload_conf();
idle_session_timeout은 커넥션 풀러 뒤에서는 주의해서 써야 합니다. 풀이 유지하려는 유휴 커넥션을 서버가 끊으면 풀이 예상치 못한 끊김을 만나게 되므로, 풀의 유휴 검증 주기보다 길게 잡아야 합니다.
가장 오래된 트랜잭션이 VACUUM을 얼마나 막고 있는지도 함께 봐야 합니다.
SELECT max(age(backend_xmin)) AS oldest_xmin_age
FROM pg_stat_activity
WHERE backend_xmin IS NOT NULL;
서버리스 환경은 여기에 구조적인 문제가 하나 더 있습니다. 함수 인스턴스는 요청마다 만들어지고 사라지므로, 각 인스턴스가 자기 풀을 유지하는 모델이 성립하지 않습니다. 동시 실행 인스턴스가 500개라면 커넥션 요청도 500개가 됩니다. 게다가 인스턴스가 종료될 때 연결을 정리할 보장이 없어 서버 쪽에 유령 커넥션이 남습니다.
대응은 세 가지입니다.
- 외부 풀러를 필수로 둡니다. PgBouncer, RDS Proxy 같은 관리형 프록시가 다중화를 담당합니다. 서버리스에서 이것은 선택 사항이 아닙니다.
- 함수 안에서는 풀 크기를 1로 잡습니다. 인스턴스 하나가 동시에 처리하는 요청이 하나라면 커넥션도 하나면 충분합니다.
- 콜드 스타트 밖에서 연결을 만들고 인스턴스 재사용 시 유지하도록 핸들러 바깥 스코프에 클라이언트를 둡니다.
HTTP 기반 드라이버를 쓰는 선택지도 있습니다. TCP 연결과 세션을 유지하지 않고 요청마다 HTTP로 쿼리를 보내는 방식으로, 커넥션 관리 문제 자체를 없앱니다. 대신 트랜잭션과 세션 기능에 제약이 생깁니다.
마치며 — 공식이 아니라 측정, 그리고 대기열의 위치
기억할 것은 세 가지입니다.
첫째, 커넥션 풀의 목적은 동시성을 늘리는 것이 아니라 제한하는 것입니다. 데이터베이스가 동시에 잘 처리할 수 있는 건수를 넘겨 보내면 처리량은 늘지 않고 지연만 커집니다. 대기열은 어차피 생기므로, 아무 자원도 쥐지 않은 채 기다릴 수 있는 풀 쪽에 세우는 편이 항상 낫습니다.
둘째, 코어 수 기반 공식은 시작점이지 정답이 아닙니다. 그 식이 알려 주는 진짜 정보는 정답의 자릿수가 수백이 아니라 수십이라는 사실입니다. 실제 값은 목표 부하에서 풀 크기를 바꿔 가며 처리량과 대기 시간을 측정해서 정해야 합니다. 그리고 대기 시간이 길다면 대개 풀이 작은 것이 아니라 트랜잭션이 긴 것입니다.
셋째, 인스턴스 수와 풀 크기의 곱을 항상 계산하십시오. 각 서비스의 설정이 모두 합리적이어도 총합이 max_connections를 넘으면 배포 중에 장애가 납니다. 사용자 단위 CONNECTION LIMIT으로 상한을 걸고, 서비스가 계속 늘어난다면 PgBouncer의 transaction 모드를 도입하되 세션 상태에 의존하는 코드를 먼저 정리하십시오.
현재 단락 (1/172)
부하 테스트에서 응답 시간이 튀고 로그에 커넥션 획득 타임아웃이 찍힙니다. 자연스러운 대응은 풀 크기를 늘리는 것입니다. 20에서 50으로, 그래도 안 되면 100으로.