본문으로 건너뛰기

"운영" 태그로 연결된 33개 게시물개의 게시물이 있습니다.

모든 태그 보기

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의 의미가 분명해졌어요.

참고 자료​

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

참고​

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가 나올 때 테스트에 며칠을 쓸 계획인지, 그리고 그 며칠이 이번 공지의 논리에 비추어 감당할 만한 구간인지입니다.

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

참고​

cgroup v2 입문

· 약 6분

docker run --memory=512m을 실행할 때, Kubernetes 매니페스트에 resources.limits.memory를 적을 때, systemd 유닛에 MemoryMax=를 넣을 때. 이 셋이 결국 같은 커널 기능 하나로 내려간다는 걸 알고 계셨나요. 컨테이너 시대의 자원 통제는 전부 cgroup 위에 서 있어요.

systemd 유닛 11종을 돌아본 글에서 cgroup을 여러 번 스쳐 지나갔는데, 정작 cgroup 자체를 따로 다룬 적은 없었습니다. 이번에 채웁니다.

cgroup은 무엇인가​

cgroup(control group)은 프로세스들을 무리(group)로 묶고, 그 무리 단위로 자원을 통제하고 계량하는 커널 기능입니다. 하는 일은 세 가지로 요약됩니다.

기능내용예
제한 (limit)무리가 쓸 수 있는 자원의 상한메모리 64MB까지, CPU 절반까지
계량 (accounting)무리가 지금까지 쓴 자원 측정누적 CPU 시간, 현재 메모리 사용량
통제 (control)무리 전체를 한 번에 조작일시 정지(freeze), 일괄 종료

핵심은 대상이 프로세스 하나가 아니라 무리라는 점입니다. ulimit 같은 전통 도구는 프로세스 단위라서, 프로세스가 fork하면 통제를 벗어납니다. cgroup은 자식 프로세스가 부모의 cgroup을 물려받으므로 fork로 도망칠 수 없습니다. 컨테이너가 "안에서 뭘 몇 개 띄우든 전체 512MB"라는 약속을 지킬 수 있는 이유입니다.

구현 형태는 파일시스템입니다. /sys/fs/cgroup 아래 디렉터리 하나가 cgroup 하나이고, 디렉터리를 만들면 cgroup이 생기고, 그 안의 파일에 값을 쓰면 제한이 걸립니다. 시스템 콜 API가 아니라 mkdir와 echo로 조작하는 커널 기능이라는 점이 cgroup의 성격을 잘 보여줍니다.

계보를 짧게 짚으면, 2006년 Google 엔지니어들이 "process containers"라는 이름으로 시작해 2008년 커널 2.6.24에 cgroup이라는 이름으로 머지됐습니다. 이후 LXC와 Docker가 이 위에 컨테이너를 지었고, systemd는 서비스 관리의 뼈대로 삼았습니다.

v1의 혼돈, v2의 단일 트리​

cgroup을 검색하면 v1과 v2 이야기가 반드시 나옵니다. 이 구분을 모르면 검색 결과의 절반이 내 시스템과 안 맞는 이유를 알 수 없습니다.

v1의 설계는 유연함이 지나쳤습니다. 컨트롤러(memory, cpu, blkio 등)마다 각자 독립된 트리를 가질 수 있었습니다. 한 프로세스가 memory 트리에서는 A 그룹, cpu 트리에서는 B 그룹에 속하는 것이 가능했다는 뜻입니다. 표현력은 높지만 대가가 컸습니다. 트리끼리 조합이 어긋나면 동작을 예측하기 어렵고, 컨트롤러마다 인터페이스 관례가 제각각이었으며, 권한 위임 규칙도 정리되어 있지 않았습니다.

v2는 2016년 커널 4.5에서 이 혼돈을 정리했습니다. 원칙은 하나입니다. 트리는 하나, 모든 컨트롤러는 그 트리 위에서 동작한다(unified hierarchy).

