PostgreSQL
PostgreSQL 쿼리 튜닝: 인덱스, LATERAL 사용
dewstream
2025. 9. 1. 08:00
728x90
※ PostgreSQL Query Tuning: Using Indexes and LATERAL.
안녕하세요. 듀스트림입니다.
오늘 포스팅은 인덱스의 중요성을 보여주는 튜닝 사례입니다. (실제 운영 환경에서 튜닝 후 약 110배 성능 향상되었습니다.)
1. AS-IS 쿼리
▸ 이 쿼리는 기준 시각 이후로 조건에 맞는 계정별 가장 최근 사용 내역을 뽑는 쿼리로 하루에 수천만 번 이상 동작합니다.
-- 테이블과 컬럼명을 가칭으로 바꾼 쿼리입니다.
SELECT table_a.id,
max(table_b.created_at) as last_at
FROM table_a
INNER JOIN table_b
ON table_b.a_id = table_a.id
AND table_b.created_at >= $1::timestamp
WHERE table_a.enabled = $2
AND table_a.b IN ($3, $4)
AND table_a.c_id = $5
AND table_a.c_status IN ($6, $7)
GROUP BY table_a.id;
-- 바인드를 리터럴로 치환한 쿼리입니다.
-- 실제로는 더 복잡한 데이터가 들어갑니다.
SELECT table_a.id,
max(table_b.created_at) as last_at
FROM table_a
INNER JOIN table_b
ON table_b.a_id = table_a.id
AND table_b.created_at >= '2025-08-18 08:00:00'::timestamp
WHERE table_a.enabled = 't'
AND table_a.b IN (3, 4)
AND table_a.c_id = 9
AND table_a.c_status IN (2, 3)
GROUP BY table_a.id;
▸ 실행계획
QUERY PLAN
-----------------------------------------------------------------------------------------------------------------------------------------------------
GroupAggregate (cost=540668.32..541682.77 rows=2618 width=16) (actual time=1665.496..1679.898 rows=2593 loops=1)
Group Key: table_a.id
Buffers: shared hit=123783 read=250502
-> Gather Merge (cost=540668.32..541616.98 rows=7923 width=16) (actual time=1665.483..1678.798 rows=8413 loops=1)
Workers Planned: 4
Workers Launched: 3
Buffers: shared hit=123783 read=250502
-> Sort (cost=539668.26..539673.21 rows=1981 width=16) (actual time=1658.375..1658.584 rows=2103 loops=4)
Sort Key: table_a.id
Sort Method: quicksort Memory: 162kB
Buffers: shared hit=123783 read=250502
Worker 0: Sort Method: quicksort Memory: 163kB
Worker 1: Sort Method: quicksort Memory: 164kB
Worker 2: Sort Method: quicksort Memory: 112kB
-> Parallel Hash Join (cost=13675.30..539559.78 rows=1981 width=16) (actual time=40.387..1656.446 rows=2103 loops=4)
Hash Cond: (table_b.a_id = table_a.id)
Buffers: shared hit=123672 read=250502
-> Parallel Seq Scan on table_b (cost=0.00..523898.40 rows=756596 width=16) (actual time=0.046..1512.071 rows=771926 loops=4)
Filter: (created_at >= '2025-08-18 08:00:00'::timestamp without time zone)
Rows Removed by Filter: 11728074
Buffers: shared hit=123259 read=244389
-> Parallel Hash (cost=13661.67..13661.67 rows=1091 width=8) (actual time=38.684..38.685 rows=678 loops=4)
Buckets: 4096 Batches: 1 Memory Usage: 192kB
Buffers: shared hit=266 read=6113
-> Parallel Seq Scan on table_a (cost=0.00..13661.67 rows=1091 width=8) (actual time=0.144..38.327 rows=678 loops=4)
Filter: (enabled AND (b = ANY ('{3,4}'::integer[])) AND (c_status = ANY ('{2,3}'::integer[])) AND (c_id = 9))
Rows Removed by Filter: 249322
Buffers: shared hit=266 read=6113
Planning:
Buffers: shared hit=170 read=8
Planning Time: 1.102 ms
Execution Time: 1680.161 ms
(32 rows)
-- 테스트 환경에서는 1.7초 정도로 나오지만 실제 운영 환경에서는 30초 내외로 걸리는 쿼리였습니다.
• 실행계획을 보시면 적절한 인덱스가 없어서 테이블 풀스캔을 해버리고 정렬 비용이 많이 든다는 게 문제였습니다. 그로 인해 발생되는 Disk I/O가 엄청난 쿼리입니다.
• 흐름: table_b Parallel Seq Scan, 필터(creatted_at) → table_a와 해시 조인 → sort → GroupAggregate
2. 인덱스 생성
-- table_a: WHERE 필터 컬럼 + GROUP BY id
CREATE INDEX CONCURRENTLY idx_table_a_filters ON table_a (enabled, b, c_id, c_status, id);
-- table_b: 조인 + created_at 범위/정렬
CREATE INDEX CONCURRENTLY idx_table_b_sort ON table_b (a_id, created_at DESC);
• 첫 번째 인덱스는 table_a의 조건이 충분히 선별적일 때 table_a를 작게 만든 뒤 table_b에 붙이도록 도와주는 인덱스로 드라이빙 시 불필요한 힙 접근을 감소시킵니다. (필터로 대상을 좁힌 뒤 id를 뽑는 의도입니다.)
• 두 번째 인덱스는 계정별 최신 1건을 정렬/집계 없이 가져올 수 있게 만듭니다.
▸ 인덱스 생성 후 실행계획
QUERY PLAN
-----------------------------------------------------------------------------------------------------------------------------------------------------
HashAggregate (cost=4778.82..4805.01 rows=2618 width=16) (actual time=428.015..428.399 rows=2593 loops=1)
Group Key: table_a.id
Batches: 1 Memory Usage: 369kB
Buffers: shared hit=14953 read=3616
-> Nested Loop (cost=0.99..4739.21 rows=7923 width=16) (actual time=0.886..423.346 rows=8413 loops=1)
Buffers: shared hit=14953 read=3616
-> Index Only Scan using idx_table_a_filters on table_a (cost=0.42..84.78 rows=2618 width=8) (actual time=0.690..2.881 rows=2712 loops=1)
Index Cond: ((enabled = true) AND (b = ANY ('{3,4}'::integer[])) AND (c_id = 9) AND (c_status = ANY ('{2,3}'::integer[])))
Heap Fetches: 0
Buffers: shared hit=8 read=17
-> Index Only Scan using idx_table_b_sort on table_b (cost=0.56..1.74 rows=4 width=16) (actual time=0.151..0.154 rows=3 loops=2712)
Index Cond: ((a_id = table_a.id) AND (created_at >= '2025-08-18 08:00:00'::timestamp without time zone))
Heap Fetches: 0
Buffers: shared hit=14945 read=3599
Planning:
Buffers: shared hit=232 read=9
Planning Time: 2.570 ms
Execution Time: 428.666 ms
(18 rows)
• 인덱스 생성 후 Nested Loop + Index Only Scan으로 4배 이상 속도가 좋아졌습니다. (실제 운영 쿼리는 30배 이상 향상되었습니다.)
※ DML 발생 시 Visibility Map(최초 SELECT 또는 VACUUM 필요)이 제대로 갱신되지 않으면 Heap Fetches가 증가할 수 있습니다.
3. TO-BE
▸ LATERAL로 튜닝한 쿼리입니다.
SELECT a.id, b.last_at
FROM (
SELECT id
FROM table_a
WHERE enabled = $2
AND b IN ($3, $4)
AND c_id = $5
AND c_status IN ($6, $7)
) AS a
JOIN LATERAL (
SELECT created_at AS last_at
FROM table_b
WHERE a_id = a.id
AND created_at >= $1::timestamptz
ORDER BY created_at DESC
LIMIT 1
) AS b ON TRUE;
-- 바인드를 리터럴로 치환한 쿼리입니다.
SELECT a.id, b.last_at
FROM (
SELECT id
FROM table_a
WHERE enabled = 't'
AND b IN (3, 4)
AND c_id = 9
AND c_status IN (2, 3)
) AS a
JOIN LATERAL (
SELECT created_at AS last_at
FROM table_b
WHERE a_id = a.id
AND created_at >= '2025-08-18 08:00:00'::timestamptz
ORDER BY created_at DESC
LIMIT 1
) AS b ON TRUE;
• table_a의 후보 id를 먼저 만들고, 각 id 마다 인덱스 앞부분 1건만 읽어오는 형태입니다.
• table_a의 후보가 작을수록 성능이 좋아집니다.
• $1 바인드는 table_b.created_at가 timestamptz라서 타입을 맞췄습니다. (플래너/인덱스 사용이 더 안정적으로 됩니다.)
• ORDER BY created_at DESC를 명시하여 통계 변화나 플랜 변동 시에도 안정성을 확보하였습니다.
▸ 튜닝 후 실행계획
QUERY PLAN
-----------------------------------------------------------------------------------------------------------------------------------------------
Nested Loop (cost=0.99..2362.44 rows=2618 width=16) (actual time=0.089..16.448 rows=2593 loops=1)
Buffers: shared hit=13258
-> Index Only Scan using idx_table_a_filters on table_a (cost=0.42..84.78 rows=2618 width=8) (actual time=0.034..0.489 rows=2712 loops=1)
Index Cond: ((enabled = true) AND (b = ANY ('{3,4}'::integer[])) AND (c_id = 9) AND (c_status = ANY ('{2,3}'::integer[])))
Heap Fetches: 0
Buffers: shared hit=19
-> Limit (cost=0.56..0.86 rows=1 width=8) (actual time=0.006..0.006 rows=1 loops=2712)
Buffers: shared hit=13239
-> Index Only Scan using idx_table_b_sort on table_b (cost=0.56..1.75 rows=4 width=8) (actual time=0.005..0.005 rows=1 loops=2712)
Index Cond: ((a_id = table_a.id) AND (created_at >= '2025-08-18 08:00:00+09'::timestamp with time zone))
Heap Fetches: 0
Buffers: shared hit=13239
Planning:
Buffers: shared hit=8
Planning Time: 0.394 ms
Execution Time: 16.621 ms
(16 rows)
• table_a의 드라이빙 조건을 인덱스가 그대로 커버하고, table_b의 (a_id, created_at DESC) 정렬을 따르는 인덱스에서 상위 1건만 가져와서 해시/정렬/해시집계 작업이 전혀 없습니다.
아래 포스팅들을 참고하시면 조금 더 도움이 되실 것 같습니다.
ANSI SQL: LATERAL
※ ANSI SQL LATERAL. 안녕하세요. 듀스트림입니다. 오늘의 포스팅은 ANSI SQL:1999 표준부터 도입된 기능인 LATERAL JOIN을 PostgreSQL 관점에서 작성한 내용입니다.1. LATERAL?LATERAL 키워드는 FROM 절에서 서브쿼
dewstream.tistory.com
PostgreSQL: 온라인 인덱스 작업(CONCURRENTLY)
※ PostgreSQL: Performing Index Operations Online Using CONCURRENTLY. 안녕하세요. 듀스트림입니다. 또 한 주가 시작되었습니다. 오늘 포스팅은 운영 서비스 중에도 쓰기 중단 없이 인덱스를 추가·재구성·삭제
dewstream.tistory.com
오늘은 여기까지~
728x90