본문으로 건너뛰기

"성능" 태그로 연결된 8개 게시물개의 게시물이 있습니다.

모든 태그 보기

PG 병렬 집계 공유 해시

· 약 7분

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

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

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

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

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

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

직접 재 봤다

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

균등 분포에서는 통한다

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

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

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

skew에서 무너진다

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

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

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

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

세 가지 장애물

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

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

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

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

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

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

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

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

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

그럼 어디에 쓸까

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

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

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

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

남는 생각

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

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

참고

PostgreSQL UUID v7 PK

· 약 5분

primary key를 UUID로 쓰기로 하면 대개 v4를 씁니다. 그런데 그 선택이 index를 얼마나 부풀리는지 숫자로 본 적은 없었어요. pgEdge의 Shaun Thomas(숀 토머스)가 The Time Traveler's Primary Key에서 그 숫자를 냈고, PostgreSQL 18 컨테이너로 직접 재현해 봤습니다.

v4가 index를 흩어 놓는 방식

B-tree는 정렬된 구조입니다. 새 키가 들어오면 그 값이 들어갈 자리를 찾아 해당 leaf page에 씁니다.

시퀀스로 만든 키는 항상 오른쪽 끝으로 갑니다. 마지막 page를 채우고, 차면 새 page를 붙입니다. page 하나가 거의 꽉 찬 상태로 남습니다.

UUID v4는 값이 완전히 무작위입니다. 원문 표현대로 "생성된 각 값이 맨 첫 행보다 앞에 정렬될 확률과 맨 마지막 행보다 뒤에 정렬될 확률이 같습니다". 그래서 삽입이 index 전체에 흩어집니다. 이미 꽉 찬 page 가운데에 값이 들어오면 page split이 일어나고, split된 두 page는 각각 절반씩만 채워집니다. 같은 개수의 키를 담는 데 page가 두 배로 필요해집니다.

UUID v7은 상위 48비트에 밀리초 단위 Unix 타임스탬프를 최상위 비트부터 담습니다. version 정보 뒤에 12비트를 더해 밀리초 이하 정밀도까지 표현합니다. 남는 약 62비트가 무작위입니다. 결과적으로 나중에 만든 값이 앞서 만든 값보다 크게 정렬됩니다. 시퀀스와 같은 삽입 패턴이 됩니다.

직접 재 봤다

PostgreSQL 18에 uuidv7()uuidv4()가 내장되어 있습니다. 기본 설정 컨테이너로 세 가지 테이블에 100만 행씩 넣었습니다.

create table t_bigint (id bigserial primary key, payload text);
create table t_v4 (id uuid primary key, payload text);
create table t_v7 (id uuid primary key, payload text);

insert into t_bigint (payload) select 'x' from generate_series(1,1000000);
insert into t_v4 (id,payload) select uuidv4(),'x' from generate_series(1,1000000);
insert into t_v7 (id,payload) select uuidv7(),'x' from generate_series(1,1000000);

크기를 뽑았습니다.

relname | heap | idx
----------+-------+-------
t_bigint | 42 MB | 21 MB
t_v4 | 50 MB | 37 MB
t_v7 | 50 MB | 30 MB

원문이 낸 수치가 v4 index 38MB, v7 30MB였는데 거의 그대로 나왔습니다. index만 놓고 보면 v7이 v4보다 19% 작습니다.

이유를 직접 확인하려면 pgstattuplepgstatindex()를 봅니다.

select 'v4' as k, avg_leaf_density, leaf_fragmentation from pgstatindex('t_v4_pkey')
union all
select 'v7', avg_leaf_density, leaf_fragmentation from pgstatindex('t_v7_pkey')
union all
select 'bigint', avg_leaf_density, leaf_fragmentation from pgstatindex('t_bigint_pkey');
k | avg_leaf_density | leaf_fragmentation
--------+------------------+--------------------
v4 | 72.61 | 49.86
v7 | 89.98 | 0
bigint | 90.01 | 0

여기가 이 실험에서 가장 선명한 부분입니다. v4의 leaf 밀도가 72.61%이고 단편화가 49.86%입니다. v7은 밀도 89.98%에 단편화 0입니다. bigserial의 90.01%와 사실상 같습니다. v7의 삽입 패턴이 시퀀스와 구분되지 않는다는 뜻입니다.

원문 수치가 71.53%와 49.89%, 89.98%와 0.00%였으니 소수점 자리까지 맞았습니다.

heap은 줄지 않는다

표에서 놓치기 쉬운 칸이 heap입니다. t_bigint가 42MB, UUID 두 개는 50MB로 같습니다. UUID는 128비트, bigint는 64비트니까 행마다 8바이트가 더 붙습니다. v7으로 바꿔도 이 차이는 그대로입니다.

정리하면 v7이 개선하는 것은 index의 공간 효율과 삽입 시 page split이고, 값 자체의 크기는 아닙니다. bigint와 UUID 사이의 선택은 여전히 별개 문제입니다. 원문도 이 부분을 분명히 합니다. 데이터가 단일 노드에 있고, 하나의 primary 인스턴스가 키를 발급하고, 키가 외부로 돌아다니지 않는다면 BIGINT GENERATED ALWAYS AS IDENTITY가 최선이라고 적었습니다. 위 실측에서도 bigint index가 21MB로 v7의 70% 수준입니다.

타임스탬프가 공개된다

v7의 대가는 성능이 아니라 정보입니다. 상위 비트가 생성 시각이라서 값에서 그대로 읽힙니다. PostgreSQL 18은 그 추출 함수를 내장으로 제공합니다.

select id, uuid_extract_timestamp(id) from t_v7 limit 2;
01a03337-728f-7bf2-bdf8-69d062bfa627|2026-08-24 10:01:06.959+00
01a03337-7290-7767-bc60-69fb35dd6c00|2026-08-24 10:01:06.96+00

버전도 확인됩니다.

select uuid_extract_version(id) from t_v7 limit 1; -- 7

