SSD Nodes Learn 🎉 VPS $5.50/월부터
가이드 Matt Connor작성자 Matt Connor · 업데이트됨 2026-08-21

PostgreSQL VPS 메모리 부족 해결을 위한 커넥션 풀링 가이드

PostgreSQL은 커넥션당 프로세스를 생성하여 4GB 이하 VPS에서 OOM 킬러를 유발합니다. max_connections 설정이 RAM 고갈로 이어지는 이유와 커넥션 풀러 도입 시 발생하는 장단점 및 시스템 안정화 전략을 상세히 설명합니다.

소규모 VPS에서 max_connections보다 먼저 RAM이 고갈되는 이유

VPS에서 PostgreSQL 커넥션 풀링은 속도를 높이는 기술이 아닙니다. 이는 4 GB 메모리를 가진 서버를 유지하기 위한 필수 조치입니다. PostgreSQL의 모든 커넥션은 각자 고유한 메모리를 점유하는 별도의 운영체제 프로세스로 동작하기 때문입니다. 풀러(pooler)를 사용하면 다수의 저렴한 클라이언트 커넥션을 소수의 고정된 실제 백엔드 프로세스로 처리할 수 있습니다.

기본값인 max_connections는 100으로 설정되어 있습니다. 이는 예산이 아니라 한계치입니다. PostgreSQL은 서버가 실제로 100개의 백엔드에서 실행되는 쿼리를 감당할 수 있는지 확인하지 않으므로, 시스템이 먼저 실패하게 됩니다. 커널의 OOM(Out of Memory) 킬러가 프로세스를 선택할 때 백엔드 프로세스가 선택되면, PostgreSQL은 공유 메모리의 안전성을 확보하기 위해 전체 클러스터를 재시작합니다. 로그에는 server process (PID 1234) was terminated by signal 9: Killed이 기록된 후 terminating any other active server processes가 나타납니다. 이 과정에서 정상적인 커넥션을 포함한 모든 열린 커넥션이 종료됩니다.

서버의 메모리가 고갈되는 이유는 각 커넥션이 프로세스 단위로 동작하기 때문이며, work_mem가 커넥션당 할당되는 것이 아니라 정렬(sort) 또는 해시(hash) 작업당 할당되기 때문입니다. 이 두 요소가 결합하여 메모리 사용량이 증폭됩니다.

모든 연결은 프로세스이며, 모든 프로세스는 메모리를 소모합니다

PostgreSQL은 연결당 하나의 프로세스를 사용합니다. 클라이언트가 연결되면 postmaster가 백엔드 프로세스를 포크(fork)하며, 이 백엔드는 클라이언트가 연결을 끊을 때까지 유지됩니다. 이는 스레드가 아닙니다. 각 백엔드는 고유한 페이지 테이블, 카탈로그 캐시, 쿼리 계획 캐시를 가집니다. 연결이 더 많은 테이블을 참조하고 다양한 쿼리를 실행할수록 이러한 캐시는 커지므로, 바쁜 ORM 애플리케이션에서 장시간 유지되는 연결은 새로 생성된 연결보다 더 많은 비용을 소모합니다.

공유 메모리는 실제로 공유됩니다. shared_buffers은 전체 클러스터에 대해 단 한 번 할당되며 모든 백엔드에 매핑됩니다. 사설 메모리는 공유되지 않으며, 이것이 바로 top이 이 상황에서 오해를 불러일으키는 이유입니다. 백엔드의 RSS(Resident Set Size)에는 해당 백엔드가 참조한 공유 페이지가 포함되어 있으므로, 50개 백엔드의 RSS를 단순히 합산하면 shared_buffers을 50번 중복 계산하게 됩니다.

대신 사설 메모리 영역을 측정하십시오. PSS(Proportional Set Size)는 각 공유 페이지를 해당 페이지를 매핑하는 프로세스 수로 나누어 계산하며, USS(Unique Set Size)는 해당 프로세스에만 속한 페이지를 계산합니다.

sudo apt update
sudo apt install -y smem
sudo smem -k -P '^postgres'

USS 열은 해당 백엔드가 종료될 경우 확보될 메모리 양을 나타냅니다. 이것이 실제 연결당 소모 비용입니다. 일반적으로 유휴 상태의 백엔드는 한 자릿수 MB를, 복잡한 ORM 쿼리를 실행한 백엔드는 그보다 몇 배 많은 메모리를 사용하는 것으로 알려져 있습니다. 이러한 수치는 일반적인 참고 자료일 뿐, 실제 운영 환경의 수치로 간주해서는 안 됩니다. 계획 수립 시 고려해야 할 유일한 수치는 실제 서버에서 실제 워크로드를 실행했을 때 측정된 값뿐입니다.

