PostgreSQL 커넥션 풀링 원리: pgcat으로 트랜잭션 풀링 이해하기
Phil Eaton 2025년 10월 24일
PostgreSQL은 클라이언트마다 별도의 백엔드 프로세스를 할당합니다. 연결 수가 늘어나면 리소스 경합과 연결 지연이 커질 수 있어 pgcat, pgbouncer 같은 PostgreSQL 커넥션 풀러를 사용합니다. 이 글에서는 pgcat 실습을 통해 트랜잭션 풀링이 여러 클라이언트 연결을 어떻게 다중화하는지, 그리고 pool_size가 1과 2일 때 백엔드 PID와 트랜잭션 동작이 어떻게 달라지는지 살펴봅니다.
Postgres는 연결되는 각 클라이언트에 ‘클라이언트 백엔드(client backend)‘라는 자체 프로세스를 할당합니다. 이 방식은 1) 리소스 경합(resource contention) 또는 2) 지연 시간(latency) 문제, 혹은 두 가지 모두 때문에 사용자는 보통 최대 클라이언트 연결 수를 수백 개 수준으로 제한합니다. 그리고 pgbouncer나 pgcat과 같은 커넥션 풀러(connection pooler)를 도입합니다.
이러한 풀러는 많은 클라이언트 연결을 단일 Postgres 클라이언트 백엔드에 투명하게 다중화(multiplexing)하여 더 많은 연결을 (더 낮은 지연 시간으로) 처리할 수 있습니다. 하지만 이 방식은 어떻게 작동하고 동작할까요?
pgcat을 통해 살펴보겠습니다.
Postgres 및 pgcat 빌드하기
pgcat과 Postgres를 위한 docker-compose.yml 파일을 찾아 의존성이나 설정에 대한 고민을 건너뛰고 싶었습니다. pgcat 리포지토리에 하나 있긴 했지만, (아마도 Docker가 아닌 Podman을 사용해서인지) 작동시키지 못했습니다. 다른 작동하는 docker-compose yaml 파일도 찾을 수 없었고요. 그래서 그냥 Docker/Podman 권한 문제를 피하고 소스에서 직접 Postgres와 pgcat을 빌드하기로 했습니다.
$ git clone https://github.com/postgres/postgres
$ cd postgres
$ git checkout REL_18_STABLE
$ ./configure --without-icu \
--prefix=$(pwd)/build \
--libdir=$(pwd)/build/lib
$ make -j16 && make install
그런 다음 인스턴스를 생성 및 시작하고 사용자에게 비밀번호를 추가합니다 (pgcat에 필요한 것 같습니다).
$ ./build/bin/initdb testdb
$ ./build/bin/pg_ctl -D $(pwd)/testdb -l logfile start
$ ./build/bin/psql postgres -c "ALTER USER $(whoami) WITH PASSOWRD '$(whoami)'"
이제 pgcat을 가져와 빌드합니다.
$ git clone https://github.com/postgresml/pgcat
$ cd pgcat
$ cargo build
pgcat은 설정 파일이 필요하지만, 리포지토리에 있는 기본 파일들은 너무 많은 기능을 포함하고 있어 최소한의 설정만 원했습니다. 그래서 빈 파일에서 시작해서 pgcat이 누락된 설정에 대한 오류를 멈출 때까지 설정을 추가하여 다음과 같은 pgcat.toml 파일을 만들었습니다.
echo '
[general]
admin_username = "$(whoami)" # Must be set but we won't use this.
admin_password = "" # Must be set but can be blank.
port = 6432
[pools.postgres.shards.0]
servers = [[“localhost”, 5432, “primary”]]
database = “postgres”
[pools.postgres.users.0]
username = “$(whoami)”
password = “$(whoami)”
pool_size = 1
‘ > pgcat.tomlpgcat에 연결 방법과 위치를 알려주고 pgcat 자체는 6432 포트에서 실행되도록 설정했습니다. 이 글의 나머지 부분에서 우리가 수정할 유일한 것은 pool_size입니다. 이에 대해서는 계속 설명하겠습니다.
pgcat을 시작합니다:
$ ./target/debug/pgcat pgcat.toml
pool_size = 1
자, 지금 Postgres가 실행 중이고 pgcat도 실행 중입니다. (복제한 Postgres 리포지토리의 루트로 돌아가서) psql REPL을 사용해 pgcat에 연결해 보겠습니다.
$ PGPASSWORD=$(whoami) ./build/bin/psql -h localhost -p 6432 postgres
psql (18rc1)
Type "help" for help.
postgres=#
pgcat은 클라이언트(psql)와 Postgres의 클라이언트 백엔드 간의 모든 상호작용을 가로채고 있습니다. 하지만 클라이언트 자체만으로는 우리가 Postgres에 직접 연결된 것이 아니라 풀러에 연결되었다는 것을 쉽게 알 수 없습니다.
앞으로 더 많은 클라이언트를 추가할 때 쉽게 구분할 수 있도록 이 세션의 psql 프롬프트를 수정해 보겠습니다.
postgres=# \set PROMPT1 '[client1] %/%R%x%# '
[client1] postgres=# \set PROMPT2 '[client1] %/%R%x%# '
[client1] postgres=#
이제 간단한 쿼리를 실행해 봅시다.
[client1] postgres=# SELECT 'hello world';
?column?
-------------
hello world
(1 row)
이제 우리 쿼리가 실제로 실행되고 있는 Postgres 클라이언트 백엔드의 PID를 확인해 보겠습니다.
[client1] postgres=# SELECT pg_backend_pid();
pg_backend_pid
----------------
19584
(1 row)
이제 세션 로컬(session-local) 설정, 즉 타임존을 변경해 보겠습니다. 저는 뉴욕에 있으므로 타임존은 America/New_York입니다.
[client1] postgres=# SHOW timezone;
TimeZone
------------------
America/New_York
(1 row)
이것을 America/Los_Angeles로 변경합니다.
[client1] postgres=# SET timezone TO 'America/Los_Angeles';
SET
[client1] postgres=# SHOW timezone;
TimeZone
---------------------
America/Los_Angeles
(1 row)
이 psql 세션을 열어둔 채로 다른 터미널에서 또 다른 psql 세션을 엽니다.
$ PGPASSWORD=$(whoami) ./build/bin/psql -h localhost -p 6432 postgres
psql (18rc1)
Type "help" for help.
postgres=#
이것도 구별하기 위해 프롬프트를 설정합니다.
postgres=# \set PROMPT1 '[client2] %/%R%x%# '
[client2] postgres=# \set PROMPT2 '[client2] %/%R%x%# '
[client2] postgres=#
client2 내에서 타임존은 기본값인 America/New_York이어야 합니다.
[client2] postgres=# SHOW timezone;
TimeZone
------------------
America/New_York
(1 row)
이제 Postgres 클라이언트 백엔드 PID를 확인합니다.
[client2] postgres=# SELECT pg_backend_pid();
pg_backend_pid
----------------
19584
(1 row)
정말 멋지네요! Postgres에서는 이것이 불가능한 일입니다! 만약 우리가 Postgres에 직접 연결했다면 이 PID들은 고유해야 합니다. 클라이언트당 하나의 프로세스니까요. 하지만 우리는 여러 클라이언트 간에 동일한 프로세스를 공유하고 있습니다.
client1의 타임존 설정이 혹시 리셋되지 않았는지 다시 확인해 보세요.
[client1] postgres=# SHOW timezone;
TimeZone
---------------------
America/Los_Angeles
(1 row)
여전히 고유한 값을 유지하고 있습니다. 즉, 풀러는 여러 클라이언트가 동일한 Postgres 클라이언트 백엔드를 공유함에도 불구하고, 마치 각 클라이언트가 고유한 Postgres 클라이언트 백엔드를 가진 것처럼 자신만의 세션을 가진 것처럼 보이게 만듭니다.
공유가… 과연 좋은 걸까요?
이 풀러들이 작동하는 기본 방식은 열려 있는 Postgres 클라이언트 백엔드 연결 풀(pool)이 있다는 것입니다. 트랜잭션 풀링 모드에서는 풀에서 클라이언트 백엔드 연결을 획득(acquire)한 다음 트랜잭션이 끝나면 반납(relinquish)합니다. Postgres의 각 문장(statement)은 트랜잭션이므로, 각 클라이언트는 문장 실행 시간 동안에만 풀링된 연결을 유지합니다.
만약 한 클라이언트가 실행에 시간이 오래 걸리는 문장을 실행하면, 다른 클라이언트들이 풀링된 연결에 접근하지 못하게 막을 수 있습니다(starve). 우리는 pool_size = 1로 설정했기 때문에 이 현상을 가장 쉽게 볼 수 있습니다. 즉, 풀러가 단 하나의 Postgres 클라이언트 백엔드만 열도록 허용한 상태입니다.
예를 들어, client2에서 15초 동안 pg_sleep()을 호출해 보겠습니다. 15초 동안 멈춰 있다가 반환될 것입니다.
[client2] postgres=# SELECT pg_sleep(15);
pg_sleep
----------
(1 row)
client2에서 Enter를 누른 직후, client1에서 SELECT 1을 실행해 보세요. client1은 몇 초간 멈춰 있다가 오류를 반환할 것입니다.
[client1] postgres=# SELECT 1;
FATAL: could not get connection from the pool - AllServersDown
하지만 client2의 sleep이 완료되고 문장이 실행되면, client1은 다시 명령을 실행할 수 있게 됩니다.
[client1] postgres=# SELECT 1;
FATAL: could not get connection from the pool - AllServersDown
-- Shortly later --
[client1] postgres=# SELECT 1;
?column?
----------
1
(1 row)
이해가 되네요! 우리는 동일한 Postgres 클라이언트 백엔드를 공유하고 있으니까요!
오래 실행되는 문장은 나쁜 습관이라고요? 맞습니다. 대신 대화형 트랜잭션(interactive transaction)을 수행해 보겠습니다.
client1에서 BEGIN으로 대화형 트랜잭션을 시작합니다.
[client1] postgres=# BEGIN;
BEGIN
[client1] postgres=*#
그리고 client2에서 SELECT 1을 실행해 봅니다.
[client2] postgres=# select 1;
FATAL: could not get connection from the pool - AllServersDown
멈추고 실패합니다. client1의 트랜잭션에서 쿼리를 실행한 다음 커밋해 보겠습니다.
[client1] postgres=# BEGIN;
BEGIN
[client1] postgres=*# SELECT 1;
?column?
----------
1
(1 row)
[client1] postgres=*# COMMIT;
COMMIT
이제 client1의 트랜잭션이 완료되면 client2는 다시 Postgres 클라이언트 백엔드에 접근할 수 있습니다.
[client2] postgres=# SELECT 1;
FATAL: could not get connection from the pool - AllServersDown
-- After client1 commits
[client2] postgres=# SELECT 1;
?column?
----------
1
(1 row)
단 하나의 Postgres 클라이언트 백엔드만 허용했을 때의 동작을 직접 테스트해보니 정말 흥미롭습니다. 하지만 이제 클라이언트 백엔드를 하나 이상 허용하면 어떻게 되는지 살펴보겠습니다.
pool_size = 2
pgcat 리포지토리 내 위에서 덮어썼던 pgcat.toml 파일에서 pool_size를 1에서 2로 변경합니다. 그런 다음 Ctrl-C로 pgcat 서버를 중지했다가 다시 실행합니다. 두 psql 세션을 닫고 두 개의 새 세션을 시작합니다. 동일하게 [client1]과 [client2]라는 프롬프트 접두사를 부여합니다.
client1의 현재 Postgres 클라이언트 백엔드 PID가 무엇인지 확인합니다. (풀러를 재시작했으므로 변경되었을 것입니다.)
$ PGPASSWORD=$(whoami) ./build/bin/psql -h localhost -p 6432 postgres
psql (18rc1)
Type "help" for help.
postgres=# \set PROMPT1 '[client1] %/%R%x%# '
[client1] postgres=# \set PROMPT2 '[client1] %/%R%x%# '
[client1] postgres=# SELECT pg_backend_pid();
pg_backend_pid
----------------
20809
(1 row)
client2에 대해서도 동일하게 수행합니다.
$ PGPASSWORD=$(whoami) ./build/bin/psql -h localhost -p 6432 postgres
psql (18rc1)
Type "help" for help.
postgres=# \set PROMPT2 '[client2] %/%R%x%# '
postgres=# \set PROMPT1 '[client2] %/%R%x%# '
[client2] postgres=# SELECT pg_backend_pid();
pg_backend_pid
----------------
20809
(1 row)
pgcat이 하나 이상의 Postgres 클라이언트 백엔드를 사용하도록 허용했지만, 여전히 하나만 사용하고 있네요. 흥미롭습니다. 물론 이 시점에서 우리가 관찰한 것은 단지 관찰일 뿐입니다. 풀러가 풀링 정책을 구현하는 방식은 아마도 공개 API의 일부로 간주되지 않을 것입니다. 그래도 계속해서 어떻게 작동하는지 살펴보겠습니다.
만약 지금 클라이언트 연결이 블로킹(blocking)되면 어떻게 될까요? client2에서 대화형 트랜잭션을 시작합니다.
[client2] postgres=# BEGIN;
BEGIN
[client2] postgres=*#
클라이언트 백엔드 PID가 무엇인지 확인해 보겠습니다.
[client2] postgres=# BEGIN;
BEGIN
[client2] postgres=*# SELECT pg_backend_pid();
pg_backend_pid
----------------
20809
(1 row)
이전과 동일한 PID입니다. 이 트랜잭션을 열어 둡니다.
그런 다음 client1에서 간단한 SELECT 1을 실행합니다.
[client1] postgres=# SELECT 1;
?column?
----------
1
(1 row)
작동합니다! client1이 어떤 클라이언트 백엔드 PID에서 실행되고 있는지 봅시다.
[client1] postgres=# SELECT pg_backend_pid();
pg_backend_pid
----------------
21004
(1 row)
이전과 같지 않습니다! 한 클라이언트가 연결 사용을 막고 있었고 pool_size가 더 많은 연결을 허용했기 때문에, pgcat이 다른 클라이언트 연결이 사용할 수 있는 새로운 Postgres 클라이언트 백엔드를 연 것입니다.
이제 client2에서 트랜잭션을 커밋하고 해당 트랜잭션 이후에 어떤 Postgres 클라이언트 백엔드 PID를 갖는지 확인합니다.
[client2] postgres=# BEGIN;
BEGIN
[client2] postgres=*# SELECT pg_backend_pid();
pg_backend_pid
----------------
20809
(1 row)
[client2] postgres=*# COMMIT;
COMMIT
[client2] postgres=# SELECT pg_backend_pid();
pg_backend_pid
----------------
21004
(1 row)
바뀌었습니다! 다시 확인해 보죠. 그리고 또. 그리고 또.
[client2] postgres=# [client2] postgres=# SELECT pg_backend_pid();
pg_backend_pid
----------------
20809
(1 row)
[client2] postgres=# SELECT pg_backend_pid();
pg_backend_pid
----------------
21004
(1 row)
[client2] postgres=# SELECT pg_backend_pid();
pg_backend_pid
----------------
20809
(1 row)
client1에서도 마찬가지입니다.
[client1] postgres=# SELECT pg_backend_pid();
pg_backend_pid
----------------
21004
(1 row)
[client1] postgres=# SELECT pg_backend_pid();
pg_backend_pid
----------------
20809
(1 row)
[client1] postgres=# SELECT pg_backend_pid();
pg_backend_pid
----------------
21004
(1 row)
기본적으로 로직은 이렇습니다. 클라이언트는 기존 연결 풀에서 (무작위로?) 연결을 가져오고, 기존의 모든 연결이 블로킹될 때까지는 새 연결을 만들지 않습니다. 각 클라이언트는 매 트랜잭션마다 완전히 다른 Postgres 클라이언트 백엔드에서 각 트랜잭션을 실행하게 될 수도 있습니다.
정말 멋지네요!
PreparedStatement 및 기타 고급 기능
우리는 커넥션 풀러의 아주 기본적인 것들만 살펴보았습니다. 심지어 저는 pgcat에서 했던 것처럼 최소한의 작동 환경을 갖추기 위해 다른 풀러들(pgbouncer, odyssey, pgpool-ii 등)이 모두 동일하게 작동하는지 시험해 보고 싶습니다.
다양한 풀러가 지원하는 (트랜잭션 모드에서의 PreparedStatement) 그리고 지원하지 않거나 할 수 없는 (트랜잭션 모드에서의 LISTEN/NOTIFY) 많은 고급 기능이 있으며, 이 글에서는 다루지 않았습니다.
세션 풀링(Session Pooling)은 어떤가요?
커넥션 풀러는 트랜잭션 풀링뿐만 아니라 몇 가지 유형의 풀링 모드를 지원할 수 있습니다. 세션 풀링과 문장 풀링(이 글에서는 다루지 않음)도 지원할 수 있습니다.
세션 풀링을 사용하면 각 클라이언트 연결이 단일 Postgres 클라이언트 백엔드에 매핑됩니다. Postgres에 직접 연결하는 것과 똑같습니다. 이는 LISTEN/NOTIFY와 같은 것들이 문제없이 작동한다는 것을 의미합니다. 다만 클라이언트 연결이 끝났을 때 Postgres 클라이언트 백엔드가 종료되도록 허용하는 대신, 풀러가 Postgres 클라이언트 백엔드를 유지하고 향후 클라이언트 연결을 위해 재사용한다는 점이 다릅니다.
세션 풀링은 연결 설정에 드는 지연 시간을 방지하는 데 도움이 되며, 심지어 클라이언트 연결 수를 확장하는 데도 도움이 될 수 있습니다. Postgres는 max_connections에 도달하면 즉시 연결을 끊어버리지만, 풀러는 설정 가능한 타임아웃까지 연결을 큐에 대기시킵니다. 트랜잭션 풀링만큼 클라이언트 연결 수를 확장하는 데 효과적이지는 않지만, 여전히 개선된 방식입니다.
기본적으로 풀러는 더 많은 연결을 처리할 수 있기 때문에 트랜잭션 풀링을 기본값으로 사용합니다. 사용자는 트랜잭션 풀링으로는 불가능한 기능이나 동작이 필요할 때 세션 풀링으로 전환합니다.
PostgreSQL 커넥션 풀링 FAQ
PostgreSQL 커넥션 풀러는 왜 사용하나요?
PostgreSQL은 연결된 클라이언트마다 별도의 백엔드 프로세스를 할당합니다. 커넥션 풀러는 여러 클라이언트 연결을 제한된 수의 백엔드 연결로 다중화해 리소스 경합과 연결 설정 지연을 줄이는 데 도움을 줍니다.
pgcat의 트랜잭션 풀링은 어떻게 작동하나요?
클라이언트는 트랜잭션을 실행하는 동안 풀의 PostgreSQL 백엔드 연결을 획득하고, 트랜잭션이 끝나면 이를 반납합니다. 따라서 같은 클라이언트라도 각 트랜잭션이 서로 다른 백엔드 PID에서 실행될 수 있습니다.
pgcat의 pool_size가 1이면 어떤 문제가 생길 수 있나요?
pool_size = 1에서는 사용할 수 있는 PostgreSQL 백엔드 연결이 하나뿐입니다. 한 클라이언트가 장기 실행 문장이나 대화형 트랜잭션으로 연결을 점유하면 다른 클라이언트가 연결을 얻지 못하고 오류를 받을 수 있습니다.
pool_size를 2로 늘리면 동작이 어떻게 달라지나요?
기존 백엔드 연결이 사용 중일 때 pgcat이 허용 범위 안에서 새 PostgreSQL 백엔드 연결을 열 수 있습니다. 이 글의 실습에서는 두 번째 클라이언트가 다른 PID의 백엔드를 사용해 쿼리를 계속 실행했습니다.
트랜잭션 풀링과 세션 풀링의 차이는 무엇인가요?
트랜잭션 풀링은 트랜잭션 단위로 백엔드 연결을 빌리고 반납합니다. 세션 풀링은 하나의 클라이언트 연결을 하나의 백엔드에 매핑하며, 클라이언트 연결이 끝난 뒤에도 풀러가 백엔드를 유지해 재사용합니다.
트랜잭션 풀링에서 LISTEN과 NOTIFY를 사용할 수 있나요?
이 글에서는 트랜잭션 모드에서 LISTEN/NOTIFY를 지원하기 어렵거나 사용할 수 없는 고급 기능의 예로 설명합니다. 이런 세션 종속 동작이 필요하다면 세션 풀링을 검토해야 합니다.