본문으로 건너뛰기

"PostgreSQL 19" 태그로 연결된 11개 게시물개의 게시물이 있습니다.

모든 태그 보기

PG19 WAIT FOR 읽기 일관성

· 약 17분

PostgreSQL 19에는 standby가 지정한 WAL 위치까지 replay할 때까지 기다리는 WAIT FOR LSN 명령이 들어가요. primary에 쓴 직후 비동기 standby에서 읽으면 방금 쓴 값이 보이지 않을 수 있어요. 이 명령은 그 문제를 읽는 쪽에서만 해결하게 해 줘요. 이 글에서는 공식 문서와 commit을 바탕으로 문법과 제약을 확인했어요. primary 읽기, remote_apply, WAIT FOR LSN도 19beta4 primary 1대, standby 1대 구성에서 비교해 봤어요.

소재는 ClickHouse 블로그의 Read your writes: WAIT FOR in PostgreSQL 19(Gülçin Yıldırım Jelínek(귈친 이을드름 옐리넥), 2026-08-25)입니다. stale read 문제에서 시작해 문법을 설명하고, top-level 명령이어야 하는 이유로 이어지는 구성을 이 글에 활용했습니다. 원문에는 측정값이 없어 지연과 stale read 비율은 직접 측정했습니다. 원문 발행 뒤 9월에 추가된 잠금 제한과 LSN 선택 문제는 commit과 측정 결과를 바탕으로 따로 정리했습니다.

비동기 standby의 stale read​

streaming replication은 기본적으로 비동기입니다. primary는 커밋 WAL을 로컬에 flush하면 클라이언트에 성공을 반환합니다. standby가 그 WAL을 받아 replay하는 작업은 이후에 진행됩니다. 쓰기는 primary로 보내고 읽기는 standby로 보내는 구성에서는 사용자가 방금 저장한 값을 다음 화면에서 보지 못하는 일이 생깁니다. 공식 문서의 Read-Your-Writes Consistency 절도 이 구간을 "a short window where changes on the primary may not yet be visible on the standby"로 설명합니다.

PostgreSQL 18까지 사용하던 해법은 크게 두 가지였습니다. 하나는 방금 쓴 사용자의 읽기를 primary로 보내는 것입니다. 다른 하나는 synchronous_commit = remote_apply를 설정해 커밋이 synchronous standby의 replay까지 기다리게 하는 것입니다. synchronous_commit 문서는 이 설정을 "commits will wait until replies from the current synchronous standby(s) indicate they have received the commit record of the transaction and applied it"라고 설명합니다. 이어서 "This will cause much larger commit delays than previous settings since it waits for WAL replay"라고 적습니다. 첫 번째 방법은 읽기 부하를 standby로 분산한다는 목적을 일부 포기해야 합니다. 두 번째 방법은 쓰는 쪽에서 지연을 감수해야 합니다.

WAIT FOR LSN은 세 번째 선택지입니다. 쓰기는 비동기로 유지하고, 일관성이 필요한 읽기만 standby에서 해당 WAL 위치까지 기다립니다.

WAIT 문법​

공식 문서(19)의 synopsis는 다음과 같습니다.

WAIT FOR LSN 'lsn'
[ WITH ( option [, ...] ) ]

-- option:
-- MODE 'mode'
-- TIMEOUT 'timeout'
-- NO_THROW
-- mode:
-- standby_replay | standby_write | standby_flush | primary_flush

명령 이름은 개발 도중 한 번 정리됐습니다. 원래 문서 제목과 오류 메시지는 "WAIT FOR"였지만, commit 1a562a266(2026-09-14)에서 명령 이름을 WAIT로 통일했습니다. command tag가 이미 WAIT였고, FOR는 INSERT INTO의 INTO처럼 문장을 자연스러운 영어로 만드는 noise word라는 이유입니다. 문법은 그대로 WAIT FOR LSN이고, 문서 URL은 sql-wait-for.html에서 sql-wait.html로 바뀌었습니다. 이 글에서도 명령은 WAIT, 문법을 나타낼 때는 WAIT FOR LSN으로 씁니다.

MODE는 대기할 대상을 정합니다. 기본값은 standby_replay입니다.

mode기다리는 대상실행 위치
standby_replay (기본)standby에서 해당 LSN까지 replay 완료standby
standby_writestandby가 해당 LSN까지 WAL을 디스크에 write (flush 전)standby
standby_flushstandby가 해당 LSN까지 WAL을 flushstandby
primary_flushprimary가 해당 LSN까지 WAL을 flushprimary

read-your-writes에 사용하는 모드는 standby_replay입니다. write와 flush는 WAL이 standby에 도착했다는 뜻일 뿐이며, 쿼리에 보인다는 보장은 없습니다. 이 두 모드는 commit cba67b5b8부터 walreceiver 위치와 replay 위치 중 큰 값을 기준으로 사용합니다. base backup이나 archive에서 이미 replay한 WAL을 walreceiver가 다시 받을 때까지 기다리는 일을 방지하기 위한 수정입니다.

TIMEOUT은 밀리초 단위 숫자 또는 '100ms', '5s' 같은 단위 문자열을 받습니다. 지정하지 않거나 0이면 무기한 기다립니다. 문서에 따르면 0.5ms 이하 값은 반올림하면 0이 되므로 무기한 대기합니다.

반환값은 status 컬럼 하나이며, 값은 success, timeout, not in recovery 중 하나입니다. NO_THROW가 없으면 timeout과 not in recovery는 오류가 됩니다. 19beta4 standby에서 확인한 두 경우의 출력은 다음과 같습니다.

lab=# WAIT FOR LSN '0/062759E0' WITH (TIMEOUT '100ms');
ERROR: timed out while waiting for target LSN 0/062759E0 to be replayed; current standby_replay LSN 0/052759E0

lab=# WAIT FOR LSN '0/062759E0' WITH (TIMEOUT '100ms', NO_THROW);
status
---------
timeout

문서는 NO_THROW를 사용하는 경우도 설명합니다. 애플리케이션에서 timeout을 처리할 예정이면 NO_THROW로 상태 문자열을 받습니다. 다시 기다리거나 다른 서버로 읽기를 보내는 경우가 그렇습니다. 목표 LSN에 도달하지 못한 상태에서 진행하면 안 되는 흐름이라면 옵션을 빼고 오류로 중단합니다. NO_THROW로 받은 상태는 명시적 트랜잭션을 abort 상태로 만들지 않는다는 차이도 있습니다.

기다릴 LSN 고르기​

애플리케이션은 쓰기 커밋 직후 primary에서 LSN을 받아 보관했다가 standby에 전달합니다. 문서는 이 LSN을 클라이언트나 connection pooler가 보관하라고 안내합니다.

어떤 LSN을 받을지가 중요합니다. commit a203d2019(2026-09-17)에서 문서에 추가된 조건은 "the target LSN is at or after the end of the relevant write transaction's COMMIT record on the primary"입니다. standby가 비교하는 위치는 마지막으로 replay한 레코드의 끝입니다. 그래서 COMMIT 레코드 내부를 가리키는 LSN은 COMMIT이 아직 적용되지 않았는데도 조건을 만족할 수 있다는 설명입니다. 따라서 트랜잭션 안에서 RETURNING pg_current_wal_insert_lsn()처럼 커밋 전에 받은 값은 이 조건을 만족하지 않습니다. autocommit에서는 쓰기 문장이 끝난 뒤 별도 문장으로 LSN을 조회해야 합니다.

문서 예시는 이 LSN을 pg_current_wal_insert_lsn()으로 받습니다. synchronous_commit이 off여도 커밋 레코드 뒤를 가리키도록 insert 위치를 사용한다고 적혀 있습니다. 하지만 이 함수에는 페이지 경계 문제가 있습니다. 마지막 레코드가 8kB WAL 페이지 끝에서 정확히 끝나면 다음 insert 위치는 다음 페이지 헤더(24바이트) 뒤를 가리킵니다. standby의 replay 위치는 페이지 경계에서 멈춰 있으므로, 다른 WAL 레코드가 하나 더 생길 때까지 이 LSN에 도달하지 않습니다. 이 현상은 2026-09-22 pgsql-hackers의 049_wait_for_lsn 테스트 스레드에 보고됐습니다. 내부 함수 GetXLogInsertEndRecPtr()을 SQL 함수로 노출하자는 패치가 제안된 상태이며, REL_19_BETA4에는 반영되지 않았습니다.

아래 측정에서 이 현상을 확인했습니다. 뒤의 "단점과 제약" 절에 결과를 정리했습니다.

측정 환경​

  • macOS Docker Desktop VM (2 CPU, 약 12GB, Linux 6.8.0), 다른 작업과 공유하는 VM
  • postgres:19beta4 이미지 (PostgreSQL 19beta4, aarch64), docker-compose로 primary 1대, physical streaming standby 1대, 측정용 client 1대 구성
  • 컨테이너마다 CPU 1개, primary와 standby는 메모리 768MB
  • client는 Python 3.13과 psycopg 3.2.6 사용, 같은 Docker 네트워크에서 두 서버에 각각 연결
  • 행 하나(id = 1)를 UPDATE ... RETURNING v로 증가시키고 바로 읽어, 읽은 값이 방금 쓴 값보다 작으면 stale로 셉니다

지연 조건은 두 가지입니다. 하나는 지연을 주입하지 않은 상태입니다. 다른 하나는 primary에서 standby로 가는 replication 경로에만 tc netem delay 20ms를 적용한 상태입니다. client와 서버 사이 경로에는 지연이 없습니다. 20ms 조건에서 pg_stat_replication의 replay_lag는 약 20.6ms였습니다.

비동기 시나리오는 synchronous_standby_names를 비우고 세션에서 synchronous_commit = local로 실행했습니다. remote_apply 시나리오에서만 synchronous_standby_names = 'standby1'로 바꾸고 sync_state = sync를 확인한 뒤 실행했습니다.

세 가지 해법 비교​

지연을 주입하지 않은 상태에서 1,000회씩 반복한 결과입니다. 시간은 쓰기부터 읽기까지 한 번 왕복하는 데 걸린 전체 시간이고 단위는 ms입니다.

시나리오stalep50p95p99
A. primary 쓰기, standby 바로 읽기999 / 10000.290.741.77
B. primary 쓰기, primary 읽기0 / 10000.250.490.80
C. remote_apply, standby 읽기0 / 10000.440.611.79
D. 커밋 후 insert LSN으로 WAIT0 / 10000.420.601.12
E. 커밋 전 insert LSN으로 WAIT0 / 10000.532.285.22

같은 VM 안에서 지연을 전혀 주지 않았는데도 A는 1,000회 중 999회가 stale이었습니다. 앞선 500회 측정에서도 489회가 stale이었습니다. 커밋 응답 직후 standby에 보낸 SELECT가 0.1ms 안팎에 끝나므로, 그 안에 WAL 전송과 replay가 완료되지 않은 것으로 보입니다. 복제 지연이 거의 없는 환경에서도 "바로 읽기"에서는 stale이 발생하는 것이 일반적이라고 보는 편이 맞습니다.

D의 WAIT 단계 자체는 p50 0.13ms였습니다. 다만 D의 최댓값은 2,004ms였습니다. 1,000회 중 1회가 2초 timeout에 걸렸기 때문입니다. 해당 회차의 목표 LSN은 0/03132018로 페이지 시작에서 24바이트 뒤였습니다. standby replay 위치와 primary의 write, flush 위치는 모두 0/03132000이었습니다. 앞 절에서 설명한 페이지 경계 현상입니다.

E는 커밋 전에 받은 LSN이므로 문서의 조건을 만족하지 않습니다. 이번 측정에서는 stale이 한 번도 나오지 않았습니다. 제 추정으로는 UPDATE 레코드와 COMMIT 레코드가 짧은 간격으로 함께 전송되고 replay되어 그사이에 읽기가 실행되지 못한 것 같습니다. 다만 이 부분은 확인하지 못했습니다. 측정에서 나오지 않았다고 해서 안전한 방법이 되는 것은 아닙니다.

replication 경로에 20ms를 적용한 상태에서 300회씩 반복한 결과입니다. F는 insert 위치 대신 pg_current_wal_lsn()(write 위치)을 받은 경우입니다.

시나리오stale쓰기 p50전체 p50전체 p95전체 p99
A. standby 바로 읽기300 / 3000.180.230.360.73
B. primary 읽기0 / 3000.160.210.350.50
C. remote_apply0 / 30022.9023.2625.5627.48
D. insert LSN으로 WAIT0 / 3000.4523.1825.6232.38
F. write LSN으로 WAIT0 / 3000.4823.2925.4726.71

C와 D의 전체 시간은 거의 같습니다. 둘 다 결국 standby가 replay할 때까지 기다리기 때문입니다. 차이는 대기하는 주체에 있습니다. C는 커밋에 22.9ms가 걸렸습니다. D와 F는 커밋이 0.5ms 안에 끝난 뒤 읽는 쪽에서 22ms를 기다렸습니다.

이 차이가 처리량에 어떻게 나타나는지 같은 20ms 조건에서 pgbench(-N, 클라이언트 4개, 15초)로 확인했습니다.

synchronous_committps평균 지연 (ms)
local4,9190.81
remote_apply17123.43

remote_apply는 세션이나 트랜잭션 단위로도 지정됩니다. 일관성이 필요한 쓰기에만 켜면 이 비용이 줄어듭니다. 그래도 이를 켠 트랜잭션은 replication 경로의 왕복 시간만큼 커밋이 늦어지고, 그동안 행 잠금도 유지됩니다.

장점​

WAIT의 가장 큰 장점은 비용을 읽는 쪽으로 옮긴다는 점입니다. 쓰기 경로는 비동기 상태를 유지하므로 쓰기 처리량과 커밋 지연이 standby 상태에 영향을 받지 않습니다. standby가 느려지거나 멈춰도 해당 standby에서 기다리는 읽기만 영향을 받습니다. TIMEOUT과 NO_THROW를 사용하면 해당 읽기를 primary로 돌릴지도 애플리케이션이 판단합니다.

remote_apply는 standby가 응답하지 않으면 커밋이 멈춥니다. 확인을 위해 synchronous_standby_names = 'standby1' 상태에서 standby 컨테이너를 docker pause로 멈췄습니다. 그리고 statement_timeout = '3s'를 설정한 세션에서 remote_apply UPDATE를 실행했습니다. 3초가 지나도 끝나지 않았습니다. 68초 시점에 pg_stat_activity의 wait_event는 SyncRep였습니다. pg_cancel_backend()로 취소하자 다음 경고와 함께 반환됐습니다.

WARNING: canceling wait for synchronous replication due to user request
DETAIL: The transaction has already committed locally, but might not have been replicated to the standby.

autocommit 문장에서는 statement_timeout이 이 대기에 적용되지 않았습니다. finish_xact_command()가 CommitTransactionCommand() 전에 disable_statement_timeout()을 호출하기 때문으로 보입니다(src/backend/tcop/postgres.c, REL_19_BETA4). 코드를 읽고 추정한 내용이며 다른 경로까지 확인하지는 않았습니다.

remote_apply가 보장하는 대상은 "current synchronous standby(s)"뿐이라는 점도 다릅니다. 읽기 standby가 여러 대이고 그중 일부만 synchronous라면 나머지 standby에서는 여전히 stale read가 발생합니다. WAIT는 읽기를 받은 standby에서 직접 기다리므로 standby 수와 관계없이 같은 방식으로 동작합니다.

단점과 제약​

애플리케이션의 LSN 전달​

WAIT는 SQL 한 줄이지만, 그 전후 과정은 애플리케이션이 처리해야 합니다. 쓰기 뒤에 LSN을 조회하고 세션이나 요청 컨텍스트에 보관한 다음 읽기 standby에 넘겨야 합니다. 읽기 경로와 쓰기 경로가 서로 다른 서비스로 분리되어 있다면 LSN을 전달할 통로도 만들어야 합니다. primary 읽기나 remote_apply는 애플리케이션 코드 변경이 거의 없다는 점에서 여전히 단순합니다.

페이지 경계의 insert LSN​

pg_current_wal_insert_lsn()의 페이지 경계 현상을 별도로 재현했습니다. primary에 다른 부하가 없는 상태에서 pg_logical_emit_message()로 WAL을 채워 마지막 레코드가 8kB 경계에서 끝나도록 만들었습니다. 그런 다음 두 LSN으로 각각 WAIT를 실행했습니다.

시도pg_current_wal_lsn() 대기pg_current_wal_insert_lsn() 대기대기를 끝낸 다음 레코드
10.3ms12,345ms다른 트랜잭션의 HOT_UPDATE, COMMIT
20.4ms6,690msRUNNING_XACTS
30.4ms19,432msCHECKPOINT_REDO, RUNNING_XACTS

write 위치를 기준으로 기다리면 즉시 끝났습니다. insert 위치를 기준으로 기다리면 전혀 관계없는 WAL 레코드가 생길 때까지 6초에서 19초를 기다렸습니다. 쓰기가 계속 발생하는 서버라면 다음 레코드가 금방 생성되어 이 현상이 드러나지 않을 것입니다. 그러나 쓰기가 드문 서버에서는 TIMEOUT이 짧으면 timeout이 발생하고, TIMEOUT이 없으면 그만큼 읽기가 늦어집니다. 앞의 1,000회 측정에서 발생한 2초 timeout 1건도 같은 원인이었습니다.

pg_current_wal_lsn()은 페이지 헤더를 건너뛰지 않습니다. 하지만 synchronous_commit = off이면 커밋이 반환된 시점에 write 위치가 아직 COMMIT 레코드 끝에 도달하지 않았을 가능성이 있습니다. 따라서 어떤 함수를 사용할지는 synchronous_commit 설정에 따라 달라집니다. on이나 local처럼 커밋이 로컬 flush를 기다리는 설정에서는 pg_current_wal_lsn()이 조건을 만족합니다. off를 사용하는 트랜잭션에서는 insert 위치를 사용하되 TIMEOUT을 반드시 지정하는 편이 안전합니다. 이 판단은 문서와 제 측정을 종합한 것이며, 공식 문서가 직접 권고하는 내용은 아닙니다.

snapshot 보유 시 실행 불가​

WAIT가 함수가 아닌 명령인 이유가 여기에 있습니다. 최초 구현 commit 447aae13b는 다음과 같이 설명합니다.

WAIT FOR needs to wait without any snapshot held. Otherwise, the snapshot could prevent the replay of WAL records, implying a kind of self-deadlock.

snapshot을 가진 세션이 replay를 기다리는 동안 replay가 해당 snapshot 때문에 멈추면 서로 기다리는 상태가 됩니다. 함수와 프로시저는 snapshot을 가진 상태에서 실행되므로 이 기능을 포함할 수 없다는 것이 commit의 설명입니다. 이후 commit a8b61c23c(2026-04-13)는 함수, 프로시저, DO 블록 안에서의 실행을 명시적으로 막았습니다.

이 제약에 관한 문서 표현은 원문 발행 뒤 바뀌었습니다. 원문은 "READ COMMITTED보다 높은 격리 수준의 트랜잭션에서는 실행할 수 없다"는 취지로 설명합니다. 그러나 commit 6fe990f34(2026-09-18)는 이것이 격리 수준 전체에 대한 금지가 아니라고 정정했습니다. 코드가 검사하는 조건은 "현재 트랜잭션이 snapshot을 갖고 있는가"입니다. REPEATABLE READ 트랜잭션은 첫 쿼리 전까지 snapshot이 없으므로 그 전에는 WAIT를 실행해도 됩니다. 반대로 READ COMMITTED여도 cursor가 열려 있으면 실행할 수 없습니다. 19beta4 standby에서 확인한 결과는 다음과 같습니다.

상황결과
REPEATABLE READ, WAIT 후 SELECTsuccess
REPEATABLE READ, SELECT 후 WAIT오류 (snapshot 보유, 격리 수준 DETAIL 포함)
READ COMMITTED, cursor 연 뒤 WAIT오류 (snapshot 보유, DETAIL 없음)
DO 블록 안 EXECUTE 'WAIT ...'오류 (top-level 전용)
ERROR: WAIT cannot be executed while the current transaction holds a snapshot
DETAIL: This transaction runs at an isolation level higher than READ COMMITTED, so it holds a snapshot from its first query until it ends.

