SQL 실행 계획 읽는 법: 옵티마이저의 예상 행 수와 실제 행 수 비교
1장. 결과는 10행인데 왜 SQL은 3초나 걸렸을까#
다음 SQL이 있다고 하겠습니다.
SELECT
customer_id,
COUNT(*) AS order_count
FROM orders
WHERE status = 'READY'
GROUP BY customer_id
ORDER BY order_count DESC
LIMIT 10;최종 결과는 10행입니다.
그렇다면 데이터베이스도 10행 정도만 처리했을까요?
전혀 그렇지 않을 수 있습니다.
실제로는 다음과 같은 흐름이 발생했을 수 있습니다.
flowchart TD
A["orders 1,000만 행"] --> B["status = READY 필터"]
B --> C["READY 20만 행"]
C --> D["customer_id별 GROUP BY"]
D --> E["고객 수천 명"]
E --> F["ORDER BY"]
F --> G["LIMIT 10"]사용자가 보는 결과는 마지막 10행뿐입니다.
하지만 데이터베이스는 그 결과를 만들기 위해 수십만 행을 읽고, 그룹화하고, 정렬했을 수 있습니다.
따라서 성능 분석에서 가장 위험한 착각은 이것입니다.
결과가 적으니 작업량도 적을 것이다.
실행 계획은 이 보이지 않는 작업량을 보여주는 도구입니다.
2장. 실행 계획은 SQL이 데이터를 찾는 경로를 보여준다#
SQL은 원하는 결과를 선언합니다.
SELECT *
FROM orders
WHERE customer_id = 1001;하지만 개발자는 다음을 직접 지정하지 않습니다.
테이블 전체를 읽어라.
customer_id 인덱스를 사용하라.
어떤 테이블부터 조인하라.
해시 조인을 사용하라.이런 실행 방법은 일반적으로 옵티마이저가 결정합니다.
실행 계획은 옵티마이저가 선택한 방법을 보여줍니다.
예를 들어 다음과 같은 노드가 등장할 수 있습니다.
Seq Scan
Index Scan
Index Only Scan
Bitmap Heap Scan
Nested Loop
Hash Join
Merge Join
Sort
Aggregate이 연산들이 트리 형태로 연결되어 최종 결과를 만듭니다.
3장. 비용 기반 옵티마이저는 여러 후보의 비용을 비교한다#
현대 관계형 DBMS에서는 일반적으로 비용 기반 옵티마이저를 사용합니다.
옵티마이저는 다음과 같은 정보를 바탕으로 실행 계획의 비용을 예상합니다.
| 정보 | 의미 |
|---|---|
| 테이블 행 수 | 데이터 규모 |
| 페이지 수 | 읽어야 할 저장 단위 |
| 고유값 수 | 선택도 추정 |
| 값 분포 | 특정 값이 얼마나 많은가 |
| NULL 비율 | NULL 조건의 예상 결과 |
| 인덱스 | 사용할 수 있는 접근 경로 |
| 컬럼 통계 | 조건 결과 행 수 예측 |
| 조인 관계 | 조인 결과 규모 예측 |
옵티마이저는 이런 정보를 이용해 여러 실행 계획을 비교합니다.
4장. 옵티마이저는 미래를 아는 것이 아니라 추정한다#
다음 조건이 있다고 하겠습니다.
WHERE status = 'READY'orders 테이블에는 100만 행이 있습니다.
통계상 READY 비율이 1%라면 옵티마이저는 대략 다음처럼 예상할 수 있습니다.
1,000,000 × 0.01
=
10,000행그 예상에 따라 인덱스 스캔을 선택할 수 있습니다.
하지만 실제 데이터에서 READY가 80%라면:
800,000행이 나옵니다.
옵티마이저는 1만 행을 예상했지만 실제로는 80만 행을 처리하게 됩니다.
이 차이가 실행 계획 선택에 큰 영향을 줄 수 있습니다.
5장. 예상 행 수와 실제 행 수의 차이가 중요한 이유#
다음 가상 계획을 보겠습니다.
Index Scan using orders_status_idx on orders
(cost=0.42..350.00 rows=1000 width=16)
(actual time=0.03..125.00 rows=90000 loops=1)핵심은 이 두 숫자입니다.
예상
rows=1000
실제
rows=90000옵티마이저는 1천 행이라고 생각했습니다.
실제로는 9만 행이 나왔습니다.
90배 차이입니다.
이런 추정 오류는 상위 조인과 정렬 선택에도 영향을 줄 수 있습니다.
6장. cost는 밀리초가 아니다#
다음 계획을 보겠습니다.
cost=0.42..350.00이를:
350ms라고 읽으면 안 됩니다.
PostgreSQL의 cost는 옵티마이저가 계획끼리 비교하기 위한 상대적인 비용 단위입니다.
실제 시간은 다음 부분에서 확인합니다.
actual time=0.03..125.00따라서 다음 둘은 다릅니다.
cost
→ 계획 선택용 예상 비용
actual time
→ 실제 실행에서 관찰된 시간7장. EXPLAIN은 실행하지 않고 예상 계획을 보여준다#
다음 명령을 보겠습니다.
EXPLAIN
SELECT *
FROM orders
WHERE status = 'READY';일반적인 EXPLAIN은 옵티마이저가 선택한 예상 계획을 보여줍니다.
실제 쿼리 실행 결과를 측정하지는 않습니다.
따라서 다음 항목을 확인할 수 있습니다.
어떤 스캔을 선택했는가?
예상 비용은 얼마인가?
예상 결과 행 수는 얼마인가?8장. EXPLAIN ANALYZE는 실제 SQL을 실행한다#
다음 명령은 다릅니다.
EXPLAIN ANALYZE
SELECT *
FROM orders
WHERE status = 'READY';이 명령은 SQL을 실제 실행합니다.
그래서 다음을 보여줄 수 있습니다.
실제 실행 시간
실제 행 수
반복 횟수매우 유용하지만 중요한 주의점이 있습니다.
EXPLAIN ANALYZE는 SQL을 실제로 실행한다.
SELECT라면 상대적으로 안전하지만 UPDATE나 DELETE라면 데이터가 실제로 변경될 수 있습니다.
9장. UPDATE에 EXPLAIN ANALYZE를 무심코 붙이면 위험하다#
다음 SQL을 생각해 보겠습니다.
EXPLAIN ANALYZE
UPDATE orders
SET status = 'CANCELLED'
WHERE order_id = 100;단순히 실행 계획만 보려고 했다고 해도 UPDATE가 실제 실행됩니다.
테스트 목적이라면 다음과 같이 트랜잭션을 사용할 수 있습니다.
BEGIN;
EXPLAIN ANALYZE
UPDATE orders
SET status = 'CANCELLED'
WHERE order_id = 100;
ROLLBACK;하지만 트리거, 외부 시스템 연계, DBMS 특성 등까지 고려해야 하므로 운영 환경에서는 특히 신중해야 합니다.
10장. PostgreSQL에서는 BUFFERS까지 함께 보는 것이 좋다#
다음 명령을 사용할 수 있습니다.
EXPLAIN (
ANALYZE,
BUFFERS
)
SELECT *
FROM orders
WHERE status = 'READY';이제 실행 시간뿐 아니라 버퍼 접근 정보도 확인할 수 있습니다.
예:
Buffers: shared hit=3000 read=450개념적으로:
hit
→ 이미 메모리에 있던 페이지 접근
read
→ 저장 장치에서 읽어온 페이지로 볼 수 있습니다.
11장. 실행 시간 하나만 비교하면 캐시 효과를 성능 개선으로 착각할 수 있다#
첫 실행:
300ms두 번째 실행:
80ms라고 하겠습니다.
SQL을 수정한 것도 아닙니다.
인덱스를 만든 것도 아닙니다.
두 번째 실행에서 필요한 페이지가 이미 메모리에 있었기 때문일 수 있습니다.
따라서 실행 시간과 함께 다음을 확인해야 합니다.
Buffers
실행 계획
실제 행 수12장. 실행 계획은 트리 구조로 읽어야 한다#
다음 계획을 단순화해 보겠습니다.
Limit
-> Sort
-> HashAggregate
-> Seq Scan on orders결과는 위에서 나오지만 입력은 아래쪽에서 만들어집니다.
개념적으로:
flowchart BT
A["Seq Scan<br/>orders 읽기"] --> B["HashAggregate<br/>그룹 집계"]
B --> C["Sort<br/>정렬"]
C --> D["Limit<br/>10행"]10행을 반환하는 LIMIT만 보면 안 됩니다.
그 아래에서 얼마나 많은 데이터를 처리했는지를 봐야 합니다.
13장. Seq Scan은 나쁜 계획이라는 뜻이 아니다#
다음 계획이 나왔다고 하겠습니다.
Seq Scan on orders많은 사람이 바로 생각합니다.
인덱스를 안 탔다. 잘못됐다.
하지만 반드시 그렇지 않습니다.
전체 주문 100만 건 가운데 90만 건을 읽어야 한다면 테이블 전체를 순차적으로 읽는 것이 효율적일 수 있습니다.
작은 테이블도 마찬가지입니다.
100행짜리 테이블에 인덱스를 탐색하는 것보다 전체를 읽는 편이 더 저렴할 수 있습니다.
14장. Index Scan도 항상 좋은 계획은 아니다#
다음 계획이 나왔습니다.
Index Scan using orders_status_idx인덱스를 사용하니 좋은 것처럼 보입니다.
그런데 이 인덱스에서 90만 건을 찾은 뒤 테이블 페이지를 90만 번 가까이 방문해야 한다면 비용이 클 수 있습니다.
따라서 다음 식으로 판단하면 안 됩니다.
Index Scan
=
성능 좋음확인해야 할 것은 실제 읽기량입니다.
15장. Index Only Scan은 인덱스만으로 끝날 가능성을 보여준다#
다음 조회가 있습니다.
SELECT
customer_id,
order_date
FROM orders
WHERE customer_id = 1001;필요한 열이 모두 적절한 인덱스에 포함되어 있다면 Index Only Scan이 가능할 수 있습니다.
개념적으로:
인덱스
↓
필요한 데이터 확보
↓
테이블 본문 접근 감소가 됩니다.
하지만 PostgreSQL에서는 MVCC 가시성 확인 때문에 힙 접근이 추가될 수 있습니다.
따라서 이름만 보고 실제 테이블 페이지 접근이 완전히 0이라고 단정해서는 안 됩니다.
16장. Filter와 Index Cond는 의미가 다르다#
다음 가상 계획을 보겠습니다.
Index Scan using idx_orders_customer
Index Cond: (customer_id = 1001)
Filter: (status = 'READY')Index Cond는 인덱스 탐색 단계에서 범위를 좁히는 조건입니다.
customer_id = 1001먼저 고객 1001의 주문을 찾습니다.
그 결과에:
status = READY필터를 적용합니다.
즉 READY가 아닌 주문도 인덱스 접근 이후 한 번 가져와 검사할 수 있습니다.
17장. Rows Removed by Filter는 버려진 행을 보여준다#
계획에 다음과 같은 정보가 있다고 하겠습니다.
actual rows=100
Rows Removed by Filter=90000최종적으로 100행만 남았습니다.
하지만 필터 단계에서 9만 행을 버렸습니다.
다음과 같은 상황일 수 있습니다.
90,100행 검사
↓
90,000행 제거
↓
100행 반환결과만 100행이라고 작은 조회라고 생각하면 안 됩니다.
18장. 가장 먼저 큰 추정 오류가 발생한 곳을 찾는 것이 중요하다#
실행 계획의 상위 노드에서도 예상과 실제가 크게 다를 수 있습니다.
하지만 그 차이가 아래쪽에서 시작된 것일 수 있습니다.
예를 들어:
Scan 예상 100
실제 200,000
Join 예상 100
실제 200,000
Sort 예상 100
실제 200,000이라면 Sort 추정 오류가 근본 원인이라고 보기 어렵습니다.
처음 크게 틀린 곳은 Scan입니다.
따라서 실행 계획에서는 첫 번째 큰 카디널리티 추정 오류를 찾는 것이 중요합니다.
19장. 카디널리티는 결과 행 수를 의미한다#
옵티마이저가 실행 계획을 만들 때 중요한 정보 중 하나가 각 단계의 예상 행 수입니다.
예를 들어:
status = READY조건 뒤에 몇 행이 남는지 예상합니다.
이 값이 조인 순서에도 영향을 줍니다.
100행이라고 생각하면 작은 데이터로 취급할 수 있습니다.
20만 행이라면 전혀 다른 조인 방법이 유리할 수 있습니다.
20장. 잘못된 행 수 추정은 조인 방법까지 바꿀 수 있다#
customers 10만 행과 orders 1천만 행을 조인한다고 하겠습니다.
옵티마이저가 READY 주문을 100건이라고 예상했습니다.
그러면 다음과 같은 접근을 선택할 수 있습니다.
READY 주문 100건
↓
각 주문마다 customer PK 조회중첩 루프가 합리적일 수 있습니다.
하지만 실제 READY 주문이 20만 건이라면:
20만 건
×
customer 인덱스 탐색이 됩니다.
처음 예상과 실제가 크게 달랐기 때문에 계획의 경제성이 바뀝니다.
21장. Nested Loop에서는 loops를 반드시 같이 봐야 한다#
다음 계획을 생각해 보겠습니다.
Nested Loop
-> Seq Scan on orders
actual rows=200000 loops=1
-> Index Scan on customer
actual rows=1 loops=200000customer 인덱스 스캔은 한 번에 한 행만 반환합니다.
그래서:
actual rows=1만 보면 매우 작아 보입니다.
하지만:
loops=200000입니다.
총 20만 번 실행되었습니다.
22장. actual rows × loops로 작업 규모를 감각적으로 볼 수 있다#
PostgreSQL 실행 계획에서 반복되는 노드는 실제 행 수가 반복당 값으로 표시되는 경우가 있습니다.
예를 들어:
actual rows=3
loops=10000이라면 대략:
3 × 10,000
=
30,000행 규모의 작업이 관련될 수 있습니다.
정확한 비용 계산 공식으로 쓰는 것은 아니지만 반복 작업의 규모를 이해하는 데 유용합니다.
23장. 실행 계획의 시간은 단순히 모두 더하면 안 된다#
다음과 같은 노드가 있다고 하겠습니다.
Parent actual time=0.1..100
Child actual time=0.05..80부모 노드 시간에는 자식 노드 작업이 포함될 수 있습니다.
따라서:
100 + 80
=
180ms처럼 단순히 모두 더하면 중복 계산이 될 수 있습니다.
실행 계획의 시간은 노드 간 포함 관계를 이해하면서 읽어야 합니다.
24장. 통계가 오래되면 옵티마이저의 현실 인식도 오래된다#
처음 데이터는 다음과 같았습니다.
| 상태 | 행 수 |
|---|---|
| READY | 1,000 |
| DONE | 99,000 |
READY 비율은 1%입니다.
대량 작업 후 실제 데이터가 다음처럼 바뀌었습니다.
| 상태 | 행 수 |
|---|---|
| READY | 90,000 |
| DONE | 10,000 |
하지만 옵티마이저 통계가 이전 상태라면 READY를 여전히 희귀한 값으로 판단할 수 있습니다.
25장. PostgreSQL에서는 ANALYZE로 통계를 갱신할 수 있다#
ANALYZE orders;특정 컬럼만 분석할 수도 있습니다.
ANALYZE orders(status);다만 다음처럼 운영 규칙을 기계적으로 만들 필요는 없습니다.
모든 테이블을 매일 새벽 3시에 반드시 ANALYZEPostgreSQL에는 자동 분석 기능도 있습니다.
중요한 것은 데이터 변화와 추정 오류를 보고 필요성을 판단하는 것입니다.
26장. 통계 갱신과 인덱스 생성은 다른 해결책이다#
추정 오류가 있다고 하겠습니다.
예상 1,000
실제 90,000이때:
ANALYZE orders;를 실행합니다.
새로운 통계로 예상이 실제에 가까워질 수 있습니다.
하지만 이것이 곧 인덱스를 만들어 준다는 뜻은 아닙니다.
반대로 인덱스를 추가해도 통계가 부정확하면 옵티마이저가 그 인덱스의 가치를 잘못 판단할 수 있습니다.
둘은 서로 다른 문제입니다.
27장. 데이터 분포가 불균등하면 단순 평균으로 추정하기 어렵다#
상태 값이 다음과 같다고 하겠습니다.
DONE 900,000
READY 80,000
FAILED 19,000
SPECIAL 1,000고유 상태값은 네 개입니다.
단순히:
1,000,000 / 4
=
250,000으로 각 상태를 예상하면 실제 분포와 크게 다릅니다.
옵티마이저는 히스토그램이나 최빈값 통계 등을 이용해 이런 불균등을 추정할 수 있습니다.
28장. 컬럼 하나씩의 통계만으로는 두 조건의 관계를 알기 어려울 수 있다#
다음 조건이 있다고 하겠습니다.
WHERE country = 'KR'
AND currency = 'KRW';실제 업무에서는 한국 주문 대부분이 KRW일 수 있습니다.
두 컬럼은 강한 상관관계를 가집니다.
하지만 각각의 통계만 독립적으로 보고 계산하면 실제 결합 비율을 잘못 추정할 수 있습니다.
이런 상관관계는 다중 컬럼 조건의 카디널리티 추정 오류를 만들 수 있습니다.
29장. 함수가 있다고 무조건 인덱스를 못 쓰는 것은 아니다#
다음 조건을 보겠습니다.
WHERE lower(email) = 'user@example.com';일반 email 인덱스와 바로 맞지 않을 수 있습니다.
하지만 표현식 인덱스를 만들 수 있는 DBMS라면:
CREATE INDEX idx_member_lower_email
ON member(lower(email));같은 접근이 가능합니다.
따라서 다음 공식은 틀릴 수 있습니다.
함수 사용
→ 인덱스 사용 불가실제 인덱스와 실행 계획을 확인해야 합니다.
30장. 날짜 컬럼에 함수를 씌우면 범위 검색으로 바꿀 수 있는지 검토한다#
다음 SQL을 보겠습니다.
WHERE EXTRACT(
YEAR FROM ordered_at
) = 2026;업무가 2026년 전체 주문이라면 다음과 같이 표현할 수 있습니다.
WHERE ordered_at >= TIMESTAMP '2026-01-01 00:00:00'
AND ordered_at < TIMESTAMP '2027-01-01 00:00:00';이 방식은 날짜 인덱스의 연속 범위와 잘 맞을 수 있습니다.
다만 실제 시간대와 데이터 타입을 정확하게 확인해야 합니다.
31장. 수식 조건도 동치 변환이 가능한지 확인할 수 있다#
다음 조건이 있습니다.
WHERE salary * 12 > 60000;salary가 월급이고 NULL이나 단위 문제가 없다는 전제라면:
WHERE salary > 5000;처럼 바꿀 수 있습니다.
하지만 모든 수식을 이렇게 단순 변환할 수 있는 것은 아닙니다.
정수·소수 연산, NULL, 반올림, 부호 등의 의미가 유지되는지 확인해야 합니다.
32장. LIKE 앞에 와일드카드가 있으면 일반 B트리 탐색이 어려울 수 있다#
다음 검색은:
WHERE name LIKE '가%';앞부분이 고정되어 있습니다.
반면:
WHERE name LIKE '%람';은 접미 검색입니다.
일반적인 B트리 인덱스의 앞쪽 범위를 이용하기 어려울 수 있습니다.
이런 검색이 중요하다면 DBMS의 전용 문자열 검색 인덱스나 전문 검색 구조를 검토할 수 있습니다.
33장. <> 조건이 있다고 인덱스를 절대 못 쓰는 것도 아니다#
다음 SQL:
WHERE status <> 'DONE';이 있습니다.
DONE이 99.9%이고 나머지가 극소수라면 인덱스가 유리할 수도 있습니다.
반대로 DONE이 10%라면 90%를 반환하는 조건이므로 전체 스캔이 나을 수도 있습니다.
즉 연산자 이름만 보고 결정하면 안 됩니다.
데이터 분포가 중요합니다.
34장. Sort 노드가 있다고 항상 문제가 있는 것은 아니다#
다음 계획이 있다고 하겠습니다.
Sort
Sort Key: created_at DESC정렬이 필요하니 당연히 Sort가 나올 수 있습니다.
문제는 정렬량입니다.
100행 정렬과:
1,000만 행 정렬은 완전히 다릅니다.
그리고 메모리를 넘어서 디스크를 사용했는지도 확인해야 합니다.
35장. 디스크 정렬은 추가 I/O를 만들 수 있다#
PostgreSQL 실행 계획에서 정렬 방법을 확인할 수 있습니다.
예를 들어:
Sort Method: quicksort
Memory: 2048kB같은 형태일 수 있습니다.
반대로 메모리가 부족해 외부 정렬이 필요하다면 디스크 사용이 나타날 수 있습니다.
대규모 정렬에서는 이 차이가 성능에 큰 영향을 줄 수 있습니다.
36장. Aggregate도 입력 행 수가 중요하다#
최종 결과가 부서 10개라고 하겠습니다.
10행하지만 Aggregate 아래에서 1천만 주문을 읽고 있을 수 있습니다.
flowchart TD
A["주문 1,000만 행"] --> B["GROUP BY 부서"]
B --> C["부서 10행"]최종 10행만 보고 작은 쿼리라고 하면 안 됩니다.
37장. Hash Join에서는 해시 테이블을 어느 쪽에서 만드는지 본다#
개념적으로 Hash Join은 한쪽 입력으로 해시 테이블을 만들고 다른 입력을 이용해 찾습니다.
flowchart LR
A["작은 입력"] --> H["Hash Table"]
B["큰 입력"] --> P["Probe"]
H --> P
P --> R["Join Result"]옵티마이저가 작다고 예상한 입력이 실제로 매우 크다면 해시 메모리 사용량도 예상보다 커질 수 있습니다.
메모리에 들어가지 못하면 배치 처리나 디스크 사용이 발생할 수 있습니다.
38장. Merge Join은 정렬된 두 입력을 이용한다#
Merge Join은 조인 키 기준으로 정렬된 입력을 함께 진행하면서 비교합니다.
개념적으로:
왼쪽 정렬 입력
1
3
5
7
오른쪽 정렬 입력
1
2
5
8을 순서대로 비교합니다.
이미 적절하게 정렬된 인덱스가 있으면 유리할 수 있습니다.
반면 정렬 자체에 큰 비용이 필요하다면 전체 계획의 이점이 줄어들 수 있습니다.
39장. 조인 이름보다 실제 입력 규모를 먼저 봐야 한다#
다음과 같은 단순 규칙은 위험합니다.
Nested Loop
→ 느리다
Hash Join
→ 빠르다작은 외부 입력과 좋은 인덱스가 있다면 Nested Loop가 매우 효율적일 수 있습니다.
반대로 수백만 행을 반복 탐색하면 문제가 될 수 있습니다.
Hash Join도 큰 해시 테이블을 만들거나 디스크를 사용한다면 비용이 커질 수 있습니다.
40장. 실행 계획에서 가장 중요한 숫자는 하나가 아니다#
다음 항목을 함께 봐야 합니다.
| 항목 | 의미 |
|---|---|
| 예상 rows | 옵티마이저가 예상한 행 수 |
| actual rows | 실제 행 수 |
| loops | 노드 반복 횟수 |
| actual time | 실제 시간 |
| Buffers hit | 메모리에서 접근한 블록 |
| Buffers read | 저장소에서 읽은 블록 |
| Rows Removed | 필터로 버린 행 |
| Sort Method | 정렬 방법 |
| Memory·Disk | 메모리 또는 디스크 작업 |
하나의 숫자만으로 병목을 판단하기 어렵습니다.
41장. 최종 결과 10행을 만드는 가상 계획을 읽어 보자#
다음은 설명을 위한 가상 계획입니다.
Limit
actual rows=10
-> Sort
actual rows=5000
-> HashAggregate
actual rows=5000
-> Seq Scan on orders
actual rows=200000
Rows Removed by Filter=9800000사용자가 보는 것은 10행입니다.
하지만 내부에서는:
1,000만 행 검사
↓
20만 행 통과
↓
5천 그룹
↓
5천 행 정렬
↓
10행 반환이라는 작업이 있었습니다.
42장. 이런 계획에서는 LIMIT만 튜닝해서는 해결되지 않는다#
최상위에 LIMIT 10이 있으니:
LIMIT이 있으니 빠를 것이다.
라고 생각할 수 있습니다.
하지만 GROUP BY와 ORDER BY 결과가 필요한 경우 DBMS는 많은 중간 데이터를 처리해야 최종 상위 10개를 결정할 수 있습니다.
따라서 느린 원인은 LIMIT이 아니라 그 아래 대량 스캔이나 집계일 수 있습니다.
43장. Rows Removed by Filter가 매우 크다면 조건 적용 위치를 확인한다#
다음과 같은 계획이 있다고 하겠습니다.
actual rows=100
Rows Removed by Filter=5,000,0005백만 행 이상을 읽고 100행을 남겼습니다.
이 경우 다음을 검토할 수 있습니다.
조건에 적합한 인덱스가 있는가?
파티션 프루닝이 가능한가?
조건 표현 때문에 인덱스 사용이 어려운가?
데이터 분포가 어떻게 되어 있는가?44장. 반환 건수가 적다고 인덱스가 반드시 필요한 것도 아니다#
테이블이 50행뿐이라고 하겠습니다.
결과는 1행입니다.
인덱스를 만들어도 옵티마이저는 Seq Scan을 선택할 수 있습니다.
50행을 읽는 비용이 인덱스 탐색보다 작을 수 있기 때문입니다.
따라서:
결과 1행
=
반드시 인덱스라는 공식은 없습니다.
45장. 실행 계획에서 통계 오류와 작업량 문제는 구분해야 한다#
다음 두 상황을 비교하겠습니다.
상황 A#
예상 100
실제 100추정은 정확합니다.
하지만 이 노드가 50만 번 반복됩니다.
→ 반복 작업량 문제
상황 B#
예상 100
실제 100,000반복은 한 번입니다.
→ 추정 오류 문제
둘 다 느릴 수 있지만 해결 방향이 다릅니다.
46장. 추정이 정확해도 쿼리가 느릴 수 있다#
옵티마이저가 다음을 정확하게 예상했다고 하겠습니다.
예상 5,000,000
실제 5,100,000추정은 훌륭합니다.
하지만 애초에 5백만 행을 처리해야 하는 업무라면 여전히 느릴 수 있습니다.
이때는 통계보다 다음을 검토해야 합니다.
조회 범위를 줄일 수 있는가?
사전 집계가 가능한가?
파티셔닝이 필요한가?
인덱스로 읽기량을 줄일 수 있는가?
업무 요구 자체를 바꿀 수 있는가?47장. 통계가 정확해졌다고 실행 시간이 반드시 줄어드는 것은 아니다#
ANALYZE 후 예상값이 실제에 가까워졌습니다.
수정 전
예상 1,000
실제 90,000
수정 후
예상 85,000
실제 90,000좋은 변화입니다.
하지만 옵티마이저가 여전히 동일한 계획을 선택할 수도 있습니다.
왜냐하면 새 통계로 계산해도 그 계획의 예상 비용이 가장 낮을 수 있기 때문입니다.
통계 정확도 향상과 실행 시간 개선은 같은 의미가 아닙니다.
48장. 실행 계획이 바뀌었다고 성공한 것도 아니다#
인덱스를 추가했습니다.
이전:
Seq Scan이후:
Index Scan으로 바뀌었습니다.
하지만 실행 시간이:
이전 200ms
이후 350ms가 될 수도 있습니다.
왜냐하면 인덱스로 찾은 수많은 행이 테이블 전체에 흩어져 있어 랜덤 페이지 접근이 늘었을 수도 있기 때문입니다.
목표는 계획을 바꾸는 것이 아니라 실제 작업량을 줄이는 것입니다.
49장. 튜닝 전후에는 같은 조건과 같은 결과를 비교해야 한다#
변경 전 SQL:
최근 30일 주문변경 후 SQL:
최근 1일 주문이 되어 빨라졌다면 인덱스 튜닝 효과라고 할 수 없습니다.
비교할 때는 다음이 같아야 합니다.
입력 조건
결과 의미
데이터 규모
가능하면 부하 조건그래야 실행 계획 변화의 효과를 판단할 수 있습니다.
50장. 버퍼 읽기가 줄었는지를 비교하는 것이 유용하다#
변경 전:
Buffers:
shared hit=100000
read=5000변경 후:
Buffers:
shared hit=5000
read=50이라면 조회가 훨씬 적은 페이지를 접근하도록 바뀌었다고 설명할 수 있습니다.
이런 기록은 단순히:
Index Scan으로 바뀌었다.보다 훨씬 의미가 있습니다.
51장. 캐시 상태가 다르면 실행 시간만으로 비교하기 어렵다#
변경 전에는 서버가 막 재시작된 상태였다고 하겠습니다.
변경 후에는 같은 SQL을 수십 번 실행했습니다.
그렇다면 두 번째 테스트는 필요한 페이지가 대부분 메모리에 있을 수 있습니다.
따라서 다음을 함께 기록하는 것이 좋습니다.
실행 시각
캐시 상태
반복 횟수
BUFFERS
동시 사용자 부하52장. 느린 SQL이 항상 CPU나 I/O 문제인 것은 아니다#
실행 시간이 길어졌는데 실행 계획 자체는 정상일 수 있습니다.
다른 트랜잭션이 행을 잠그고 있어 기다리고 있을 수도 있습니다.
SQL 실행
↓
락 대기
↓
다른 트랜잭션 COMMIT
↓
실행 계속이 경우 인덱스를 추가해도 문제를 해결하지 못합니다.
53장. 전체 응답 시간과 실행 계획 시간도 구분해야 한다#
사용자는 5초가 걸렸다고 합니다.
DB 서버 실행 계획에서는 1초입니다.
나머지 4초는 어디에서 발생했을까요?
가능한 원인은 다음과 같습니다.
네트워크
클라이언트 데이터 처리
애플리케이션 로직
커넥션 풀 대기
락 대기
결과 전송량DB 실행 계획은 매우 중요하지만 전체 시스템 지연의 모든 원인을 보여주는 것은 아닙니다.
54장. 인덱스가 있는데도 Seq Scan이 나왔다면 먼저 선택도를 본다#
다음 인덱스가 있습니다.
status 인덱스조회는:
WHERE status = 'DONE';입니다.
DONE이 전체 행의 95%라면 인덱스로 95% 위치를 찾고 테이블을 방문하는 것보다 순차 읽기가 더 유리할 수 있습니다.
이 경우 Seq Scan은 옵티마이저의 합리적인 선택일 수 있습니다.
55장. 복합 인덱스의 선두 열과 조회 조건도 확인한다#
인덱스:
(customer_id, order_date)조회:
WHERE order_date >= DATE '2026-09-01';입니다.
customer_id 조건이 없습니다.
DBMS가 이 인덱스를 어떤 방식으로 활용할 수 있는지는 제품과 비용 판단에 따라 달라집니다.
중요한 것은 단순히:
order_date가 인덱스에 있으니 반드시 빠르다라고 생각하지 않는 것입니다.
56장. 실행 계획에서 파티션 프루닝도 확인할 수 있다#
월별 파티션 테이블에서 9월 주문만 조회한다고 하겠습니다.
WHERE order_date >= DATE '2026-09-01'
AND order_date < DATE '2026-10-01'계획에서 실제로 9월 파티션만 접근하는지 확인해야 합니다.
결과가 10행이라고 해도 모든 월 파티션을 읽었다면 프루닝 효과는 없을 수 있습니다.
57장. 병렬 계획이 나오면 workers도 확인해야 한다#
대규모 스캔이나 집계에서는 PostgreSQL이 병렬 실행을 선택할 수 있습니다.
개념적으로:
flowchart TD
T["큰 테이블"] --> W1["Worker 1"]
T --> W2["Worker 2"]
T --> W3["Worker 3"]
W1 --> G["Gather"]
W2 --> G
W3 --> G이 경우 한 노드의 행 수와 loops를 해석할 때 병렬 작업자까지 고려해야 합니다.
58장. 추정 오류 비율은 원인을 찾는 단서가 된다#
다음처럼 간단히 볼 수 있습니다.
예상 = 100
실제 = 100,000오차 비율:
100,000 / 100
=
1,000배이는 실행 시간이 1,000배라는 뜻은 아닙니다.
하지만 옵티마이저가 데이터 규모를 크게 잘못 판단했다는 강한 신호입니다.
59장. 첫 번째 큰 추정 오류를 찾는 실전 방법#
실행 계획을 아래쪽 입력부터 살펴봅니다.
예:
Scan A
예상 1,000
실제 1,100
Scan B
예상 100
실제 80,000
Join
예상 500
실제 70,000
Sort
예상 500
실제 70,000첫 번째 큰 오류는 Scan B입니다.
Join과 Sort의 오류는 Scan B의 잘못된 입력 예상이 전파된 결과일 가능성이 큽니다.
60장. 실행 계획 튜닝 흐름을 정리하면#
flowchart TD
A["느린 SQL 확인"] --> B["같은 입력으로 EXPLAIN ANALYZE"]
B --> C["예상 rows와 실제 rows 비교"]
C --> D["첫 큰 추정 오류 찾기"]
D --> E["loops와 중간 행 수 확인"]
E --> F["Buffers와 Sort·Hash 확인"]
F --> G["통계·인덱스·조건 표현 검토"]
G --> H["수정"]
H --> I["동일 조건으로 다시 측정"]
I --> J["작업량과 응답 시간 비교"]이 순서를 따르면 연산자 이름만 보고 추측하는 것을 줄일 수 있습니다.
61장. 실행 계획을 볼 때 흔히 하는 잘못된 판단#
“Seq Scan이니까 문제다”#
아닐 수 있습니다.
대부분의 행이 필요하거나 테이블이 작다면 합리적입니다.
“Index Scan이니까 성공이다”#
아닐 수 있습니다.
수십만 행의 랜덤 테이블 접근이 발생할 수 있습니다.
“결과가 10건이니까 작은 쿼리다”#
아닙니다.
중간에 수백만 행을 처리할 수 있습니다.
“cost가 100이니 100ms다”#
아닙니다.
cost와 실제 시간은 다른 단위입니다.
“ANALYZE를 했으니 빨라져야 한다”#
반드시 그렇지 않습니다.
통계 정확도와 최종 계획 비용은 별개입니다.
62장. 튜닝 기록에는 실행계획 이름보다 줄어든 작업량을 남기는 것이 좋다#
다음 기록은 정보가 부족합니다.
Seq Scan을 Index Scan으로 변경대신 다음처럼 남기는 편이 좋습니다.
변경 전
READY 조건에서 500만 행 검사
shared buffer 82,000 접근
변경 후
READY 대상 8,200행 범위 접근
shared buffer 1,300 접근어떤 작업을 줄였는지가 분명합니다.
63장. 실행 계획 비교 표를 만들어 두면 좋다#
| 항목 | 변경 전 | 변경 후 |
|---|---|---|
| 실행 시간 | 850ms | 95ms |
| 예상 행 | 1,000 | 8,000 |
| 실제 행 | 8,200 | 8,200 |
| 버퍼 접근 | 82,000 | 1,300 |
| 물리 읽기 | 2,100 | 35 |
| 필터 제거 행 | 4,900,000 | 0 |
| 주요 접근 | Seq Scan | Index Scan |
이 표는 단순히 빨라졌다는 주장보다 개선 근거를 명확하게 보여줍니다.
64장. 실제 운영에서는 SQL 하나보다 호출 빈도도 중요하다#
쿼리 A:
실행 시간 500ms
하루 10회쿼리 B:
실행 시간 30ms
하루 1,000만 회한 번만 보면 A가 더 느립니다.
하지만 시스템 전체 자원을 더 많이 소비하는 것은 B일 수 있습니다.
따라서 튜닝 우선순위에서는 다음을 함께 봐야 합니다.
한 번의 비용
×
실행 횟수65장. 실행 계획은 SQL 성능의 원인을 설명하기 위한 도구다#
실행 계획 분석의 목적은 다음 질문에 답하는 것입니다.
어디에서 많은 행을 읽었는가?
어디에서 예상보다 행이 많이 나왔는가?
어떤 노드가 반복적으로 실행됐는가?
어디에서 대부분의 행이 버려졌는가?
어떤 단계가 정렬이나 해시에 큰 메모리를 사용했는가?
실제로 몇 개의 페이지를 읽었는가?이 질문에 답하면 단순한 “쿼리가 느리다”에서 실제 원인으로 접근할 수 있습니다.
66장. 실행 계획 분석 체크리스트#
SQL이 느릴 때 다음 순서로 확인할 수 있습니다.
- 최종 반환 행 수를 확인합니다.
- 계획의 하위 입력 행 수를 확인합니다.
- 예상 rows와 actual rows를 비교합니다.
- 첫 번째 큰 추정 오류를 찾습니다.
- loops가 많은 노드를 확인합니다.
- Rows Removed by Filter를 확인합니다.
- Seq Scan이 실제로 불필요한 대량 읽기인지 판단합니다.
- Index Scan이 너무 많은 테이블 접근을 만드는지 확인합니다.
- Sort가 메모리 안에서 끝났는지 확인합니다.
- Hash 작업이 과도하게 커지지 않았는지 확인합니다.
- BUFFERS의 hit와 read를 비교합니다.
- 통계가 오래되었는지 확인합니다.
- 조건 컬럼 사이 상관관계를 확인합니다.
- 함수나 자료형 변환이 접근 경로에 영향을 주는지 봅니다.
- 락·I/O·네트워크 대기도 별도로 확인합니다.
- 수정 후 같은 조건으로 다시 측정합니다.
67장. 핵심 정리#
SQL 실행 계획은 단순히 인덱스를 사용하는지 확인하는 화면이 아닙니다.
옵티마이저가:
얼마나 많은 행이 나올 것이라고 예상했고
그 예상으로 어떤 접근 방법을 선택했으며
실제로는 얼마나 많은 작업이 발생했는가를 비교하는 자료입니다.
가장 먼저 봐야 하는 것은 특정 연산자의 이름이 아니라 예상 행 수와 실제 행 수의 차이입니다.
예상 1,000행
실제 90,000행처럼 크게 어긋났다면 그 오차가 위쪽 조인·집계·정렬 계획까지 영향을 줄 수 있습니다.
또 actual rows 하나만 보면 안 됩니다.
actual rows
+
loops를 함께 봐야 반복 작업의 규모를 이해할 수 있습니다.
최종 반환 결과가 작아도:
수백만 행 스캔
↓
대량 필터
↓
집계
↓
정렬
↓
10행 반환일 수 있습니다.
그래서 실행 계획에서는 최종 행 수보다 중간 작업량을 봐야 합니다.
또한 다음 등식은 성립하지 않습니다.
Seq Scan
=
나쁨
Index Scan
=
좋음테이블 크기, 선택도, 데이터 분포, 반환 행 수, 물리적 배치에 따라 전체 스캔이 더 효율적일 수도 있습니다.
PostgreSQL에서 성능을 확인할 때는 다음 명령이 유용합니다.
EXPLAIN (
ANALYZE,
BUFFERS
)
SELECT ...;다만 ANALYZE 옵션은 실제 SQL을 실행하므로 변경 SQL에서는 특히 주의해야 합니다.
실행 계획을 읽을 때 가장 중요한 질문은 이것입니다.
옵티마이저는 몇 행이라고 생각했고, 실제로는 몇 행을 처리했는가?
그다음 질문은 다음과 같습니다.
그 차이 때문에 얼마나 많은 반복·버퍼 접근·정렬·조인 작업이 추가되었는가?
좋은 SQL 튜닝은 실행 계획의 이름을 바꾸는 일이 아닙니다.
같은 결과를 만들면서 실제로 읽고, 비교하고, 반복하고, 정렬해야 하는 작업량을 줄이는 것입니다.