세션당 할당되는 메모리는 간과하기 쉽습니다. temp_buffers은 기본값이 8MB이며, 세션에서 임시 테이블을 처음 사용할 때 할당됩니다. 이 메모리는 세션이 종료될 때까지 반환되지 않습니다.

work_mem은 연결 단위가 아니라 작업 단위로 할당됩니다

여기서 산술적인 계산 착오가 자주 발생합니다. work_mem의 기본값은 4MB이며, PostgreSQL 문서는 이에 대해 다음과 같이 명확히 설명합니다. "복잡한 쿼리는 동시에 여러 정렬 및 해시 작업을 수행할 수 있으며, 각 작업은 일반적으로 임시 파일에 데이터를 쓰기 시작하기 전까지 이 값만큼의 메모리를 사용할 수 있습니다." 정렬 노드가 3개인 실행 계획은 하나의 백엔드 내에서 동시에 work_mem의 3배에 달하는 메모리를 사용할 수 있습니다.

해시 작업은 더 많은 메모리를 사용합니다. hash_mem_multiplier의 기본값은 2.0이므로, 해시 조인이나 해시 집계 작업은 work_mem의 2배인 8MB를 사용할 수 있습니다(기본 설정 기준). 병렬 쿼리를 사용하면 이 값은 더 커집니다. 각 병렬 워커는 고유한 메모리 할당량을 가진 별도의 프로세스이기 때문입니다.

4 GB RAM을 가진 VPS에서 수치를 계산해 보겠습니다. shared_buffers를 1 GB로 설정하고, work_mem을 4MB로 둔 상태에서 100개의 연결이 각각 2개의 해시 노드를 포함한 쿼리를 실행한다고 가정합니다. 이 경우 100 곱하기 16MB, 즉 1.6 GB의 개인 메모리가 1 GB의 공유 버퍼 외에 추가로 필요하게 됩니다. 이는 페이지 캐시나 서버의 다른 프로세스가 사용하는 메모리를 고려하기 전의 수치입니다. 이제 서버에 여유 RAM이 있다고 판단하여 work_mem을 64MB로 올리면, 동일한 100개의 연결이 100 곱하기 256MB를 요구하게 됩니다. 이를 경고해 주는 시스템은 없습니다. OOM killer가 작동할 때 비로소 문제를 알게 됩니다.

추측에 의존하는 대신 work_mem이 너무 작은지 확인할 수 있습니다. postgresql.conf에서 log_temp_files = 0를 설정하고 설정을 다시 불러오십시오. 이후 디스크로 데이터가 넘칠(spill) 때마다 파일 이름과 크기가 temporary file: path "base/pgsql_tmp/pgsql_tmp1234.0", size 20971520과 같이 로그에 기록됩니다. 빈번한 디스크 쓰기는 work_mem를 높이는 것이 도움이 된다는 신호입니다. 반대로 디스크 쓰기가 전혀 없다면 값을 높여도 성능 향상은 없으며, 가용 메모리만 낭비하게 됩니다.

실제로 문제가 되는 풀 산술

아무도 240개의 연결을 열려고 의도하지 않습니다. 20개의 연결 풀을 설정한 뒤, 애플리케이션을 여러 곳에서 실행할 뿐입니다.

ChartBackends requested per app topology, pool size 20 per worker (arithmetic)
The data behind this chart
[
  {
    "config": "1 worker",
    "backends": 20
  },
  {
    "config": "4 web workers",
    "backends": 80
  },
  {
    "config": "4 web + 2 background",
    "backends": 120
  },
  {
    "config": "2 hosts x 4 workers",
    "backends": 160
  },
  {
    "config": "3 hosts x 4 workers",
    "backends": 240
  }
]

4개의 Gunicorn 워커가 각각 20개의 풀을 유지하면 80개의 백엔드를 요청하게 됩니다. 여기에 백그라운드 작업 워커 2개를 추가하면 120개가 됩니다. 3 hosts x 4 workers로 늘어나면 애플리케이션은 max_connections 100에 대해 240개의 백엔드를 요청하게 됩니다. 이 5개의 설정 중 어느 한 곳도 잘못 설정된 곳은 없습니다. 풀은 프로세스별로 생성되며, 애플리케이션의 어떤 부분도 전체 합계를 알 수 없기 때문입니다.