REPEATABLE READ에서 WAIT를 먼저 실행하고 첫 SELECT에서 snapshot을 얻으면 해당 트랜잭션 전체가 목표 LSN 이후의 데이터를 봅니다. 여러 쿼리에서 같은 시점의 데이터를 읽어야 하는 화면이라면 이 순서가 적합합니다.

잠금 보유 시 대기 거부​

snapshot이 없어도 잠금이 남아 있으면 같은 순환이 발생합니다. commit f7c092917(2026-09-12)의 설명은 다음과 같습니다. standby의 startup process가 replay 중에 이 세션이 가진 잠금을 필요로 하는 경우가 있습니다. 이때 startup은 세션의 잠금 해제를 기다리고 세션은 startup의 replay를 기다립니다. WAIT의 대기는 잠금 대기로 등록되지 않아 deadlock detector가 이 순환을 감지하지 못합니다. standby conflict 지연이 무제한이면 이 상태가 무기한 지속될 수 있습니다. 이 commit 이후에는 목표 LSN에 아직 도달하지 않았고 세션이 heavyweight lock을 하나라도 가지고 있으면 WAIT를 거부합니다. 19beta4 발표문의 "Several fixes for the new WAIT FOR command, including a deadlock and clearer reporting of isolation-level errors"에서 deadlock 부분은 날짜로 보아 이 수정을 가리키는 것으로 보입니다.

READ COMMITTED에서도 트랜잭션 안에서 앞선 SELECT가 획득한 AccessShareLock은 트랜잭션이 끝날 때까지 남습니다. 따라서 다음 순서는 거부됩니다.

lab=# BEGIN;
lab=*# SELECT v FROM t WHERE id = 1;
lab=*# WAIT FOR LSN '0/062759E0' WITH (TIMEOUT '100ms', NO_THROW);
ERROR: cannot wait for a standby LSN while holding locks
DETAIL: This session holds a lock on relation 16389 of database 16384, which could make recovery wait for this session while this session waits for recovery.
HINT: Release the locks, or execute WAIT before acquiring them.

NO_THROW를 지정해도 이 오류는 그대로 발생합니다. 문서는 NO_THROW가 timeout과 not in recovery의 보고 방식만 바꾼다고 명시합니다. 세션 단위 advisory lock(pg_advisory_lock(42))을 획득한 상태에서도 같은 오류가 발생했습니다. 목표 LSN에 이미 도달한 경우에는 잠금이 있어도 즉시 success가 반환됐습니다. 문서에서 권하는 순서는 트랜잭션 밖에서, 또는 트랜잭션의 첫 문장으로 WAIT를 실행한 뒤 읽기 쿼리를 실행하는 것입니다.

timeline과 recovery conflict​

WAIT는 LSN 숫자만 비교합니다. 문서는 cascading standby의 upstream이 promote되어 timeline이 바뀐 경우에도 숫자만 넘으면 success를 반환한다고 명시합니다. timeline 확인이 필요하면 애플리케이션에서 직접 확인하라고 안내합니다. standby 자신이 기다리는 도중 promote되면 not in recovery가 반환되거나 오류가 발생합니다. 이 두 경우는 이번에 재현하지 않았습니다.

standby의 recovery conflict 때문에 WAIT 세션이 종료될 수도 있습니다. 문서는 tablespace drop replay처럼 모든 backend를 종료하는 경우를 예로 들며, 재시도나 대체 경로를 준비하라고 안내합니다.

점검 쿼리​

WAIT를 도입하기 전후에 확인할 쿼리입니다. 앞의 두 개는 이번 환경에서 실행해 확인했습니다. 세 번째의 wait event 이름은 소스의 wait_event_names.txt(REL_19_BETA4)를 기준으로 적었으며, 실제 대기 중인 세션에서 조회해 보지는 않았습니다.

primary에서 standby별 지연을 확인합니다. 20ms 조건에서 replay_lag는 약 20.6ms였고, WAIT 대기 시간 p50은 22.0ms로 서로 비슷했습니다.

SELECT application_name, sync_state,
write_lag, flush_lag, replay_lag,
pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) AS replay_behind_bytes
FROM pg_stat_replication;

standby에서 현재 replay 위치를 확인합니다. 문서에 따르면 standby_replay 모드가 성공하면 pg_last_wal_replay_lsn()이 목표 LSN 이상이 됩니다.

SELECT pg_last_wal_receive_lsn(),
pg_last_wal_replay_lsn(),
now() - pg_last_xact_replay_timestamp() AS since_last_replayed_xact;

standby에서 WAIT로 대기하는 세션을 찾습니다. 소스에 따르면 wait event는 WaitForWalReplay, WaitForWalWrite, WaitForWalFlush입니다.

SELECT pid, application_name, wait_event, now() - query_start AS waiting, query
FROM pg_stat_activity
WHERE wait_event IN ('WaitForWalReplay', 'WaitForWalWrite', 'WaitForWalFlush');

버전별 차이​

WAIT는 PostgreSQL 19에서 처음 추가된 명령입니다. remote_apply는 9.6부터 있었습니다. 18 이하에서 standby read-your-writes를 보장하려면 primary 읽기, remote_apply, 또는 애플리케이션에서 standby의 pg_last_wal_replay_lsn()을 반복 조회하는 방법을 사용해야 했습니다. 반복 조회 방식은 이번에 측정하지 않았습니다. 2020년 PostgreSQL 13 개발 주기에도 트랜잭션 시작 시 LSN을 기다리는 기능이 commit(0f5ca02f5)됐지만, 다음 날 revert(1aac32df8)된 적이 있습니다.

19 개발 주기의 주요 commit은 다음과 같습니다. 모두 REL_19_BETA4 태그(2026-09-21 stamp)에 포함돼 있습니다.

날짜(UTC)commit내용
2025-11-05447aae13bWAIT FOR 명령 추가
2026-01-0549a181b5dMODE 옵션 추가 (write, flush, primary_flush)
2026-04-0610484c2ccrecovery conflict로 중단될 수 있음을 문서화
2026-04-13a8b61c23c함수, 프로시저, DO 블록 안 실행 금지
2026-05-03cba67b5b8write, flush 모드의 기준에 replay 위치 반영
2026-05-0321d290161timeline을 구분하지 않는다는 점 문서화
2026-09-12f7c092917잠금 보유 시 standby 모드 대기 거부
2026-09-141a562a266문서와 오류 메시지의 명령 이름을 WAIT로 변경
2026-09-14f260c03bctimeout overflow 수정
2026-09-17a203d2019read-your-writes에 쓸 LSN 조건 문서화
2026-09-186fe990f34snapshot 제약 설명과 오류 메시지 정정

베타 기간에 문서 표현과 제약이 여러 번 바뀌었습니다. 페이지 경계 문제처럼 논의 중인 사안도 있습니다. 따라서 도입 시점에는 GA 버전의 문서와 릴리스 노트를 다시 확인하는 것이 좋습니다.

측정 결과 remote_apply와 WAIT의 전체 지연은 거의 같았어요. 차이는 그 지연을 쓰기와 읽기 중 어디에 두느냐였어요. 기다릴 LSN을 어떤 함수로 받을지는 GA 전까지 계속 지켜볼 생각이에요.

참고​

PG19 REPACK 온라인 재구성

· 약 15분

PostgreSQL 19에 REPACK 명령이 추가됐고, CONCURRENTLY 옵션을 사용하면 읽기와 쓰기를 막지 않고 테이블을 다시 쓸 수 있어요. 지금까지 운영 중 bloat 정리는 확장 도구인 pg_repack에 맡기는 경우가 많았는데, 이제 코어가 그 역할을 맡게 됐어요. 이 글에서는 공식 개발 문서와 commit으로 동작을 확인하고, 19beta4 컨테이너에서 VACUUM FULL, REPACK (CONCURRENTLY), pg_repack을 같은 부하 조건에서 비교해 봤어요.

VACUUM FULL과 CLUSTER, 그리고 pg_repack​

PostgreSQL 18까지 테이블 파일 전체를 다시 쓰는 명령은 두 가지였습니다. VACUUM FULL은 dead tuple을 제외한 행만 새 파일로 옮기고, CLUSTER는 같은 작업을 하면서 지정한 인덱스 순서로 행을 정렬합니다. 두 명령 모두 작업하는 동안 ACCESS EXCLUSIVE 잠금을 유지하므로 해당 테이블에는 SELECT도 들어가지 못합니다.

이름도 혼란스러웠습니다. VACUUM FULL은 이름에 vacuum이 들어가지만 일반 vacuum과 전혀 다른 작업을 합니다. CLUSTER는 IT 전반과 PostgreSQL 안에서 이미 "database cluster"라는 뜻으로 쓰이는 단어입니다. REPACK을 도입한 commit ac58465e0의 메시지도 이 두 가지를 이유로 제시합니다.

REPACK absorbs the functionality of VACUUM FULL and CLUSTER in a single command. Because this functionality is completely different from regular VACUUM, having it separate from VACUUM makes it easier for users to understand; as for CLUSTER, the term is heavily overloaded in the IT world and even in Postgres itself, so it's good that we can avoid it.

운영 중에 잠금 없이 테이블을 재구성하려면 지금까지는 코어 외부의 도구를 써야 했습니다. 대표적인 도구가 pg_repack입니다. 트리거와 로그 테이블로 변경 사항을 추적하면서 새 테이블을 만든 뒤 마지막에 교체합니다. dbalog의 VACUUM과 autovacuum 장과 인덱스 BLOAT와 REINDEX CONCURRENTLY 장도 운영 중 VACUUM FULL 대신 pg_repack을 쓰라고 안내합니다. PostgreSQL 19의 REPACK (CONCURRENTLY)는 이 역할을 코어 기능으로 제공하려는 시도입니다. 설계와 코드 대부분은 Antonin Houska(안토닌 하우스카)가 만든 서드파티 확장 pg_squeeze에 기반한다고 commit 28d534e2a에 적혀 있습니다.

REPACK 문법​

공식 개발 문서의 synopsis는 다음과 같습니다.

REPACK [ ( option [, ...] ) ] [ table_and_columns [ USING INDEX [ index_name ] ] ]
REPACK [ ( option [, ...] ) ] USING INDEX

-- option:
-- VERBOSE [ boolean ]
-- ANALYZE [ boolean ]
-- CONCURRENTLY [ boolean ]

기존 명령과의 대응은 단순합니다. USING INDEX가 없으면 VACUUM FULL, 있으면 CLUSTER입니다.

REPACK t; -- VACUUM FULL t 와 같은 재작성
REPACK t USING INDEX t_pkey; -- CLUSTER t USING t_pkey 와 같은 재작성
REPACK (CONCURRENTLY) t; -- 교체 순간에만 ACCESS EXCLUSIVE
REPACK (CONCURRENTLY, ANALYZE) t; -- 재작성 후 ANALYZE까지

VACUUM FULL과 CLUSTER는 호환성을 위해 남아 있고, 문서에서는 비중이 줄었습니다. 19beta4에서 CLUSTER t USING t_pkey와 VACUUM FULL은 그대로 동작했습니다. 진행 상황을 보여 주는 뷰로 pg_stat_progress_repack이 새로 생겼습니다. VACUUM FULL로 실행해도 이 뷰의 command 컬럼에는 VACUUM FULL로 표시됐습니다. 기존 pg_stat_progress_cluster는 하위 호환용으로만 남아 있다고 문서에 적혀 있습니다.

테이블 이름 없이 REPACK만 실행하면 현재 사용자가 MAINTAIN 권한을 가진 모든 테이블과 materialized view를 처리합니다. REPACK USING INDEX만 실행하면 clustering 인덱스가 지정된 테이블이 대상입니다.

CONCURRENTLY 동작 구조​

CONCURRENTLY 없이 실행하면 VACUUM FULL과 같은 방식으로 동작해 처음부터 끝까지 ACCESS EXCLUSIVE를 유지합니다. CONCURRENTLY를 사용하면 흐름이 달라집니다. commit 28d534e2a의 설명을 정리하면 다음과 같습니다.

  1. 원본 테이블에 SHARE UPDATE EXCLUSIVE만 획득합니다. 일반 vacuum과 같은 잠금이므로 DML과 충돌하지 않습니다.
  2. 임시 logical replication slot을 만들고, 그 시점의 MVCC snapshot을 기준으로 기존 행을 새 파일에 복사합니다.
  3. 별도의 background worker(REPACK decoding worker)가 slot에서 logical decoding을 진행하며 복사 중에 발생한 변경을 수집합니다.
  4. 새 파일에 인덱스를 만든 뒤, 수집한 변경을 새 파일에 다시 적용합니다(catch-up).
  5. 마지막으로 ACCESS EXCLUSIVE를 획득해 남은 변경을 적용하고 relfilenode를 교체합니다.

19beta4에서 0.25초 간격으로 pg_stat_activity, pg_locks, pg_replication_slots를 샘플링하면 이 흐름을 그대로 확인할 수 있습니다. 실행 중에는 pg_repack_735처럼 pg_repack_ 접두사가 붙은 temporary logical slot이 생겼고, 명령이 끝나면 사라졌습니다. 확장 도구 pg_repack과 이름이 겹칩니다. 모니터링 쿼리에서 slot 이름으로 구분할 때 혼동하지 않도록 주의해야 합니다. 백엔드 목록에서는 backend_type이 REPACK decoding worker인 프로세스가 별도로 실행되고 있었습니다.

wal_level도 달라집니다. 설정값이 기본값 replica인 상태에서 REPACK (CONCURRENTLY)를 실행하자 effective_wal_level이 실행 중에는 logical로 올라갔다가, 끝난 뒤에는 replica로 돌아왔습니다. REPACK도 PostgreSQL 19의 동적 wal_level 동작(PG19 동적 wal_level 참고)을 그대로 따릅니다. 이는 명령 실행 중에 클러스터 전체의 DML이 logical decoding용 정보를 WAL에 추가로 기록한다는 뜻이기도 합니다. wal_level이 replica보다 낮으면 실행할 수 없습니다.

측정 환경​

측정은 macOS 호스트의 Docker VM(2 CPU, 약 12GB 메모리, Linux 6.8 커널)에서 postgres:19beta4 이미지로 진행했습니다. 컨테이너는 --cpus 2 --memory 2g로 제한했고, shared_buffers=256MB, max_wal_size=4GB, checkpoint_timeout=30min만 변경했습니다. 이 VM은 다른 작업과 공유되고 있어 TPS 절대값은 회차마다 크게 달라졌습니다. 아래 수치는 방식 간 경향을 확인하는 용도로만 읽어 주세요.

테스트 테이블은 다음과 같이 만들었습니다. 400만 행을 넣고 75%를 지운 뒤 일반 VACUUM만 실행했습니다. 파일 크기는 그대로이고 살아 있는 행은 100만 개인 상태입니다.

CREATE TABLE t (id int PRIMARY KEY, v int NOT NULL DEFAULT 0, pad text NOT NULL)
WITH (autovacuum_enabled = off);
INSERT INTO t SELECT g, 0, repeat(md5(g::text), 3) FROM generate_series(1, 4000000) g;
DELETE FROM t WHERE id % 4 <> 0;
VACUUM (ANALYZE) t;

재작성 전 heap은 539MB, 인덱스는 86MB였고, 세 방식 모두 재작성 후 heap 137~140MB, 인덱스 21MB로 줄었습니다. 부하는 pgbench 4개 클라이언트가 남은 행을 무작위로 UPDATE하고 SELECT하는 스크립트를 45초 동안 실행하는 방식으로 생성했습니다. 시작 10초 뒤에 각 명령을 실행했습니다. 방식마다 테이블을 새로 만들어 3회씩 반복했습니다. pg_repack은 PGDG apt 저장소의 postgresql-19-repack 1.5.3 패키지를 컨테이너에 설치해 측정했습니다. pg_repack 공식 문서에는 지원 범위가 PostgreSQL 18까지로 적혀 있지만, 이 패키지는 19beta4에서 설치하고 실행할 수 있었습니다.

잠금 범위​

pgbench 부하 중 세 방식의 결과입니다. 값은 3회 측정값을 순서대로 적었습니다.

방식소요 시간(초)pgbench 최대 지연(ms)명령 구간 WAL(MB)
VACUUM FULL1.24 / 1.24 / 1.521,208 / 1,216 / 1,481154 / 154 / 154
REPACK (CONCURRENTLY)5.30 / 1.47 / 1.43132 / 63 / 122181 / 178 / 176
pg_repack 1.5.32.54 / 2.35 / 1.53167 / 102 / 46181 / 180 / 179

VACUUM FULL은 소요 시간이 가장 짧았지만, pgbench의 최대 지연은 명령 소요 시간과 거의 같았습니다. 샘플링 결과에서도 VACUUM FULL이 ACCESS EXCLUSIVE를 보유하는 동안 pgbench 세션 4개가 모두 Lock 대기 상태였습니다. 3회차에는 1초 동안 처리한 트랜잭션이 32건까지 줄어든 구간이 있었습니다. 또한 VACUUM FULL이 ACCESS EXCLUSIVE를 기다리는 동안 pgbench 세션 2개가 그 뒤에서 대기하는 샘플도 관찰됐습니다.

REPACK (CONCURRENTLY)는 전체 샘플 구간에서 SHARE UPDATE EXCLUSIVE만 보였고, Lock 대기 중인 pgbench 세션은 0개였습니다. 교체 순간의 ACCESS EXCLUSIVE는 0.25초 간격의 샘플에서 한 번도 관찰되지 않을 만큼 짧았습니다. 최대 지연 63~132ms는 잠금 대기보다 2 CPU VM에서 복사 작업과 CPU를 나눠 쓴 영향으로 보입니다. 다만 이번 측정으로 원인을 분리하지는 못했습니다. 1회차만 5.30초가 걸렸는데, 샘플상 seq scanning heap 단계가 다른 회차보다 길었습니다. 공유 VM이라 이 차이의 원인도 확인하지 못했습니다.

pg_repack도 pgbench를 멈추지 않았고, 최대 지연은 REPACK (CONCURRENTLY)와 비슷한 범위였습니다. pg_repack 문서에 따르면 초기 설정과 마지막 교체 단계에서 짧게 ACCESS EXCLUSIVE를 획득하고, 나머지 구간에서는 SHARE UPDATE EXCLUSIVE를 유지합니다. 이번 샘플링에서는 복사 세션의 AccessShareLock만 관찰됐습니다.

WAL과 디스크 공간​

부하 중 WAL 양에는 pgbench가 생성한 WAL이 섞여 있습니다. 명령 없이 pgbench만 실행한 구간은 3초에 약 23MB였습니다. 명령 자체의 WAL을 확인하려고 부하 없이 한 번씩 더 실행하고 pg_waldump --stats로 구성을 확인했습니다. 크기는 pg_size_pretty()와 같은 1024 단위로 적었습니다.

방식명령 구간 WAL주요 레코드
VACUUM FULL154MBXLOG(full page image) 628건, 약 153MB
REPACK (CONCURRENTLY)173MBHeap 1,000,067건, 약 153MB / XLOG FPI 약 19MB
pg_repack 1.5.3173MBHeap 1,000,169건, 약 153MB / XLOG FPI 약 19MB

VACUUM FULL은 새 heap을 페이지 이미지 단위로 WAL에 기록했습니다. 반면 REPACK (CONCURRENTLY)는 이번 측정에서 행 100만 개를 Heap 레코드 100만 건으로 하나씩 기록했습니다. 레코드 구성은 INSERT ... SELECT로 복사하는 pg_repack과 거의 같았습니다. 그 결과 무부하 기준 WAL은 VACUUM FULL보다 약 12% 많았습니다. 행 폭이 다르면 이 비율도 달라질 것으로 보입니다. 다만 이번에는 행 하나당 약 100바이트인 테이블 하나만 측정했습니다. standby나 WAL archive가 연결된 환경이라면 이 차이가 전송량과 보관량으로 이어집니다.

