PostgreSQL EXPLAIN ANALYZE 읽는 법, 숫자 5개

PostgreSQL 16 실행계획의 actual time, rows 오차, loops, Buffers, Sort Method를 200만 건 테이블로 읽었다. ANALYZE 한 줄로 361ms가 88ms가 된 과정.

aborts.403 ·
PostgreSQL EXPLAIN ANALYZE 실행계획에서 rows 추정 오차와 loops를 강조한 화면

느린 쿼리를 잡을 때 EXPLAIN ANALYZE 출력은 수십 줄이지만, 실제로 판단에 쓰는 숫자는 다섯 개 정도다. 예상 rows와 실제 rows의 차이, loops, Buffers, Sort Method, 그리고 actual time을 읽는 요령이다. 이 글은 PostgreSQL 16.15에 200만 건짜리 주문 테이블을 만들고, 그 다섯 숫자만으로 361ms 쿼리를 88ms로, 44ms 쿼리를 0.08ms로 줄인 기록이다.

출력은 전부 직접 돌려서 받은 그대로 옮겼고, 앞에서 이미 본 줄만 ...로 줄였다. 하드웨어가 다르면 ms 단위는 달라지지만, 숫자 사이의 관계는 똑같이 나온다.

🧪 실습 환경

macOS의 Homebrew postgresql@16(16.15)을 기본 설정 그대로 썼다. shared_buffers는 128MB, work_mem은 4MB다. 고객 10만 명, 주문 200만 건을 만들고 주문 테이블은 130MB가 됐다. 통계가 낡는 상황을 재현하려고 주문 테이블만 autovacuum을 껐다.

CREATE TABLE customers (
  id         bigint PRIMARY KEY,
  name       text NOT NULL,
  grade      text NOT NULL,          -- bronze / silver / gold / vip
  created_at timestamptz NOT NULL
);

CREATE TABLE orders (
  id          bigserial PRIMARY KEY,
  customer_id bigint NOT NULL REFERENCES customers(id),
  status      text NOT NULL,         -- done 97%, canceled 3%
  amount      numeric(12,0) NOT NULL,
  created_at  timestamptz NOT NULL
) WITH (autovacuum_enabled = off);

-- generate_series로 customers 10만 건, orders 200만 건 적재 후
ANALYZE customers;
ANALYZE orders;

옵션은 EXPLAIN (ANALYZE, BUFFERS)를 기본으로 쓴다. ANALYZE만 주면 Buffers 줄이 빠지는데, 아래에서 보듯 그 줄이 시간보다 정직한 지표다.

🌲 읽기 전에: 트리는 안쪽부터

실행계획은 들여쓰기로 표현한 트리다. ->가 붙은 줄이 노드이고, 가장 깊이 들여쓴 노드가 먼저 실행된다. 그 결과가 한 단계 바깥 노드로 올라간다. 위에서 아래로 읽으면 결과부터 보고 원인을 거슬러 가는 셈이라, 처음엔 맨 아래 스캔 노드에서 출발해 위로 읽는 편이 쉽다.

노드 이름도 몇 개만 알면 된다.

노드하는 일의심해볼 때
Seq Scan테이블 전체를 처음부터 끝까지 읽는다큰 테이블에서 몇 건만 꺼내는데 이게 보이면
Index Scan / Index Only Scan인덱스로 필요한 행만 찾는다수십만 건을 이걸로 하나씩 꺼내면
Bitmap Heap Scan인덱스로 위치를 모은 뒤 페이지 단위로 읽는다대체로 중간 규모 조회의 정답
Nested Loop바깥 행마다 안쪽을 한 번씩 뒤진다바깥 행이 많은데 이게 선택되면
Hash Join한쪽으로 해시 테이블을 만들고 다른 쪽을 대조한다해시가 work_mem을 넘어 Batches가 1보다 크면

⏱ 숫자 1. actual time은 두 개이고, loop당 값이다

첫 번째 쿼리는 고객 한 명의 최근 주문 20건이다. 인덱스가 없는 상태에서 서버를 재시작해 캐시를 비우고 돌렸다.