라이브러리의 기본값도 같은 방향으로 작용합니다. SQLAlchemy의 QueuePool는 기본적으로 pool_size=5max_overflow=10을 사용하여 프로세스당 15개의 연결을 생성합니다. HikariCP의 기본값은 10입니다. Django는 5.1 이전에는 내장 풀이 없어 워커 프로세스당 1개의 연결만 사용했습니다. 이것이 Django 애플리케이션이 이 문제를 나중에 겪다가, 누군가 CONN_MAX_AGE을 설정하거나 새로운 "pool": True 옵션을 켰을 때 한꺼번에 문제를 마주하는 이유입니다. Gunicorn과 nginx 뒤에서 Django 앱을 실행하는 경우, 곱해야 할 숫자는 서버 수가 아니라 Gunicorn 워커의 수입니다.

VPS에서 Postgres 커넥션 풀링이 실제로 변화시키는 것

풀러(pooler)는 한쪽으로는 애플리케이션과 PostgreSQL 와이어 프로토콜로 통신하고, 다른 한쪽으로는 소수의 실제 서버 연결을 유지하는 프로세스입니다. 풀러는 쿼리 속도를 직접적으로 높이지는 않습니다. 다만 연결 비용을 누가 부담하는지, 그리고 실제 백엔드가 몇 개나 존재하는지를 변화시킵니다.

두 가지가 개선됩니다. 풀러가 클라이언트의 연결 요청에 직접 응답하므로, 연결 시마다 발생하는 프로세스 포크(fork)와 빈 백엔드 캐시를 채우기 위한 카탈로그 조회 비용이 사라집니다. 더 중요한 점은 실제 백엔드 개수가 애플리케이션 연결 개수와 일대일로 대응하지 않게 된다는 것입니다. 따라서 500개의 클라이언트가 20개의 백엔드를 공유할 수 있습니다.

대기(waiting)는 풀러의 핵심 기능이며, 많은 사용자가 이를 거부감을 느끼는 부분이기도 합니다. 풀러가 없다면 500개의 동시 쿼리가 각각 백엔드를 할당받아 2개의 CPU 코어에서 동시에 실행되므로, 모든 쿼리가 느려지고 메모리는 한순간에 고갈됩니다. 풀러를 사용하면 20개만 먼저 실행되고 나머지는 수 밀리초 동안 대기하므로, 실행 중인 각 쿼리는 CPU 자원을 온전히 할당받아 더 빨리 완료됩니다. 작은 풀 앞에 대기열을 두는 것이, 큰 풀 앞에 대기열을 두지 않는 것보다 낫습니다.

풀러가 해결하지 못하는 것은 서버 내의 다른 자원 제한입니다. 만약 Postgres가 애플리케이션 서버나 동일한 VPS의 벡터 데이터베이스와 자원을 공유한다면, 풀러는 애플리케이션으로부터 Postgres를 보호할 뿐 그 이상은 하지 못합니다. 이웃 프로세스에도 엄격한 제한을 두어야 합니다. systemd를 사용하여 서비스가 사용할 수 있는 메모리와 CPU를 제한하면, 폭주하는 프로세스 하나가 데이터베이스를 다운시키는 상황을 방지할 수 있습니다. 데이터베이스가 어디에 위치하느냐에 따라 이러한 제한을 설정하는 방법이 달라지며, 이것이 Postgres를 Docker에서 실행하는 것과 호스트에서 직접 실행하는 것 사이의 실질적인 차이입니다.

세션 풀링과 트랜잭션 풀링의 비교

모든 설정을 결정짓는 단 하나의 설정은 pool_mode입니다.

세션 풀링에서는 클라이언트 연결이 유지되는 전체 기간 동안 서버 연결이 해당 클라이언트에 할당되며, 클라이언트가 연결을 끊을 때 비로소 반환됩니다. 풀러가 단순한 프록시 역할을 하므로 모든 기능이 정상적으로 작동합니다. 이 방식은 연결 생성 비용만 절감할 뿐 다른 이점은 없습니다. 애플리케이션이 200개의 연결을 열면, 백엔드 역시 200개가 필요합니다.

트랜잭션 풀링에서는 하나의 트랜잭션이 진행되는 동안에만 서버 연결이 클라이언트에 할당됩니다. COMMIT 또는 ROLLBACK 시점에 연결은 풀로 반환되며, 대기 중인 다음 클라이언트가 이를 사용합니다. 이 방식 덕분에 500개의 클라이언트를 20개의 백엔드로 처리할 수 있습니다. 하지만 이 방식은 설계상 일부 기능을 제한합니다. 즉, 이전 문장과 다음 문장이 서로 다른 백엔드에서 실행될 수 있습니다.