인터페이스도 통일됐습니다. 어느 cgroup 디렉터리든 같은 규칙의 파일들이 있습니다.

  • cgroup.procs: 이 무리에 속한 PID 목록. 여기에 PID를 쓰면 편입
  • cgroup.controllers: 이 지점에서 쓸 수 있는 컨트롤러
  • memory.max, cpu.max, io.max, pids.max: 컨트롤러별 상한
  • memory.events: OOM 발생 횟수 같은 사건 카운터

v2에만 있는 물건도 생겼습니다. PSI(Pressure Stall Information)입니다. memory.pressure, cpu.pressure, io.pressure 파일이 "이 무리의 프로세스들이 자원을 못 받아 지연된 시간의 비율"을 보여줍니다. 사용량이 아니라 부족으로 인한 고통을 직접 재는 지표라서, 사용률 그래프보다 정직할 때가 많습니다.

전환은 배포판 기본값으로 이미 끝난 이야기입니다. Fedora 31(2019)을 시작으로 Ubuntu 21.10, Debian 11, RHEL 9가 v2를 기본으로 켰고, Docker는 20.10부터, Kubernetes는 1.25에서 v2 지원을 GA로 올렸습니다. 지금 새로 만나는 시스템은 거의 v2라고 봐도 됩니다. 내 시스템 확인은 한 줄입니다.

stat -fc %T /sys/fs/cgroup/
# cgroup2fs 면 v2, tmpfs 면 v1

실험: 64MB 국경에 100MB를 밀어 넣으면​

개념은 여기까지 하고, 직접 확인해 보겠습니다. macOS라서 Docker 컨테이너(Linux VM) 안에서 실험했습니다. 컨테이너 자체가 cgroup으로 만들어지니 오히려 좋은 교보재입니다.

먼저 --memory=64m이 정말 cgroup 파일로 내려가는지 확인합니다.

$ docker run --rm --memory=64m alpine cat /sys/fs/cgroup/memory.max
67108864

67108864 바이트, 정확히 64MiB입니다. Docker 플래그는 커널에 도달하면 memory.max 파일의 숫자 하나가 됩니다. 이제 이 국경 안에서 100MB를 할당해 봅니다.

$ docker run --rm --memory=64m alpine sh -c 'head -c 100m /dev/zero | tail'
Killed
# 종료 코드 137 (128 + SIGKILL 9)

tail은 입력을 메모리에 쌓는 성질이 있어 간단한 메모리 포식자로 쓸 수 있습니다. 64MiB 상한을 넘는 순간 커널 OOM killer가 그 cgroup 안에서 희생자를 골라 SIGKILL을 보냅니다. 시그널 글에서 다룬 137이라는 종료 코드가 여기서 나옵니다.

죽인 흔적은 사건 카운터에 남습니다. 같은 컨테이너에서 OOM을 겪은 직후 memory.events를 읽으면 이렇습니다.

$ cat /sys/fs/cgroup/memory.events
low 0
high 0
max 72
oom 3
oom_kill 1
oom_group_kill 0

$ cat /sys/fs/cgroup/memory.peak
67108864

읽는 법이 흥미롭습니다. max 72는 사용량이 상한선에 부딪힌 횟수입니다. 부딪힐 때마다 커널은 먼저 page 회수(reclaim)로 버텨 봅니다. 그래도 안 되면 oom(3회)으로 넘어가고, 최종적으로 프로세스 하나를 죽였습니다(oom_kill 1). memory.peak가 정확히 67108864, 즉 상한과 같다는 것은 국경까지 꽉 채우고 넘지 못했다는 증거입니다. 상한 초과가 아니라 상한 도달 후 처형이라는 순서가 파일에 그대로 남습니다.

cgroup 메모리 상한 초과 처리 흐름

컨테이너 없이 맨 리눅스에서 같은 실험을 하려면 systemd가 지름길입니다.

# 임시 scope 유닛으로 감싸서 실행, 상한 64M
systemd-run --scope -p MemoryMax=64M some-command

# 서비스라면 유닛 파일에
[Service]
MemoryMax=64M

systemd 유닛은 각자 cgroup 하나씩을 차지하므로, MemoryMax=는 결국 그 cgroup의 memory.max에 값을 쓰는 것입니다. 트리 전체는 systemd-cgls로, 무리별 실시간 사용량은 systemd-cgtop으로 봅니다.

