본문으로 건너뛰기

Barman Cloud S3 백업

· 약 14분

Barman 서버 없이 백업하기​

2~5편은 모두 Barman 서버 한 대가 PostgreSQL에서 백업을 가져오는 구조였어요. 이번 편은 그 서버를 제외하고, PostgreSQL 호스트가 S3 버킷에 직접 백업을 올리는 barman-cloud-* 명령을 다뤄요. 로컬 Docker에 MinIO를 띄워 WAL 아카이브, base backup, 보존 정책, PITR까지 한 번씩 실행해 봤어요.

Barman의 barman-cloud-* 명령은 barman-cli-cloud 패키지로 따로 배포됩니다. 공식 문서는 이 명령을 PostgreSQL 서버와 같은 머신에 설치하고, PGDATA를 읽어야 하므로 보통 postgres 사용자로 실행한다고 안내합니다. 백업 결과는 Barman 서버 카탈로그 대신 오브젝트 스토리지에 객체로 남습니다. CloudNativePG의 in-tree 구현도 복구할 때 barman-cloud-restore를 호출하므로, 7편(CNPG와 Barman Cloud Plugin)으로 넘어가기 전에 명령 단위로 동작을 확인해 두려는 목적도 있습니다.

barman-cloud 명령 9개​

Barman 3.20 문서 기준으로 명령은 아홉 개입니다.

명령역할쓰는 자리
barman-cloud-wal-archiveWAL 세그먼트 업로드archive_command
barman-cloud-wal-restoreWAL 세그먼트 다운로드restore_command
barman-cloud-backupbase backup 생성과 업로드cron, 스케줄러
barman-cloud-backup-list백업 목록수동 확인
barman-cloud-backup-show백업 상세수동 확인
barman-cloud-backup-deleteID 또는 보존 정책으로 삭제cron, 스케줄러
barman-cloud-backup-keep보존 정책 제외 표시수동
barman-cloud-check-wal-archive아카이브 위치가 비었는지 검사최초 연결, 복구 후
barman-cloud-restorebase backup 다운로드복구

모든 명령이 DESTINATION_URL SERVER_NAME 두 인자를 받습니다. s3://pgbackup/lab이 URL이고 pg18이 서버 이름이면 객체는 pgbackup 버킷의 lab/pg18/ 아래에 쌓입니다. S3 호환 스토리지의 주소는 --endpoint-url로 지정하고, 기본 --cloud-provider는 aws-s3입니다.

측정 환경​

측정은 macOS의 Docker Desktop VM(2 CPU, 약 11.7GiB, 다른 작업과 공유)에서 진행했습니다. 시간 값은 모두 한 번씩 측정한 값이라 참고용입니다.

항목값
PostgreSQLpostgres:18 이미지, 18.4 (Debian 13)
BarmanPGDG apt barman-cli-cloud 3.20.0, Python 3.13.5, boto3 1.37.9
MinIOcgr.dev/chainguard/minio, RELEASE.2026-09-22T19-25-18Z
데이터pgbench -i -s 3, DB 53MB, pg_wal 제외 PGDATA 68MB

Docker Hub의 minio/minio와 quay.io/minio/minio는 이 환경에서 pull이 되지 않아 Chainguard 이미지를 썼습니다. Barman 문서는 MinIO를 쓴다면 RELEASE.2025-02-03T21-03-04Z 이상으로 올려야 백업 삭제가 실패하지 않는다고 경고합니다. 측정 버전은 이 조건을 충족합니다. 오래된 S3 호환 스토리지를 boto3 1.36 이상과 함께 써야 한다면 문서가 안내하는 AWS_REQUEST_CHECKSUM_CALCULATION=when_required, AWS_RESPONSE_CHECKSUM_VALIDATION=when_required 환경변수를 확인해 보세요.

설치에는 PGDG apt 저장소의 barman-cli-cloud와 python3-boto3 패키지를 사용했습니다. 처음에는 venv에서 pip install "barman[cloud]==3.20.0"을 시도했지만 psycopg2 소스 빌드에서 실패했습니다. Barman 3.20부터는 Python 3.12 이상이 필요하므로(3.20.0 릴리스 노트), Python 3.11 기반 배포판에서는 패키지 버전을 먼저 확인해야 합니다.

WAL 아카이브 연결​

인증 정보는 PostgreSQL 프로세스의 환경변수(AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_DEFAULT_REGION)로 전달했습니다. archive_command는 postmaster 환경을 물려받으므로, 운영에서는 systemd unit의 환경 파일이나 ~postgres/.aws/credentials로 같은 값을 줍니다.

연결하기 전에 아카이브 위치부터 검사합니다.

barman-cloud-check-wal-archive \
--endpoint-url http://barman-cloud-s3-backup-minio:9000 \
s3://pgbackup/lab pg18
# exit=0

이 명령은 아카이브 위치가 비어 있으면 성공합니다. WAL이 하나라도 있으면 실패하고, --timeline을 지정하면 그보다 앞선 timeline의 WAL만 있을 때 통과합니다. 다른 클러스터의 WAL과 섞이는 사고를 막는 검사라서, 새 클러스터를 기존 경로에 연결하기 전에 실행해 두는 편이 좋습니다.

ALTER SYSTEM SET archive_mode = on;
ALTER SYSTEM SET archive_command =
'barman-cloud-wal-archive --endpoint-url http://barman-cloud-s3-backup-minio:9000 --zstd s3://pgbackup/lab pg18 %p';
-- archive_mode 변경은 재시작 필요

재시작한 뒤 pg_switch_wal()을 한 번 호출하자 pg_stat_archiver가 archived_count=1, failed_count=0으로 바뀌었습니다. 16MB 세그먼트는 --zstd 덕분에 버킷에 1.2MiB, 500KiB처럼 작게 올라갔고, 거의 빈 세그먼트는 700B 안팎이었습니다.

base backup 올리기​

barman-cloud-backup --endpoint-url http://barman-cloud-s3-backup-minio:9000 \
--immediate-checkpoint -n plain s3://pgbackup/lab pg18
barman-cloud-backup --endpoint-url http://barman-cloud-s3-backup-minio:9000 \
--immediate-checkpoint --zstd -n zstd s3://pgbackup/lab pg18
백업압축소요 시간 (time real)버킷 크기
plain없음2.893초data.tar 69MiB
zstd--zstd2.964초data.tar.zst 4.7MiB

압축 비율이 이렇게 큰 것은 pgbench 초기 데이터가 반복 패턴이 많은 합성 데이터이기 때문입니다. 실제 데이터에서는 이 비율이 나오지 않을 가능성이 높으니 일반화하지 않는 편이 맞습니다. 이 크기에서는 압축 여부가 소요 시간에 거의 영향을 미치지 않았습니다. 병렬 업로드 기본값은 -J 2이고, backup.info의 copy_stats에도 number_of_workers: 2로 기록됐습니다.

버킷에 남은 객체는 다음과 같습니다(mc ls -r, 일부 생략).

69MiB lab/pg18/base/20260928T070251/data.tar
1.3KiB lab/pg18/base/20260928T070251/backup.info
4.7MiB lab/pg18/base/20260928T070254/data.tar.zst
1.3KiB lab/pg18/base/20260928T070254/backup.info
1.2MiB lab/pg18/wals/0000000100000000/000000010000000000000001.zst
203B lab/pg18/wals/0000000100000000/000000010000000000000005.00000028.backup.zst
699B lab/pg18/wals/0000000100000000/000000010000000000000005.zst
...

이 lab에서는 백업마다 base/<백업ID>/ 디렉터리에 tar 한 개와 backup.info 한 개가 생겼습니다. -S/--max-archive-size는 tar 한 개의 최대 크기이며 기본값은 100GB입니다. WAL은 wals/<timeline+log 번호>/ 아래에 세그먼트 단위로 쌓입니다. backup.info는 key=value 텍스트이고 begin_wal, end_wal, timeline, systemid, backup_name 같은 값이 들어 있어서, 삭제와 복구 명령이 이 파일을 읽어 판단합니다.

Backup ID End Time Begin Wal Archival Status Name
20260928T070251 2026-09-28 07:02:52 000000010000000000000005 plain
20260928T070254 2026-09-28 07:02:54 000000010000000000000007 zstd

-n으로 지정한 이름은 barman-cloud-backup-list의 Name 열에 표시됩니다. 백업 ID 자리에는 first/oldest, last/latest, last-failed 단축어도 사용합니다. 뒤의 복구에서 사용한 latest가 그 예입니다.

보존 정책과 삭제​

Barman 서버 모드에서는 barman cron이 보존 정책을 주기적으로 적용합니다(4편). barman-cloud-*에는 그런 상주 프로세스가 없어서, barman-cloud-backup-delete -r를 백업 스케줄 옆에 직접 등록해야 합니다. 정책 문법은 서버 모드와 같은 REDUNDANCY n, RECOVERY WINDOW OF n {DAYS|WEEKS|MONTHS}이고, -m/--minimum-redundancy로 최소 개수를 지정합니다.

백업을 두 개 더 만들어 네 개로 늘린 뒤, 가장 오래된 plain에 keep --target standalone을 설정하고 REDUNDANCY 2를 --dry-run으로 먼저 실행했습니다.

barman-cloud-backup-keep --endpoint-url ... --target standalone \
s3://pgbackup/lab pg18 20260928T070251
barman-cloud-backup-delete --endpoint-url ... -r "REDUNDANCY 2" --dry-run \
s3://pgbackup/lab pg18
Skipping deletion of objects ['lab/pg18/base/20260928T070254/data.tar.zst',
'lab/pg18/base/20260928T070254/backup.info'] due to --dry-run option
Skipping deletion of objects ['.../000000010000000000000001.zst', ... '0002', '0003', '0004',
'0006', '0007.00000028.backup', '0007', '0008', '0009', '000A', '000B', '000C'] due to --dry-run option

삭제 대상 백업은 zstd 하나였습니다. 네 개 가운데 plain은 keep 표시 때문에 개수 계산에서 빠지고, 최신 두 개(b3, b4)가 REDUNDANCY 2를 채웠기 때문입니다. WAL 쪽 결과가 더 흥미로웠습니다. plain의 시작 세그먼트인 ...05와 ...05.00000028.backup은 목록에서 빠졌지만, plain 이후부터 b3 시작 전까지인 ...06~...0C는 삭제 대상에 들어갔습니다.

문서의 WAL 삭제 조건이 이 결과를 설명합니다. 남은 백업 중 가장 오래된 비archival 백업의 begin_wal보다 앞서고, 클라우드에 있는 어떤 archival 백업에도 필요하지 않은 WAL이 삭제 대상입니다. standalone keep은 백업 자체의 일관성에 필요한 WAL만 보존하기 때문에, plain 시점으로는 되돌려도 plain과 b3 사이의 임의 시점으로는 PITR이 되지 않습니다. 그 구간이 필요하면 --target full로 표시해야 합니다.

실제 삭제에는 0.253초가 걸렸고, 버킷은 30개 객체 97MiB에서 16개 객체 85MiB로 줄었습니다. plain이 압축하지 않은 69MiB라서 크기는 많이 줄지 않았습니다.

Backup ID End Time Begin Wal Archival Status Name
20260928T070251 2026-09-28 07:02:52 000000010000000000000005 KEEP:STANDALONE plain
20260928T070408 2026-09-28 07:04:08 00000001000000000000000D b3
20260928T070415 2026-09-28 07:04:15 000000010000000000000011 b4

keep 표시는 base/20260928T070251/annotations/keep라는 10바이트 객체로 저장됐습니다. 버킷 수명 주기 규칙(lifecycle rule)으로 오래된 객체를 삭제하는 방식과 함께 쓰면 이 관계가 깨지므로, 삭제는 barman-cloud-backup-delete에 맡기는 편이 안전합니다. AWS S3의 Object Lock을 쓰는 경우 3.17.0부터 --check-object-lock으로 잠긴 객체가 있는 백업의 삭제를 중단합니다(3.17.0 릴리스 노트).