이게 뜻하는 바가 셋입니다. 키를 받은 쪽이 그 레코드의 생성 시각을 밀리초 단위로 압니다. 키 두 개를 비교하면 그 사이 생성 간격을 알 수 있어서 생성 속도가 측정됩니다. 그리고 값이 시간순으로 인접하니 이웃 키를 추측해 보는 것이 무작위 값보다 훨씬 그럴듯해집니다.

주문 번호나 사용자 ID를 URL에 노출하는 구조라면 이 세 가지가 실제 문제가 됩니다. 가입 시각, 하루 주문 건수, 경쟁사 성장 속도가 키에서 읽힙니다. 원문도 생성 시각과 삽입 속도가 비공개여야 하는 경우에는 v4가 여전히 적합하다고 적었습니다.

오른쪽 끝이 경합 지점이 된다

또 하나 짚어 둘 만한 대가가 있습니다. v7의 삽입이 index 오른쪽 끝으로 몰린다는 것은 곧 그 page가 모든 writer의 경합 지점이 된다는 뜻입니다.

샤딩된 환경이나 병렬 쓰기가 많은 구성에서는 이게 실제 병목이 될 수 있습니다. v4는 흩어져서 비효율적인 대신, 쓰기를 고르게 분산합니다. 단일 노드에서 순차 삽입이 주된 패턴이라면 v7이 유리하고, writer가 많고 오른쪽 끝 경합이 이미 보이는 환경이라면 계산이 달라집니다.

어떻게 고를까

세 선택지의 성격을 정리하면 이렇습니다.

index 효율분산 생성생성 시각 비공개크기
bigserial / identity가장 좋음 (21MB)어려움유지8바이트
UUID v4나쁨 (37MB, 단편화 50%)지원유지16바이트
UUID v7좋음 (30MB, 단편화 0)지원노출16바이트

이미 UUID v4를 쓰고 있고 분산 생성이 필요해서 그렇게 한 것이라면, v7으로 옮기는 건 대체로 이득입니다. index가 작아지고 page split이 사라지는 대신 잃는 것은 생성 시각의 비공개성뿐입니다. 그 시각이 민감하지 않다면 바꿀 이유가 충분합니다.

반대로 UUID를 쓰는 이유가 "ID를 추측할 수 없게 하려고"였다면 v7은 그 목적과 충돌합니다. v7의 무작위 비트가 62비트라 값 자체를 맞히기는 여전히 어렵지만, 시간순 인접성 때문에 열거 시도의 탐색 공간이 좁아집니다.

새로 만드는 테이블이고 단일 노드라면 bigint가 답입니다. 위 실측에서 index가 v7의 70%, heap이 84%였고 그게 전부 실제 I/O입니다. UUID를 고르는 이유는 성능이 아니라 분산 생성이나 외부 노출이니까요.

참고

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만 재생하며 접속을 거부합니다. 용량을 더하려고 만든 노드가 용량이 되어 주지 않는 역설입니다.

지금 확인할 것들

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

먼저 지금 이 순간 subtransaction을 쌓고 있는 backend가 있는지 확인합니다. PostgreSQL 14부터 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 블록은 공짜가 아니고, 비용 청구서는 옆 세션으로 날아갑니다. 저는 이번 주에 저희 클러스터에도 위 진단 쿼리 두 개를 모니터링에 추가해 두려고 합니다.

참고 자료

pgrust 쿼리 엔진

· 약 4분

PostgreSQL로 분석 쿼리를 돌려 본 사람은 다들 한 번쯤 궁금해했을 거예요. 같은 데이터를 두고 왜 컬럼 스토어 엔진들과 이렇게까지 차이가 날까요. 인덱스나 통계의 문제가 아니라 더 아래층의 문제라는 감은 오는데, 그 아래층이 정확히 뭘까요.

7월 초 regression test 전체 통과 소식으로 화제가 됐던 Rust 재작성 프로젝트 pgrust가 7월 30일 v0.2를 릴리스하면서 성능 주장을 들고 나왔고, 며칠 뒤 저자가 그 성능이 어디서 나왔는지 해설한 글을 올렸습니다. 해설 글이 Hacker News 339점을 받으며 이 기간 PostgreSQL 관련 글 중 가장 크게 회자됐는데, pgrust 홍보를 걷어내고 봐도 PostgreSQL executor가 어디서 시간을 쓰는지 보여주는 교재로 훌륭합니다.

Volcano 모델: 우아함의 청구서

PostgreSQL의 executor는 Volcano 모델(iterator 모델)입니다. 실행 계획의 각 노드가 next()를 구현하고, 부모 노드가 자식에게 next()를 호출하면 행이 한 번에 하나씩 위로 올라옵니다.

이 모델의 장점은 우아함입니다. 어떤 연산자든 같은 인터페이스로 조립되고, 중간 결과를 통째로 메모리에 들 필요가 없습니다. OLTP처럼 행 몇 개를 만지는 쿼리에서는 완벽합니다.

문제는 5억 행을 집계할 때입니다. 행 하나마다 함수 호출 체인이 반복되니 호출 오버헤드가 5억 번 쌓이고, CPU 입장에서는 분기가 많고 예측이 어려운 코드가 됩니다. 파이프라이닝과 캐시가 일을 못 합니다. 저자의 측정에서 5억 행 합계 쿼리가 PostgreSQL에서 약 20초 걸렸는데, 이 시간 대부분이 데이터가 아니라 행을 나르는 절차에 쓰인 셈입니다.

세 단계 처방

해설 글은 같은 쿼리를 세 단계로 빠르게 만듭니다. 측정 환경은 AWS c8g.4xlarge(Graviton4, 16 vCPU), PostgreSQL 18.4 대비, 병렬 실행은 꺼서 엔진 구조 차이만 비교했습니다.

단계실행 시간직전 대비
Volcano 방식 재구현1.3초기준
+ batching (1024행)480ms2.7배
+ operator fusion358ms1.3배
+ SIMD135ms2.7배

Batchingnext()next_batch()로 바꿔 1024행씩 나르는 것입니다. 함수 호출 오버헤드가 1/1024로 줄고, 루프가 단순해져 CPU가 파이프라이닝할 수 있는 코드가 됩니다. 이 한 가지만으로 2.7배입니다.