운영자가 기억할 세 가지​

첫째, 모든 상위 도구의 제한은 cgroup 파일로 환원됩니다. Docker 플래그든, Kubernetes limits든, systemd 지시자든, 디버깅의 종착지는 /sys/fs/cgroup 아래 파일입니다. 제한이 이상하게 동작하면 도구의 문서보다 그 파일의 실제 값을 먼저 확인하는 쪽이 빠릅니다.

둘째, cgroup의 메모리 계정에는 page cache가 포함됩니다. memory.max는 프로세스의 anonymous 메모리만 세는 것이 아니라, 그 무리가 일으킨 파일 캐시까지 셉니다. 파일을 많이 읽는 워크로드(백업, 대량 스캔, 그리고 데이터베이스)가 "실제 사용량은 얼마 안 되는데" OOM에 몰리는 전형적인 이유입니다. 캐시는 압박이 오면 회수되므로 보통은 문제가 없지만, 회수 속도가 할당 속도를 못 따라가는 순간이 있습니다. 컨테이너 위 PostgreSQL의 메모리 제한 설정은 이 지점이 본론이라, 각도를 잡아 후속 글에서 따로 다루겠습니다.

셋째, v2의 사건 카운터와 PSI를 감시 지표로 쓸 수 있습니다. memory.events의 max와 oom_kill, 그리고 memory.pressure는 "제한에 얼마나 자주 부딪히고 있는가"를 알려주는 조기 경보입니다. OOM으로 죽고 나서 아는 것과, max 카운터가 오르는 걸 보고 미리 아는 것의 차이입니다.

여담 하나로 마무리할게요. 이 글을 쓰게 된 계기는 Claude Code가 Bash 명령에 cgroup 메모리 제한을 거는 옵션을 넣었다는 소식이었어요. AI 에이전트가 실행하는 명령의 폭주를 막는 안전장치로 2008년의 커널 기능이 다시 불려 나오는 것, cgroup이 여전히 현역 국경 수비대라는 좋은 증거 아닐까요.

참고 자료​

PostgreSQL 18.6 보안

· 약 5분

이번 분기 마이너 릴리스는 조용히 지나갈 수 없는 규모예요. 8월 13일 공지로 PostgreSQL 18.6, 17.11, 16.15, 15.19, 14.24가 한꺼번에 나왔는데, 보안 취약점 수정이 28건입니다. 통상 마이너 릴리스의 보안 수정이 손에 꼽는 수준인 걸 생각하면 이례적인 숫자입니다.

버전 번호부터 눈에 걸립니다. 18 계열의 직전 버전은 18.4인데 이번이 18.6입니다. 18.5는 릴리스 준비 중 회귀(regression)가 발견되어 배포 없이 결번 처리됐습니다. 18.4에서 바로 18.6으로 올라가면 되고, 중간에 놓친 버전은 없습니다.

CVSS 8점대만 12건​

28건 전체 중 CVSS 8.0 이상이 12건입니다. 성격별로 묶으면 그림이 보입니다.

계열대표 CVECVSS내용
클라이언트 도구CVE-2026-64648.1psql COPY FROM STDIN, 데이터 줄을 psql 명령으로 처리
클라이언트 도구CVE-2026-184088.8psql \unrestrict, 조작된 덤프 원본에서 임의 코드 실행
클라이언트 도구CVE-2026-193858.8pg_dump heap buffer overflow
heap overflowCVE-2026-146648.8regexp 처리, 임의 코드 실행 가능
heap overflowCVE-2026-146698.8to_char()
heap overflowCVE-2026-146768.8pg_stat_statements
type confusionCVE-2026-146718.8refint plan cache
type confusionCVE-2026-162388.8pg_restore_attribute_stats()
type confusionCVE-2026-162398.8cursor CLOSE + DECLARE
type confusionCVE-2026-146808.8"internal" 인자 처리
기타CVE-2026-146628.8tsvector/tsquery integer wraparound
기타CVE-2026-157428.8fuzzystrmatch, 임의 주소 기록