디스크 공간은 원본과 새 파일이 잠시 함께 존재하므로 기존 명령과 마찬가지로 넉넉해야 합니다. 문서는 인덱스 스캔이나 정렬이 없는 순차 스캔이면 테이블 크기와 인덱스 크기의 합만큼 여유 공간이 필요하다고 설명합니다. 순차 스캔 후 정렬이면 테이블 크기의 최대 두 배와 인덱스 크기만큼 필요합니다. CONCURRENTLY는 복사 중 들어온 변경을 새 파일에 반영할 수 있을 때까지 임시 파일에 따로 보관하므로 임시 공간을 조금 더 사용합니다.

logical slot이 WAL을 보존하는 문제도 있습니다. 개발 초기에는 slot의 restart_lsn이 가장 오래된 트랜잭션, 대개 REPACK 자신의 트랜잭션에 묶여 WAL이 필요 이상으로 남았습니다. commit 45b02984e는 WAL segment 경계를 넘을 때마다 slot을 전진시켜 실행 중에도 오래된 WAL을 재활용하도록 고쳤습니다. 이번 측정 규모에서는 이 동작을 따로 확인하지 않았습니다.

장점​

가장 큰 변화는 온라인 재구성에 확장 설치가 필요 없어졌다는 점입니다. pg_repack은 대상 데이터베이스에서 CREATE EXTENSION pg_repack을 실행해야 하고, 서버 버전에 맞는 클라이언트 바이너리도 따로 관리해야 합니다. 관리형 서비스라면 허용된 확장 목록에도 포함돼야 합니다. REPACK (CONCURRENTLY)는 SQL 한 줄로 실행할 수 있고, 필요한 권한도 테이블의 MAINTAIN 권한입니다. commit 5dbb63fc8에서 REPLICATION이나 LOGIN 속성 요구도 제거했습니다.

변경 추적 방식도 다릅니다. pg_repack은 원본 테이블에 트리거를 추가해 모든 DML을 로그 테이블에 한 번 더 기록하지만, REPACK은 이미 기록되는 WAL을 logical decoding으로 읽습니다. 진행 상황은 pg_stat_progress_repack의 단계와 카운터로 확인합니다. 전용 slot 풀(max_repack_replication_slots, 기본값 5)이 따로 있어 logical replication용 slot을 소진하지 않는다는 점도 운영에 편리합니다(max_repack_replication_slots 참고).

단점과 제약​

사용할 수 없는 테이블​

문서에 적힌 제약을 19beta4에서 하나씩 실행해 봤습니다. 모두 명시적인 오류와 함께 거부됐습니다.

대상19beta4 오류 메시지
identity index 없는 테이블Relation "nopk" has no identity index.
REPLICA IDENTITY FULLThis operation does not support tables with REPLICA IDENTITY FULL.
트랜잭션 블록 안REPACK (CONCURRENTLY) cannot run inside a transaction block
테이블 이름 생략REPACK (CONCURRENTLY) requires an explicit table name
파티션 테이블 부모REPACK (CONCURRENTLY) is not supported for partitioned tables
UNLOGGED 테이블This operation is only supported for permanent relations.
materialized viewThis operation is not supported for materialized views.
max_repack_replication_slots = 0REPACK can only be used if "max_repack_replication_slots" > 0

문서에는 조건이 "primary key와 index 기반 replica identity가 모두 없는 경우"로 적혀 있습니다. 실제로 primary key 없이 NOT NULL unique 인덱스를 REPLICA IDENTITY USING INDEX로 지정한 테이블은 통과했습니다. 파티션 테이블은 부모에서 거부되지만 파티션 하나를 지정하면 실행됐고, 오류 HINT도 파티션별로 실행하라고 안내합니다. 이 밖에도 시스템 카탈로그와 TOAST 테이블, heap이 아닌 access method, user_catalog_table 테이블은 문서상 제외 대상입니다. max_repack_replication_slots는 서버를 재시작해야 적용되는 파라미터입니다.

시작 단계의 대기​

initializing 단계에서는 logical decoding을 준비하면서 XID를 할당받은 트랜잭션이 모두 끝나기를 기다립니다. 다른 테이블을 UPDATE한 뒤 5초 동안 커밋하지 않는 세션을 열어 두고, 0.5초 뒤 작은 테이블에 REPACK (CONCURRENTLY)를 실행했습니다. 단독 실행 때 0.13초 걸리던 명령이 4.70초 걸렸습니다. 기다리는 동안 백엔드에는 initializing 단계에서 IPC / RepackWorkerExport 대기 이벤트가 나타났습니다. 대상 테이블과 관계없는 트랜잭션이라도 XID를 가진 채 오래 열려 있으면 REPACK 시작이 그만큼 늦어집니다. 문서도 이 단계가 명령 시작 시점에 실행 중이던 가장 긴 트랜잭션보다 오래 걸릴 수 있다고 설명합니다.

한 번에 하나만 진행​

PostgreSQL 19에서는 REPACK (CONCURRENTLY)가 클러스터 전체에서 한 번에 하나만 진행됩니다. 데이터베이스별 snapshot으로 이 제약을 풀려던 commit 0d3dba38c는 근본적인 결함이 발견돼 01a80f062로 되돌려졌고, 제약 해소는 다음 개발 주기로 넘어갔습니다. max_repack_replication_slots 기본값이 5여도 동시에 5개가 실행되지는 않습니다.

측정에서도 같은 결과가 나왔습니다. 큰 테이블 t에 REPACK (CONCURRENTLY)를 시작하고 0.4초 뒤 작은 테이블 t2에 같은 명령을 실행했습니다. t2는 오류 없이 initializing 단계에서 RepackWorkerExport를 기다리다가 t가 끝난 뒤 완료됐습니다. t2를 단독으로 실행했을 때는 0.13초였지만, 이때는 1.07초가 걸렸습니다. 여러 테이블을 정리하는 스크립트를 병렬로 실행해도 실제로는 직렬로 처리된다는 점을 고려해 일정을 세워야 합니다.

교체 단계의 잠금 대기​

마지막 교체에는 여전히 ACCESS EXCLUSIVE가 필요합니다. 대상 테이블을 읽은 뒤 4초 동안 트랜잭션을 열어 둔 세션을 만들고 REPACK (CONCURRENTLY)를 실행했습니다. REPACK은 catch-up 단계에서 ACCESS EXCLUSIVE를 기다렸습니다. 그 뒤에 들어온 단순 SELECT도 REPACK의 잠금 요청 뒤에서 2.04초를 기다렸습니다. 당시 pg_locks에는 부여되지 않은 AccessExclusiveLock 1건(REPACK)과 AccessShareLock 1건(새 SELECT)이 함께 나타났습니다. ALTER TABLE 같은 DDL에서 흔히 나타나는 잠금 대기열과 같은 형태입니다. 따라서 긴 트랜잭션이 있는지 먼저 확인하고 실행하는 습관은 여전히 필요합니다.

MVCC 안전성​

문서는 REPACK (CONCURRENTLY)를 TRUNCATE, 테이블을 재작성하는 ALTER TABLE과 함께 MVCC-safe하지 않은 명령으로 분류합니다(13.6절). 명령이 커밋되기 전에 snapshot을 얻었으며 그때까지 이 테이블에 접근하지 않은 트랜잭션은, 커밋 후 테이블이 비어 있는 것으로 볼 수 있습니다. 테이블에 한 번이라도 접근한 트랜잭션은 ACCESS SHARE를 보유합니다. REPACK의 교체는 이 트랜잭션이 끝날 때까지 기다리므로, 이런 트랜잭션은 해당하지 않습니다. REPEATABLE READ나 SERIALIZABLE로 긴 보고서 쿼리를 실행하는 시스템이라면 알아 둘 부분입니다. 이번 측정에서는 이 현상을 재현하지 않았습니다.

그 밖에 문서는 REPACK이 시작된 뒤 들어온 행은 USING INDEX 순서로 정렬되지 않으며, 실행 중 다른 트랜잭션이 DDL을 실행하면 REPACK이 실패할 수 있다고 설명합니다. 실행 도중 취소하면 어떻게 되는지도 확인했습니다. 실행 중 temporary logical slot이 생성된 것을 확인한 뒤 pg_cancel_backend()로 취소하자 slot은 남지 않았고 effective_wal_level도 replica로 돌아왔습니다.

pg_repack과 비교​

문서와 측정에서 확인한 차이를 정리하면 다음과 같습니다.

항목REPACK (CONCURRENTLY)pg_repack 1.5.3
설치코어 명령확장과 클라이언트 바이너리
변경 추적logical decoding (temporary slot)트리거와 로그 테이블
필요 키identity index (PK 또는 replica identity 인덱스)PK 또는 NOT NULL 컬럼의 unique 인덱스
파티션 테이블파티션 단위로만--parent-table 옵션
정렬 재작성USING INDEX--order-by, --no-order 등
wal_level 영향실행 중 effective_wal_level = logical없음
무부하 WAL (이번 측정)173MB173MB

어느 쪽이든 교체 순간의 ACCESS EXCLUSIVE와 원본 크기만큼의 추가 디스크는 피할 수 없습니다. PostgreSQL 19로 올린 뒤 바로 pg_repack을 제거하기보다는 파티션 테이블 일괄 처리(--parent-table)나 인덱스 병렬 재생성(--jobs)처럼 pg_repack에만 있는 기능을 사용하고 있는지 먼저 확인하는 것이 안전합니다.

진행 상황 확인 쿼리​

측정 중에는 아래 쿼리에서 command, phase 컬럼과 slot 목록, 잠금 집계를 사용했습니다. 나머지 컬럼과 조건은 문서를 기준으로 덧붙였습니다. 진행 단계와 처리량은 pg_stat_progress_repack에서 확인합니다.

SELECT pid, relid::regclass, command, phase,
heap_blks_scanned, heap_blks_total,
heap_tuples_inserted, heap_tuples_updated, heap_tuples_deleted
FROM pg_stat_progress_repack;

initializing에 오래 머물면 XID를 가진 긴 트랜잭션이나 먼저 실행 중인 다른 REPACK (CONCURRENTLY)가 있는지 확인합니다. catch-up에서 멈춰 있으면 교체용 잠금을 기다리는 중인지 확인합니다. 문서도 catch-up 단계가 ACCESS EXCLUSIVE 대기 시간을 포함한다고 설명합니다.

-- REPACK이 만든 temporary slot과 wal_level 상태
SELECT slot_name, slot_type, temporary, restart_lsn
FROM pg_replication_slots
WHERE slot_name LIKE 'pg\_repack\_%';

SHOW effective_wal_level;

-- 대상 테이블의 잠금 대기열
SELECT mode, granted, count(*)
FROM pg_locks
WHERE relation = 't'::regclass
GROUP BY 1, 2
ORDER BY 1;

버전별 차이​

REPACK은 PostgreSQL 19에 처음 들어온 명령이라 18 이하에는 없습니다. 18 이하에서는 VACUUM FULL, CLUSTER, pg_repack이 그대로 선택지입니다. 19 개발 주기에 확인한 주요 commit은 다음과 같습니다.

날짜(UTC)commit내용
2026-03-10ac58465e0REPACK 명령 추가
2026-04-0628d534e2aCONCURRENTLY 옵션 추가
2026-04-07e76d8c749max_repack_replication_slots 추가
2026-04-205dbb63fc8REPLICATION, LOGIN 요구 제거
2026-05-2401a80f062데이터베이스별 snapshot revert, 동시 1개 제약 유지
2026-05-272af1dc892실행 후 effective_wal_level이 logical에 머무는 문제 수정
2026-05-2945b02984e실행 중 오래된 WAL 재활용 허용

19beta4 발표(2026-09-24)에도 REPACK의 crash, invalid 인덱스와 materialized view 처리, 권한과 오류 메시지 수정이 포함돼 있습니다. 발표 이후에도 ADD COLUMN으로 테이블을 재작성하지 않고 추가된 컬럼을 처리하는 수정(45da2c1d7)과 REPACK 스트레스 테스트에서 발견된 초기 decoding snapshot 문제 수정(46024c573)이 이어졌습니다. 후자는 모든 브랜치에 있던 버그라고 commit 메시지에 적혀 있습니다. RC는 10월 초로 예고돼 있으니, 운영 적용 여부는 GA 버전의 릴리스 노트를 다시 확인한 뒤 판단하는 편이 좋습니다.

측정해 보니 부하가 있을 때의 잠금 영향은 pg_repack과 비슷했고, 설치 없이 SQL 한 줄로 끝난다는 점이 가장 크게 느껴졌어요. 다만 동시에 하나만 실행된다는 제약과 시작, 교체 단계의 대기는 GA 이후에도 계속 확인해 볼 생각이에요.

참고​

PG19 Beta 4 기능 철회

· 약 17분

PostgreSQL 19 Beta 4에서 신기능 네 가지가 통째로 빠졌어요. SQL/PGQ, 온라인 체크섬 전환, FOR PORTION OF, MERGE/SPLIT PARTITIONS예요. 이 블로그에서 PostgreSQL 19 신기능으로 소개했던 글 다섯 편이 이 기능들과 관련되어 있어서, 무엇이 왜 빠졌는지 1차 출처를 바탕으로 다시 정리해 봤어요.

배경: Beta 4 공지​

PostgreSQL Global Development Group은 2026-09-24 PostgreSQL 19 Beta 4 공지를 냈습니다. 공지는 이번 베타에서 19에 포함하려던 기능 몇 가지를 revert했다고 밝히고, 이유를 이렇게 적었습니다.

The PostgreSQL community strongly believes that, first and foremost, PostgreSQL must be reliable. Additionally, the community strives for a predictable release schedule.

신뢰성과 예측 가능한 릴리스 일정을 위해 일부 기능을 19에서 빼고, 이후 메이저 릴리스에서 다시 다룰 여지를 남긴다는 설명입니다. 같은 공지는 다음 단계인 release candidate를 10월 초로 예고하고, GA도 10월에 나올 수 있다고 안내합니다.

공지의 "Changes Since Beta 3" 목록 가운데 revert/remove 항목은 여섯 개입니다.

공지 항목revert 커밋커밋 날짜 (UTC)삭제 줄 수
SQL/PGQ (property graph query)2b9e1aff4d2026-09-0715,857
온라인 data checksums 활성/비활성c05d5ce1232026-09-166,673
UPDATE/DELETE ... FOR PORTION OFa9d2f728242026-09-1512,938
ALTER TABLE ... MERGE/SPLIT PARTITION(S)3e8bcc86442026-08-279,379
pg_get_role_ddl() 등 3개 함수db169985c12026-09-131,500
postmaster의 LC_COLLATE=C 강제1956251afa2026-09-0142

삭제 줄 수는 GitHub API가 각 revert 커밋에 산정한 deletions 값을 그대로 옮긴 것입니다. 문서, 회귀 테스트, 릴리스 노트 항목까지 포함한 수치라 기능 코드의 크기와 같지 않습니다.

REL_19_BETA4 태그 커밋(Stamp 19beta4)은 2026-09-21입니다. 위 여섯 커밋은 모두 그 전에 REL_19_STABLE에 반영되었습니다.

GROUP BY ALL은 이보다 앞서 빠졌습니다. Beta 3 공지(2026-08-13)의 첫 항목이 "Revert GROUP BY ALL"이고, revert 커밋은 a32733d8f1(2026-07-17)입니다. 커밋 메시지에 따르면 ORDER BY에 함께 나오는 항목의 특수 처리를 고려하지 않아, ORDER BY가 기본과 다른 equality 의미를 지정하면 잘못된 결과를 반환했습니다. 수정하려면 코드 변경이 너무 커서 v20에서 다시 시도한다고 적혀 있습니다.

철회된 기능과 사유​

사유는 revert 커밋 메시지와 커밋이 가리키는 pgsql-hackers 메일로 확인했습니다. 메일 인용은 원문 그대로 두고, 해석은 따로 적었습니다.

SQL/PGQ​

revert 커밋은 원 커밋 2f094e7ac69와 후속 수정 46건을 한 번에 되돌렸습니다. 커밋 메시지는 되돌린 커밋 목록과 논의 링크만 담고 있습니다.

그 링크는 Melanie Plageman(멜라니 플라게만)이 2026-09-02 "PGQ catalog representation and pg_dump support" 스레드에 남긴 메일입니다. RMT(Release Management Team)의 의견으로 설계 문제를 네 가지로 정리했습니다.

  • DROP TABLE ... CASCADE 같은 외부 CASCADE가 graph 메타데이터를 고아로 남기는 문제
  • label과 property의 범위를 전역으로 볼지 label 단위로 볼지 정해지지 않은 문제
  • property graph를 pg_class에 둘지에 관한 카탈로그 표현 문제
  • materialized view가 GRAPH_TABLE을 조회할 때 pg_dump에 생기는 의존 순환

결론 문장은 다음과 같습니다.

As such, unless we are misunderstanding something about the discussion, we feel it would be better to revert this in 19. That would take off the time pressure now and would make it easier to fix these things properly in 20 without having to be burdened by backwards compatibility and backpatching.

19에 넣은 채 고치면 하위 호환과 backpatch 부담이 생기므로, 19에서 빼고 20에서 제대로 고치자는 판단입니다.

온라인 data checksums 전환​

이 revert는 커밋 메시지에 사유가 직접 적혀 있습니다.

The feature to enable, or disable, data checksums in an online cluster saw a number of postcommit fixes during the beta period. Suspicions were raised about the risk of more issues surfacing after GA.

베타 기간에 사후 수정이 많았고 GA 이후에도 문제가 더 발견될 위험이 제기돼, 버그가 있을 수 있는 코드를 출시하지 않으려고 되돌렸다는 내용입니다. 원 커밋 f19c0eccae9를 포함해 30개 커밋을 전부 또는 일부 되돌렸습니다.

전부 사라지지는 않았습니다. 커밋 메시지는 유지한 네 가지를 밝힙니다. 오프라인 pg_checksums가 pg_control_init에서 초기 상태를 보존하지 못하던 버그의 수정, page 검증 실패 로그에 buffer를 0으로 채우는지 알려 주는 보조 메시지, 현재 상태에 맞게 다시 쓴 Data Checksum 문서 절, 체크섬 상태 enum입니다. 이후 4a9a6c5a69(09-16)와 6b0760a10a(09-17)가 revert 과정에서 누락한 pg_control_checkpoint 컬럼, XLOG_PAGE_MAGIC 증가, 문서 잔여분을 정리했습니다.

FOR PORTION OF​

revert 커밋은 원 커밋 8e72d914c52와 후속 22건을 되돌렸습니다. 논의 링크는 FOR PORTION OF code review 스레드입니다.

스레드에서 문제가 된 것은 READ COMMITTED에서의 동시성입니다. FOR PORTION OF는 범위의 일부만 고치고 나머지 구간을 leftover 행으로 다시 삽입하는데, 동시에 실행된 UPDATE/DELETE가 서로의 leftover를 보지 못하는 경우가 있었습니다. Andres Freund(안드레스 프로인트)는 2026-09-11 문서화된 우회책인 SELECT ... FOR UPDATE가 temporal 조건에 걸려 대상 행을 잠그지 못하는 경우가 많다고 지적했고, DELETE가 끼면 전체 키를 잠가도 lost update가 난다고 했습니다. Paul Jungwirth(폴 융비르트)는 READ COMMITTED를 허용하지 않는 대신 serialization failure를 내서 재시도하게 하자고 제안했고, Andres는 이렇게 답했습니다.

Personally I don't find that it's a convincing feature with that limitation... READ COMMITTED is extremely widely used, with one reason for that being not needing to retry, and making FPO not really work that way doesn't seem convincing to me.

Peter Eisentraut(페터 아이젠트라우트)는 09-14 "I was trying to nudge toward reverting without saying it."라고 쓴 뒤 09-15 revert했습니다.

MERGE/SPLIT PARTITIONS​

커밋 메시지의 사유는 한 문장입니다.

The feature is reverted due to multiple design issues which are too late to address in this release cycle.

원 커밋 f2e4cc4279, 4b3d173629와 후속 수정 12건을 되돌렸습니다. 논의 링크는 Zsolt Parragi(졸트 파라기)가 2026-07-23 올린 MERGE/SPLIT PARTITIONS issues/questions입니다. 옮겨진 행이 새 파티션에 대한 INSERT로만 decode돼 짝이 되는 DELETE 없이 logical replication에 전달되는 문제가 첫 항목입니다. 새 파티션이 기존 파티션의 publication 소속과 replica identity를 물려받아야 하는지도 질문으로 제기되었습니다. generated column 값이 재계산되며 바뀌는 문제도 함께 제기되었습니다. 파티션별 인덱스, 제약, 트리거, reloptions, ACL, RLS 정책 같은 메타데이터가 조용히 사라지는 문제도 같은 메일에 적혀 있습니다.