Operator fusion은 인접한 노드를 하나로 합치는 것입니다. sequential scan과 aggregation이 별도 노드면 batch를 만들어 넘기고 다시 읽는 복사가 생기는데, 합치면 스캔하면서 바로 집계합니다. 중간 버퍼가 사라집니다.

SIMD는 마지막 단계입니다. batch로 정렬된 데이터는 벡터 연산에 이상적인 모양이라, ARM64 벡터 명령으로 덧셈을 처리하면 일반 루프보다 3배 빨라집니다. batching이 먼저 있었기에 가능한 최적화입니다. 행 단위로 흩어진 데이터에는 SIMD를 적용할 자리가 없습니다.

결과적으로 20초가 135ms로, 약 150배입니다. 릴리스 노트는 여기에 columnar 저장 포맷까지 얹어 ClickBench 종합에서 ClickHouse를 앞선다고 주장합니다. 이 주장에는 유보를 달아 두는 게 좋겠습니다. 자체 벤치마크이고, 독립 검증이 쌓이려면 시간이 필요합니다. OLTP 쪽 30% 향상 주장도 마찬가지입니다.

PostgreSQL은 이걸 몰랐을까

당연히 알고 있습니다. 이 글이 보여준 처방은 학계와 업계에서 오래된 레시피입니다. MonetDB/X100 계열 연구가 20년 전에 벡터화 실행을 정립했고, DuckDB와 ClickHouse가 그 위에 서 있습니다.

PostgreSQL 본가에도 부분 부분 들어와 있습니다. JIT 컴파일이 expression 평가를 통째로 컴파일해 호출 오버헤드를 줄이고, executor 내부에 부분적인 batch 처리가 도입되는 흐름도 있습니다. 다만 35년 된 C 코드베이스에서 executor의 기본 단위를 행에서 batch로 바꾸는 것은, 백지에서 Rust로 다시 짜는 것과는 전혀 다른 난이도입니다. pgrust가 유리한 것은 Rust여서라기보다 백지여서입니다.

그래서 이 프로젝트를 보는 제 관점은 "PostgreSQL의 대체재가 나타났다"가 아닙니다. 재미있는 것은 두 가지입니다. 하나, PostgreSQL 호환(문법, 프로토콜, regression test)을 유지하면서 executor만 현대식으로 갈아 끼우면 어디까지 가는지 보여주는 실험이라는 점. 둘, vacuum이 미운 날에서 다뤘던 것과 같은 질문, 즉 PostgreSQL의 설계 결정들이 어떤 트레이드오프였는지를 실물로 보여준다는 점입니다. Volcano 모델은 게으름이 아니라 1980년대의 합리적 선택이었고, 그 청구서가 분석 워크로드에서 날아오고 있을 뿐입니다.

실무 관점의 결론은 소박하게 남겨 둘게요. 오늘 PostgreSQL에서 느린 집계를 빠르게 하는 검증된 길은 여전히 병렬 쿼리, JIT, 사전 집계, 그리고 필요하면 분석 전용 엔진의 병행입니다. pgrust는 북마크해 두고, regression test 통과라는 출발점이 성능 주장 이후에도 유지되는지 지켜보겠습니다.

참고 자료

DuckDB v2.0 비동기 I/O

· 약 4분

작년에 PostgreSQL 18의 비동기 I/O를 다루면서, DB 엔진들이 하나둘 동기 I/O와 결별하는 중이라고 썼어요. 이번엔 DuckDB 차례입니다. 7월 31일 공식 블로그 글이 가을 출시 예정인 v2.0의 비동기 I/O 구조와 벤치마크를 공개했는데, 숫자가 눈에 띄어서 정리합니다.

문제: 대역폭이 아니라 대기가 병목

DuckDB의 기존 실행 모델은 CPU 스레드당 워커 하나입니다. 로컬 NVMe에서는 이걸로 충분합니다. 읽기 지연이 짧아서 워커가 I/O를 기다리는 시간이 얼마 안 되기 때문입니다.

S3 같은 오브젝트 스토리지에서는 사정이 다릅니다. HTTP 요청 하나의 지연이 수십 ms 단위라, 동기 방식으로는 워커가 요청을 보내고 응답을 기다리며 잠들어 있는 시간이 압도적으로 깁니다. 결과적으로 동시 요청 수가 부족해서 네트워크 대역폭을 채우지 못합니다. 실측에서 기존 버전(v1.5.5)은 25 Gbit/s를 쓸 수 있는 인스턴스에서 5 Gbit/s밖에 못 썼습니다. 인프라는 놀고 쿼리는 느린, 돈이 새는 구간입니다.

구조: 워커 풀과 I/O 풀의 분리

v2.0은 스레드 풀을 둘로 나눕니다.

크기역할
REGULARCPU 스레드당 1개 (기본)디코딩, 조인, 집계 등 실제 연산
ASYNC시스템 스레드의 4배, 최대 256개 (기본)블로킹 I/O 전담

ASYNC 풀을 CPU 수보다 훨씬 크게 잡을 수 있는 이유는 이 스레드들이 대부분의 시간을 HTTP 응답 대기로 보내기 때문입니다. CPU를 거의 안 쓰니 수백 개를 띄워도 부담이 없고, 그만큼 동시 요청 수가 올라가 네트워크 대역폭이 채워집니다.

여기에 read-ahead가 얹힙니다. 정규 워커가 데이터를 소비하는 속도보다 앞서서 fetch 작업을 큐에 넣어 두는 방식입니다. 작업 단위는 Parquet이면 row group, CSV면 고정 바이트 범위입니다.

미리 받아 두는 만큼 메모리를 먹으니 제어 장치도 있습니다. read_ahead_depth 설정이 기본 -1(무제한, 메모리 예산으로만 제한)이고, 양수로 제한하거나 0으로 끌 수 있습니다. 메모리가 부족해지면 임시 메모리 관리자와 협상해 큐 크기를 스스로 줄입니다.

숫자: Parquet 3배, CSV 19배

공개된 벤치마크는 EC2 r7i.16xlarge에서 S3의 TPC-H SF100 데이터를 읽는 구성입니다.

