PostgreSQL 쿼리 최적화: 느린 쿼리에서 밀리초 미만까지
프로덕션 데이터베이스에서 사용하는 PostgreSQL 최적화 기법. EXPLAIN ANALYZE, 인덱스 전략, 쿼리 리라이트, 그리고 쿼리 시간을 초 단위에서 마이크로초 단위로 줄인 정확한 변경 사항들.
지난달 새벽 2시에 Slack 메시지를 받았습니다: "대시보드가 타임아웃 됩니다." 프로덕션 서버에 SSH로 접속하고, pg_stat_activity를 열어보니 12개의 결과를 반환하기 위해 1,400만 행을 스캔하는 단일 쿼리를 발견했습니다. 해결책은 30초 만에 생성한 부분 인덱스였습니다. 쿼리가 4.2초에서 0.3밀리초로 바뀌었습니다.
PostgreSQL 성능에 대한 것은 이겁니다. 문제는 거의 이국적이지 않습니다. 놓친 인덱스, 나쁜 통계, 테이블에 10,000행이었을 때는 합리적이었지만 이제 1,000만 행인 쿼리들입니다. 수정은 보통 간단합니다 — 어디를 봐야 하는지 알면요.
이 글은 프로덕션 데이터베이스를 운영하면서 PostgreSQL 쿼리 최적화에 대해 배운 모든 것입니다. 실천 없는 이론은 없습니다. 여기의 모든 기법은 실제 시스템에서 실제 시간을 절약해 주었습니다.
디버깅 마인드셋: 추측하지 말고 측정하라#
느린 쿼리에서 개발자들이 저지르는 가장 큰 실수는 추측입니다. "아마 그 열에 인덱스가 필요할 거야." "아마 JOIN이 느린 거야." "아마 RAM을 더 추가해야 할 거야."
추측을 멈추세요. PostgreSQL은 어떤 데이터베이스보다 최고의 쿼리 분석기 중 하나를 가지고 있습니다. 사용하세요.
EXPLAIN — 청사진#
일반 EXPLAIN은 쿼리를 실제로 실행하지 않고 PostgreSQL이 계획하는 것을 보여줍니다:
EXPLAIN SELECT * FROM orders WHERE customer_id = 42; QUERY PLAN
---------------------------------------------------------------------------
Index Scan using idx_orders_customer_id on orders (cost=0.43..8.45 rows=1 width=128)
Index Cond: (customer_id = 42)
이것은 계획 — 인덱스 스캔 — 과 예상 비용을 알려줍니다. 하지만 이것은 예측입니다. PostgreSQL이 행 수에 대해 완전히 틀릴 수 있습니다. "1행"이라는 추정치가 실제 결과가 50,000행이었던 경우를 본 적이 있습니다. 그런 종류의 오추정은 끔찍한 계획 선택으로 이어집니다.
데이터를 수정하는 DELETE를 분석하거나 실행에 30분 걸리는 쿼리일 때처럼, 실제로 쿼리를 실행하지 않고 계획을 빠르게 보고 싶을 때 일반 EXPLAIN을 사용하세요.
EXPLAIN ANALYZE — 진실#
EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 42; QUERY PLAN
---------------------------------------------------------------------------
Index Scan using idx_orders_customer_id on orders (cost=0.43..8.45 rows=1 width=128)
(actual time=0.021..0.023 rows=1 loops=1)
Index Cond: (customer_id = 42)
Planning Time: 0.085 ms
Execution Time: 0.041 ms
이제 실제 실행 시간과 실제 행 수를 볼 수 있습니다. 여기서 진짜 디버깅이 일어납니다. rows=1 (추정)을 rows=1 (실제)과 비교하세요 — 일치하므로 플래너가 좋은 결정을 내렸습니다. 일치하지 않을 때가 첫 번째 단서입니다.
경고: EXPLAIN ANALYZE는 실제로 쿼리를 실행합니다. UPDATE나 DELETE를 분석한다면 트랜잭션으로 감싸세요:
BEGIN;
EXPLAIN ANALYZE DELETE FROM sessions WHERE expires_at < now();
ROLLBACK;EXPLAIN (ANALYZE, BUFFERS) — 전체 그림#
제가 실제로 90%의 시간에 사용하는 것입니다:
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT o.id, o.total, c.name
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.created_at > '2026-01-01'
AND o.status = 'completed'; QUERY PLAN
---------------------------------------------------------------------------
Hash Join (cost=425.00..1893.25 rows=2150 width=48)
(actual time=2.341..15.892 rows=2347 loops=1)
Hash Cond: (o.customer_id = c.id)
Buffers: shared hit=312 read=45
-> Bitmap Heap Scan on orders o (cost=12.50..1450.75 rows=2150 width=32)
(actual time=0.512..12.105 rows=2347 loops=1)
Recheck Cond: (created_at > '2026-01-01'::date)
Filter: (status = 'completed')
Rows Removed by Filter: 893
Heap Blocks: exact=189
Buffers: shared hit=195 read=45
-> Bitmap Index Scan on idx_orders_created_at (cost=0.00..11.96 rows=3043 width=0)
(actual time=0.287..0.287 rows=3240 loops=1)
Index Cond: (created_at > '2026-01-01'::date)
Buffers: shared hit=12
-> Hash (cost=287.50..287.50 rows=10000 width=24)
(actual time=1.753..1.753 rows=10000 loops=1)
Buffers: shared hit=117
-> Seq Scan on customers c (cost=0.00..287.50 rows=10000 width=24)
(actual time=0.008..0.892 rows=10000 loops=1)
Buffers: shared hit=117
Planning Time: 0.215 ms
Execution Time: 16.147 ms
BUFFERS 출력이 중요합니다. shared hit=312는 312개 페이지가 버퍼 캐시(RAM)에서 왔다는 의미입니다. read=45는 45개 페이지를 디스크에서 읽어야 했다는 의미입니다. hit 대비 read가 많으면 shared_buffers가 너무 작거나 테이블이 캐시에 남기엔 너무 큰 것입니다.
시각화를 위해 explain.dalibo.com에 계획을 붙여넣어야 할 때는 FORMAT JSON도 사용합니다. 트리 뷰가 복잡한 계획을 훨씬 읽기 쉽게 만들어줍니다:
EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) SELECT ...;제가 실제로 사용하는 EXPLAIN 옵션#
느린 쿼리 조사에서 먼저 실행하는 전체 진단 쿼리입니다:
EXPLAIN (ANALYZE, BUFFERS, TIMING, VERBOSE)
SELECT ...;TIMING— 노드별 타이밍 (ANALYZE에서 기본으로 켜져 있지만 명시적으로 지정)VERBOSE— 출력 열 목록과 스키마 한정 테이블 이름을 보여줌
탐정처럼 쿼리 계획 읽기#
모든 쿼리 계획은 트리입니다. PostgreSQL은 아래에서 위로 읽습니다: 가장 깊이 들여쓰기된 노드가 먼저 실행되어 결과를 위로 전달합니다. 스캔 유형을 이해하는 것이 다른 모든 것의 기초입니다.
Seq Scan — 전체 테이블 스캔#
Seq Scan on orders (cost=0.00..25000.00 rows=1000000 width=128)
순차 스캔은 테이블의 모든 행을 읽습니다. 100만 행 테이블이면 디스크의 모든 페이지입니다.
하지만 뉘앙스가 있습니다: Seq Scan이 항상 나쁜 것은 아닙니다. 테이블의 30% 이상을 선택한다면, 순차 I/O가 랜덤 I/O보다 훨씬 빠르기 때문에 순차 스캔이 실제로 인덱스 스캔보다 빠릅니다. PostgreSQL은 이것을 알고 있습니다. 인덱스가 있는데 Seq Scan을 선택한다면, WHERE 절이 실제로 얼마나 선택적인지 확인하세요.
문제가 되는 경우: 큰 테이블에서 아주 작은 비율의 행을 선택하는데 인덱스가 없을 때.
Index Scan — 타겟 조회#
Index Scan using idx_orders_customer_id on orders (cost=0.43..8.45 rows=5 width=128)
Index Cond: (customer_id = 42)
B-tree 인덱스로 가서 일치하는 항목을 찾은 다음, 테이블 힙에서 실제 행을 가져옵니다. 각 행 가져오기는 힙에 대한 랜덤 I/O 작업입니다. 높은 선택성 쿼리에 좋지만 각 힙 페치에 비용이 있습니다.
Index Only Scan — 성배#
Index Only Scan using idx_orders_customer_total on orders (cost=0.43..4.15 rows=5 width=12)
Index Cond: (customer_id = 42)
Heap Fetches: 0
이것은 가장 빠른 스캔 유형입니다. PostgreSQL이 인덱스만으로 필요한 모든 것을 가져와서 테이블 힙을 전혀 건드리지 않습니다. SELECT하고 WHERE하는 모든 열이 인덱스에 있을 때 이것을 봅니다.
주의점: Heap Fetches: 0은 가시성 맵이 최신이라는 의미입니다. 테이블에 죽은 튜플이 많으면 (최근에 vacuum하지 않았다면) PostgreSQL은 여전히 힙을 확인하여 행 가시성을 검증해야 합니다. 이것이 autovacuum이 디스크 공간뿐만 아니라 성능에도 중요한 이유 중 하나입니다.
Bitmap Scan — 중간 지점#
Bitmap Heap Scan on orders (cost=45.00..1250.00 rows=5000 width=128)
Recheck Cond: (created_at > '2026-01-01')
-> Bitmap Index Scan on idx_orders_created_at (cost=0.00..43.75 rows=5000 width=0)
Index Cond: (created_at > '2026-01-01')
비트맵 스캔은 "인덱스 스캔에는 너무 많은 행, seq 스캔에는 너무 적은 행" 문제에 대한 PostgreSQL의 답입니다. 일치하는 행이 있는 페이지의 비트맵을 만들고, 물리적 위치로 정렬한 다음, 순서대로 가져옵니다. 이것은 랜덤 I/O를 순차 I/O로 변환합니다.
두 개 이상의 인덱스가 결합될 때 비트맵 스캔을 자주 봅니다:
Bitmap Heap Scan on orders
Recheck Cond: ((status = 'completed') AND (created_at > '2026-01-01'))
-> BitmapAnd
-> Bitmap Index Scan on idx_orders_status
Index Cond: (status = 'completed')
-> Bitmap Index Scan on idx_orders_created_at
Index Cond: (created_at > '2026-01-01')
이 BitmapAnd는 두 개의 별도 인덱스를 결합합니다. PostgreSQL이 "복합 인덱스는 없지만 이 두 단일 열 인덱스를 결합할 수 있다"고 말하는 것입니다. 작동하지만 적절한 복합 인덱스가 더 빠를 것입니다.
나쁜 추정치 발견하기#
쿼리 계획에서 제가 가장 먼저 찾는 것은 일치하지 않는 추정치입니다:
Nested Loop (cost=0.43..50.12 rows=1 width=128)
(actual time=0.025..4521.337 rows=48753 loops=1)
플래너가 1행을 추정했습니다. 실제 결과는 48,753행이었습니다. 5자릿수 오차입니다. 플래너는 1행에 대해 조인한다고 생각해서 Nested Loop을 선택했습니다. 48,753행이면 Hash Join이 몇 자릿수나 더 빨랐을 것입니다.
나쁜 추정치의 일반적인 원인:
- 오래된 통계: 테이블에
ANALYZE실행 - 상관된 열: 플래너는 열 값이 독립적이라고 가정합니다.
status = 'shipped'와created_at > '2026-01-01'가 상관되어 있으면 (가장 최근 주문이 배송됨) 플래너가 결합 선택도를 과소추정합니다 - WHERE의 커스텀 함수: 플래너가 함수를 분석할 수 없을 때 기본 선택도 추정치를 사용합니다 (보통 동등 비교에 0.5%, 범위에 33%)
- 제네릭 계획이 있는 매개변수화된 쿼리: 5번 실행 후 PostgreSQL이 실제 매개변수 값을 고려하지 않는 제네릭 계획으로 전환할 수 있습니다
나쁜 추정치를 보면 해결책은 보통: ANALYZE 실행, 확장 통계 생성, 쿼리 리라이트, 또는 CTE를 최적화 울타리로 사용하는 것 중 하나입니다.
-- 상관된 열에 대한 확장 통계 생성
CREATE STATISTICS orders_status_date (dependencies)
ON status, created_at FROM orders;
ANALYZE orders;인덱스 전략: 올바른 작업에 올바른 인덱스#
인덱스는 공짜가 아닙니다. 각 인덱스는 쓰기를 느리게 하고, 디스크 공간을 소비하며, 유지 관리가 필요합니다. 목표는 "모든 것에 인덱스"가 아니라 "필요한 것에 정확히 인덱스"입니다.
B-tree — 기본 주력#
B-tree는 기본이며 대다수의 경우를 처리합니다. 동등 및 범위 연산자 (=, <, >, <=, >=, BETWEEN, IN, IS NULL)를 지원합니다.
CREATE INDEX idx_orders_created_at ON orders (created_at);B-tree 인덱스 열 순서는 복합 인덱스에서 매우 중요합니다. 가장 왼쪽 열이 기본 정렬이고, 그다음, 그다음 순입니다. (a, b, c) 인덱스는 효율적으로 답할 수 있는 것:
WHERE a = 1WHERE a = 1 AND b = 2WHERE a = 1 AND b = 2 AND c = 3WHERE a = 1 AND b > 5WHERE a = 1 ORDER BY b
효율적으로 답할 수 없는 것:
WHERE b = 2(첫 번째 열을 건너뜀)WHERE c = 3(처음 두 열을 건너뜀)WHERE a = 1 AND c = 3(중간에 갭 —a조건은 사용됨,c조건은 필터 필요)
성을 기준으로 정렬한 다음 이름으로 정렬한 전화번호부처럼 생각하세요. 모든 "김" 항목을 빠르게 찾을 수 있고, "김영수"를 구체적으로 찾을 수 있습니다. 하지만 전체 책을 스캔하지 않고는 모든 "영수" 항목을 빠르게 찾을 수 없습니다.
규칙: 동등 조건을 먼저, 그다음 범위 조건, 그다음 정렬 열.
-- 쿼리: WHERE status = 'active' AND created_at > '2026-01-01' ORDER BY total DESC
-- 좋음: 동등 먼저, 그다음 범위, 그다음 정렬
CREATE INDEX idx_orders_optimized ON orders (status, created_at, total);
-- 나쁨: 동등 전에 범위
CREATE INDEX idx_orders_bad ON orders (created_at, status, total);INCLUDE를 사용한 커버링 인덱스#
PostgreSQL 11+에서는 INCLUDE로 비키 열을 인덱스에 추가할 수 있습니다. 이러한 열은 리프 페이지에 저장되지만 B-tree 구조의 일부는 아닙니다. 트리를 비대하게 만들지 않으면서 인덱스 온리 스캔을 가능하게 합니다:
-- 자주 실행하는 쿼리:
-- SELECT id, total, status FROM orders WHERE customer_id = 42;
CREATE INDEX idx_orders_customer_covering
ON orders (customer_id) INCLUDE (total, status);이제 PostgreSQL이 인덱스만으로 전체 쿼리를 충족할 수 있습니다. INCLUDE 없이는 힙에서 total과 status를 가져와야 합니다. 콜드 캐시에서의 차이는 극적일 수 있습니다 — INCLUDE 열을 추가하는 것만으로 쿼리가 50ms에서 0.2ms로 간 것을 본 적이 있습니다.
부분 인덱스 — 중요한 것만 인덱싱#
이것은 제가 가장 좋아하는 PostgreSQL 기능이며 대부분의 개발자가 모르는 것입니다.
-- 주문의 2%만 'pending'이지만 지속적으로 쿼리함
CREATE INDEX idx_orders_pending ON orders (created_at)
WHERE status = 'pending';이 인덱스는 created_at 전체 인덱스에 비해 작습니다. status = 'pending'인 행만 포함합니다. WHERE 절에 일치하는 쿼리는 이 작고 빠른 인덱스를 사용합니다. 일치하지 않는 쿼리는 무시합니다.
프로덕션의 실제 예: 5,000만 행의 sessions 테이블이 있었습니다. 약 200,000개만 활성 (만료되지 않음)이었습니다. user_id의 전체 인덱스는 1.2GB였습니다. WHERE expires_at > now() 부분 인덱스는 8MB였습니다. 전체 인덱스가 캐시에 들어갔기 때문에 쿼리가 12ms에서 0.1ms로 갔습니다.
-- 또 다른 일반적인 패턴: 소프트 삭제
CREATE INDEX idx_users_active_email ON users (email)
WHERE deleted_at IS NULL;
-- 활성 레코드에만 유니크 제약 조건
CREATE UNIQUE INDEX idx_users_unique_email ON users (email)
WHERE deleted_at IS NULL;마지막 것은 매우 유용합니다. 활성 사용자에 대한 고유 이메일 제약 조건을 가지면서 삭제된 레코드에는 같은 이메일이 나타날 수 있게 해줍니다.
GIN 인덱스 — 전문 검색과 JSONB#
GIN (Generalized Inverted Index)은 값 내부를 검색해야 할 때의 답입니다 — 배열, JSONB 문서, 또는 전문 검색 벡터.
-- JSONB 포함 쿼리
CREATE INDEX idx_products_metadata ON products USING GIN (metadata);
-- 이제 이것이 빠름:
SELECT * FROM products WHERE metadata @> '{"color": "red"}';
-- 전문 검색
CREATE INDEX idx_articles_search ON articles USING GIN (to_tsvector('english', title || ' ' || body));GIN 인덱스는 크고 만드는 데 느리지만 쿼리하기에 매우 빠릅니다. 복잡한 포함 쿼리가 있는 읽기 중심 워크로드에 가치가 있습니다.
JSONB의 경우, @> 포함 쿼리에 더 작고 빠르지만 존재 연산자 (?, ?|, ?&)를 지원하지 않는 jsonb_path_ops도 있습니다:
CREATE INDEX idx_products_metadata_pathops
ON products USING GIN (metadata jsonb_path_ops);GiST 인덱스 — 기하학적 및 범위 타입#
GiST (Generalized Search Tree)는 겹치는 데이터 타입을 처리합니다: 기하학적 도형, 범위, 전문 검색 (GIN의 대안, 더 작지만 더 느림).
-- IP 범위 조회
CREATE INDEX idx_ip_ranges ON ip_blocks USING GiST (ip_range);
SELECT * FROM ip_blocks WHERE ip_range @> '192.168.1.50'::inet;
-- 시간 범위 (스케줄 충돌)
CREATE INDEX idx_bookings_period ON bookings USING GiST (
tstzrange(start_time, end_time)
);
-- 겹치는 예약 찾기:
SELECT * FROM bookings
WHERE tstzrange(start_time, end_time) && tstzrange('2026-03-01 10:00', '2026-03-01 12:00');Hash 인덱스 — 틈새지만 유용#
Hash 인덱스는 동등 비교에만 유용합니다. PostgreSQL 10부터 WAL 로깅되고 크래시 안전합니다. 넓은 열에 대해 B-tree보다 작고 순수 동등 비교에 약간 더 빠릅니다:
-- 큰 텍스트 열에 대한 동등 전용 조회에 좋음
CREATE INDEX idx_sessions_token ON sessions USING HASH (session_token);
-- 이것은 빠름:
SELECT * FROM sessions WHERE session_token = 'abc123def456...';
-- 이것은 hash 인덱스를 사용할 수 없음:
SELECT * FROM sessions WHERE session_token LIKE 'abc%';실제로 hash 인덱스는 거의 사용하지 않습니다. B-tree가 동등을 잘 처리하고, 범위 쿼리나 정렬을 할 수 없다는 것이 대부분의 실제 사용 사례에서 hash 인덱스를 너무 유연하지 않게 만듭니다.
인덱스가 해가 되는 경우#
모든 인덱스에는 비용이 있습니다:
- 쓰기 증폭: 각 INSERT는 테이블의 모든 인덱스를 업데이트합니다. 8개의 인덱스가 있는 테이블은 INSERT당 8번의 추가 쓰기를 의미합니다
- HOT 업데이트 차단: Heap-Only Tuple (HOT) 업데이트는 인덱스된 열이 변경되지 않은 경우에만 인덱스를 업데이트하지 않고 행을 업데이트할 수 있는 주요 최적화입니다. 더 많은 인덱스 = HOT 업데이트를 차단할 가능성이 더 높음
- Vacuum 오버헤드: 더 많은 인덱스는 vacuum이 더 오래 걸린다는 의미
- 플래너 오버헤드: 더 많은 인덱스는 플래너가 평가할 옵션이 더 많다는 의미
프로덕션 테이블의 인덱스를 정기적으로 감사합니다:
-- 사용되지 않는 인덱스 찾기
SELECT
schemaname, tablename, indexname,
idx_scan,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_stat_user_indexes
WHERE idx_scan = 0
AND indexrelid NOT IN (
SELECT conindid FROM pg_constraint
WHERE contype IN ('p', 'u') -- 기본 키와 유니크 제약 조건 제외
)
ORDER BY pg_relation_size(indexrelid) DESC;일회성 마이그레이션 중에 생성되고 다시는 사용되지 않은 수 기가바이트 인덱스를 발견한 적이 있습니다. 삭제하니 쓰기가 눈에 띄게 빨라졌습니다.
-- 중복 인덱스 찾기 (같은 열, 같은 순서)
SELECT
a.indexrelid::regclass AS index1,
b.indexrelid::regclass AS index2,
a.indrelid::regclass AS table_name
FROM pg_index a
JOIN pg_index b ON a.indrelid = b.indrelid
AND a.indexrelid < b.indexrelid
AND a.indkey = b.indkey;N+1 쿼리 문제#
모든 ORM 개발자가 결국 부딪힙니다. 제가 디버깅하는 가장 흔한 성능 문제입니다.
N+1이 어떤 모습인지#
# Python / SQLAlchemy — 클래식 함정
orders = session.query(Order).filter_by(status='pending').all()
for order in orders:
# 각 주문에 대해 별도의 쿼리를 트리거
print(f"Customer: {order.customer.name}")이것이 생성하는 쿼리:
-- 쿼리 1: 모든 대기 중 주문 가져오기
SELECT * FROM orders WHERE status = 'pending';
-- 쿼리 2: 주문 1의 고객 가져오기
SELECT * FROM customers WHERE id = 101;
-- 쿼리 3: 주문 2의 고객 가져오기
SELECT * FROM customers WHERE id = 102;
-- ...쿼리 502: 주문 500의 고객 가져오기
SELECT * FROM customers WHERE id = 600;1개나 2개 대신 501개의 쿼리. 각 쿼리는 개별적으로 빠릅니다, 아마 0.5ms. 하지만 501개를 합치면 데이터베이스 시간만 250ms이고, 각각에 대한 네트워크 왕복 지연 시간을 더합니다.
로그에서 N+1 감지하기#
N+1 쿼리를 잡는 가장 빠른 방법은 문장 로깅을 일시적으로 활성화하는 것입니다:
-- 0ms 이상 걸리는 모든 쿼리 로깅 (즉, 모든 쿼리)
SET log_min_duration_statement = 0;
-- 프로덕션에 더 좋음: 느린 쿼리만 로깅
ALTER SYSTEM SET log_min_duration_statement = 50; -- 50ms 임계값
SELECT pg_reload_conf();그런 다음 로그를 보세요. N+1은 틀림없습니다 — 빠른 연속으로 다른 매개변수 값을 가진 수백 개의 동일한 쿼리를 보게 됩니다.
개발 환경에 더 타겟팅된 접근:
-- 느린 쿼리에 대한 auto_explain 활성화
LOAD 'auto_explain';
SET auto_explain.log_min_duration = 100; -- 100ms
SET auto_explain.log_nested_statements = ON;
SET auto_explain.log_analyze = ON;이것은 함수 내부의 쿼리를 포함하여 100ms 이상 걸리는 쿼리에 대한 전체 EXPLAIN ANALYZE 출력을 로깅합니다.
해결: 즉시 로딩 또는 JOIN#
ORM 접근 — ORM에게 관련 데이터를 미리 로드하라고 지시:
# SQLAlchemy — joinedload가 같은 쿼리에서 고객을 가져옴
from sqlalchemy.orm import joinedload
orders = (
session.query(Order)
.options(joinedload(Order.customer))
.filter_by(status='pending')
.all()
)생 SQL 접근 — 그냥 JOIN을 사용:
SELECT o.*, c.name AS customer_name
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.status = 'pending';쿼리 하나. 끝.
JOIN vs 다중 쿼리#
여기에는 타당한 논쟁이 있습니다. 때로는 두 개의 쿼리가 JOIN보다 낫습니다:
-- 접근 1: 단일 JOIN (일대다이면 중복 데이터를 생산할 수 있음)
SELECT o.*, i.product_name, i.quantity
FROM orders o
JOIN order_items i ON i.order_id = o.id
WHERE o.status = 'pending';
-- 접근 2: 두 쿼리 (주문에 열이 많으면 더 적은 데이터 전송)
SELECT * FROM orders WHERE status = 'pending';
-- 애플리케이션이 order_ids = [1, 2, 3, ...]를 수집
SELECT * FROM order_items WHERE order_id = ANY($1);JOIN 접근은 모든 항목에 대해 주문 데이터를 중복합니다. 각 주문에 20개의 항목이 있고 주문 행이 넓으면 20배의 데이터 전송입니다. 두 쿼리 접근은 각 주문을 정확히 한 번만 보냅니다.
제 규칙: 일대일 관계에는 JOIN을 사용하고, "일" 쪽이 넓을 때는 일대다에 대해 별도 쿼리를 고려합니다. 하지만 항상 벤치마크하세요 — 두 번째 쿼리의 네트워크 왕복이 중복 데이터보다 더 비용이 드는 경우가 많습니다.
일반적인 쿼리 리라이트#
일부 쿼리는 누락된 인덱스 때문이 아니라 작성 방식 때문에 느립니다. PostgreSQL의 플래너는 좋지만 마법은 아닙니다.
서브쿼리 vs JOIN vs CTE#
이 세 접근 방식은 매우 다른 계획을 생산할 수 있습니다:
-- WHERE의 서브쿼리 — 종종 괜찮지만 때로는 끔찍함
SELECT * FROM orders
WHERE customer_id IN (
SELECT id FROM customers WHERE country = 'US'
);
-- JOIN — 보통 최선의 선택
SELECT o.* FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE c.country = 'US';
-- CTE — PG 11 이하에서는 구체화됨, PG 12+에서는 최적화됨
WITH us_customers AS (
SELECT id FROM customers WHERE country = 'US'
)
SELECT o.* FROM orders o
JOIN us_customers uc ON uc.id = o.customer_id;PostgreSQL 12+에서는 CTE가 보통 인라인됩니다 (플래너가 서브쿼리처럼 취급), 그래서 성능이 동일합니다. 하지만 PostgreSQL 11 이하에서는 CTE가 최적화 울타리입니다 — 플래너가 구체화하고 조건을 통과시킬 수 없습니다. PG 11에 아직 있다면 성능이 중요한 쿼리에서 CTE를 피하세요.
EXISTS vs IN vs JOIN#
이것은 끊임없이 나옵니다:
-- EXISTS — 첫 매치에서 멈춤, 존재 확인에 좋음
SELECT * FROM orders o
WHERE EXISTS (
SELECT 1 FROM returns r WHERE r.order_id = o.id
);
-- 서브쿼리가 있는 IN — 먼저 전체 결과 집합을 만듦
SELECT * FROM orders
WHERE id IN (SELECT order_id FROM returns);
-- JOIN — returns에 주문당 여러 행이 있으면 중복을 생산할 수 있음
SELECT DISTINCT o.* FROM orders o
JOIN returns r ON r.order_id = o.id;큰 테이블에서 EXISTS가 종종 이기는데, 단락 평가하기 때문입니다. IN 버전은 필터링 전에 반품 order_ids의 전체 목록을 만들어야 합니다. JOIN 버전은 중복을 생산할 수 있어 정렬이나 해시 단계를 추가하는 DISTINCT가 필요합니다.
제 기본: 관련 행의 존재를 확인할 때 EXISTS를 사용하세요. 의미적으로 가장 명확하고 보통 가장 빠릅니다.
하지만 반례가 있습니다. 서브쿼리 결과 집합이 작고 여러 조건에 필요하다면:
-- 서브쿼리가 적은 행을 반환하면 IN이 완전히 괜찮음
SELECT * FROM orders
WHERE customer_id IN (1, 2, 3, 4, 5);SELECT * 피하기#
이것은 단순한 코드 스타일 선호가 아닙니다. 실제 성능 함의가 있습니다:
-- 나쁨: 킬로바이트의 데이터가 있는 TEXT 열을 포함한 25개 열 모두 가져옴
SELECT * FROM products WHERE category = 'electronics';
-- 좋음: 필요한 것만 가져옴
SELECT id, name, price FROM products WHERE category = 'electronics';SELECT *로:
- 인덱스 온리 스캔을 사용할 수 없음 (모든 열이 인덱스에 있어야 함)
- 네트워크를 통해 더 많은 데이터 전송
- 정렬과 해싱에 더 많은 메모리 사용
- 누군가 나중에 10MB BYTEA 열을 추가하면 기존 쿼리가 조용히 느려짐
윈도우 함수 vs 서브쿼리#
윈도우 함수는 PostgreSQL의 가장 강력한 기능 중 하나이며 상관 서브쿼리보다 거의 항상 성능이 좋습니다:
-- 느림: 상관 서브쿼리가 행당 한 번 실행
SELECT
o.*,
(SELECT COUNT(*) FROM order_items i WHERE i.order_id = o.id) AS item_count,
(SELECT SUM(amount) FROM payments p WHERE p.order_id = o.id) AS total_paid
FROM orders o
WHERE o.status = 'completed';
-- 빠름: 윈도우 함수가 단일 패스로 계산
SELECT
o.*,
COUNT(i.id) OVER (PARTITION BY o.id) AS item_count,
SUM(p.amount) OVER (PARTITION BY o.id) AS total_paid
FROM orders o
LEFT JOIN order_items i ON i.order_id = o.id
LEFT JOIN payments p ON p.order_id = o.id
WHERE o.status = 'completed';또 다른 일반적인 패턴 — 그룹당 최신 행 가져오기:
-- 느림: 상관 서브쿼리
SELECT * FROM notifications n
WHERE n.created_at = (
SELECT MAX(created_at) FROM notifications
WHERE user_id = n.user_id
);
-- 빠름: DISTINCT ON (PostgreSQL 전용, 매우 유용)
SELECT DISTINCT ON (user_id) *
FROM notifications
ORDER BY user_id, created_at DESC;
-- 역시 빠름: ROW_NUMBER 윈도우 함수
SELECT * FROM (
SELECT *, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn
FROM notifications
) sub
WHERE rn = 1;DISTINCT ON이 이 패턴에 대한 제 선택입니다. 간결하고, 읽기 쉬우며, PostgreSQL이 올바른 인덱스로 잘 최적화합니다:
CREATE INDEX idx_notifications_user_latest
ON notifications (user_id, created_at DESC);올바른 페이지네이션#
OFFSET은 큰 데이터셋에 대한 함정입니다:
-- 1페이지: 빠름
SELECT * FROM products ORDER BY id LIMIT 20 OFFSET 0;
-- 500페이지: 느림 — PostgreSQL이 9,980행을 스캔하고 버려야 함
SELECT * FROM products ORDER BY id LIMIT 20 OFFSET 9980;
-- 5000페이지: 매우 느림 — 20개를 반환하기 위해 99,980행을 스캔
SELECT * FROM products ORDER BY id LIMIT 20 OFFSET 99980;해결책은 키셋 페이지네이션 (커서 기반 페이지네이션이라고도 함)입니다:
-- 첫 페이지
SELECT * FROM products ORDER BY id LIMIT 20;
-- 다음 페이지: 이전 페이지의 마지막 id 사용
SELECT * FROM products WHERE id > 12345 ORDER BY id LIMIT 20;이것은 어떤 "페이지"에 있든 항상 빠릅니다. 인덱스를 사용해 올바른 위치로 바로 점프하기 때문입니다. 트레이드오프는 임의의 페이지 번호로 점프할 수 없다는 것이지만, 무한 스크롤이나 "다음 페이지" UI에서는 키셋 페이지네이션이 엄격히 우수합니다.
복잡한 정렬 순서의 경우:
-- 여러 정렬 열이 있는 키셋 페이지네이션
SELECT * FROM products
WHERE (price, id) > (29.99, 12345)
ORDER BY price, id
LIMIT 20;이것은 PostgreSQL이 (price, id) 복합 인덱스로 효율적으로 처리하는 행 값 비교를 사용합니다.
테이블 통계와 Vacuum#
PostgreSQL의 쿼리 플래너는 데이터에 대한 통계를 기반으로 결정을 내립니다. 나쁜 통계는 나쁜 계획으로 이어집니다. 그렇게 간단합니다.
ANALYZE: 통계 업데이트#
-- 단일 테이블 분석
ANALYZE orders;
-- 전체 데이터베이스 분석
ANALYZE;
-- 열의 현재 통계 보기
SELECT
tablename, attname, n_distinct, most_common_vals, correlation
FROM pg_stats
WHERE tablename = 'orders' AND attname = 'status';correlation 값이 특히 흥미롭습니다. -1에서 1 범위이며 행의 물리적 순서가 열의 논리적 순서와 얼마나 잘 일치하는지 측정합니다. 1이나 -1에 가까운 상관관계는 데이터가 해당 열로 물리적으로 정렬되어 있어 범위 스캔이 매우 효율적(순차 I/O)임을 의미합니다. 0에 가까운 상관관계는 범위 쿼리에 대해 랜덤 I/O를 의미합니다.
-- 편향된 분포를 가진 열에 대한 통계 대상 증가
ALTER TABLE orders ALTER COLUMN status SET STATISTICS 1000;
ANALYZE orders;기본 통계 대상은 100이며, PostgreSQL이 300 * 100 = 30,000행을 샘플링합니다. 고유 값이 많거나 편향된 분포를 가진 열의 경우 500이나 1000으로 올리면 약간 더 긴 ANALYZE 시간의 비용으로 플래너에 더 좋은 데이터를 줍니다.
Autovacuum 튜닝#
Autovacuum은 두 가지를 합니다: 죽은 튜플(삭제되거나 업데이트된 행)의 공간을 회수하고 통계를 업데이트합니다. 바쁜 테이블에서 기본 autovacuum 설정은 종종 너무 보수적입니다.
-- autovacuum 상태 확인
SELECT
relname,
n_live_tup,
n_dead_tup,
last_vacuum,
last_autovacuum,
last_analyze,
last_autoanalyze
FROM pg_stat_user_tables
WHERE n_dead_tup > 1000
ORDER BY n_dead_tup DESC;수백만 개의 죽은 튜플이 있는 테이블과 마지막 vacuum이 몇 시간 전이면, autovacuum이 뒤처지고 있는 것입니다.
높은 변동 테이블(세션, 작업 큐, 메트릭 등)의 경우, 테이블별 autovacuum 설정을 지정합니다:
-- 쓰기가 많은 테이블에 대한 더 공격적인 autovacuum
ALTER TABLE sessions SET (
autovacuum_vacuum_scale_factor = 0.01, -- 1% 죽은 튜플에서 vacuum (기본 20%)
autovacuum_analyze_scale_factor = 0.005, -- 0.5% 변경에서 analyze (기본 10%)
autovacuum_vacuum_cost_delay = 2 -- 더 빠른 vacuum (최신 PG에서 기본 2ms)
);테이블 블로트#
PostgreSQL이 행을 업데이트하면 제자리에서 수정하지 않습니다 — 새 버전을 만들고 이전 것을 죽은 것으로 표시합니다. Vacuum이 죽은 행을 회수하지만, 공간은 해당 테이블에서만 재사용됩니다. 디스크의 테이블 파일은 줄어들지 않습니다.
시간이 지남에 따라 많이 업데이트된 테이블은 상당한 블로트를 가질 수 있습니다 — 테이블이 라이브 데이터가 필요로 하는 것보다 디스크에서 훨씬 큽니다. 이것은 더 많은 페이지를 스캔하고, 더 많은 I/O, 더 많은 캐시 압박을 의미합니다.
-- 테이블 블로트 추정 (간소화 버전)
SELECT
current_database(),
schemaname,
tablename,
pg_size_pretty(pg_total_relation_size(schemaname || '.' || tablename)) AS total_size,
pg_size_pretty(
pg_total_relation_size(schemaname || '.' || tablename) -
pg_relation_size(schemaname || '.' || tablename)
) AS index_size
FROM pg_tables
WHERE schemaname = 'public'
ORDER BY pg_total_relation_size(schemaname || '.' || tablename) DESC
LIMIT 20;심하게 블로트된 테이블의 경우 핵 옵션은 VACUUM FULL입니다 — 전체 테이블을 다시 씁니다. 하지만 배타적 잠금을 잡아서 다운타임 없이 라이브 프로덕션 테이블에서 할 수 없습니다. 더 나은 접근은 잠금 없이 같은 일을 하는 pg_repack입니다:
pg_repack --table orders --no-kill-backend -d mydb커넥션 풀링#
많은 개발자를 놀라게 하는 것이 있습니다: PostgreSQL 연결은 비쌉니다. 각 연결이 새 프로세스를 생성하고 (스레드가 아님), 약 5-10MB의 메모리를 소비하며, 무시할 수 없는 fork 오버헤드가 있습니다.
기본 max_connections는 100입니다. 20개의 워커를 가진 애플리케이션 서버가 각각 5개의 연결을 열면, 이미 한계에 도달합니다. 백그라운드 작업 프로세서, 모니터링 도구, 어딘가에서 실행 중인 마이그레이션을 추가하면 문제가 됩니다.
왜 커넥션 풀러가 필요한가#
풀러 없이 애플리케이션이 500개의 동시 요청을 처리해야 하면, 500개의 PostgreSQL 연결이 필요합니다. 연결 오버헤드만으로 5GB의 메모리이고, PostgreSQL의 성능은 프로세스 관리 오버헤드로 인해 수백 개의 연결을 넘으면 상당히 저하됩니다.
PostgreSQL 앞에 PgBouncer를 두면, 그 500개의 애플리케이션 연결이 실제로는 약 20개의 PostgreSQL 연결에 매핑됩니다. 풀러는 모든 데이터베이스 연결이 바쁠 때 요청을 큐에 넣습니다.
PgBouncer 설정#
[databases]
mydb = host=127.0.0.1 port=5432 dbname=mydb
[pgbouncer]
listen_addr = 127.0.0.1
listen_port = 6432
auth_type = md5
auth_file = /etc/pgbouncer/userlist.txt
; 풀 모드
pool_mode = transaction
; 풀 크기 조정
default_pool_size = 20
max_client_conn = 500
min_pool_size = 5
reserve_pool_size = 5
reserve_pool_timeout = 3
; 타임아웃
server_idle_timeout = 300
client_idle_timeout = 0
query_timeout = 30Transaction vs Session 모드#
- Transaction 모드 (
pool_mode = transaction): 각 트랜잭션 후에 연결이 풀로 반환됩니다. 95%의 경우 원하는 것입니다. 최대 연결 재사용. - Session 모드 (
pool_mode = session): 전체 클라이언트 세션 동안 연결이 유지됩니다. 준비된 문,SET명령,LISTEN/NOTIFY, 또는 다른 세션 수준 기능이 필요하면 이것을 사용하세요. - Statement 모드 (
pool_mode = statement): 각 문 후에 연결이 반환됩니다. 대부분의 애플리케이션에 너무 제한적 — 명시적 트랜잭션도 사용할 수 없습니다.
Transaction 모드의 주의점: 준비된 문을 사용할 수 없고 (세션 수준 상태임), 세션 변수에 SET을 사용할 수 없으며, LISTEN/NOTIFY가 예상대로 작동하지 않습니다. 대부분의 ORM은 준비된 문을 비활성화하는 방법이 있습니다.
Node.js에서 pg 드라이버 사용:
const pool = new Pool({
host: '127.0.0.1',
port: 6432, // PgBouncer 포트, PostgreSQL 포트가 아님
database: 'mydb',
max: 20, // PgBouncer default_pool_size에 맞춤
statement_timeout: 30000,
// PgBouncer transaction 모드를 위해 준비된 문 비활성화
prepare: false,
});풀 크기 공식#
최적의 PostgreSQL 커넥션 풀 크기에 대한 일반적인 공식이 있습니다:
optimal_connections = (core_count * 2) + effective_spindle_count
4코어와 SSD (유효 스핀들 1개)를 가진 현대 서버의 경우:
optimal = (4 * 2) + 1 = 9
직관적으로 낮아 보입니다. 하지만 PostgreSQL은 대부분의 현대 하드웨어에서 CPU 바운드입니다 (SSD가 I/O가 거의 병목이 되지 않을 만큼 빠릅니다). 이보다 더 많은 연결은 실제로 속도를 늦추는 컨텍스트 스위칭 오버헤드로 이어집니다.
실제로 저는 보통 default_pool_size를 이 수의 2-3배로 설정해서 폭주 트래픽을 처리하되, 피크 부하 시 일부 쿼리가 모두 동시에 PostgreSQL에 히트하는 것보다 PgBouncer 큐에서 대기한다는 이해와 함께 합니다.
실전 체크리스트: 모든 느린 쿼리에 대한 정확한 단계#
"이 쿼리가 느리다"라는 보고를 받을 때의 실제 프로세스입니다. 이 단계를 이 순서대로, 매번 따릅니다.
단계 1: 실제 쿼리 얻기#
"엔드포인트가 느리다"가 아니라 — 실제 SQL. ORM을 사용하고 있다면 쿼리 로깅을 활성화하세요:
-- 100ms 이상 걸리는 모든 쿼리 임시 로깅
ALTER SYSTEM SET log_min_duration_statement = 100;
SELECT pg_reload_conf();또는 총 시간별 상위 쿼리에 대해 pg_stat_statements를 확인하세요:
-- 총 실행 시간별 상위 10 쿼리
SELECT
queryid,
calls,
round(total_exec_time::numeric, 2) AS total_time_ms,
round(mean_exec_time::numeric, 2) AS avg_time_ms,
round((100 * total_exec_time / sum(total_exec_time) OVER ())::numeric, 2) AS pct,
query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;pg_stat_statements는 PostgreSQL 성능을 위한 가장 가치 있는 단일 확장입니다. 실행하고 있지 않다면 지금 설치하세요:
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;단계 2: EXPLAIN (ANALYZE, BUFFERS) 실행#
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) <느린 쿼리>;찾을 것:
- 나쁜 행 추정치 — 실제 행이 추정 행과 매우 다름
- 큰 테이블에서의 Seq Scan — 잠재적 누락 인덱스
- 많은 행을 가진 Nested Loop — Hash Join이나 Merge Join이어야 함
- 높은 버퍼 읽기 — 콜드 캐시 또는 테이블이 너무 큼
- 디스크로 유출되는 정렬 작업 —
work_mem증가 또는 정렬을 위한 인덱스 추가
단계 3: 테이블 통계 확인#
-- 통계가 마지막으로 업데이트된 시점은?
SELECT
relname,
last_analyze,
last_autoanalyze,
n_live_tup,
n_dead_tup
FROM pg_stat_user_tables
WHERE relname = 'orders';last_analyze가 오래되었거나 n_dead_tup이 n_live_tup 대비 높으면:
ANALYZE orders;그런 다음 EXPLAIN ANALYZE를 다시 실행하세요. 계획이 바뀌면 오래된 통계가 문제였습니다.
단계 4: 기존 인덱스 확인#
-- 이 테이블에 어떤 인덱스가 존재하는가?
SELECT
indexname,
indexdef,
idx_scan,
idx_tup_read,
pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_stat_user_indexes
JOIN pg_indexes ON pg_stat_user_indexes.indexrelname = pg_indexes.indexname
WHERE tablename = 'orders'
ORDER BY idx_scan DESC;인덱스가 존재하지만 사용되지 않을 수 있습니다. 쿼리에 대한 열 순서가 잘못되었을 수 있습니다.
단계 5: 인덱스 생성 또는 수정#
쿼리 계획을 기반으로 적절한 인덱스를 생성하세요. 전후에 EXPLAIN ANALYZE로 테스트하세요.
-- 동시에 인덱스 생성 (테이블을 잠그지 않음)
CREATE INDEX CONCURRENTLY idx_orders_customer_status
ON orders (customer_id, status)
WHERE status IN ('pending', 'processing');
-- 인덱스가 사용되는지 확인
EXPLAIN (ANALYZE, BUFFERS) <느린 쿼리>;프로덕션 테이블에서는 항상 CONCURRENTLY를 사용하세요. 일반 CREATE INDEX는 모든 쓰기를 차단하는 전체 테이블 잠금을 잡습니다.
단계 6: 쿼리 리라이트 고려#
인덱스가 존재하고 통계가 최신이지만 쿼리가 여전히 느리면, 쿼리 자체를 보세요:
- 서브쿼리를 JOIN으로 리라이트할 수 있는가?
OFFSET이 문제를 일으키는가? 키셋 페이지네이션으로 전환- 필요 이상으로 많은 열을 선택하고 있는가?
- 상관 서브쿼리를 윈도우 함수로 만들 수 있는가?
- CTE가 플래너의 최적화를 방해하는가?
단계 7: 서버 설정 확인#
특정 쿼리 패턴의 경우 서버 설정이 중요합니다:
-- work_mem: 정렬과 해시 조인을 위한 메모리 (연결당이 아니라 작업당)
-- 기본값은 4MB로, 복잡한 쿼리에는 너무 낮음
SET work_mem = '64MB'; -- 이것으로 시도하고 EXPLAIN ANALYZE 다시 실행
-- effective_cache_size: 플래너에게 얼마나 많은 디스크 캐시를 기대하는지 알려줌
-- 총 RAM의 ~75%로 설정
SHOW effective_cache_size;
-- random_page_cost: 랜덤 I/O 대 순차 I/O 비율
-- 기본값은 4.0, SSD에는 1.1-1.5로 설정
SHOW random_page_cost;work_mem 변경이 디스크 유출 정렬 문제를 해결한다면 전역적으로 올리는 것을 고려하세요. 하지만 주의하세요 — 연결당이 아니라 작업당입니다. 10개의 정렬 작업이 있는 복잡한 쿼리와 work_mem = 256MB이면 단일 쿼리가 2.5GB를 사용할 수 있습니다.
단계 8: 수정 후 모니터링#
수정하고 잊지 마세요. 수정이 유지되는지 확인하세요:
-- 변경 후 새 데이터를 보기 위해 pg_stat_statements 초기화
SELECT pg_stat_statements_reset();
-- 1시간/1일 후 확인
SELECT
queryid,
calls,
round(mean_exec_time::numeric, 2) AS avg_time_ms,
round(max_exec_time::numeric, 2) AS max_time_ms,
query
FROM pg_stat_statements
WHERE query LIKE '%orders%'
ORDER BY mean_exec_time DESC;보너스: 모든 새 데이터베이스에 적용하는 빠른 조치#
성능 문제가 발생하기 전에 설정하는 모든 PostgreSQL 데이터베이스에 적용하는 설정과 관행입니다:
-- 1. pg_stat_statements 설치
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
-- 2. 느린 쿼리 로깅 활성화 (50ms가 제 임계값)
ALTER SYSTEM SET log_min_duration_statement = 50;
-- 3. SSD에 적합한 설정
ALTER SYSTEM SET random_page_cost = 1.1;
ALTER SYSTEM SET effective_io_concurrency = 200;
-- 4. 빈약한 기본값에서 work_mem 증가
ALTER SYSTEM SET work_mem = '32MB';
-- 5. effective_cache_size를 RAM의 75%로 설정
-- 16GB 서버의 경우:
ALTER SYSTEM SET effective_cache_size = '12GB';
-- 6. shared_buffers를 RAM의 25%로 설정
-- 16GB 서버의 경우:
ALTER SYSTEM SET shared_buffers = '4GB';
-- 7. autovacuum 반응성 개선
ALTER SYSTEM SET autovacuum_naptime = '10s';
-- 변경 적용
SELECT pg_reload_conf();
-- 참고: shared_buffers는 재시작 필요그리고 매주 실행하는 모니터링 쿼리:
-- 주의가 필요한 테이블
SELECT
schemaname || '.' || relname AS table,
pg_size_pretty(pg_total_relation_size(relid)) AS total_size,
n_live_tup,
n_dead_tup,
CASE WHEN n_live_tup > 0
THEN round(100.0 * n_dead_tup / n_live_tup, 1)
ELSE 0
END AS dead_pct,
last_autovacuum,
last_autoanalyze
FROM pg_stat_user_tables
WHERE n_dead_tup > 10000
ORDER BY n_dead_tup DESC;마무리#
PostgreSQL 쿼리 최적화는 흑마법이 아닙니다. 체계적인 프로세스입니다:
- 측정하라 — 추측하지 마세요. EXPLAIN (ANALYZE, BUFFERS)이 최고의 친구입니다.
- 계획을 이해하라 — 스캔 유형, 조인 유형, 행 추정치를 읽는 법을 배우세요.
- 전략적으로 인덱싱하라 — 올바른 열에 올바른 순서로 올바른 인덱스. 부분 인덱스와 커버링 인덱스는 과소 활용된 초능력입니다.
- 더 나은 쿼리를 작성하라 — 존재 확인에는 IN보다 EXISTS, OFFSET보다 키셋 페이지네이션, 상관 서브쿼리보다 윈도우 함수.
- 데이터베이스를 유지 관리하라 — autovacuum 튜닝, 통계 업데이트, 커넥션 풀링.
- 지속적으로 모니터링하라 — pg_stat_statements가 데이터베이스가 시간을 어디에 쓰는지 알려줍니다. 정기적으로 확인하세요.
4초 걸리는 쿼리와 0.3밀리초 걸리는 쿼리의 차이는 거의 하드웨어가 아닙니다. 거의 항상 지식입니다 — 어디를 보고 무엇을 바꿔야 하는지 아는 것. 그리고 이제 어디를 봐야 하는지 알고 있습니다.