EXPLAIN (ANALYZE, BUFFERS)
SELECT id, status, amount, created_at
FROM orders
WHERE customer_id = 4242
ORDER BY created_at DESC
LIMIT 20;
 Limit  (cost=28083.83..28085.93 rows=18 width=27) (actual time=43.635..44.453 rows=20 loops=1)
   Buffers: shared hit=74 read=16667
   ->  Gather Merge  (cost=28083.83..28085.93 rows=18 width=27) (actual time=43.634..44.451 rows=20 loops=1)
         Workers Planned: 2
         Workers Launched: 2
         Buffers: shared hit=74 read=16667
         ->  Sort  (cost=27083.81..27083.83 rows=9 width=27) (actual time=41.743..41.743 rows=7 loops=3)
               Sort Key: created_at DESC
               Sort Method: quicksort  Memory: 25kB
               Buffers: shared hit=74 read=16667
               Worker 0:  Sort Method: quicksort  Memory: 25kB
               Worker 1:  Sort Method: quicksort  Memory: 25kB
               ->  Parallel Seq Scan on orders  (cost=0.00..27083.67 rows=9 width=27) (actual time=0.381..41.711 rows=8 loops=3)
                     Filter: (customer_id = 4242)
                     Rows Removed by Filter: 666659
                     Buffers: shared read=16667
 Planning Time: 0.018 ms
 Execution Time: 44.460 ms

actual time=0.381..41.711에서 앞 숫자는 첫 행을 내놓기까지 걸린 시간, 뒤 숫자는 마지막 행까지 걸린 시간이다. Sort 노드의 41.743..41.743처럼 두 값이 같다면 모든 입력을 받은 뒤에야 첫 행을 줄 수 있는 노드라는 뜻이다. 정렬은 마지막 행을 보기 전엔 1등을 알 수 없으니 당연하다.

함정은 이 시간이 loop 한 번당 평균이라는 점이다. 병렬 스캔은 리더와 워커 2개가 나눠 읽어서 loops=3이 찍혔다. rows도 loop당 평균이라, 실제로 걸러진 행은 Rows Removed by Filter: 666659에 3을 곱한 약 200만 건이다. 200만 건을 읽고 20건을 남긴 쿼리다.

고객 한 명을 찾으려고 테이블 전체를 읽고 있었다. 이 쿼리의 문제는 시간보다 Rows Removed by Filter가 더 크게 말해준다.

📦 숫자 2. Buffers는 캐시 상태에 덜 흔들리는 지표다

같은 쿼리의 Buffers 줄을 보자. shared read=16667은 PostgreSQL의 공유 버퍼에 없어서 바깥(OS 캐시나 디스크)에서 가져온 8KB 페이지 수다. hit은 공유 버퍼에서 바로 찾은 페이지다. 둘을 더한 16,741페이지, 약 130MB가 이 쿼리가 건드린 데이터 양이다.

시간은 캐시가 데워졌는지, 옆에서 배치가 돌고 있는지에 따라 몇 배씩 출렁인다. 반면 hit과 read를 더한 페이지 수는 같은 데이터, 같은 계획이면 거의 그대로다. 튜닝 전후를 비교할 때 시간보다 이 합계를 먼저 본다.

(customer_id, created_at) 복합 인덱스를 만들고 다시 돌렸다.

CREATE INDEX orders_customer_created_idx ON orders (customer_id, created_at);
 Limit  (cost=0.43..84.59 rows=20 width=27) (actual time=0.029..0.072 rows=20 loops=1)
   Buffers: shared hit=3 read=23
   ->  Index Scan Backward using orders_customer_created_idx on orders  (cost=0.43..88.79 rows=21 width=27) (actual time=0.028..0.069 rows=20 loops=1)
         Index Cond: (customer_id = 4242)
         Buffers: shared hit=3 read=23
 Planning:
   Buffers: shared hit=79 read=1
 Planning Time: 0.283 ms
 Execution Time: 0.084 ms

16,741페이지가 26페이지로 줄었다. 정렬 노드도 사라졌다. 인덱스가 이미 customer_id 안에서 created_at 순으로 정렬돼 있어서 Index Scan Backward로 거꾸로 20건만 읽고 멈춘다. ORDER BY ... LIMIT 쿼리에 복합 인덱스 열 순서가 중요한 이유가 여기 있다.

🔁 두 번 돌렸는데 read가 그대로인 이유

인덱스를 만들기 전에 같은 쿼리를 연달아 두 번 돌렸는데, 두 번째에도 shared hit=170 read=16571이 나왔다. 130MB 테이블을 막 읽었으니 128MB 공유 버퍼에 상당 부분 남아 있으리라 예상했는데 아니었다.

pg_buffercache로 확인해보니 200만 건 전체를 순차 스캔한 직후에도 orders 페이지는 공유 버퍼에 32개만 남아 있었다. 32 × 8KB, 정확히 256KB다.

