
플래너 통계와 EXPLAIN 읽는 법
- 6플래너 통계와 EXPLAIN 읽는 법읽는 중
- 7PostgreSQL 조인 알고리즘: Nested Loop, Hash Join, Merge Join
- 8PostgreSQL 병렬 쿼리와 파티셔닝
- 9VACUUM과 bloat의 정체: dead tuple은 누가 언제 치우는가
- 10트랜잭션 격리 수준과 락: 쓰기 충돌은 어떻게 해결되는가
users(email)에 인덱스를 걸어뒀는데 어떤 날은 Index Scan을 타고 어떤 날은 Seq Scan이 됩니다. EXPLAIN ANALYZE를 찍어보니 rows=1이라 예상한 자리에서 실제로는 84만 행이 나옵니다. EXPLAIN의 cost는 더없이 낮게 찍히는데 정작 쿼리는 2초가 걸립니다.
세 현상은 모두 플래너가 쿼리를 실제로 돌려보지 않고 pg_statistic에 담긴 숫자 몇 개로 비용을 추정한다는 사실에서 출발합니다. 플래너는 “이 조건을 걸면 몇 행이 남는가”를 통계 기반으로 계산하고, 그 추정치 위에 cost를 얹어 plan을 고릅니다. 통계가 현실과 어긋나는 순간 조인 순서, 인덱스 선택, 병렬 여부가 줄줄이 잘못 결정됩니다.
이 글에서는 플래너가 cost를 만드는 과정, EXPLAIN 출력의 각 숫자가 무엇을 뜻하는지, 그리고 추정치가 크게 틀어졌을 때 원인을 좁혀가는 법을 순서대로 살펴봅니다. 플래너가 저울에 올리는 후보 중 하나는 B-tree 인덱스를 타고 내려가는 경로이고, 다른 하나는 테이블을 처음부터 끝까지 훑는 경로입니다. 둘 중 어느 쪽이 싼지는 “조건에 걸리는 행이 몇 개냐”에 달려 있고, 그 숫자를 통계가 정합니다.
플래너가 하는 일
쿼리 한 건이 파서를 거쳐 트리 구조로 바뀌면, 플래너는 그 트리를 실행 가능한 plan으로 변환합니다. 같은 SQL이라도 실행 방법은 여러 가지입니다.
users테이블을 처음부터 끝까지 읽을지 (Seq Scan)users(email)인덱스를 타고 필요한 행만 찾을지 (Index Scan)orders와 조인할 때 Nested Loop와 Hash Join 중 어느 쪽을 쓸지
플래너는 각 후보에 cost라는 숫자를 매겨 가장 낮은 것을 고릅니다. 여기서 cost는 밀리초 단위의 실행 시간이 아니라 ”seq_page_cost를 1.0으로 놓았을 때의 상대적인 비용”입니다. 절대값으로 해석하면 안 되고, 같은 쿼리 안에서 plan끼리 비교할 때만 의미가 있다고 기억해두면 좋습니다.
cost를 만드는 데 쓰이는 주요 파라미터는 다섯 개뿐입니다.
| 파라미터 | 기본값 | 의미 |
|---|---|---|
seq_page_cost |
1.0 | 디스크 페이지를 순차로 읽는 비용(기준) |
random_page_cost |
4.0 | 랜덤 위치 페이지를 읽는 비용 |
cpu_tuple_cost |
0.01 | 한 행을 처리하는 CPU 비용 |
cpu_index_tuple_cost |
0.005 | 인덱스 엔트리 하나를 처리하는 비용 |
cpu_operator_cost |
0.0025 | 연산자나 함수를 한 번 실행하는 비용 |
Seq Scan의 total cost는 이 값들의 단순 합입니다. 페이지 수 × seq_page_cost + 행 수 × cpu_tuple_cost + 행 수 × 조건 개수 × cpu_operator_cost. 뒤에서 볼 cost=0.00..1972.00도 이 덧셈의 결과입니다.
random_page_cost가 seq_page_cost보다 4배 비싸다는 설정은 HDD 시절의 유산입니다. SSD 환경에서는 이 차이가 거의 없어서 4.0을 그대로 두면 플래너가 인덱스를 지나치게 기피합니다. SSD를 쓰는 서버라면 random_page_cost를 1.1~1.5 정도로 내려 잡는 편이 실측에 가깝습니다.
그런데 이 cost 공식 자체는 단순해 보여도, 여기서 rows 자리에 들어갈 숫자를 어떻게 구하는지가 진짜 핵심입니다. 플래너가 “몇 행이 남을 것”이라고 추정하는 근거가 다음 절의 주제입니다.
pg_statistic: cost의 원료
cost를 계산하려면 “조건을 걸었을 때 몇 행이 남는가”를 먼저 알아야 합니다. 이걸 selectivity라 부릅니다. 플래너는 실제 테이블을 읽지 않고 pg_statistic에 저장된 표본 기반 통계로 selectivity를 추정합니다.
ANALYZE가 돌면 각 컬럼에 대해 세 가지가 저장됩니다.
n_distinct: 고유값 개수. 양수면 절대값, 음수면 행 수 대비 비율입니다(-1이면 전부 다름).- MCV(most_common_vals): 가장 자주 나오는 값 상위 100개(기본)와 각 값의 빈도.
- histogram: MCV에 뽑히지 않은 나머지 값을 대략 같은 개수씩 담도록 나눈 구간 경계선들.
등치 조건(=)이 MCV 안에 들어 있으면 그 값의 빈도를 바로 씁니다. MCV 밖이면 “MCV가 덮지 못한 나머지 비율”을 MCV에 없는 고유값 개수로 나눠 추정합니다. 범위 조건(>, <, BETWEEN)은 histogram 경계선을 따라 어느 위치까지 포함되는지를 보간해 비율을 계산합니다. MCV는 주로 카디널리티 낮은 범주형에서 위력을 발휘하고, histogram은 연속형 숫자·날짜 범위 조건을 받쳐주는 역할입니다.
둘은 서로의 빈자리를 메웁니다. histogram은 MCV에 뽑힌 값을 빼고 계산되기 때문에, MCV가 컬럼 전체를 덮어버리면 histogram은 아예 만들어지지 않습니다. 반대로 값이 거의 다 다른 컬럼은 MCV에 걸리는 값이 없다시피 해서 histogram이 분포를 통째로 떠맡습니다.
말로만 보면 잘 와닿지 않으니 직접 뜯어봅니다. 아래 실습과 출력은 모두 PostgreSQL 18.3에서 기본 설정 그대로(재현을 위해 autovacuum만 꺼둔 상태) 실행한 결과입니다.
CREATE TABLE orders (
id bigserial PRIMARY KEY,
status text,
amount numeric
);
-- 약 85% completed, 약 13% pending, 약 2% refunded
INSERT INTO orders (status, amount)
SELECT
CASE
WHEN s.r < 0.85 THEN 'completed'
WHEN s.r < 0.98 THEN 'pending'
ELSE 'refunded'
END,
(random() * 1000)::numeric(10, 2)
FROM (SELECT random() AS r FROM generate_series(1, 100000)) s;
ANALYZE orders;
SELECT attname, n_distinct, most_common_vals, most_common_freqs
FROM pg_stats
WHERE tablename = 'orders' AND attname = 'status'; attname | n_distinct | most_common_vals | most_common_freqs
---------+------------+------------------------------+-------------------------------
status | 3 | {completed,pending,refunded} | {0.8502333,0.12996666,0.0198}고유값은 3개, 그중 completed가 85.02%를 차지한다는 사실이 숫자로 박혀 있습니다. most_common_freqs는 표본에서 센 비율이라 데이터를 다시 만들면 소수점 아래가 조금씩 달라집니다. 세 값이 전부 MCV에 담겼기 때문에 이 컬럼의 histogram_bounds는 비어 있습니다.
이 테이블에 WHERE status = 'completed'를 걸면 플래너는 그 빈도를 그대로 곱해 rows=85023을 내놓습니다. 설령 status에 인덱스를 만들어둬도 이 쿼리는 Seq Scan이 이깁니다. 전체의 85%를 읽어야 하는 상황에서 인덱스 트리를 타고 다시 힙으로 가는 건 오히려 느립니다.
반면 WHERE status = 'refunded'는 rows=1980이라 인덱스 쪽이 훨씬 쌉니다. 실제로 인덱스를 만들어두고 찍어보면 Seq Scan은 cost=0.00..1972.00 그대로인데 Bitmap Heap Scan은 cost=23.64..770.39까지 내려가 이쪽이 선택됩니다. 13%인 pending도 cost=149.02..1033.48이라 여전히 Bitmap Heap Scan이 이깁니다. 경계를 정하는 건 인덱스의 유무가 아니라 조건 값의 빈도입니다. 같은 컬럼이어도 조건 값에 따라 plan이 달라지는 이유가 이 통계에 있습니다.
같은 테이블의 status와 amount를 나란히 놓으면 MCV와 histogram이 각각 어떤 컬럼을 맡는지가 드러납니다.
status는 세 값이 MCV를 다 채워 histogram이 필요 없고, amount는 반대로 MCV에 걸리는 값이 거의 없어 경계선 101개가 만든 버킷 100개가 분포를 통째로 표현합니다. 버킷마다 담긴 행 수가 같으므로 amount > 500은 “경계선 배열에서 500이 어디쯤인가”를 보간하는 문제로 바뀝니다.
EXPLAIN 출력 한 줄 해석하기
위에서 만든 테이블로 간단한 쿼리를 찍어봅니다.
EXPLAIN SELECT * FROM orders WHERE amount > 500; Seq Scan on orders (cost=0.00..1972.00 rows=49861 width=23)
Filter: (amount > '500'::numeric)각 숫자의 의미는 이렇습니다.
cost=0.00..1972.00: startup cost(첫 행 반환까지)와 total cost(전체 완료까지). Seq Scan은 바로 읽기 시작하니 startup이 0입니다. 앞 절의 덧셈을 그대로 대입하면 722페이지 + 100000 × 0.01 + 100000 × 0.0025 = 1972가 나옵니다.rows=49861: 플래너가 통계로 추정한 반환 행 수.width=23: 행 하나의 평균 크기(bytes).
여기까지는 전부 추정치입니다. EXPLAIN ANALYZE를 붙이면 실제 실행 결과가 함께 나옵니다.
EXPLAIN ANALYZE SELECT * FROM orders WHERE amount > 500; Seq Scan on orders (cost=0.00..1972.00 rows=49861 width=23)
(actual time=0.003..6.633 rows=49948.00 loops=1)
Filter: (amount > '500'::numeric)
Rows Removed by Filter: 50052
Buffers: shared hit=722
Planning Time: 0.015 ms
Execution Time: 8.209 ms같은 노드에 괄호가 두 개 붙었습니다. 앞 괄호는 플래너가 실행 전에 계산한 값이고, 뒤 괄호는 실제로 돌려본 결과입니다. 실제 psql은 이 둘을 한 줄에 붙여서 출력하는데, 여기서는 폭에 맞추려고 뒤 괄호를 아랫줄로 내렸습니다. 헷갈리는 지점은 rows=가 양쪽에 한 번씩 나온다는 것입니다.
실측 쪽 rows에 소수점 두 자리가 붙는 것은 PostgreSQL 18부터입니다. 이 숫자는 원래 loops로 나눈 평균이라, 예전처럼 정수로 반올림하면 한 행도 못 찾은 노드와 두 번에 한 번꼴로 한 행씩 찾은 노드가 똑같이 rows=0으로 보였습니다. 이제는 rows=0.00과 rows=0.50으로 갈립니다.
actual time=0.003..6.633의 앞 숫자는 첫 행이 나오기까지 걸린 시간, 뒷 숫자는 마지막 행까지 걸린 시간(ms)입니다. loops=1은 이 노드가 한 번만 실행됐다는 뜻입니다.
추정 49861, 실제 49948. 오차는 0.2% 수준이고, 이 정도면 통계가 현실을 잘 따라가고 있다고 봐도 됩니다.
버퍼 캐시 상황을 알려주는 Buffers: 줄은 PostgreSQL 18부터 EXPLAIN ANALYZE에 기본으로 따라붙습니다. 17 이하에서는 아래처럼 직접 켜야 같은 줄이 나오고, 반대로 18에서 이 줄이 거슬리면 BUFFERS OFF로 끕니다.
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE amount > 500;앞의 출력에 찍힌 shared hit=722는 이미 한 번 돌려본 뒤라 722페이지가 전부 메모리에 있었다는 뜻입니다. 서버를 막 재시작해 캐시가 빈 상태에서 같은 쿼리를 찍으면 이렇게 바뀝니다.
Seq Scan on orders (cost=0.00..1972.00 rows=49861 width=23)
(actual time=0.079..6.430 rows=49948.00 loops=1)
Filter: (amount > '500'::numeric)
Rows Removed by Filter: 50052
Buffers: shared read=722
Planning:
Buffers: shared hit=49 read=16
Planning Time: 0.190 ms
Execution Time: 7.803 msshared hit:shared_buffers(메모리)에서 바로 가져온 8KB 페이지 수shared read: 메모리에 없어서 디스크(또는 OS 캐시)까지 갔던 페이지 수Planning:아래의Buffers: 실행이 아니라 계획을 세우는 동안 읽은 페이지입니다. 통계와 카탈로그를 읽느라 발생합니다.
같은 쿼리를 한 번 더 돌리면 같은 자리가 이렇게 바뀝니다.
Buffers: shared hit=722
Planning:
Buffers: shared hit=65
Planning Time: 0.133 ms
Execution Time: 6.992 ms722페이지가 통째로 read에서 hit으로 넘어갔고, 계획 단계의 read=16도 사라졌습니다. plan은 한 글자도 바뀌지 않았는데 실행 시간만 7.803ms에서 6.992ms로 줄었습니다. 722페이지짜리 테이블이라 차이가 1ms 남짓이지만, 같은 비율이 수십만 페이지에서는 초 단위로 벌어집니다. “EXPLAIN만 봤을 땐 빨라 보였는데 실제로는 느리다”는 상황은 대개 이 read 값 때문이지 plan 탓이 아닙니다.
JOIN이 섞인 plan 읽는 법
단일 테이블 plan을 읽었으니 JOIN이 추가됐을 때 출력이 어떻게 달라지는지 이어서 봅니다. orders 옆에 users를 붙여 조인해봅니다.
CREATE TABLE users (
id bigserial PRIMARY KEY,
email text UNIQUE
);
INSERT INTO users (email)
SELECT 'user' || g || '@example.com'
FROM generate_series(1, 10000) g;
ALTER TABLE orders ADD COLUMN user_id bigint;
UPDATE orders SET user_id = (floor(random() * 10000)::int + 1);
CREATE INDEX ON orders (user_id);
ANALYZE;
EXPLAIN ANALYZE
SELECT u.email, o.amount
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE u.id = 42; Nested Loop (cost=4.66..50.48 rows=10 width=26)
(actual time=0.006..0.010 rows=11.00 loops=1)
Buffers: shared hit=16
-> Index Scan using users_pkey on users u
(cost=0.29..8.30 rows=1 width=28)
(actual time=0.002..0.002 rows=1.00 loops=1)
Index Cond: (id = 42)
Index Searches: 1
Buffers: shared hit=3
-> Bitmap Heap Scan on orders o
(cost=4.37..42.08 rows=10 width=14)
(actual time=0.003..0.006 rows=11.00 loops=1)
Recheck Cond: (user_id = 42)
Heap Blocks: exact=11
Buffers: shared hit=13
-> Bitmap Index Scan on orders_user_id_idx
(cost=0.00..4.37 rows=10 width=0)
(actual time=0.001..0.001 rows=11.00 loops=1)
Index Cond: (user_id = 42)
Index Searches: 1
Buffers: shared hit=2
Planning Time: 0.019 ms
Execution Time: 0.014 msplan 트리를 읽는 규칙은 두 가지입니다.
- 안쪽이 먼저 실행됩니다.(들여쓰기 깊은 노드) 바깥 노드는 안쪽의 출력을 입력으로 받습니다.
loops는 바깥 노드가 이 노드를 몇 번 호출했는지입니다. Nested Loop에서 안쪽 노드의 실제 비용은actual time × loops로 대략 계산합니다.
가장 안쪽의 Bitmap Index Scan이 먼저 돌아 user_id = 42인 위치를 모으고, Bitmap Heap Scan이 그 위치로 힙 블록 11개를 읽고, 마지막으로 Nested Loop가 users의 1행과 붙입니다. 결국 users에서 1행(id=42)을 뽑고 그 1행에 대해 orders 쪽을 한 번 조회해 11행을 가져온 것입니다. 추정 10행에 실제 11행이니 이쪽도 추정이 정확했습니다.
인덱스 노드에 붙은 Index Searches: 1도 PostgreSQL 18에서 새로 생긴 줄입니다. 인덱스를 처음부터 다시 타고 내려간 횟수라, = ANY (...)나 스킵 스캔처럼 한 노드 안에서 트리를 여러 번 뒤지는 경우에 1보다 큰 값이 찍힙니다.
플래너가 Nested Loop를 고른 이유는 바깥이 1행으로 줄어들 것임을 users_pkey 통계로 알았기 때문입니다. 바깥이 만 행 규모였다면 Nested Loop의 cost가 선형으로 불어나면서 Hash Join이나 Merge Join이 유리한 지점으로 넘어갑니다. 이 선택 과정은 다음 글에서 다룹니다.
추정치가 틀어질 때
건강한 쿼리는 추정 행과 실제 행이 비슷합니다. 오차가 10배를 넘어가면 plan 선택이 엉뚱해지기 시작합니다. 틀어지는 원인은 대개 네 가지로 나뉩니다.
1. 통계가 오래됐다
autovacuum이 돌 때 autoanalyze가 함께 통계를 갱신하지만, 임계값에 안 닿으면 며칠째 옛날 통계를 쓰고 있을 수 있습니다.
SELECT relname, n_live_tup, n_dead_tup, last_autoanalyze, last_analyze
FROM pg_stat_user_tables
WHERE relname = 'orders';last_autoanalyze가 며칠 전이고 그 사이에 대량 INSERT/UPDATE가 있었다면, 수동으로 ANALYZE orders;부터 돌려봅니다. 이 한 줄로 plan이 정상으로 돌아오는 경우가 많습니다. 도입부에서 언급한 “어제는 Index Scan, 오늘은 Seq Scan” 같은 상황이 설명되는 지점이 여기입니다. 코드도 스키마도 그대로인데 중간에 autoanalyze가 돌면서 MCV나 histogram이 바뀌면, 같은 WHERE 절이라도 selectivity 추정이 달라져 plan이 뒤집힙니다.
2. 상관된 두 컬럼
플래너는 기본적으로 두 컬럼이 서로 독립이라고 가정하고 selectivity를 곱합니다. 현실은 종종 그렇지 않습니다.
CREATE TABLE addresses (
id bigserial PRIMARY KEY,
country text,
city text
);
-- 도시는 나라에 종속적이다
INSERT INTO addresses (country, city)
SELECT
c.country,
c.city
FROM (
VALUES
('KR', 'Seoul'), ('KR', 'Busan'), ('KR', 'Incheon'),
('US', 'NewYork'), ('US', 'LA'), ('US', 'Chicago'),
('JP', 'Tokyo'), ('JP', 'Osaka')
) c(country, city),
generate_series(1, 10000);
ANALYZE addresses;
EXPLAIN ANALYZE
SELECT * FROM addresses
WHERE country = 'KR' AND city = 'Seoul'; Seq Scan on addresses (cost=0.00..1700.00 rows=3813 width=17)
(actual time=0.003..3.240 rows=10000.00 loops=1)
Filter: ((country = 'KR'::text) AND (city = 'Seoul'::text))
Rows Removed by Filter: 70000
Buffers: shared hit=500pg_stats를 열어 보면 MCV에 country의 KR이 0.3765, city의 Seoul이 0.1266으로 잡혀 있습니다. 플래너는 두 조건이 무관하다고 보고 그냥 곱합니다. 0.3765 × 0.1266 = 0.04767, 여기에 8만 행을 곱해 3813행을 예상했습니다. 실제로는 Seoul이 KR에만 있어서 두 번째 조건이 첫 번째 조건을 하나도 걸러내지 못하고, city = 'Seoul'인 10000행이 그대로 남습니다. 2.6배 어긋난 것입니다.
이 오차가 이 노드에서 끝나지 않는다는 점이 문제입니다.
행 수 추정은 그 노드 하나의 문제로 끝나지 않고 위쪽 노드의 입력이 됩니다. 바깥이 3,813행일 줄 알고 Nested Loop를 골랐는데 실제로 10,000행이 들어오면, 안쪽 인덱스 조회가 그만큼 더 돌아갑니다. 조인이 두세 단 겹치면 이 배수가 곱해집니다.
PostgreSQL 10부터 이걸 교정할 수 있습니다.
CREATE STATISTICS addr_stats (dependencies, ndistinct)
ON country, city FROM addresses;
ANALYZE addresses;
EXPLAIN ANALYZE
SELECT * FROM addresses
WHERE country = 'KR' AND city = 'Seoul'; Seq Scan on addresses (cost=0.00..1700.00 rows=9984 width=17)
(actual time=0.004..3.449 rows=10000.00 loops=1)
Filter: ((country = 'KR'::text) AND (city = 'Seoul'::text))
Rows Removed by Filter: 70000
Buffers: shared hit=500추정 9984에 실제 10000, 오차 0.2%입니다. dependencies가 “city 값을 알면 country가 정해진다”는 함수 종속을 표본에서 찾아내 두 조건을 곱하지 않도록 막아준 결과입니다. 두 컬럼이 강하게 연관된 경우 CREATE STATISTICS는 거의 항상 효과가 있습니다.
3. 극단적인 스큐
어떤 컬럼은 값 하나가 99%를 차지하고 나머지가 여기저기 흩어져 있을 수 있습니다. 기본 MCV 슬롯(100개)이 이걸 다 담지 못하면 histogram 쪽 추정이 엉킵니다.
컬럼별로 샘플 크기를 늘릴 수 있습니다.
ALTER TABLE orders ALTER COLUMN status SET STATISTICS 1000;
ANALYZE orders;이 값은 most_common_vals와 histogram_bounds에 들어갈 수 있는 최대 항목 수인 동시에 표본 크기를 정합니다. ANALYZE는 목표값 × 300행을 표본으로 뽑으므로, 기본값 100이면 3만 행을 읽고 1000이면 30만 행을 읽습니다. 실제로 ANALYZE VERBOSE를 찍어보면 기본 설정에서 30000 rows in sample이라고 나옵니다.
목표값의 상한은 10000입니다. 다만 올린 만큼 ANALYZE 시간과 플래너의 통계 조회 비용이 함께 늘어나므로, 문제가 확인된 컬럼에만 올리는 편이 낫습니다. 테이블 전체의 기본값은 default_statistics_target으로 조절합니다.
4. 표현식에 감싼 컬럼
SELECT * FROM users WHERE lower(email) = 'admin@example.com';플래너는 lower(email)이라는 표현식 값의 분포를 모릅니다. pg_statistic에는 email 컬럼의 원본 분포만 있기 때문입니다. 결과적으로 등치 조건에 붙는 기본 선택도 0.5%가 그대로 적용됩니다. email이 UNIQUE라 1행만 나올 것이 뻔한데도 1만 행짜리 users에서 rows=50이라는 추정이 나옵니다.
Seq Scan on users (cost=0.00..224.00 rows=50 width=28)
Filter: (lower(email) = 'admin@example.com'::text)해법은 표현식 인덱스 + 그 인덱스에 대한 통계 생성입니다.
CREATE INDEX users_email_lower ON users (lower(email));
ANALYZE users;표현식 인덱스를 만들면 ANALYZE가 lower(email) 값 자체의 통계를 따로 수집합니다. 그때부터 추정이 제자리를 찾습니다.
Index Scan using users_email_lower on users (cost=0.29..8.30 rows=1 width=28)
Index Cond: (lower(email) = 'admin@example.com'::text)rows=50이 rows=1이 되면서 plan도 Seq Scan에서 Index Scan으로 바뀌었습니다.
실전에서는
bad plan을 만났을 때 확인 순서를 정해두면 디버깅이 빨라집니다.
1. 일단 EXPLAIN부터 찍습니다. ANALYZE 옵션을 함께 주고, 17 이하라면 BUFFERS도 붙입니다. 추정 행과 실제 행의 비율이 10배 이상 벌어진 노드가 있는지 봅니다. 이게 원인 후보입니다.
2. 해당 테이블의 last_autoanalyze를 확인합니다.
SELECT relname, n_mod_since_analyze, last_autoanalyze
FROM pg_stat_user_tables
WHERE relname = '문제_테이블';며칠째 갱신이 없거나 n_mod_since_analyze가 크면 수동 ANALYZE부터 돌려봅니다.
3. 한 번만 느린 쿼리는 auto_explain으로 잡습니다. 재현이 어려운 slow query는 항상 로그에 남겨둡니다.
# postgresql.conf
shared_preload_libraries = 'auto_explain'
auto_explain.log_min_duration = '500ms'
auto_explain.log_analyze = on
auto_explain.log_buffers = on500ms 넘는 쿼리는 전부 plan과 함께 로그에 찍힙니다. 이벤트 순간의 plan을 나중에 볼 수 있다는 것만으로도 원인 파악이 쉬워집니다.
4. “cost는 낮은데 실제로 느림”은 대체로 버퍼 미스입니다. Buffers: read= 값이 크면 shared_buffers 크기나 워밍업 상태를 먼저 의심합니다. plan이 문제가 아닐 수 있습니다.
흔한 오해
- “cost 숫자가 작으면 빠른 쿼리다”: cost는 상대 비용입니다. 같은 쿼리의 plan끼리 비교할 때만 의미가 있고, 서로 다른 쿼리의 cost를 두고 “이게 더 빠르다”고 말할 수는 없습니다.
- “ANALYZE는 인덱스를 갱신한다”:
ANALYZE는 통계만 갱신합니다. 인덱스 재구축과는 무관합니다. - ”
VACUUM을 돌리면 통계도 갱신된다”: 기본VACUUM은 통계를 건드리지 않습니다.VACUUM ANALYZE이거나 autovacuum에 딸린 autoanalyze가 돌아야 합니다. - “추정치 오차는 테이블이 작을수록 덜 나온다”: 오히려 반대에 가깝습니다. 작은 테이블은 Seq Scan이 어차피 이겨서 오차가 드러나지 않을 뿐이고, 테이블이 커지는 순간 같은 오차가 plan을 뒤집습니다.
마치며
플래너는 실제 데이터를 보지 않습니다. pg_statistic에 박힌 숫자 몇 개로 비용을 추정하고, 가장 싼 plan을 고를 뿐입니다. 그 추정이 맞아야 plan도 맞습니다. EXPLAIN ANALYZE에서 추정 행과 실제 행의 괴리가 곧 진단의 출발점입니다.
다음 글에서는 같은 통계를 받아든 플래너가 Nested Loop, Hash Join, Merge Join 중 무엇을 왜 고르는지 살펴봅니다. 세 알고리즘의 cost 공식이 데이터 크기와 메모리 한계에서 어떻게 교차하는지가 핵심입니다.
참고자료
- PostgreSQL 18 공식 문서: 14.1. Using EXPLAIN
- 14.2. Statistics Used by the Planner: n_distinct, MCV, histogram, 확장 통계 개요
- EXPLAIN: ANALYZE, BUFFERS, SETTINGS 등 옵션 목록
- pg_stats: most_common_vals, most_common_freqs, histogram_bounds 컬럼 정의
- 69.1. Row Estimation Examples: selectivity 계산을 손으로 따라가는 예제
- 69.2. Multivariate Statistics Examples: dependencies, ndistinct가 추정을 어떻게 고치는지
- CREATE STATISTICS: 확장 통계 문법과 통계 종류
- 19.7. Query Planning: seq_page_cost, random_page_cost 등 비용 상수와 default_statistics_target
- auto_explain: 느린 쿼리의 plan을 로그로 남기는 확장
- Hironobu Suzuki, The Internals of PostgreSQL, Chapter 3: Query Processing