PgBouncer의 기본값은 pool_mode = session입니다. 설치 후 설정을 변경하지 않으면, 연결 비용 절감이라는 이점은 얻지 못한 채 절반의 기능만 사용하게 됩니다. 세 번째 모드인 statement은 모든 개별 문장 실행 후 연결을 반환하며, 여러 문장으로 구성된 트랜잭션을 거부합니다. 이 모드가 필요한 명확한 이유가 없다면 사용하지 마십시오.

어떤 트랜잭션 모드가 실패하며, 그 이유는 무엇인가

아래 나열된 모든 항목은 한 가지 이유로 실패합니다. 이는 단일 백엔드 내부에 존재하는 상태 정보인데, 트랜잭션 풀링은 동일한 백엔드를 두 번 다시 제공한다고 보장하지 않기 때문입니다.

  • 세션 수준의 SETRESET. SET search_path, SET statement_timeout, SET TIME ZONESET ROLE는 해당 문장을 처리한 백엔드에 적용되며, 다음 트랜잭션으로 넘어가면 사라집니다. 명시적 트랜잭션 내부에서 SET LOCAL을 사용하십시오. 이는 해당 트랜잭션 범위 내에서만 유효하므로 안전합니다.
  • LISTEN. 알림 전달은 LISTEN를 실행한 백엔드에 귀속되는데, 트랜잭션이 종료되는 즉시 해당 백엔드는 다른 클라이언트에게 할당됩니다. NOTIFY은 트랜잭션 모드에서도 여전히 작동하므로 혼란스러운 실패를 유발합니다. 즉, 전송은 성공하지만 수신은 절대 이루어지지 않습니다. LISTEN가 필요하다면, 풀러를 거치지 않고 포트 5432로 직접 연결되는 별도의 커넥션을 하나 더 여십시오.
  • 세션 수준의 권고 잠금(Advisory locks). pg_advisory_lock()는 세션에 의해 유지되며 세션이 종료될 때 해제됩니다. 트랜잭션 풀링 환경에서는 잠금 해제 호출이 다른 백엔드에서 실행되므로, PgBouncer가 해당 서버 커넥션을 회수할 때까지 잠금이 유지됩니다. 기본 설정상 이 시간은 server_lifetime, 즉 1시간입니다. 트랜잭션이 끝날 때 잠금을 획득한 동일한 백엔드에 의해 해제되는 pg_advisory_xact_lock()을 사용하십시오.
  • SQL 문인 PREPAREDEALLOCATE. 트랜잭션 모드에서는 절대 사용할 수 없습니다.
  • WITH HOLD 커서 및 트랜잭션보다 오래 유지될 것으로 예상되는 모든 서버 측 커서.
  • 커밋 이후에도 유지되어야 하는 임시 테이블. CREATE TEMP TABLE ... ON COMMIT PRESERVE ROWS은 테이블을 특정 백엔드의 임시 스키마에 생성하는데, 다음 트랜잭션은 해당 백엔드에서 실행되지 않을 수 있습니다.
  • LOAD.

프로토콜 수준의 준비된 문장(Prepared statements)은 변경된 항목 중 하나입니다. PgBouncer 1.21.0은 트랜잭션 모드에서 이를 지원하기 시작했으며, 1.24.0부터는 max_prepared_statements을 200으로 설정하여 기본적으로 활성화했습니다. 이전 빌드에서는 이 값이 0으로 설정되어 있어 기능이 꺼져 있습니다. Ubuntu 24.04는 PgBouncer 1.22.0을 제공하므로 기능은 존재하지만, max_prepared_statements를 직접 설정해야 합니다. 사용 중인 빌드의 동작이 확실하지 않다면 클라이언트 측에서 설정하는 것이 안전합니다. prepare_thresholdNone으로 설정하면 psycopg 3은 서버 측 준비된 문장 사용을 중단합니다.

Django는 이와 관련하여 자체적인 명칭을 사용합니다. 문서에 따르면 "트랜잭션 풀링 모드(예: PgBouncer)에서 커넥션 풀러를 사용하려면 해당 커넥션에 대해 서버 측 커서를 비활성화해야 합니다."라고 명시되어 있습니다. 그 이유는 "서버 측 커서는 생성된 커넥션 내에서만 접근할 수 있기 때문"입니다. 해당 데이터베이스 설정 항목에서 DISABLE_SERVER_SIDE_CURSORSTrue로 설정하십시오. 그렇지 않으면 모든 .iterator() 호출이 부하가 걸릴 때만 간헐적으로 실패하는 현상이 발생합니다.