pg_get_role_ddl(), pg_get_tablespace_ddl(), pg_get_database_ddl()​

세 함수와 공통 인프라 커밋을 포함해 13건을 되돌렸습니다. 논의 링크는 Noah Misch(노아 미시)가 2026-08-27 올린 pg_get_*_ddl() needs a redesign입니다. 지적은 세 가지입니다. 마이그레이션과 백업 용도라면서 비밀번호 hash를 출력하지 않는 점, role, database, IN DATABASE ... SET의 의존 순서를 다루지 않는 점, 그리고 catalog 상태를 SQL로 되살리는 구현이 pg_dump와 별도로 하나 더 생긴다는 점입니다.

PostgreSQL should not carry two independent implementations of this logic.

postmaster의 LC_COLLATE=C 강제​

되돌린 커밋 5e6e42e(2025-07)는 postmaster에서 LC_COLLATE를 C로 고정해 collation 호출이 libc 전역 설정에 우연히 의존하지 않게 하려던 변경입니다. 원 커밋 메시지는 backend 동작 변화는 없지만 일부 확장 모듈이 영향을 받을 수 있다고 적었습니다. revert 커밋 메시지는 "Out of an abundance of caution."이 전부이고, Daniel Verite(다니엘 베리테)와 Noah Misch가 요청자로 올라 있습니다.

논의 메일에서 Noah Misch는 영향을 받는 쪽이 변화를 알아차리기 어렵다는 점을 문제로 봤습니다.

Also important to me: the disruption is silent. Applications are on their own to even figure out the change affected them.

이 항목은 SQL 문법이 아니라 확장 모듈의 libc 호출 동작에 관한 것이라, 아래 측정에서는 다루지 않았습니다.

공지 목록 밖의 revert​

REL_19_STABLE 커밋 로그에는 Beta 4 공지 목록에 없는 revert도 있습니다. 공지가 모든 변경을 나열하지는 않으니, 19를 검증 중이라면 커밋 로그도 함께 보는 편이 안전합니다.

  • CREATE SCHEMA 안에서 쓸 수 있는 객체 종류 확대와 하위 명령 재정렬 중단: 3c5d28ba64, e0fdc3f54b (09-11, 호환성 영향이 이득보다 크다는 이유)
  • default locale 기준 identifier casefolding: c58566d06f (09-11, builtin provider의 C locale과 single-byte encoding 조합에서 의도하지 않은 동작 차이)
  • RI fast-path 검사의 batching 제거: 25649d6e79 (09-10). per-row fast path는 남았고, 공지에는 "Several fixes to the performance improvement feature for the foreign key constraint checks"로만 묶여 있습니다

scary patch contest​

revert 논의와 같은 시기에 나온 글이 Robert Haas(로버트 하스)가 2026-08-25 pgsql-hackers에 올린 "scary patch contest" 메일입니다. LWN의 Joe Brockmeier(조 브록마이어)가 2026-09-10 기사로 이 스레드를 다뤘습니다. 메일 첫 문장은 다음과 같습니다.

I asked Claude to evaluate which v19 patches were the scariest based on the number and type of bugs fixed post-freeze.

기능 동결 이후 고친 버그의 수와 종류로 v19 패치를 평가하게 했고, 결과 여섯 개를 싣되 LLM의 신랄한 논평 일부는 뺐다고 적었습니다. 메일에 실린 순서대로 옮기면 이렇습니다. 마지막 열의 경과는 시기가 겹친다는 사실만 보여 주며, 이 메일 때문에 revert됐다는 뜻은 아닙니다.

순서대상메일에 적힌 사후 수정이후 경과
1RI fast-path FK 검사와 batching약 16건batching 제거
2REPACK / REPACK CONCURRENTLY28건유지, Beta 4에서 수정 다수
3온라인 data checksums약 25건revert
4UPDATE/DELETE FOR PORTION OF17건 (보안 3건)revert
5SQL/PGQ17건revert
6postgres_fdw 통계 가져오기7건이 글에서 확인하지 않음

온라인 체크섬 항목에는 "A corruption-detection feature producing false positives is exactly the wrong failure mode."라는 문장이 붙어 있습니다. 손상을 감지하려는 기능이 멀쩡한 페이지를 실패로 판단하면, 그 기능을 믿고 쓸 근거가 사라진다는 지적입니다.

Robert Haas 본인은 메일 끝에서 "I don't currently have a firm position on what we should do here."라고 해서 revert를 주장하지 않았습니다. 1~3번에 아직 찾지 못한 버그가 오랫동안 남아 있을까 걱정된다고만 적었습니다. 이 메일이 각 revert를 직접 이끌었는지는 확인하지 못했습니다. revert 커밋이 가리키는 논의 스레드가 각각 따로라서, 이 글에서는 시기가 겹친다는 사실까지만 적습니다. MERGE/SPLIT PARTITIONS와 pg_get_*_ddl()은 이 메일의 여섯 항목에 들어 있지 않습니다.

beta3와 beta4 비교 측정​

측정 환경​

  • 호스트: macOS 26.6.2, Docker Engine 28.3.3 VM (Linux aarch64, 2 CPU, 약 11.7GiB, 다른 작업과 공유)
  • 이미지: Docker Hub 공식 postgres:19beta3, postgres:19beta4 (Debian 19~beta3-1.pgdg13+1, 19~beta4-1.pgdg13+1)
  • 컨테이너별 --cpus 0.5 --memory 512m, 테이블은 행 몇 개 수준

같은 SQL 파일을 두 컨테이너의 빈 데이터베이스에서 실행했습니다. 전체 로그와 스크립트는 저장소의 docs/postgresql-19-beta4-feature-reverts-measurements.md에 남겼습니다.

문법과 함수​

구문19beta319beta4
CREATE PROPERTY GRAPH성공syntax error at or near "PROPERTY"
GRAPH_TABLE (g MATCH ...)2행 반환syntax error at or near "MATCH"
UPDATE ... FOR PORTION OFUPDATE 1, 행이 3개로 나뉨syntax error at or near "FOR"
ALTER TABLE ... SPLIT PARTITION성공syntax error at or near "SPLIT"
ALTER TABLE ... MERGE PARTITIONS성공syntax error at or near "MERGE"
pg_get_role_ddl('postgres')CREATE ROLE postgres ... 반환function does not exist
pg_get_database_ddl('postgres')2행 반환function does not exist
pg_disable_data_checksums()data_checksums가 off로 바뀜function does not exist
pg_enable_data_checksums()data_checksums가 on으로 돌아옴function does not exist
GROUP BY ALLsyntax errorsyntax error

문법 계열은 전부 파서 단계에서 막힙니다. 함수 계열은 파싱은 통과하지만 함수 해석 단계에서 "function ... does not exist"로 실패합니다. pg_get_keywords()로 보면 19beta3에 있던 property, graph, graph_table, vertex, edge, destination, portion, split, partitions 키워드가 19beta4에서는 모두 사라지고 merge만 남았습니다. merge는 PostgreSQL 15의 MERGE 문 때문에 원래 있던 키워드입니다.

체크섬 관련 내용을 덧붙이면, 두 이미지 모두 initdb 기본값으로 data_checksums = on이었습니다. PostgreSQL 18부터 initdb가 checksum을 기본으로 켜기 때문입니다. 19beta4에서 이미 켜진 checksum을 운영 중에 끄는 방법은 없고, 오프라인 pg_checksums를 사용해야 합니다. 19beta3 측정에서는 DB에 행이 몇 개뿐이라 3초, 5초 대기 안에 off/on이 끝났습니다. 실제 크기에서 걸리는 시간은 재지 않았습니다.

pg_get_tablespace_ddl('pg_default')는 19beta3에서도 tablespace name "pg_default" is reserved 오류였습니다. 시스템 tablespace는 대상이 아니었던 것으로 보이며, 사용자 tablespace로는 확인하지 않았습니다.

beta3 데이터 디렉터리와 dump/restore​

베타 사이에는 원래 catalog 호환이 보장되지 않습니다. 이번에도 pg_controldata 값이 달랐습니다.

항목19beta319beta4
pg_control version number19021905
Catalog version number202607272202609165
Latest checkpoint's data_checksum_version 줄있음없음

19beta3로 initdb한 볼륨을 19beta4 이미지로 실행하면 바로 멈춥니다.

FATAL: database files are incompatible with server
DETAIL: The database cluster was initialized with PG_CONTROL_VERSION 1902, but the server was compiled with PG_CONTROL_VERSION 1905.
HINT: It looks like you need to initdb.

그래서 dump/restore를 두 방향으로 확인했습니다. 19beta3 DB에는 property graph g, FOR PORTION OF로 3행이 된 price, MERGE 결과인 파티션 sales_all2를 만들어 두었습니다.

첫째, 19beta3의 pg_dump로 뜬 파일을 19beta4에 넣었습니다. CREATE PROPERTY GRAPH public.g와 ALTER PROPERTY GRAPH public.g OWNER TO postgres 두 문장만 syntax error로 실패하고 나머지는 복원됐습니다. psql -f 기본값은 오류가 나도 계속 진행하므로, 로그를 보지 않으면 graph가 빠진 줄 모르고 넘어갈 수 있습니다.

둘째, 19beta4의 pg_dump로 19beta3 서버를 직접 dump했습니다. 종료 코드는 0이었고, stderr까지 함께 저장한 출력 파일에 PROPERTY GRAPH라는 문자열이 한 줄도 없었습니다. 복원한 DB에는 person, knows, price, sales, sales_all2만 있습니다. 새 버전 pg_dump로 옛 서버를 dump하는 일반적인 업그레이드 순서에서는 graph를 언급하는 메시지 없이 graph만 빠진다는 뜻입니다.

반면 이미 만들어진 결과물은 일반 데이터라 그대로 옮겨졌습니다. FOR PORTION OF로 나뉜 price 3행, MERGE로 생긴 파티션 sales_all2는 19beta4에서도 같은 모습으로 복원됐습니다. pg_upgrade 경로는 두 버전 바이너리를 한 환경에 두지 않아 측정하지 않았습니다.

베타 클러스터 점검 쿼리​

19 베타에서 이 기능들을 시험해 본 클러스터가 있다면, RC나 GA로 옮기기 전에 아래 쿼리로 남은 흔적을 찾을 수 있습니다. 세 쿼리 모두 19beta3에서 예상한 객체를 찾았고, 19beta4에서는 0행이었습니다.

-- 1) property graph 객체 (relkind 'g')
SELECT n.nspname, c.relname
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'g';

-- 2) property graph에 의존하는 view, materialized view
SELECT DISTINCT v.oid::regclass AS dependent_view, g.oid::regclass AS graph
FROM pg_depend d
JOIN pg_rewrite r ON r.oid = d.objid AND d.classid = 'pg_rewrite'::regclass
JOIN pg_class v ON v.oid = r.ev_class
JOIN pg_class g ON g.oid = d.refobjid AND d.refclassid = 'pg_class'::regclass
WHERE g.relkind = 'g' AND v.oid <> g.oid;

-- 3) 함수 본문에서 철회된 문법과 함수 이름 검색
SELECT p.oid::regprocedure AS func,
array_remove(ARRAY[
CASE WHEN p.prosrc ~* 'for\s+portion\s+of' THEN 'FOR PORTION OF' END,
CASE WHEN p.prosrc ~* 'graph_table|property\s+graph' THEN 'SQL/PGQ' END,
CASE WHEN p.prosrc ~* '(merge|split)\s+partitions?' THEN 'MERGE/SPLIT' END,
CASE WHEN p.prosrc ~* 'pg_get_(role|tablespace|database)_ddl' THEN 'pg_get_*_ddl' END,
CASE WHEN p.prosrc ~* 'pg_(en|dis)able_data_checksums' THEN 'online checksums' END
], NULL) AS hits
FROM pg_proc p
JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
AND p.prosrc ~* 'for\s+portion\s+of|graph_table|property\s+graph|(merge|split)\s+partitions?|pg_get_(role|tablespace|database)_ddl|pg_(en|dis)able_data_checksums';

19beta3에서 1번은 g, 2번은 GRAPH_TABLE을 감싼 view v_edges, 3번은 FOR PORTION OF를 쓰는 PL/pgSQL 함수 fix_price()와 pg_get_role_ddl()을 호출하는 dump_role(text)를 찾았습니다.

3번이 필요한 이유는 PL/pgSQL의 검증 시점이 두 가지로 나뉘기 때문입니다. 19beta4에서 FOR PORTION OF가 든 PL/pgSQL 함수를 만들면 check_function_bodies = on(기본값)인 동안 CREATE 시점에 syntax error가 납니다. 그러나 pg_get_role_ddl()을 호출하는 PL/pgSQL 함수는 CREATE가 성공하고, 호출 시점에야 "function pg_get_role_ddl(regrole) does not exist"로 실패했습니다. 함수 이름은 실행 시점에 해석되기 때문입니다. 애플리케이션 SQL이나 배치 스크립트 안의 호출은 이 쿼리로 찾을 수 없으므로 코드 저장소도 따로 검색해야 합니다.

prosrc 정규식 검색이므로 주석이나 문자열 안의 단어도 검색됩니다. 결과는 후보 목록으로 보고 하나씩 확인하는 용도입니다.

기존 글 정정 범위​

이 블로그의 PostgreSQL 19 글 중 아래 부분이 19 GA 기준에 맞지 않게 되었습니다. 기존 글 본문은 이 글에서 고치지 않았고, 각 글에는 따로 안내를 추가합니다.

글맞지 않게 된 부분유효한 부분
PG19 SQL/PGQ 그래프 쿼리"PostgreSQL 19에 들어갔다"는 전제 전체SQL:2023 SQL/PGQ 표준과 문법 설명 (20 이후 참고용)
PG19 온라인 체크섬 전환"PostgreSQL 19의 온라인 전환" 절 이후data_checksums의 역할, 오프라인 pg_checksums 전환 역사
PG19 파티션 MERGE/SPLITPostgreSQL 19 문법 절, 비교 표와 락 절의 PostgreSQL 19 쪽기존 PostgreSQL의 수동 분할/병합 방식, Oracle 문법 설명
PG19 Beta 핵심 변화 4가지3절 FOR PORTION OF 전체, 체크리스트의 해당 항목, 9월 출시 예정 문구1절 MultiXact, 2절 병렬 autovacuum, 4절 JIT 기본값
PostgreSQL 19 새 기능 총정리GROUP BY ALL 절, MERGE/SPLIT PARTITIONS 절, 그래프 쿼리(SQL/PGQ) 절, 마무리 소감 중 GROUP BY ALL과 SQL/PGQ 언급, 9월 출시 예정 문구나머지 항목 (Beta 4 공지 revert 목록 기준)

"유효한 부분"은 Beta 4 공지와 이 글에서 확인한 revert 목록에 포함되지 않는다는 뜻입니다. 각 글의 다른 서술을 RC 기준으로 다시 검증했다는 뜻은 아닙니다. pg_get_*_ddl(), LC_COLLATE, CREATE SCHEMA 변경은 이 블로그의 기존 글에서 다루지 않았습니다.

마무리​

네 번째 베타에서 신기능이 여럿 제외되는 것을 보면서, 저도 19 글을 쓸 때 "베타에 들어갔다"를 "릴리스에 들어간다"로 너무 쉽게 받아들였다는 생각이 들었어요. RC가 나오면 릴리스 노트를 기준으로 남은 기능을 한 번 더 확인해 볼게요.

참고​

PG19 논리 복제 시퀀스

· 약 8분

PostgreSQL 19부터 logical replication이 sequence 값을 subscriber로 동기화해요. 18까지는 테이블 데이터만 넘어가고 SERIAL/IDENTITY 뒤에 붙은 sequence는 subscriber에서 초기값에 그대로 멈춰 있어 promote 직후 첫 INSERT가 duplicate key로 터지는 사고가 흔했지만, 19는 publication에 ALL SEQUENCES 절을 들이고 ALTER SUBSCRIPTION ... REFRESH SEQUENCES 명령을 더해 이 오래된 빈틈을 보완했어요.

이 글은 pgEdge의 Shaun Thomas(숀 토머스)가 쓴 "Looking Forward to Postgres 19: Logically Sequenced"를 한국어로 풀고, PostgreSQL 19 릴리스 노트와 실제 동작 데모로 사실을 교차검증합니다. DBA 관점에서 "failover 때 왜 사고가 났나", "19에서 무엇이 정확히 달라지고 무엇은 여전히 그대로인가"를 함께 봅니다.

sequence는 왜 그동안 복제 대상이 아니었나​

sequence가 logical replication에서 빠져 있던 데에는 구조적인 문제가 있었습니다. logical decoding은 WAL을 트랜잭션 단위로 재조립해 commit 순서대로 replay합니다. 철저히 트랜잭션 기반입니다.

그런데 sequence는 트랜잭션과 어울리지 않습니다. nextval()로 뽑은 값은 트랜잭션을 rollback해도 되돌아오지 않습니다. 한 번 소비된 번호는 영구히 사라집니다. 이 비트랜잭션 동작과 commit 순서 기반 decoding을 깔끔하게 화해시키기가 어려웠습니다.

실제로 Tomas Vondra(토마스 본드라)가 만든 "logical decoding of sequences" 패치가 PostgreSQL 16에 한 번 들어갔다가, 트랜잭션과 비트랜잭션 동작을 조율하는 난점 때문에 되돌려진 적이 있습니다. 그만큼 묵은 숙제였습니다. (pgEdge)

결과적으로 18 이하의 logical replication에서 sequence는 공식 문서의 제약 사항에 명시된 복제 제외 대상이었습니다. 테이블 row는 넘어가지만, 그 row의 id를 발급하는 sequence는 subscriber에서 따로 놀았습니다.

failover 때 무슨 사고가 났나​

운영 입장에서 이게 왜 문제였는지는 cutover 시나리오로 보면 분명합니다. zero-downtime upgrade나 마이그레이션에서 흔한 흐름입니다.

  1. publisher(구 primary)에서 subscriber(신 primary)로 logical replication을 겁니다.
  2. 테이블 데이터는 잘 넘어옵니다. row가 수백만 건 들어와 있고, id 컬럼은 publisher에서처럼 큰 값까지 차 있습니다.
  3. cutover 시점에 subscriber를 promote하고 애플리케이션을 붙입니다.
  4. 첫 INSERT가 떨어지는 순간 duplicate key value violates unique constraint로 터집니다.

원인은 단순합니다. 테이블의 id는 큰 값까지 차 있는데, 그 id를 발급하는 sequence는 subscriber에서 초기값(보통 1)에 그대로 멈춰 있었습니다. sequence가 복제 대상이 아니었으니 당연한 결과입니다. nextval()이 1을 돌려주는데, 그 자리는 이미 넘어온 데이터가 차지하고 있습니다.

그동안의 회피책은 cutover 직전에 손으로 sequence를 끌어올리는 것이었습니다.

-- 옛날 방식: 각 sequence를 publisher 값보다 높게 수동으로 밀어 올림
SELECT setval('public.orders_id_seq', 5000000 + 1000);

+ 1000 같은 인위적인 여유분은 cutover 도중 publisher에 추가로 들어올 write를 흡수하려는 안전 마진입니다. sequence가 수십/수백 개면 이걸 전부 스크립트로 긁어 돌려야 했고, 마진을 잘못 잡으면 번호가 비거나 충돌했습니다. failover runbook에서 늘 신경 쓰이던 자리였습니다.

PostgreSQL 19가 푸는 방식​

19는 이 작업을 logical replication 안으로 들였습니다. 릴리스 노트는 이렇게 적습니다.

Allow sequence values stored in subscribers to match the publisher (Vignesh C) — PostgreSQL 19 Release Notes

핵심은 세 조각입니다.

  • publication에 ALL SEQUENCES 절이 생겼습니다.
  • subscriber가 sequence 값을 publisher에서 당겨오는 시점이 세 개 정해졌습니다.
  • 백그라운드에서 sequence를 배치로 당겨오는 sequencesync worker가 추가됐습니다.

