본문으로 건너뛰기

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

모든 태그 보기

pg_statistic 통계 구조

· 약 10분

PostgreSQL planner는 테이블의 실제 데이터를 들여다보지 않아요. 100만 건짜리 테이블에 조건을 걸어도, planner는 그 100만 건을 세어 보는 대신 pg_statistic에 저장된 통계 요약만 읽고 "이 조건이면 대략 몇 row가 나오겠다"고 추정해요. 이 추정값이 맞으면 좋은 계획이 나오고, 틀어지면 seq scan을 해야 할 자리에 index scan을 고르거나 nested loop가 터지는 계획이 나와요. 그래서 쿼리가 느릴 때 인덱스보다 먼저 의심해야 할 게 통계예요.

이 글은 그 통계가 어디서 와서 어디에 저장되고 planner가 어떻게 읽는지를 따라갑니다. 시드 출처(richyen.com)와 PostgreSQL 공식 문서를 교차 확인하며 한국어 실무 관점으로 다시 엮었습니다.

왜 통계가 실행계획을 좌우하나

planner의 일은 같은 쿼리를 실행하는 여러 방법(seq scan vs index scan, nested loop vs hash join, 조인 순서) 중 비용이 가장 싼 것을 고르는 것입니다. 비용을 계산하려면 각 단계에서 몇 row가 흘러갈지를 알아야 합니다. 그런데 쿼리를 실제로 돌려 보고 정할 수는 없으니, 미리 떠둔 통계로 추정합니다.

추정이 틀어지는 전형적인 예는 이렇습니다. 어떤 조건이 실제로는 4000 row를 반환하는데 planner가 8 row로 추정했다면, planner는 "8 row니까 index scan으로 한 건씩 찾아오는 게 싸겠다"고 판단합니다. 막상 실행하면 4000번을 random access로 긁느라 seq scan보다 훨씬 느려집니다. 계획 자체는 통계가 맞다는 가정 아래 합리적이었습니다. 통계가 틀렸습니다.

그래서 row 추정(row estimation)이 쿼리 성능의 출발점입니다. 시드 글의 표현을 빌리면 "planner는 쥐여 준 통계만큼만 똑똑하다".

ANALYZE가 통계를 만드는 과정

통계를 채우는 명령은 ANALYZE입니다. autovacuum이 백그라운드에서 알아서 돌려 주지만, 대량 적재나 마이그레이션 직후에는 통계가 옛날 값이라 직접 실행해 주는 게 안전합니다.

ANALYZE customers; -- 테이블 전체 컬럼 통계 갱신
ANALYZE customers (state); -- 특정 컬럼만

여기서 주목할 점은 ANALYZE가 테이블 전체를 읽지 않는다는 사실입니다. 큰 테이블을 매번 통째로 스캔하면 비용이 감당되지 않으니, 무작위 샘플을 떠서 그걸로 분포를 추정합니다.

샘플 크기는 통계 타깃(statistics target)에 비례합니다. default_statistics_target이 기본값 100일 때 ANALYZE는 약 30,000개의 row를 샘플로 뽑습니다(테이블이 작으면 그보다 적게 뽑습니다). 무작위성을 보장하기 위해 Vitter의 reservoir sampling 알고리즘을 씁니다. 샘플을 다 모으면 컬럼별로 분포를 계산해 pg_statistic에 저장합니다.

이때 테이블 전체 규모를 나타내는 reltuples(행 수)와 relpages(블록 수)는 pg_class에 따로 저장됩니다. 이 값들은 실시간으로 갱신되지 않고 VACUUM, ANALYZE, 일부 DDL 시점에만 갱신되며, 전체를 스캔하지 않은 경우 스캔한 부분으로부터 근사값을 추정해 갱신합니다.

pg_statistic은 슬롯 구조라 사람이 직접 읽기 까다롭습니다. 그래서 PostgreSQL은 같은 내용을 사람이 읽기 좋게 펼친 pg_stats 뷰를 제공하고, 일반 사용자도 자기 권한 안에서 조회할 수 있습니다. 실무에서는 거의 항상 pg_stats를 봅니다.

아래는 ANALYZE가 통계를 만들어 planner에 닿기까지의 흐름입니다.

pg_stats 주요 컬럼 읽는 법

pg_stats 한 행은 한 컬럼의 통계 요약입니다. 컬럼이 여럿이지만 실무에서 손이 가는 건 다섯 개 정도입니다.

컬럼의미무엇을 말해 주나
null_fracNULL인 행의 비율NULL이 얼마나 흔한가
avg_width값의 평균 바이트 폭행 크기/메모리 추정
n_distinct서로 다른 값의 수(또는 비율)카디널리티
most_common_vals가장 흔한 값 목록(MCV)편향된 값
most_common_freqsMCV 각 값의 빈도그 값의 점유율
histogram_boundsMCV를 뺀 나머지 분포의 경계범위 조건 추정
correlation물리 순서와 논리 순서의 상관index scan 효율

조회는 이렇게 합니다.

SELECT attname, n_distinct, null_frac,
most_common_vals, most_common_freqs,
correlation
FROM pg_stats
WHERE tablename = 'customers' AND attname = 'state';

n_distinct

n_distinct에서 눈여겨볼 것은 음수의 의미입니다 — 서로 다른 값의 추정 개수를 나타내지만 별도의 부호 규칙이 있습니다. 공식 문서 정의는 이렇습니다.

0보다 크면 컬럼의 distinct 값 추정 개수. 0보다 작으면 distinct 값 개수를 행 수로 나눈 값의 음수. (음수 형태는 테이블이 커질수록 distinct 값도 늘어날 것으로 ANALYZE가 판단할 때 쓰이고, 양수 형태는 가능한 값의 수가 고정돼 보일 때 쓰인다.) 예를 들어 -1은 distinct 값 수가 행 수와 같은 unique 컬럼을 뜻한다.

state 컬럼이 50으로 나오면 "값이 50종이고 테이블이 커져도 50종일 것"이라는 뜻이고, 기본키처럼 -1이면 "모든 값이 다 다르다"는 뜻입니다. -0.5라면 "행 두 개당 distinct 값 하나꼴"입니다.

이 값이 실제와 어긋나면 추정이 통째로 흔들립니다. 실제로는 값이 만 종인데 통계가 100종으로 잡혀 있으면 planner는 100분의 1만 걸러질 조건을 만 분의 1로 착각합니다.

most_common_vals와 most_common_freqs

여기서는 편향된 값을 읽습니다 — 분포가 한쪽으로 쏠린 컬럼에서는 흔한 값 몇 개가 통계를 지배합니다. most_common_vals(MCV)는 그 흔한 값들의 목록이고, most_common_freqs는 각 값이 차지하는 비율입니다. 두 배열은 같은 순서로 짝을 이룹니다.

예를 들어 state 컬럼에서 CA가 0.174, TX가 0.116으로 나온다면, planner는 WHERE state = 'CA'에 대해 "전체의 17.4%"라고 정확히 추정할 수 있습니다. 흔한 값은 빈도를 직접 들고 있으니 추측할 필요가 없습니다.

histogram_bounds

histogram_bounds는 나머지 값의 분포를 담습니다 — MCV에 들지 못한 값들이 대상입니다. 공식 문서 정의는 "컬럼 값들을 거의 같은 개수의 그룹으로 나누는 경계값 목록"입니다. 즉 equi-depth histogram이라, 각 구간(bucket)이 데이터의 거의 같은 비율을 담습니다. 기본 타깃 100이면 경계가 약 101개 잡혀 구간마다 전체의 약 1%를 덮습니다.

중요한 성질이 두 가지 있습니다. 첫째, MCV에 들어간 값은 histogram 계산에서 빠집니다. 흔한 값은 MCV가 맡고 나머지는 histogram이 맡습니다. 둘째, 컬럼 타입에 < 연산자가 없거나 MCV가 전체를 다 덮으면 이 값은 NULL입니다.

WHERE signup_date < '2026-03-01' 같은 범위 조건의 추정이 여기서 나옵니다. 경계가 촘촘할수록 범위 추정이 정확해집니다.

correlation

correlation으로는 index scan이 쌀지 가늠합니다 — 물리적 행 순서와 논리적 값 순서가 얼마나 일치하는지를 -1에서 +1로 나타냅니다. 공식 문서는 "값이 -1이나 +1에 가까우면 그 컬럼의 index scan이 0에 가까울 때보다 싸게 추정됩니다. random access가 줄어들기 때문"이라고 설명합니다.

값이 디스크에 정렬된 순서로 쌓여 있으면(예: 시간순 append) correlation이 1에 가깝고, index로 범위를 긁어도 디스크를 거의 순차로 읽습니다. 값이 무작위로 흩어져 있으면 0에 가깝고, index scan은 매번 다른 블록으로 점프해야 해서 비싸집니다. 같은 인덱스라도 이 값에 따라 planner의 선택이 갈립니다.

planner가 통계로 row를 추정하는 예

추정의 뼈대 공식은 하나입니다.

추정 row = reltuples × selectivity

reltuples는 테이블 전체 행 수, selectivity는 조건이 걸러 내는 비율(0~1)입니다. selectivity를 어떻게 구하느냐가 통계가 쓰이는 지점입니다. 공식 문서의 Row Estimation Examples에 나온 tenk1(1만 행) 예시로 봅니다.

MCV에 있는 등치 조건

조건 값이 most_common_vals에 있으면 그 빈도를 그대로 selectivity로 씁니다.

SELECT * FROM tenk1 WHERE stringu1 = 'CRAAAA';

'CRAAAA'most_common_freqs가 0.003이면 selectivity는 0.003, 추정 row는 10000 × 0.003 = 30입니다.

MCV에 없는 등치 조건

조건 값이 MCV 목록에 없으면, MCV가 차지하지 않은 나머지를 남은 distinct 값들이 고르게 나눠 가진다고 가정합니다.