트랜잭션 모드는 도입할 가치가 있으며, 이는 일종의 계약과 같습니다. 목록을 읽고, 사용 중인 ORM과 백그라운드 작업 라이브러리가 이에 부합하는지 확인한 뒤 전환하십시오.

PgBouncer 설치 및 애플리케이션 연결 설정

아래 설정은 Ubuntu 24.04 서버에서 PostgreSQL이 이미 127.0.0.1의 5432 포트에서 대기 중인 환경을 기준으로 합니다.

sudo apt update
sudo apt install -y pgbouncer
pgbouncer --version

Ubuntu 24.04는 PgBouncer 1.22.0 버전을 제공합니다. 2026년 8월 기준 최신 버전은 1.25.2입니다. 위에서 언급한 prepared statement 동작 방식이 버전에 따라 다르므로 현재 설치된 버전을 확인하십시오.

PgBouncer 관리 콘솔 접속만을 위한 역할을 생성한 뒤 비밀번호 파일을 생성합니다. PgBouncer는 pg_authid에서 SCRAM(salted challenge response authentication mechanism) 비밀 정보를 가져와야 하며, 이 테이블은 슈퍼유저만 읽을 수 있습니다.

sudo -u postgres psql -c "CREATE ROLE pgb_admin LOGIN PASSWORD 'change-this'"
sudo -u postgres psql -At -c \
  'SELECT format($$"%s" "%s"$$, rolname, rolpassword) FROM pg_authid WHERE rolpassword IS NOT NULL' \
  > /tmp/userlist.txt
sudo install -o postgres -g postgres -m 640 /tmp/userlist.txt /etc/pgbouncer/userlist.txt
rm /tmp/userlist.txt

비밀번호를 직접 입력하는 대신 비밀 정보를 복사해야 정상적으로 작동합니다. PgBouncer가 PostgreSQL에 로그인할 때 SCRAM 비밀 정보를 재사용하려면, 클라이언트 역시 SCRAM으로 인증해야 하며, 파일 내 비밀 정보가 pg_authid의 값과 바이트 단위로 일치해야 합니다(단순 비밀번호가 아닌 솔트와 반복 횟수까지 동일해야 함). 또한 [databases] 라인에 user=이 고정되어 있지 않아야 합니다. 해당 라인에 user=appuser를 추가하면 PgBouncer는 일반 텍스트 비밀번호를 요구하게 됩니다. systemctl show pgbouncer -p User를 사용하여 파일 소유자가 서비스 실행 계정과 일치하는지 확인하십시오. PostgreSQL에서 비밀번호를 변경하면 이 파일을 다시 생성해야 하며, 그렇지 않으면 다음 연결 시 password authentication failed 오류가 발생합니다.

이제 /etc/pgbouncer/pgbouncer.ini을 작성합니다.

[databases]
appdb = host=127.0.0.1 port=5432 dbname=appdb

[pgbouncer]
listen_addr = 127.0.0.1
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
admin_users = pgb_admin
pool_mode = transaction
max_client_conn = 500
default_pool_size = 20
min_pool_size = 5
max_db_connections = 80
max_prepared_statements = 200
ignore_startup_parameters = extra_float_digits

listen_addr = 127.0.0.1은 풀러가 공용 인터넷에 노출되지 않도록 합니다. 외부에서 접근 가능한 풀러는 의도치 않은 인증 엔드포인트가 될 수 있으므로 중요합니다. max_client_conn는 PgBouncer가 수용할 애플리케이션 연결 수이며, 비용이 적게 들기 때문에 크게 설정해도 됩니다. default_pool_size은 하나의 데이터베이스와 사용자 쌍이 유지할 실제 백엔드 연결 수이며, 이는 비용이 많이 드는 설정입니다. max_db_connections은 전체 데이터베이스 연결을 80개로 제한하여 max_connections 아래에 psql, 백업 및 모니터링을 위한 여유 공간을 확보합니다. ignore_startup_parameters = extra_float_digits는 연결 시 해당 파라미터를 전송하는 JDBC 드라이버 등의 드라이버가 PgBouncer에 의해 거부되는 것을 방지합니다.

sudo systemctl restart pgbouncer
sudo systemctl status pgbouncer --no-pager
sudo journalctl -u pgbouncer -n 20 --no-pager

정상적으로 시작되면 PgBouncer가 127.0.0.1:6432에서 대기 중이라는 로그가 기록됩니다. 비밀번호 파일 문제로 시작에 실패하면 읽을 수 없는 경로가 로그에 남는데, 이는 대부분 구문 오류가 아니라 권한이나 소유권 문제입니다. 이후 애플리케이션의 연결 문자열 포트를 5432에서 6432로 변경하고 재시작하십시오. 애플리케이션에서 변경할 다른 사항은 없습니다.

