pgcat을 이용한 Postgres 트랜잭션 풀링
Phil Eaton 2025년 10월 24일
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.toml
pgcat에 연결 방법과 위치를 알려주고 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 프롬프트를 수정해 보겠습니다.
SQL
postgres=# \set PROMPT1 '[client1] %/%R%x%# '
[client1] postgres=# \set PROMPT2 '[client1] %/%R%x%# '
[client1] postgres=#
이제 간단한 쿼리를 실행해 봅시다.
SQL
[client1] postgres=# SELECT 'hello world';
?column?
-------------
hello world
(1 row)
이제 우리 쿼리가 실제로 실행되고 있는 Postgres 클라이언트 백엔드의 PID를 확인해 보겠습니다.
SQL
[client1] postgres=# SELECT pg_backend_pid();
pg_backend_pid
----------------
19584
(1 row)
이제 세션 로컬(session-local) 설정, 즉 타임존을 변경해 보겠습니다. 저는 뉴욕에 있으므로 타임존은 America/New_York입니다.
SQL
[client1] postgres=# SHOW timezone;
TimeZone
------------------
America/New_York
(1 row)
이것을 America/Los_Angeles로 변경합니다.
SQL
[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=#
이것도 구별하기 위해 프롬프트를 설정합니다.
SQL
postgres=# \set PROMPT1 '[client2] %/%R%x%# '
[client2] postgres=# \set PROMPT2 '[client2] %/%R%x%# '
[client2] postgres=#
client2 내에서 타임존은 기본값인 America/New_York이어야 합니다.
SQL
[client2] postgres=# SHOW timezone;
TimeZone
------------------
America/New_York
(1 row)
이제 Postgres 클라이언트 백엔드 PID를 확인합니다.
SQL
[client2] postgres=# SELECT pg_backend_pid();
pg_backend_pid
----------------
19584
(1 row)
정말 멋지네요! Postgres에서는 이것이 불가능한 일입니다! 만약 우리가 Postgres에 직접 연결했다면 이 PID들은 고유해야 합니다. 클라이언트당 하나의 프로세스니까요. 하지만 우리는 여러 클라이언트 간에 동일한 프로세스를 공유하고 있습니다.
client1의 타임존 설정이 혹시 리셋되지 않았는지 다시 확인해 보세요.
SQL
[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초 동안 멈춰 있다가 반환될 것입니다.
SQL
[client2] postgres=# SELECT pg_sleep(15);
pg_sleep
----------
(1 row)
client2에서 Enter를 누른 직후, client1에서 SELECT 1을 실행해 보세요. client1은 몇 초간 멈춰 있다가 오류를 반환할 것입니다.
SQL
[client1] postgres=# SELECT 1;
FATAL: could not get connection from the pool - AllServersDown
하지만 client2의 sleep이 완료되고 문장이 실행되면, client1은 다시 명령을 실행할 수 있게 됩니다.
SQL
[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으로 대화형 트랜잭션을 시작합니다.
SQL
[client1] postgres=# BEGIN;
BEGIN
[client1] postgres=*#
그리고 client2에서 SELECT 1을 실행해 봅니다.
SQL
[client2] postgres=# select 1;
FATAL: could not get connection from the pool - AllServersDown
멈추고 실패합니다. client1의 트랜잭션에서 쿼리를 실행한 다음 커밋해 보겠습니다.
SQL
[client1] postgres=# BEGIN;
BEGIN
[client1] postgres=*# SELECT 1;
?column?
----------
1
(1 row)
[client1] postgres=*# COMMIT;
COMMIT
이제 client1의 트랜잭션이 완료되면 client2는 다시 Postgres 클라이언트 백엔드에 접근할 수 있습니다.
SQL
[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에서 대화형 트랜잭션을 시작합니다.
SQL
[client2] postgres=# BEGIN;
BEGIN
[client2] postgres=*#
클라이언트 백엔드 PID가 무엇인지 확인해 보겠습니다.
SQL
[client2] postgres=# BEGIN;
BEGIN
[client2] postgres=*# SELECT pg_backend_pid();
pg_backend_pid
----------------
20809
(1 row)
이전과 동일한 PID입니다. 이 트랜잭션을 열어 둡니다.
그런 다음 client1에서 간단한 SELECT 1을 실행합니다.
SQL
[client1] postgres=# SELECT 1;
?column?
----------
1
(1 row)
작동합니다! client1이 어떤 클라이언트 백엔드 PID에서 실행되고 있는지 봅시다.
SQL
[client1] postgres=# SELECT pg_backend_pid();
pg_backend_pid
----------------
21004
(1 row)
이전과 같지 않습니다! 한 클라이언트가 연결 사용을 막고 있었고 pool_size가 더 많은 연결을 허용했기 때문에, pgcat이 다른 클라이언트 연결이 사용할 수 있는 새로운 Postgres 클라이언트 백엔드를 연 것입니다.
이제 client2에서 트랜잭션을 커밋하고 해당 트랜잭션 이후에 어떤 Postgres 클라이언트 백엔드 PID를 갖는지 확인합니다.
SQL
[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)
바뀌었습니다! 다시 확인해 보죠. 그리고 또. 그리고 또.
SQL
[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에서도 마찬가지입니다.
SQL
[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에 도달하면 즉시 연결을 끊어버리지만, 풀러는 설정 가능한 타임아웃까지 연결을 큐에 대기시킵니다. 트랜잭션 풀링만큼 클라이언트 연결 수를 확장하는 데 효과적이지는 않지만, 여전히 개선된 방식입니다.
기본적으로 풀러는 더 많은 연결을 처리할 수 있기 때문에 트랜잭션 풀링을 기본값으로 사용합니다. 사용자는 트랜잭션 풀링으로는 불가능한 기능이나 동작이 필요할 때 세션 풀링으로 전환합니다.