selectivity = (1 - sum(mcv_freqs)) / (n_distinct - num_mcv)
SELECT * FROM tenk1 WHERE stringu1 = 'xxx';

MCV 빈도 합이 0.03333, n_distinct가 676, MCV 개수가 10이면,

selectivity = (1 - 0.03333) / (676 - 10) = 0.0014559
추정 row = 10000 × 0.0014559 ≈ 15

n_distinct가 추정에 직접 들어가는 게 여기서 보입니다. 이 값이 틀리면 비-MCV 등치 조건이 통째로 빗나갑니다.

histogram을 사용하는 범위 조건

<, > 같은 범위 조건은 histogram_bounds로 값이 어느 구간에 떨어지는지를 보고 비율을 보간합니다.

SELECT * FROM tenk1 WHERE unique1 < 1000;

histogram 경계가 {0, 993, 1997, 3050, ...}(10구간)이고 1000이 993~1997 구간 안에 있다면,

selectivity = (1 + (1000 - 993) / (1997 - 993)) / 10 = 0.100697
추정 row = 10000 × 0.100697 ≈ 1007

독립을 가정하는 AND 결합

여러 조건이 AND로 묶이면 planner는 기본적으로 각 조건이 서로 독립이라 보고 selectivity를 곱합니다.

SELECT * FROM tenk1 WHERE unique1 < 1000 AND stringu1 = 'xxx';
selectivity = 0.100697 × 0.0014559 = 0.0001466
추정 row = 10000 × 0.00014661

이 독립 가정이 다음 절의 함정으로 이어집니다. 추정값과 실제값은 EXPLAIN으로 바로 대볼 수 있습니다.

EXPLAIN ANALYZE
SELECT * FROM customers WHERE state = 'CA';
-- Seq Scan ... (rows=1740 ...) (actual ... rows=1736 ...)
-- ↑ 추정 ↑ 실제

추정(rows=)과 실제(actual ... rows=)가 크게 벌어지는 노드가 통계 문제의 출발점입니다.

통계가 틀어질 때의 운영 점검

default_statistics_target

pg_statistic에 담기는 MCV/histogram 배열의 최대 길이는 컬럼별 ALTER TABLE ... SET STATISTICS로, 또는 전역 default_statistics_target으로 정합니다. 공식 문서 기준 기본 한도는 100입니다. 값을 키우면 샘플이 늘고 배열이 길어져 분포가 불규칙한 컬럼에서 추정이 정밀해지지만, pg_statistic 공간과 ANALYZE 시간을 더 씁니다.

-- 특정 컬럼만 정밀하게
ALTER TABLE customers ALTER COLUMN signup_date SET STATISTICS 1000;
ANALYZE customers;

특정 컬럼의 단일 추정이 계속 빗나가면, 전역값을 올리기 전에 그 컬럼만 타깃을 올리는 게 비용 대비 효과가 좋습니다.

조정 대상효과비용
전역 default_statistics_target 상향모든 컬럼 추정 정밀ANALYZE 시간/공간 전반 증가
컬럼 SET STATISTICS 상향해당 컬럼만 정밀그 컬럼만 비용 증가
컬럼별 다중 통계상관된 컬럼 조합 추정ANALYZE 시 추가 계산

다중 컬럼 상관과 extended statistics

AND 결합의 독립 가정은 컬럼들이 실제로 상관돼 있으면 깨집니다. 시드 글의 예가 명확합니다. WHERE city = 'Cheyenne' AND state = 'WY'는 도시가 정해지면 주는 사실상 결정되는데, planner는 둘을 독립으로 보고 곱해 8 row로 추정합니다. 실제는 4012 row로, 약 500배 차이가 납니다.

PostgreSQL 10부터 도입된 extended statistics가 이 문제를 풉니다. CREATE STATISTICS로 관심 컬럼 조합을 등록하면 ANALYZE가 그 조합의 통계를 함께 모읍니다.

CREATE STATISTICS customers_city_state (dependencies, ndistinct)
ON city, state FROM customers;
ANALYZE customers;

이후 같은 쿼리의 추정은 4087 row로, 실제(4012)에 거의 붙습니다. 단, dependencies(함수 종속성)는 컬럼을 상수와 비교하는 단순 등치 조건과 상수 IN 절에만 적용되고, 두 컬럼끼리 비교하거나 범위/LIKE 조건에는 쓰이지 않는다는 한계가 있습니다. 한 가지 더, extended statistics도 일반 단일 컬럼 통계와 같은 샘플로 계산되므로, 통계 타깃을 올리면 extended statistics도 함께 정밀해집니다.

통계가 의심될 때의 점검 순서

추정과 실제가 벌어지는 노드를 찾았다면 순서대로 봅니다.

  1. EXPLAIN ANALYZE로 추정 row와 실제 row의 차이를 짚습니다.
  2. 문제 컬럼의 pg_stats에서 n_distinct와 MCV를 확인합니다.
  3. 통계가 낡았으면 ANALYZE를 먼저 실행합니다.
  4. 단일 컬럼 추정이 계속 빗나가면 그 컬럼의 SET STATISTICS를 올립니다.
  5. 다중 컬럼 상관이 원인이면 extended statistics를 만듭니다.
  6. 그래도 안 되면 그때 쿼리 재작성을 검토합니다.

대량 적재나 마이그레이션 직후, 파티션 추가 직후처럼 데이터가 급변한 시점에는 autovacuum의 ANALYZE를 기다리지 말고 직접 실행해 두는 습관이 추정 사고를 가장 많이 막아 줍니다.

정리

planner는 데이터를 보지 않고 통계를 봐요. ANALYZE가 무작위 샘플로 분포를 떠서 pg_statistic에 채우고, pg_stats가 그걸 읽기 좋게 펼쳐요. 등치 조건은 MCV의 빈도나 n_distinct로, 범위 조건은 histogram_bounds로, index scan 여부는 correlation으로 추정해요. 추정이 빗나가면 인덱스를 의심하기 전에 pg_stats를 먼저 열어 보고, 단일 컬럼이면 통계 타깃을, 상관된 컬럼이면 extended statistics를 손봐요. 이 흐름만 손에 익으면 "왜 이 계획이 나왔지"의 절반은 통계 한 군데에서 답이 나와요.


참고한 출처:

PostgreSQL 코어의 빈칸

· 약 10분

들어가며

PostgreSQL은 매년 약 200개의 기능과 변경을 더하지만, "이건 당연히 되겠지" 싶은 큰 기능 몇 개는 30년째 코어에 비어 있어요. sharding, connection pooling, 내장 암호화(TDE)처럼 상용 DB라면 체크리스트에 들어가는 항목들인데, PostgreSQL을 쓰다 보면 어느 순간 "여기까지 다 되는데 왜 이건 안 되지" 하는 벽을 만나요.

Bruce Momjian(브루스 모미잔)은 2026년 발표 《What's Missing in Postgres?》에서 이 빈칸들을 정면으로 다뤘습니다. 그가 발표 슬라이드를 쓰면서 깨달은 한 가지가 있습니다. 빠진 기능의 대다수는 기능(functionality)이 없어서가 아니라 성능(performance)을 위한 것이라는 점입니다. 즉 PostgreSQL은 "못 하는" 쪽이라기보다, "더 빨리 하기 위한 장치"가 아직 코어에 없는 쪽에 가깝습니다.

이 글은 그 빈칸을 항목별로 짚습니다. 각 항목마다 무엇이 없는지, 왜 코어에 없는지, 지금은 무엇으로 메우는지, 코어 편입 전망은 어떤지 차례로 봅니다. Momjian의 발표 분류를 따라 단일 호스트(single host) 성능 항목과 다중 호스트(multi-host) 항목으로 나눕니다.

한 장으로 보는 빈칸 지도

먼저 전체 그림을 표로 깔아둡니다.

빈칸분류지금 메우는 도구코어 편입 전망
내장 암호화 (TDE)단일 호스트pg_tde, EDB(상용)논의 단계
내장 connection pooler단일 호스트PgBouncer, Pgpool-II, Supavisor패치 제안 반복
optimizer hints단일 호스트pg_hint_planPostgreSQL 19 1차 도입
columnar storage단일 호스트Citus columnar, Hydra 등미정
global index단일 호스트없음(수동 우회)미정
direct I/O단일 호스트(PostgreSQL 18 AIO 기반)진행 중
server-side threading단일 호스트없음(프로세스 모델)장기 과제
64-bit transaction ID단일 호스트Postgres Pro(fork)장기 논의
sharding다중 호스트Citus, Multigres미정
multi-master replication다중 호스트pgEdge/Spock, BDR(상용)미정
Oracle RAC 동급다중 호스트없음사실상 없음
DDL의 logical replication다중 호스트일부 extension부분 진행

표를 위에서 아래로 훑으면 패턴이 보입니다. 빈칸 대부분에 "지금 메우는 도구"가 이미 하나씩 있다는 점입니다. PostgreSQL은 코어를 작게 유지하고 extension/외부 프로세스로 확장하는 철학을 30년간 지켜 왔고, 빈칸은 그 철학의 그림자이기도 합니다.

코어와 빈칸의 경계

PostgreSQL이 무엇을 코어에 두고 무엇을 바깥에 두는지, 영역을 그림으로 나눠 봅니다.

코어는 쿼리 처리, MVCC, streaming replication, declarative partition까지를 책임집니다. 그 위로 sharding, 암호화, hint 같은 빈칸은 extension이 메우고, connection pooling은 아예 별도 프로세스가 연결 앞단에서 받습니다. 이제 각 빈칸을 풀어씁니다.

빈칸 1. 내장 암호화

첫 번째 빈칸은 TDE입니다.

무엇이 없나

데이터 파일을 디스크에 암호화해 저장하는 Transparent Data Encryption(TDE)이 코어에 없습니다. 디스크나 백업 미디어를 통째로 탈취당해도 키 없이는 못 읽게 막는 data-at-rest 보호인데, Oracle/SQL Server는 오래전부터 내장한 기능입니다.

왜 코어에 없나