풀이 정상적으로 작동하는지 확인하는 방법

PgBouncer는 pgbouncer라는 가상 데이터베이스를 통해 접근할 수 있는 관리 콘솔을 제공합니다.

psql -h 127.0.0.1 -p 6432 -U pgb_admin pgbouncer
SHOW POOLS;
SHOW STATS;
SHOW CLIENTS;

SHOW POOLS 항목을 주시해야 합니다. cl_active은 현재 서버 연결에 할당된 클라이언트 수이며, cl_waiting은 연결을 기다리는 대기열의 클라이언트 수입니다. sv_activesv_idle은 각각 사용 중인 실제 백엔드와 유휴 상태인 백엔드를 나타내며, maxwait은 대기열 가장 앞에 있는 클라이언트가 대기한 시간을 초 단위로 표시합니다. 정상적인 부하 상태에서 시스템이 건강하다면 cl_waitingmaxwait은 0이어야 합니다. maxwait 값이 1~2초 이상으로 증가한다면 풀 크기가 너무 작거나 쿼리 속도가 느린 것이며, 이는 서로 다른 해결책이 필요합니다.

default_pool_size를 늘리기 전에 어떤 항목이 문제인지 확인하십시오.

SELECT state, count(*) FROM pg_stat_activity
  WHERE backend_type = 'client backend' GROUP BY state;

대부분의 백엔드가 idle in transaction 상태에 머물러 있다면 풀 크기 문제는 아닙니다. 애플리케이션이 트랜잭션을 시작한 뒤 그 안에서 HTTP 호출과 같이 느린 작업을 수행하고 있어, 쿼리를 실행하지 않으면서도 백엔드를 점유하고 있는 상황입니다. idle_in_transaction_session_timeout 설정을 통해 이러한 연결을 끊을 수 있지만, 근본적인 해결책은 애플리케이션 코드를 수정하는 것입니다. 반대로 백엔드가 모두 active 상태라면 풀이 완전히 포화된 것이므로, 연결 수를 늘리기 전에 EXPLAIN (ANALYZE, BUFFERS)를 통해 쿼리를 최적화해야 합니다.

풀 크기 산정 시 가장 많이 인용되는 시작점은 코어 수의 2배에 1을 더한 HikariCP 휴리스틱입니다. 예를 들어 2코어 VPS라면 5로 설정합니다. 이를 초기 권장 수치로 삼아 default_pool_size을 그 근처로 설정한 뒤, maxwait을 기준으로 조정하십시오. 풀 크기를 작게 설정하는 것이 직관적으로는 부족해 보일 수 있으나, 실제 측정 결과는 더 나은 경우가 많습니다. 대기 중인 백엔드는 비용이 발생하지 않지만, 실행 중인 백엔드는 CPU, 메모리 사용량 및 잠금 경합을 유발하기 때문입니다.

PgBouncer, PgDog, Pgpool-II 선택하기

일반적인 상황, 즉 PostgreSQL 서버 한 대와 VPS 한 대를 운영하면서 애플리케이션이 서버 수용 범위를 넘어서는 연결을 생성할 때는 PgBouncer가 적합합니다. 이 도구는 단일 목적에 집중하며, 설정은 하나의 ini 파일로 관리되고, Debian과 Ubuntu 패키지로도 제공됩니다. 연결 처리를 단일 스레드에서 수행하는데, 이는 VPS 규모의 워크로드에는 충분하며 훨씬 큰 규모의 장비에서만 성능 제한이 발생합니다.

라우팅 결정이 풀링과 동일한 네트워크 홉에서 이루어져야 한다면 PgDog를 고려할 가치가 있습니다. Rust로 작성된 이 도구는 PostgreSQL 확장을 위한 프록시를 표방하며, 쿼리 파싱을 통한 트랜잭션 및 세션 풀링, 읽기/쓰기 분리, 멀티 샤드 라우팅을 포함한 샤딩, 2단계 커밋을 지원합니다. 프라이머리 서버와 하나 이상의 복제본이 있는 환경에서, 애플리케이션이 복제본의 존재를 알지 못해도 읽기 요청을 복제본으로 전달하고 싶을 때 사용하십시오. 두 가지 주의 사항이 있습니다. 첫째, 라이선스가 AGPLv3이므로 프로덕션 환경에 도입하기 전에 네트워크 사용 조항에 대해 사내 의사결정권자와 확인해야 합니다. 프로젝트 측 입장은 내부 사용 및 비공개 수정은 소스 공개 의무를 발생시키지 않는다는 것입니다. 둘째, 이 프로젝트는 아직 초기 단계이며 매주 릴리스가 나오고 버전 번호가 0.x대이므로, main를 따라가기보다는 특정 릴리스 태그를 고정하여 사용하십시오.