워크로드v1.5.5v2.0.0-dev배율
S3 Parquet 읽기8.230초2.844초2.9배
S3 CSV 읽기 (80.89GB)877.563초45.264초19.4배
로컬 디스크 (M4 MacBook Pro)1.321초0.883초1.5배

CSV의 19배가 특히 극적인데, 뒤집어 보면 기존 CSV 리더가 원격 스토리지에서 그만큼 직렬화되어 있었다는 뜻입니다. 네트워크 사용률은 5 Gbit/s에서 25 Gbit/s 포화로 올라갔습니다.

동시성 수치도 흥미롭습니다. 쿼리 4개를 동시에 돌렸을 때 v1.5.5는 평균 5.9코어(활용률 6%)를 쓰며 35.8초가 걸렸고, v2.0.0-dev는 48.1코어(75%)를 쓰며 15.6초에 끝냈습니다. I/O 대기에 묶여 있던 CPU가 풀려난 그림입니다.

제한도 명시되어 있습니다. 현재 구현된 것은 Parquet과 비압축 UTF-8 CSV뿐이고, JSON과 DuckDB 네이티브 포맷은 추후 예정입니다. row group이 거대해서 파일 안 병렬성이 부족한 경우에는 효과가 줄어듭니다.

PostgreSQL 18과 나란히 놓고 보면

같은 문제를 두 엔진이 어떻게 다르게 푸는지 비교하면 각자의 처지가 보입니다.

PostgreSQL 18DuckDB v2.0
대상 I/O로컬 디스크 (heap 읽기)원격 오브젝트 스토리지
방식io_method (worker / io_uring)ASYNC 스레드 풀 + read-ahead
단위블록row group / 바이트 범위
배경17년 만의 아키텍처 전환분석 엔진의 클라우드 이행

PostgreSQL은 커널 인터페이스(io_uring)까지 내려가 로컬 블록 I/O를 비동기화했고, DuckDB는 HTTP 위의 원격 읽기를 스레드 물량으로 병렬화했습니다. 방식은 달라도 결론은 같습니다. 스토리지가 어디에 있든, 워커가 I/O를 기다리며 잠드는 구조는 더 이상 용납되지 않는다는 것입니다.

같은 글에서 DuckDB는 v2.0과 함께 가는 방향도 살짝 내비쳤습니다. 8월 5일 40,000 스타 기념 글에서는 다중 동시 쓰기를 지원하는 원격 프로토콜 Quack까지 언급했는데, 임베디드 분석 엔진이라는 출발점에서 점점 멀어지는 중입니다. 오브젝트 스토리지 위의 분석 스택에서 DuckDB의 자리가 어디까지 커질지, v2.0이 나오면 직접 실행해 보고 후속으로 다루겠습니다.

참고 자료

PostgreSQL 18 비동기 I/O

· 약 10분

17년을 동기 I/O로 버텨온 데이터베이스

PostgreSQL은 1996년에 첫 릴리스가 나온 이래로 process-per-connection 모델 + 동기 I/O라는 단순한 조합을 17년 넘게 유지해왔어요. 클라이언트 하나가 붙으면 백엔드 프로세스 하나가 fork되고, 그 프로세스는 디스크에서 페이지를 읽을 때 read() 시스템 콜을 직접 호출해서 결과가 돌아올 때까지 그냥 멈춰서 기다려요.

이 모델은 단순함이 가장 큰 무기였습니다. fork 한 번이면 격리되고 신호 처리도 직관적이며, 잠금이나 컨텍스트 스위치 같은 까다로운 동시성 이슈도 적게 신경 써도 됩니다. MySQL이나 SQL Server가 thread-per-connection 모델로 가면서 동시성 버그와 평생을 싸우는 동안, PostgreSQL은 다른 길을 갔습니다.

문제는 2020년대의 NVMe와 클라우드 스토리지가 그 단순함의 대가를 점점 더 비싸게 만들고 있다는 점입니다.

PostgreSQL 18(2025-09-25 GA)은 17년 만에 처음으로 비동기 I/O 서브시스템을 들고 왔습니다. 이 글에서는 그 구조와 트레이드오프를 살펴봅니다.

동기 I/O의 정확한 병목

PostgreSQL이 디스크에서 데이터를 읽을 때의 기본 단위는 8KB 페이지 한 장입니다. Sequential Scan 한 번에 100만 페이지를 읽어야 한다면, 옛 모델은 이렇게 동작합니다.

Sequential Scan (cold cache):

read(page 1) → 디스크 응답 대기 (lat) → 처리
read(page 2) → 디스크 응답 대기 (lat) → 처리
read(page 3) → 디스크 응답 대기 (lat) → 처리
...

총 시간 ≈ 페이지 수 × 디스크 latency (직렬)

CPU는 매 페이지마다 디스크를 기다리며 놀고, NVMe는 큐가 대부분 비어 있는 채로 능력의 일부만 씁니다. 클라우드 EBS처럼 한 IOP의 latency가 수백 마이크로초인 환경에서는 이 직렬 누적이 잔인하게 드러납니다.

PostgreSQL도 손 놓고 있던 건 아닙니다. 17 버전까지는 OS의 readahead와 posix_fadvise(POSIX_FADV_WILLNEED) 정도로 커널에 "앞으로 이 영역을 읽을 테니 미리 좀 가져와"라고 힌트만 줬습니다. 동작은 하지만, PostgreSQL이 직접 I/O 깊이를 통제하지 못하기 때문에 어떤 페이지가 언제 도착할지를 알 수 없었습니다. 통계 기반 readahead는 패턴이 깨지면 무력해집니다.

"This feature allows backends to queue multiple read requests, which allows for more efficient sequential scans, bitmap heap scans, vacuums, etc."PostgreSQL 18 Release Notes, E.4.3.1.3

PostgreSQL 18의 한 줄 요지는 이렇습니다. "여러 read를 큐에 넣고 동시에 보냅니다." 페이지마다 멈추지 않고 100개를 한꺼번에 던져놓은 뒤, NVMe가 알아서 병렬로 처리하게 두자는 이야기입니다.