이 중 DBA가 특히 무겁게 볼 것은 psql 계열입니다. 나머지는 대체로 "DB에 로그인한 공격자가 권한을 넘어서는" 유형이라 접속 통제가 1차 방어선이 되지만, psql 취약점은 방향이 반대입니다. 신뢰할 수 없는 덤프나 SQL 파일을 psql로 읽어들이는 쪽이 피해자가 됩니다.

CVE-2026-6464는 COPY FROM STDIN 처리 중 초기 실패가 발생하면 뒤따르는 데이터 줄을 psql 명령으로 해석하는 문제입니다. 누군가 건네준 덤프 파일을 복원하는 일상 작업이 곧 공격 경로입니다. CVE-2026-18408도 같은 결로, pg_dump 출력에 포함되는 \unrestrict 처리를 악용하면 덤프를 만든 쪽(superuser 권한으로 조작된 서버)이 복원하는 쪽 클라이언트에서 임의 코드를 실행할 수 있습니다. 외부에서 받은 덤프를 복원할 일이 있는 조직이라면 클라이언트 패키지 업데이트를 서버만큼 서둘러야 합니다.

일반 서버 취약점과 psql 취약점의 공격 경로 비교

낮은 점수 중에도 운영에 직접 닿는 항목이 있습니다. CVE-2026-14663은 pgcrypto에서 비활성화된 cipher로 암복호화를 요청하면 조용히 평문으로 처리하던 문제입니다. 점수는 6.5지만 "암호화됐다고 믿었는데 평문이었다"는 유형이라 감사 관점에서는 확인해 볼 가치가 있습니다. CVE-2026-14672는 SCRAM 인증에서 scram_iterations 응답 차이로 계정 존재 여부가 노출되는 문제입니다.

업데이트만으로 끝나지 않는 세 가지​

이번 릴리스의 함정은 바이너리 교체 후에도 남는 숙제입니다. 릴리스 노트가 세 가지 후속 조치를 명시합니다.

첫째, parallel GIN index build를 쓴 적이 있다면 reltuples 오염을 확인합니다. PostgreSQL 14, 15, 16, 18에서 parallel worker가 초기화되지 않은 row count를 보고해 pg_class.reltuples가 Infinity나 NaN이 되는 버그가 있었습니다. 이 상태가 되면 autovacuum과 autoanalyze가 해당 테이블을 건너뛰고, 자연적으로는 복구되지 않습니다. GIN 인덱스를 가진 테이블을 이 쿼리로 확인합니다.

SELECT DISTINCT t.oid::regclass, t.reltuples
FROM pg_class t
JOIN pg_index i ON t.oid = i.indrelid
JOIN pg_class ic ON i.indexrelid = ic.oid
WHERE t.relhasindex AND ic.relam = 2742; -- 2742 = gin

reltuples가 Infinity, NaN, 혹은 말이 안 되는 값이면 해당 테이블에 ANALYZE를 실행합니다. vacuum이 안 도는 테이블은 wraparound 위험까지 이어지니, GIN을 쓰는 클러스터라면 이 확인을 빼먹지 않는 게 좋습니다.

둘째, btree_gist 인덱스의 REINDEX입니다. float4/float8 컬럼에서 NaN 처리, bit/bit varying 컬럼에서 정렬이 잘못되던 문제가 고쳐졌습니다. 해당 타입 컬럼에 btree_gist 인덱스가 있다면 REINDEX가 필요합니다.

셋째, 큰 ltree 값의 인덱스도 REINDEX 대상입니다. 약 14,653개 이상의 label을 가진 ltree 값에서 비교 연산이 틀려 B-tree 인덱스가 손상될 수 있었습니다. 그 정도 깊이의 ltree를 쓰는 곳은 드물겠지만, 해당된다면 REINDEX 대상입니다.

이 밖에 버그 수정 중에는 standby가 구버전 마이너의 WAL을 재생하다 멈추는 deadlock 수정(14~16), DEFAULT partition이 pruning에서 잘못 제외되던 문제, REINDEX CONCURRENTLY와 deferred unique constraint 조합 오류 같은 굵직한 것들이 포함됐습니다. 전체 목록은 릴리스 노트에 있습니다.

19 Beta 3와 14 EOL​

