PostgreSQL 인덱스를 설명할 때 흔히 이런 주장을 봅니다.
PostgreSQL 인덱스는 기본적으로 ASC(오름차순)으로 생성되며,
DESC쿼리를 위해 역순으로 스캔합니다. 명시적으로DESC인덱스를 생성하면, 인덱스 역순 스캔 대신 정방향 스캔이 되어 성능이 더 좋습니다.
로그 테이블을 ORDER BY timestamp DESC로 조회하는 흔한 패턴을 설명할 때 나오는 주장입니다. 역순으로 읽는 게 정말 더 느린지, 느리다면 얼마나 느린지는 측정해야 압니다.
docker로 PostgreSQL을 띄우고 500만 행을 넣어 직접 재봤습니다. 결론부터 말하면 이 주장은 틀렸습니다. 단일 컬럼 정렬에서 인덱스 방향은 성능에 영향을 주지 않았습니다.
같이 확인한 커버링 인덱스는 다른 결과가 나왔습니다. 효과가 있지만 조건이 붙고, 조건을 벗어나면 오히려 손해였습니다.
이 글은 그 측정 기록입니다. 인덱스를 뒤에서부터 읽으면 정말 느려지는지, 조회할 컬럼 값을 인덱스에 같이 저장해 두면 언제 이득인지를 차례로 확인합니다.
측정 환경 만들기
먼저 재현 가능한 환경부터 만들었습니다. docker로 PostgreSQL 18을 띄우고, 로그성 테이블에 500만 행을 넣습니다.
docker run -d --name pg-index-bench \
-e POSTGRES_PASSWORD=bench -e POSTGRES_DB=bench \
-p 55432:5432 postgres:18테이블은 로그 데이터를 흉내 냈습니다. 6초 간격으로 500만 건이면 대략 1년 치입니다.
CREATE TABLE log_data (
id bigserial PRIMARY KEY,
ts timestamptz NOT NULL,
level text NOT NULL,
service text NOT NULL,
message text NOT NULL
);
INSERT INTO log_data (ts, level, service, message)
SELECT
timestamptz '2025-01-01 00:00:00+00' + (i * interval '6 seconds'),
(ARRAY['DEBUG','INFO','WARN','ERROR'])[1 + (i % 4)],
(ARRAY['api','worker','scheduler','gateway'])[1 + (i % 4)],
md5(i::text) || repeat('.', 80)
FROM generate_series(1, 5000000) i;
VACUUM ANALYZE log_data;
SELECT count(*), pg_size_pretty(pg_total_relation_size('log_data')) FROM log_data;적재 결과는 500만 행, 테이블과 PK 인덱스를 합쳐 939MB입니다.
(pg_total_relation_size는 힙뿐 아니라 딸린 인덱스까지 더한 값이라, 이 시점에는 log_data_pkey가 함께 잡혀 있습니다.)
측정에서 제일 신경 쓴 건 한 번만 재고 결론 내리지 않는 것이었습니다.
EXPLAIN ANALYZE를 한 번 돌린 값은 캐시 상태에 따라 두 배씩 튑니다.
실제로 이번 측정에서도 단발 실행으로는 비교 결과가 뒤집히는 경우가 있었습니다.
그래서 같은 쿼리를 여러 번 돌려 중앙값을 보는 함수를 하나 만들어 썼습니다.
-- 같은 쿼리를 n회 실행해 실행시간 분포를 돌려준다.
-- EXPLAIN ANALYZE의 "Execution Time"만 뽑아 쓰므로 클라이언트 왕복 시간은 빠진다.
CREATE OR REPLACE FUNCTION bench(q text, n int DEFAULT 10)
RETURNS TABLE(runs int, min_ms numeric, med_ms numeric, max_ms numeric) AS $$
DECLARE
j json;
times numeric[] := '{}';
BEGIN
FOR i IN 1..n LOOP
EXECUTE 'EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) ' || q INTO j;
times := array_append(times, (j->0->>'Execution Time')::numeric);
END LOOP;
RETURN QUERY
SELECT n,
round(min(x)::numeric, 2),
round(percentile_cont(0.5) WITHIN GROUP (ORDER BY x)::numeric, 2),
round(max(x)::numeric, 2)
FROM unnest(times) x;
END;
$$ LANGUAGE plpgsql;이제 SELECT * FROM bench('SELECT ...', 20) 한 줄로 20회 반복 측정이 됩니다.
인덱스 유무 비교
본론에 들어가기 전에 기준선부터 잡았습니다. 인덱스가 아예 없을 때입니다.
SELECT * FROM log_data ORDER BY ts DESC LIMIT 1000;Limit (actual time=474.719..486.259 rows=1000.00 loops=1)
-> Gather Merge (actual time=471.228..482.724 rows=1000.00 loops=1)
Workers Planned: 2
Workers Launched: 2
-> Sort (actual time=458.305..458.327 rows=547.00 loops=3)
Sort Key: ts DESC
Sort Method: top-N heapsort Memory: 564kB
-> Parallel Seq Scan on log_data (actual time=0.378..217.953 rows=1666666.67 loops=3)
Execution Time: 525.450 ms중앙값 412.52ms. 1000행만 필요한데 500만 행을 전부 훑고(Parallel Seq Scan) 정렬합니다(top-N heapsort).
여기에 인덱스를 하나 만들면 이렇게 바뀝니다.
CREATE INDEX idx_asc ON log_data (ts);중앙값 0.13ms. 412ms에서 0.13ms, 약 3,000배입니다. 여기까지는 예상대로였습니다.
인덱스 방향 비교(ASC vs DESC)
이제 이 주장을 확인할 차례입니다. 같은 쿼리를 ASC 인덱스와 DESC 인덱스에서 각각 20회씩 돌렸습니다.
먼저 평범한 오름차순 인덱스인 (ts)입니다.
--- ASC 인덱스 (ts) ---
Limit (cost=0.43..47.68 rows=1000 width=141) (actual time=0.102..0.335 rows=1000.00 loops=1)
Buffers: shared hit=22 read=6
-> Index Scan Backward using idx_asc on log_data
(cost=0.43..236238.45 rows=5000001 width=141) (actual time=0.102..0.294 rows=1000.00 loops=1)
Execution Time: 0.387 ms
runs | min_ms | med_ms | max_ms
------+--------+--------+--------
20 | 0.12 | 0.13 | 0.31이 주장대로 Index Scan Backward, 즉 역방향 스캔이 잡혔습니다.
다음은 (ts DESC)입니다. 주장에 따르면 이 내림차순 인덱스를 만들어야 정방향 스캔이 됩니다.
--- DESC 인덱스 (ts DESC) ---
Limit (cost=0.43..47.68 rows=1000 width=141) (actual time=0.115..0.316 rows=1000.00 loops=1)
Buffers: shared hit=22 read=5
-> Index Scan using idx_desc on log_data
(cost=0.43..236238.39 rows=4999997 width=141) (actual time=0.115..0.266 rows=1000.00 loops=1)
Execution Time: 0.371 ms
runs | min_ms | med_ms | max_ms
------+--------+--------+--------
20 | 0.12 | 0.12 | 0.20Backward가 사라지고 정방향 Index Scan이 됐습니다. 주장과 일치하는 결과입니다.
다만 실행 시간은 중앙값 0.13ms와 0.12ms로 차이가 없습니다. 최솟값은 0.12ms로 아예 같습니다.
읽은 버퍼도 28개와 27개로 사실상 동일합니다.
더 결정적인 건 비용 추정치입니다. 두 계획 모두 cost=0.43..47.68로 소수점까지 완전히 같습니다.
플래너 자신이 역방향 스캔과 정방향 스캔을 같은 비용으로 취급하고 있다는 뜻입니다.
인덱스 크기도 봤는데, 이것도 같았습니다.
indexrelname | size
--------------+--------
idx_asc | 107 MB
idx_desc | 107 MB차이가 없는 이유
PostgreSQL의 B-tree 인덱스는 리프 페이지들이 양쪽으로 연결되어 있습니다. 각 리프 페이지에는 오른쪽 형제와 왼쪽 형제를 가리키는 포인터가 모두 있습니다. 그래서 뒤에서부터 읽을 때도 앞에서부터 읽을 때와 똑같이 포인터를 따라 한 페이지씩 이동합니다.
DESC 인덱스를 새로 만들면 같은 순서 정보를 역순으로 한 벌 더 저장하게 됩니다. 107MB를 더 쓰고 얻는 게 없습니다.
읽는 행이 많을 때
역방향 스캔이 불리하다는 이야기에는 나름의 근거가 있습니다.
정방향 순차 읽기는 OS나 스토리지의 미리 읽기(read-ahead) 혜택을 받지만, 역방향은 그 혜택을 받기 어렵다는 설명입니다.
1000행만 읽어서는 이 차이가 드러나지 않을 수 있으니, LIMIT을 50만으로 올려 30회씩 다시 재봤습니다.
--- LIMIT 500000, 30회 반복 ---
ASC 인덱스 (역방향) : min 62.06 / med 66.52 / max 83.88
DESC 인덱스 (정방향) : min 64.74 / med 67.21 / max 98.63
--- 인덱스를 새로 만들고 VACUUM 후 한 번 더 ---
ASC 인덱스 (역방향) : min 64.86 / med 72.24 / max 87.15
DESC 인덱스 (정방향) : min 71.41 / med 97.78 / max 194.7550만 행을 훑어도 차이가 없습니다. 오히려 두 라운드 모두 역방향이 근소하게 빨랐습니다. 이 결과를 두고 역방향이 더 빠르다고 말할 수는 없습니다. 편차 안에 묻히는 수준입니다. 다만 역방향 스캔에 눈에 띄는 손해는 없었습니다.
한 가지 덧붙이면, 이 측정은 테이블과 인덱스를 합쳐 1GB 남짓 되는 데이터가 대부분 메모리에 올라온 상태에서 이뤄졌습니다. 디스크에서 매번 읽어야 하는 환경이라면 미리 읽기 이야기가 더 유효할 수 있습니다. 하지만 이 측정 결과로는 인덱스를 하나 더 만들 만한 이유를 찾지 못했습니다. 인덱스 유무에 따른 차이는 3,000배였지만, 방향에 따른 차이는 두 라운드 모두 측정 편차 안에 머물렀습니다.
DESC 인덱스가 필요한 경우
여기서 그만두면 "인덱스 방향은 신경 쓸 필요 없다"가 되는데, 그것도 사실이 아닙니다.
DESC를 명시해야만 하는 경우가 분명히 있습니다. 정렬 방향이 섞일 때입니다.
로그를 서비스별로 묶고, 각 서비스 안에서는 최신순으로 보고 싶다고 해봅시다.
SELECT * FROM log_data ORDER BY service ASC, ts DESC LIMIT 1000;service는 오름차순, ts는 내림차순입니다. 먼저 평범한 복합 인덱스를 만들어 봤습니다.
CREATE INDEX idx_mixed_plain ON log_data (service, ts);Limit (cost=165029.96..165249.01 rows=1000) (actual time=548.504..553.979 rows=1000.00 loops=1)
Buffers: shared hit=4422 read=111180 written=248
-> Gather Merge
Workers Planned: 2
Workers Launched: 2
-> Incremental Sort (actual time=534.359..534.400 rows=550.00 loops=3)
Sort Key: service, ts DESC
Presorted Key: service
Full-sort Groups: 1 Sort Method: quicksort Average Memory: 41kB
Pre-sorted Groups: 1 Sort Method: top-N heapsort Average Memory: 564kB
-> Parallel Index Scan using idx_mixed_plain on log_data
(actual time=0.094..435.165 rows=416667.67 loops=3)
Execution Time: 573.375 ms
runs | min_ms | med_ms | max_ms
------+--------+--------+--------
10 | 318.60 | 377.88 | 569.01중앙값 377.88ms. 계획을 보면 무슨 일이 벌어졌는지 그대로 나옵니다.
Presorted Key: service는 인덱스 덕분에 service까지 정렬이 맞다는 뜻입니다.
하지만 그 안의 ts DESC는 맞지 않아서 Incremental Sort가 붙었습니다.
그리고 rows=416667.67 loops=3을 보면, 워커 2개와 리더까지 세 프로세스가 41만여 행씩, 모두 125만 행을 읽었습니다. 필요한 행은 1000개입니다.
service 그룹 하나(약 125만 행)를 전부 읽고 그 안에서 다시 정렬해야 해서, LIMIT이 읽는 행 수를 크게 줄이지 못했습니다.
이번엔 방향을 맞춘 인덱스를 만들어 봅니다.
CREATE INDEX idx_mixed_desc ON log_data (service ASC, ts DESC);Limit (cost=0.43..114.87 rows=1000 width=141) (actual time=0.060..0.547 rows=1000.00 loops=1)
Buffers: shared hit=86 read=6
-> Index Scan using idx_mixed_desc on log_data
(actual time=0.059..0.506 rows=1000.00 loops=1)
Execution Time: 0.612 ms
runs | min_ms | med_ms | max_ms
------+--------+--------+--------
10 | 0.13 | 0.23 | 0.34Sort가 완전히 사라지고 순수 Index Scan이 됐습니다. 377.88ms에서 0.23ms, 약 1,600배입니다.
복합 인덱스와 단일 컬럼의 차이
단일 컬럼일 때는 방향이 무의미했지만, 이 쿼리처럼 정렬 방향이 섞인 경우에는 복합 인덱스의 방향이 결정적입니다. 인덱스를 뒤집어 읽는 것으로 만들 수 있는 순서가 무엇인지 보면 이유를 알 수 있습니다.
(service, ts) 인덱스를 읽는 방법은 두 가지뿐입니다.
- 정방향으로 읽으면
service ASC, ts ASC - 역방향으로 읽으면
service DESC, ts DESC
역방향으로 읽으면 두 컬럼이 같이 뒤집힙니다. 한쪽만 뒤집을 수는 없습니다.
그래서 service 값이 여러 개 섞여 나오는 한, service ASC, ts DESC는 이 인덱스를 어떻게 읽어도 만들 수 없는 순서입니다.
여기서 한 가지 단서를 붙여야 합니다. WHERE service = 'api'처럼 선행 컬럼이 한 값으로 고정되면 이야기가 달라집니다.
그때는 플래너가 service를 상수로 보고 정렬 조건에서 지워 버리기 때문에, 필요한 정렬이 사실상 ts DESC 하나로 줄어 역방향 스캔만으로 끝납니다.
복합 인덱스의 방향이 문제가 되는 건 선행 컬럼이 실제로 여러 값을 오갈 때입니다.
단일 컬럼일 때 방향이 무의미했던 것도 같은 이유입니다. 컬럼이 하나뿐이면 전체를 뒤집는 것만으로 원하는 순서가 나오기 때문입니다. 뒤집기로 만들 수 있으면 방향은 공짜고, 만들 수 없으면 인덱스를 새로 만들어야 합니다.
NULLS 위치라는 예외
"컬럼이 하나면 방향은 공짜"라고 했는데, 여기에는 예외가 있습니다. 인덱스를 뒤집으면 정렬 방향만이 아니라 NULL의 위치도 같이 뒤집히기 때문입니다.
PostgreSQL에서 (ts)는 사실 (ts ASC NULLS LAST)입니다. 이걸 역방향으로 읽으면 ts DESC NULLS FIRST만 나옵니다.
그래서 ORDER BY ts DESC는 되지만 ORDER BY ts DESC NULLS LAST는 안 됩니다.
-- (ts) 인덱스에서 ORDER BY ts DESC
Limit
-> Index Only Scan Backward using idx_asc on log_data
-- (ts) 인덱스에서 ORDER BY ts DESC NULLS LAST
Limit
-> Gather Merge
Workers Planned: 2
-> Sort
Sort Key: ts DESC NULLS LAST
-> Parallel Index Only Scan using idx_asc on log_data컬럼이 하나뿐인데도 전체 Sort가 붙었습니다.
주목할 점은 이 ts 컬럼이 NOT NULL이라는 것입니다. NULL이 한 건도 들어갈 수 없는데도 플래너는 이 제약을 고려하지 않습니다.
이때는 방향을 맞춘 인덱스가 실제로 필요합니다.
-- (ts DESC NULLS LAST) 인덱스를 만들면
Limit
-> Index Only Scan using idx_desc_nl on log_data"뒤집기로 만들 수 있는가"라는 기준은 그대로입니다. 다만 뒤집기가 방향과 NULL 위치를 한 묶음으로 뒤집는다는 점까지 같이 봐야 정확합니다.
커버링 인덱스가 이득인 조건
다음으로 확인할 것은 커버링 인덱스에 관한 주장입니다.
INCLUDE로 조회할 컬럼을 인덱스에 넣어 두면 테이블(힙)을 건드리지 않고 인덱스만 읽고 끝낼 수 있다는 내용입니다.
이 주장은 맞았습니다. 다만 이 주장에는 언제 이득인지가 빠져 있습니다.
먼저 1000행만 읽는 경우입니다.
SELECT ts, level, service FROM log_data ORDER BY ts DESC LIMIT 1000;일반 인덱스 (ts DESC) : med 0.14ms
커버링 인덱스 (ts DESC) INCLUDE (level, service) : med 0.11ms0.14ms에서 0.11ms. 빨라지긴 했지만 차이가 미미합니다. 이 정도로는 107MB를 더 쓸 이유가 되기 어렵습니다.
그래서 읽는 양을 늘려 봤습니다. 특정 시점 이후 20만 행을 반환하는 쿼리입니다.
SELECT ts, level, service FROM log_data
WHERE ts >= timestamptz '2025-06-01+00'
ORDER BY ts DESC LIMIT 200000;--- 일반 DESC 인덱스 ---
Limit (cost=0.43..10090.81 rows=200000) (actual time=0.137..46.701 rows=200000.00 loops=1)
Buffers: shared hit=4258 read=549
-> Index Scan using idx_desc on log_data
Execution Time: 51.209 ms
med 32.20ms
--- 커버링 인덱스 ---
Limit (cost=0.43..7461.46 rows=200000) (actual time=0.076..27.374 rows=200000.00 loops=1)
Buffers: shared hit=1 read=988
-> Index Only Scan using idx_covering on log_data
Heap Fetches: 0
Execution Time: 31.727 ms
med 21.22ms여기서는 차이가 확실합니다. 32.20ms에서 21.22ms로 약 34% 빨라졌습니다.
더 눈여겨볼 건 시간이 아니라 Buffers입니다.
4,807개에서 989개로 약 5분의 1이 됐습니다.
Index Scan은 인덱스에서 찾은 20만 개의 위치로 힙 페이지를 하나씩 찾아가야 하는데, Index Only Scan은 인덱스 페이지만 순서대로 읽고 끝납니다. Heap Fetches: 0이 그 증거입니다.
즉 커버링 인덱스의 이득은 읽는 행 수에 비례해서 커집니다. 1000행에서는 아낄 힙 접근 자체가 얼마 없었습니다.
SELECT *를 쓴 경우
INCLUDE에 넣지 않은 컬럼을 조회하면 결과가 달라집니다.
SELECT * FROM log_data ORDER BY ts DESC LIMIT 1000;
-> Index Scan using idx_covering on log_dataIndex Only Scan이 아니라 그냥 Index Scan이 잡힙니다.
message 컬럼이 인덱스에 없으니 힙에 가야 하고, 커버링 인덱스를 만든 의미가 사라집니다.
필요한 컬럼만 SELECT하는 것은 커버링 인덱스가 동작하기 위한 전제 조건입니다.
커버링 인덱스가 오히려 손해인 경우
여기까지는 조건만 맞으면 이득이라는 이야기였습니다. 조건을 벗어나면 손해가 나는 경우도 있습니다.
집계 쿼리가 그런 경우입니다.
SELECT count(*) FROM log_data WHERE ts >= timestamptz '2025-06-01+00';--- 일반 DESC 인덱스 ---
-> Parallel Index Only Scan using idx_desc on log_data
Heap Fetches: 0
Buffers: shared hit=9 read=7723
med 71.53ms
--- 커버링 인덱스 ---
-> Parallel Index Only Scan using idx_covering on log_data
Heap Fetches: 0
Buffers: shared hit=9 read=13922
med 80.52ms커버링 인덱스 쪽이 더 느립니다. 71.53ms에서 80.52ms입니다.
이유는 Buffers에 그대로 나옵니다. 읽은 페이지가 7,723개에서 13,922개로 거의 두 배입니다.
count(*)는 컬럼 값 자체가 필요 없어서 일반 인덱스로도 Index Only Scan이 잡힙니다(측정 직전 VACUUM을 돌려 Heap Fetches: 0인 상태입니다).
INCLUDE로 붙인 level, service 컬럼은 이 쿼리에 아무 도움이 안 되면서, 인덱스만 107MB에서 193MB로 불려 놓았습니다.
읽을 페이지가 늘었으니 느려지는 게 당연합니다.
쓰기 비용도 재봤습니다. 10만 건을 INSERT하는 시간입니다.
인덱스 없음 (PK만) : med 155.66ms
일반 DESC 인덱스 : med 222.52ms (+43%)
커버링 인덱스 : med 276.81ms (+78%)커버링 인덱스는 이 측정에서 쓰기를 78% 느리게 만들었습니다. 인덱스에 넣은 컬럼이 늘었으니 INSERT마다 기록할 것도 늘어났기 때문입니다.
다만 이 숫자는 앞의 측정값보다 느슨하게 봐야 합니다. 각 조건을 3회씩 재서 구한 중앙값입니다. 또 세 조건을 순서대로 재는 동안 실행마다 10만 행이 테이블에 실제로 남아서, 뒤로 갈수록 테이블이 커진 상태(500만 행 → 590만 행)에서 측정했습니다. 쓰기가 느려지는 방향은 분명하지만, 43%와 78%라는 폭은 그대로 옮겨 쓸 값이 아닙니다.
INCLUDE로 컬럼을 추가하면 찾을 때는 편해지지만 인덱스 자체가 커집니다.
인덱스를 전부 훑어야 하는 집계 쿼리에서는 커진 인덱스가 그대로 손해이고, 쓰기가 일어날 때마다 갱신할 데이터도 늘어납니다.
VACUUM과 Index Only Scan
마지막으로 실무에서 자주 걸리는 경우를 하나 확인했습니다.
Index Only Scan이라는 이름과 달리, 이 스캔은 조건이 맞지 않으면 힙을 다시 읽습니다.
PostgreSQL의 인덱스는 각 행이 현재 트랜잭션에서 보여도 되는지(가시성)를 저장하지 않습니다.
그래서 인덱스만 읽고 끝내려면 "이 페이지의 모든 행은 모두에게 보인다"는 정보가 따로 필요한데, 그게 visibility map이고 이걸 갱신하는 게 VACUUM입니다.
VACUUM 직후에는 이렇습니다.
-> Index Only Scan using idx_covering on log_data
Heap Fetches: 0
Execution Time: 0.554 ms여기서 최근 구간의 행들을 UPDATE해서 visibility map을 더럽힌 뒤 같은 쿼리를 돌리면 이렇게 바뀝니다.
-> Index Only Scan using idx_covering on log_data
Heap Fetches: 1999
Execution Time: 0.756 ms계획 이름은 여전히 Index Only Scan인데 Heap Fetches가 1999입니다.
1000행을 돌려주려고 힙을 1999번 찾아갔습니다.
다시 VACUUM ANALYZE를 돌리면 0으로 돌아옵니다.
-> Index Only Scan using idx_covering on log_data
Heap Fetches: 0
Execution Time: 0.391 ms로그 테이블처럼 쓰기가 잦은 테이블에 커버링 인덱스를 걸었다면, Heap Fetches가 0인지 꼭 확인해야 합니다.
0이 아니라면 autovacuum이 따라오지 못하고 있다는 신호입니다.
정리
측정 결과를 한 표로 모으면 이렇습니다. PostgreSQL 18.4, 500만 행 기준입니다. 반복 횟수가 항목마다 달라서 마지막 열에 같이 적었습니다. 3회짜리와 30회짜리를 같은 무게로 읽으면 안 됩니다.
| 시나리오 | 비교 | 결과 | 반복 |
|---|---|---|---|
ORDER BY ts DESC LIMIT 1000 | 인덱스 없음 | 412.52ms | 5 |
(ts): Index Scan Backward | 0.13ms | 20 | |
(ts DESC): Index Scan | 0.12ms | 20 | |
ORDER BY ts DESC LIMIT 500000 | (ts) 역방향 | 66.52ms | 30 |
(ts DESC) 정방향 | 67.21ms | 30 | |
ORDER BY service ASC, ts DESC | (service, ts): Incremental Sort | 377.88ms | 10 |
(service ASC, ts DESC): Index Scan | 0.23ms | 10 | |
| 20만 행 반환 | (ts DESC): Index Scan | 32.20ms | 10 |
| INCLUDE 추가: Index Only Scan | 21.22ms | 10 | |
count(*) (약 282만 행) | (ts DESC) | 71.53ms | 10 |
| INCLUDE 추가 | 80.52ms | 10 | |
| INSERT 10만 건 | 인덱스 없음 | 155.66ms | 3 |
(ts DESC) | 222.52ms | 3 | |
| INCLUDE 추가 | 276.81ms | 3 |
여기서 끌어낸 원칙은 세 가지입니다.
- 효과의 대부분은 인덱스 유무에서 나온다. 412ms에서 0.13ms, 약 3,000배입니다. 방향이나
INCLUDE를 고민하기 전에 인덱스가 걸려 있는지 확인하는 게 먼저입니다. - 인덱스 방향은 뒤집기로 만들 수 없는 순서일 때만 의미가 있다. 단일 컬럼 정렬이면
DESC를 붙이든 말든 같습니다. 단,NULLS위치까지 지정하면 예외입니다.ORDER BY a ASC, b DESC처럼 방향이 섞일 때는 인덱스에 방향을 명시해야 하며, 이때는 차이가 1,600배까지 벌어집니다. - 커버링 인덱스는 읽는 행이 많을 때만 값어치를 한다. 20만 행에서는 34% 이득이었지만, 1000행에서는 미미했고
count(*)에서는 오히려 손해였습니다. 그리고 인덱스 크기는 80% 늘고, 쓰기는 이 측정에서 78% 느려졌습니다.
세 가지 결과에 공통되는 기준은 "이 쿼리가 몇 페이지를 읽어야 하는가" 하나입니다.
Execution Time보다 Buffers와 Heap Fetches를 먼저 보는 이유입니다. 시간은 캐시 상태에 따라 흔들리지만, 읽은 페이지 수는 캐시 상태와 무관하게 일정합니다.
마치며
DESC 인덱스가 더 빠르다는 주장은 직관적으로 그럴듯합니다. 역순으로 읽으면 손해일 것 같고, 방향을 맞춰 주면 이득일 것 같습니다. 하지만 B-tree 리프 페이지는 양방향으로 연결돼 있어서 이 직관은 맞지 않습니다. 이런 주장이 맞는지는 EXPLAIN ANALYZE로 직접 확인해야 합니다.
이 글에 쓴 측정 스크립트는 위에 그대로 옮겨 두었으니, 각자 환경에서 직접 재볼 수 있습니다.
긴 글 읽어주셔서 감사합니다.