복구와 PITR​

PITR 대상 시점을 만들기 위해 orders_marker 테이블에 1,000행을 넣었습니다. 2초 뒤 시각 2026-09-28 07:03:20+00을 기록하고, 다시 2초 뒤 테이블을 삭제했습니다.

barman-cloud-restore --endpoint-url ... \
s3://pgbackup/lab pg18 latest /var/lib/postgresql/restore
# real 0m0.330s

barman-cloud-restore는 파일만 풀어 놓습니다. 문서도 "This command does not automatically prepare Postgres for recovery"라고 적고 있습니다. restore_command, recovery target, signal 파일, 필요한 WAL도 직접 준비해야 한다고 안내합니다. 풀린 디렉터리에는 backup_label이 있었고 pg_wal은 비어 있었습니다.

# restore/postgresql.auto.conf 에 추가
port = 5433
archive_mode = off
restore_command = 'barman-cloud-wal-restore --endpoint-url http://barman-cloud-s3-backup-minio:9000 s3://pgbackup/lab pg18 %f %p'
recovery_target_time = '2026-09-28 07:03:20+00'
recovery_target_action = 'promote'
touch /var/lib/postgresql/restore/recovery.signal
chmod 700 /var/lib/postgresql/restore
pg_ctl -D /var/lib/postgresql/restore -l restore.log -w start

archive_mode = off는 복구한 인스턴스가 원본과 같은 경로에 WAL을 올리지 않게 하려고 넣었습니다. 복구 인스턴스가 promote된 뒤 timeline 2의 WAL을 같은 SERVER_NAME으로 올리면 두 클러스터의 이력이 한 경로에 섞입니다.

LOG: restored log file "000000010000000000000007" from archive
LOG: starting point-in-time recovery to 2026-09-28 07:03:20+00
LOG: restored log file "000000010000000000000008" from archive
LOG: consistent recovery state reached at 0/7000120
LOG: recovery stopping before commit of transaction 767, time 2026-09-28 07:03:22.355701+00
LOG: selected new timeline ID: 2
LOG: archive recovery complete

pg_ctl start는 1.118초 만에 반환됐습니다. orders_marker는 1,000행 그대로 돌아왔고 pg_is_in_recovery()는 f였습니다. 로그의 transaction 767이 DROP TABLE이고, 기록한 시각 이후 첫 commit에서 replay가 멈췄습니다. 대량 WAL을 받아야 하는 복구라면 3.19.0에서 추가된 barman-cloud-wal-restore -p/--parallel 옵션을 검토해 볼 만합니다. 여러 세그먼트를 동시에 받아 --spool-dir에 저장하는 방식입니다.

아카이브가 실패하면​

MinIO 컨테이너를 멈춘 상태에서 WAL 세그먼트 두 개를 채웠습니다. 약 40초 뒤 상태는 다음과 같았습니다.

pg_stat_archiver: archived_count=21, failed_count=5, last_failed_wal=000000010000000000000012
archive_status: ...12.ready, ...13.ready

ERROR: Barman cloud WAL archiver exception: Could not connect to the endpoint URL: "http://.../000000010000000000000012.zst"
LOG: archive command failed with exit code 4
WARNING: archiving write-ahead log file "000000010000000000000012" failed too many times, will try again later

PostgreSQL archiver는 같은 세그먼트를 순서대로 재시도합니다. 연속 3회 실패하면 잠시 쉬었다가 다시 시도합니다. 실패한 세그먼트는 .ready 상태로 pg_wal에 남아 있습니다. MinIO를 다시 켜자 70초 안에 두 세그먼트 모두 .done으로 바뀌었습니다. 스토리지 장애가 길어지면 그동안 pg_wal이 계속 커지므로, pg_stat_archiver.failed_count와 last_failed_time을 알람 대상에 넣어 두어야 합니다. Barman 서버의 barman check처럼 이 상태를 대신 확인해 주는 프로세스가 없다는 점이 서버 모드와 가장 크게 다릅니다.

rsync, streaming 모드와 비교​

항목rsync 모드 (3편)streaming 모드 (2편)barman-cloud
백업 저장 위치Barman 서버 디스크Barman 서버 디스크오브젝트 스토리지
Barman 서버필요필요불필요
base backup 전송rsync over SSHpg_basebackupPostgreSQL 호스트에서 업로드
WAL 전송archive_command (SSH)pg_receivewalarchive_command
보존 정책 적용barman cronbarman cronbarman-cloud-backup-delete 직접 실행
파일 단위 dedupreuse_backup = link——
백업 부하 위치PostgreSQL + Barman 서버PostgreSQL + Barman 서버PostgreSQL 호스트

서버 모드에서는 Barman 서버가 여러 PostgreSQL을 한곳에서 관리하고 barman check, barman cron, 카탈로그를 제공합니다. barman-cloud는 그 관리 계층을 포기하는 대신 서버 한 대를 따로 운영하지 않아도 됩니다. 압축과 업로드에 PostgreSQL 호스트의 CPU를 쓴다는 점도 설계할 때 고려해야 합니다. 이번 측정 규모에서는 부하 차이가 드러나지 않았고, 대용량에서 비교한 수치는 없습니다.

두 방식을 함께 쓰는 구성도 있습니다. 문서는 barman-cloud-backup을 Barman 서버의 post_backup_retry_script hook으로 사용해 서버에 받은 백업을 클라우드로 복사하는 구성을 안내합니다. 로컬에서 빠르게 복구하고 원격 사본은 버킷에 두는 구조입니다.

장점​

PostgreSQL 호스트와 버킷만 있으면 백업이 성립합니다. 서버 대수를 줄이려는 환경이나 이미 오브젝트 스토리지를 표준 백업 위치로 쓰는 조직에 적합합니다. 버킷의 내구성과 수명 비용 구조를 그대로 가져오므로 원격 보관을 따로 설계하지 않아도 됩니다.

객체 배치가 단순합니다. base/<ID>/data.tar*, backup.info, wals/<prefix>/<segment> 구조라서 mc나 aws s3 ls로도 상태를 확인할 수 있고, 명령이 없는 환경에서도 tar를 직접 받아 풀 여지가 있습니다.

CloudNativePG와 쓰는 도구가 겹칩니다. in-tree 구현이 barman-cloud-restore 같은 명령을 호출하므로, 여기서 익힌 명령과 객체 배치가 Kubernetes 쪽 문제를 분석할 때도 쓰입니다. Barman Cloud Plugin 내부 구조는 이번에 확인하지 않았고 7편에서 다룹니다.

단점과 제약​

  • 상주 프로세스 없음: 보존 정책 적용, 아카이브 감시, 백업 스케줄을 전부 외부(cron, 모니터링)에 맡겨야 함
  • 복구 준비 수동: barman-cloud-restore는 파일만 복원하고 recovery 설정은 직접 작성
  • 증분 없음: 3.20 barman-cloud-backup --help에 incremental 옵션이 없고, 매번 전체 tar를 업로드
  • 부하 위치: 압축과 업로드가 PostgreSQL 호스트의 CPU와 네트워크를 사용
  • 삭제 시점: REDUNDANCY 계산에서 keep 백업이 빠지고, standalone keep은 사이 구간 WAL을 보존하지 않음

증분 백업은 서버 쪽 기능으로 추가되고 있습니다. 3.18.0은 backup_method = postgres에 basebackups_directory, wals_directory를 클라우드 URL로 지정하는 block-level 증분 백업을 실험 기능으로 추가했습니다. 이번 글에서는 측정하지 않았습니다.

버전별 변화와 CloudNativePG​

최근 Barman 릴리스는 클라우드 기능을 barman 본체로 옮기는 방향입니다.

버전날짜클라우드 관련 변경
3.17.02026-01-07barman-cloud-backup-delete --check-object-lock (AWS S3)
3.18.02026-03-12backup_method = local-to-cloud 추가, 클라우드 증분 백업(실험)
3.19.02026-05-20클라우드 백업을 barman restore로 복원, barman cloud-wal-restore, barman-cloud-wal-restore 병렬
3.20.02026-08-27Python 3.12 이상 필요, AWS S3 SSE-C (--sse-customer-key)

3.18.0 릴리스 노트는 local-to-cloud를 설명하면서 이것이 "the first step toward deprecating the standalone barman-cloud-* scripts"라고 적었습니다. 3.20.0 기준으로 스크립트 제거 일정은 찾지 못했습니다. 지금 스크립트로 구성하더라도 이 방향은 알고 있어야 합니다.

CloudNativePG 쪽은 공식 릴리스 노트에서 다음 범위까지 확인했습니다.

CNPG 릴리스날짜in-tree Barman Cloud 제거 예정 버전
1.26.02025-05-231.28.0 (deprecated 발표)
1.27.42026-03-311.29.0에서 1.30.0으로 변경
1.30.02026-06-291.30.0에서 1.31.0으로 변경

1.28.0이 1.29.0으로 바뀐 시점은 이번에 확인하지 못했습니다. 1.30.0 릴리스 노트는 "Users are still encouraged to migrate to the Barman Cloud Plugin"이라고 적고 있습니다. 2026-09-23에 나온 1.30.1에도 in-tree Barman Cloud 복구 관련 수정이 포함되어 있습니다. 제거 일정이 여러 번 미뤄졌지만 방향은 바뀌지 않았으므로, 1.31 전에 플러그인 전환을 계획해 두는 편이 안전합니다. 전환 절차는 7편에서 다룹니다.

정리​

항목내용
구성PostgreSQL 호스트에 barman-cli-cloud, archive_command에 barman-cloud-wal-archive
객체 배치<prefix>/<server>/base/<ID>/, <prefix>/<server>/wals/<prefix>/
보존 정책barman-cloud-backup-delete -r를 직접 스케줄, --dry-run으로 먼저 확인
keepstandalone은 백업 시점만, full은 이후 WAL까지 보존
복구barman-cloud-restore 후 restore_command, recovery target, recovery.signal 직접 작성
감시pg_stat_archiver.failed_count, pg_wal 크기
CNPGin-tree Barman Cloud는 1.31.0 제거 예정 (1.30.0 릴리스 노트)

서버 없이 버킷 하나로 백업이 돌아가는 건 편하지만, Barman 서버가 담당하던 감시와 정리를 이제 직접 챙겨야 해요. 저는 --dry-run 출력으로 WAL 삭제 범위를 한 번 확인해 보고 나서야 standalone keep의 의미가 분명해졌어요.

참고 자료​

CNPG exporter 취약점 점검

· 약 14분

CloudNativePG metrics exporter는 1.29.1과 1.28.3 이전까지 postgres superuser로 접속한 뒤 SET ROLE pg_monitor로 권한을 낮춰 쿼리를 실행했어요. SET ROLE은 current_user만 바꾸고 session_user는 그대로 두기 때문에, scrape 세션 안에서 평가되는 SQL 식이 RESET ROLE로 superuser를 되찾을 수 있었습니다. 이 결함이 CVE-2026-44477(GHSA-423p-g724-fr39)이에요. 이 글은 방어와 점검 관점에서 원인, 수정 내용, 업그레이드 순서, 점검 쿼리를 정리하고 공격 절차는 다루지 않습니다. PostgreSQL 쪽 동작은 순수 PostgreSQL 컨테이너에서 하나씩 측정했고, exploit 체인은 재현하지 않았습니다.

요약​

항목내용
식별자CVE-2026-44477, GHSA-423p-g724-fr39
공개2026-05-08 (저장소 advisory와 패치 릴리스 같은 날)
심각도Critical, CVSS 3.1 9.9 (AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H)
CWECWE-250, CWE-271, CWE-426
영향 버전1.28.3 미만, 1.29.0
해결 버전1.28.3, 1.29.1
필요 권한scrape 대상 database 안에서 search_path에 걸리는 객체를 만들 수 있는 계정
결과PostgreSQL superuser 획득, primary Pod 안에서 postgres OS 사용자로 명령 실행
제보Mehmet Ince