세 가지 io_method

PostgreSQL 18은 새 GUC 파라미터 io_method로 비동기 I/O 동작을 고릅니다. 세 가지 옵션이 있습니다.

모드어디서 동작환경 요건장점단점
sync메인 백엔드모든 OSPG17과 동일, 호환성 100%, 추가 프로세스 없음사실상 비동기 X, NVMe/EBS 큐 활용 못 함
workerI/O 워커 프로세스모든 OS (default)어디서든 돌고 sync 대비 1.6배, 운영 안정성 검증된 디폴트워커 프로세스 비용, 컨텍스트 스위치/메모리 카피 오버헤드
io_uring커널과 ring buffer 공유Linux 5.1+sync 대비 2.7배, 워커 없이 syscall 거의 0일부 배포판/컨테이너에서 seccomp으로 차단, ARM/macOS/BSD 불가

기본값은 worker입니다. 모든 운영체제에서 동작하면서도 동기보다는 확연히 빠르기 때문에 안전한 선택입니다. macOS, BSD 또는 io_uring을 못 쓰는 환경에서는 그대로 default로 두면 됩니다.

함께 추가된 GUC들도 알아둘 만합니다(공식 release notes).

io_workersworker 모드의 워커 프로세스 수이며 기본값은 3입니다. io_combine_limitio_max_combine_limit는 인접 read를 한 요청으로 합치는 한도입니다. effective_io_concurrency의 기본값은 1 → 16으로 올랐으며, 이 값은 사실상 "동시에 던질 read 수"입니다. maintenance_io_concurrency는 VACUUM 같은 유지보수 작업의 동시 I/O를 조절합니다.

fadvise()가 없는 OS에서도 effective_io_concurrency > 0이 의미를 갖게 됐다는 점도 조용히 큰 변화입니다. 이전에는 Linux 외 환경에서 이 파라미터가 사실상 장식이었습니다.

worker 모드

누군가 대신 디스크를 읽는 방식입니다.

worker의 구조는 의외로 깔끔합니다.

백엔드는 read 요청을 큐에 넣고 다른 일을 합니다. 별도의 I/O 워커 프로세스 풀이 큐에서 꺼내 실제 read() syscall을 호출하고, 결과를 공유 버퍼에 채워줍니다. 백엔드는 자기가 요청한 페이지가 필요한 시점에 공유 버퍼만 들여다보면 됩니다.

장점은 호환성입니다. POSIX read만 있으면 어디서든 동작합니다. 단점은 워커 프로세스 자체가 일종의 디스패처라서 컨텍스트 스위치와 메모리 카피 비용이 발생한다는 점입니다.

io_uring 모드

커널에 ring을 심는 방식입니다. io_uring은 Linux 5.1(2019년)에 들어온 커널 비동기 I/O 인터페이스입니다. PostgreSQL과 커널 사이에 공유 메모리 ring 두 개를 두고, 시스템 콜 없이 요청을 주고받습니다.

PostgreSQL 18 구현의 흥미로운 결정 하나는 ring 인스턴스를 backend마다 따로 둔다는 점입니다. 한 인스턴스를 여러 백엔드가 공유하면 lock contention이 생기므로 아예 격리했습니다. 다만 ring 자체는 fork 전에 postmaster가 미리 생성해서 shared memory에 올려둡니다(credativ deep-dive).

워커가 빠지므로 컨텍스트 스위치가 사라지고, syscall도 제출과 대기를 잘 묶으면 거의 0에 수렴합니다. 다만 Linux 5.1+ 전용이며, 일부 배포판은 보안 정책상 io_uring을 비활성화해두기도 합니다(예: 특정 컨테이너 런타임의 seccomp 프로파일).

pg_aios

PostgreSQL의 I/O 큐를 처음으로 들여다볼 수 있게 됐습니다. 운영자 입장에서 더 반가운 변화는 새 시스템 뷰 하나입니다. pg_aios는 진행 중인 비동기 I/O 요청을 그대로 보여줍니다.

SELECT pid, io_method, op, state, target, off, length
FROM pg_aios
ORDER BY pid, off
LIMIT 20;

지금까지 PostgreSQL의 I/O는 거의 블랙박스였습니다. pg_stat_io(PostgreSQL 16에서 들어옴)는 누적 통계를, pg_stat_activity는 wait event 정도를 보여줬습니다. "바로 지금 어떤 read가 큐에 떠 있는가"는 이제야 들여다볼 수 있게 됐습니다.

여기서 잡히는 정보로 특정 파일에 I/O가 몰리는 hot relation을 식별하거나, io_uring이 큐를 정말 깊게 쓰고 있는지를 확인할 수 있습니다.

벤치마크

숫자로 성능 차이를 살펴봅니다.

pganalyze 벤치마크는 AWS c7i.8xlarge에서 3.5GB 테이블의 cold scan을 측정했습니다.

버전 / 모드실행 시간대 PG17대 PG18 sync
PostgreSQL 17 (sync)15,830 ms기준해당 없음
PostgreSQL 18 sync15,071 ms-5%기준
PostgreSQL 18 worker10,051 ms-37%-33%
PostgreSQL 18 io_uring5,723 ms-64%-62%

cold cache Sequential Scan에서 io_uringPostgreSQL 17 대비 2.7배 빠릅니다. worker도 1.6배 빠릅니다. 같은 PostgreSQL 18을 sync로만 켜두면 거의 차이가 없다는 점도 중요한 신호입니다. 모드 선택이 곧 성능입니다.

이 향상이 어디서 오는지 한 줄로 정리하면, 디스크 latency를 여러 번 직렬로 치르던 것을 한 번 병렬로 치르게 된 것뿐입니다. NVMe는 원래 그렇게 쓰는 물건이었는데, PostgreSQL이 17년 만에 그 사용법을 익혔습니다.

한계

아직 비동기가 아닌 작업도 있습니다.

PostgreSQL 18 AIO는 읽기 작업 일부에만 적용됩니다.

작업PG18 AIO 적용?
Sequential Scan적용
Bitmap Heap Scan적용
VACUUM적용
ANALYZE (일부)적용
Index Scan random read아직 동기
WAL write미적용
Checkpoint write미적용