같은 날 PostgreSQL 19 Beta 3도 나왔습니다. 눈에 띄는 변화는 GROUP BY ALL 문법이 revert 된 것입니다. Beta 1에 들어왔던 편의 문법인데 최종 릴리스 전에 빠졌습니다. 그 외 FOR PORTION OF(temporal 문법) 수정 다수, logical replication의 sequence 동기화 race condition 수정 등이 반영됐습니다. 19 신기능을 검증 중이라면 Beta 3 기준으로 다시 확인하는 게 안전합니다.

그리고 PostgreSQL 14의 지원 종료가 2026년 11월 12일로 예고됐습니다. 이번 14.24를 포함해 앞으로 한 번의 릴리스만 남았습니다. 아직 14로 운영 중인 클러스터는 마이그레이션 일정을 지금 세워야 합니다.

지금 해야 할 일​

우선순위로 정리하면 이렇습니다.

  1. 서버 바이너리 업데이트 (마이너 업데이트라 dump/reload 불필요, 재시작만)
  2. 클라이언트 패키지(psql, pg_dump)도 같이 업데이트 - 이번 릴리스에서는 클라이언트가 서버만큼 급합니다
  3. GIN 인덱스 테이블의 reltuples 확인, 이상 시 ANALYZE
  4. btree_gist(float/bit 컬럼), 깊은 ltree 인덱스 REINDEX
  5. PostgreSQL 14 사용 중이면 업그레이드 계획 수립

28건이라는 숫자에 놀랐지만, 뒤집어 보면 fuzzing과 코드 감사가 코어에 촘촘히 들어가고 있다는 뜻이기도 합니다. 저는 클라이언트 도구가 공격면이 되는 흐름이 이번 릴리스의 진짜 교훈이라고 봐요. 서버는 잘 잠가 두는데 psql은 아무 파일이나 여는 습관, 이번 기회에 같이 고치면 좋겠습니다.

참고 자료​

subtransaction 64개 한계

· 약 5분

SAVEPOINT는 안전장치처럼 생겼어요. 트랜잭션 중간에 표시를 남기고, 일부만 되돌릴 수 있게 해 주는 기능이니까요. 그런데 이 안전장치를 한 트랜잭션에서 64번 넘게 쓰는 순간, 그 트랜잭션이 아니라 클러스터 전체가 절벽에서 떨어집니다.

PlanetScale이 8월 11일에 공개한 분석이 이 경로를 측정으로 보여줬습니다. 동일한 INSERT/SELECT/DELETE 워크로드가 평소 약 7,200 TPS(지연 약 2ms)로 돌다가, 어느 한 트랜잭션이 subtransaction 64개를 넘긴 순간 약 160 TPS(지연 약 90ms)로 떨어졌습니다. 45배 하락입니다. 문제의 트랜잭션이 끝나자 처리량은 원래대로 돌아왔습니다.

이 글은 해당 분석을 따라가면서, 왜 남의 트랜잭션 하나가 내 쿼리를 느리게 만드는지 경로를 짚고, 마지막에 원문에는 없는 우리 클러스터 점검용 진단 쿼리를 붙입니다.

subtransaction은 어디서 생기나​

subtransaction은 최상위 트랜잭션 안에 중첩된 트랜잭션입니다. 각 subtransaction은 자기만의 transaction ID(subxid)를 받습니다. 만드는 경로는 두 가지입니다.

명시적 경로는 SAVEPOINT입니다.

BEGIN;
SAVEPOINT s1; -- subtransaction 1
INSERT INTO orders ...;
SAVEPOINT s2; -- subtransaction 2
UPDATE stock ...;
COMMIT;

암묵적 경로가 더 위험합니다. PL/pgSQL에서 EXCEPTION 절이 있는 블록은 진입할 때마다 subtransaction을 하나 만듭니다. "혹시 실패하면 이 블록만 되돌린다"를 구현하는 방법이 내부적으로 SAVEPOINT와 같기 때문입니다.

-- 이 루프는 반복마다 subtransaction을 하나씩 만든다
FOR i IN 1..70 LOOP
BEGIN
INSERT INTO audit_log VALUES (...);
EXCEPTION WHEN unique_violation THEN
NULL; -- 무시하고 계속
END;
END LOOP;
-- 70개 생성, 64개 한계 초과