암호화 자체는 어렵지 않습니다. 어려운 건 키 관리와 성능, 그리고 WAL, 임시 파일, 통계까지 빠짐없이 덮는 일관성입니다. 코어에 넣으려면 KMS 연동 모델까지 표준화해야 하는데, 이 합의가 더딥니다. Momjian은 cluster file encryption을 단일 호스트 항목으로 분류하면서, 진행은 있되 코어 합의에는 이르지 못한 상태로 짚습니다.

지금은 무엇으로 메우나

Percona의 pg_tde extension이 2025년에 production 궤도에 올랐습니다. 2025년에 WAL 암호화가 GA에 도달했고, 2025년 11월에는 pg_tde 2.1이 릴리스되며 PostgreSQL 18.1과 asynchronous I/O까지 지원합니다. HashiCorp, Thales, Fortanix, OpenBao 같은 KMS 연동도 붙었습니다. pg_tde는 구독 뒤에 숨기지 않은 오픈소스라는 점을 내세웁니다. 한편 EDB도 TDE를 제공하지만, 이쪽은 EDB Postgres Advanced Server/Extended Server의 라이선스 제품에서만 쓸 수 있습니다.

pg_tde를 쓸 때 백업 도구와의 궁합은 따로 검증이 필요합니다. 이 주제는 암호화된 PostgreSQL은 pgBackRest로 백업될까 글에서 한 편 다뤘습니다.

코어 편입 전망

당장은 어렵습니다. pg_tde가 사실상의 오픈소스 표준 자리를 먼저 굳히는 중이고, 코어 편입은 그 다음 논의가 될 가능성이 큽니다.

빈칸 2. 내장 connection pooler

무엇이 없나

PostgreSQL은 17까지도 내장 connection pooler가 없습니다. PostgreSQL은 연결 하나당 프로세스 하나(process-per-connection) 모델이라, 연결 수가 늘면 메모리와 context switch 비용이 가파르게 오릅니다. 수천 개의 짧은 연결을 받는 웹 백엔드에서 특히 아픕니다.

왜 코어에 없나

프로세스 모델 자체가 발목을 잡습니다. 내장 pooler를 제대로 넣으려면 연결 처리 구조를 손봐야 하고, 이는 server-side threading 같은 더 깊은 과제와 얽힙니다. 패치 제안은 여러 번 올라왔지만 코어 합의까지 가지 못했습니다.

지금은 무엇으로 메우나

외부 프로세스가 연결 앞단에서 받습니다. 가장 널리 쓰이는 건 PgBouncer로, transaction/session 단위 풀링을 지원하는 경량 도구이자 사실상의 표준입니다. Pgpool-II는 풀링에 더해 load balancing/query routing까지 묶은 무거운 도구고, Supavisor는 Supabase가 Elixir로 만든 멀티테넌트 pooler로 클라우드 규모를 노립니다.

클라우드 사업자들은 이걸 관리형으로 흡수했습니다. Azure Database for PostgreSQL은 PgBouncer를 서버 단위 옵션으로 켤 수 있게 내장했습니다.

코어 편입 전망

논의는 살아 있습니다. 다만 process-per-connection 구조와 threading 과제가 함께 풀려야 본격적인 내장 pooler가 가능해집니다. 단기간에 PgBouncer를 대체할 그림은 아닙니다.

빈칸 3. optimizer hints

무엇이 없나

쿼리 planner에게 "이 인덱스를 써라", "이 조인 순서로 가라" 식으로 강제하는 optimizer hint가 코어에 없었습니다. Oracle 사용자가 PostgreSQL로 옮길 때 가장 먼저 당황하는 지점 중 하나입니다.

왜 코어에 없나

PostgreSQL 커뮤니티는 hint를 의도적으로 거부해 왔습니다. planner가 통계로 최적해를 찾게 두는 편이 장기적으로 낫고, hint는 잘못된 플랜을 영구히 고착시키는 부채가 된다는 철학입니다. "hint가 필요하면 그건 planner나 통계를 고칠 신호"라는 입장이 오래 유지됐습니다.

지금은 무엇으로 메우나

pg_hint_plan extension이 그 자리를 메워 왔습니다. 주석 형태로 hint를 심어 planner 동작을 강제합니다.

코어 편입 전망

여기서 흐름이 바뀌었습니다. optimizer hint의 1차 형태가 PostgreSQL 19에 들어옵니다. 오래 거부하던 기능이 코어에 발을 들이는 사례라, 빈칸 목록에서 가장 먼저 지워질 항목입니다.

빈칸 4. 단일 호스트의 나머지 성능 항목

Momjian이 단일 호스트 묶음으로 짚은 나머지를 한 번에 정리합니다. 이들의 공통점은 기능 자체의 결핍이라기보다 더 빠르게 하기 위한 장치라는 점입니다.

columnar storage는 분석 워크로드용 열 지향 저장인데, 코어에 없고 Citus의 columnar나 Hydra 같은 extension이 메웁니다. 코어 작업은 아직 널리 알려진 움직임이 없습니다. global index는 partition 테이블 전체를 가로지르는 인덱스로, 여러 partition에 걸친 unique 보장을 한 인덱스로 처리하려는 것인데 지금은 코어에 없어 수동 우회에 의존합니다. direct I/O는 OS 페이지 캐시를 우회하는 I/O로, PostgreSQL 18이 asynchronous I/O(AIO) 서브시스템을 들이며 기반이 깔렸고 그 위에서 진행 중입니다. server-side threading은 프로세스 모델을 thread 모델로 바꾸는 장기 과제라 connection pooler 빈칸과 뿌리가 같습니다.

64-bit transaction ID는 조금 더 설명이 필요합니다. 32-bit XID는 wraparound 위험을 안고 삽니다. PostgreSQL은 epoch을 포함한 xid8 타입을 이미 갖췄지만, 내부 XID를 통째로 64-bit로 넓히는 작업은 on-disk 호환성 때문에 코어에 못 들어왔습니다. Postgres Pro fork는 내부 64-bit XID를 상용으로 돌리고 있어, 가능은 하되 코어 편입의 벽이 높다는 걸 보여줍니다.

빈칸 5. sharding

무엇이 없나

데이터를 여러 노드에 수평 분산하는 sharding이 코어에 없습니다. 단일 서버 용량을 넘어서는 순간 부딪히는 벽입니다. MySQL 진영은 Vitess라는 검증된 sharding 시스템을 오래 가졌지만, PostgreSQL은 비교 대상이 없었습니다.

왜 코어에 없나

sharding은 distributed transaction, 분산 plan, 노드 간 일관성까지 묶인 거대한 과제입니다. PostgreSQL은 declarative partition으로 단일 노드 안의 분할까지는 코어에 들였지만, 노드를 가로지르는 분산은 extension/미들웨어의 몫으로 남겨 뒀습니다.

지금은 무엇으로 메우나

Citus는 분산 PostgreSQL extension입니다. Microsoft가 인수해 Azure로 들어갔고, Azure의 Elastic Clusters가 이 오픈소스 기술 위에서 row/schema 단위 sharding을 제공합니다. Citus 14는 PostgreSQL 18을 지원합니다. Multigres는 2025년 6월 Supabase가 Vitess 공동 창시자 Sugu(수구)를 영입해 시작한 "PostgreSQL용 Vitess"입니다. PostgreSQL 앞단에 놓이는 proxy로 표준 PostgreSQL 호환을 최우선에 두며, Vitess와 같은 Apache 2.0 라이선스 오픈소스입니다.

Multigres는 분량이 커서 PostgreSQL에도 Vitess가 온다 글에서 따로 다뤘습니다.

코어 편입 전망

가까운 시일에는 어렵습니다. 코어가 분산 트랜잭션까지 흡수하기보다, Citus/Multigres 같은 미들웨어가 각자 자리를 잡는 그림이 현실적입니다.

빈칸 6. 다중 호스트의 나머지 항목

다중 호스트 묶음의 나머지를 정리합니다. multi-master replication은 여러 노드가 동시에 write를 받는 구성인데, 코어의 replication은 단일 primary 기준입니다. pgEdge의 Spock이 multi-master logical replication을 제공해 지리적으로 분산된 배치에 쓰이고, EDB의 BDR이 상용으로 그 자리를 채웁니다. Oracle RAC 동급은 공유 스토리지 위에서 여러 인스턴스가 같은 DB를 동시에 여는 RAC 모델을 말하는데, PostgreSQL에는 이에 직접 대응하는 코어 기능도, 널리 쓰이는 대체재도 사실상 없습니다. 빈칸 중 가장 비어 있는 자리입니다. DDL의 logical replication은 결이 조금 다릅니다. logical replication이 DML은 나르지만 CREATE TABLE 같은 DDL은 자동으로 나르지 못하는데, PostgreSQL 19에서 sequence 복제 같은 주변부가 채워지며 부분적으로 전진하고 있습니다.

왜 비어 있는가

빈칸들을 한 발 떨어져 보면 공통된 이유가 보입니다. 첫째는 철학입니다. 코어를 작게 두고 extension/외부 프로세스로 확장하는 노선인데, optimizer hint를 오래 거부한 것이 대표적입니다. 둘째는 구조입니다. process-per-connection 모델이 connection pooler와 threading을 동시에 막고, on-disk 포맷 호환성이 64-bit XID를 막습니다. 셋째는 합의 비용입니다. TDE의 키 관리나 sharding의 분산 트랜잭션처럼 표준화 합의가 비싼 과제는 코어 진입이 더딥니다.

그리고 Momjian의 결론처럼, 이 빈칸들은 대부분 "PostgreSQL이 못 하는 일"이라기보다 "더 빠르게/더 크게 하기 위한 장치"에 가깝습니다. 기능의 결핍보다는 성능과 규모의 천장에 걸리는 문제라, 대부분의 빈칸 옆에는 이미 그 천장을 뚫는 도구가 하나씩 서 있습니다.

닫으며