영향 범위와 해결 버전은 GitHub advisory의 패키지 범위(< 1.28.3, >= 1.29.0, < 1.29.1)와 1.29.1 릴리스 노트, 1.28.3 릴리스 노트에서 확인했습니다. 1.27 이하 라인에는 별도 패치 릴리스가 없습니다. 1.27.x는 2026-03-09에 이미 지원이 끝났습니다.

경고

2026-06-29에 같은 계열의 후속 취약점 CVE-2026-55769(GHSA-x8c2-3p4r-v9r6)가 공개됐고 1.28.4, 1.29.2, 1.30.0에서 수정됐습니다. 1.29.1이나 1.28.3에서 멈추면 이 후속 건이 남습니다. 업그레이드 목표는 아래 "업그레이드" 절을 참고하십시오.

원인: SET ROLE 뒤에 남는 superuser 신원​

수정 전 exporter는 Pod 로컬 Unix socket으로 postgres에 접속했고, 쿼리마다 read-only 트랜잭션을 열어 다음을 실행했습니다. 수정 commit의 createMonitoringTx에서 지워진 줄입니다.

// Set the pg_monitor role
_, err = tx.Exec("SET ROLE TO pg_monitor")

advisory는 이 구조를 연쇄의 출발점으로 봅니다. 요지는 세 문장입니다.

  • SET ROLE은 current_user만 바꾸고 session_user는 postgres로 남습니다.
  • scrape 세션에서 평가되는 SQL 식은 무엇이든 RESET ROLE로 superuser를 되찾고, COPY ... TO PROGRAM으로 Pod 안에서 프로세스를 띄울 수 있습니다.
  • READ ONLY 트랜잭션은 데이터베이스 상태에 대한 쓰기를 막을 뿐, 외부 프로세스 실행은 막지 않습니다.

아래 세 절은 이 문장들을 PostgreSQL 18.4 컨테이너에서 하나씩 확인한 결과입니다. 공격 객체와 권한 회복을 한 번에 엮는 코드는 쓰지 않았습니다.

SET ROLE과 session_user​

superuser로 접속해 read-only 트랜잭션 안에서 SET ROLE pg_monitor를 실행한 결과입니다.

BEGIN READ ONLY;
SET ROLE pg_monitor;
SELECT session_user, current_user,
(SELECT rolsuper FROM pg_catalog.pg_roles WHERE rolname = current_user) AS cur_super,
(SELECT rolsuper FROM pg_catalog.pg_roles WHERE rolname = session_user) AS sess_super;
SELECT count(*) FROM pg_catalog.pg_authid;
session_user | current_user | cur_super | sess_super
--------------+--------------+-----------+------------
postgres | pg_monitor | f | t

ERROR: permission denied for table pg_authid

권한 검사는 current_user 기준이라 pg_authid 조회가 막힙니다. 여기까지만 보면 권한이 잘 낮아진 것처럼 보입니다. 같은 트랜잭션에서 RESET ROLE을 실행하면 결과가 달라집니다.

BEGIN READ ONLY;
SET ROLE pg_monitor;
RESET ROLE;
SELECT session_user, current_user;
SELECT count(*) AS authid_rows FROM pg_catalog.pg_authid;
session_user | current_user
--------------+--------------
postgres | postgres

authid_rows
-------------
17

RESET ROLE은 current_user를 session_user로 되돌리는 명령이고, 이 명령에는 별도 권한이 필요 없습니다. session_user가 superuser인 한 SET ROLE은 격리 수단이 되지 못합니다.

SET SESSION AUTHORIZATION도 같은 결과​

advisory는 SQL 수준에서 SET SESSION AUTHORIZATION pg_monitor로 한 단계 더 내려도 부족하다고 적었습니다. SET SESSION AUTHORIZATION의 권한 검사가 현재 session_user가 아니라 처음 인증된 사용자가 superuser인지를 보기 때문입니다. 측정 결과도 같았습니다.

SET SESSION AUTHORIZATION cnpg_metrics_exporter;
SELECT session_user, current_user;
RESET SESSION AUTHORIZATION;
SELECT session_user, current_user;
SET SESSION AUTHORIZATION cnpg_metrics_exporter;
SET SESSION AUTHORIZATION postgres;
SELECT session_user, current_user;
session_user | current_user
-----------------------+-----------------------
cnpg_metrics_exporter | cnpg_metrics_exporter

session_user | current_user
--------------+--------------
postgres | postgres

session_user | current_user
--------------+--------------
postgres | postgres

session_user를 비 superuser로 바꾼 뒤에도 RESET SESSION AUTHORIZATION이나 SET SESSION AUTHORIZATION postgres로 다시 superuser가 됩니다. 인증 단계의 사용자 자체를 바꿔야 한다는 advisory의 결론이 여기서 나옵니다.

READ ONLY가 막는 것과 막지 못하는 것​

superuser 세션에서 BEGIN READ ONLY 뒤에 실행한 결과입니다.

문장결과
CREATE TABLE t2(id int)cannot execute CREATE TABLE in a read-only transaction
INSERT INTO t VALUES (1)cannot execute INSERT in a read-only transaction
SELECT nextval('s')cannot execute nextval() in a read-only transaction
SELECT pg_read_file('postgresql.conf')성공 (32,557바이트)
COPY (SELECT ...) TO '/tmp/ro_copy.txt'성공, 파일 생성 확인
COPY (SELECT 1) TO PROGRAM 'true'성공
SELECT set_config('work_mem', '8MB', true)성공

transaction_read_only는 데이터베이스 안의 테이블, 시퀀스, 카탈로그 변경을 막습니다. 서버 파일 읽기와 쓰기, 외부 프로그램 실행은 트랜잭션이 기록하는 데이터 변경이 아니어서 검사 대상에서 빠집니다. 그래서 이 조합을 막는 것은 트랜잭션 모드가 아니라 role의 권한입니다.

영향 조건​

advisory는 영향 대상을 두 갈래로 정리했습니다. 공격 방법은 이 글에서 다루지 않고 대상 여부를 판단하는 데 필요한 조건만 옮깁니다.

  • 기본 모니터링을 켠 모든 지원 릴리스. 기본 제공 default-monitoring.yaml의 pg_extensions 메트릭이 스키마를 붙이지 않은 current_database()를 호출했고 target_databases: '*'로 모든 사용자 database에서 실행됐기 때문입니다. bootstrap.initdb가 만드는 기본 app role처럼 database를 소유한 비 superuser 계정이 있으면 조건을 채웁니다.
  • 스키마를 붙이지 않은 함수나 relation을 참조하는 custom metric 쿼리를 쓰는 배포. 고객이 metric 쿼리 본문을 넣거나 바꿀 수 있는 멀티테넌트 플랫폼을 advisory는 가장 위험한 경우로 봤습니다.

advisory는 스키마 누락이 가장 직접적인 형태일 뿐이라고 덧붙였습니다. scrape 세션에서 평가되는 식은 모두 superuser 권한으로 실행됐으므로, 사용자 정의 함수, operator, cast, 사용자 소유 테이블이나 view, 읽는 객체에 걸린 index 식과 RLS policy도 같은 범주에 들어갑니다. 쿼리에 pg_catalog.를 붙이는 것만으로는 원인이 없어지지 않는다는 뜻입니다.

PostgreSQL 14 이하는 조건이 더 넓다고 advisory가 따로 적었습니다. PostgreSQL 15부터 public 스키마의 CREATE 권한이 PUBLIC에서 빠졌기 때문입니다. 두 버전에서 아무 권한 없는 login role로 확인한 결과입니다.

버전has_schema_privilege(..., 'public', 'CREATE')public ACLpublic 소유자
14.19t{postgres=UC/postgres,=UC/postgres}postgres
18.4f{pg_database_owner=UC/pg_database_owner,=U/pg_database_owner}pg_database_owner

CloudNativePG 1.29와 1.30은 PostgreSQL 14부터 18까지 지원합니다. 14를 쓰는 클러스터라면 database 소유자가 아닌 계정도 대상에 넣어 판단해야 합니다.

수정 내용​

advisory 본문은 "세 개의 패치"라고 적었지만 2026-09-28에 조회한 Patches 절에는 두 항목만 있습니다. 두 패치 모두 1.29.1과 1.28.3에 들어갔습니다.

Patch 1: 기본 쿼리의 스키마 명시​

PR #10576은 default-monitoring.yaml과 문서 예제의 카탈로그 참조에 pg_catalog.를 붙였습니다. pg_extensions 쿼리의 변경은 이렇습니다.

- current_database() as datname,
+ pg_catalog.current_database() as datname,
...
- WHEN default_version = installed_version THEN 0
+ WHEN default_version OPERATOR(pg_catalog.=) installed_version THEN 0