ORM도 조용히 만듭니다. Django의 transaction.atomic()을 중첩하면 안쪽은 SAVEPOINT가 되고, JPA/Hibernate에서 REQUIRES_NEW가 아닌 중첩 트랜잭션 전파, Rails의 중첩 transaction 블록(requires_new: true)도 같습니다. 애플리케이션 코드에는 SAVEPOINT라는 글자가 한 번도 안 나오는데 DB에는 수십 개씩 쌓이는 상황이 흔합니다.

64개 한계와 pg_subtrans​

각 backend의 PGPROC 구조체에는 subtransaction ID를 담는 캐시가 있고, 크기가 PGPROC_MAX_CACHED_SUBXIDS, 즉 64개입니다. 이 안에 다 들어가는 동안에는 다른 세션이 MVCC visibility를 판단할 때 공유 메모리만 보면 됩니다. 빠릅니다.

65개째부터 이 캐시는 overflow 상태로 표시됩니다. 이제 다른 세션들은 어떤 subxid가 어느 최상위 트랜잭션에 속하는지 공유 메모리에서 알 수 없고, 디스크 기반 구조인 pg_subtrans를 조회해야 합니다. pg_subtrans는 SLRU(simple least-recently-used) 캐시를 통해 접근하는데, 이 조회는 lightweight lock을 잡고 필요하면 디스크 I/O까지 발생시킵니다.

핵심은 비용을 치르는 쪽이 subtransaction을 만든 트랜잭션이 아니라는 점입니다. overflow가 켜져 있는 동안 다른 모든 세션이 자기 snapshot으로 튜플 가시성을 판단할 때마다(XidInMVCCSnapshot) pg_subtrans SLRU lock을 두고 경합합니다. PlanetScale 측정에서 병목으로 지목된 지점이 정확히 이 lock입니다.

overflow 상태는 문제의 트랜잭션이 끝날 때까지 유지됩니다. 그리고 얼마나 오래 가는지는 그 트랜잭션이 아니라, 클러스터에서 가장 오래 돌고 있는 트랜잭션의 수명에 좌우됩니다. long-running transaction이 있으면 그만큼 고통이 길어집니다.

hot standby까지 번지는 이유​

이 문제의 두 번째 얼굴이 더 고약합니다. overflow가 발생한 시점에 WAL로 기록되는 RUNNING_XACTS 레코드에 "subxid overflowed" 표시가 함께 남습니다. 활성 subtransaction 목록을 다 담지 못했다는 뜻입니다.

새로 배포한 standby가 이 오염된 RUNNING_XACTS를 읽으면, 일관된 snapshot을 만들 수 없다고 판단하고 hot standby 활성화를 보류합니다. WAL은 계속 재생하는데 읽기 쿼리는 받지 않는 상태, 접속하면 이런 메시지만 돌아옵니다.

FATAL: the database system is not yet accepting connections
DETAIL: Recovery snapshot is not yet ready for hot standby.

pg_subtrans를 primary에서 넘겨받으면 되지 않느냐는 생각이 들지만, pg_subtrans 변경은 WAL에 기록되지 않고, standby는 기동 시 활성 페이지를 0으로 초기화하기 때문에 로컬 데이터도 신뢰할 수 없습니다. standby가 이 상태를 벗어나는 조건은 overflow 되지 않은 새 RUNNING_XACTS 레코드가 도착하거나, primary의 shutdown checkpoint를 재생하거나, 누락 가능성이 있는 트랜잭션들이 전부 끝나는 것입니다.

운영 관점에서 시나리오를 조합하면 이렇게 됩니다. 부하가 튀어서 읽기 용량을 늘리려고 replica를 새로 붙였는데, 하필 그 부하를 만들던 워크로드가 subtransaction overflow를 일으키는 중이라면, 새 replica는 WAL만 재생하며 접속을 거부합니다. 용량을 더하려고 만든 노드가 용량이 되어 주지 않는 역설입니다.

Primary의 subtransaction overflow가 새 standby의 recovery snapshot 준비를 막는 흐름