CREATE EXTENSION pg_buffercache;

SELECT count(b.bufferid)
FROM pg_buffercache b
JOIN pg_class c ON b.relfilenode = pg_relation_filenode(c.oid)
WHERE c.relname = 'orders';
--  count
-- -------
--     32

PostgreSQL은 공유 버퍼의 4분의 1보다 큰 테이블을 순차 스캔할 때 256KB짜리 링 버퍼만 돌려 쓴다. 큰 스캔 한 번이 다른 쿼리들의 캐시를 밀어내지 않게 하려는 장치다. 그래서 큰 테이블의 Seq Scan은 몇 번을 돌려도 read가 줄지 않는다. 그래도 44ms에 끝난 건 그 read가 실제 디스크가 아니라 OS 페이지 캐시에서 왔기 때문이다. read가 디스크에서 왔는지 OS 캐시에서 왔는지 구분하려면 아래에 나오는 track_io_timing을 켠다.

🎯 숫자 3. 예상 rows와 실제 rows의 차이

여기서부터가 핵심이다. 마지막 ANALYZE 이후에 status = 'pending'인 주문 30만 건을 새로 넣었다. 통계에는 pending이라는 값이 아예 없다. autovacuum이 꺼져 있으니 pg_stat_user_tables의 n_mod_since_analyze는 300000인데 통계는 그대로다.

이 상태에서 pending 주문을 고객 등급별로 집계했다.

EXPLAIN (ANALYZE, BUFFERS)
SELECT c.grade, count(o.id), sum(o.amount)
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.status = 'pending'
GROUP BY c.grade;
 GroupAggregate  (cost=32154.56..32154.58 rows=1 width=45) (actual time=326.740..360.516 rows=4 loops=1)
   Group Key: c.grade
   Buffers: shared hit=902598 read=16569, temp read=1212 written=1217
   ->  Sort  (cost=32154.56..32154.56 rows=1 width=19) (actual time=320.225..337.023 rows=300000 loops=1)
         Sort Key: c.grade
         Sort Method: external merge  Disk: 9696kB
         Buffers: shared hit=902598 read=16569, temp read=1212 written=1217
         ->  Nested Loop  (cost=1000.29..32154.55 rows=1 width=19) (actual time=38.348..276.755 rows=300000 loops=1)
               Buffers: shared hit=902598 read=16569
               ->  Gather  (cost=1000.00..32146.24 rows=1 width=22) (actual time=38.336..50.239 rows=300000 loops=1)
                     Workers Planned: 2
                     Workers Launched: 2
                     Buffers: shared hit=2598 read=16569
                     ->  Parallel Seq Scan on orders o  (cost=0.00..31146.14 rows=1 width=22) (actual time=36.819..46.207 rows=100000 loops=3)
                           Filter: (status = 'pending'::text)
                           Rows Removed by Filter: 666667
                           Buffers: shared hit=2598 read=16569
               ->  Index Scan using customers_pkey on customers c  (cost=0.29..8.31 rows=1 width=13) (actual time=0.001..0.001 rows=1 loops=300000)
                     Index Cond: (id = o.customer_id)
                     Buffers: shared hit=900000
 Planning:
   Buffers: shared hit=14
 Planning Time: 0.109 ms
 Execution Time: 361.227 ms

Nested Loop 줄의 괄호 두 개를 나란히 놓고 보면 된다. 플래너는 rows=1로 예상했고, 실제로는 rows=300000이 나왔다. 30만 배 오차다.

이 오차 하나가 아래쪽 선택을 전부 틀어놨다. 1건이라고 믿었으니 해시 테이블을 만드는 것보다 고객 PK 인덱스를 한 번 찾는 Nested Loop가 싸 보였다. 1건이니 정렬도 메모리로 충분하다고 봤다. 실제로는 30만 건이 쏟아졌다.

오차가 처음 생긴 노드를 찾는 것도 요령이다. 트리 안쪽부터 올라가며 예상과 실제가 처음 크게 벌어지는 지점을 보면, 여기서는 맨 아래 Parallel Seq Scan의 rows=1 대 rows=100000(× loops 3)이다. 그 위 노드들의 오차는 이 오차가 전파된 결과일 뿐이다. 스캔 단계의 추정이 틀렸다면 원인은 대개 통계가 낡았거나, 컬럼 사이 상관관계를 플래너가 모르거나, 함수로 감싼 조건이라 통계를 못 쓰는 경우다.

🔂 숫자 4. loops는 곱해서 읽는다