PostgreSQL의 빈칸 목록은 약점 목록이라기보다 지도에 가깝습니다. 어디까지가 코어이고 어디부터 extension/미들웨어의 영역인지, 그리고 다음 5년 동안 어느 칸이 먼저 채워질지를 보여줍니다. optimizer hint가 PostgreSQL 19에서 코어로 들어오는 것처럼, 빈칸은 고정된 게 아니라 천천히 메워집니다.

DBA 입장에서 실무적으로 남는 건 단순해요. 벽에 부딪히기 전에 어느 칸이 비어 있는지 미리 알아 두면 돼요. sharding이 필요하면 Citus나 Multigres를 일찍 검토하고, 연결 폭증이 보이면 PgBouncer를 처음부터 설계에 넣고, 규제 요건이 있으면 pg_tde를 미리 검증하면 돼요. 빈칸은 막다른 길이라기보다 무엇을 곁들여야 하는지 알려주는 표지판에 가깝습니다.

1차 출처

Momjian 발표:

암호화(TDE):

connection pooling:

optimizer hints:

sharding / multi-host:

64-bit XID:

PG19 논리 복제 시퀀스

· 약 8분

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

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

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

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

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

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

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

failover 때 무슨 사고가 났나

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

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

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

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

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

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

PostgreSQL 19가 푸는 방식

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

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

핵심은 세 조각입니다.

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

publication의 ALL SEQUENCES

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

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

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

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

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

subscription의 동기화 시점

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

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

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

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

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

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

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

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

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

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

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

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

동기화 흐름

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

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

상태와 모니터링

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

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

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

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

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

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

PostgreSQL 18 이하 vs 19

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

운영 점검 포인트

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

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

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

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

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

정리

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

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

참고 자료

PG19 온라인 체크섬 전환

· 약 8분

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

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

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

data_checksums가 무엇을 막아주나

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

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

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

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

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

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

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

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

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

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

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

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

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

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

PostgreSQL 19의 온라인 전환

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

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

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

SELECT pg_enable_data_checksums();

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

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

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

postgres: datachecksum launcher
postgres: datachecksum worker

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

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

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

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

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

SELECT pg_disable_data_checksums();

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

전환 흐름 비교

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

정리

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

출처

pg_stat_statements 해설

· 약 9분

pg_stat_statements는 컬럼이 스무 개가 넘지만, DBA가 매일 보는 건 사실 대여섯 개뿐이에요. 나머지는 그 대여섯 개를 해석할 때 보조로 쓰이며, 이 글에서는 핵심 컬럼을 읽는 법과 이 확장의 내부 동작 때문에 가끔 데이터가 비어 보이는 이유를 운영 관점에서 정리해요.

같은 주에 boringsql와 pganalyze가 이 주제를 각각 깊게 다뤘습니다. 두 글을 PostgreSQL 공식 문서로 교차 확인하면서 한국어 실무 가이드로 다시 엮었습니다.

pg_stat_statements가 수집하는 것

pg_stat_statements는 공유 메모리에 있는 고정 크기 해시 테이블입니다. 개별 실행을 하나하나 기록하는 게 아니라, 같은 모양의 쿼리를 하나의 항목으로 묶어 누적 카운터를 갱신합니다.

여기서 "같은 모양"의 기준은 정규화(normalization)입니다. 상수를 떼어내고 $1, $2 같은 자리표시자로 바꾼 뒤, 파싱된 쿼리 트리의 해시값을 queryid로 삼습니다. 그래서 아래 두 쿼리는 하나의 항목으로 집계됩니다.

SELECT * FROM users WHERE id = 42;
SELECT * FROM users WHERE id = 99;

여기서 DBA가 미리 알아둬야 할 정규화의 성질이 몇 가지 있습니다.

  • queryid는 메이저 버전 사이에서 안정적이지 않습니다. 같은 SQL이라도 메이저 업그레이드 후, 혹은 다른 CPU 아키텍처에서 다른 값으로 해시될 수 있습니다. 여러 서버의 통계를 묶을 때 queryid를 영구 조인 키로 쓰면 안 됩니다.
  • OID 기반이라 이름 기반이 아닙니다. 테이블을 drop하고 다시 만들면 새 OID가 붙고, 그 결과 새 queryid가 생깁니다. 이전 통계는 고아로 남습니다.
  • 구조에 민감합니다. 테이블 alias가 다르거나, 컬럼 목록이 바뀌거나, LIMIT이 붙거나, 조건 순서가 달라지면 논리적으로 같은 쿼리가 여러 항목으로 쪼개집니다. ORM이 이런 변형을 대량으로 쏟아내면 항목이 수백 개로 흩어지기도 합니다.

또 하나, 중요한 동작상의 제약이 있습니다. pg_stat_statements는 ExecutorEnd 단계에서 통계를 기록합니다. 즉 정상적으로 끝난 실행만 집계됩니다. 타임아웃으로 끊기거나 중간에 abort된 쿼리는 흔적을 남기지 않습니다. 장애 시점에 문제를 일으킨 쿼리가 정작 이 뷰에 안 보이는 경우가 여기서 나옵니다.

DBA가 봐야 할 핵심 컬럼

전체 컬럼은 공식 문서에 다 있습니다. 여기서는 운영 중에 실제로 손이 가는 것만 추립니다.

컬럼의미언제 보나
calls실행 횟수빈도. 한 번 느린지, 자주 느린지 구분
total_exec_time누적 실행 시간(ms)서버 부하에 가장 크게 기여하는 쿼리 찾기
mean_exec_time평균 실행 시간(ms)한 번 실행이 비싼 쿼리 찾기
min/max/stddev_exec_time최소, 최대, 표준편차(ms)편차가 큰 쿼리 식별
rows반환/영향 행 수호출당 결과 규모 추정
shared_blks_hitbuffer cache 적중 블록 수캐시 효율
shared_blks_read디스크에서 읽은 공유 블록 수실제 I/O 부담
wal_bytes생성한 WAL 양(bytes)쓰기 부하/복제 부담

느린 쿼리를 찾을 때 mean과 total 구분하기

가장 먼저 정해야 할 건 정렬 기준입니다. mean_exec_timetotal_exec_time은 전혀 다른 질문에 답합니다.

  • total_exec_time이 큰 쿼리는 서버 전체 부하에 가장 많이 기여하는 쿼리입니다. 한 번은 1ms로 빨라도 하루에 수천만 번 실행되면 누적 시간이 1위가 됩니다. 서버가 바쁜 원인을 찾을 때는 이 값을 봅니다.
  • mean_exec_time이 큰 쿼리는 한 번 실행이 비싼 쿼리입니다. 빈도는 낮아도 실행할 때마다 사용자를 기다리게 만듭니다. 특정 화면이 느리다는 제보를 추적할 때 유용합니다.
-- 누적 부하 상위 10개
SELECT
queryid,
calls,
round(total_exec_time::numeric, 1) AS total_ms,
round(mean_exec_time::numeric, 2) AS mean_ms,
rows,
query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;

calls를 항상 함께 봐야 둘을 헷갈리지 않습니다. total_exec_time이 높은데 calls도 높으면 "자주 불려서 누적된" 것이고, calls가 낮은데 total이 높으면 "한 번이 무거운" 것입니다.

평균에서 사라지는 분포

pg_stat_statements는 평균(mean)과 표준편차(stddev)만 유지하고, 개별 실행 시간은 즉시 버립니다. 그래서 분포의 모양을 알 수 없습니다.

예를 들어 99%는 1ms에 끝나지만 1%는 2초가 걸리는 쿼리가 있다면, 평균은 약 21ms 근처로 찍힙니다. 이 21ms는 어느 실행도 대표하지 못하는 숫자입니다. 정작 문제가 되는 2초짜리 1%는 나머지 99%에 희석돼 평균 뒤로 묻힙니다. 게다가 pg_stat_statements는 p99(실행의 99%가 그 안에 들어오는 시간, 상위 1%를 잘라낸 경계) 같은 percentile 값을 주지 않으니, 평균만 봐서는 이런 느린 실행을 따로 짚을 길이 없습니다. stddev_exec_time이 평균에 비해 유난히 크거나 max_exec_time이 평균과 크게 벌어져 있다면, 그 쿼리는 분포가 넓다는 신호이니 별도로 들여다볼 가치가 있습니다.

I/O 컬럼의 hit와 read 읽기

shared_blks_hitshared_blks_read는 짝으로 읽습니다.

  • shared_blks_hit은 buffer cache에서 바로 찾은 블록 수입니다. 빠릅니다.
  • shared_blks_read는 buffer에 없어서 디스크(또는 OS page cache)에서 읽어 온 블록 수입니다. 느립니다.

shared_blks_read가 크다는 건 그 쿼리가 실제 I/O를 많이 일으킨다는 뜻입니다. 작업 집합이 buffer pool보다 크거나, 인덱스 없이 큰 테이블을 스캔하고 있을 가능성이 있습니다. 두 값을 합친 대비 적중률을 보면 캐시 효율을 가늠할 수 있습니다.

읽기/쓰기에 걸린 실제 시간까지 보려면 shared_blk_read_time, shared_blk_write_time 컬럼을 봅니다. 단, 이 값은 track_io_timing이 켜져 있어야 채워지고, 꺼져 있으면 전부 0입니다.

temp_blks_read, temp_blks_written은 임시 파일 I/O입니다. 정렬이나 해시 조인이 work_mem을 넘겨서 디스크로 흘러넘쳤다는 신호이므로, 이 값이 큰 쿼리는 work_mem 조정이나 쿼리 재작성 후보입니다.

쓰기 부하를 보여주는 WAL 컬럼

wal_bytes, wal_records, wal_fpi는 그 쿼리가 만든 WAL 양을 보여줍니다. 쓰기가 많은 쿼리, 즉 replication 지연이나 디스크 쓰기 부하의 원인을 찾을 때 봅니다. wal_fpi(full page image)가 유난히 크면 checkpoint 직후의 첫 쓰기에서 페이지 전체가 WAL에 기록되는 패턴일 수 있습니다.