지금 확인할 것들​

여기부터는 원문 내용에 우리가 운영에서 바로 실행할 쿼리를 더해 정리합니다.

먼저 지금 이 순간 subtransaction을 쌓고 있는 backend가 있는지 확인합니다. PostgreSQL 16부터 pg_stat_get_backend_subxact()로 backend별 subxact 개수와 overflow 여부를 볼 수 있습니다.

SELECT pg_stat_get_backend_pid(id) AS pid, s.subxact_count, s.subxact_overflowed
FROM pg_stat_get_backend_idset() AS id
JOIN LATERAL pg_stat_get_backend_subxact(id) AS s ON true
WHERE s.subxact_count > 0
ORDER BY s.subxact_count DESC;

subxact_overflowed = true인 행이 하나라도 보이면 그 순간 클러스터 전체가 pg_subtrans 조회 경로로 동작 중이라는 뜻입니다. pg_stat_activity와 조인하면 어떤 애플리케이션, 어떤 쿼리인지까지 특정됩니다.

과거에 겪었는지 의심된다면 SLRU 통계를 봅니다.

SELECT name, blks_hit, blks_read, blks_written, stats_reset
FROM pg_stat_slru
WHERE name = 'subtransaction';

평상시 이 카운터는 거의 움직이지 않습니다. blks_hit가 급증한 구간이 있었다면 overflow가 있었다는 강한 정황입니다. 그래프 도구에 이 두 컬럼을 올려 두면 사후 추적이 쉬워집니다.

가장 오래된 트랜잭션 나이도 함께 감시합니다. overflow의 지속 시간을 결정하는 값입니다.

SELECT max(now() - xact_start) AS oldest_tx
FROM pg_stat_activity
WHERE xact_start IS NOT NULL;

예방책은 소박합니다.

대상조치
PL/pgSQL 루프 안 EXCEPTION 블록루프 밖으로 빼거나, 예외를 안 쓰는 로직(ON CONFLICT 등)으로 전환
ORM 중첩 트랜잭션중첩 atomic()/transaction 블록이 정말 필요한지 검토
long-running transactiontransaction_timeout(17+), idle_in_transaction_session_timeout 설정
상시 감시pg_stat_get_backend_subxact() + pg_stat_slru 지표화

코어 쪽 근본 해법으로는 PGPROC_MAX_CACHED_SUBXIDS를 늘려 재컴파일하는 단기 우회(공유 메모리 비용 증가)와, snapshot을 CSN(Commit Sequence Number) 기반으로 바꾸는 장기 방향이 거론됩니다. CSN 논의는 10년 넘게 이어지고 있지만 아직 본류에 머지되지 않았습니다.

64라는 숫자를 기억해 두면 좋겠어요. SAVEPOINT와 EXCEPTION 블록은 공짜가 아니고, 비용 청구서는 옆 세션으로 날아갑니다. 저는 이번 주에 저희 클러스터에도 위 진단 쿼리 두 개를 모니터링에 추가해 두려고 합니다.

참고 자료​

cascading 복제 버그

· 약 4분

"requested starting point ... is ahead of the WAL flush position of this server". 이 오류를 cascading standby에서 본 적이 있다면, 그건 설정 실수가 아니었을 수도 있어요. 2013년 PostgreSQL 9.3부터 잠복해 있던 버그였습니다.

CloudNativePG 창립자 Gabriele Bartolini(가브리엘레 바르톨리니)가 8월 3일 EDB 블로그에 공개한 글이 이 버그의 발견부터 수정까지를 담고 있습니다. 발견 경위가 흥미롭습니다. 코어 해커의 코드 감사가 아니라, Kubernetes 위에서 분산 토폴로지를 선언적으로 돌리다가 드러났습니다.

증상: 재시도 없는 무한 대기​

구성은 cascading replication입니다. primary에서 standby A가 받고, standby B는 A에게서 받는 구조로, 다중 리전 토폴로지에서 흔한 형태입니다.

standby B가 어떤 이유로든 streaming이 끊겨 archive recovery로 폴백했다가, WAL을 따라잡고 다시 upstream(A)으로 streaming 접속을 시도하는 순간이 문제의 무대입니다. 특정 조건에서 A가 접속을 이렇게 거부합니다.