publication의 ALL SEQUENCES​

publication이 sequence를 포함할 수 있습니다.

-- 테이블과 sequence를 함께 발행
CREATE PUBLICATION migration_pub FOR ALL TABLES, ALL SEQUENCES;

-- sequence만 발행도 가능
CREATE PUBLICATION pubseq FOR ALL SEQUENCES;

ALL SEQUENCES는 ALL TABLES와 함께 묶을 수 있습니다. 다만 TABLE이나 TABLES IN SCHEMA 같은 세밀한 옵션과는 함께 쓸 수 없습니다. (dbi-services)

여기서 첫 제약이 나옵니다. sequence는 개별 선택(cherry-pick)이 안 됩니다. 테이블처럼 "이 sequence만 publication에 넣겠다"가 불가능하고, 전부(ALL SEQUENCES) 아니면 전무입니다.

subscription의 동기화 시점​

subscription 쪽은 평소처럼 만듭니다.

CREATE SUBSCRIPTION migration_sub
CONNECTION 'host=oldprimary dbname=app user=repl'
PUBLICATION migration_pub;

sequence 값이 subscriber로 당겨지는 시점은 정확히 셋입니다. (릴리스 노트)

-- (1) CREATE SUBSCRIPTION — 최초 1회 당겨옴

-- (2) sequence 존재 여부 + 값을 publication에 맞춰 재조정
ALTER SUBSCRIPTION migration_sub REFRESH PUBLICATION;

-- (3) 값만 갱신 (membership 은 건드리지 않음)
ALTER SUBSCRIPTION migration_sub REFRESH SEQUENCES;

REFRESH PUBLICATION은 publication에서 sequence가 추가/제거된 것을 반영하면서 값도 맞춥니다. REFRESH SEQUENCES는 membership은 그대로 두고 값만 다시 당겨옵니다. 내부적으로는 pg_subscription_rel의 모든 sequence를 INIT 상태로 되돌린 뒤 sequencesync worker가 다시 채웁니다.

연속 동기화가 아닌 시점 동기화​

여기서 DBA가 반드시 머리에 둬야 할 한 가지가 있습니다. sequence 동기화는 연속이 아니라 시점 동기화입니다.

테이블 row처럼 publisher의 변경이 실시간으로 흐르지 않습니다. 위의 세 시점에만 값을 당겨오고, 그 직후부터 publisher가 새 번호를 발급하는 순간 subscriber의 값은 곧바로 낡은(stale) 값이 됩니다.

dbi-services의 데모가 이를 그대로 보여줍니다. publisher에서 sequence의 last_value가 3까지 올라가도 subscriber는 2에 머뭅니다. ALTER SUBSCRIPTION ... REFRESH SEQUENCES를 실행해야 비로소 3으로 맞춰집니다. 그 뒤 publisher에 다시 row를 넣어 sequence가 6까지 올라가도, subscriber는 또 멈춰 있고 다시 한번 refresh를 실행해야 따라옵니다. (dbi-services)

publisher와 subscriber 시퀀스 값의 시점 동기화

그래서 운영 원칙은 명확합니다. REFRESH SEQUENCES는 promote 직전에 실행합니다. 미리 돌려두면 그 사이 publisher가 발급한 번호만큼 다시 어긋납니다. cutover runbook에서 sequence refresh는 publisher write를 멈추고 promote로 넘어가기 바로 전 칸에 들어가야 합니다. (pgEdge)

동기화 흐름​

cutover 시점의 sequence 동기화를 단계로 그리면 이렇습니다.

worker는 INIT으로 표시된 sequence를 모아 publisher에서 현재 값과 page LSN을 가져와 subscriber에 쓰고, 끝나면 해당 항목을 READY로 바꿉니다. 배치로 처리해 빠릅니다. depesz의 테스트에서는 sequence 1만 개 동기화가 약 1초, 100ms당 약 1,200개 속도였습니다. (depesz)

상태와 모니터링​

sequence도 테이블처럼 pg_subscription_rel에서 상태를 가집니다. 동기화 전에는 INIT(i), 끝나면 READY(r)입니다.

SELECT c.relname, r.srsubstate, r.srsublsn
FROM pg_subscription AS s
JOIN pg_subscription_rel AS r ON s.oid = r.srsubid
JOIN pg_class AS c ON r.srrelid = c.oid;
relname | srsubstate | srsublsn
--------------+------------+------------
orders_id_seq| r | 0/04004780

srsubstate가 r이면 그 sequence는 READY 상태로 한 번 동기화를 마쳤다는 뜻입니다. (dbi-services)

값 자체를 확인할 때는 새로 들어온 pg_get_sequence_data() 함수를 씁니다.

SELECT last_value FROM pg_get_sequence_data('public.orders_id_seq');

오류 카운트도 새로 추적됩니다. pg_stat_subscription_stats 뷰에 sync_seq_error_count 컬럼이 추가됐고, 기존 sync_error_count는 sync_table_error_count로 이름이 바뀌었습니다. sequence 오류가 별도로 집계되기 때문입니다. 모니터링 대시보드가 sync_error_count를 직접 참조하고 있었다면 19 업그레이드 때 컬럼명을 손봐야 합니다. (릴리스 노트)

PostgreSQL 18 이하 vs 19​

PG18 이하PG19
logical replication의 sequence 처리복제 제외동기화 지원
failover 후 첫 INSERTduplicate key 위험refresh 후 정상
sequence 값 맞추기setval 수동 스크립트REFRESH SEQUENCES 명령
publication 포함 방법—ALL SEQUENCES 절
동기화 방식—시점 동기화 (3개 시점)
개별 sequence 선택—불가 (전체만)
오류 추적—sync_seq_error_count

운영 점검 항목​

19로 올려 sequence 동기화를 쓰기 전에 한 번씩 확인할 자리들입니다.

cutover runbook에서는 REFRESH SEQUENCES를 publisher write 중단 후, promote 직전 칸에 둡니다. 미리 돌리면 그 사이 발급된 번호만큼 다시 어긋납니다.

publication을 설계할 때는 sequence를 개별 선택할 수 없다는 점을 고려합니다. 일부 sequence만 복제하려는 설계가 있었다면 ALL SEQUENCES 전체 발행 전제로 다시 봐야 합니다.

stale 값도 인지해야 합니다. 동기화는 연속으로 이루어지지 않으므로, 평상시 replication이 도는 동안에도 subscriber의 sequence가 따라가지 않는다는 사실을 모니터링 기준에 반영합니다.

모니터링 컬럼명도 확인합니다. pg_stat_subscription_stats의 sync_error_count를 직접 참조하던 쿼리/알람은 sync_table_error_count로 바꾸고, sync_seq_error_count도 함께 봅니다.

정리​

PostgreSQL 19의 sequence 동기화는 화려한 신기능이라기보다 logical replication에 오래 남아 있던 미비점을 보완한 변화입니다. failover 직후 첫 INSERT가 duplicate key로 터지던 사고, 그리고 이를 막으려고 setval 마진 스크립트를 돌리던 수작업이 REFRESH SEQUENCES 한 줄로 정리됐습니다.

다만 연속 동기화가 아니라는 점이 이 기능의 성격을 결정하며, 평상시 흐르는 게 아니라 cutover 시점에 한 번 맞추는 도구예요. 그래서 진짜 가치는 zero-downtime upgrade와 마이그레이션 cutover에서 나와요. runbook의 정해진 칸에 한 줄을 넣고 promote 직전에 실행하면, sequence가 더 이상 사고의 출처가 아니게 돼요.

참고 자료​

PG19 온라인 체크섬 전환

· 약 8분
2026-09-28 업데이트

data checksums 온라인 활성/비활성 기능은 2026-09-24 PostgreSQL 19 Beta 4 공지에서 revert됐습니다. PostgreSQL 19 정식 버전에서도 checksum 전환은 initdb 시점이나 정지 상태의 pg_checksums로 해야 합니다. 아래 본문은 revert 전 개발 버전 기준 기록입니다. 경위와 컨테이너 비교 결과는 PG19 Beta 4 기능 철회에 정리했습니다.

PostgreSQL 19에서 data_checksums가 또 하나의 재시작 파라미터에서 벗어났어요. 이제 실행 중인 클러스터에서 재시작도 정지도 없이 page checksum을 켜고 끌 수 있어요. SQL 함수 하나를 실행하면 background worker가 모든 page를 다시 쓰면서 checksum을 입히고, 그동안 클러스터는 평소처럼 트래픽을 받아요.

이 글은 The Build의 Christophe Pettus(크리스토프 페투스)가 정리한 "All Your GUCs in a Row: data_checksums"를 한국어로 풀고, 13년에 걸친 data_checksums의 진화사와 PostgreSQL 19가 정확히 무엇을 바꿨는지를 DBA 시선으로 봅니다. 그리고 "온라인"이라는 단어가 "공짜"나 "즉시"를 뜻하지 않는다는 점까지 함께 짚습니다.

dbalog에는 PostgreSQL 19의 "재시작 없이 바꾼다" 계열 글이 이미 두 편 있습니다. wal_level이 고정값에서 동적 floor로 바뀐 이야기와 autovacuum_worker_slots로 worker 수를 재시작 없이 조절하는 이야기입니다. data_checksums의 온라인 전환은 그 흐름 위에 올라가는 또 한 칸입니다.

data_checksums가 무엇을 막아주나​

data_checksums는 page 단위 checksum 기능을 켜는 read-only GUC입니다. 켜져 있으면 PostgreSQL은 data page를 디스크에 쓸 때마다 checksum을 계산해 page 안에 함께 적고, 그 page를 다시 읽어 올릴 때 checksum을 검증합니다.

값이 맞지 않으면 PostgreSQL은 깨진 데이터를 그대로 돌려주지 않고 error를 냅니다. 여기서 막아주는 대상은 silent data corruption, 즉 조용히 번지는 손상입니다.

  • bit rot, 디스크 위 데이터가 시간이 지나며 미세하게 망가지는 현상
  • 고장 직전의 디스크가 슬그머니 잘못된 비트를 돌려주는 경우
  • "썼다"고 응답해 놓고 실제로는 쓰지 않은 storage layer의 거짓말

이런 손상은 error 없이 흘러갑니다. checksum이 없으면 PostgreSQL은 깨진 page를 멀쩡한 데이터로 믿고 그대로 읽어 들이고, 그 위에 연산을 쌓습니다. 문제를 알아챌 무렵엔 이미 backup에까지 손상이 번진 뒤입니다.

검증에 실패하면 PostgreSQL은 pg_stat_database의 checksum_failures 카운터를 올립니다. DBA는 이 값을 모니터링해 손상이 처음 감지된 시점을 잡아낼 수 있습니다.

데이터 페이지에 체크섬을 기록하고 읽을 때 검증하는 흐름

비용은 어떻습니까. 2013년 도입 당시엔 checksum 계산 부담이 켤 가치가 없을 만큼 크다고 봤습니다. 그래서 기본값이 off였습니다. 그 뒤로 하드웨어가 좋아지면서 오버헤드는 한 자릿수 퍼센트 초반대까지 내려왔고, 손상을 조기에 잡는 가치에 비하면 충분히 감당할 만한 수준이 됐습니다.

과거엔 켜기가 왜 고통이었나​

문제는 비용이 아니라 켜는 방법이었습니다. PostgreSQL 18까지 data_checksums를 켜는 길은 둘뿐이었고, 둘 다 운영 클러스터에는 무겁습니다.

첫째, initdb 시점에 정하는 것입니다. 클러스터를 처음 만들 때 checksum을 켜두면 그 클러스터는 평생 켜진 상태로 삽니다. 깔끔하지만 시점이 고약합니다. 이미 몇 년째 돌고 있는 운영 클러스터에는 적용할 길이 없습니다. "처음부터 켰어야 했다"는 후회만 남습니다.

둘째, pg_checksums로 오프라인 전환하는 것입니다. PostgreSQL 12에서 추가된 이 명령은 멈춰 있는 클러스터의 checksum 설정을 바꿔줍니다. 강조점은 "멈춰 있는"입니다.

# 반드시 클러스터를 먼저 정지한 상태에서 실행
pg_ctl -D /var/lib/pgsql/data stop
pg_checksums --enable -D /var/lib/pgsql/data
pg_ctl -D /var/lib/pgsql/data start

pg_checksums는 모든 heap과 index page를 한 장씩 읽어 checksum을 계산해 다시 씁니다. 멀티 테라바이트 클러스터라면 이 작업만 몇 시간이 걸립니다. 그리고 그 몇 시간 내내 클러스터는 내려가 있어야 합니다.

DBA에게 이건 사실상 "큰맘 먹고 잡는 점검 시간"입니다. 서비스 중단 공지를 내고, 새벽 시간을 확보하고, 작업이 예상보다 길어질 경우까지 대비해야 합니다. 그래서 많은 운영 클러스터가 checksum의 가치를 알면서도 "지금 켜기엔 다운타임이 부담"이라는 이유로 off인 채 남았습니다.

PostgreSQL 18에서 한 발 나아가긴 했습니다. initdb의 기본값이 checksum 켜짐으로 바뀌어, 새로 만드는 클러스터는 별도 조치 없이 checksum을 켠 채 출발합니다. 하지만 이는 새 클러스터 이야기입니다. 이미 돌고 있는 클러스터의 고민은 그대로였습니다.

PostgreSQL 19의 온라인 전환​

PostgreSQL 19는 마지막 매듭을 풉니다. 클러스터를 멈추지 않고 재시작도 없이 SQL 함수 호출만으로 checksum을 켜고 끕니다. 새로 들어온 함수는 둘입니다.

pg_enable_data_checksums(cost_delay integer DEFAULT 0, cost_limit integer DEFAULT 100)
pg_disable_data_checksums()

켜는 동작은 이렇게 실행합니다.

SELECT pg_enable_data_checksums();

이 함수는 곧바로 반환됩니다. 밀리초 단위입니다. 하지만 그 시점에 checksum이 다 입혀진 것은 아닙니다. 실제 작업은 background에서 비동기로 흐릅니다.

내부 동작은 이렇습니다. background worker launcher가 데이터베이스마다 per-database worker를 띄웁니다. 이 worker는 storage를 가진 모든 relation의 buffer를 dirty로 표시합니다. dirty page는 디스크로 다시 쓰일 때 checksum을 계산해 함께 적게 됩니다. 모든 데이터베이스의 모든 relation이 처리되고 나면, 그제야 data_checksums 상태가 on으로 넘어갑니다.

OS의 process 목록에서도 이 launcher와 worker가 보입니다.

postgres: datachecksum launcher
postgres: datachecksum worker

진행 중에 data_checksums가 가질 수 있는 상태는 다음과 같습니다.

상태의미
offchecksum 꺼짐
inprogress-on켜는 중, page 재작성 진행 중
on켜짐, 모든 page 처리 완료
inprogress-off끄는 중

SHOW로 현재 상태를 확인합니다.

SHOW data_checksums;
-- 진행 중: inprogress-on
-- 완료 후: on

끄는 동작도 같은 방식입니다.

SELECT pg_disable_data_checksums();

이 모든 과정에서 클러스터는 멈추지 않습니다. 읽기도 쓰기도 평소처럼 받습니다. PostgreSQL 19에서는 다운타임이 사라졌습니다.

전환 흐름 비교​

오프라인 전환과 온라인 전환의 차이를 한눈에 보면 이렇습니다.

PostgreSQL 18 이하의 경로는 정지-작업-재기동이라는 다운타임 구간을 통과해야 합니다. PostgreSQL 19는 그 구간 자체가 없습니다. 작업은 가동 중인 클러스터 위에서 background로 흐릅니다.

상태 전이로 보면 켜는 과정은 다음과 같이 움직입니다.

여기서 DBA가 기억할 점이 하나 있습니다. inprogress-on은 "켜지는 중"이지 "켜짐"이 아닙니다. 모든 page가 처리되기 전까지는 on으로 넘어가지 않습니다.

DBA 관점에서 보는 온라인 전환의 시간과 비용​

함수가 밀리초 만에 반환된다고 해서 작업이 끝난 것은 아닙니다. 이 지점에서 운영 실수가 갈립니다. Christophe Pettus는 이 작업의 무게를 "minor version 업그레이드와 전체 클러스터 VACUUM FULL 사이 어디쯤"으로 보고 계획하라고 권합니다.

기억할 점을 정리하면 이렇습니다.

진짜 작업은 background에서 진행됩니다. 함수 반환은 시작 신호일 뿐이며, 멀티 테라바이트 클러스터에서 모든 page를 재작성하려면 몇 시간이 걸립니다.

이 작업은 다른 작업과 자원을 다툽니다. page 재작성은 autovacuum, 평상시 워크로드, backup 작업과 디스크 I/O를 두고 경쟁합니다. burst IOPS를 쓰는 클라우드 인스턴스라면 burst 예산을 일찍 소진하고 throttle 구간에 들어갑니다.

중간에 끊으면 일부 page에는 checksum이 입혀지고 일부에는 입혀지지 않은 mixed state로 남습니다. 이 상태 자체는 안전하지만, 작업이 끝나기 전까지 data_checksums는 on으로 넘어가지 않습니다. 오래 머물 상태는 아닙니다.

그래서 켜는 함수에는 throttle 제어가 붙어 있습니다. cost_delay와 cost_limit은 autovacuum의 vacuum cost 의미를 그대로 따릅니다. worker가 cost_limit만큼 작업 단위를 쌓을 때마다 cost_delay 밀리초씩 쉬게 해서, 평상시 워크로드에 주는 압박을 낮춥니다.

-- worker가 자원을 덜 차지하도록 천천히 진행
SELECT pg_enable_data_checksums(cost_delay => 1, cost_limit => 3000);

운영 현장에서 잡을 체크리스트는 단순합니다.

  • 트래픽이 한가한 시간대를 골라 시작합니다.
  • autovacuum이 급한 작업을 들고 있지 않은지 확인합니다.
  • backup window와 겹치지 않게 합니다.
  • 진행 중에는 pg_stat_io로 I/O 부하를, replication을 쓴다면 replica lag을 함께 봅니다.

PostgreSQL 19의 이번 변화를 앞선 두 글과 나란히 놓으면 방향이 또렷합니다. wal_level은 재시작 파라미터에서 동적 floor로, autovacuum worker 수는 재시작 없이 조절 가능하게, 그리고 이제 data_checksums는 클러스터를 멈추지 않고 켜고 끄는 대상이 됐습니다. "운영 중에 바꾸려면 재시작/정지가 필요하던 설정"의 목록이 한 칸씩 줄고 있습니다.

다만 방향이 같다고 비용까지 같진 않습니다. wal_level의 effective level 전환은 다음 checkpoint면 끝나지만, data_checksums의 온라인 전환은 디스크 위 모든 page를 다시 쓰는 무거운 작업입니다. "재시작이 사라졌다"와 "부담 없이 켤 수 있다"는 다른 말입니다. 다운타임은 없앴지만, I/O 비용과 소요 시간은 그대로 DBA의 계획표 위에 남습니다.

정리​

  • data_checksums는 page 단위 checksum으로 silent data corruption을 조기에 잡아주는 기능입니다. 실패는 pg_stat_database.checksum_failures로 드러납니다.
  • PostgreSQL 18까지는 initdb 시점에 고정하거나, 클러스터를 정지하고 pg_checksums로 오프라인 전환해야 했습니다. 운영 클러스터엔 다운타임이 부담이었습니다.
  • PostgreSQL 18부터 initdb 기본값이 켜짐으로 바뀌어, 새 클러스터는 별도 조치 없이 checksum을 켠 채 출발합니다.
  • PostgreSQL 19는 pg_enable_data_checksums() / pg_disable_data_checksums()로 가동 중인 클러스터에서 재시작 없이 켜고 끌 수 있게 했습니다. background worker가 모든 page를 다시 쓰며, 진행 중 상태는 inprogress-on / inprogress-off로 보입니다.
  • 다운타임은 사라졌지만 작업 자체는 무거워요. 멀티 테라바이트 클러스터에서 몇 시간이 걸리고 다른 I/O와 경쟁하므로, cost_delay / cost_limit throttle과 한가한 시간대 시작이 필요해요.