함수와 함께 = 같은 operator도 OPERATOR(pg_catalog.=)로 바꿨습니다. v1.29.0과 v1.29.1의 파일을 받아 스키마 없는 호출을 세어 보면 count( 3곳, now( 1곳, current_database( 1곳이 사라지고 OPERATOR( 13곳이 생깁니다. 파일 길이는 둘 다 491줄입니다. target_databases에 '*'를 쓰는 쿼리는 두 버전 모두 pg_extensions 하나입니다.

이 패치는 오퍼레이터가 배포하는 설정만 고칩니다. default-monitoring.yaml을 복사해 만든 custom ConfigMap은 사용자가 직접 고쳐야 합니다.

Patch 2: 전용 cnpg_metrics_exporter role​

commit 04e8c19가 근본 원인을 없앤 변경입니다. 18개 파일을 바꿨고, 요지는 인증 단계의 사용자 교체입니다.

  • cnpg_metrics_exporter role을 LOGIN PASSWORD NULL로 만들고 pg_monitor만 부여
  • NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS INHERIT LOGIN 속성과 pg_monitor 멤버십, 비밀번호 없음을 reconcile마다 primary에서 다시 맞춤
  • exporter 전용 connection pool이 user=cnpg_metrics_exporter application_name=cnpg_metrics_exporter로 Unix socket에 접속
  • pg_hba.conf에 local all cnpg_metrics_exporter peer map=cnpg_metrics_exporter, pg_ident.conf에 매핑 한 줄 추가
  • SET ROLE TO pg_monitor 제거, 대신 트랜잭션마다 search_path 앞에 pg_catalog를 붙임
  • target_databases: '*' 확장 시 has_database_privilege(datname, 'CONNECT')가 false인 database는 Debug 로그만 남기고 제외
  • bootstrap.initdb.import의 role 가져오기 대상에서 제외

수정 후 구조를 같은 PostgreSQL 18.4 컨테이너에서 재현했습니다. role을 commit과 같은 문장으로 만들고, 전용 계정으로 접속해 앞 절의 문장을 다시 실행한 결과입니다.

session_user | current_user
-----------------------+-----------------------
cnpg_metrics_exporter | cnpg_metrics_exporter

RESET -- 결과는 그대로 cnpg_metrics_exporter
ERROR: permission denied to set role "postgres"
ERROR: permission denied to set session authorization "postgres"
ERROR: permission denied for function pg_read_file
ERROR: permission denied to COPY to or from an external program
DETAIL: Only roles with privileges of the "pg_execute_server_program" role may COPY to or from an external program.
ERROR: permission denied to COPY to a file
ERROR: permission denied for table pg_authid

pg_stat_activity와 pg_settings는 그대로 조회됩니다. session_user가 처음부터 비 superuser이므로 RESET ROLE이 돌아갈 곳도 비 superuser입니다.

peer 매핑도 확인했습니다. commit의 두 줄을 컨테이너 설정에 넣고({{.Username}} 자리는 컨테이너 OS 사용자 postgres) reload한 뒤 pg_hba_file_rules, pg_ident_file_mappings로 읽힌 규칙을 보고 접속해 봤습니다.

접속 시도결과
OS 사용자 postgres, socket, -U cnpg_metrics_exporter성공, session_user는 cnpg_metrics_exporter
OS 사용자 root, socket, -U cnpg_metrics_exporterPeer authentication failed
TCP 127.0.0.1, 비밀번호 입력password authentication failed

비밀번호가 NULL이라 TCP로는 접속할 수 없습니다. socket에서는 Pod 안의 postgres OS 사용자만 이 role로 접속합니다.

노트

수정 후 exporter 권한은 pg_monitor 범위로 줄었습니다. 사용자 소유 테이블이나 pg_authid, pg_subscription 같은 superuser 전용 카탈로그를 읽던 custom 쿼리는 실패합니다. 이런 쿼리를 계속 쓰려면 GRANT USAGE ON SCHEMA, GRANT SELECT를 cnpg_metrics_exporter에 따로 부여해야 합니다. PUBLIC의 CONNECT를 회수한 database도 GRANT CONNECT가 필요합니다. 공식 문서는 이 role에 pg_monitor와 필요한 테이블 권한 외의 멤버십을 주지 말라고 경고합니다.

후속 취약점 CVE-2026-55769​

1.30.0 릴리스 날인 2026-06-29에 GHSA-x8c2-3p4r-v9r6이 공개됐습니다. 이번에는 exporter 대신 instance manager가 superuser로 여는 connection에 search_path가 고정돼 있지 않았던 문제입니다. 역시 CWE-426이고 critical입니다. 수정은 PR #10774이며, 오퍼레이터가 여는 모든 pooled connection의 startup message에 search_path = pg_catalog, public, pg_temp를 넣었습니다.

라인CVE-2026-44477 수정CVE-2026-55769 수정
1.281.28.31.28.4
1.291.29.11.29.2
1.301.30.0부터 포함1.30.0부터 포함

advisory는 오퍼레이터 connection에 고정 search_path를 주거나 신뢰할 수 없는 사용자에게 database 소유권을 주지 않은 클러스터는 해당되지 않는다고 적었습니다. database 소유자 계정이 이번 두 건에서 공통 조건이라는 점은 운영 관점에서 기억해 둘 만합니다.

업그레이드​

지원 정책 문서 기준으로 1.28.x는 2026-06-30에 지원이 끝났고, 1.29.x는 2026-09-29가 EOL입니다. 2026-09-23에 나온 1.30.1과 1.29.3이 현재 최신입니다. 두 CVE를 함께 정리하려면 1.30.1로 올리는 것이 가장 단순합니다. 1.29에 남는다면 최소 1.29.2가 필요하지만 EOL이 바로 다음 날입니다.

공식 문서와 릴리스 노트에서 확인한 순서와 주의 사항입니다. CloudNativePG 업그레이드 자체는 이 글에서 실행해 보지 않았습니다.

  1. 현재 오퍼레이터 버전과 대상 클러스터의 PostgreSQL major 버전 확인
  2. custom monitoring ConfigMap, Secret에서 사용자 테이블이나 superuser 전용 카탈로그를 읽는 쿼리 목록 작성
  3. 그 쿼리에 필요한 GRANT를 cnpg_metrics_exporter 기준으로 준비 (role은 업그레이드 후 생김)
  4. replica cluster가 있으면 source primary cluster를 먼저 업그레이드. role은 source primary에서 만들어져 streaming replication으로 내려가므로, replica cluster를 먼저 올리면 role이 없는 상태로 scrape합니다
  5. 업그레이드 후 instance manager 로그에서 exporter의 권한 오류, role 누락 오류 확인
  6. role이 없으면 공식 문서의 수동 생성 문장을 writable primary에서 superuser로 실행
CREATE ROLE cnpg_metrics_exporter WITH LOGIN NOSUPERUSER NOCREATEDB
NOCREATEROLE NOREPLICATION NOBYPASSRLS INHERIT;
GRANT pg_monitor TO cnpg_metrics_exporter;

바로 올릴 수 없을 때 advisory가 제시한 임시 조치는 네 가지입니다. custom 쿼리의 모든 카탈로그 참조에 pg_catalog.를 붙이기, 신뢰하는 role만 database를 소유하게 하기, target_databases: '*' 범위 줄이기, 신뢰할 수 없는 사용자에게 metric 쿼리 본문을 노출하지 않기입니다. advisory는 첫 번째가 부분 완화일 뿐이라고 명시합니다.

점검 항목​

아래 SQL은 PostgreSQL 18.4 컨테이너에서 실행해 결과 형태를 확인했습니다. CloudNativePG Pod 안에서는 실행해 보지 않았습니다. primary에 superuser로 접속해 실행하는 것을 전제로 합니다. superuser로 사용자 database에 접속할 때는 PGOPTIONS="-c search_path=pg_catalog"를 붙여 세션 search_path를 고정하십시오.

exporter가 어떤 계정으로 접속해 있는지 확인합니다. 수정 전 버전이면 usename이 postgres, rolsuper가 t로 나옵니다.

SELECT a.datname, a.usename, r.rolsuper, a.application_name
FROM pg_catalog.pg_stat_activity a
JOIN pg_catalog.pg_roles r ON r.oid = a.usesysid
WHERE a.application_name = 'cnpg_metrics_exporter';
datname | usename | rolsuper | application_name
----------+-----------------------+----------+-----------------------
app | cnpg_metrics_exporter | f | cnpg_metrics_exporter
postgres | postgres | t | cnpg_metrics_exporter

위 출력은 수정 전과 수정 후 연결을 컨테이너에 동시에 띄워 만든 예시입니다. 실제 클러스터에서는 한 종류만 나와야 합니다.

role 속성과 멤버십이 commit이 강제하는 상태와 같은지 봅니다. member_of에 pg_monitor 외의 role이 있으면 누가 왜 줬는지 확인해야 합니다.

SELECT r.rolname, r.rolsuper, r.rolinherit, r.rolcanlogin,
a.rolpassword IS NULL AS no_password,
ARRAY(SELECT b.rolname FROM pg_catalog.pg_auth_members m
JOIN pg_catalog.pg_roles b ON b.oid = m.roleid
WHERE m.member = r.oid) AS member_of
FROM pg_catalog.pg_roles r
JOIN pg_catalog.pg_authid a ON a.oid = r.oid
WHERE r.rolname = 'cnpg_metrics_exporter';
rolname | rolsuper | rolinherit | rolcanlogin | no_password | member_of
-----------------------+----------+------------+-------------+-------------+--------------
cnpg_metrics_exporter | f | t | t | t | {pg_monitor}

database 소유자가 누구인지 봅니다. 두 CVE 모두 database 소유자 계정이 조건에 들어가므로, 애플리케이션 계정이 소유자인지 확인해 둡니다.

SELECT d.datname, pg_catalog.pg_get_userbyid(d.datdba) AS owner, r.rolsuper AS owner_is_super
FROM pg_catalog.pg_database d
JOIN pg_catalog.pg_roles r ON r.oid = d.datdba
WHERE NOT d.datistemplate
ORDER BY 1;

database나 role 수준에서 search_path를 바꿔 둔 설정이 있는지 봅니다. 의도하지 않은 항목이 있으면 설정한 사람과 이유를 확인합니다.

SELECT d.datname, r.rolname, s.setconfig
FROM pg_catalog.pg_db_role_setting s
LEFT JOIN pg_catalog.pg_database d ON d.oid = s.setdatabase
LEFT JOIN pg_catalog.pg_roles r ON r.oid = s.setrole
WHERE pg_catalog.array_to_string(s.setconfig, ',') LIKE '%search_path%';

pg_catalog 밖에 pg_catalog 함수와 같은 이름의 함수, 또는 사용자 operator가 있는지 database마다 확인합니다. 확장 소속 객체는 pg_depend의 deptype = 'e'로 제외했습니다. 이 조건이 없으면 citext 하나만 설치해도 동명 함수 25개가 나오고, 조건을 넣으면 확장이 아닌 객체만 남습니다.

SELECT n.nspname AS schema, p.proname AS name, 'function' AS kind,
pg_catalog.pg_get_userbyid(p.proowner) AS owner
FROM pg_catalog.pg_proc p
JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
AND EXISTS (SELECT 1 FROM pg_catalog.pg_proc c
WHERE c.pronamespace = 'pg_catalog'::pg_catalog.regnamespace
AND c.proname OPERATOR(pg_catalog.=) p.proname)
AND NOT EXISTS (SELECT 1 FROM pg_catalog.pg_depend d
WHERE d.classid = 'pg_catalog.pg_proc'::pg_catalog.regclass
AND d.objid = p.oid AND d.deptype = 'e')
UNION ALL
SELECT n.nspname, o.oprname, 'operator', pg_catalog.pg_get_userbyid(o.oprowner)
FROM pg_catalog.pg_operator o
JOIN pg_catalog.pg_namespace n ON n.oid = o.oprnamespace
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
AND NOT EXISTS (SELECT 1 FROM pg_catalog.pg_depend d
WHERE d.classid = 'pg_catalog.pg_operator'::pg_catalog.regclass
AND d.objid = o.oid AND d.deptype = 'e')
ORDER BY 1, 2;

결과가 있다고 해서 공격 흔적이라는 뜻은 아닙니다. 애플리케이션이 일부러 만든 함수일 수도 있으므로 소유자와 생성 시점을 확인하는 출발점으로 쓰십시오.

custom 쿼리 본문은 Kubernetes 쪽에서 확인합니다. 아래 명령은 이 글에서 실행하지 않았고, 클러스터 spec의 monitoring.customQueriesConfigMap 경로를 따른 것입니다.

kubectl get cluster -A -o jsonpath='{range .items[*]}{.metadata.namespace}/{.metadata.name}{"\t"}{.spec.monitoring.customQueriesConfigMap}{"\n"}{end}'
kubectl get configmap <name> -n <ns> -o yaml | grep -nE "target_databases|current_database\(|now\("

측정 환경과 한계​

  • macOS 호스트의 Colima VM (Docker 28.3.3, 2 CPU, 약 12GB, Linux 6.8, arm64), 다른 작업과 공유
  • postgres:18 (PostgreSQL 18.4), postgres:14 (PostgreSQL 14.19) 컨테이너
  • CloudNativePG 오퍼레이터와 Kubernetes 클러스터는 띄우지 않았습니다. CNPG 쪽 사실은 advisory, commit diff, 릴리스 노트, 지원 정책 문서, default-monitoring.yaml 원본으로만 확인했습니다
  • exploit 체인은 재현하지 않았고, PostgreSQL 동작을 하나씩 따로 확인했습니다

관련 글​

  • pg_maintain 권한 위임 정리: 유지보수 명령 중 search_path를 고정하는 PostgreSQL 17의 방어 장치를 다룹니다. 권한을 위임받은 세션이 무엇을 실행하게 되는지를 본다는 점에서 이 글과 같은 질문입니다
  • CloudNativePG 10.1 모니터링: exporter 구조, 9187 포트, custom 쿼리 ConfigMap 작성법

참고​

SET ROLE로 권한을 낮춘 연결은 처음 인증한 신원을 계속 기억해요. 모니터링처럼 주기적으로 도는 연결일수록 처음부터 낮은 권한으로 인증하는 편이 맞다는 것이 이번 수정에서 제가 얻은 기준이에요. 아직 1.29.1 이전이라면 1.30.1로 올리고 위 점검 쿼리를 한 번 실행해 보시길 권해요.

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가 나오면 릴리스 노트를 기준으로 남은 기능을 한 번 더 확인해 볼게요.

참고​

pg_maintain 권한 위임 정리

· 약 8분

PostgreSQL 17은 테이블 수준 권한에 MAINTAIN을, predefined role에 pg_maintain을 추가했습니다. VACUUM과 ANALYZE 같은 유지보수 명령을 소유자나 superuser가 아닌 계정에 위임하기 위한 권한입니다.

이 글의 모든 동작은 Docker 컨테이너(PostgreSQL 16.14 / 17.11 / 18.4, aarch64)에서 직접 실행해 확인한 결과입니다.

배경: PostgreSQL 16까지의 권한 모델​

PostgreSQL 16까지 유지보수 명령의 실행 조건은 테이블 소유자, 데이터베이스 소유자, superuser 셋 중 하나였습니다. 중간 단계가 없었습니다.

소유자가 아닌 ops 계정으로 실행한 결과입니다.

SET ROLE ops;
VACUUM orders;
ANALYZE orders;
REINDEX TABLE orders;
WARNING: permission denied to vacuum "orders", skipping it
WARNING: permission denied to analyze "orders", skipping it
ERROR: must be owner of table orders

REINDEX는 에러로 멈추는데 VACUUM과 ANALYZE는 경고만 남기고 넘어갑니다. 대상이 여러 개일 때 하나가 막혔다고 전체를 중단시키지 않으려는 설계입니다. 문제는 권한 자체가 없는 계정으로 데이터베이스 전체를 대상으로 실행할 때입니다.

$ vacuumdb -U ops -d postgres -z
$ echo $?
0

경고 70건이 나왔는데 종료코드는 0입니다. 이 동작은 PostgreSQL 18.4까지 동일합니다.

그래서 PostgreSQL 16까지는 유지보수 배치 계정에 소유권을 넘기거나, superuser를 주거나, SECURITY DEFINER 함수로 감싸는 세 가지 우회책 중 하나를 골라야 했습니다. 셋 다 유지보수 하나 때문에 필요 이상의 권한이 따라옵니다.

MAINTAIN은 원래 PostgreSQL 16을 겨냥해 커밋됐다가 릴리스 전에 되돌려졌습니다. 되돌린 이유는 아래 search_path 절에서 따로 다룹니다.

기능 1: MAINTAIN 테이블 권한​

ACL 약어는 m이고 대상 객체 타입은 테이블 계열입니다.

GRANT MAINTAIN ON TABLE orders TO ops;
relname | relacl
---------+------------------------------
orders | {app=arwdDxtm/app,ops=m/app}

이 권한으로 열리는 명령은 여섯 개입니다.

명령비고
VACUUMFULL 포함
ANALYZE통계 갱신
CLUSTER
REINDEX
REFRESH MATERIALIZED VIEWmaterialized view 자체에 부여해야 함
LOCK TABLE모드 제한 없음

GRANT ALL ON TABLE에도 포함됩니다. PostgreSQL 16의 arwdDxt가 17부터 arwdDxtm이 됩니다.

-- 16.14
grant_all_test16 | {app=arwdDxt/app,ops=arwdDxt/app}
-- 17.11
grant_all_test | {app=arwdDxtm/app,ops=arwdDxtm/app}

일괄 부여와 기본 권한 지정도 지원합니다.

GRANT MAINTAIN ON ALL TABLES IN SCHEMA public TO ops;
ALTER DEFAULT PRIVILEGES FOR ROLE app IN SCHEMA public
GRANT MAINTAIN ON TABLES TO ops;

기능 2: pg_maintain predefined role​

테이블마다 부여하는 대신 role 하나로 클러스터 전체를 덮습니다.

GRANT pg_maintain TO ops2;

모든 relation에 MAINTAIN이 있는 것처럼 동작하고, 범위에 시스템 카탈로그도 들어갑니다.

SET ROLE ops2;
REINDEX TABLE pg_catalog.pg_class; -- 성공

pg_maintain은 다른 predefined role의 멤버가 아닙니다. 이 role을 부여해도 pg_monitor나 pg_read_all_stats가 함께 붙지 않습니다.

기능 3: 유지보수 중 search_path 고정​

PostgreSQL 16에서 되돌린 이유가 여기 있습니다. Nathan Bossart(네이선 보사트)의 revert 커밋 메시지입니다.

A role with the MAINTAIN privilege may be able to use search_path tricks to escalate privileges to the table owner.

소유자 app이 index expression에 함수를 걸어 두고, ops가 MAINTAIN으로 REINDEX를 실행할 때 그 함수가 누구로 실행되는지 확인했습니다.

-- 소유자 app
CREATE FUNCTION trap2(i int) RETURNS int LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
RAISE NOTICE '[trap] current_user=% / search_path=%',
current_user, current_setting('search_path');
RETURN i;
END $$;
CREATE INDEX small_trap_idx ON small (trap2(amt));

-- 유지보수 담당 ops
SET ROLE ops;
SET search_path TO evil, public;
REINDEX INDEX public.small_trap_idx;

PostgreSQL 17.11의 출력입니다.

NOTICE: [trap] current_user=app / search_path=pg_catalog, pg_temp

index expression은 명령을 실행한 ops가 아니라 테이블 소유자 app의 권한으로 평가됩니다. 그리고 ops가 세션에 지정한 evil, public은 무시되고 search_path가 pg_catalog, pg_temp로 고정됩니다.

앞쪽 절반은 PostgreSQL 16도 같습니다. 뒤쪽 고정이 없으면 경로가 이렇게 열립니다.

ops는 자기가 만든 스키마를 search_path 앞에 두기만 하면 됩니다. 소유자의 함수가 스키마를 한정하지 않고 다른 함수를 호출하면 그 호출은 ops가 준비한 동명 함수로 연결되고, 결과적으로 app의 권한으로 실행됩니다. PostgreSQL 16 개발 주기가 늦어 기능 전체를 되돌렸고, Jeff Davis(제프 데이비스)가 search_path 고정을 구현한 뒤 PostgreSQL 17에서 함께 들어왔습니다.

장점​

읽기 권한과 유지보수 권한이 분리됩니다. MAINTAIN만 가진 계정으로 실행한 결과입니다.

VACUUM orders; -- 성공
ANALYZE orders; -- 성공
SELECT count(*) FROM orders; -- ERROR: permission denied for table orders
DELETE FROM orders WHERE id=1; -- ERROR: permission denied for table orders
TRUNCATE orders; -- ERROR: permission denied for table orders
ALTER TABLE orders ADD COLUMN x int; -- ERROR: must be owner of table orders
DROP TABLE orders; -- ERROR: must be owner of table orders

데이터를 한 줄도 읽지 못하는 계정이 그 테이블을 vacuum하고 통계를 갱신합니다.

통계도 새어 나가지 않습니다. pg_stats는 컬럼 단위 SELECT 권한을 확인하므로 MAINTAIN만 가진 계정에는 0행이 돌아옵니다. 히스토그램과 최빈값은 사실상 데이터 샘플이라 이 구분이 맞습니다.

표준 GRANT 체계에 들어와 있어서 ALTER DEFAULT PRIVILEGES로 신규 테이블까지 자동 적용되고, 권한 감사도 relacl의 m 하나로 끝납니다. 소유권을 옮기거나 superuser를 내주던 방식은 감사 추적이 어려웠습니다.

단점과 제약​

파티션 테이블의 부모에 부여해도 자식 파티션으로 내려가지 않습니다. 그리고 VACUUM이라 에러도 나지 않습니다.

GRANT MAINTAIN ON TABLE evt TO ops; -- evt 는 파티션 부모

SET ROLE ops;
VACUUM evt;
-- WARNING: permission denied to vacuum "evt_2026_01", skipping it
-- WARNING: permission denied to vacuum "evt_2026_02", skipping it

파티션이 많으면 ALL TABLES IN SCHEMA로 덮거나 pg_maintain role을 쓰는 쪽이 현실적입니다.

materialized view도 별도입니다. 기반 테이블에 부여해도 REFRESH MATERIALIZED VIEW는 막히고, materialized view 자체에 다시 부여해야 통과합니다.

SET ROLE ops;
REFRESH MATERIALIZED VIEW mv_orders;
-- ERROR: permission denied for materialized view mv_orders

RESET ROLE;
GRANT MAINTAIN ON TABLE mv_orders TO ops; -- TABLE 키워드로 부여한다

LOCK TABLE이 포함되는데 모드 제한이 없습니다. 읽기 권한도 없는 계정이 운영 테이블을 ACCESS EXCLUSIVE로 잠글 수 있으므로, 유지보수 위임과 가용성 위험을 같이 저울질해야 합니다.

권한을 제대로 부여한 뒤에도 VACUUM의 조용한 skip 동작은 그대로입니다. 배치 쪽에서 종료코드 대신 경고 건수를 세는 편이 안전합니다.

search_path 고정은 MAINTAIN 권한을 쓰지 않아도 적용됩니다. ANALYZE, CLUSTER, CREATE INDEX, CREATE MATERIALIZED VIEW, REFRESH MATERIALIZED VIEW, REINDEX, VACUUM 전부가 대상이고 소유자 본인이 실행해도 같습니다. 그래서 이런 코드가 PostgreSQL 17에서 깨집니다.

CREATE FUNCTION helper(i int) RETURNS int LANGUAGE sql IMMUTABLE
AS 'SELECT i * 2';

CREATE FUNCTION expr(i int) RETURNS int LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
RETURN helper(i); -- 스키마를 한정하지 않았다
END $$;

CREATE INDEX t2_expr_idx ON t2 (expr(amt));
버전CREATE INDEX 결과
16.14성공, indisvalid = t
17.11ERROR: function helper(integer) does not exist
18.4ERROR: function helper(integer) does not exist

helper와 expr이 같은 public 스키마에 있고 소유자가 직접 실행했는데도 실패합니다. search_path가 pg_catalog, pg_temp라서 public이 검색 경로에 없기 때문입니다. 기존 인덱스를 재생성하거나 REINDEX를 실행하는 시점에 드러나므로, 업그레이드 직후가 아니라 한참 뒤 정기 유지보수 중에 발견되기 쉽습니다.

고치는 방법은 두 가지이고 둘 다 확인했습니다. 함수 정의에 검색 경로를 지정하거나,

CREATE FUNCTION expr_fixed(i int) RETURNS int LANGUAGE plpgsql IMMUTABLE
SET search_path = public, pg_catalog
AS $$ BEGIN RETURN helper(i); END $$;

호출 쪽을 스키마로 한정합니다.

CREATE FUNCTION expr_qual(i int) RETURNS int LANGUAGE plpgsql IMMUTABLE
AS $$ BEGIN RETURN public.helper(i); END $$;
경고

PostgreSQL 17로 올리기 전에 index expression과 materialized view 정의에 쓰인 사용자 정의 함수를 먼저 확인하는 편이 좋습니다. pg_proc에서 proconfig가 비어 있으면서 본문에 스키마 한정 없는 호출이 있는 함수가 후보입니다.

마지막으로, autovacuum은 이 권한 체계와 무관하게 동작합니다. pg_maintain을 부여한다고 autovacuum 쪽이 달라지지 않습니다.

17과 18의 차이​

PostgreSQL 18에서 MAINTAIN의 범위가 넓어졌습니다. 통계 조작 함수가 이 권한으로 묶였습니다.

버전MAINTAIN 설명에 포함된 범위
17VACUUM, ANALYZE, CLUSTER, REFRESH MATERIALIZED VIEW, REINDEX, LOCK TABLE
18위 전부 + database object statistics manipulation functions
SET ROLE ops2; -- pg_maintain
SELECT pg_clear_relation_stats('public', 'orders'); -- 성공
SELECT pg_restore_relation_stats(
'schemaname', 'public', 'relname', 'orders',
'version', 180000, 'relpages', 42::integer); -- t

SET ROLE nobody;
SELECT pg_clear_relation_stats('public', 'orders');
-- ERROR: permission denied for table orders

읽지 못하는 테이블의 통계를 지우거나 원하는 값으로 덮어쓸 수 있다는 뜻입니다. 통계가 틀어지면 실행 계획이 틀어지므로, PostgreSQL 18에서 pg_maintain을 부여하는 판단은 17에서보다 무겁게 봐야 합니다.

같은 흐름에서 PostgreSQL 18은 pg_signal_autovacuum_worker role도 추가했습니다. 16과 17 컨테이너에는 없고 18.4에만 존재하는 것을 확인했습니다. autovacuum worker를 취소하거나 세션을 종료하는 권한이라, 유지보수 위임을 설계할 때 함께 검토할 대상입니다.

정리​

MAINTAIN은 vacuum과 통계 갱신을 데이터 접근과 분리한 권한입니다. 유지보수 하나 때문에 소유권을 넘기거나 superuser를 내주던 구간이 없어졌습니다.

도입 전에 두 가지는 확인하고 가시는 게 좋아요. 하나는 search_path 고정 때문에 기존 index expression 함수가 깨지지 않는지, 다른 하나는 파티션과 materialized view처럼 권한이 상속되지 않는 곳이 배치에 섞여 있지 않은지입니다.

참고​

Hugo에서 Docusaurus로 통합

· 약 3분

Hugo PaperMod 블로그 구축에서 시작한 블로그를 Hugo에서 Docusaurus로 마이그레이션했어요. 원래 한 도메인에서 블로그 2개와 문서 노트 5개, 모두 Hugo 사이트 7개를 따로 돌렸는데 영역마다 빌드와 설정을 챙기는 일이 점점 무거워졌어요.

Hugo 7개 사이트를 Docusaurus 하나로 통합한 구조

Docusaurus 소개​

Docusaurus는 Meta가 만든 React 기반 오픈소스 정적 사이트 생성기입니다. 코드는 facebook/docusaurus에 공개되어 있습니다. 일반 블로그보다 버전 관리, 사이드바, 문서 탐색 같은 문서 사이트 기능에 초점을 맞춥니다. docs 플러그인을 여러 인스턴스로 구성할 수 있어 한 사이트 안에서도 문서 영역별 경로와 사이드바를 독립적으로 운영합니다. 블로그 플러그인 역시 여러 개를 띄울 수 있으므로 성격이 다른 블로그를 분리하기 좋습니다. Mermaid, 로컬 검색 플러그인, 다크 모드까지 문서 사이트에 필요한 기능을 생태계 안에서 함께 구성할 수 있습니다.

Docusaurus 공식 사이트

후보와 선택 기준​

첫 번째 후보는 Hugo를 유지하고 Hextra 단일 사이트로 합치는 방식이었습니다. 기존 도구를 그대로 쓰므로 마이그레이션 비용은 가장 적지만, 문서 중심 테마라 블로그 2개의 레이아웃을 나누어 살리기에는 아쉬웠습니다.

Docusaurus는 독립된 문서 영역 여러 개와 블로그 여러 개를 공식 플러그인의 다중 인스턴스로 구성할 수 있습니다. 문서 노트 5개와 블로그 2개라는 저의 구조에 이 방식이 정확히 맞았습니다.

Astro Starlight도 빠르고 깔끔한 문서 도구였지만 기본 구조는 단일 문서 사이트에 가깝고, 블로그에는 서드파티 플러그인이 필요했습니다. 결국 새 기술의 화려함보다 지금 가진 영역을 무리 없이 한 프로젝트에 담을 수 있는지를 기준으로 Docusaurus를 골랐습니다.

힘들었던 점​

첫 번째는 자동 변환의 사각지대였습니다. 변환 작업이 커밋된 파일을 기준으로 진행되면서 커밋하지 않은 글 11개가 결과에서 빠졌고, 배포 후 운영에서 404로 드러났습니다. 전체 변환을 다시 돌리면 이미 손본 글까지 덮을 수 있어 변환 스크립트에 증분 모드를 추가하고 누락된 글만 복구했습니다.

두 번째는 구 URL 보존이었습니다. 기존 주소 1,100여 개를 새 경로로 보내는 301 map을 만들었는데, URL 인코딩된 한글 태그 주소가 긴 key가 되면서 Nginx의 map_hash_bucket_size 기본값을 넘겼습니다. 설정 자체는 정상이었지만 reload가 실패했고, bucket 크기를 늘린 뒤에야 기존 주소를 유지한 채 전환할 수 있었습니다.

마이그레이션 중 콘텐츠 누락과 URL 보존 장애의 복구 경로

통합 후 모습​

홈은 일곱 영역을 안내하는 카드로 바꿨습니다. 상단 메뉴에서 블로그와 문서 노트를 오갑니다.

통합 후 dbalog.dev 홈

문서 영역은 좌측 사이드바와 우측 목차가 있는 Docusaurus 기본 문서 레이아웃을 그대로 씁니다.

문서 영역 레이아웃 (PostgreSQL 노트)

이제 빌드와 배포가 하나로 줄었어요. 예전 URL의 색인이 새 주소로 안정적으로 재구축되는지는 조금 더 지켜보는 중이에요.

PostgreSQL 18.5 결번

· 약 8분

버전 번호가 하나 비어 있으면 신경이 쓰여요. 8월 13일에 나온 PostgreSQL 마이너 릴리스는 18.6인데, 그 앞은 18.5가 아니라 18.4입니다. 제 경우엔 패키지 목록을 보다가 "내가 하나 놓쳤나" 싶어 한참을 되짚었습니다. 결론부터 적으면 놓친 것은 없습니다. 18.5는 만들어졌지만 세상에 나오지 않았습니다.

공식이 말한 것은 한 줄뿐입니다​

사유를 언급한 곳은 두 군데인데, 표현이 미묘하게 다릅니다.

18.6 릴리스 노트에는 이렇게 적혀 있습니다.

18.5 was never released, due to a regression discovered post-wrap.

릴리스 공지는 좀 더 건조합니다.

This release skips PostgreSQL 18 versions from PostgreSQL 18.4 to 18.6. 18.5 was not shipped due to a regression.

공지는 "shipped"라고만 했고, 릴리스 노트는 post-wrap이라는 단어를 하나 더 얹었습니다. 이 단어가 사실상 전부를 설명합니다. 무엇이 wrap이고, 그 이후라는 게 왜 중요한지 알면 18.5가 사라진 경로가 그대로 보입니다.

마이너 릴리스는 이런 순서로 만들어집니다​

PostgreSQL의 Release process 위키에 절차가 공개돼 있습니다. 요약하면 committer가 버전을 찍고, 서버에서 tarball을 말고, packager들이 먼저 써 보고, 마지막에 태그를 미는 순서입니다.

2단계가 wrap입니다. pgsql 계정으로 borka.postgresql.org에 들어가 mk-release-bundle을 실행하면 tarball과 체크섬이 staging 디렉토리에 만들어집니다. 이 단계를 특정 서버에서만 하는 이유는 재현성 때문입니다. bison, flex, docbook 버전이 다르면 결과물이 달라지니 빌드 환경을 한 곳으로 고정해 둔 것입니다.

주목할 곳은 3단계와 4단계 사이입니다. 위키가 태그를 두고 분명하게 적어 뒀습니다.

Pushing a tag is more or less irreversible, so don't do this until packager preliminary testing is over.

태그를 밀면 되돌리기 어렵습니다. 그래서 packager들이 최소 24시간 먼저 만져 보고, 이상이 없어야 태그가 나갑니다. 18.5는 그 구간을 통과하지 못했습니다.

취소는 되는데 번호는 돌아오지 않습니다​

같은 위키에 사고 처리 절차도 한 줄로 적혀 있습니다.

In event of disaster, fix code as needed then repeat the wrap process, with or without a version number bump as seems appropriate.

문제가 생기면 코드를 고쳐 wrap을 다시 하되, 번호를 올릴지 말지는 그때 적절히 판단하라는 뜻입니다. 규정이 아니라 재량입니다. 18.5 건에서는 올리는 쪽을 택했습니다.

왜 그랬을까요. version_stamp.pl이 이미 Stamp 18.5. 커밋을 남겼고 그 번호로 tarball이 만들어졌기 때문입니다. staging에 있던 물건이라 일반 사용자에게 내려간 것은 아니지만, packager들 손에는 18.5라는 이름표가 붙은 파일이 이미 가 있었습니다. 같은 번호로 내용이 다른 tarball을 한 번 더 돌리는 것보다 번호를 하나 올리는 편이 안전합니다. 태그를 밀기 전이라 릴리스 자체는 멈출 수 있었지만, 번호는 그 시점에 이미 쓰인 것입니다.

8월 9일까지는 18.5였습니다​

타임라인도 이 해석과 맞습니다. Jonathan Katz(조너선 카츠)가 pgsql-hackers에 올린 8월 13일 릴리스 공지 초안은 2026년 8월 9일자인데, 거기 적힌 버전은 아직 18.5입니다.

마이너 릴리스는 관례상 목요일에 공개하고 tarball은 그 주 초에 맙니다. 8월 13일이 목요일이니 wrap은 10일 전후, 공지 초안은 그 직전에 돌린 셈입니다. 초안이 나가고 며칠 사이에 번호가 하나 올라갔습니다.

결번은 여섯 개뿐입니다​

그럼 이런 일이 자주 있었을까요. 세어 봤습니다. bucardo가 모든 버전의 릴리스 노트를 한 페이지에 모아 두는데, 여기서 never released를 전수 검색하면 여섯 건이 나옵니다.

결번직전 릴리스대체 릴리스
7.4.207.4.19 (2008-01-07)7.4.21 (2008-06-12)
8.0.168.0.15 (2008-01-07)8.0.17 (2008-06-12)
8.1.128.1.11 (2008-01-07)8.1.13 (2008-06-12)
8.2.88.2.7 (2008-03-17)8.2.9 (2008-06-12)
8.3.28.3.1 (2008-03-17)8.3.3 (2008-06-12)
18.518.4 (2026-05-14)18.6 (2026-08-13)

여섯 개지만 사건은 두 건입니다. 위의 다섯은 대체 릴리스 날짜가 전부 2008년 6월 12일로 같습니다. 한 번의 사고로 당시 지원하던 브랜치가 통째로 결번된 것입니다.

18.5는 혼자입니다. 같은 날 나온 17.11, 16.15, 15.19, 14.24는 번호가 정상입니다. 회귀가 18 브랜치에만 있었다는 뜻입니다.

2008년에는 무슨 일이 있었나​

8.3.2 릴리스 노트를 열면 Release Date 필드가 never released입니다. 그런데 본문은 멀쩡합니다. "This release contains a variety of fixes from 8.3.1"로 시작하고, Migration to Version 8.3.2 절도 붙어 있고, Changes 목록에 마흔 건 가까운 수정이 그대로 나열돼 있습니다. Windows에서 UTF-8 인코딩일 때 나던 크래시, %r 매크로의 archive 절단 지점 오산, GIN의 "too many LWLocks taken" 실패, SIGTERM으로 backend를 개별 종료했을 때 shared memory가 오염되던 문제가 보입니다.

그중 %r 항목은 지금 봐도 아찔합니다. warm standby 스크립트가 그 값을 믿고 WAL segment 파일을 버리면 데이터를 잃을 수 있었습니다. 이런 수정을 담은 릴리스가 문턱까지 갔다가 멈췄습니다.

노트에 사유는 적혀 있지 않습니다. 대신 대체 버전 노트의 첫 문장이 단서를 흘립니다.

버전첫 문장
8.3.3one serious and one minor bug fix over 8.3.2
8.2.9one serious and one minor bug fix over 8.2.8
7.4.21one serious bug fix over 7.4.20

세상에 나온 적 없는 버전을 기준점으로 삼아 "그 대비 몇 건"이라고 적었습니다. 결번본이 실재했다는 자백입니다. 그리고 여기서 말하는 "one serious"가 무엇인지는 다섯 브랜치 공통 항목으로 노트에 남아 있습니다.

Make pg_get_ruledef() parenthesize negative constants (Tom Lane)

Before this fix, a negative constant in a view or rule might be dumped as, say, -42::integer, which is subtly incorrect: it should be (-42)::integer due to operator precedence rules. Usually this would make little difference, but it could interact with another recent patch to cause PostgreSQL to reject what had been a valid SELECT DISTINCT view query. Since this could result in pg_dump output failing to reload, it is being treated as a high-priority fix.

view나 rule 안에 들어 있는 음수 상수를 괄호 없이 덤프하던 문제입니다. 연산자 우선순위상 (-42)::integer여야 하는데 -42::integer로 나왔습니다. 그 자체로는 대개 차이가 없지만, 당시 함께 들어간 다른 패치와 맞물리면 멀쩡하던 SELECT DISTINCT view 쿼리를 PostgreSQL이 거부했습니다.

여기서 심각도가 뛴 지점은 마지막 문장입니다. pg_dump 출력이 다시 적재되지 않을 수 있다는 결론이었습니다. 백업 파일이 복원되지 않는 문제는 다른 어떤 버그와도 무게가 다릅니다. 준비돼 있던 다섯 개 tarball을 버리고 이 수정을 얹어 다시 낸 이유로 충분합니다.

두 사건이 남긴 기록의 차이​

구조는 같습니다. wrap이 끝난 뒤 회귀가 잡혔고, 고쳐서 번호를 올려 다시 냈습니다. 달라진 것은 그 사실을 어떻게 적어 두느냐입니다.

18.6 노트는 결번 사유를 한 줄로 명시했습니다. 짧지만 왜 번호가 비었는지가 문서 안에 적혀 있습니다. 2008년 노트들은 never released라는 사실만 남기고 사유를 적지 않았습니다. 지금 그 이유를 재구성할 수 있는 것은 대체본이 "over 8.3.2"라는 표현을 쓴 덕분입니다. 의도한 기록이라기보다 흔적에 가깝습니다.

공통점도 있습니다. 여섯 개 전부 릴리스 노트 페이지가 지금도 살아 있습니다. 나오지 않은 버전의 문서를 지우지 않는 관행이 적어도 2008년부터 이어지고 있습니다. 덕분에 18년 전 사건을 1차 출처로 따라갈 수 있었습니다.

검색하면 섞여 나오는 이야기 하나​

18.5 결번을 검색하면 "standby가 구버전 마이너의 WAL을 재생하다 self-deadlock에 빠진다"는 내용이 사유처럼 딸려 나옵니다. 별개 사건입니다.

credativ가 정리한 그 버그는 MultiXactOffsetSLRU deadlock이고, 2026년 5월 14일에 나온 14.23, 15.18, 16.14에서 유입됐습니다. 17과 18은 영향이 없습니다. 증상은 standby의 startup 프로세스가 pg_stat_activity에서 LWLock/MultiXactOffsetSLRU 대기로 멈추는 형태입니다.

같은 8월 13일 릴리스에서 함께 고쳐졌을 뿐, 18.5를 취소시킨 원인이 아닙니다. 두 이야기를 붙여 쓰면 틀린 글이 됩니다.

지금 확인할 것​

운영 관점에서 할 일은 많지 않습니다.

  1. 18.4에서 18.6으로 바로 올립니다. 중간에 빠뜨린 버전은 없습니다
  2. 자동화 스크립트가 마이너 번호를 순차 증가로 가정하고 있다면 확인합니다. 18.4 + 1 = 18.5를 기대하는 코드는 여기서 멈춥니다
  3. 사내 문서나 지원 버전 표에 18.5를 적어 둔 곳이 있으면 지웁니다

3번은 사소해 보이지만 실제로 혼란을 만듭니다. "18.5 적용 예정"이라고 써 둔 계획서가 남아 있으면 나중에 읽는 사람이 없는 버전을 찾게 됩니다.

남은 질문​

18.5를 취소시킨 회귀가 정확히 무엇이었는지는 공개되지 않았습니다. 공식 문구는 "a regression"이 전부입니다. 2008년 사건에서도 회귀를 유발한 "another recent patch"가 무엇인지는 노트에 적혀 있지 않습니다.

저는 이 공백이 문제라고 보지는 않아요. 세상에 나가지 않은 코드의 버그를 상세히 적는 것은 그 자체로 이상한 일이니까요. 다만 번호가 하나 비면 사람은 반드시 이유를 찾습니다. 18.6 노트가 한 줄이라도 남겨 둔 덕분에 저 같은 사람이 패키지 목록 앞에서 오래 헤매지 않았습니다. 기록은 그 정도만 해 줘도 충분히 일을 합니다.

참고​

Oracle RU 즉시 적용

· 약 5분

벤더가 "패치를 빨리 적용하세요"라고 말하는 건 새롭지 않아요. 그런데 그 이유가 "AI가 우리 패치를 분석해서 공격 방법을 알아낼 수 있어서"라면 성격이 다릅니다.

2026년 8월 18일 오라클이 Prepare Now: Apply Oracle Database Release Update Immediately Upon Availability를 냈습니다. 지원 중인 모든 릴리스, 19c와 Oracle AI Database 26ai를 포함해, 데이터베이스 자산 전체에 최신 분기 Release Update를 테스트하고 배포할 준비를 지금 하라는 내용입니다.

권고의 근거가 달라졌다​

주목할 부분은 요구 사항이 아니라 근거입니다. 오라클이 든 이유는 frontier AI 모델이 소프트웨어 취약점을 찾고 악용하는 장벽을 크게 낮추고 있다는 것입니다. 그 모델들이 하는 일로 네 가지를 꼽았습니다. 약점 식별, 소프트웨어 변경 분석, 보안 패치의 리버스 엔지니어링, 그리고 잠재적 공격 경로 개발입니다. 속도와 규모가 전례 없다고 표현했습니다.

이 목록에서 세 번째가 핵심입니다. 보안 패치의 리버스 엔지니어링입니다.

패치는 무엇이 잘못됐는지를 알려 줍니다. 코드의 어느 줄이 어떻게 바뀌었는지 보면 그 전에 무엇이 가능했는지 역산할 수 있습니다. 이걸 patch diffing이라고 부르고, 오래된 기법입니다. 새로운 건 그 작업의 비용입니다. 숙련된 분석가가 며칠 걸리던 일을 모델이 훨씬 빨리 해내면, 패치 공개와 실제 적용 사이의 구간이 그만큼 위험해집니다.

패치를 늦게 적용하는 쪽이 더 위험해지는 구조는 원래 있었습니다. 달라진 것은 그 위험이 커지는 속도입니다.

8월 CSPU의 물량​

같은 주에 나온 숫자가 이 권고의 배경을 보여 줍니다. Qualys 분석에 따르면 8월 Critical Security Patch Update는 943개 취약점을 다뤘습니다. Oracle 8월 CSPU advisory도 이 릴리스를 분기 CPU가 아닌 CSPU로 분류하고, 신규 보안 패치 943개를 담았다고 밝힙니다.

무인증 원격 악용이 가능한 것들이 제품군별로 이렇게 나왔습니다.

제품군무인증 원격 악용 가능
Oracle Fusion Middleware182
Oracle Hyperion107
Oracle Commerce47
Oracle E-Business Suite27
Oracle Siebel CRM21

E-Business Suite에서 CVSS 9.8인 CVE-2026-60782와 CVE-2026-70926이 나왔습니다.

DBA 관점에서 궁금한 것은 Database 쪽 물량입니다. 943개 중 데이터베이스 제품군은 17개였습니다.

구성요소패치 수최고 CVSS
Oracle Database Server69.6
Oracle Autonomous Health Framework78.8
Oracle Essbase49.8

전체 943개에 비해 17개는 적어 보입니다. 하지만 Database Server의 최고 점수가 9.6이라는 게 중요합니다. 개수가 아니라 그 6개가 무엇인지가 판단 근거입니다.

월간 체제와 즉시 적용이 만나면​

오라클이 분기 CPU에 월간 CSPU를 더한 이야기를 다룬 적이 있습니다. 이번 권고를 그 흐름 위에 놓으면 실무 부담이 선명해집니다.

분기 체제에서는 1년에 네 번 패치 시기가 왔습니다. 한 번마다 테스트에 몇 주를 쓸 여유가 있었습니다. 월간 체제에서는 열두 번입니다. 여기에 "나오는 즉시 적용하라"가 붙으면, 테스트 기간을 얼마로 잡을지가 실질적인 문제가 됩니다.

권고와 현실 사이의 간격을 어떻게 좁힐지가 결국 각 조직의 판단입니다. 몇 가지 방향은 이렇습니다.

RU 종류를 구분하는 것이 먼저입니다. 오라클의 Release Update(RU)와 Monthly Recommended Patch(MRP)는 성격이 다릅니다. 과거의 Release Update Revision(RUR)은 19c 기준 2023년 초에 제공이 끝났고, 그 자리를 MRP가 대신합니다. 전체를 같은 절차로 다루면 열두 번을 감당하지 못합니다.

테스트 자동화의 범위를 다시 볼 필요도 있습니다. 회귀 검증을 사람이 도는 구조라면 월간 주기를 못 따라갑니다. 이 부분은 패치 정책 이전에 검증 파이프라인 문제입니다.

그리고 적용 순서를 노출도로 정하는 방법이 있습니다. 무인증 원격 악용이 가능한 구성요소를 앞에 두는 식입니다. 위 표에서 Fusion Middleware의 182개가 왜 먼저인지는 숫자가 설명합니다.

AI가 양쪽에서 일한다​

Anthropic의 Project Glasswing을 다룰 때는 AI가 핵심 인프라의 취약점을 찾아 주는 쪽 이야기였습니다. 이번 오라클 공지는 같은 능력의 반대쪽입니다.

두 사례를 나란히 놓으면 구도가 정리됩니다. 방어 측은 AI로 코드를 감사해 취약점을 미리 찾습니다. 공격 측은 AI로 패치를 분석해 이미 고쳐진 취약점을 역산합니다. 전자는 패치 이전, 후자는 패치 이후입니다. 그래서 패치 공개 시점이 양쪽의 경계선이 됩니다.

벤더가 이 판단을 공식 문서로 낸 것이 이번 공지의 의미라고 봅니다. "AI 때문에 패치 정책을 바꿉니다"를 제품 블로그에 적은 사례가 아직 흔하지 않습니다. 오라클이 자사 데이터베이스 고객 전체를 향해 그렇게 적었습니다.

실무에서 확인할 것​

Oracle Database Appliance를 쓰는 환경이라면 릴리스 19.32에 7월 Database Release Update와 19c, 26ai 클론 파일이 들어 있습니다. 계획, 테스트, 배포 일정을 지금 시작하라는 것이 공지의 요구입니다.

정리하면 확인 항목은 셋입니다. 현재 운영 중인 데이터베이스의 RU 수준이 어디인지, 다음 RU가 나올 때 테스트에 며칠을 쓸 계획인지, 그리고 그 며칠이 이번 공지의 논리에 비추어 감당할 만한 구간인지입니다.

마지막 항목이 답하기 어렵습니다. 패치 공개 후 며칠이 안전한지 아무도 숫자로 말해 주지 않으니까요. 저는 이 공지가 그 숫자가 줄어들고 있다는 신호로 읽혔어요.

참고​

PG 병렬 집계 공유 해시

· 약 7분

병렬 쿼리를 켰는데 빨라지지 않는 경험, 집계 쿼리에서 특히 잦습니다. 이유가 구조에 있고, 그 구조를 바꿔 보려는 실험 결과가 나왔어요.

pgEdge의 Andrei Lepikhov(안드레이 레피호프)가 Do Global Hash Tables Strike Back in PostgreSQL?에서 공유 해시 테이블 기반 병렬 집계를 직접 구현하고 측정했습니다. 결론이 흥미롭습니다. 되기는 되는데, 값이 너무 비쌉니다.

지금 구조: 나눠서 세고, 하나가 합친다​

PostgreSQL의 병렬 집계는 두 단계입니다. 각 워커가 자기가 읽은 행을 Partial HashAggregate로 부분 집계합니다. 그 결과를 Gather가 모으고, 리더 프로세스 하나가 Finalize HashAggregate로 합칩니다.

그룹 수가 적으면 이 구조가 잘 돕니다. 워커 넷이 각각 100개 그룹의 부분 합계를 만들면, 리더는 400개 행만 합치면 됩니다.

그룹 수가 많으면 이야기가 달라집니다. 그룹이 500만 개면 각 워커의 부분 집계 결과도 수백만 행이고, 리더가 그걸 다 받아 다시 해시 테이블을 만들어야 합니다. 저자가 든 예시에서 스캔은 252ms인데 Finalize 단계가 전체 25초 중 18초를 썼습니다. 병렬을 끄면 13초였으니, 병렬 계획이 직렬보다 거의 두 배 느렸습니다.

직접 재 봤다​

PostgreSQL 18에서 확인했습니다. 워커를 8개까지 쓸 수 있게 두고, 500만 행 테이블 셋을 만들어 그룹 수만 달리했습니다.

-- 그룹 100개
create table agg_few as select (i % 100) as g, i as v
from generate_series(1,5000000) i;

-- 그룹 500만개 (모든 행이 각자 그룹)
create table agg_many (g bigint, v bigint);
insert into agg_many select i, i from generate_series(1,5000000) i;

그룹 100개짜리는 예상대로 병렬 계획이 나왔습니다.

Finalize GroupAggregate (actual time=851.515..855.136 rows=100.00)
-> Gather Merge (actual time=851.509..855.094 rows=400.00)
Workers Planned: 3
Workers Launched: 3
-> Sort (actual time=837.048..837.052 rows=100.00 loops=4)

Gather Merge가 400행을 올리고 Finalize가 4ms 안에 끝납니다. 워커 넷이 각각 100개 그룹을 만들었으니 정확히 400행입니다.

그룹 500만개에서는 계획 자체가 달라졌습니다.

HashAggregate (actual time=3860.148..8242.441 rows=5000000.00)
Group Key: g
Batches: 17 Memory Usage: 135313kB Disk Usage: 161920kB
-> Seq Scan on agg_many (actual time=0.023..569.741 rows=5000000.00)
Execution Time: 8488.126 ms

병렬이 아예 없습니다. planner가 워커를 하나도 쓰지 않기로 결정했습니다. 비용 모형이 이미 "이 경우 병렬 집계는 손해"라고 판단한 것입니다. 저자가 지적한 문제를 planner가 회피하는 방식으로 대응하고 있다는 뜻입니다.

그래서 억지로 병렬을 켜 봤습니다. parallel_setup_cost와 parallel_tuple_cost를 0으로 낮췄습니다.

HashAggregate (actual time=2438.858..6180.438 rows=5000000.00)
Batches: 17 Memory Usage: 135313kB Disk Usage: 161936kB
-> Gather (actual time=0.263..300.906 rows=5000000.00)
Workers Planned: 3
Workers Launched: 3

여기가 이 실험에서 가장 선명한 부분입니다. Gather가 500만 행을 300ms에 올립니다. 읽기는 병렬로 잘 됐습니다. 그런데 그 위의 HashAggregate가 6,180ms를 씁니다. 스캔은 병렬인데 집계는 단일 프로세스입니다.

주목할 것은 Partial HashAggregate가 아예 붙지 않았다는 점입니다. 그룹이 너무 많아 부분 집계가 아무 이득이 없다고 보고, 워커는 스캔만 하고 집계 전부를 리더에게 넘겼습니다. 저자가 지적한 "단일 프로세스 병목"이 계획에 그대로 나타납니다.

디스크 spill도 눈에 걸립니다. work_mem을 64MB로 뒀는데 17개 batch로 나뉘고 161MB를 디스크에 썼습니다. 리더 하나가 500만 그룹의 해시 테이블을 메모리에 못 담아서입니다. 워커들이 나눠 담았다면 각자 125만 그룹이니 상황이 달랐을 것입니다.

프로토타입: 공유 해시 테이블​

저자가 만든 것은 DSM(Dynamic Shared Memory)에 해시 테이블 하나를 두고, 모든 워커가 lock을 잡고 그 테이블을 갱신하는 방식입니다. 부분 집계와 Finalize 단계가 통째로 사라집니다.

워커별 부분 집계와 리더 Finalize 구조를 DSM 공유 해시 방식과 비교

구현 규모가 이 실험의 성격을 보여 줍니다. nodeAgg.c 밖에 약 800줄, planner에 약 600줄(비용 모형과 적용 여부 판단), executor 코어에 일곱 줄이 들어갔습니다. nodeAgg.c에서 가져와 고친 static 함수가 여덟 개입니다.

새로 만들어야 했던 것들이 있습니다. Parallel Hash Join의 해시 테이블 설계를 재사용할 수 없어서 전용 해시 테이블을 만들었습니다. numeric이나 text처럼 가변 타입의 상태를 다루려고 DSM 안팎으로 복사하는 계층을 별도로 짰습니다. 메모리 한도 계산은 로컬에 모았다가 일괄 반영하는 방식으로 처리했고, 집계 함수마다 공유 방식이 안전한지 표시하는 aggsharedsafe 플래그를 새로 넣었습니다.

기존 기계장치도 꽤 썼습니다. spill 처리에 SharedTuplestore, by-value 상태에 32KB 단위 DSA 할당자, 단계 동기화와 임시 파일, 그리고 대기 이벤트 다섯 개와 LWLock tranche 두 개입니다.

균등 분포에서는 통한다​

측정은 GCP VM에서 했습니다. 48코어를 주로 쓰고 16코어, 14코어도 함께 봤습니다. 전부 메모리 안에서 돌았고 디스크 spill은 없었습니다.

그룹이 고르게 분포하고 워커가 8개일 때 결과입니다. by-value 집계는 약 4.8배, numeric 같은 by-reference 집계는 약 2.0배 빨라졌습니다. 같은 조건에서 두 배 차이가 나는 이유가 DSM 안팎으로 상태를 복사하는 비용입니다.

집계 함수 개수를 늘려 보면 곡선이 나옵니다. 그룹당 집계 2개면 4.64배, 12개면 5.80배로 정점을 찍고, 32개가 되면 4.40배로 내려갑니다. lock을 한 번 잡고 여러 집계를 갱신하니 개수가 늘면 lock 비용이 분산되고, 너무 늘면 critical section이 길어져 다시 나빠집니다.

skew에서 무너진다​

문제는 여기입니다. 한 그룹이 전체를 얼마나 차지하는지에 따라 결과가 이렇게 변합니다.

최대 그룹 비중속도 향상
2%4.49배
5~10%거의 동등
95%0.04배

95%에서 0.04배는 25배 느려진다는 뜻입니다. 이유가 명확합니다. 모든 워커가 같은 그룹의 상태를 갱신하려고 같은 lock을 두고 줄을 섭니다. 병렬이 아니라 순차가 되고, 여기에 lock 획득 비용이 얹힙니다.

현실 데이터에서 흔한 80대 20 분포에서는 by-value 집계가 대략 동등, by-reference는 통상적인 워커 수에서 이득이 거의 없었습니다. 실무 데이터가 대개 고르지 않다는 점을 생각하면 이 결과가 결정적입니다.

세 가지 장애물​

저자는 lock 경합 자체보다 더 근본적인 문제 셋을 지목했습니다.

첫째, LWLock의 잠금 단위입니다. "LWLock은 이 크기의 critical section에 맞는 primitive가 아니다"라고 적었습니다. count(*)조차 나노초 단위 증가 연산을 하려고 마이크로초 단위 LWLockAcquire()를 부릅니다. 보호하려는 작업보다 보호 장치가 비쌉니다.

둘째, JIT 컴파일이 불가능해집니다. 지금의 비공유 경로는 ExecBuildAggTrans()가 만든 마이크로프로그램 하나를 JIT으로 함수 하나로 컴파일합니다. 공유 경로에서는 lock을 잡기 전에 인자를 계산하고 lock 안에서 transition 함수를 호출해야 하니, 이 통합 컴파일이 성립하지 않습니다. 병렬로 얻은 이득을 컴파일 손실로 반납하는 구조입니다.

셋째, lock 안에서 임의 코드가 돕니다. enum_cmp_internal() 같은 함수는 카탈로그를 조회하고, 그 과정에서 table_open()과 buffer 읽기가 일어날 수 있습니다. LWLock을 잡은 상태에서 다른 lock을 잡는 상황이 만들어집니다.

프로세스 모델이 청구서를 보낸다​

저자의 결론 문장이 셉니다. "프로세스 모델이 공유 모델로 가는 길의 주된 장애물이다."

스레드 기반 엔진과 비교하면 차이가 구체적입니다. DSM 안에서는 직접 포인터를 쓸 수 없어서 palloc, repalloc, MemoryContext를 쓸 수 없습니다. by-reference 상태는 복사해 넣고 복사해 나와야 합니다. TOAST를 제자리에서 압축 해제할 수 없습니다. 집계 함수 구현이 표준적인 가변 상태 패턴을 쓸 수 없습니다.

정리하면 이렇습니다. "PostgreSQL은 스레드를 가진 엔진보다 공유 가변 상태에 훨씬 많은 값을 치르고, 돌려받는 것은 같다."

pgrust가 Volcano 모델의 청구서를 뜯어 보여 준 이야기와 같은 종류의 결론입니다. 30년 전에 정한 실행 모델이 지금 어떤 최적화를 막고 있는지 확인하는 작업입니다. Momjian이 짚은 코어의 빈칸들에도 같은 성격의 항목이 여럿 있었습니다.

그럼 어디에 쓸까​

저자는 포기하지 않고 적용 범위를 좁히자고 제안합니다.

고정 폭 by-value 상태로 한정합니다. count, sum(integer), sum(float)이 여기 들어갑니다. 이런 상태는 원자적 병합 연산 하나로 처리할 수 있으니 lock 없이 갱신할 수 있습니다. 위 세 장애물 중 첫째와 둘째가 상당히 완화됩니다.

그리고 MCV 통계로 skew를 미리 감지해 병리적인 경우를 피합니다. planner가 pg_stats의 most common values를 보고 "이 그룹 키는 한 값이 지배적이다"라고 판단하면 공유 방식을 고르지 않는 식입니다. 통계가 이미 있는 정보라서 추가 비용이 낮습니다.

더 유망한 쪽으로 두 곳을 꼽았습니다. SetOp 연산자는 현재 병렬화가 아예 없습니다. 그리고 단순 SELECT DISTINCT입니다. 둘 다 by-reference 상태의 복잡성이 걸리지 않는 영역입니다.

남는 생각​

이 글의 값은 4.8배라는 숫자가 아니라 그 숫자가 왜 못 남는지에 있습니다. 균등 분포 벤치마크만 보면 도입할 이유가 충분해 보이는데, skew 하나로 25배 역행이 나옵니다. 벤치마크 조건을 고르는 일이 결과 자체보다 중요하다는 사례입니다.

제 측정에서 planner가 500만 그룹에 병렬을 아예 안 붙인 것도 같은 맥락으로 읽힙니다. 비용 모형이 이미 이 지점을 알고 회피하고 있습니다. 새 실행 방식을 넣는다면 planner가 그것을 언제 고를지 판단하는 부분이 구현의 절반이 됩니다. 저자가 planner에 600줄을 쓴 이유겠죠.

참고​