같은 계획의 Index Scan using customers_pkey 줄을 보면 actual time=0.001..0.001이다. 1마이크로초니 문제없어 보인다. 하지만 옆에 loops=300000이 붙어 있다. 이 노드의 실제 비용은 0.001ms × 30만 번이고, Buffers는 shared hit=900000이다. 인덱스 한 번 찾을 때마다 루트, 리프, 테이블 페이지 3개를 읽었고 그걸 30만 번 반복했다.

이 Buffers 숫자는 loop당 평균이 아니라 전체 합계다. actual time과 rows는 loop당 평균, Buffers는 합계라는 차이를 기억해두면 헷갈리지 않는다. 쿼리 전체 Buffers 92만 페이지 중 90만이 이 줄에서 나왔다. 앞의 Seq Scan이 읽은 1만 9천 페이지보다 47배 많다.

이제 통계만 다시 모았다. 인덱스도, 쿼리도 그대로다.

ANALYZE orders;
 Finalize GroupAggregate  (cost=36433.67..36434.73 rows=4 width=45) (actual time=87.016..88.357 rows=4 loops=1)
   Group Key: c.grade
   Buffers: shared hit=5226 read=16345
   ->  Gather Merge  (cost=36433.67..36434.60 rows=8 width=45) (actual time=86.996..88.336 rows=12 loops=1)
         Workers Planned: 2
         Workers Launched: 2
         Buffers: shared hit=5226 read=16345
         ->  Sort  (cost=35433.65..35433.66 rows=4 width=45) (actual time=85.138..85.138 rows=4 loops=3)
               Sort Key: c.grade
               Sort Method: quicksort  Memory: 25kB
               Buffers: shared hit=5226 read=16345
               Worker 0:  Sort Method: quicksort  Memory: 25kB
               Worker 1:  Sort Method: quicksort  Memory: 25kB
               ->  Partial HashAggregate  (cost=35433.56..35433.61 rows=4 width=45) (actual time=85.123..85.124 rows=4 loops=3)
                     Group Key: c.grade
                     Batches: 1  Memory Usage: 24kB
                     Buffers: shared hit=5210 read=16345
                     Worker 0:  Batches: 1  Memory Usage: 24kB
                     Worker 1:  Batches: 1  Memory Usage: 24kB
                     ->  Hash Join  (cost=3036.00..34506.61 rows=123593 width=19) (actual time=50.303..74.292 rows=100000 loops=3)
                           Hash Cond: (o.customer_id = c.id)
                           Buffers: shared hit=5210 read=16345
                           ->  Parallel Seq Scan on orders o  (cost=0.00..31146.17 rows=123593 width=22) (actual time=37.701..45.206 rows=100000 loops=3)
                                 Filter: (status = 'pending'::text)
                                 Rows Removed by Filter: 666667
                                 Buffers: shared hit=2822 read=16345
                           ->  Hash  (cost=1786.00..1786.00 rows=100000 width=13) (actual time=12.503..12.503 rows=100000 loops=3)
                                 Buckets: 131072  Batches: 1  Memory Usage: 5712kB
                                 Buffers: shared hit=2358
                                 ->  Seq Scan on customers c  (cost=0.00..1786.00 rows=100000 width=13) (actual time=0.067..5.855 rows=100000 loops=3)
                                       Buffers: shared hit=2358
 Planning:
   Buffers: shared hit=14
 Planning Time: 0.124 ms
 Execution Time: 88.396 ms
항목ANALYZE 전ANALYZE 후
스캔 예상 rows (loop당)1123,593
조인 방식Nested Loop + PK Index ScanHash Join
정렬external merge, Disk 9,696kB집계 후 4행 quicksort, 25kB
전체 Buffers919,16721,571
Execution Time361ms88ms

예상이 실제(loop당 10만)에 가까워지자 플래너는 고객 테이블 10만 건으로 해시 테이블을 한 번 만들고(Batches: 1, 5.7MB로 메모리 안에 들어감) 주문을 대조하는 쪽을 골랐다. 집계도 정렬 전에 해시로 끝내서 4행만 정렬한다. 인덱스 하나 안 건드리고 통계만으로 페이지 접근이 43분의 1이 됐다.

실행계획이 이상하면 인덱스를 추가하기 전에 예상 rows부터 본다. 추정이 틀렸다면 원인은 계획이 아니라 통계에 있다.

