Postgres 논리적 복제 설정 가이드: PostgreSQL 17 실습과 동작 원리
필 이튼
2025년 1월 17일
Postgres 논리적 복제는 데이터 변경 사항을 게시자에서 구독자로 전달하는 기능입니다. 이 가이드에서는 PostgreSQL 17 소스 코드를 빌드하고 두 개의 Postgres 데이터베이스 사이에 게시(publication)와 구독(subscription)을 구성하는 방법을 단계별로 살펴봅니다.
또한 walsender, apply worker, pgoutput이 논리적 변경 사항을 전송하고 적용하는 내부 과정도 소스 코드와 로그를 통해 확인합니다.

Postgres 논리적 복제의 배경
Postgres는 처음에 물리적 복제를 구현했습니다. 물리적 복제는 데이터베이스 A에서 B로 디스크의 바이트 데이터를 전송하고, 데이터베이스 B가 이 바이트를 디스크에 그대로 기록하는 방식입니다. 하지만 동일한 Postgres 버전을 실행하는 인스턴스 간에서만 작동하며, 동일한 CPU 아키텍처와 페이지 크기 같은 운영 체제 설정도 요구한다는 제한이 있습니다.
논리적 복제는 삽입(insert), 수정(update), 삭제(delete), 트렁케이트(truncate) 같은 특정 메시지를 대부분 안정적인 형식으로 네트워크를 통해 복제합니다. 이를 통해 서로 다른 Postgres 버전을 실행하는 데이터베이스 간에 데이터를 복제할 수 있으며, 복제할 때 행이나 열을 필터링할 수도 있습니다.
그러나 논리적 복제는 물리적 복제보다 비교적 새롭게 도입된 기술이기 때문에 아직 성숙 단계에 있습니다. 예를 들어, 현재는 DDL 변경 사항을 논리적 복제를 통해 얻을 수 없습니다.
필자는 이전에 Postgres에서 기본적인 논리적 복제를 직접 설정해 본 적이 없었기 때문에, 이번 글에서 그 과정을 실습합니다.
PostgreSQL 17 실습 환경 설정
먼저 Postgres 17을 소스 코드에서 빌드합니다. 이후 논리적 복제가 작동하는 방식을 확인하기 위해 몇 가지 변경 사항을 적용할 예정이므로 필요한 과정입니다.
$ git clone https://github.com/postgres/postgres
$ cd postgres
$ git checkout REL_17_STABLE
$ ./configure --with-libxml --without-icu --prefix=$(pwd)/build --libdir=$(pwd)/build/lib --enable-cassert --enable-debug
$ make -j8
$ make install다음으로 두 개의 Postgres 인스턴스를 생성합니다.
$ ./build/bin/initdb testdb1$ ./build/bin/initdb testdb2
두 데이터베이스에 고유한 포트를 설정하고, 논리적 복제를 사용할 수 있도록 wal_level을 logical로 설정합니다.
$ printf '\nport = 6001\nwal_level = logical' >> testdb1/postgresql.conf
$ printf '\nport = 6002\nwal_level = logical' >> testdb2/postgresql.conf그런 다음 두 데이터베이스를 시작합니다.
$ ./build/bin/pg_ctl -D testdb1 -l logfile1 start
$ ./build/bin/pg_ctl -D testdb2 -l logfile2 startPostgres 논리적 복제 설정 방법
먼저 testdb1에 단일 테이블을 생성하고 몇 개의 행을 삽입합니다. 이 행들이 testdb2의 동일한 테이블에도 반영되도록 설정하겠습니다.
논리적 복제로는 DDL을 복제할 수 없으므로 CREATE TABLE 문은 두 데이터베이스에서 각각 실행해야 합니다.
$ psql -h localhost -p 6001 postgres -c "CREATE TABLE users (name TEXT PRIMARY KEY, age INT);"
CREATE TABLE
$ psql -h localhost -p 6002 postgres -c "CREATE TABLE users (name TEXT PRIMARY KEY, age INT);"
CREATE TABLE이제 testdb1(포트 6001)이 이 테이블의 변경 사항을 게시하도록 설정합니다. 이를 pub이라는 게시(publication)로 정의합니다.
$ psql -h localhost -p 6001 postgres -c "CREATE PUBLICATION pub FOR TABLE users;"
CREATE PUBLICATION이어서 testdb2(포트 6002)가 testdb1의 변경 사항을 구독하도록 설정합니다. 여기서 구독(subscription)은 testdb1에 생성된 pub 게시를 참조합니다.
$ psql -h localhost -p 6002 postgres -c "CREATE SUBSCRIPTION sub CONNECTION 'port=6001 dbname=postgres' PUBLICATION pub;"
NOTICE: created replication slot "sub" on publisher
CREATE SUBSCRIPTION현재 두 데이터베이스의 테이블은 비어 있습니다.
$ psql -h localhost -p 6001 postgres -c "SELECT * FROM users;"
name | age
-------+-----
(0 rows)
$ psql -h localhost -p 6002 postgres -c "SELECT * FROM users;"
name | age
-------+-----
(0 rows)이제 testdb1에 두 개의 행을 삽입합니다.
$ psql -h localhost -p 6001 postgres -c "INSERT INTO users VALUES ('deb', 12);"
INSERT 0 1
$ psql -h localhost -p 6001 postgres -c "INSERT INTO users VALUES ('kevin', 13);"
INSERT 0 1그런 다음 testdb2에서 테이블을 조회합니다.
$ psql -h localhost -p 6002 postgres -c "SELECT * FROM users;"
name | age
-------+-----
deb | 12
kevin | 13
(2 rows)testdb1에 삽입한 두 개의 행이 testdb2에도 반영되었습니다. 이제 백그라운드에서 논리적 복제가 어떻게 작동하는지 살펴보겠습니다.
Postgres 논리적 복제 아키텍처
Postgres의 프로세스 모델은 내부 동작을 이해하기 쉽게 구성되어 있습니다. 다음 프로세스 트리에서 게시 측과 구독 측에 생성된 논리적 복제 프로세스를 확인할 수 있습니다.
$ pstree -s postgres
-+= 00001 root /sbin/launchd
|-+= 87091 phil /Users/phil/edb/postgres17/build/bin/postgres -D testdb2
| |--= 87092 phil postgres: checkpointer
| |--= 87093 phil postgres: background writer
| |--= 87095 phil postgres: walwriter
| |--= 87096 phil postgres: autovacuum launcher
| |--= 87097 phil postgres: logical replication launcher
| \--= 89689 phil postgres: logical replication apply worker for subscription 16407
\-+= 87135 phil /Users/phil/edb/postgres17/build/bin/postgres -D testdb1
|--= 87136 phil postgres: checkpointer
|--= 87137 phil postgres: background writer
|--= 87139 phil postgres: walwriter
|--= 87140 phil postgres: autovacuum launcher
|--= 87141 phil postgres: logical replication launcher
\--= 89696 phil postgres: walsender phil postgres [local] START_REPLICATION게시 측의 walsender 프로세스
게시 측(Postgres testdb1)에서는 walsender라는 새로운 프로세스가 생성됩니다.
walsender라는 이름은 Postgres에서 물리적 복제가 먼저 구현되었던 역사적 배경의 흔적으로 보입니다. 물리적 복제에서는 보통 16MB 크기의 WAL(WAL Segment)을 한 번에 데이터베이스 간에 전송했습니다. 실제로 walsender.c 코드에서는 물리적 복제와 논리적 복제를 모두 처리합니다.
walsender 프로세스는 CREATE PUBLICATION 명령과 같이 게시(publication)가 구성될 때 시작됩니다. 각 게시마다 고유한 walsender 프로세스가 생성됩니다.
구독 측의 apply worker 프로세스
구독 측(Postgres testdb2)에서는 apply worker라는 새로운 프로세스가 생성됩니다.
apply worker의 소스 코드는 worker.c 파일에 있으며, 논리적 복제를 위해 설계된 코드입니다. 이 프로세스는 게시(Postgres testdb1) 데이터베이스에 연결하여 walsender 프로세스와 통신합니다.
apply worker 프로세스는 CREATE SUBSCRIPTION 명령과 같은 구독(subscription)이 구성될 때 시작됩니다. 각 구독마다 고유한 apply worker 프로세스가 생성됩니다.
논리적 복제 통신 프로토콜
apply worker와 walsender 사이의 통신은 논리적 복제 프로토콜(Logical Replication Protocol)을 통해 이루어집니다.
소스 코드를 이용한 실습
더 구체적으로 이해하기 위해 Postgres 소스 코드를 편집해 보겠습니다.
walsender프로세스에서INSERT변경 사항을 보낼 때 로그를 추가합니다.apply worker프로세스에서INSERT변경 사항을 받을 때 로그를 추가합니다.
전송 단계에 로그 추가하기
walsender.c 자체는 어떤 WAL 레코드도 처리하지 않습니다. WAL 레코드를 처리하는 것은 walsender 프로세스 내에서 실행되는 출력 플러그인(output plugin)입니다.
walsender와 apply worker 사이에 전송되는 WAL 레코드의 기본 형식은 pgoutput이며, 이는 pgoutput.c에 구현되어 있습니다. 이 형식은 libpqwalreceiver.c에서 기본값으로 설정됩니다.
일반적인 사용자라면 이 형식을 별도로 고려할 필요가 없습니다. CREATE SUBSCRIPTION 명령은 형식을 선택할 수 있는 옵션을 제공하지 않습니다. 기본 형식을 재정의하려면 pg_create_logical_replication_slot 함수를 호출하여 다른 출력 플러그인을 전달해야 합니다.
또한 CREATE SUBSCRIPTION에 새로운 슬롯을 생성하지 않고, pg_create_logical_replication_slot로 수동 생성한 슬롯을 사용하도록 지정해야 합니다. 출력 플러그인을 재정의할 필요가 없다면 pg_create_logical_replication_slot을 직접 호출할 필요도 없습니다.
각 출력 플러그인은 자체 _PG_output_plugin_init 구현에서 WAL 디코더를 위한 콜백을 설정합니다. 이 콜백 중 하나는 change_cb로, INSERT, UPDATE, DELETE를 처리합니다.
INSERT, UPDATE, DELETE를 디코딩해 구독자에게 논리적 변경 사항을 전송하는 과정을 확인하기 위해 pgoutput_change에 elog를 사용하여 로그 라인을 추가합니다.
$ diff --git a/src/backend/replication/pgoutput/pgoutput.c b/src/backend/replication/pgoutput/pgoutput.c
index 99518c6b6d..7719d5a622 100644
--- a/src/backend/replication/pgoutput/pgoutput.c
+++ b/src/backend/replication/pgoutput/pgoutput.c
@@ -1556,14 +1556,17 @@ pgoutput_change(LogicalDecodingContext *ctx, ReorderBufferTXN *txn,
switch (action)
{
case REORDER_BUFFER_CHANGE_INSERT:
+ elog(LOG, "SENDING INSERT!");
logicalrep_write_insert(ctx->out, xid, targetrel, new_slot,
data->binary, relentry->columns);
break;
case REORDER_BUFFER_CHANGE_UPDATE:
+ elog(LOG, "SENDING UPDATE!");
logicalrep_write_update(ctx->out, xid, targetrel, old_slot,
new_slot, data->binary, relentry->columns);
break;
case REORDER_BUFFER_CHANGE_DELETE:
+ elog(LOG, "SENDING DELETE!");
logicalrep_write_delete(ctx->out, xid, targetrel, old_slot,
data->binary, relentry->columns);
break;수신 단계에 로그 추가하기
apply worker 프로세스가 동작하는 구독 측은 더 간단합니다. 구독 작업자 프로세스에는 게시자가 보낸 각 메시지를 해석하는 루프가 있으며, INSERT는 메시지 유형 중 하나입니다.
작업자는 INSERT 메시지를 처리하기 위해 apply_handle_insert를 호출합니다. 구독자 측에서 INSERT, UPDATE, DELETE를 받을 때마다 이를 확인할 수 있도록 해당 메서드에 로그를 추가합니다.
또한 apply_handle_delete와 apply_handle_update 메서드에도 로그를 추가할 수 있습니다.
$ diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index d091a1dd27..44d921ba5a 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -2427,6 +2427,7 @@ apply_handle_insert(StringInfo s)
slot_fill_defaults(rel, estate, remoteslot);
MemoryContextSwitchTo(oldctx);
+ elog(LOG, "GOT AN INSERT!");
/* For a partitioned table, insert the tuple into a partition. */
if (rel->localrel->rd_rel->relkind == RELKIND_PARTITIONED_TABLE)
apply_handle_tuple_routing(edata,
@@ -2607,6 +2608,7 @@ apply_handle_update(StringInfo s)
has_oldtup ? &oldtup : &newtup);
MemoryContextSwitchTo(oldctx);
+ elog(LOG, "GOT AN UPDATE!");
/* For a partitioned table, apply update to correct partition. */
if (rel->localrel->rd_rel->relkind == RELKIND_PARTITIONED_TABLE)
apply_handle_tuple_routing(edata,
@@ -2762,6 +2764,7 @@ apply_handle_delete(StringInfo s)
slot_store_data(remoteslot, rel, &oldtup);
MemoryContextSwitchTo(oldctx);
+ elog(LOG, "GOT A DELETE!");
/* For a partitioned table, apply delete to correct partition. */
if (rel->localrel->rd_rel->relkind == RELKIND_PARTITIONED_TABLE)
apply_handle_tuple_routing(edata,그런 다음 로그를 확인할 수 있도록 make && make install로 Postgres를 다시 빌드합니다.
변경된 논리적 복제 코드 실행하기
두 데이터베이스를 종료하고 삭제한 다음 재생성하여 다시 시작합니다.
# Shut down both databases
$ ./build/bin/pg_ctl -D testdb1 -l logfile1 stop
waiting for server to shut down.... done
server stopped
$ ./build/bin/pg_ctl -D testdb2 -l logfile2 stop
waiting for server to shut down.... done
server stopped
# Delete both databases
$ rm -rf testdb1 testdb2 logfile1 logfile2
# Recreate both databases
$ ./build/bin/initdb testdb1
$ ./build/bin/initdb testdb2
$ printf 'port = 6002\nwal_level = logical' >> testdb2/postgresql.conf
$ printf 'port = 6001\nwal_level = logical' >> testdb1/postgresql.conf
# Start both databases
$./build/bin/pg_ctl -D testdb2 -l logfile2 start
waiting for server to start.... done
server started
$ ./build/bin/pg_ctl -D testdb1 -l logfile1 start
waiting for server to start.... done
server started이제 두 로그 파일을 모니터링하여 추가한 새로운 로그를 확인합니다.
$ tail -f logfile1 logfile2
==> logfile1 <==
2025-01-16 20:43:37.240 EST [93365] LOG: starting PostgreSQL 17.2 on aarch64-apple-darwin23.6.0, compiled by Apple clang version 16.0.0 (clang-1600.0.26.6), 64-bit
2025-01-16 20:43:37.245 EST [93365] LOG: listening on IPv6 address "::1", port 6001
2025-01-16 20:43:37.248 EST [93365] LOG: listening on IPv4 address "127.0.0.1", port 6001
2025-01-16 20:43:37.253 EST [93365] LOG: listening on Unix socket "/tmp/.s.PGSQL.6001"
2025-01-16 20:43:37.256 EST [93368] LOG: database system was shut down at 2025-01-16 20:42:08 EST
2025-01-16 20:43:37.263 EST [93365] LOG: database system is ready to accept connections
==> logfile2 <==
2025-01-16 20:43:33.662 EST [93334] LOG: starting PostgreSQL 17.2 on aarch64-apple-darwin23.6.0, compiled by Apple clang version 16.0.0 (clang-1600.0.26.6), 64-bit
2025-01-16 20:43:33.663 EST [93334] LOG: listening on IPv6 address "::1", port 6002
2025-01-16 20:43:33.663 EST [93334] LOG: listening on IPv4 address "127.0.0.1", port 6002
2025-01-16 20:43:33.664 EST [93334] LOG: listening on Unix socket "/tmp/.s.PGSQL.6002"
2025-01-16 20:43:33.666 EST [93337] LOG: database system was shut down at 2025-01-16 20:42:11 EST
2025-01-16 20:43:33.670 EST [93334] LOG: database system is ready to accept connections두 데이터베이스에 테이블을 생성합니다.
$ psql -h localhost -p 6001 postgres -c "CREATE TABLE users (name TEXT PRIMARY KEY, age INT);"
CREATE TABLE
$ psql -h localhost -p 6002 postgres -c "CREATE TABLE users (name TEXT PRIMARY KEY, age INT);"
CREATE TABLEtestdb1에서 게시를 설정하고 testdb2에서 구독을 설정합니다.
$ psql -h localhost -p 6001 postgres -c "CREATE PUBLICATION pub FOR TABLE users;"
CREATE PUBLICATION
$ psql -h localhost -p 6002 postgres -c "CREATE SUBSCRIPTION sub CONNECTION 'port=6001 dbname=postgres' PUBLICATION pub;"
NOTICE: created replication slot "sub" on publisher
CREATE SUBSCRIPTION이어서 testdb1에 데이터를 삽입하고 로그 파일을 확인합니다.
$ psql -h localhost -p 6001 postgres -c "INSERT INTO users VALUES ('deb', 12);"
INSERT 0 1tail 프로세스에는 다음과 같이 표시됩니다.
==> logfile1 <==
2025-01-16 20:46:02.764 EST [93544] LOG: SENDING INSERT!
2025-01-16 20:46:02.764 EST [93544] CONTEXT: slot "sub", output plugin "pgoutput", in the change callback, associated LSN 0/15037E0
2025-01-16 20:46:02.764 EST [93544] STATEMENT: START_REPLICATION SLOT "sub" LOGICAL 0/0 (proto_version '4', origin 'any', publication_names '"pub"')
==> logfile2 <==
2025-01-16 20:46:02.764 EST [93536] LOG: GOT AN INSERT!
2025-01-16 20:46:02.764 EST [93536] CONTEXT: processing remote data for replication origin "pg_16395" during message type "INSERT" for replication target relation "public.users" in transaction 751, finished at 0/15038C0logfile1은 게시자 testdb1에 해당하며 SENDING INSERT! 로그를 기록했습니다. logfile2는 구독자 testdb2에 해당하며 GOT AN INSERT! 로그를 기록했습니다.
Postgres 논리적 복제의 향후 과제
WAL(Write-Ahead Logging)을 디코딩하여 논리적 변경 사항을 복제하는 것은 하나의 사용 사례에 불과합니다. 앞으로의 글에서는 WAL을 목적에 맞게 디코딩하는 방법을 실제 코드를 통해 설명하고자 합니다. Rust로 작성된 pgrx 소스 코드에는 이와 관련된 좋은 예제가 포함되어 있는데, 필자가 생각하는 방향과 일치할 수도 있지만 아직 자세히 살펴보지는 않았습니다.
Postgres는 분산 데이터베이스입니다. 하지만 논리적 복제의 일부 기능은 아직 커뮤니티 버전의 Postgres에서 완전히 구현되지 않았으며, 특히 DDL 복제가 지원되지 않는 점이 큰 과제로 남아 있습니다. 필자가 작업 중인 독점 제품과 같은 일부 솔루션은 이러한 문제를 해결하기 위한 대안을 제공하지만, 이러한 인프라를 직접 구축하는 것도 가능한 방법입니다.
Postgres 논리적 복제 FAQ
Postgres 논리적 복제란 무엇인가요?
Postgres 논리적 복제는 INSERT, UPDATE, DELETE, TRUNCATE와 같은 변경 메시지를 네트워크를 통해 복제하는 방식입니다. 서로 다른 Postgres 버전 간에 데이터를 복제할 수 있으며, 복제할 행이나 열을 필터링할 수도 있습니다.
Postgres 논리적 복제를 사용하려면 어떤 설정이 필요한가요?
게시자와 구독자 역할을 수행할 Postgres 인스턴스가 필요하며, 논리적 복제를 사용할 수 있도록 wal_level을 logical로 설정해야 합니다. 이후 게시자에서 PUBLICATION을 만들고 구독자에서 해당 게시를 참조하는 SUBSCRIPTION을 생성합니다.
Postgres 논리적 복제로 DDL도 복제할 수 있나요?
이 글에서 다루는 Postgres 논리적 복제는 DDL 변경 사항을 복제하지 않습니다. 따라서 CREATE TABLE과 같은 DDL 문은 게시자와 구독자 데이터베이스에서 각각 실행해야 합니다.
논리적 복제에서 walsender와 apply worker는 어떤 역할을 하나요?
게시 측의 walsender 프로세스는 논리적 변경 사항을 구독자에게 전송합니다. 구독 측의 apply worker 프로세스는 게시 데이터베이스에 연결해 메시지를 받고 대상 테이블에 변경 사항을 적용합니다.
Postgres 논리적 복제의 기본 출력 플러그인은 무엇인가요?
walsender와 apply worker 사이에서 WAL 레코드를 전송할 때 사용하는 기본 형식은 pgoutput입니다. 일반적인 사용자는 CREATE SUBSCRIPTION을 이용할 때 이 형식을 별도로 지정할 필요가 없습니다.