Postgres의 논리적 복제: 기본 사항

필 이튼
2025년 1월 17일

Postgres 논리적 복제 설정 가이드

이 글에서는 사용자와 개발자 관점에서 두 Postgres 데이터베이스 간의 논리적 복제 기본 사항을 살펴보겠습니다.

배경

Postgres는 처음에 물리적 복제를 구현했습니다. 물리적 복제는 데이터베이스 A에서 B로 디스크의 바이트 데이터를 전송하고, 데이터베이스 B는 이 바이트를 디스크에 그대로 기록하는 방식입니다. 하지만 물리적 복제는 동일한 Postgres 버전을 실행하는 인스턴스 간에서만 작동하며, 동일한 CPU 아키텍처(그리고 페이지 크기 같은 기타 운영 체제 설정)에서도만 가능하다는 제한이 있습니다.

논리적 복제는 삽입(insert), 수정(update), 삭제(delete), 트렁케이트(truncate) 같은 특정 메시지를 대부분 안정적인 형식으로 네트워크를 통해 복제합니다. 이를 통해 서로 다른 Postgres 버전을 실행하는 데이터베이스 간에 데이터를 복제할 수 있습니다. 또한, 복제할 때 행이나 열을 필터링할 수도 있습니다. 멋지죠!

그러나 논리적 복제는 물리적 복제보다 비교적 새롭게 도입된 기술이기 때문에 아직 성숙 단계에 있습니다. 예를 들어, 현재는 DDL 변경 사항을 논리적 복제를 통해 얻을 수 없습니다.

어쨌든, 저는 Postgres에서 기본적인 논리적 복제를 설정해 본 적이 없기 때문에, 이번 글에서는 그것을 직접 시도해보려고 합니다. 🙂

설정


먼저 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_levellogical로 설정합니다.

$ 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 start

논리적 복제 설정

간단한 작업을 진행해 보겠습니다. 먼저 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)

아주 좋습니다!

이제 백그라운드에서 어떻게 작동하는지 조금 들여다보겠습니다.

아키텍처

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

게시 측(Publishing Side)

게시 측(Postgres testdb1)에서는 새로운 프로세스가 생성됩니다. 이 프로세스는 walsender라고 불립니다.
walsender라는 이름은 Postgres에서 물리적 복제가 먼저 구현되었던 역사적 배경의 흔적으로 보입니다. 물리적 복제에서는 보통 16MB 크기의 WAL(WAL Segment)을 한 번에 데이터베이스 간에 전송했습니다. 실제로 walsender.c 코드에서는 물리적 복제와 논리적 복제 모두를 처리합니다.

walsender 프로세스는 CREATE PUBLICATION 명령과 같이 게시(publication)가 구성될 때 시작됩니다. 각 게시마다 고유한 walsender 프로세스가 생성됩니다.

구독 측(Subscribing Side)

구독 측(Postgres testdb2)에서는 apply worker라는 새로운 프로세스가 생성됩니다.
apply worker의 소스 코드는 worker.c 파일에 있으며, 이 코드는 논리적 복제를 위해 설계된 것입니다. 이 프로세스는 게시(Postgres testdb1) 데이터베이스에 연결하여 walsender 프로세스와 통신합니다.

apply worker 프로세스는 CREATE SUBSCRIPTION 명령과 같은 구독(subscription)이 구성될 때 시작됩니다. 각 구독마다 고유한 apply worker 프로세스가 생성됩니다.

통신 프로토콜

apply workerwalsender 간의 통신은 논리적 복제 프로토콜(Logical Replication Protocol)을 통해 이루어집니다.

실습 예제

이제 더 구체적으로 이해하기 위해 Postgres 소스 코드를 편집해 보겠습니다.

  • walsender 프로세스에서 INSERT 변경 사항을 보낼 때 로그를 추가하고,
  • apply worker 프로세스에서 INSERT 변경 사항을 받을 때 로그를 추가해 보겠습니다.

전송 단계에서의 연결(Hooking on Send)

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_changeelog를 사용하여 로그 라인을 추가할 것입니다.

$ 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;

수신 단계에서의 연결 (Hooking on Receive)

적용 작업자 프로세스의 구독 측은 더 간단합니다. 구독 작업자 프로세스에서 게시자가 보낸 각 메시지를 해석하는 루프가 있습니다. INSERT는 메시지 유형 중 하나입니다. 작업자는 INSERT 메시지를 처리하기 위해 apply_handle_insert를 호출합니다. 따라서 구독자 측에서 INSERT, UPDATE, DELETE를 받을 때마다 이를 확인하기 위해 해당 메서드에 로그를 추가할 수 있습니다. 또한 apply_handle_deleteapply_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 TABLE

testdb1에서 게시를 설정하고 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 1

tail 프로세스에서 다음과 같이 표시됩니다:

==> 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/15038C0

이제 완료되었습니다! logfile1은 게시자 testdb1에 해당하며, SENDING INSERT! 로그를 기록했습니다. logfile2는 구독자 testdb2에 해당하며, GOT AN INSERT! 로그를 기록했습니다.

앞으로의 계획

WAL(Write-Ahead Logging)을 디코딩하여 논리적 변경 사항을 복제하는 것은 하나의 사용 사례에 불과합니다. 앞으로의 글에서는 WAL을 우리의 목적에 맞게 디코딩하는 방법을 실제 코드를 통해 설명하고자 합니다. Rust로 작성된 pgrx 소스 코드에는 이와 관련된 좋은 예제가 포함되어 있는데, 제가 생각하는 방향과 일치할 수도 있지만, 아직 자세히 살펴보지는 않았습니다.

Postgres는 분산 데이터베이스입니다. 하지만 논리적 복제의 일부 기능은 아직 커뮤니티 버전의 Postgres에서 완전히 구현되지 않았습니다(특히 DDL 복제가 지원되지 않는 점이 큰 과제로 남아 있습니다). 제가 작업 중인 독점 제품과 같은 일부 솔루션은 이러한 문제를 해결하기 위한 대안을 제공합니다. 그러나 이러한 인프라를 직접 구축하는 것도 항상 가능한 방법입니다.

Postgres 논리적 복제 설정 가이드

본문: Logical replication in Postgres: Basics

이메일: salesinquiry@enterprisedb.com