쓰기는 전부 동기 그대로입니다. 인덱스 random read도 들어가지 않았습니다. 즉 OLTP 점 쿼리 워크로드는 이번 변화로 직접 빨라지지는 않고, 이득은 분석 워크로드/대량 스캔/VACUUM에 몰려 있습니다.

PostgreSQL 19(2026-09 예정) 개발 트리에서는 인덱스 prefetch와 일부 쓰기 경로의 비동기화가 논의되고 있습니다. AIO는 PostgreSQL 18에서 끝난 게 아니라 시작된 것에 가깝습니다.

운영 관점

무엇을 켤지 선택하는 기준은 사실 단순합니다.

환경추천
Linux 5.1+ 직접 운영, io_uring 사용 가능io_uring
Linux지만 io_uring 비활성 배포판/컨테이너worker (default)
macOS / BSD / 구형 Linuxworker (default)
호환성 이슈 발생 시 임시 폴백sync

추가로 함께 조정할 만한 설정은 다음과 같습니다.

# postgresql.conf 예시 (분석 워크로드 기준)
io_method = io_uring
effective_io_concurrency = 32 # 기본 16에서 NVMe 깊이에 맞게 조정
maintenance_io_concurrency = 32 # VACUUM 가속
io_combine_limit = 256kB

effective_io_concurrency는 "동시에 던질 read 수"라고 생각하면 됩니다. NVMe 큐 깊이 / 동시 사용자 수에 맞춰 늘립니다. 너무 키우면 다른 백엔드와 디스크 대역을 놓고 다투게 되니, 분석 전용 인스턴스가 아니라면 기본 16에서 천천히 올립니다.

배포 전에는 다음 항목을 확인합니다.

  1. 커널 버전(uname -r): 5.1 미만이면 io_uring 불가
  2. seccomp/AppArmor 프로파일이 io_uring 시스템 콜을 막는지 확인
  3. pg_aios 뷰가 보이는지로 AIO 활성 검증
  4. cold scan 워크로드에서 EXPLAIN (ANALYZE, BUFFERS) 비교 측정

한국 커뮤니티 반응

GeekNews에는 이미 작년에 Postgres 18을 기다리며: 비동기 I/O로 디스크 읽기 속도 향상이 올라왔습니다. 댓글은 많지 않지만 톤은 거의 "드디어"에 가깝고, 관심사는 io_uring 보안 우려와 클라우드 NVMe에서의 실측 향상에 집중돼 있었습니다. 1년이 지난 지금 PostgreSQL 18이 GA된 상태에서 그 기대가 어느 정도 채워졌다고 봐도 됩니다.

정리

PG17 이하PG18
I/O 모델동기 (메인 백엔드 직접 syscall)동기 + worker + io_uring
모드 선택없음io_method GUC
가시성pg_stat_io (누적 통계)+ pg_aios (실시간 큐)
effective_io_concurrencyLinux fadvise 한정모든 OS, 기본 16
Cold scan 성능 (3.5GB)15.8초5.7초 (io_uring)
적용 범위Sequential/Bitmap Scan, VACUUM
미적용Index random read, WAL/Checkpoint write

PostgreSQL이 process-per-connection이라는 17년짜리 아키텍처 결정을 바꾸지 않고서도 비동기 I/O를 들였다는 점이 이번 변화의 진짜 핵심입니다. 백엔드 프로세스 모델은 그대로 두고 syscall 경계만 다시 그었습니다. 그래서 운영자 입장에서 마이그레이션이 거의 무료이며, io_method 한 줄만 바꾸면 됩니다.

다음 PostgreSQL 18 시리즈 글에서는 UUIDv7과 B-tree 인덱스의 관계를 다룰 예정이에요. 같은 "스토리지 효율"이라는 축에서, 이번에는 인덱스 페이지 분할 쪽 이야기를 해보려고 해요.

참고 자료

agnoster와 Starship

· 약 7분

어느 날 Enter가 느려졌다

터미널에서 Enter를 눌렀는데 다음 프롬프트가 뜰 때까지 300ms 넘게 비는 느낌이 왔어요. 큰 레포일 때는 그러려니 했지만 작은 디렉토리에서도 똑같이 굼떴고, "디렉토리 크기와 무관하게 느리다" — 이 사실이 첫 번째 단서였어요.

이 글은 그 지연의 정체를 찾아가서, 얕은 수정으로 안 됐고, 결국 프롬프트 테마를 갈아엎고 Starship으로 넘어온 여정의 기록입니다.

첫 시도: status를 빠르게 한다

처음에는 단순하게 접근했습니다. "Enter 뒤 지연 = git status가 느림"이라고 가정하고, 속도를 높이는 옵션부터 모두 켰습니다.

# ~/.zshrc
DISABLE_UNTRACKED_FILES_DIRTY="true"
# git 전역
git config --global core.fsmonitor true
git config --global core.untrackedCache true
git config --global feature.manyFiles true

fsmonitor는 파일 시스템 이벤트를 상주 데몬이 추적해 git status의 내부 스캔 비용을 사실상 0에 가깝게 만듭니다. 실제로 git status --porcelain 자체의 시간은 크게 짧아졌습니다.

레포.git 크기fsmonitor 적용 후 status
small-repo작음즉시
mid-repo-A30 MB0.025 s
mid-repo-B51 MB0.041 s
big-repo246 MB0.042 s

그런데 Enter 지연은 거의 그대로였습니다. git status가 10배 빨라졌는데도 체감이 달라지지 않았으니, 제가 엉뚱한 부분을 최적화했다는 뜻이었습니다.

측정을 다시 할 때 만난 세 가지 함정

문제를 다시 풀어헤치려면 먼저 제대로 측정해야 했습니다. 여기서 쉽게 빠지는 함정이 세 개 있었습니다.

함정 1. zsh -i -c exit는 per-Enter 지연이 아니다

