목차
- 시작은 max_connections 500이었다
- pg_stat_activity가 보여준 진실
- PgBouncer를 고른 이유와 모드 3가지
- 설치와 초기 설정
- Transaction 모드에서 터진 것 — prepared statement 충돌
- pool_size와 max_connections 다시 잡기
- 3개월 후 숫자
- 다음엔 이렇게
운영 중이던 서비스에서 PostgreSQL 커넥션 풀 설정 없이 max_connections만 500까지 키웠다가 DB 인스턴스 메모리가 8GB 가까이 부풀어 OOM 직전까지 갔다. 3개월 뒤 PgBouncer Transaction 모드로 정리하고 max_connections를 80으로 내렸더니 메모리는 3GB대로 떨어졌고 FATAL: too many connections 에러도 사라졌다.
반면, 이 글은 그 3개월 동안 부숴먹고 고친 순서를 그대로 적은 회고다. 무엇을 의심해서 무엇을 골랐고, 어디서 터졌는지 시간 흐름대로 풀어간다.
시작은 max_connections 500이었다
서비스 구성은 단순한 편이었다. Django 4.2 백엔드, Celery 워커 두어 개, FastAPI로 분리된 알림 서버 하나, 그리고 RDS PostgreSQL 14.10. 사용자 요청이 늘면서 OperationalError: FATAL: sorry, too many clients already 메시지가 Sentry에 박히기 시작했다.
이처럼, 처음엔 단순하게 접근했다. RDS 파라미터 그룹에서 max_connections를 200에서 500으로 올렸다. 며칠은 잠잠했다. 그러다 메모리 사용률이 70%를 넘는 알람이 자주 울렸다. CloudWatch에서 FreeableMemory 그래프가 톱니처럼 흔들리는 게 보였고, 같은 인스턴스 클래스에서 예전과 동일한 트래픽인데 메모리 여유가 줄어 있었다.
그래서, PostgreSQL 공식 문서의 Resource Consumption 섹션을 다시 읽었다. 커넥션 하나가 최소 10MB 안팎의 백엔드 프로세스 메모리를 잡고, work_mem이 곱해지면 더 늘어난다. 500개라면 백엔드 프로세스만 5GB 안팎이 사라진다. 셰어드 버퍼와 OS 캐시까지 고려하면 8GB 인스턴스에 500은 무리한 숫자였다는 게 뒤늦게 보였다.
실제로, 여기서 처음 의심이 생겼다. 정말 동시에 500개가 쿼리를 돌리는 게 맞나? 아니면 그냥 놀고 있는 커넥션이 쌓여 있는 건가?
pg_stat_activity가 보여준 진실
pg_stat_activity는 운영 중인 PostgreSQL의 진실을 보여주는 첫 번째 창이다. 다음 쿼리를 30초 간격으로 5분 동안 돌렸다.
-- 상태별 커넥션 분포 확인
SELECT state, count(*) AS conn_count
FROM pg_stat_activity
WHERE backend_type = 'client backend'
GROUP BY state
ORDER BY conn_count DESC;
-- application_name별로 어디서 잡고 있는지
SELECT application_name, state, count(*)
FROM pg_stat_activity
WHERE backend_type = 'client backend'
GROUP BY application_name, state
ORDER BY count DESC;
따라서, 결과는 예상과 달랐다. 트래픽이 가장 몰리는 구간에서도 active는 30~50개 사이였고 idle이 380~420개였다. idle in transaction도 가끔 5~10개씩 보였다. 평소 평균은 active 10개 미만이었다.
쿼리를 실제로 돌리는 커넥션은 전체의 10%가 안 됐다. 나머지는 Django 워커, Celery 워커, FastAPI가 각자 풀을 잡고 그냥 놀리고 있었다. Django의 CONN_MAX_AGE=600 설정이 워커 수만큼 곱해지고, 거기에 Celery prefetch까지 합쳐지면 커넥션은 빠르게 누적된다.
문제 정의가 바뀌었다. 동시 쿼리가 늘어난 게 아니라 idle 커넥션이 메모리를 갉아먹고 있었다. max_connections를 늘리는 건 증상 완화도 아니고 그냥 메모리만 더 태우는 짓이었다.
결국, 여기서 PgBouncer를 본격적으로 검토했다. 클라이언트 측 풀이 잘못 잡혀 있다면, 서버 앞단에서 풀을 한 번 더 묶어주는 게 정공법이다.
PgBouncer를 고른 이유와 모드 3가지
후보는 두 개였다. PgBouncer와 Pgpool-II. 읽기 부하 분산이나 자동 페일오버 같은 기능은 Pgpool-II가 풍부하지만, 우리에게 필요한 건 커넥션 수를 줄이는 것 단 하나였다. 기능이 많으면 운영 복잡도가 따라온다.
Pgpool-II vs PgBouncer
| 항목 | PgBouncer 1.21 | Pgpool-II 4.4 |
|---|---|---|
| 주요 목적 | 커넥션 풀링 | 풀링 + 부하분산 + 페일오버 |
| 메모리 사용 | 매우 적음 (수십 MB) | 비교적 큼 |
| 설정 복잡도 | 단일 ini 파일 | 다수의 설정 + 클러스터 인지 |
| 풀링 모드 | Session / Transaction / Statement | Session 중심 |
| 장애 영향 범위 | 단일 프로세스 | 클러스터 단위 |
또한, (2026년 5월 기준, 각 프로젝트의 PgBouncer 1.21 changelog, Pgpool-II 4.4 docs 참조)
PgBouncer로 결정했다. 단일 바이너리, ini 하나, 메모리 적음. 지금 우리에게 필요한 건 그게 전부였다.
Session·Transaction·Statement 모드 비교
PgBouncer를 깔기 전에 모드 선택부터 했다. 모드는 풀에서 커넥션을 클라이언트에 얼마나 오래 빌려주느냐의 차이다.
| 모드 | 빌려주는 단위 | 풀 효율 | 호환성 |
|---|---|---|---|
| Session | 클라이언트 연결 종료까지 | 낮음 (개선 효과 적음) | 거의 모든 기능 호환 |
| Transaction | 트랜잭션 1건 동안 | 높음 | prepared statement·SET·LISTEN 제약 |
| Statement | 쿼리 1건 동안 | 가장 높음 | 멀티 스테이트먼트 트랜잭션 불가 |
또한, 처음엔 안전해 보이는 Session 모드로 깔았다. 클라이언트가 disconnect할 때까지 같은 백엔드 커넥션을 잡고 있는 방식이라 호환성 걱정은 없었다. 그런데 문제는, 이미 Django가 클라이언트 측에서 커넥션을 길게 들고 있었다는 점이다. PgBouncer를 끼워도 풀이 끊임없이 점유 상태였다. 효과가 거의 없었다.
실제로, 전환은 불가피했다. Transaction 모드로 가야 했다.
설치와 초기 설정
설치는 빨랐다. 운영 환경은 EC2 한 대를 PgBouncer 전용으로 띄워서 RDS 앞단에 뒀다. RDS Proxy도 후보였지만 비용과 모드 제약이 마음에 안 들어서 패스했다.
# Ubuntu 22.04 기준
sudo apt-get install -y pgbouncer
# 설정 파일 위치
# /etc/pgbouncer/pgbouncer.ini
# /etc/pgbouncer/userlist.txt
예를 들어, 초기 pgbouncer.ini는 이렇게 잡았다.
[databases]
main = host=db.internal port=5432 dbname=app_prod
[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 25
reserve_pool_size = 5
reserve_pool_timeout = 3
server_reset_query = DISCARD ALL
server_idle_timeout = 600
log_connections = 0
log_disconnections = 0
물론, 여기서 default_pool_size가 핵심이다. PgBouncer는 데이터베이스/유저 쌍마다 풀을 따로 만들고, 풀 크기만큼만 실제 PostgreSQL 백엔드 커넥션을 잡는다. 클라이언트 1000개가 붙어도 PostgreSQL 입장에선 25개만 보인다.
server_reset_query는 DISCARD ALL로 잡았다. Transaction 모드에서 같은 백엔드 커넥션이 다음 클라이언트로 넘어가기 전에 세션 상태를 깨끗이 비우는 쿼리다. 안 잡으면 이전 클라이언트의 임시 테이블이나 세션 변수가 남는다.
Transaction 모드에서 터진 것 — prepared statement 충돌
설정 끝내고 카나리 환경부터 트래픽을 흘렸다. 5분 만에 Sentry에 같은 에러가 줄줄이 박혔다.
첫 번째 사고: prepared statement already exists
django.db.utils.OperationalError:
ERROR: prepared statement "S_1" already exists
원인은 단순하면서도 골치 아팠다. Django ORM은 내부적으로 psycopg2/psycopg3로 prepared statement를 만든다. Transaction 모드에서는 트랜잭션이 끝나면 백엔드 커넥션이 풀로 반납되고 다른 클라이언트가 그걸 받는다. 새 클라이언트가 같은 이름(S_1)으로 prepared statement를 만들려고 하면 이미 존재한다는 에러가 난다.
그런데, 해결은 두 갈래였다.
- 클라이언트 측에서 prepared statement를 끄거나 이름 충돌을 피하기
- PostgreSQL 14 기준에선 server-side prepared statement 자체를 비활성화
psycopg3를 쓰고 있었다면 prepare_threshold=None으로 막을 수 있다(출처: psycopg3 connection options). psycopg2에서는 Django가 DISABLE_SERVER_SIDE_CURSORS=True를 설정해야 일부 케이스가 풀린다.
우리는 psycopg3로 마이그레이션 중이었던 게 운이 좋았다. prepare_threshold=None을 데이터베이스 옵션에 추가하니 이 에러는 사라졌다.
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"OPTIONS": {
"prepare_threshold": None,
},
# ...
}
}
두 번째 사고: SET 문이 사라진다
다음으로 터진 건 SET TIMEZONE 같은 세션 단위 설정이 무시되는 현상이었다. 분석 쿼리 일부에서 시간대가 어긋났다. Transaction 모드에서는 SET LOCAL이 아닌 SET은 트랜잭션을 넘어 살아남지 못한다. 같은 커넥션이 다음 클라이언트로 넘어가기 전에 DISCARD ALL이 모두 비워버린다.
방법은 SET LOCAL로 바꾸거나, 아예 connection string에 options=-c TimeZone=Asia/Seoul 형태로 넣는 것이다. 우리는 후자를 택했다. ORM 곳곳을 뒤지는 것보다 한 줄 박는 게 빨랐다.
세 번째: LISTEN/NOTIFY는 포기
그래서, 알림 서버 일부에서 LISTEN/NOTIFY로 이벤트를 받고 있었다. Transaction 모드에선 LISTEN이 의미가 없다. 커넥션이 풀로 돌아가버리니 알림을 받을 주체가 없다. 이건 그냥 Redis Pub/Sub으로 옮겼다. 작은 코드 변경이었지만 결과적으로 더 깔끔해졌다.
pool_size와 max_connections 다시 잡기
실제로, 사고를 잡고 나서 숫자를 다시 잡았다. 무작정 default_pool_size=25로 두진 않았다. 기준은 두 가지였다.
그러나, PostgreSQL의 적정 동시 활성 커넥션 수는 보통 (코어 수 * 2) + 디스크 수 안팎이라는 경험칙이 자주 인용된다(출처: PgBouncer pool_size 권고, PostgreSQL wiki의 number-of-database-connections 토론). 우리는 4 vCPU, gp3 단일 볼륨이라 실질 상한은 9~10 정도였다. 너무 빡빡하면 트랜잭션이 길어질 때 큐가 쌓이니 약간의 여유를 줘서 25로 잡았다.
max_connections는 PgBouncer 풀 크기와 PostgreSQL의 직접 접속을 합쳐 결정했다.
max_connections
= (PgBouncer pool_size * DB-User 쌍 수)
+ (관리·모니터링 직접 접속)
+ (replication slot)
+ (안전 여유)
대상 DB가 1개, 유저 2명(앱·읽기전용), 풀 사이즈 25라면 PgBouncer가 잡는 커넥션은 최대 50. 모니터링·관리·복제용으로 20을 더해 70이 필요한 최소치였다. 여유를 더해 80으로 정착시켰다.
| 항목 | 도입 전 | 도입 후 |
|---|---|---|
max_connections |
500 | 80 |
default_pool_size |
— | 25 |
max_client_conn |
— | 1000 |
server_idle_timeout |
— | 600초 |
server_reset_query |
— | DISCARD ALL |
특히, 여기까지 오는 데 한 달 반이 걸렸다. 모드 선택과 설치는 며칠이지만, 모드 전환에서 발생하는 호환성 문제를 라이브에서 잡는 데 시간이 더 들었다.
3개월 후 숫자
3개월쯤 운영하고 CloudWatch와 pg_stat_activity 기록을 다시 꺼냈다.
| 지표 | 도입 전 | 도입 후 |
|---|---|---|
max_connections |
500 | 80 |
평균 활성 커넥션 (active) |
30~50 | 8~15 |
| 평균 idle 커넥션 | 380~420 | 10 미만 |
| FreeableMemory 평균 | 약 1.2GB | 약 4.8GB |
too many connections 에러 |
주 5~15건 | 0건 |
| p99 쿼리 응답 시간 | 약 240ms | 약 210ms |
가장 큰 변화는 메모리였다. 같은 인스턴스 클래스에서 가용 메모리가 4배 가까이 늘었다. p99는 약간 좋아진 정도인데, 이건 풀 대기 시간이 추가되는 만큼 idle 커넥션이 빠진 효과가 상쇄되어서일 거다(개인적으로 더 큰 개선을 기대했다).
운영하면서 추가로 확인한 점이 있다. PgBouncer 자체의 메모리는 거의 변동이 없다. 클라이언트 1000개를 붙여도 100MB를 넘지 않았다. 이게 RDS Proxy 대비 PgBouncer를 자체 운영할 때의 매력으로 보인다.
다음엔 이렇게
즉, 3개월 끝에 남은 숙제는 두 가지다.
게다가, 첫째, 단일 PgBouncer 인스턴스가 SPOF다. EC2 한 대가 죽으면 전체 DB 접근이 막힌다. HAProxy 앞단에 두 개를 띄우거나, AWS NLB 뒤에 PgBouncer 두 노드를 active-active로 두는 구성을 검토 중이다. PgBouncer는 상태가 거의 없어서 다중 노드가 어렵지 않다.
둘째, Statement 모드를 한 번도 시도해보지 않았다. 트랜잭션이 거의 단일 쿼리로 끝나는 분석 워커에는 Statement 모드가 더 효율적일 수 있다. 다만 멀티 스테이트먼트 트랜잭션이 섞이지 않게 워크로드를 분리하는 작업이 먼저다.
그래서, 지금 당장 비슷한 상황이라면 세 가지를 권한다. (1) max_connections를 올리기 전에 pg_stat_activity로 idle/active 비율부터 확인. (2) PgBouncer를 깔 때 Session이 아닌 Transaction 모드로 시작. (3) prepared statement·LISTEN/NOTIFY·세션 변수 사용처를 미리 grep으로 훑어두기. 이 세 가지만 챙겨도 한 달 안에 끝낼 수 있는 일이었다.
다음엔 PgBouncer 다중 노드 + HAProxy 구성을 붙여서 가용성까지 채워볼 생각이다.
관련 글
- Redis 캐싱 전략 실무 — Cache-Aside, Write-Through, TTL 설계까지 – Redis를 캐시로 도입할 때 흔히 저지르는 실수와 Cache-Aside, Write-Through, TTL 설계 기준을 실무 관점에서 정…
- PostgreSQL 인덱스 최적화 실무 — 인덱스 걸었는데 왜 느린지 모르겠다면 – 인덱스 걸면 빨라진다고들 하는데, 실제로는 그렇지 않은 경우가 많다. EXPLAIN ANALYZE 해석부터 복합 인덱스 컬럼 순서, par…
- PostgreSQL pgvector 벡터 검색 — 임베딩 저장부터 유사도 쿼리 최적화까지 – 프론트엔드에서 백엔드로 전환한 지 2년. 벡터 검색이 필요해졌을 때 별도 벡터 DB를 붙일지 PostgreSQL pgvector로 해결할지…