출처​

PG19 Beta 핵심 변화 4가지

· 약 12분
2026-09-28 업데이트

3절의 UPDATE/DELETE ... FOR PORTION OF는 2026-09-24 PostgreSQL 19 Beta 4 공지에서 revert됐습니다. PostgreSQL 19 정식 버전에는 들어가지 않으므로 업그레이드 체크리스트의 해당 항목은 적용되지 않습니다. 나머지 세 항목은 이 공지의 revert 목록에 없습니다. 공지 기준 GA 예상 시점도 10월로 바뀌었습니다. 경위와 컨테이너 비교 결과는 PG19 Beta 4 기능 철회에 정리했습니다.

Big 4를 한눈에​

Christophe Pettus(크리스토프 페투스)가 The Build에서 정리한 PostgreSQL 19 Beta Big 4에는 공통점이 있어요. 넷 다 피처 노트로 끝나지 않고 운영 시나리오를 바꾼다는 점이에요. 신규 syntax 한 줄에 그치지 않고, DBA의 메모리 계산식, 장애 시나리오, 튜닝 기본값을 다시 그리게 만들어요.

업데이트 (2026-06-04): PostgreSQL 19 Beta 1이 공식 릴리스됐습니다. 이 글이 예고한 Big 4는 모두 Beta 1에 그대로 담겼습니다: 64bit MultiXact members(Make multixid members 64-bit), 병렬 autovacuum worker(autovacuum_max_parallel_workers), UPDATE/DELETE FOR PORTION OF, JIT 기본 비활성화입니다. 정식 출시는 9월~10월 예정이며, 그사이 release candidate가 한 차례 이상 나옵니다.

PostgreSQL 19는 9월 정식 출시 예정입니다. 이번 글은 Pettus의 Big 4 큐레이션을 출발점으로 삼아, 거기에 DBA 점검 항목, 메모리 계산식, SQL 예시, 업그레이드 체크리스트를 보탠 글입니다. 네 가지 선정은 Pettus의 시각이고, 운영 점검, 메커니즘 해설, 업그레이드 절차는 기존 PG19 시리즈 5편이 다루지 않은 부분을 채우는 보완/확장입니다.

왜 이 Big 4인가​

PostgreSQL 19의 변경 목록은 CommitFest 5회분의 수백 건입니다. 그중 DBA가 실제로 체감하는 변화는 보통 둘 중 하나입니다.

  • 장애 회피: 지금까지 운영 매뉴얼에 "이거 터지면 끝"으로 적혀 있던 시나리오가 사라집니다.
  • 튜닝 기본값 변경: 업그레이드 직후 워크로드 성격에 따라 plan 시간, 메모리, 비상 상황이 달라집니다.

Pettus가 꼽은 Big 4는 정확히 그 두 축에 들어갑니다. 새 syntax나 새 알고리즘이라기보다, 기존 운영 매뉴얼을 손보게 만드는 변경들입니다.

변경축영향
64bit MultiXact members장애 회피"긴급 vacuum"이라는 운영 시나리오가 사라집니다
병렬 autovacuum 인덱스 worker튜닝 기본값 변경최악 메모리 사용량 식이 달라집니다
UPDATE/DELETE FOR PORTION OF신규 운영 영역row trigger/cascading FK 동작에 새 사각지대
jit = off (기본)튜닝 기본값 변경OLAP는 명시 활성화하지 않으면 회귀합니다

1. 64bit MultiXact members가 없앤 긴급 vacuum 시나리오​

무엇이 바뀌었나​

MultiXact 멤버 카운터가 32bit에서 64bit로 확장됐습니다. 4 billion(약 40억) 멤버 공간 고갈 시나리오가 사실상 사라집니다.

DBA가 왜 체감하나​

PostgreSQL에는 DBA 사이에서 "이게 터지면 끝"으로 통하는 시나리오가 몇 개 있습니다. MultiXact wraparound는 거기 한참 머물러 있었습니다.

SELECT ... FOR SHARE, foreign-key check 같은 공유 row lock이 같은 행에 여러 트랜잭션에서 동시에 걸리면, PostgreSQL은 그 잠금 정보를 묶어서 MultiXact라는 별도 구조에 기록합니다. 각 MultiXact는 멤버 슬롯을 소비합니다. 그 멤버 카운터의 자료형이 PostgreSQL 19에서 처음으로 확장됐습니다.

구분타입최대값
기존 (PG 18 이하)uint32 (32비트)약 42억 9천만
변경 (PG 19~)uint64 (64비트)약 1844경

42억은 운영 부하가 큰 워크로드(공유 lock + FK check 누적)에서 실제로 도달할 수 있는 수치입니다. 1844경은 사실상 도달이 불가능한 영역으로 넘어갑니다.

주의: XID/mxid wraparound는 그대로다​

DBA가 흔히 "wraparound"라고 묶어 부르는 경우는 사실 셋입니다. 이번 변경은 그중 하나에 한정됩니다.

wraparound 종류PG 19에서 변경운영 영향
MultiXact members 카운터uint32 → uint64사실상 해결
MultiXact ID (mxid)변경 없음 (32bit)autovacuum 의존, anti-wraparound vacuum 그대로
Transaction ID (xid)변경 없음 (32bit)autovacuum의 freeze 책임 그대로

PostgreSQL의 오래된 숙제인 XID wraparound와 mxid wraparound는 PostgreSQL 19에서도 살아 있습니다. autovacuum이 주기적으로 freeze를 수행해야 하는 이유가 정확히 이것입니다. 이번 글이 다루는 "해결된 wraparound"는 MultiXact members 카운터 하나뿐임을 분명히 해 둡니다.

64bit XID 확장은 2018년부터 PostgreSQL hackers 메일링 리스트에서 논의돼 왔습니다. Postgres Pro가 PostgreSQL 15 시기에 실험적 패치 시리즈로 동작 확인까지 갔지만, tuple 헤더 크기 증가, 모든 index format 재설계, WAL format 변경 같은 광범위한 ripple effect 때문에 main 브랜치 commit으로 이어지지 않았습니다. PostgreSQL 19에서도 같은 상태입니다. PostgreSQL의 가장 오래된 숙제 중 하나가 한 라운드 더 미뤄졌고, 그 사이 DBA는 autovacuum freeze 튜닝과 pg_visibility/pg_class.relfrozenxid 모니터링을 손에서 놓을 수 없습니다.

운영 영향은 이렇게 정리됩니다.

항목PG 18까지PG 19 Beta
MultiXact members 고갈 시새 트랜잭션 거부 + offline emergency VACUUM사실상 도달 불가
XID/mxid wraparoundautovacuum freeze로 방어그대로이며 모니터링을 유지합니다
운영 매뉴얼 항목MultiXact members 항목은 "이거 터지면 끝" 목록의 한 줄members 항목만 삭제 후보. XID/mxid는 유지

42억 한계에 도달하면 DBA가 마주하는 그림은 단순했습니다. 새 트랜잭션이 거부되고, 복구 경로는 offline emergency VACUUM 하나. Pettus의 한 줄이 이를 짚습니다.

"When exhausted, the system would refuse new transactions, and the only recovery path was an emergency VACUUM."

이론적으로 wraparound 수학은 2^64에서도 여전히 존재합니다. 다만 같은 워크로드를 우주의 나이만큼 굴려도 도달이 어렵습니다. 32bit 공간 도달 사례는 운영 사고 보고서들에 종종 등장했지만, 64bit는 실무 카탈로그에서 제외할 수 있습니다.

DBA가 할 일​

  • 운영 매뉴얼/런북에서 "MultiXact wraparound" 비상 절차 항목을 "PG 19부터는 도달 불가"로 갱신
  • 단, PostgreSQL 18 이하 운영 중인 클러스터는 그대로 이 시나리오가 살아 있으니 모니터링 대시보드를 유지

Beta 1에는 이 부분을 보강하는 변경도 함께 들어왔습니다. multixact 활동을 보여 주는 pg_get_multixact_stats() 함수가 추가됐고, xid/multixact wraparound 경고 임계값이 기존 4천만에서 1억 건 남았을 때로 올라갔습니다(클라이언트와 서버 로그 양쪽에 경고). 여전히 살아 있는 xid/mxid wraparound를 그만큼 더 일찍 알아챌 수 있습니다.

2. 병렬 autovacuum 인덱스 worker와 메모리 계산식​

무엇이 바뀌었나​

새 GUC autovacuum_max_parallel_workers가 추가됩니다. autovacuum이 단일 테이블의 인덱스 정리를 여러 worker로 병렬 처리합니다. PostgreSQL 13에서 도입된 manual VACUUM의 병렬 인덱스 정리가 autovacuum 경로까지 확장됐습니다.

DBA가 왜 체감하나​

인덱스가 많이 붙은 wide table을 운영해 본 사람은 압니다. autovacuum이 돌면 heap 정리는 빠른데 인덱스 정리 단계에서 한참 멈춰 있습니다. 단일 worker가 인덱스 N개를 순차로 도는 구조였기 때문입니다.

PostgreSQL 19에서는 같은 단계에 worker가 동시에 들어갑니다. wide-and-many-indexes 테이블의 autovacuum 시간이 줄어들고, vacuum 누적 부담이 풀립니다.

-- 새 GUC (값은 예시)
ALTER SYSTEM SET autovacuum_max_parallel_workers = 4;
SELECT pg_reload_conf();

주의: 최악 메모리 식이 달라진다​

같은 변경이 운영 부담을 새로 만드는 지점도 있습니다. 각 병렬 worker는 자기 몫의 maintenance_work_mem을 따로 잡습니다.

항목PG 18까지PG 19 Beta
autovacuum 최악 메모리autovacuum_max_workers × maintenance_work_memautovacuum_max_workers × autovacuum_max_parallel_workers × maintenance_work_mem

autovacuum_max_workers=3, autovacuum_max_parallel_workers=4, maintenance_work_mem=1GB인 환경에서 최악 메모리 사용량은 3 × 4 × 1GB = 12GB가 됩니다. PostgreSQL 18까지의 3GB와 4배 차입니다.

병렬 autovacuum 도입 전후 최악 메모리 사용량 비교

DBA는 PostgreSQL 19 업그레이드 전에 세 가지를 확인합니다.

  1. maintenance_work_mem 현재 값
  2. autovacuum_max_workers 현재 값
  3. 위 두 값에 autovacuum_max_parallel_workers를 곱한 값이 시스템 메모리에 맞는가

maintenance_work_mem을 1GB 이상으로 키워 놓은 운영 환경(분석 워크로드/큰 인덱스 재정렬 빈도가 잦은 곳)은 특히 다시 계산해야 합니다.

"명백한 이득"이 보이는 경우는 한정적입니다. serial 인덱스 정리가 vacuum의 병목이던 wide table에서 가장 큰 폭으로 줄어듭니다.

DBA가 할 일​

  • 업그레이드 전 메모리 식 재계산. RAM이 빠듯하면 autovacuum_max_parallel_workers를 기본보다 낮춰 시작
  • wide table/인덱스 다수 테이블에서 autovacuum 지속 시간 모니터링 (pg_stat_progress_vacuum)
  • 한꺼번에 너무 많은 worker가 깨어나 I/O 포화되지 않도록 autovacuum_vacuum_cost_limit 검토

3. UPDATE ... FOR PORTION OF, SQL:2011 시간 범위가 PostgreSQL로​

무엇이 바뀌었나​

시간 범위(period) 컬럼을 가진 테이블에서 부분 범위만 UPDATE 또는 DELETE할 수 있게 됐습니다. SQL:2011 표준 syntax입니다.

-- 가격 이력 테이블 (daterange 컬럼 사용)
CREATE TABLE price_history (
product_id int,
price numeric,
valid_period daterange
);

-- 2026-06-01 ~ 2026-06-15 구간만 가격 변경
UPDATE price_history
FOR PORTION OF valid_period
FROM DATE '2026-06-01' TO DATE '2026-06-15'
SET price = price * 0.9
WHERE product_id = 42;

이전에는 DBA가 직접 row를 자르고 새 row를 끼워 넣는 SQL을 손으로 썼습니다. PostgreSQL 19부터는 한 문장으로 끝납니다. 건드리지 않은 범위의 row는 자동으로 보존됩니다.

DBA가 왜 체감하나​

가격 이력, 직원 직책 이력, 권한 이력, 보험료 이력처럼 시간 범위 컬럼을 두는 모델은 흔합니다. 매번 손으로 자르는 SQL을 짜는 대신 syntax 한 줄로 정리되는 경우가 늘어납니다.

다만 새 운영 사각지대도 함께 생깁니다.

주의: row trigger/cascading FK​

Pettus가 직접 짚습니다.

"a FOR PORTION OF update can fire row triggers on rows that did not exist when the statement started."

(FOR PORTION OF UPDATE는 문이 시작될 때 존재하지 않던 row에 대해서도 row trigger를 발화시킬 수 있다.)

내부적으로 UPDATE ... FOR PORTION OF는 원본 row에 UPDATE를 적용해 범위를 대상 구간으로 좁히고, 대상 밖에 남는 구간은 새 보존 row(최대 두 개)로 INSERT합니다. 그 결과 UPDATE 트리거가 원본 row에, INSERT 트리거가 새 보존 row마다 발화합니다. 기능 추가 커밋의 트리거 문서는 BEFORE UPDATE, BEFORE INSERT, AFTER INSERT, AFTER UPDATE 순서로 설명합니다.

부분 범위 갱신이 원본 행을 분리하고 트리거를 실행하는 구조

cascading foreign-key 동작은 한 단계 더 복잡해집니다. parent 행의 시간 범위가 잘리면 child의 FK 동작이 어떻게 따라가는지 production에서 테스트하지 않고 올리면 사고 가능성이 큽니다. parent의 부분 UPDATE가 child를 DELETE해야 하는가, CASCADE해야 하는가, RESTRICT해야 하는가. 이런 운영 정책 결정이 SQL 한 줄 안에 묻혀 들어갑니다.

DBA가 할 일​

  • FOR PORTION OF를 production에 올리기 전에 row trigger, FK, audit trigger 동작을 staging에서 전부 확인
  • 트리거가 row 단위로 카운트를 세는 코드(예: audit row 개수 검증)는 PostgreSQL 19에서 결과가 다를 수 있으니 재검토

4. jit = off 기본값, PostgreSQL 12 이후 처음의 방향 전환​

무엇이 바뀌었나​

Just-in-time compilation 기본값이 off로 바뀝니다. PostgreSQL 12에서 jit = on이 기본값으로 들어온 이후 처음 있는 방향 전환입니다.

DBA가 왜 체감하나​

이 변경은 워크로드 성격에 따라 정반대 결과를 만듭니다.

워크로드PG 18까지PG 19 Beta
OLTP (짧은 쿼리 다수)JIT plan 오버헤드가 매 쿼리마다 누적plan 시간 단축, 응답성 개선
OLAP (긴 분석 쿼리)JIT 컴파일 후 실행 가속명시적으로 jit = on 안 켜면 회귀

Pettus는 OLAP 회귀의 크기를 한 줄로 묘사합니다.

"a six-minute report now takes nineteen."

(6분짜리 리포트가 19분 걸리게 된다.)

3배 회귀 사례를 예고하는 대목입니다. OLAP 클러스터를 PostgreSQL 19로 올리면서 jit = on을 명시 설정하지 않으면, 출시 직후 가장 먼저 받는 분기 보고서가 평소보다 한참 늦게 도착합니다.

DBA가 할 일​

OLTP 클러스터는 그대로 두면 plan 효율이 자연스럽게 개선되므로 별도 설정이 필요 없습니다. OLAP/분석 클러스터는 업그레이드 직후 ALTER SYSTEM SET jit = on;을 실행하거나, 분석 사용자에게 ALTER ROLE ... SET jit = on;으로 user-level 설정을 지정합니다. 혼합 워크로드는 pgBouncer 등의 connection pool에서 분석 트래픽을 분리한 뒤 그쪽에만 jit = on을 적용합니다.

왜 방향이 바뀌었는가​

PostgreSQL 12에서 JIT를 기본 활성화한 동기는 "분석 쿼리에서 측정 가능한 가속이 있다"였습니다. 하지만 운영 통계가 쌓이면서, OLTP가 절대 다수인 PostgreSQL 운영 현실에서 plan 시간 오버헤드 손실이 컴파일 가속 이득을 자주 초과한다는 점이 드러났습니다. 6년 만에 측정에 근거해 기본값을 되돌린 것입니다.

PostgreSQL 19 업그레이드 체크리스트 (이번 Big 4)​

업그레이드 전 다음을 점검합니다.

점검 항목명령 / 확인
maintenance_work_mem 현재 값SHOW maintenance_work_mem;
autovacuum_max_workers 현재 값SHOW autovacuum_max_workers;
최악 메모리 사용량 재계산autovacuum_max_workers × autovacuum_max_parallel_workers × maintenance_work_mem
OLAP/혼합 워크로드 식별분석 ETL/BI 도구 사용 여부
시간 범위 컬럼 사용 테이블 식별\d+ 로 range 타입 컬럼 점검
MultiXact 모니터링 운영 매뉴얼"PG 19부터 도달 불가" 주석

업그레이드 직후에는 다음을 즉시 확인합니다.

-- 1. autovacuum 동작 확인
SELECT * FROM pg_stat_progress_vacuum;

-- 2. JIT 설정 확인 (OLAP면 명시 on)
SHOW jit;

-- 3. 새 GUC 확인
SHOW autovacuum_max_parallel_workers;

정리​

앞의 표에서 정리했듯 64bit MultiXact members는 최악 wraparound 시나리오를 제거하지만 PG 18 이하 클러스터 모니터링은 유지해야 하고, 병렬 autovacuum 인덱스 worker는 wide table autovacuum 시간을 단축하지만 최악 메모리 사용량 식이 달라집니다. UPDATE/DELETE FOR PORTION OF는 시간 범위 SQL을 한 줄로 정리하는 대신 row trigger/cascading FK 동작을 재검증해야 하고, 기본 jit = off는 OLTP plan 시간을 단축하지만 OLAP는 명시 활성화 안 하면 회귀합니다.

PostgreSQL 19는 화려한 신기능보다 운영 매뉴얼을 다시 쓰게 만드는 쪽에 가까운 릴리스예요. DBA 입장에서 이번 Big 4는 9월 정식 출시 전에 staging에서 한 번 돌려 보고, 메모리 식, 튜닝 기본값, 운영 매뉴얼 세 가지를 같이 갱신해야 해요.

기존 PostgreSQL 19 새 기능 총정리와 PostgreSQL 19 시리즈 4편이 "어떤 기능이 들어오는가"를 봤다면, 이번 글은 "어떤 운영이 달라지는가"를 봅니다. 두 글을 같이 두면 PostgreSQL 19를 올리기 전 점검할 항목이 한곳에 모입니다.

참고​

PG19 동적 wal_level

· 약 7분

PostgreSQL 19에서 wal_level이 의미를 바꿨어요: 더 이상 "이 서버가 항상 쓰는 WAL 레벨"이 아니라 하한값이며, 실제 effective level은 그 위에서 slot 상태에 따라 자동으로 움직여요. logical replication slot이 하나 생기면 effective level이 logical로 올라가고, 마지막 slot이 사라지면 다음 checkpoint에서 내려가요.

이 글에서는 The Build의 Christophe Pettus(크리스토프 페투스)가 다룬 "The wal_level You Set Is Not the wal_level You Get"을 한국어로 풀고, cascading standby와 archived WAL에서 운영자가 한 번은 부딪힐 자리들을 함께 살펴봅니다.

보험성 wal_level=logical 의 비용​

기존 PostgreSQL에서 wal_level은 minimal, replica, logical 셋 중 하나로 고정돼 있었습니다. 변경하려면 서버를 재시작해야 했습니다.

운영 패턴이 자연스럽게 굳어졌습니다. 지금 당장 logical replication을 쓰지 않더라도, 나중에 쓸 가능성이 조금이라도 있으면 일단 logical로 잡아둡니다. 그래야 그날 새벽에 슬롯 하나 만들면서 재시작을 잡을 일이 없습니다. "혹시 모르니까" 라는 안전망입니다.