운영 환경에서 autovacuum을 끄는 일은 드물다. 하지만 대량 적재 직후, 그리고 메이저 버전 업그레이드 직후에는 똑같은 상황이 생긴다. pg_upgrade는 옵티마이저 통계를 옮기지 않아서, 업그레이드 직후 모든 테이블이 이 글의 ‘pending’ 상태가 된다. Aurora PostgreSQL 15→16 Blue/Green 업그레이드 글에서 스위치오버 전에 그린에서 데이터베이스마다 ANALYZE를 돌린 이유가 이것이다.

💾 숫자 5. Sort Method의 Disk, 무조건 범인은 아니다

ANALYZE 전 계획에는 Sort Method: external merge Disk: 9696kB가 있었다. 정렬할 데이터가 work_mem(기본 4MB)을 넘어서 임시 파일로 나눠 정렬했다는 뜻이다. Buffers 줄의 temp read=1212 written=1217이 그 임시 파일 I/O다.

이 표시가 보이면 반사적으로 work_mem부터 올리기 쉽다. 정렬만 따로 떼어 시험해봤다. 비교를 단순하게 하려고 병렬은 끄고 I/O 시간 측정을 켰다.

SET max_parallel_workers_per_gather = 0;
SET track_io_timing = on;

EXPLAIN (ANALYZE, BUFFERS)
SELECT id, customer_id, amount
FROM orders
WHERE created_at >= '2026-09-01'
ORDER BY amount DESC;
 Sort  (cost=127671.83..129370.44 rows=679445 width=22) (actual time=295.175..407.840 rows=666118 loops=1)
   Sort Key: amount DESC
   Sort Method: external merge  Disk: 21528kB
   Buffers: shared hit=3017 read=16153, temp read=2691 written=2702
   I/O Timings: shared read=17.684, temp read=3.066 write=15.380
   ->  Seq Scan on orders  (cost=0.00..47917.00 rows=679445 width=22) (actual time=0.010..140.580 rows=666118 loops=1)
         Filter: (created_at >= '2026-09-01 00:00:00+09'::timestamp with time zone)
         Rows Removed by Filter: 1633882
         Buffers: shared hit=3014 read=16153
         I/O Timings: shared read=17.684
 Planning:
   Buffers: shared hit=114
 Planning Time: 0.214 ms
 Execution Time: 422.553 ms

SET work_mem = '64MB' 후 같은 쿼리를 돌렸다.

 Sort  (cost=113734.83..115433.44 rows=679445 width=22) (actual time=328.491..383.243 rows=666118 loops=1)
   Sort Key: amount DESC
   Sort Method: quicksort  Memory: 53070kB
   Buffers: shared hit=3046 read=16121
   I/O Timings: shared read=18.040
   ...
 Execution Time: 404.939 ms

솔직히 예상보다 차이가 작았다. 422ms가 405ms가 됐을 뿐이다. I/O Timings를 보면 임시 파일 쓰기와 읽기에 쓴 시간은 합쳐서 18ms 남짓이다. SSD에 OS 캐시까지 받쳐주는 환경에서는 디스크 정렬이 그렇게 비싸지 않다. 디스크에선 21MB였던 데이터가 메모리에선 53MB를 차지한 것도 눈여겨볼 만하다.

그리고 work_mem은 연결당이 아니라 정렬·해시 노드 하나당 잡히는 한도다. 조인 세 개, 정렬 두 개짜리 쿼리를 동시에 100개 연결이 돌리면 64MB의 수백 배가 필요해질 수 있다. 전역으로 올리기보다 무거운 리포트 세션에서만 SET LOCAL work_mem으로 올리는 편이 안전하다.

진짜 질문은 66만 건을 전부 정렬해서 받을 필요가 있느냐다. 화면에 상위 100건만 보여준다면 LIMIT을 붙이는 것만으로 정렬 방식 자체가 바뀐다.

 Limit  (cost=73884.90..73885.15 rows=100 width=22) (actual time=198.957..198.969 rows=100 loops=1)
   ->  Sort  (cost=73884.90..75583.51 rows=679445 width=22) (actual time=198.956..198.959 rows=100 loops=1)
         Sort Key: amount DESC
         Sort Method: top-N heapsort  Memory: 36kB
         ...
 Execution Time: 198.987 ms

top-N heapsort는 상위 100건만 힙에 유지하며 훑기 때문에 메모리 36kB로 끝난다. work_mem을 16배 올렸을 때보다 이쪽이 두 배 빠르다.