git clone https://github.com/pgdogdev/pgdog
cd pgdog
cargo build --release
./target/release/pgdog --config pgdog.toml --users users.toml

소스에서 빌드하려면 최신 안정화 버전의 Rust 툴체인, CMake, C/C++ 컴파일러가 필요합니다. 릴리스 페이지에서 미리 빌드된 Linux 바이너리와 Debian 패키지를 구할 수 있으며, ghcr.io/pgdogdev/pgdog에서 컨테이너 이미지를 제공합니다. 설정은 두 개의 파일로 나뉩니다. 첫 번째 파일은 일반 설정과 데이터베이스별 항목을 포함하며, 두 형식을 쉽게 구분할 수 있도록 인라인 테이블의 TOML 배열 형태로 작성됩니다.

databases = [
  { name = "appdb", host = "127.0.0.1" },
]

[general]
port = 6432
default_pool_size = 10

두 번째 파일은 동일한 배열 형식으로 사용자별 항목을 포함합니다.

users = [
  { name = "appuser", database = "appdb", password = "change-this" },
]

PgDog는 기본적으로 6432 포트에서 대기하는데, 이는 PgBouncer와 동일하므로 한 호스트에서 두 도구가 동시에 기본 포트를 사용할 수는 없습니다.

2026년 6월 기준 4.7.2 버전인 Pgpool-II는 풀링과 로드 밸런싱을 제공하며, 자동 장애 조치를 위한 워치독 기능을 포함합니다. 추가된 기능만큼 장애 발생 지점도 늘어나므로, 선택하기 전에 풀링 모델을 반드시 이해해야 합니다. Pgpool-II는 num_init_children개의 자식 프로세스를 미리 생성(pre-fork)하며, 각 자식 프로세스는 최대 max_pool개의 서버 연결을 캐시하므로 백엔드 연결의 최대치는 num_init_childrenmax_pool을 곱한 값이 됩니다. 각 자식 프로세스는 한 번에 하나의 클라이언트만 처리하므로 수용 가능한 클라이언트 수는 num_init_children과 같으며 이는 시작 시점에 고정됩니다. 또한 유휴 상태의 클라이언트도 자식 프로세스를 점유합니다. num_init_children를 100으로, max_pool을 4로 설정하면 400개의 백엔드 연결이 허용되는데, 이는 풀러를 설치한 본래 목적과 상충하는 문제가 될 수 있습니다. 장애 조치와 쿼리 라우팅 기능이 필요할 때 Pgpool-II를 선택하고, 설정 시 위 계산을 신중하게 수행하십시오. 단순히 백엔드 연결 수를 줄이는 것이 목적이라면, 이 도구는 필요한 것보다 훨씬 복잡한 구성을 요구합니다.

관리형 프록시와 자체 호스팅 방식의 비교

관리형 플랫폼은 이를 별도의 제품으로 판매합니다. AWS는 RDS 앞에 RDS Proxy를 배치하고, Supabase는 Supabase Postgres 앞에 자체 풀러인 Supavisor를 둡니다. 두 서비스 모두 여기서 설명한 역할을 수행합니다. 즉, 클라이언트 연결을 저비용으로 유지하고, 실제 백엔드에는 더 적은 수의 연결을 전달합니다. Supavisor는 오픈 소스이며 자체 호스팅이 가능하므로, 선택의 기준이 독점 기술과 무료 기술 사이의 대립은 아닙니다.

관리형 프록시와 동일한 기능을 자체 호스팅으로 구현하는 것은 다른 개념이 아닙니다. 데이터베이스와 동일한 VPS에서 127.0.0.1 포트로 대기 중인 PgBouncer를 트랜잭션 모드로 설정하는 것과 같은 개념입니다. 두 가지 실질적인 차이점이 있습니다. 관리형 프록시는 네트워크 홉 하나를 거치므로 지연 시간이 추가되며, 데이터베이스가 재시작되는 동안에도 클라이언트 연결을 유지합니다. 데이터베이스 호스트에서 실행되는 PgBouncer는 루프백 홉을 추가하는데 이는 비용이 거의 들지 않지만, 해당 호스트가 죽으면 함께 종료됩니다. 재시작 시에도 연결을 유지하는 동작이 필요하다면 장애 조치(failover) 메커니즘이 추가로 필요하며, 이때부터 Pgpool-II의 watchdog이나 PgDog의 상태 확인 기능이 복잡성을 더하게 됩니다.