버전에 따른 total_time 분리

PostgreSQL 13에서 타이밍 컬럼 이름이 바뀌었습니다. 12 이하에서는 total_time, mean_time 한 묶음이었지만, 13부터 계획(planning)과 실행(execution)이 분리되어 total_plan_time/total_exec_time, mean_plan_time/mean_exec_time으로 나뉘었습니다. 12 이하를 대상으로 한 옛 쿼리는 13 이상에서 컬럼명을 바꿔줘야 동작합니다.

단, 계획 시간 컬럼(total_plan_time 등)은 pg_stat_statements.track_planning이 켜져 있어야 채워집니다. 이 설정의 기본값은 off입니다. 계획 시간이 전부 0으로 보인다면 이 설정부터 확인합니다.

PostgreSQL 17부터는 stats_since(항목 통계 수집이 시작된 시각)와 minmax_stats_since(min/max 통계 수집 시작 시각) 컬럼이 추가됐습니다. 어떤 구간을 측정할 때 그 사이에 eviction이나 리셋이 끼었는지 판단하는 데 씁니다.

해시 테이블이 가득 찰 때의 eviction과 dealloc

pg_stat_statements가 추적하는 서로 다른 쿼리 수에는 상한이 있습니다. pg_stat_statements.max이고 기본값은 5000입니다. 이 값은 서버 시작 시에만 설정할 수 있습니다.

서로 다른 쿼리 수가 이 상한을 넘으면 eviction(축출)이 일어납니다. 공식 문서는 "가장 적게 실행된(least-executed) 항목 정보를 버려서 새 항목 자리를 만든다"고 설명합니다. 내부적으로는 각 항목에 겉으로 드러나지 않는 usage 카운터가 있고, deallocation 시점에 모든 usage 값을 일정 비율 줄인 뒤 정렬해서 하위 일부를 버립니다. 이 정렬은 항목 수에 비례하는 작업이며, exclusive lock을 잡은 채로 진행됩니다.

그래서 pg_stat_statements.max를 무작정 키우는 건 공짜가 아닙니다. max가 클수록 deallocation 시점의 정렬 비용이 커지고, 그동안 lock 경합도 길어집니다.

축출이 일어났는지는 별도 뷰인 pg_stat_statements_info로 확인합니다. 이 뷰는 단 한 행이고 컬럼 두 개를 가집니다.

컬럼의미
deallocmax를 초과해 항목이 축출된 누적 횟수
stats_reset전체 통계가 마지막으로 리셋된 시각
SELECT dealloc, stats_reset FROM pg_stat_statements_info;

dealloc이 계속 올라간다면, 테이블이 쉼 없이 가득 차고 비워지는 중입니다. 이 상태에서는 자주 실행되지 않는 쿼리의 통계가 수시로 사라지므로 데이터를 신뢰하기 어렵습니다. ORM이 구조가 조금씩 다른 쿼리를 대량으로 흘려보내 해시 테이블을 가득 채우는 경우가 대표적입니다. 이때 실행 빈도가 낮은 항목이 먼저 축출되면서, 나중에 그 쿼리를 되짚어볼 단서 자체가 남지 않습니다.

대응은 두 갈래입니다. max를 적당히 늘리거나(정렬 비용 증가를 감수하고), 애초에 쿼리 변형 수를 줄이는 것입니다. 예컨대 길이가 제각각인 IN 목록은 PostgreSQL 18 이전까지 길이마다 별도 항목을 만들었는데, = ANY($1) 배열 바인딩으로 바꾸면 버전과 무관하게 하나의 항목으로 묶입니다.

아래 도식은 새 쿼리가 들어왔을 때 항목이 잡히고 축출되기까지의 흐름입니다.

운영 관점의 리셋 전략과 함정

단조 증가하는 누적 통계

뷰의 카운터는 마지막 리셋 이후로 계속 쌓입니다. 그래서 "지금 어떤 쿼리가 부하를 일으키는가"를 보려면 두 시점의 스냅샷을 떠서 빼는 수밖에 없습니다. 한 번 조회한 절댓값만으로는 한 달 전 배치 작업과 방금 들어온 트래픽이 뒤섞여 보입니다.

모니터링 도구들이 일정 간격으로 뷰를 떠다가 차분을 계산하는 이유가 이것입니다. 직접 차분을 계산할 때는 그 사이에 eviction이나 리셋이 끼면 음수가 나올 수 있으니, stats_since(17 이상)나 pg_stat_statements_infodealloc/stats_reset을 함께 확인해야 합니다.

리셋 함수

pg_stat_statements_reset()으로 통계를 비웁니다. 인자 없이 호출하면 전체를 리셋하고, 특정 대상만 비울 수도 있습니다.

-- 전체 리셋
SELECT pg_stat_statements_reset();

-- 특정 user / db / queryid만 리셋 (0은 "전체" 의미)
SELECT pg_stat_statements_reset(0, 0, :queryid);

minmax_only 인자를 true로 주면 min/max_exec_time, min/max_plan_time만 비웁니다. 평균과 누적은 살려두고 최소/최대만 다시 측정하고 싶을 때 씁니다. 기본값은 superuser만 실행할 수 있고, 필요하면 GRANT로 권한을 넘깁니다.

리셋 전략에는 정답이 없습니다. 흔한 선택은 두 가지입니다.

  • 리셋하지 않고 누적 + 차분. 모니터링 도구에 맡기는 방식. 장기 추세를 잃지 않습니다.
  • 정기 리셋(예: 배포/점검 전후). 특정 구간만 깨끗하게 보고 싶을 때. 단, 리셋 순간 이전 데이터는 사라지니 추세 분석은 포기합니다.

자주 밟는 함정 정리

  • 타임아웃/abort된 쿼리는 집계되지 않습니다. 장애 원인 쿼리가 뷰에 없을 수 있습니다.
  • 평균만으로 판단하면 드물게 튀는 느린 실행을 놓칩니다. max_exec_time/stddev로 분포를 함께 의심합니다.
  • dealloc이 오르는 상태에서는 통계가 불완전합니다. 먼저 그것부터 해결합니다.
  • 계획 시간(*_plan_time)이 0이면 track_planning이 꺼진 것입니다.
  • I/O 시간(shared_blk_read_time 등)이 0이면 track_io_timing이 꺼진 것입니다.
  • queryid는 메이저 버전/아키텍처 간에 달라질 수 있어 영구 키로 부적합합니다.

정리

pg_stat_statements는 컬럼이 많아 보여도 DBA의 시선은 결국 몇 갈래로 좁혀져요. 부하의 원인은 total_exec_time, 느린 한 방은 mean_exec_time으로 찾고 둘 다 calls로 맥락을 잡으며, I/O가 의심되면 shared_blks_read와 temp 블록을, 쓰기가 의심되면 wal_bytes를 봐요. 데이터가 비어 보이거나 음수가 나오면 pg_stat_statements_infodeallocstats_reset을 먼저 확인하면 되고, 이 정도만 손에 익히면 대부분의 쿼리 튜닝은 이 뷰 하나에서 출발할 수 있어요.


참고한 출처:

PostgreSQL 파일 디스크립터

· 약 6분

튜닝 한 줄로 max_connections를 1만까지 올린 인스턴스가 어느 날 통째로 죽어요. 원인은 쿼리도 디스크도 아니고, DBA가 평소 신경 쓰지 않는 OS limit, file descriptor예요. 연결 풀러 없이 연결 수만 키운 구성은 file descriptor 고갈이라는 단 하나의 벽에 부딪혀 무너져요. 이 글에서는 그 메커니즘과 대응을 한 번에 정리해요.

PostgreSQL의 프로세스 모델과 file descriptor

PostgreSQL은 연결 하나마다 OS 프로세스 하나를 띄우는 process-per-connection 모델입니다. 클라이언트가 접속하면 postmaster가 backend 프로세스를 fork하고, 그 backend가 해당 세션의 모든 작업을 담당합니다. 스레드 풀로 연결을 다중화하는 일부 다른 데이터베이스와 결정적으로 다른 지점입니다.

여기서 핵심은 backend 프로세스 하나가 여러 개의 file descriptor를 동시에 들고 있다는 점입니다. file descriptor(이하 fd)는 프로세스가 열어둔 파일, 소켓, 파이프를 가리키는 OS 차원의 정수 핸들입니다. 하나의 backend는 다음을 모두 fd로 잡습니다.

  • 클라이언트와 연결된 소켓
  • 읽고 쓰는 테이블/인덱스 파일 (PostgreSQL 내부 VFD 계층이 관리)
  • WAL 세그먼트
  • 정렬/조인 과정에서 만들어지는 임시 파일

그래서 fd 소비량은 연결 수에 딱 비례하지 않고, 그 연결이 무슨 일을 하느냐에 따라 출렁입니다. 놀고 있는 idle backend는 fd를 10개에서 15개 정도만 쓰지만, 여러 테이블과 인덱스를 동시에 건드리는 write backend는 50개에서 200개 이상까지 올라갑니다. 연결 1만 개가 전부 활성 상태로 무거운 쓰기를 돌리는 순간, fd 소비량은 산술적 예상치를 한참 넘어섭니다.

고갈 메커니즘과 증상

fd에는 두 겹의 한계가 걸려 있고, 둘 중 무엇에 먼저 닿느냐에 따라 증상이 달라집니다.

첫 번째는 프로세스 단위 한계인 RLIMIT_NOFILE입니다. ulimit -n으로 조회/설정하며, 프로세스 하나가 열 수 있는 fd 개수를 제한합니다. PostgreSQL의 max_files_per_process는 이 OS limit 안에서 backend 하나가 잡을 fd 상한을 한 번 더 좁히는 PostgreSQL 자체 파라미터입니다.