이 안전망에는 비용이 붙습니다.

  • wal_level = logical은 모든 변경에 대해 추가 메타데이터를 WAL에 적습니다. row의 이전 이미지, replica identity 정보, 다중행 변경의 stream 표식 등입니다.
  • 실제로 logical replication slot이 단 하나도 없어도 적힙니다. "지금 안 쓰는데 적어두는 값"이 분 단위로 디스크에 흐릅니다.
  • 운영 환경에 따라 다르지만, replica 대비 WAL 볼륨이 10~30% 더 늘어나는 케이스가 흔히 보고됩니다.
  • WAL 볼륨이 늘면 archive 비용, streaming replication 대역폭, PITR 복구 시간이 같이 늘어납니다.

"The right thing has corners." — Christophe Pettus, The Build

PostgreSQL 19는 이 안전망의 비용을 덜기 위해 wal_level을 "고정 레벨"에서 "최소 보장"으로 바꿨습니다.

PG19의 configured wal_level과 effective wal_level​

PostgreSQL 19에서 서버는 두 개의 WAL level을 가집니다.

configured wal_level은 postgresql.conf에 적힌 값입니다. 여전히 재시작 파라미터이며, "이 서버가 어떤 경우에도 떨어지지 않을 하한값"을 뜻합니다. effective wal_level은 서버가 실제로 지금 WAL에 쓰는 레벨이며, configured 값보다 위로 올라갈 수 있습니다.

규칙은 단순합니다.

상태effective wal_level
configured replica, 유효한 logical replication slot이 하나 이상 있음logical
configured replica, 유효한 logical replication slot 없음replica
configured logicallogical

즉, configured를 replica로 잡아두고도 logical replication slot을 만드는 순간 서버가 알아서 logical로 올라갑니다. 마지막 slot이 사라지면 다음 checkpoint에서 다시 내려갑니다.

상태 전이도​

중요한 지점은 두 군데입니다 — logical 진입 시점은 forced checkpoint 직후이고, 다시 내려가는 시점은 그다음 일반 checkpoint입니다. 두 시점은 비대칭입니다.

동작 단계​

올라갈 때와 내려갈 때를 따로 봅니다.

올라갈 때: logical로의 전이

  1. 운영자(또는 CREATE SUBSCRIPTION)가 첫 logical replication slot 생성을 요청합니다.
  2. 서버는 effective level이 logical 미만이면 즉시 forced checkpoint를 돕니다.
  3. 이 checkpoint 직후의 LSN을 "guaranteed LSN"으로 기록하고, slot은 그 지점부터 시작합니다.
  4. 그 LSN 이후의 WAL은 logical 레벨 메타데이터를 함께 적기 시작합니다.

내려갈 때: replica로의 하강

  1. 마지막 logical slot이 사라집니다.
  2. 서버는 다음 일반 checkpoint까지 그대로 logical을 유지합니다. 즉시 내리지 않습니다.
  3. 그 checkpoint가 돌고 나면 effective level이 configured 값으로 복귀합니다.
  4. 이후 WAL은 다시 replica 메타데이터로 가벼워집니다.

이 비대칭은 의도된 설계입니다. 올라갈 때는 slot이 막 만들어진 WAL을 읽지 못하면 안 되므로 checkpoint를 즉시 실행합니다. 내려갈 때는 별도 checkpoint를 잡아 운영 영향을 만들 이유가 없습니다.

운영 영향​

가장 큰 효과는 WAL 볼륨 절감입니다.

  • "혹시 모르니까 logical"로 잡아둔 클러스터는, configured를 replica로 내리고 PG19로 올리면 그대로 WAL이 줄어듭니다. 실제로 logical을 쓸 일이 생기면 그 순간 자동으로 올라가니 운영 부담은 늘지 않습니다.
  • archive 비용, streaming replication 대역폭, PITR 복구 시간이 모두 따라 줄어듭니다.
  • 클라우드 관리형 PostgreSQL에서는 archive 저장 비용이 매월 청구되는 항목이라 체감이 큽니다.

부차 효과는 logical replication을 사용 중인 동안에만 그 비용을 낸다는 점입니다. 일회성 마이그레이션을 위해 logical을 켰다가 작업을 마치면 비용이 자동으로 사라집니다.

함정과 주의사항​

네 가지를 짚습니다.

첫째, standby의 logical slot입니다. standby는 primary가 만든 WAL을 재생하므로, standby에 만든 logical slot은 primary의 effective level에 기대어 동작합니다. PostgreSQL 19 문서는 configured wal_level이 replica일 때 primary의 마지막 logical slot을 제거하거나 무효화하면 primary의 logical decoding이 꺼지고, 그 결과 standby의 logical slot이 무효화된다고 경고합니다. 무효화는 standby가 WAL 스트림에서 이 변화를 감지하는 즉시 일어납니다. Pettus의 원문은 standby의 effective level이 primary의 slot 상태와 독립적이라고 설명하지만, 이 부분은 공식 문서의 경고를 기준으로 봐야 합니다. standby에서 logical slot을 운영한다면 primary의 마지막 slot을 정리하기 전에 이 영향을 먼저 확인해야 합니다.

Primary의 effective wal_level이 replica로 내려가면 standby의 logical slot이 무효화되는 구조

둘째, archive에 mixed-level WAL이 섞입니다. 같은 archive 디렉토리에 replica 시기의 WAL과 logical 시기의 WAL이 함께 쌓입니다. archive 자체가 깨지는 것은 아니지만, "이 archive는 통째로 logical이다" 같은 가정을 두고 작성한 PITR script가 있다면 다시 봐야 합니다.

셋째, prepared transaction입니다. 2-phase commit 자체의 동작은 변하지 않습니다. 다만 prepared transaction이 많은 환경에서는 logical 전이 시점의 forced checkpoint가 의외로 오래 걸릴 수 있습니다. 테스트 환경에서 한 번은 의도적으로 재현해 보고 timeout 설정을 검토하는 편이 좋습니다.

넷째, 첫 slot 생성 시 checkpoint 대기입니다. 새 subscription이 publisher에 logical slot을 만들면 그 호출이 forced checkpoint를 기다립니다. 평소 checkpoint 부담이 큰 시스템에서는 수십 초가 걸릴 수도 있습니다. 자동화 script가 CREATE SUBSCRIPTION 호출에 짧은 timeout을 걸어뒀다면 PG19 업그레이드 직후 한 번 흔들릴 가능성이 있습니다.

PITR / archive script 점검 포인트​

PostgreSQL 19로 올라가기 전에 한 번씩 확인할 자리들입니다.

archive level과 관련해서는 script가 "이 archive는 logical 레벨"임을 전제로 메타데이터를 파싱하지 않는지 확인합니다. slot 생성 timeout은 자동화 도구의 CREATE SUBSCRIPTION / pg_create_logical_replication_slot 호출에 30초 미만으로 설정되어 있지 않은지 살펴봅니다.

WAL 볼륨 모니터링에서는 PG19로 올린 직후 WAL 생성률이 줄어드는 변화를 정상으로 인식하는지 확인합니다. 감소를 장애로 판단해 알람을 보내지 않도록 해야 합니다. cascading 구성에서는 어느 standby에 logical slot이 있는지, primary의 effective level이 내려갈 때 그 slot이 어떤 영향을 받는지 그림으로 한 번 정리해 두는 편이 좋습니다.

"혹시 모르니까"라는 비용​

logical replication을 본격적으로 쓰기 시작한 계기는 zero-downtime upgrade와 CDC 파이프라인입니다. 한 번 켜놓으면 끝나는 것이 아니라 "혹시 다음 마이그레이션 때 또 쓸지 모르니까" wal_level = logical 그대로 두는 패턴이 굳어졌습니다. 그 사이 WAL 볼륨이 10~30% 더 흐르고, archive 비용과 streaming replication 대역폭이 같이 늘어납니다. 평소에는 의식하지 못하지만 PITR 복구 시간을 재 보면 한 번씩 드러납니다. PostgreSQL 19의 동적 wal_level은 이 "보험 비용"을 시점성 비용으로 바꿉니다. 일회성 마이그레이션이 끝나면 slot만 정리해도 effective level이 자동으로 내려가고 WAL이 가벼워집니다. 운영자가 wal_level을 내릴지 말지 회의에 올릴 일이 없어진다는 점이 PG19가 일상에 주는 가장 큰 차이입니다.

정리​

PG18 이하PG19
wal_level 의 의미고정값하한값 (floor)
logical 전환 비용항상 부담slot 있는 동안만
WAL 볼륨 (logical 미사용 시)풀 부담replica 수준
첫 logical slot 생성즉시forced checkpoint 후
마지막 slot 제거 후 하강n/a다음 checkpoint

PostgreSQL 19는 운영자가 "혹시 모르니까"라는 이유로 영구히 짊어졌던 비용을 시점성 비용으로 바꿔, logical replication이 필요한 그 순간에만 그 비용을 내게 해요. 기본 동작이 더 똑똑해진 변화지만, standby의 logical slot과 archive처럼 effective level 변화의 영향을 받는 곳에서는 모서리가 한 번씩 보여요.

참고 자료​

PG19 SQL/PGQ 그래프 쿼리

· 약 8분
2026-09-28 업데이트

SQL/PGQ 지원은 2026-09-24 PostgreSQL 19 Beta 4 공지에서 revert됐습니다. PostgreSQL 19 정식 버전에는 이 글의 CREATE PROPERTY GRAPH, GRAPH_TABLE 문법이 들어가지 않습니다. 아래 본문은 revert 전 개발 버전 기준 기록입니다. 경위와 컨테이너 비교 결과는 PG19 Beta 4 기능 철회에 정리했습니다.

들어가며​

PostgreSQL 19는 SQL/PGQ(SQL Property Graph Queries) 를 코어에 들였어요. ISO/IEC 9075-16:2023, 즉 SQL:2023 Part 16으로 표준화된 기능이에요.

그래프 워크로드를 다루려면 Neo4j나 ArangoDB 같은 전용 DB를 띄우거나, PostgreSQL 위에 Apache AGE 확장을 올리는 길이 대표적이었습니다. 데이터는 멀쩡히 관계형 테이블에 들어 있는데 권한, 조직도, 추천 같은 일부 쿼리만 그래프 모양으로 풀고 싶을 때는, 그 절반쯤만 필요한 인프라를 들이는 셈이라 늘 어색했습니다.

SQL/PGQ는 그 회색 지대를 노립니다. 기존 테이블 위에 그래프 뷰를 얹고, 표준 SQL 안에서 그래프 패턴 매칭을 그대로 씁니다.

이 글은 PostgreSQL 19 베타 직전 기준으로 작성되었으며, 정식 출시 전까지 일부 문법이 조정될 수 있습니다. 앞선 PostgreSQL 19 새 기능 총정리의 후속편으로 읽으면 좋습니다.

SQL/PGQ가 푸는 문제​

"아이유를 팔로우하는 사용자가 좋아요를 누른 게시물의 작성자"를 표준 SQL로 풀려면 어떻게 써야 할까요. users, follows, liked, posts, authored 다섯 테이블을 차례로 조인해야 합니다.

SELECT DISTINCT author.handle, p.content
FROM users iu
JOIN follows f ON f.followee_id = iu.id
JOIN users follower ON follower.id = f.follower_id
JOIN liked l ON l.user_id = follower.id
JOIN posts p ON p.id = l.post_id
JOIN authored a ON a.post_id = p.id
JOIN users author ON author.id = a.user_id
WHERE iu.handle = '아이유';

동작은 합니다. 그러나 한눈에 들어오지 않습니다. 어느 컬럼이 출발이고 어느 컬럼이 도착인지, 팔로우의 방향이 어디로 향하는지 매번 머릿속에서 다시 짚어야 합니다. 한 홉만 더 늘려도 조인이 두 줄씩 붙습니다.

SQL/PGQ는 이 코드를 시각적 패턴으로 옮깁니다. 정점은 괄호 (), 간선은 꺾쇠 -[]->. 의도가 그대로 보입니다.

다만 PostgreSQL 19의 첫 구현은 고정 깊이 패턴까지입니다. 정량 패턴(+, *, {2,5})과 가변 길이 경로는 다음 릴리스로 미뤄졌습니다. 이 글에서 다루는 범위는 그 첫 구현입니다.

CREATE PROPERTY GRAPH로 그래프 뷰 정의하기​

먼저 그래프를 선언합니다. 데이터는 옮기지 않습니다. 기존 정점 테이블과 간선 테이블을 그래프로 어떻게 볼지를 카탈로그에 기록할 뿐입니다.

아래는 소셜 서비스 도메인을 그래프로 노출하는 예시입니다. 사용자와 게시물을 정점으로, 팔로우, 작성, 좋아요를 간선으로 정의합니다.

CREATE PROPERTY GRAPH social
VERTEX TABLES (
users LABEL person PROPERTIES (id, handle, joined_at),
posts LABEL post PROPERTIES (id, content, posted_at)
)
EDGE TABLES (
follows
SOURCE KEY (follower_id) REFERENCES users (id)
DESTINATION KEY (followee_id) REFERENCES users (id)
LABEL follows PROPERTIES (followed_at),
authored
SOURCE KEY (user_id) REFERENCES users (id)
DESTINATION KEY (post_id) REFERENCES posts (id)
LABEL authored,
liked
SOURCE KEY (user_id) REFERENCES users (id)
DESTINATION KEY (post_id) REFERENCES posts (id)
LABEL liked PROPERTIES (liked_at)
);

세 가지만 짚으면 충분합니다. VERTEX TABLES는 정점 데이터를 들고 있는 실제 테이블이고, LABEL로 그래프상 노드 이름을 지정합니다. 정점 테이블은 여러 개 둘 수 있어서 여기서는 users와 posts 두 종류를 등록했습니다. EDGE TABLES는 간선 데이터를 들고 있는 실제 테이블인데, SOURCE KEY와 DESTINATION KEY가 화살표의 출발과 도착을 결정합니다. 같은 정점을 잇는 follows와 서로 다른 정점을 잇는 authored/liked가 한 그래프 안에 공존합니다. 마지막으로 PROPERTIES는 그래프 패턴에서 노출할 컬럼만 선택합니다. 모든 컬럼을 자동으로 끌어오지는 않습니다.

CREATE PROPERTY GRAPH는 새 테이블을 만들지 않습니다. users, posts, follows, authored, liked는 그대로 남고, 그 위에 social이라는 그래프 객체 하나가 더 생깁니다. DROP PROPERTY GRAPH social로 언제든 제거할 수 있습니다.

기존 관계형 테이블 위에 social property graph 뷰를 정의하는 구조

GRAPH_TABLE과 MATCH로 패턴 매칭하기​

쿼리 쪽 핵심은 GRAPH_TABLE입니다. 그래프 패턴을 받아 관계형 행 집합으로 돌려줍니다. 즉 결과를 다시 일반 SQL의 FROM 절에서 받아 쓸 수 있습니다.

1홉: 아이유를 팔로우하는 사용자​

SELECT handle
FROM GRAPH_TABLE (social
MATCH (iu IS person WHERE iu.handle = '아이유')
<-[f IS follows]-
(follower IS person)
COLUMNS (follower.handle AS handle)
);

화살표가 왼쪽으로 향합니다. "아이유를 팔로우하는 사람들"을 찾는다는 뜻입니다. 같은 의도를 오른쪽 방향으로 쓰려면 패턴의 시작점을 팔로워로 바꾸면 됩니다. 의미는 같지만 가독성은 사람에 따라 갈립니다.

COLUMNS (...) 절은 패턴 안에서 매칭한 값을 일반 컬럼으로 투영합니다. 이름이 곧 결과 컬럼명이 됩니다.

3홉: 팔로워가 좋아요 누른 게시물의 작성자​

SELECT DISTINCT author.handle, p.content
FROM GRAPH_TABLE (social
MATCH (iu IS person WHERE iu.handle = '아이유')
<-[IS follows]-
(follower IS person)
-[IS liked]->
(p IS post)
<-[IS authored]-
(author IS person)
COLUMNS (author.handle, p.content)
);

앞서 여섯 번 조인으로 풀던 쿼리가 패턴 한 덩어리로 줄었습니다. 화살표 방향이 의도와 그대로 일치합니다.

간선 변수 이름은 본문에서 쓰지 않으면 생략해 -[IS liked]->처럼 적어도 됩니다. 간선 속성을 다시 꺼내야 할 때만 -[l IS liked]->처럼 이름을 붙이면 됩니다.

양방향 매칭은 <-[e]->로 씁니다. 다만 PostgreSQL 19에서는 양방향 자유 매칭이 일부 제한되며, 단방향 두 번으로 풀어쓰는 편이 안전합니다.

내부 동작과 rewriter의 역할​

SQL/PGQ는 새 실행 엔진을 들고 오지 않습니다. 파서가 GRAPH_TABLE을 만나면 rewriter가 그래프 패턴을 표준 관계형 트리로 풀어 씁니다. 정점은 RangeTblEntry가 되고, 간선은 출발 테이블, 간선 테이블, 도착 테이블 사이의 3-way 조인이 됩니다. 그 결과 트리는 일반 플래너로 그대로 흘러갑니다.

이 설계가 가져오는 결과는 셋입니다. 우선 기존 인덱스를 그대로 씁니다. 간선 테이블의 (follower_id, followee_id)나 (user_id, post_id) 같은 복합 인덱스가 그래프 쿼리에서도 그대로 활용됩니다. MVCC와 트랜잭션도 자동으로 따라오니, 별도 그래프 엔진이라면 따로 신경 써야 했을 부분이 사라집니다. EXPLAIN도 여전히 의미가 있어서, 결국 보이는 건 일반 조인 플랜이고 평소 쓰던 튜닝 도구가 다 통합니다.

대신 그래프 쿼리의 성능은 결국 조인 최적화에 종속됩니다. 간선이 많고 선택도가 낮은 패턴은 대량 조인이 될 수 있습니다. 인덱스와 통계 정보가 평소처럼 중요합니다.

PostgreSQL 19에 들어간 것 vs 빠진 것​

항목PostgreSQL 19
고정 깊이 패턴 매칭지원
레이블 필터 (v:Person)지원
속성 투영 COLUMNS (...)지원
단방향/다방향 다홉 조합지원
가변 길이 경로 -[e*1..3]->미지원
정량 패턴 +, *, {2,5}미지원
양방향 자유 매칭 일부미지원
BFS/DFS 탐색 순서 지정미지원
CREATE PROPERTY GRAPH DDL지원

정리하면 이렇습니다. 모양이 정해진 그래프 질의는 PostgreSQL 19에서 곧장 풀 수 있고, 모양이 가변인 그래프 질의는 여전히 재귀 CTE의 영역에 남습니다.

가변 길이 지원은 다음 메이저 릴리스의 1차 후보로 거론됩니다. rewriter가 재귀 CTE 또는 자기조인 반복으로 펴는 형태가 유력합니다.

다른 옵션과 비교​

선택지가 늘었으니 정리해 둘 만합니다.

항목SQL/PGQ (PG19)Apache AGENeo4j
설치 방식PostgreSQL 코어PostgreSQL 확장별도 DB
쿼리 언어SQL + GRAPH_TABLEopenCypherCypher
데이터 저장기존 관계형 테이블AGE 전용 그래프 저장소네이티브 그래프
트랜잭션PostgreSQL MVCCPostgreSQL MVCC자체
가변 길이 경로미지원 (PG19 한정)지원지원
그래프 알고리즘—일부풍부
성숙도신규 (코어 첫 진입)중간높음

이미 PostgreSQL에 든 데이터를 가볍게 그래프로 보고 싶다면 SQL/PGQ가 첫 후보입니다. 그래프 자체가 1급 워크로드라면 여전히 Apache AGE나 Neo4j가 더 멀리 갑니다.

언제 쓰면 좋은가​

쓰기 좋은 쪽부터 짚습니다. 가장 자연스러운 건 권한/조직도 탐색입니다. "이 사용자의 상위 N단계 관리자"처럼 깊이가 정해진 질의가 여기 들어갑니다. "내가 산 상품을 산 사람이 산 다른 상품" 같은 가벼운 추천, 친구의 친구 정도로 얕은 소셜 그래프, 기존 BI 쿼리에 그래프 패턴을 끼워 넣는 분석 워크로드도 잘 맞습니다.