FATAL: requested starting point 0/A000000 is ahead of
the WAL flush position of this server 0/9000000

그리고 B는 재시도 메커니즘 없이 이 상태에 머뭅니다. 사람이 개입할 때까지 replication이 서 있는 것입니다.

메커니즘: segment 단위 recovery와 record 단위 flush의 어긋남​

원인은 StartReplication()에 2013년 추가된 timeline switch 로직입니다. 이 로직은 "요청한 시작 LSN이 내 WAL flush 위치보다 앞서면 거부한다"는 방어 검사를 합니다. 검사 자체는 합리적입니다. 아직 나에게 없는 WAL을 달라는 요청이니까요.

문제는 archive recovery의 진행 단위입니다. streaming은 record 단위로 흐르지만, archive recovery는 segment 파일 단위로 처리합니다. B가 archive에서 segment 하나를 다 소화하면, 다음 읽기 위치는 그다음 segment의 시작(예: 0/A000000)이 됩니다. 그런데 upstream A의 flush 위치는 record 단위로 진행 중이라 그 경계에 못 미쳐 있을 수 있습니다(예: 0/9000000 근처). B의 요청 위치가 A의 flush 위치를 기계적으로 앞서게 되는 것입니다.

타이밍이 정확히 맞아야 하는 race라서 13년 동안 드물게, 재현 불가능하게만 나타났습니다. 마주친 운영자는 대개 standby를 재기동하거나 재생성했을 것이고, 문제는 "가끔 이상해지는 replication"이라는 민담으로만 남았을 겁니다.

수정: 접속 전에 물어보고, 가까우면 기다린다​

수정은 walreceiver 쪽에 들어갔습니다. START_REPLICATION을 보내기 전에 IDENTIFY_SYSTEM으로 upstream의 flush 위치를 먼저 확인하고, 내 요청 위치와의 차이가 WAL segment 하나 이내면 거부당할 요청을 던지는 대신 재시도합니다(wal_receiver_timeout 한도 안에서). upstream이 곧 그 지점까지 flush할 것이 확실한 상황이니, 잠깐 기다리면 자연히 풀리는 것입니다.

이 수정은 8월 13일 나온 마이너 릴리스에 포함됐습니다. EDB 글은 작성 시점 기준 18.5를 예고했지만, 18.5가 회귀로 결번되면서 실제로는 18.6, 17.11, 16.15, 15.19, 14.24에 실렸습니다. cascading 구성을 운영 중이라면 이번 마이너 업데이트를 챙길 이유가 하나 더 있는 셈입니다.

13년 만에 잡힌 이유​

이 이야기에서 버그 자체보다 오래 남는 것은 발견의 조건입니다.

CloudNativePG는 primary, cascading standby, archive(오브젝트 스토리지), 리전 간 복제를 하나의 선언으로 묶어 돌립니다. 같은 토폴로지가 수천 클러스터에서 반복 생성되고 파괴되니, 확률이 낮은 race도 통계적으로 반드시 걸립니다. 게다가 구성이 코드로 고정되어 있어 "그때 그 상황"을 그대로 다시 만들 수 있습니다. 실제로 이 버그는 Docker/Kind 기반 학습 환경인 cnpg-playground에서도 재현됩니다.

수동 운영의 세계에서 이 버그는 재현 불가능한 유령이었습니다. 선언적 운영의 세계에서는 재현 가능한 테스트 케이스가 됐습니다. "Kubernetes에서 데이터베이스를 돌려도 되는가"라는 오래된 논쟁에 대한 답변으로, 오퍼레이터 진영이 내놓을 수 있는 가장 실속 있는 증거가 아닐까 싶습니다. 돌려도 되는가를 넘어, 돌렸더니 코어의 13년 묵은 버그가 잡혔다는 것이니까요.

pgBackRest 종료 이후 CNPG 쪽 흐름을 계속 지켜보고 있는데, 이 사건은 그 생태계의 성숙을 보여주는 좋은 지표로 기억해 둘 만해요.

참고 자료​