두 번째는 시스템 전체 한계인 fs.file-max입니다. 커널이 머신 전체에서 열 수 있는 fd 총량을 제어하며, 이 값에 도달하면 어느 프로세스가 요청하든 모든 open() 호출이 실패합니다. PostgreSQL뿐 아니라 그 위에 떠 있는 모든 프로세스가 동시에 영향을 받습니다.

연결 풀러 없이 max_connections를 과하게 올린 구성에서는 두 번째 한계에 먼저 닿기 쉽습니다. 이론상 최악의 소비량은 다음 곱으로 표현됩니다.

worst-case fd ≈ max_connections × max_files_per_process

max_files_per_process 기본값이 1,000이므로, max_connections를 10,000으로 잡으면 산술적 상한이 천만 단위로 뜁니다. 실제로는 모든 backend가 동시에 상한까지 fd를 사용하지는 않으니 평소에는 멀쩡합니다. 문제는 배치 잡이 한꺼번에 쓰기를 일으킬 때입니다. LWLock:BufferContent, LWLock:WALInsert, LWLock:WALWrite 같은 lock 경합으로 락 보유 시간이 길어지면 동시에 오픈된 fd가 쌓이고, 어느 순간 시스템 한계를 갑자기 넘깁니다.

이때 PostgreSQL 로그는 한꺼번에 죽지 않고 단계적으로 무너집니다.

out of file descriptors: Too many open files in system; release and retry
...
server process (PID XXXXX) was terminated by signal 6
...
failed to send SSL negotiation response: Broken pipe

처음에는 평범한 로그 사이에 "Too many open files"(OS 레벨로는 EMFILE/ENFILE) 메시지가 하나둘 섞이다가, fd를 못 잡은 backend가 signal 6(abort)으로 죽고, postmaster가 crash recovery에 들어가면서 살아 있던 연결까지 SSL 끊김으로 떨어집니다. 애플리케이션 쪽에서는 "Unable to create new connection" 같은 커넥션 풀 예외로 나타납니다. 즉 fd 고갈은 느린 성능 저하가 아니라 인스턴스 전체가 한 번에 내려앉는 장애로 드러납니다.

공식 문서도 같은 맥락을 짚습니다. max_files_per_process에 대해 "If you find yourself seeing 'Too many open files' failures, try reducing this setting"이라고 안내하고, kernel resource 항목에서는 시스템 전역 한계를 fs.file-max로 조정하라고 명시합니다.

ulimit, max_files_per_process, 연결 풀러로 대응하기

대응은 세 층위로 나뉩니다. 급한 불을 끄는 임시 조치와 구조를 바꾸는 근본 조치를 구분하는 것이 중요합니다.

대응무엇을 푸나성격부작용/한계
fs.file-max 상향시스템 전역 fd 총량 확대임시 (시간 벌기)근본 원인인 backend 수는 그대로
ulimit -n 상향프로세스당 fd 상한 확대임시/보완메모리 소비 증가, 한계만 미룸
max_files_per_process 조정backend 1개의 fd 상한 제어보완너무 낮추면 VFD 캐시 회전 잦아져 성능 저하, 재시작 필요
PgBouncer 등 연결 풀러backend 절대 수 감소근본풀러 운영/모드 이해 필요

1단계: 임시로 OS limit 넓히기

PgBouncer 도입 전까지 시간을 벌어야 한다면 시스템 전역 한계부터 올립니다.

# 현재 상태 확인
cat /proc/sys/fs/file-max # 시스템 최대치
cat /proc/sys/fs/file-nr # 현재 사용량: <할당> <유휴> <최대>

# 시스템 전역 한계 상향 (재부팅 후에도 유지)
sysctl -w fs.file-max=20000000
echo "fs.file-max = 20000000" >> /etc/sysctl.conf
sysctl -p

프로세스당 한계는 postgres 사용자에 대해 /etc/security/limits.conf(또는 systemd 유닛의 LimitNOFILE)에서 soft/hard를 함께 올립니다. soft 한계가 실제로 적용되는 값이고 hard 한계까지 사용자가 올릴 수 있으며, hard 한계 자체는 root만 바꿀 수 있습니다. 다만 이는 어디까지나 시간을 버는 조치입니다. backend 수가 그대로라면 한계를 올린 만큼 더 큰 폭으로 다시 터집니다.

2단계: max_files_per_process로 backend 소비 제한

OS 전역 한계를 못 건드리는 환경이라면, backend 하나가 잡는 fd를 PostgreSQL 쪽에서 좁힐 수 있습니다. 공식 문서가 안내하는 방식입니다.

# postgresql.conf — 재시작 필요
max_files_per_process = 1000 # 기본값. "Too many open files" 보이면 낮춰 본다

단, 이 값은 OS의 프로세스당 fd 한계를 넘을 수 없습니다. 그리고 너무 낮추면 backend가 파일을 자주 닫았다 여는 VFD 캐시 회전이 잦아져 성능이 떨어집니다. 어디까지나 OS 한계 조정과 짝을 이루는 보완책으로 봅니다.

3단계: 연결 풀러로 근본 대응

진짜 해법은 애플리케이션과 PostgreSQL 사이에 PgBouncer 같은 연결 풀러를 두는 것입니다. 풀러는 수천 개의 애플리케이션 연결을 실제 200~500개 backend로 다중화합니다. transaction 모드에서는 트랜잭션이 끝날 때마다 backend가 풀로 반환되므로, 1만 개 클라이언트가 붙어도 PostgreSQL이 실제로 띄우는 backend 수는 풀 크기로 묶입니다.

풀러를 앞에 세운 뒤에는 max_connections를 500~1,000 수준으로 되돌립니다. 그래야 backend가 무한정 쌓이는 구조 자체가 사라집니다. 한계를 넓히는 게 아니라, 한계에 도달할 일을 없애는 방향입니다.

DBA 체크리스트

장애가 터지기 전에 점검할 항목과, 터졌을 때 들여다볼 지점을 나눠 정리합니다.

평상시 점검:

  • SHOW max_connections; 값이 실제 동시 연결 수요 대비 과하게 크지 않은가
  • 애플리케이션과 PostgreSQL 사이에 PgBouncer 등 연결 풀러가 있는가
  • 풀러를 쓴다면 transaction 모드로 backend가 트랜잭션마다 반환되는가
  • fs.file-max와 postgres 사용자 ulimit -n이 워크로드 피크를 감당할 값인가
  • cat /proc/sys/fs/file-nr 첫 번째 값(할당된 fd)이 fs.file-max의 50~60%를 넘지 않는가

모니터링/알람:

  • file-nr 사용량이 fs.file-max의 일정 비율을 넘으면 알람이 울리는가
  • pg_stat_activity의 활성 연결 수가 배치 기준선(예: 1,000)을 넘으면 감지되는가
  • 배치 윈도우에는 watch -n 5 'cat /proc/sys/fs/file-nr'로 실시간 추적하는가

장애 발생 시 진단:

  • 로그에 "Too many open files in system"이 보이는가 — fd 고갈 확정
  • cat /proc/$(pgrep -o postgres)/limits | grep "open files"로 프로세스 한계 확인
  • pg_stat_activity를 state/wait_event별로 집계해 lock 경합 backend가 몰려 있는지 확인
SELECT count(*), state, wait_event_type, wait_event
FROM pg_stat_activity
GROUP BY state, wait_event_type, wait_event
ORDER BY count DESC;

정리하면, fs.file-maxulimit을 올리는 건 응급 처치이지 치료가 아니에요. fd 고갈의 뿌리는 backend 프로세스가 너무 많다는 데 있고, 그 수를 구조적으로 묶는 유일한 방법이 연결 풀러예요. 연결 풀러가 이번 주 계획에 없다면, 지금 넣어야 해요.

참고

pgBackRest와 pg_tde 백업

· 약 5분

데이터를 디스크에 암호화해서 저장하는 클러스터를 백업 도구가 제대로 다룰 수 있을까요? 결론부터 말하면, pg_tde로 암호화한 PostgreSQL 클러스터를 pgBackRest로 백업하고 복구하는 과정은 거의 대부분 투명하게 작동하지만, 암호화된 데이터를 백업 도구가 해석할 수 없다는 본질적 한계 때문에 몇 가지 검증/압축 옵션은 꺼야 해요.

Data Egret의 Stefan Fercot(스테판 페르코)가 pgBackRest and pg_tde 글에서 이 조합을 직접 검증했습니다. 이 블로그에서 다뤄 온 백업 시리즈의 곁가지로, 백업 도구와 암호화 extension의 궁합을 운영 관점에서 정리해 봐요.

pg_tde가 암호화하는 것

pg_tde는 Percona가 개발 중인 투명 데이터 암호화(Transparent Data Encryption) extension입니다. 이름 그대로 애플리케이션은 암호화 여부를 신경 쓰지 않고, 디스크에 저장되는 데이터(data at rest)만 암호화합니다. 암호화 대상은 크게 두 가지입니다.

  • 테이블 데이터(heap): tde_heap 접근 방식(access method)으로 만든 테이블. 기존 테이블은 ALTER TABLE ... SET ACCESS METHOD tde_heap으로 전환합니다. heap 테이블뿐 아니라 거기 딸린 index, TOAST, sequence까지 암호화됩니다.
  • WAL: pg_tde.wal_encrypt GUC를 켜면 WAL 세그먼트도 암호화됩니다. 이 설정은 서버 전역이라 재시작이 필요하고, 켠 시점 이후의 WAL write부터 암호화가 적용됩니다.

다만 모든 게 암호화되지는 않습니다. 시스템 카탈로그와 통계 데이터 같은 메타데이터는 아직 암호화 대상이 아닙니다. 이름 그대로 "데이터" 암호화에 초점이 맞춰져 있습니다.

키 구조는 2계층입니다. 실제 데이터를 암호화하는 internal key는 $PGDATA/pg_tde 아래에 로컬로 저장되고, 이 internal key를 다시 암호화하는 principal key는 외부 KMS에 둡니다. principal key는 데이터베이스당 하나입니다. 검증에서는 KMS로 OpenBao를 Docker로 띄워 썼고, root 자격증명이 아니라 secret/ 경로에만 읽기/쓰기 권한을 준 최소 권한 토큰을 발급해 사용했습니다.