흔히 쓰는 "쉘 벤치마크"인 time zsh -i -c exit는 전혀 다른 대상을 측정합니다. 쉘이 시작할 때 한 번만 드는 비용입니다. compinit, plugin 로딩 같은 일회성 작업을 포함합니다. 매번 Enter를 칠 때 드는 비용과는 다른 층이라, 이 값을 아무리 줄여도 프롬프트 재평가 비용은 그대로입니다.

함정 2. precmd를 직접 호출해도 안 된다

일반적인 zsh 테마는 precmd_functions에 훅을 걸어 매 프롬프트 직전에 준비 작업을 합니다. 그러나 agnoster는 다릅니다.

PROMPT='%{%f%b%k%}$(build_prompt) '

PROMPT 변수 안에 명령 치환 $(build_prompt)를 끼워 둔 구조라, 프롬프트 문자열이 expansion될 때 비로소 git 호출이 일어납니다. 그래서 precmd를 수동으로 실행해 재면 거의 0ms가 찍힙니다. 실제 Enter 경로와 다른 곳에서 측정하는 셈입니다.

함정 3. 반복마다 새 zsh를 띄우면 측정이 오염된다

# 나쁜 예
for i in {1..10}; do
time zsh -ic 'print -P "$PROMPT" >/dev/null'
done

이렇게 측정하면 매 반복이 zsh 시작 비용(~200ms)에 묻힙니다. 같은 zsh 프로세스 안에서 루프를 돌려야 순수 프롬프트 렌더 비용만 남습니다.

올바른 one-liner

결국 큰 레포로 cd 한 상태에서 다음과 같이 측정해야 했습니다.

zsh -ic '
print -P "$PROMPT" >/dev/null # 웜업
{ time (for i in {1..10}; do print -P "$PROMPT" >/dev/null; done) } 2>&1
'

print -P "$PROMPT"는 PROMPT 문자열을 강제로 expand시키기 때문에 내부의 $(build_prompt)가 실제로 실행되고, 진짜 비용이 드러납니다.

진짜 범인: agnoster의 7× git spawn

측정을 제대로 돌리자 범인이 또렷이 보였습니다. ~/.oh-my-zsh/themes/agnoster.zsh-themeprompt_git()은 매 프롬프트 렌더마다 git 서브프로세스를 일곱 번 띄웁니다.

1. git config --get oh-my-zsh.hide-status
2. git rev-parse --is-inside-work-tree
3. git rev-parse --git-dir
4. git status --porcelain (parse_git_dirty)
5. git symbolic-ref HEAD
6. git log --oneline @{upstream}.. (ahead)
7. git log --oneline ..@{upstream} (behind)

그리고 macOS의 기본 git 바이너리는 Apple wrapper입니다.

/Library/Developer/CommandLineTools/usr/libexec/git-core/git

호출 한 번당 fork → exec → wrapper → core 체인에 고정적으로 40~50ms가 듭니다. 이 오버헤드는 git이 아무리 빨라도 줄어들지 않습니다.

산수가 맞아떨어집니다.

7 × 50ms ≈ 350ms

그래서 .git이 수십 MB든 수백 MB든 바닥값은 비슷하게 나옵니다. 이게 Enter 지연의 정체였습니다.

측정 결과 (10회 평균)

상태big-repo (246MB .git)small-repo
agnoster 원본 (prompt_git 포함)~427 ms~335 ms
prompt_git를 빈 함수로 대체~14.7 ms~13.6 ms

프롬프트 지연의 95% 이상이 prompt_git의 spawn 오버헤드였습니다. git status 자체는 fsmonitor 덕분에 이미 충분히 빨랐습니다. 제가 status 내부를 최적화하느라 헛돈 까닭은 줄일 수 있는 구간만 보고 줄이려 했기 때문입니다. spawn 횟수는 그대로 7이었으니 체감이 달라지지 않은 게 당연했습니다.

세 가지 선택지

구조적 한계가 확인되자 길이 명확해졌습니다.

A. agnoster의 git 세그먼트를 끈다

git config --global oh-my-zsh.hide-status 1

혹은 zshrc 마지막에:

prompt_git() { : }

프롬프트 렌더 비용이 15ms로 떨어집니다. 대신 브랜치/dirty 표시가 사라집니다. 저는 브랜치를 프롬프트에서 보는 편이 좋아서 이 선택지는 제외했습니다.

B. powerlevel10k로 간다

gitstatusd라는 상주 데몬이 변경을 감시하며 결과를 파이프로 돌려줍니다. git 바이너리를 띄우지 않으므로 spawn은 0회입니다. instant prompt를 사용해 초기 표시도 빠릅니다.

괜찮은 옵션이지만, 설정을 직접 다듬기엔 .p10k.zsh 포맷이 다소 난잡해 보였습니다.

C. Starship으로 간다

Rust로 작성한 단일 바이너리입니다. zsh 프롬프트 훅에서 starship 바이너리를 한 번만 fork하고, 그 안에서 libgit2를 라이브러리로 링크해 호출합니다. 외부 git 프로세스는 띄우지 않습니다. 즉 spawn 1회로 끝납니다.

  • 설정은 ~/.config/starship.toml 한 파일 (TOML)
  • git diff로 리뷰/롤백이 쉬움
  • 프리셋 갤러리가 존재해 시작점 제공

풍부한 정보와 속도, 설정 가독성을 모두 고려해 Starship으로 결정했습니다.

Starship 전환 작업

설치

brew install starship
# 1.25.0

~/.zshrc 두 군데 수정

agnoster를 끄고:

- ZSH_THEME="agnoster"
+ # ZSH_THEME="agnoster" # 비활성, Starship 사용
+ ZSH_THEME=""

파일 끝에 초기화 추가:

# Starship prompt. agnoster 대신. 없으면 graceful skip.
if command -v starship >/dev/null 2>&1; then
eval "$(starship init zsh)"
fi

바이너리가 없으면 조용히 건너뛰도록 command -v 체크를 넣었습니다. 새 머신 셋업할 때 zshrc를 그대로 복사해도 안전합니다.

프리셋 적용

starship preset pastel-powerline -o ~/.config/starship.toml