반대로 적합하지 않은 경우는 분명합니다. 트리 루트까지 N단계를 거슬러 오르거나 도달 가능한 모든 노드를 탐색하는 것처럼 깊이가 가변인 traversal, PageRank, 중심성, 커뮤니티 탐지 같은 본격 그래프 알고리즘은 이 범위를 벗어납니다. 수억 노드급 그래프도 마찬가지인데, 이쪽은 네이티브 그래프 저장소의 인덱스/캐시 구조가 더 유리합니다.

선택 기준 자체는 단순합니다. 그래프 모양 질의가 늘면 SQL/PGQ로 옮기고, 그래프 워크로드가 본진이 되는 순간 전용 엔진으로 갑니다.

마무리​

PostgreSQL 19의 SQL/PGQ는 화려한 신기능이라기보다 관계형 DB가 그래프 워크로드를 흡수하는 첫 발판에 가깝습니다. 첫 릴리스는 의도적으로 좁습니다. 고정 깊이부터 안정적으로 굳히고, 가변 길이는 다음으로 미루는 그림입니다.

제가 주목하는 지점은 몇 가지입니다. 표준이 코어로 들어와 Apache AGE와 SQL/PGQ가 한 데이터베이스 안에서 공존할 수 있게 됐고, 새 엔진을 들이지 않고 기존 플래너에 얹은 rewriter 기반 설계라 운영 부담도 최소이며, 가변 길이를 일단 빼고 출시한 절제 역시 신기능 폭주 대신 표준 추종을 우선한 결정으로 읽혀요.

9월 정식 출시 전 베타 단계에서 한 번쯤 만져볼 만한 기능으로, 같은 시리즈의 PostgreSQL 19 새 기능 총정리, EXPLAIN ANALYZE RDTSC, 파티션 MERGE/SPLIT도 함께 참고할 수 있어요.

참고 자료​

PG19 파티션 MERGE/SPLIT

· 약 5분
2026-09-28 업데이트

ALTER TABLE ... MERGE PARTITIONS와 SPLIT PARTITIONS는 2026-09-24 PostgreSQL 19 Beta 4 공지에서 revert됐습니다. PostgreSQL 19 정식 버전에는 이 문법이 들어가지 않습니다. 아래 본문의 PostgreSQL 문법 설명은 revert 전 개발 버전 기준 기록입니다. 경위와 컨테이너 비교 결과는 PG19 Beta 4 기능 철회에 정리했습니다.

PostgreSQL 파티셔닝의 짧은 역사​

PostgreSQL의 파티셔닝은 10 버전에서 선언적(declarative) 문법이 들어온 이후 꾸준히 성숙해왔어요. 11에서 UPDATE로 파티션 간 row 이동이 가능해졌고, 14에서 DETACH PARTITION CONCURRENTLY가 추가됐어요. 그리고 PostgreSQL 19에서 마침내 파티션 자체를 병합하고 분할하는 DDL이 들어와요.

ALTER TABLE ... SPLIT PARTITION ...
ALTER TABLE ... MERGE PARTITIONS ...

익숙한 문법입니다. Oracle을 써본 사람이라면 20년 넘게 봐온 구문과 거의 판박입니다. 다만 자세히 보면 문법과 제약에서 결정적인 차이들이 있습니다.

이전까지 PostgreSQL에서는 어떻게 했나​

PostgreSQL 18 이하에서 파티션을 병합하려면 이런 절차를 거쳤습니다.

  1. 새로운 합쳐진 파티션 테이블을 생성
  2. 기존 파티션에서 INSERT SELECT로 데이터 이동
  3. 기존 파티션 DETACH
  4. 새 파티션 ATTACH
  5. 기존 파티션 DROP

한 번에 트랜잭션으로 묶기도 까다롭고, 데이터 이동 중 ACCESS EXCLUSIVE LOCK이 걸려 서비스 가용성에도 영향을 줍니다. 분할도 마찬가지로 귀찮았습니다. Oracle에서는 한 줄이면 끝나는 작업이었습니다.

PostgreSQL 19의 새 문법​

SPLIT과 MERGE를 각각 예제로 살펴보겠습니다.

SPLIT PARTITION​

-- 원본 파티션 테이블
CREATE TABLE sales (
id bigint generated always as identity,
region text not null,
amount numeric
) PARTITION BY LIST (region);

CREATE TABLE sales_all PARTITION OF sales
FOR VALUES IN ('KR', 'JP', 'CN', 'US', 'UK');

-- 하나의 파티션을 셋으로 분할
ALTER TABLE sales SPLIT PARTITION sales_all INTO (
PARTITION sales_asia FOR VALUES IN ('KR', 'JP', 'CN'),
PARTITION sales_us FOR VALUES IN ('US'),
PARTITION sales_uk FOR VALUES IN ('UK')
);

MERGE PARTITIONS​

-- 세 파티션을 하나로 합치기
ALTER TABLE sales MERGE PARTITIONS
(sales_asia, sales_us, sales_uk)
INTO sales_all;

PostgreSQL 19의 파티션 분할과 병합 구조

문법에서 눈여겨볼 점은 다음과 같습니다.

  • 지원 타입: RANGE, LIST (HASH는 지원하지 않음)
  • RANGE 파티션은 인접(adjacent) 해야 병합 가능
  • LIST는 인접 제약 없음
  • 소스 파티션들은 괄호 (...)로 감싼다
  • 대상 파티션에 PARTITION 키워드를 붙이지 않는다

Oracle의 문법​

같은 일을 Oracle에서 하면 이렇게 됩니다. Oracle 쪽은 8i 시절부터 존재해온 문법이라 변주가 많습니다.

SPLIT PARTITION​

-- RANGE 파티션 분할 (AT 값 기준)
ALTER TABLE sales SPLIT PARTITION sales_2026 AT (DATE '2026-07-01')
INTO (
PARTITION sales_2026_h1 TABLESPACE ts1,
PARTITION sales_2026_h2 TABLESPACE ts2
);

-- LIST 파티션 분할 (VALUES 기준)
ALTER TABLE sales SPLIT PARTITION sales_all
VALUES ('KR', 'JP', 'CN')
INTO (
PARTITION sales_asia,
PARTITION sales_rest
);

MERGE PARTITIONS​

-- 기본 문법
ALTER TABLE sales MERGE PARTITIONS
sales_q1_2026, sales_q2_2026, sales_q3_2026, sales_q4_2026
INTO PARTITION sales_2026;

-- RANGE 전용 TO 단축 문법
ALTER TABLE sales MERGE PARTITIONS
sales_q1_2026 TO sales_q4_2026
INTO PARTITION sales_2026;

나란히 비교​

문법 차이​

항목PostgreSQL 19Oracle
MERGE 소스 지정MERGE PARTITIONS (p1, p2) 괄호MERGE PARTITIONS p1, p2 나열
MERGE 대상 지정INTO p_newINTO PARTITION p_new
SPLIT 경계 지정FOR VALUES IN (...) / FOR VALUES FROM ... TO ...AT (value) (RANGE) / VALUES (...) (LIST)
RANGE 범위 병합 단축없음 (일일이 나열)p1 TO p4 단축 문법
TABLESPACE 지정각 대상 파티션마다 개별동일

기능/제약 차이​

항목PostgreSQL 19Oracle
지원 파티션 타입RANGE, LISTRANGE, LIST, SYSTEM
HASH 파티션지원 안 함지원 안 함
RANGE 인접 조건필요필요
LIST 인접 조건불필요불필요
락 수준ACCESS EXCLUSIVE (전 구간)EXCLUSIVE + ONLINE 옵션 (EE, SPLIT 12.2+, MERGE 18c+)
실행 방식단일 프로세스병렬 실행 가능

실전에서 갈리는 락과 온라인 실행​

문법은 거의 맞춰졌습니다. 하지만 운영에서 진짜 갈리는 것은 락과 온라인 실행 여부입니다.

PostgreSQL 19​

공식 커밋 메시지와 depesz의 벤치(1천만 행 LIST 파티션 기준)에서 확인된 내용은 다음과 같습니다.

  • 작업 전체 구간에서 ACCESS EXCLUSIVE LOCK 유지
  • 단일 프로세스에서 순차 실행
  • 대규모 파티션에서는 실질적 다운타임이 발생할 수 있음

쓸모 있는 평가는 이렇습니다. "편의성은 크게 좋아졌지만, 온라인성은 Oracle을 따라잡지 못했습니다." 한 줄 SQL로 간단히 표현할 수 있게 된 것만으로도 의미가 크지만, 수백 GB 파티션을 무중단으로 합칠 수 있는 수준은 아닙니다.

Oracle​

Oracle Enterprise Edition에서는 ONLINE 키워드로 DML 블로킹 없이 파티션을 쪼개거나 합칠 수 있습니다. SPLIT PARTITION ... ONLINE은 12.2부터, MERGE PARTITIONS ... ONLINE은 18c부터 지원합니다.

ALTER TABLE sales MERGE PARTITIONS
sales_q1_2026, sales_q2_2026
INTO PARTITION sales_h1_2026
ONLINE;

내부적으로는 shadow 구조를 만들고 점진적으로 데이터를 이관하는 방식이라, 배타 락 보유 시간이 거의 없습니다. Enterprise Edition 라이선스가 전제라는 게 큰 단서지만, 대형 운영 환경에서 체감되는 차이는 큽니다.

PostgreSQL 19와 Oracle 온라인 파티션 작업의 락 구간 비교

어디까지 따라왔고, 어디까지 아직인가​

따라잡은 것

  • SPLIT/MERGE DDL 자체의 존재: SQL 한 줄로 끝납니다
  • 지원 파티션 타입 (RANGE, LIST)
  • 인접 조건 정책 (RANGE는 인접 필요, LIST는 무관)
  • 다중 분할(한 파티션 → N개)과 다중 병합(N개 → 하나)

아직 못 따라온 것

  • 온라인(ONLINE) 실행: 대형 파티션은 실질적 다운타임이 남음
  • 병렬 실행: 단일 프로세스로만 처리
  • RANGE 범위 병합 단축 문법(p1 TO p4) 없음

PostgreSQL의 역사적 약점 중 하나가 "큰 파티션 테이블의 유지보수가 DDL 한 줄로 안 끝난다"였습니다. PostgreSQL 19는 그 거리를 눈에 띄게 좁혔습니다. 다만 운영에서 가장 아픈 "락을 오래 붙잡는다"는 문제는 남아 있어서, 대형 테이블은 여전히 DETACH CONCURRENTLY + 수동 작업 전략을 병용해야 할 수 있습니다.

Oracle에서 PostgreSQL로 넘어올 때 "제가 쓰던 그 문법, PostgreSQL에도 있어요?"라는 질문의 목록이 하나씩 채워지고 있어요. 이번은 파티션 병합/분할 차례였어요.


참고

PG19 EXPLAIN RDTSC

· 약 6분

EXPLAIN ANALYZE의 불편한 진실​

EXPLAIN ANALYZE는 PostgreSQL에서 쿼리 성능을 분석할 때 가장 먼저 쓰는 도구예요. 실제로 쿼리를 실행하면서 각 노드의 실행 시간, 행 수, 루프 횟수를 보여줘요.

그런데 한 가지 문제가 있습니다. EXPLAIN ANALYZE를 붙이는 것 자체가 쿼리를 느리게 만듭니다.

이건 버그가 아니라 구조적인 문제입니다. 공식 문서에서도 이렇게 경고합니다.

"The measurement overhead added by EXPLAIN ANALYZE can be significant, especially on machines with slow gettimeofday() operating-system calls."

왜 느려지나​

EXPLAIN ANALYZE는 실행 계획의 각 노드를 통과할 때마다 시간을 측정합니다. 구체적으로는 InstrStartNode(시작)과 InstrStopNode(종료)에서 시스템 시계를 읽습니다.

핵심은 이 시계를 읽는 함수가 clock_gettime()이라는 것입니다.

쿼리 실행 흐름:

SeqScan (100만 행)
├─ 행 1: clock_gettime() → 처리 → clock_gettime()
├─ 행 2: clock_gettime() → 처리 → clock_gettime()
├─ 행 3: clock_gettime() → 처리 → clock_gettime()
│ ...
└─ 행 1,000,000: clock_gettime() → 처리 → clock_gettime()

→ clock_gettime() 호출 횟수: 200만 번

100만 행을 스캔하면 clock_gettime()이 200만 번 호출됩니다. 중첩된 노드가 있으면 더 늘어납니다. 각 호출이 약 20ns라고 해도, 200만 번이면 40ms의 순수 오버헤드가 발생합니다.

프로파일링을 해보면 InstrStartNode/InstrStopNode의 실행 시간 대부분이 clock_gettime()에서 소비됩니다. 실제 쿼리 로직이 아니라 시간 측정에 시간을 쓰고 있는 것입니다.

clock_gettime()이 뭔데​

clock_gettime()은 Linux에서 고해상도 시간을 가져오는 시스템 콜입니다. 정확하고 안정적이지만, 호출할 때마다 비용이 듭니다.

현대 Linux에서는 VDSO (Virtual Dynamic Shared Object)를 통해 커널 진입 없이 호출할 수 있도록 최적화되어 있습니다. 그래도 호출당 약 18~20ns의 오버헤드가 있습니다. 플랫폼에 따라 20~100ns까지 달라질 수 있습니다.

clock_gettime() 호출 경로:

사용자 공간 → VDSO → TSC 레지스터 읽기 → 보정 → 반환
↑
커널 진입 없이 실행되지만
보정 로직 때문에 여전히 비용 발생

매번 이 경로를 거치는 게 문제입니다. 쿼리 노드 처리 시간이 수십 ns 수준이면, 시간 측정 비용이 실제 처리 비용보다 클 수도 있습니다.

TIMING FALSE라는 우회로​

사실 PostgreSQL 9.2부터 이 문제의 우회 방법은 있었습니다.

EXPLAIN (ANALYZE, TIMING FALSE) SELECT * FROM large_table;

TIMING FALSE를 쓰면 시간 측정을 건너뜁니다. 실제 행 수와 루프 횟수만 보여줍니다. 오버헤드가 거의 사라집니다.

하지만 타이밍이 없으면 어느 노드가 병목인지 알 수 없습니다. 그래서 실무에서는 결국 TIMING TRUE(기본값)를 쓰게 됩니다.

pg_test_timing 유틸리티로 현재 시스템의 타이밍 오버헤드를 확인합니다.

$ pg_test_timing
Testing timing overhead for 3 seconds.
Per loop time including overhead: 18.80 ns

PostgreSQL 19의 해결책: RDTSC​

PostgreSQL 19에서는 x86-64 CPU의 RDTSC (Read Time-Stamp Counter) 명령어를 활용합니다.

RDTSC란​

CPU에는 TSC(Time Stamp Counter)라는 카운터가 있습니다. CPU 클럭마다 1씩 증가하는 레지스터입니다. RDTSC 명령어는 이 카운터 값을 직접 읽습니다.

clock_gettime(): 사용자 공간 → VDSO → 보정 로직 → 반환
RDTSC: CPU 레지스터 직접 읽기 → 반환

EXPLAIN ANALYZE의 시스템 시계와 RDTSC 읽기 경로 비교

시스템 콜도, VDSO 경유도, 보정 로직도 없습니다. CPU 명령어 하나로 끝납니다.

성능 비교​

pg_test_timing으로 측정한 결과:

클럭 소스평균 루프 시간비교
System clock (기존)18.80 ns기준
RDTSC (PG19)11.69 ns38% 감소
RDTSCP16.94 ns10% 감소

200만 번 호출 기준으로 환산하면:

클럭 소스총 오버헤드
System clock~37.6 ms
RDTSC~23.4 ms
절감량~14.2 ms

단일 쿼리에서 14ms라면 크지 않아 보이지만, 중첩 노드가 있거나 행 수가 더 많으면 오버헤드는 행 수와 노드 수에 비례해 커집니다.

RDTSC vs RDTSCP​

두 명령어의 차이를 알아두면 좋습니다.

RDTSCRDTSCP
속도더 빠름 (11.69 ns)약간 느림 (16.94 ns)
순서 보장비순차 실행 가능순서 보장됨
정밀도약간 낮음높음
용도EXPLAIN ANALYZE (상대적 시간 측정)절대적 시간 측정이 필요한 경우

RDTSC는 CPU의 비순차 실행(out-of-order execution)으로 인해 측정 순서가 살짝 뒤바뀔 수 있습니다. 하지만 EXPLAIN ANALYZE에서는 상대적인 시간 차이만 보면 되므로, 약간의 부정확함은 문제가 되지 않습니다.

PostgreSQL 19는 EXPLAIN ANALYZE에는 빠른 RDTSC를, 높은 정밀도가 필요한 다른 경우에는 RDTSCP를 사용합니다.

timing_clock_source 설정​

새로운 timing_clock_source 파라미터로 클럭 소스를 제어합니다. 허용값은 auto, system, tsc 세 가지이고 기본값은 auto입니다. superuser만 바꿀 수 있습니다.

-- 현재 설정 확인
SHOW timing_clock_source;

-- 변경 (postgresql.conf 또는 SET, superuser 전용)
SET timing_clock_source = 'auto'; -- 기본값. 지원 CPU면 TSC, 아니면 OS 시스템 클럭
SET timing_clock_source = 'tsc'; -- CPU 명령어(x86-64의 RDTSC/RDTSCP)로 측정
SET timing_clock_source = 'system'; -- OS 시스템 클럭 (기존 방식)

tsc를 선택해도 RDTSC와 RDTSCP 중 무엇을 쓸지는 PostgreSQL이 용도에 따라 정합니다. 두 명령어를 따로 고르는 값은 없습니다.

x86-64 CPU에서 해당 명령어를 지원하면 auto가 TSC 클럭 소스를 선택합니다. 오래된 x86-64 CPU나 ARM 등 다른 아키텍처에서는 기존 방식이 유지됩니다. 공식 문서는 쿼리 실행 중 이 값을 바꾸면 구간 시간이 크게 튀거나 음수가 나올 수 있다고 경고합니다.

주의사항​

x86-64 전용​

이번 최적화는 x86-64 아키텍처 전용입니다. ARM 기반 서버(AWS Graviton 등)에서는 적용되지 않습니다. 향후 ARM용 최적화도 추가될 수 있지만, 초기 릴리즈에는 포함되지 않았습니다.

TSC 신뢰성​

모든 x86-64 CPU에서 TSC가 동일하게 동작하지는 않습니다. 오래된 CPU나 가상화 환경에서는 TSC가 불안정할 수 있습니다. PostgreSQL은 TSC 지원 여부를 확인한 후 자동으로 적절한 클럭 소스를 선택합니다.

변화의 체감​

일상적인 EXPLAIN ANALYZE 사용에서 이 변화를 극적으로 체감하기는 어려울 수 있습니다. 하지만 대량 행을 처리하는 복잡한 쿼리에서는 측정 오버헤드 감소가 실행 시간 측정의 정확도를 높여준다는 점이 핵심입니다.

역사: 6년간의 논의​

이 아이디어가 처음 나온 건 2020년입니다. Andres Freund(안드레스 프로인트)가 PostgreSQL Hackers 메일링 리스트에 "Reduce timing overhead of EXPLAIN ANALYZE using rdtsc?" 라는 제목으로 제안했습니다.

6년 동안 논의와 구현이 이어진 끝에 PostgreSQL 19에 드디어 포함되었습니다. 아이디어 자체는 단순하지만, TSC 안정성 검증, 다양한 CPU/VM 환경 호환성, 정밀도 트레이드오프 같은 세부 사항을 해결하는 데 시간이 걸렸습니다.

정리​

기존 (PG18 이하)PG19
클럭 소스clock_gettime()RDTSC
호출당 비용~18.80 ns~11.69 ns
개선율-38% 감소
대상 아키텍처모든 플랫폼x86-64 (자동 감지)
설정없음timing_clock_source

EXPLAIN ANALYZE를 프로덕션에서 쓰는 건 여전히 주의가 필요해요. 하지만 PostgreSQL 19부터는 측정 때문에 발생하는 노이즈가 줄어들어, 더 정확한 성능 분석이 가능해져요.

참고 자료​