pgBackRest가 암호화된 클러스터를 다루는 흐름

pgBackRest 입장에서 보면, pg_tde가 이미 암호화해 놓은 데이터 파일과 WAL을 그대로 받아 저장소로 옮깁니다. 백업 도구는 그 안을 들여다보지 않습니다. 암호화는 PostgreSQL 쪽에서 끝나 있고, pgBackRest는 암호화된 바이트 덩어리를 운반하는 역할입니다.

중요한 지점은 복구 이후입니다. pgBackRest가 복구해 놓은 PGDATA는 여전히 암호화된 상태입니다. 이걸 PostgreSQL이 다시 읽으려면 internal key를 풀 principal key가 필요하고, principal key는 외부 KMS에 있습니다. 즉 백업 파일만 들고 있어서는 복구한 클러스터를 기동할 수 없고, KMS 접근이 함께 살아 있어야 합니다.

검증 결과와 주의점

Fercot는 pgbench로 부하를 주면서 full 백업과 incremental 백업을 뜨고, pgbench_tellers에서 1,000건을 삭제한 뒤 named restore point로 시점 복구(PITR)를 실행해 삭제된 1,000건이 되살아나는 것까지 확인했습니다. WAL 세그먼트와 데이터 파일은 백업 저장소 안에서도 암호화된 채 유지됐고, 복구 과정에서 별도의 복호화 래퍼 없이 named restore point에 깔끔하게 도달했습니다.

다만 암호화 특성 때문에 꺼야 하는 설정들이 있습니다. 정리하면 다음과 같습니다.

설정이유
archive-header-checkn암호화된 WAL 헤더를 pgBackRest가 해석하지 못합니다.
checksum-pagen암호화된 page의 checksum을 검증할 수 없습니다.
compress-typenone암호화된 데이터는 무작위에 가까워 압축 이득이 거의 없습니다.
repo1-blocknblock 단위 incremental의 효율이 암호화로 무력화됩니다.

표의 항목들은 모두 같은 원인에서 나옵니다. pgBackRest는 암호화된 내용을 들여다볼 수 없으므로, 데이터 내부를 읽어야 성립하는 검증과 최적화가 작동하지 않습니다. 그래서 page checksum 검증과 WAL 헤더 검증을 끄고, 압축도 끄는 편이 낫습니다. 압축을 켜 두면 줄어들지도 않을 데이터를 압축하느라 CPU만 씁니다.

한 가지 더, incremental 백업 크기가 예상보다 컸습니다. block 단위 incremental은 변경된 블록만 골라 담아 용량을 아끼는데, 암호화된 데이터에서는 이 절감 효과가 크게 줄어듭니다.

WAL 암호화 관련: 과거 Percona 문서는 암호화 환경에서 비동기 아카이빙(archive-async=y)을 권하지 않았습니다. 그러나 이번 검증에서는 비동기 아카이빙이 문제없이 작동했습니다. 운영에서 적용하기 전에는 사용하는 pg_tde 버전 기준으로 한 번 더 확인하는 편이 안전합니다.

키 관리 관점: 백업과 복구가 투명하게 돈다고 해서 백업만으로 복구가 끝나는 것은 아닙니다. 복구된 PGDATA는 암호화 상태 그대로이므로, principal key를 보관한 KMS에 접근하지 못하면 클러스터를 기동할 수 없습니다. 백업 파일과 KMS 접근 권한을 함께 보존하고, KMS 자체의 가용성과 백업도 별도로 챙겨야 합니다. 백업 저장소 한쪽만 살아남는 시나리오도 복구 절차에 반드시 포함해 두는 편이 좋습니다.

저장소 암호화 중복: pgBackRest 자체에도 저장소 암호화 옵션(repo1-cipher-type)이 있습니다. pg_tde가 이미 데이터를 암호화한 뒤 넘겨주므로 저장소 암호화는 선택 사항입니다. 다만 시스템 카탈로그처럼 pg_tde가 암호화하지 않는 영역이 백업에 포함되는 점을 고려하면, 저장소 암호화를 한 겹 더 두는 선택도 합리적입니다.

검증에는 Percona Server for PostgreSQL이 쓰였습니다. pg_tde가 특정 패치에 의존하기 때문에 표준 PostgreSQL이 아닌 Percona 배포판이 필요하고, pgBackRest가 버전을 제대로 인식하도록 pg-version-force로 버전을 강제해야 했습니다.

운영 관점과 백업 시리즈 연결

백업 도구를 고를 때는 도구 자체의 기능만 보기 쉽지만, 실제 운영에서는 암호화, 복제, 버전 같은 주변 환경과의 궁합이 더 자주 발목을 잡습니다. 이번 검증의 의미는 "pg_tde를 켜도 pgBackRest를 그대로 쓸 수 있다"는 점을 확인했다는 데 있습니다. 암호화 때문에 백업 도구를 새로 고민할 필요는 없고, 검증/압축 옵션 몇 개를 끄는 선에서 정리됩니다.

대신 복구 절차의 무게중심이 옮겨 갑니다. 평소처럼 백업 무결성만 점검하는 데서 그치지 말고, principal key를 가진 KMS까지 포함한 복구 리허설을 정기적으로 돌려야 합니다. 암호화를 도입하는 순간, 백업 전략은 "데이터 백업"에서 "데이터 백업 + 키 관리"로 확장됩니다.

pgBackRest를 운영에 들이는 과정 자체가 처음이라면, 이 블로그의 pgBackRest 도입기에서 기본 stanza 구성과 아카이빙 흐름을 먼저 잡고 오는 편이 좋아요. 암호화 궁합은 그 위에 얹는 한 겹이에요.

참고

Oracle 월간 CSPU 전환

· 약 8분

Oracle이 20년 넘게 유지해 온 분기 Critical Patch Update(CPU) 체제 위에 매달 발행하는 Critical Security Patch Update(CSPU)를 얹었습니다. 그 첫 릴리스가 2026년 5월 28일에 나왔습니다.

분기 CPU는 그대로 1, 4, 7, 10월에 누적본으로 나옵니다. CSPU는 그 사이 달에 셋째 화요일마다 끼어드는 소규모/고우선 전용 패치입니다. 첫 CSPU는 CVE 35건을 담았고, 그중 하나는 CVSS 10.0이었습니다. Oracle이 분기 리듬을 깨고 빠른 차선을 새로 낸 이유를 Oracle Security Blog의 공지와 DBA 관점에서 정리합니다.

20년간 이어진 분기 CPU

Oracle의 분기 CPU는 DBA에게 익숙한 행사입니다. 매년 1, 4, 7, 10월 셋째 화요일에, Oracle의 전 제품군에 걸친 보안 수정이 한 묶음으로 떨어집니다. 한 번에 수백 건이 쏟아지는 게 특징입니다. 바로 직전인 2026년 4월 CPU만 해도 450건의 취약점을 한꺼번에 고쳤습니다.

이 모델의 핵심은 누적(cumulative)이라는 점입니다. 가장 최신 CPU 하나에 그 이전의 모든 수정이 포함되므로, DBA는 사실상 최신 CPU 한 개만 적용하면 됩니다. 관리가 단순하다는 장점은 분명합니다.

문제는 주기입니다. 1월에 발표된 취약점을 표준 절차로 막으려면 4월 CPU까지 최대 석 달을 기다려야 합니다. 그 석 달 동안 취약점은 공격자에게 열린 창으로 남습니다.

사이를 메우는 월간 CSPU

CSPU는 그 창을 좁히는 장치입니다. 분기 CPU를 대체하지 않고 사이를 메웁니다.

  • 발행 주기: 분기 CPU가 없는 달의 셋째 화요일 — 2, 3, 5, 6, 8, 9, 11, 12월
  • 성격: 전 제품군을 훑는 게 아니라, 즉시 대응이 필요하다고 Oracle이 판단한 소수 항목만 추린 묶음
  • 사전 예고: 각 CSPU 발행 직전 목요일(T-5)에 미리 공지

분기 CPU 4회와 CSPU 8회를 합치면, DBA는 1년에 12번의 예측 가능한 패치 이벤트를 갖게 됩니다(Oracle). 사실상 월 1회 리듬입니다.

분기 CPU vs 월간 CSPU

둘은 목적이 다릅니다. 나란히 놓으면 역할이 분명해집니다.

항목분기 CPU월간 CSPU
발행 시점1, 4, 7, 10월 셋째 화요일2, 3, 5, 6, 8, 9, 11, 12월 셋째 화요일
연간 횟수4회8회
범위전 제품군 전수고우선 항목만 선별
규모수백 건 (4월 450건)소규모 (5월 35건)
누적 여부누적 (최신본 하나면 됨)비누적 (개별 적용)
사전 예고발행 전 목요일발행 전 목요일

여기서 DBA가 놓치면 안 되는 한 가지가 있습니다. CSPU는 비누적이지만, 다음 분기 CPU가 그동안의 CSPU 수정을 모두 빨아들인다는 점입니다. 즉 CSPU를 적용하지 않고 넘어갔더라도 다음 분기 CPU를 적용하면 그 사이 CSPU 수정이 따라옵니다 — 다만 그때까지 노출 창이 길어질 뿐입니다.

5월 28일, 첫 CSPU의 내용

첫 CSPU는 새 CVE 35건을 담았습니다(Oracle Security Blog). 분기 CPU의 수백 건과 비교하면 의도적으로 작은 묶음입니다. 제품군별 분포는 다음과 같습니다.

제품군패치 수
Oracle E-Business Suite12
Oracle REST Data Services (ORDS)11
Oracle Communications Unified Assurance8
Oracle Database Server3
Oracle Hospitality OPERA 51