Disk가 찍혔다면 work_mem 대신 정렬하는 행 수부터 의심한다. 줄일 수 있는 행을 줄이는 쪽이 설정을 올리는 쪽보다 대개 싸게 먹힌다.

🙅 cost는 ms가 아니다

cost=0.00..47917.00을 47초로 읽는 경우가 꽤 있다. cost는 단위가 없는 상대 점수다. 순차로 8KB 페이지 하나 읽는 비용을 1로 놓고 나머지를 그에 비례해 매긴다. 위 Seq Scan의 47917도 손으로 계산하면 그대로 나온다.

SELECT relpages, reltuples FROM pg_class WHERE relname = 'orders';
--  relpages | reltuples
--     19167 |   2.3e+06
  seq_page_cost     1.0    × 19,167 페이지  = 19,167
+ cpu_tuple_cost    0.01   × 2,300,000 행   = 23,000
+ cpu_operator_cost 0.0025 × 2,300,000 행   =  5,750   (필터 비교 1회)
= 47,917

필터 없이 테이블 전체를 조회하면 마지막 항이 빠져 42167이 나온다. 실제로 EXPLAIN을 돌려도 그 값이 찍힌다. cost는 플래너가 후보 계획끼리 비교하려고 쓰는 점수일 뿐이라서, 서로 다른 쿼리의 cost를 비교하거나 시간으로 환산하는 건 의미가 없다. 같은 쿼리에서 cost는 낮은데 actual time이 길다면, 그 간극이 바로 추정이 틀렸다는 신호다. ANALYZE 전 계획이 cost 32154로 ANALYZE 후(36433)보다 ‘싸 보였던’ 것도 그래서다.

⚠️ 쓰기 전에 알아둘 것

EXPLAIN ANALYZE는 쿼리를 실제로 실행한다. SELECT는 상관없지만 UPDATE, DELETE, INSERT에 붙이면 데이터가 바뀐다. 쓰기 쿼리는 트랜잭션으로 감싸고 되돌린다.

BEGIN;
EXPLAIN (ANALYZE, BUFFERS) DELETE FROM orders WHERE status = 'canceled';
ROLLBACK;

그 밖에 실무에서 쓰는 옵션과 도구는 이 정도다.

  • track_io_timing: Buffers의 read가 실제로 얼마나 걸렸는지 I/O Timings로 보여준다. 세션 단위로 켤 수 있고 오버헤드가 작아서 운영 DB에서 상시로 켜두는 곳도 많다.
  • EXPLAIN (GENERIC_PLAN): 16에서 새로 생긴 옵션이다. WHERE id = $1처럼 파라미터가 남은 쿼리를 값 없이 계획만 볼 수 있어서, 로그에 찍힌 prepared statement를 그대로 붙여넣을 때 편하다. ANALYZE와는 같이 못 쓴다.
  • auto_explain: 일정 시간 넘게 걸린 쿼리의 실행계획을 로그로 남긴다. 느린 쿼리가 재현이 안 될 때 쓴다. RDS·Aurora에서는 파라미터 그룹의 shared_preload_libraries에 추가해서 켠다.
  • FORMAT JSON: 출력이 길면 JSON으로 받아 시각화 도구에 붙여넣는다. 텍스트로 읽기 힘든 수백 줄짜리 계획도 어느 노드가 시간을 먹었는지 바로 보인다.

📝 정리

느린 쿼리의 실행계획을 받으면 이 순서로 본다.

  1. Execution Time과 맨 위 Buffers로 규모를 잡는다. 시간보다 hit + read 페이지 수가 전후 비교에 정확하다.
  2. 트리 안쪽부터 올라가며 예상 rows와 실제 rows가 처음 크게 벌어지는 노드를 찾는다. 10배 이상 벌어졌다면 ANALYZE부터 해본다.
  3. loops가 큰 노드는 actual time에 loops를 곱해 실제 비용을 본다. Nested Loop 안쪽에서 자주 나온다.
  4. Rows Removed by Filter가 남긴 행보다 훨씬 크면 인덱스나 조건을 의심한다.
  5. Sort Method에 Disk가 보이면 work_mem보다 정렬할 행 수를 줄일 방법부터 찾는다.

이번 실습에서 가장 큰 개선은 인덱스도 설정 변경도 아니라 ANALYZE 한 줄이었다. 실행계획 읽기가 결국 플래너가 무엇을 믿었고 실제로 무슨 일이 일어났는지 비교하는 작업이라, 그 믿음의 재료인 통계가 틀리면 나머지는 전부 따라 틀린다.

관련 글