pastel-powerline의 정보 밀도가 가장 괜찮아 선택했습니다. 이대로 써도 되지만 두 가지가 걸렸습니다.

  1. 한 줄 프롬프트라 긴 디렉토리/브랜치명이 나오면 명령 입력 공간이 좁아집니다.
  2. 다크 터미널에서 일부 세그먼트의 글자가 묻힙니다.

튜닝 1: 두 줄 프롬프트

format 문자열 끝(마지막 """ 직전)에 줄바꿈과 character 모듈을 추가합니다.

$line_break\
$character

파일 끝에는 [character] 섹션을 추가합니다.

[character]
success_symbol = "[❯](bold #FCA17D)"
error_symbol = "[❯](bold red)"
vimcmd_symbol = "[❮](bold green)"

이제 첫 줄에는 정보(유저/경로/git/언어/시간)를 표시하고, 두 번째 줄에는 하나만 둡니다. 명령 입력이 세그먼트에 영향받지 않습니다.

튜닝 2: 다크 터미널 가독성

기본 pastel-powerline은 각 세그먼트 배경색만 지정하고 글자색은 터미널 기본을 씁니다. 어두운 터미널(제 환경은 거의 검정 배경)에서는 짙은 배경 세그먼트 안의 글자가 잘 보이지 않았습니다.

각 세그먼트에 명시적 글자색과 bold를 지정했습니다.

세그먼트배경글자색
username#9A348E (보라)#ffffff bold
directory#DA627D (분홍)#1a1a2e bold
git_branch / git_status#FCA17D (피치)#1a1a2e bold
언어 버전 (golang 등)#86BBD8 (연파랑)#1a1a2e bold
docker_context#06969A (청록)#ffffff bold
time#33658A#4A86C5#ffffff bold

time 세그먼트의 원래 배경 #33658A는 너무 어두워서 거의 터미널 배경과 합쳐져 보였습니다. 더 밝은 파랑 #4A86C5로 바꾸고, format 문자열 안의 배경/전경 치환자 두 군데도 같이 교체했습니다.

bg:#33658A) → bg:#4A86C5)
fg:#33658A) → fg:#4A86C5)

Nerd Font 글리프가 많은 파일이라 직접 편집하면 글자가 깨지기 쉽습니다. 수정은 Python 스크립트로 바이트 안전하게 적용했습니다.

결과

속도 (20회 평균, 같은 zsh 내 print -P "$PROMPT")

상태big-repo (246MB .git)small-repo
agnoster 원본427 ms335 ms
Starship (pastel-powerline)~70 ms~60 ms

약 6배입니다. 인간 지각 한계(~100ms) 안쪽이라 체감상 즉시 반응합니다. Enter가 걸리던 감각이 완전히 사라졌습니다.

시각

실제 화면입니다(일부 모자이크).

Starship 프롬프트 실제 렌더링

표시 요소:

  • 사용자명 (흰 bold, 보라 배경)
  • 디렉토리 (3계층 축약)
  • git 브랜치 + 상태 (stashed $ / untracked ? / modified ! / renamed / ahead/behind 등)
  • 감지된 언어 버전 (위 예시엔 Go v7.3.2, Java v25.0.1)
  • 시간 (♥ HH:MM)
  • 두 번째 줄: 입력 커서

이 과정에서 배운 것

  1. 느린 걸 고치려면 먼저 제대로 재야 합니다. 제가 잰 zsh -i -c exit는 Enter 지연이 아니라 쉘 시작 시간이었습니다. 엉뚱한 걸 최적화하느라 며칠 돌아갔습니다.

  2. spawn 횟수는 내부 로직 속도와 독립입니다. git이 1ms에 끝나도 7번 띄우면 350ms가 듭니다. 개선 가능한 구간이 보여도, 그 부분이 병목인지부터 확인해야 합니다.

  3. 도구의 구조를 모르면 설정도 고치기 어렵습니다. agnoster가 PROMPT 변수 안에 명령 치환을 넣는 구조라는 걸 몰랐다면 precmd 벤치마크만 반복하며 "왜 안 느리지?"라고 했을 겁니다.

  4. 근본 전환이 결국 싸게 먹힐 때가 있어요. 보조 옵션을 쌓아도 못 넘는 한계가 있을 때는 테마 자체를 갈아치우는 편이 빨라요. agnoster → Starship은 설정 파일 두 개 수정 + brew install 한 번이었어요.

참고 자료

PG19 EXPLAIN RDTSC

· 약 5분

EXPLAIN ANALYZE의 불편한 진실

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

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

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

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

왜 느려지나

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

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

쿼리 실행 흐름:

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

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

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

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

clock_gettime()이 뭔데

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

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

clock_gettime() 호출 경로:

사용자 공간 → VDSO → TSC 레지스터 읽기 → 보정 → 반환

커널 진입 없이 실행되지만
보정 로직 때문에 여전히 비용 발생

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

TIMING FALSE라는 우회로

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

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

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

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

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

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

PostgreSQL 19의 해결책: RDTSC

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

RDTSC란

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

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

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

성능 비교

pg_test_timing으로 측정한 결과:

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

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

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

단일 쿼리에서 14ms라면 크지 않아 보이지만, 중첩 노드가 있거나 행 수가 더 많으면 오버헤드는 기하급수적으로 늘어납니다.

RDTSC vs RDTSCP

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

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

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

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

timing_clock_source 설정

새로운 timing_clock_source 파라미터로 클럭 소스를 제어할 수 있습니다.

-- 현재 설정 확인
SHOW timing_clock_source;

-- 변경 (postgresql.conf 또는 SET)
SET timing_clock_source = 'rdtsc'; -- 빠름, 약간 낮은 정밀도
SET timing_clock_source = 'rdtscp'; -- 높은 정밀도
SET timing_clock_source = 'system'; -- 기존 clock_gettime()

x86-64 CPU에서 해당 명령어를 지원하면 자동으로 RDTSC를 사용합니다. ARM 등 다른 아키텍처에서는 기존 방식이 유지됩니다.

주의사항

x86-64 전용

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

TSC 신뢰성

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

변화의 체감

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

역사: 6년간의 논의

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

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

정리

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

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

참고 자료