심각도가 가볍지 않습니다. 가장 눈에 띄는 건 ORDS의 CVE-2026-46840으로, CVSS 10.0 만점입니다. 기밀성, 무결성, 가용성이 모두 완전히 무너지는 등급입니다. E-Business Suite에도 CVSS 9.8~9.9급이 여러 건 있었고, Hospitality OPERA 5의 CVE-2026-34311은 9.8이었습니다.

집계 기준도 짚어 둘 필요가 있습니다. 새 CVE는 35건이지만, Communications 제품군에 들어간 서드파티 컴포넌트 CVE까지 합치면 이번 CSPU가 처리한 취약점은 모두 77건입니다(SecurityWeek). 그중 상당수는 인증 없이 원격으로 악용할 수 있었습니다. ORDS 7건, Communications 4건, E-Business Suite/Database Server 각 3건이 인증 없는 원격 공격에 그대로 열려 있었습니다.

묶음이 작다고 가벼운 것은 아닙니다. 오히려 분기까지 기다릴 수 없다고 Oracle이 판단한 항목만 골라 담았기 때문에, 평균 심각도는 분기 CPU보다 높다고 봐야 합니다.

20년 리듬을 깬 배경

이 블로그가 주목하는 지점이 여기입니다. Oracle이 20년 넘게 지켜 온 분기 리듬을 굳이 깬 배경에는, 취약점이 발견되는 속도 자체가 달라졌다는 인식이 깔려 있습니다.

Oracle Database 업그레이드를 총괄하는 Mike Dietrich(마이크 디트리히)는 이 변화를 AI 기반 위협에 대한 대응으로 명시했습니다. 그는 AI 모델이 이전과 비교가 안 되는 속도로 취약점을 찾아내고 있으며, Oracle 자체도 Anthropic과 OpenAI의 모델을 취약점 탐지에 활용하고 있다고 밝혔습니다. 발견 속도가 빨라지면 노출 창을 그만큼 줄여야 한다는 게 그의 논리입니다.

이건 지난달 Copy Fail 글에서 다룬 흐름과 정확히 같은 줄기입니다. AI 코드 감사기가 10년 묵은 커널 버그를 한 시간 만에 찾아낸 사건은, 방어자에게 한 가지를 분명히 일러 줬습니다. 오늘 멀쩡해 보이는 코드가 내일도 멀쩡하리라고 가정할 수 없다는 것입니다. 취약점이 더 빨리, 더 많이 드러나는 시대에는 패치 사이클도 그만큼 짧아져야 합니다.

분기에서 월간으로의 전환은 그 압력에 벤더가 내놓은 응답입니다. 발견과 패치 사이의 간격을 구조적으로 좁히려는 시도입니다.

[과거] 분기 모델
취약점 발견 ───────── 최대 3개월 ─────────▶ CPU 적용
(열린 창)

[현재] 월간 + 분기 모델
취약점 발견 ─── 최대 1개월 ───▶ CSPU 적용
(좁힌 창)

DBA가 지금 해야 할 일

패치 주기가 4배로 잦아졌다는 건, 운영 부담도 그만큼 늘었다는 뜻입니다. 12번을 모두 같은 속도로 따라가면 검증과 변경 관리가 무너집니다. 그래서 차선을 나눠 대응해야 합니다.

1) 패치 차선을 risk로 나눈다

모든 CSPU를 같은 속도로 처리할 필요는 없습니다. 인터넷에 노출된 ORDS/E-Business Suite처럼 공격 표면이 넓은 자산은 긴급 차선, 내부망 전용 DB는 표준 차선으로 분류합니다. 5월 CSPU의 ORDS CVSS 10.0 같은 항목은 자산 분류와 무관하게 즉시 차선입니다.

2) T-5 목요일 예고에 계획을 건다

Oracle이 발행 5일 전 목요일에 미리 알려 주므로, 화요일 발행을 기다리지 말고 목요일 예고로 영향 자산을 먼저 추립니다. 발행 당일에 시작하면 이미 늦습니다.

3) 회귀 테스트를 가볍고 결정론적으로 만든다

월 1회 리듬에서는 매번 광범위한 수동 검증을 붙일 여유가 없습니다. 핵심 쿼리, 배치, 연동 지점만 자동으로 돌려 확인하는 최소 세트를 미리 갖춰 둬야 패치 주기를 따라갈 수 있습니다.

4) 우회책에 기대지 않는다

Dietrich의 표현을 빌리면, virtual patching처럼 임시방편에 의존하는 건 보안이 아니라 심리적 위안에 가깝습니다. 결국 정식 패치를 빠르게 적용할 수 있는 체계를 만드는 게 유일한 답입니다.

이 변화가 남기는 신호

이번 변화의 의미는 단순히 "Oracle 패치가 잦아졌다"가 아닙니다. 보안 패치의 기본 단위가 분기에서 월로 바뀌는 흐름이 메이저 DB 벤더에서 시작됐다는 신호입니다.

Microsoft는 오래전부터 매달 둘째 화요일 Patch Tuesday를 돌려 왔습니다. 리눅스 배포판들은 CVE가 뜨면 며칠 안에 안정 커널을 내놓습니다. 분기라는 긴 호흡을 고수하던 Oracle마저 월간 차선을 열었다는 건, AI가 취약점 발견 속도를 끌어올린 환경에서 분기 주기로는 더 이상 버틸 수 없다는 업계의 공통 인식에 합류했다는 뜻입니다.

DBA에게 이 흐름의 결론은 명확합니다. 패치를 분기에 한 번 몰아서 하는 행사가 아니라 상시로 도는 프로세스로 다시 설계해야 합니다. CSPU는 그 전환을 강제하는 첫 신호예요.

정리

  • Oracle이 분기 CPU에 더해 매달 셋째 화요일 CSPU를 발행하기 시작했습니다. 첫 릴리스는 2026년 5월 28일입니다.
  • CSPU는 CPU를 대체하지 않고 사이 달을 메우는 소규모/고우선 패치입니다. 분기 CPU는 누적, CSPU는 비누적입니다.
  • 첫 CSPU는 CVE 35건, ORDS의 CVSS 10.0(CVE-2026-46840)을 포함한 고심각도 항목 중심이었습니다.
  • 전환의 배경은 AI가 끌어올린 취약점 발견 속도 — 발견과 패치 사이 창을 구조적으로 좁히려는 응답입니다.
  • DBA는 패치를 분기 행사가 아니라 상시 프로세스로 재설계하고, risk 기준으로 차선을 나눠야 합니다.

참고

PG19 Beta 핵심 변화 4가지

· 약 11분

Big 4를 한눈에

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

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

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

왜 이 Big 4인가

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

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

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

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

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

무엇이 바뀌었나

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

DBA가 왜 체감하나

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

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

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

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

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

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

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

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

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

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

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

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

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

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

DBA가 할 일

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

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

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

무엇이 바뀌었나

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

DBA가 왜 체감하나

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

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

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

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

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

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

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

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

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

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

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

DBA가 할 일

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

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

무엇이 바뀌었나

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

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

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

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

DBA가 왜 체감하나

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

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

주의: row trigger/cascading FK

Pettus가 직접 짚습니다.

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

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

내부적으로 FOR PORTION OF는 원본 row를 잘라 새 row 두 개(또는 세 개)로 분리하고, 그중 하나에 UPDATE를 적용합니다. 그 결과 DELETE 트리거가 원본 row에, INSERT 트리거가 새 보존 row 둘에, UPDATE 트리거가 갱신된 부분 row에 각각 발화합니다.

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

DBA가 할 일

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

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

무엇이 바뀌었나

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

DBA가 왜 체감하나

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

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

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

"a six-minute report now takes nineteen."

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

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

DBA가 할 일

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

왜 방향이 바뀌었는가

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

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

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

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

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

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

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

-- 3. 새 GUC 확인
SHOW autovacuum_max_parallel_workers;

정리

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

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

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

참고

PG19 동적 wal_level

· 약 7분

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

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

보험성 wal_level=logical 의 비용

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

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

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

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

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

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

PG19의 configured wal_level과 effective wal_level

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

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

규칙은 단순합니다.

상태effective wal_level
logical replication slot이 하나 이상 살아있음logical
streaming replication 연결만 있음replica 또는 configured 중 높은 쪽
아무것도 없음configured 값 그대로

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

상태 전이도

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

동작 단계

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

올라갈 때: logical로의 전이

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

내려갈 때: replica로의 하강

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

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

운영 영향

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

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

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

함정과 주의사항

네 가지를 짚습니다.

첫째, cascading standby의 비대칭입니다. primary와 standby는 각자 effective level을 따로 가집니다. primary의 마지막 logical slot이 사라져도 standby는 자신의 슬롯이 살아있는 한 effective logical을 유지합니다. primary가 보내는 WAL은 그새 replica로 내려갈 수 있지만, standby는 자기 슬롯을 위해 그 WAL을 logical로 받아야 합니다. 이 경계는 운영자에게 보이지 않는 곳에서 정렬됩니다. PG19가 알아서 잘 처리하지만, replication lag를 분석할 때 헷갈리지 않으려면 "두 노드의 effective level은 따로 움직인다"는 사실을 머리에 둬야 합니다.

둘째, archive에 mixed-level WAL이 섞입니다. 같은 archive 디렉토리에 replica 시기의 WAL과 logical 시기의 WAL이 함께 쌓입니다. PostgreSQL은 segment마다 metadata로 어떤 레벨인지 표시해두지만, "이 archive는 통째로 logical이다" 같은 가정을 두고 작성한 PITR script가 있다면 다시 봐야 합니다.

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

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

PITR / archive script 점검 포인트

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

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

WAL 볼륨 모니터링에서는 PG19로 올린 직후 WAL 생성률이 줄어드는 변화를 정상으로 인식하는지 확인합니다. 감소를 장애로 판단해 알람을 보내지 않도록 해야 합니다. cascading 구성에서는 primary, intermediate, leaf의 effective level이 각각 어떻게 정렬되는지 그림으로 한 번 그려두는 편이 좋습니다.

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

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

정리

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

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

참고 자료