목록에 한 가지 옵션을 더 추가할 수 있습니다. 연결 수 문제로 배포가 복잡해지는 것이 주된 고민이라면, 내장형 데이터베이스를 사용하는 방법이 있습니다. 이는 포트에서 실행되는 서버가 아니라 프로세스 내부의 라이브러리이므로 풀링할 연결 모델 자체가 존재하지 않습니다. 쓰기 작업량이 적은 단일 애플리케이션 서버의 경우, VPS에서 SQLite를 운영하는 것이 이 문제를 관리하는 대신 근본적으로 제거하는 방법이 됩니다. 실제 서버가 필요한 상황이라면, 장비 규모를 결정하기 전에 먼저 연결 풀의 크기를 결정하십시오.

FAQ

애플리케이션에 이미 커넥션 풀이 있는데도 PgBouncer가 필요한가요?

대개 그렇습니다. 애플리케이션의 풀은 프로세스별로 독립되어 있어 서로를 인식하지 못하기 때문입니다. 예를 들어 Gunicorn 워커 4개가 각각 20개의 커넥션 풀을 유지하면 80개의 백엔드가 생성되며, 여기에 백그라운드 워커 2개를 추가하면 총 120개가 됩니다. PgBouncer는 전체 커넥션 수를 파악하여 상한선을 설정할 수 있는 유일한 구성 요소입니다. 가장 좋은 구성은 두 가지를 병행하는 것입니다. 각 워커 내부에 작은 풀을 두어 요청마다 TCP 연결 비용을 지불하지 않게 하고, 그 뒤에 PgBouncer를 트랜잭션 모드로 배치하여 실제 백엔드 연결 수를 제한하는 방식입니다.

PgBouncer를 트랜잭션 모드로 전환하면 정확히 무엇이 문제가 되나요?

트랜잭션 간에 백엔드 상태를 유지하는 모든 기능이 영향을 받습니다. 세션 수준의 SETRESET, LISTEN, WITH HOLD 커서, SQL PREPAREDEALLOCATE 문, 세션 수준의 advisory lock, 커밋 이후에도 유지되어야 하는 임시 테이블, 그리고 LOAD이 이에 해당합니다. NOTIFY은 계속 작동하므로, 고장 난 LISTEN는 풀링 문제가 아닌 전달 버그처럼 보일 수 있습니다. Django에서는 DISABLE_SERVER_SIDE_CURSORSTrue로 설정하십시오. psycopg 3의 경우 prepare_thresholdNone으로 설정하거나, max_prepared_statements가 0보다 큰 PgBouncer 1.22 이상 버전을 사용하십시오. pg_advisory_lock()pg_advisory_xact_lock()으로 교체하십시오.

2코어 VPS에서 default_pool_size는 어느 정도가 적당한가요?

생각보다 작게 설정하는 것이 좋습니다. 널리 알려진 HikariCP 휴리스틱에 따르면 코어 수의 2배에 1을 더한 값, 즉 2코어 기준 약 5 정도가 시작점입니다. 이 값을 설정한 뒤 실제 부하가 걸린 상태에서 SHOW POOLSmaxwaitcl_waiting을 확인하십시오. 두 값이 0이면 풀 크기가 충분하다는 의미입니다. maxwait이 증가한다면 클라이언트가 대기 중이라는 뜻이며, 이 숫자를 높이기 전에 pg_stat_activity을 먼저 확인하십시오. idle in transaction 상태에 머물러 있는 백엔드는 애플리케이션 버그일 가능성이 높으며, 커넥션 수를 늘리는 것은 이를 숨길 뿐입니다.

PgBouncer와 PgDog 중 무엇을 선택해야 하나요?

대부분의 배포 환경인 단일 VPS의 단일 PostgreSQL 서버라면 PgBouncer를 선택하십시오. Ubuntu 패키지로 제공되며 동작 방식이 잘 문서화되어 있고, 설정 파일 하나로 모든 구성이 가능합니다. 복제본 간의 읽기/쓰기 분산이나 샤딩이 필요한 경우, 애플리케이션이 토폴로지를 알 필요가 없도록 풀링과 동일한 홉(hop)에서 처리하는 PgDog를 고려하십시오. PgDog를 도입하기 전에 소속 조직의 라이선스 정책에 따라 AGPLv3 관련 문제를 먼저 해결하십시오. 또한 프로젝트가 아직 0.x 버전이며 매주 릴리스가 나오므로 특정 버전을 고정하여 사용하십시오.

#postgres#pgbouncer#pgdog#connections#performance