database

데이터 베이스 실행계획(1)

co2plant

PostgreSQL 실행 계획(Execution Plan) 이해하기

SQL을 작성하고 실행하면 데이터베이스는 우리가 작성한 SQL을 그대로 한 줄씩 수행하지 않는다.
예를 들어 다음과 같은 SQL이 있다고 하자.

SELECT *
FROM users
WHERE email = 'test@test.com';

개발자의 관점에서는 단순히 users 테이블에서 email이 일치하는 데이터를 가져오는 쿼리다.
하지만 데이터베이스 입장에서는 여러 가지 방법으로 데이터를 찾을 수 있다.

  • 테이블 전체를 처음부터 끝까지 읽을 것인가?
  • email 인덱스를 사용할 것인가?
  • 인덱스를 사용한다면 어떤 인덱스를 사용할 것인가?
  • 여러 테이블을 조인한다면 어떤 테이블부터 읽을 것인가?
  • Nested Loop, Hash Join, Merge Join 중 어떤 방법으로 조인할 것인가?

PostgreSQL의 Planner / Optimizer는 이러한 후보를 비교한 뒤 가장 비용이 낮을 것으로 예상되는 방법을 선택한다.
그 결과가 바로 **실행 계획(Execution Plan)**이다.


1. 실행 계획이란?

정의

사용자가 SQL 쿼리를 실행했을 때 데이터베이스의 옵티마이저가 데이터를 효율적으로 처리하기 위해 선택한 작업 절차와 데이터 접근 경로이다.

쉽게 표현하면 다음과 같다.

SQL
↓
어떤 방법으로 데이터를 가져올까?
↓
여러 실행 방법 비교
↓
가장 저렴할 것으로 예상되는 방법 선택
↓
실행

여기서 중요한 단어가 하나 있다.
가장 빠른 방법이 아니라, 가장 빠를 것이라고 "예상되는" 방법을 선택한다.
옵티마이저는 실제로 모든 실행 방법을 직접 수행해서 속도를 비교하는 것이 아니다.
테이블 통계, 데이터 건수, 값의 분포, 인덱스 정보, 비용 설정 등을 이용해서 각 방법의 비용을 추정한다.
따라서 실행 계획을 이해할 때는 항상 다음 관계를 기억해야 한다.

Optimizer의 예상
        ↓
Execution Plan
        ↓
Executor의 실제 실행

그리고 이 예상과 실제의 차이를 확인하기 위해 사용하는 것이 EXPLAIN ANALYZE이다.


2. SQL이 실행되기까지

PostgreSQL에서 하나의 SQL이 결과로 반환되기까지의 흐름을 단순화하면 다음과 같다.

flowchart TD
    A["SQL 입력<br/>SELECT ... FROM ... WHERE ..."]

    subgraph PRE["SQL 해석 및 변환"]
        direction LR
        B["Parser<br/>문법 분석"]
        C["Analyzer<br/>테이블·컬럼·타입 확인"]
        D["Rewriter<br/>규칙 및 View 변환"]

        B --> C --> D
    end

    E["Planner / Optimizer<br/>실행 경로 비교 및 실행계획 선택"]

    F["Executor<br/>Plan Tree 실행"]

    G["조회 결과"]

    A --> PRE
    PRE --> E
    E --> F
    F --> G

    style A fill:#E3F2FD,stroke:#1976D2,color:#1565C0
    style E fill:#FFF3E0,stroke:#F57C00,color:#E65100
    style F fill:#E8F5E9,stroke:#388E3C,color:#2E7D32
    style G fill:#E3F2FD,stroke:#1976D2,color:#1565C0

PostgreSQL 공식 문서에서는 큰 단계를 Parser → Rewrite System → Planner/Optimizer → Executor로 설명한다. Parser가 SQL의 문법을 검사하고 Query Tree를 생성한 뒤 Rewrite System이 필요한 변환을 수행하고, Planner/Optimizer가 후보 경로의 비용을 비교하여 실행계획을 결정한다. Executor는 만들어진 Plan Tree를 따라 실제 데이터를 읽는다.
위 그림의 Analyzer는 개념을 이해하기 쉽게 의미 분석 과정을 별도로 표현한 것이다.


Parser / Analyzer

먼저 SQL을 분석한다.

SELECT name
FROM users
WHERE id = 10;

이 과정에서는 대략 다음과 같은 내용을 확인한다.

  1. SELECT 문법이 올바른가?
  2. users라는 테이블이 존재하는가?
  3. name, id라는 컬럼이 존재하는가?
  4. id = 10이라는 비교가 타입 상 가능한가?
    그리고 SQL 문자열은 데이터베이스가 처리할 수 있는 Query Tree 형태로 변환된다.

Rewriter

다음은 Query Rewrite 단계다.
대표적인 사례가 View다.
예를 들어 다음 View가 있다고 하자.

CREATE VIEW active_users AS
SELECT *
FROM users
WHERE active = true;

그리고 다음 SQL을 실행한다.

SELECT *
FROM active_users;

실제로 active_users라는 물리적인 테이블을 읽는 것이 아니다.
Rewrite System은 View 정의를 이용해 내부적으로 원본 테이블을 조회할 수 있는 Query Tree로 변환한다.
PostgreSQL의 Query Rewriter는 Parser와 Planner/Optimizer 사이에서 동작하며 입력과 출력 모두 Query Tree 형태를 사용한다.


3. Planner / Optimizer

실행 계획에서 가장 중요한 단계다.
Optimizer는 Query Tree를 보고 가능한 데이터 접근 방법을 검토한다.
예를 들어 다음 SQL이 있다고 하자.

SELECT *
FROM users
WHERE id = 100;

users.id에 인덱스가 있다면 PostgreSQL은 대략 다음과 같은 후보를 생각할 수 있다.

방법 A

users 전체 읽기

Seq Scan

또는

방법 B

users_id_idx 인덱스 탐색
        ↓
해당 데이터 접근

Index Scan

그리고 각각의 Cost를 계산한다.

Seq Scan

Cost = 1800


Index Scan

Cost = 8

이 상황이라면 Index Scan을 선택할 가능성이 높다.
하지만 중요한 점이 있다.

인덱스가 존재한다고 해서 항상 Index Scan을 선택하는 것은 아니다.

예를 들어 테이블의 80%를 조회하는 SQL이라면

SELECT *
FROM users
WHERE active = true;

인덱스를 이용해서 대부분의 Row를 하나씩 찾아가는 것보다 테이블을 순차적으로 읽는 Seq Scan이 더 저렴할 수 있다.
즉,

Index 존재 ≠ Index Scan 사용

이다.
Optimizer의 목적은 인덱스를 사용하는 것이 아니라 예상 비용이 가장 낮은 실행 방법을 찾는 것이다. PostgreSQL 공식 문서에서도 인덱스가 존재하는 경우 Sequential Scan과 Index Scan 같은 후보 경로를 만들고 각 경로의 Cost를 추정하여 더 저렴한 경로를 선택한다고 설명한다.


4. Executor

Planner가 실행계획을 만들었다면 Executor가 실제로 실행한다.
예를 들어 실행계획이 다음과 같다고 하자.

Index Scan using users_email_idx on users

Executor는 실제로 해당 인덱스를 탐색하고 Row를 가져온다.
조인이 포함되었다면 실행계획은 하나의 작업이 아니라 여러 Plan Node가 연결된 트리 형태가 된다.
예를 들어 다음과 같다.

Nested Loop
├── Index Scan on users
└── Index Scan on orders

Executor는 이 Plan Tree를 따라가면서 데이터를 처리한다.
PostgreSQL Executor는 Plan Tree를 재귀적으로 순회하면서 Scan, Sort, Join 등의 작업을 수행하고 최종 결과 Row를 반환한다.


5. EXPLAIN / EXPLAIN ANALYZE

PostgreSQL에서는 실행계획을 확인하기 위해 대표적으로 두 가지 명령을 사용한다.

EXPLAIN

그리고

EXPLAIN ANALYZE

이다.
두 명령은 비슷해 보이지만 중요한 차이가 있다.


EXPLAIN

EXPLAIN
SELECT *
FROM users
WHERE id = 100;

EXPLAIN은 PostgreSQL Planner가 선택한 예상 실행계획을 보여준다.
예를 들어 다음과 같은 결과가 나올 수 있다.

Index Scan using users_pkey on users
    (cost=0.29..8.31 rows=1 width=64)
    Index Cond: (id = 100)

여기서 보이는

cost
rows
width

는 전부 Optimizer가 계산한 추정값이다.
즉 실제로 쿼리를 수행해 얻은 결과가 아니다.
PostgreSQL 문서에서도 EXPLAIN을 Planner가 생성한 실행계획을 표시하는 명령으로 설명한다.


EXPLAIN ANALYZE

이번에는 다음과 같이 실행한다.

EXPLAIN ANALYZE
SELECT *
FROM users
WHERE id = 100;

결과는 조금 달라진다.

Index Scan using users_pkey on users
    (cost=0.29..8.31 rows=1 width=64)
    (actual time=0.028..0.030 rows=1 loops=1)

    Index Cond: (id = 100)

Planning Time: 0.150 ms
Execution Time: 0.080 ms

기존에 있던

cost
rows
width

뿐만 아니라

actual time
actual rows
loops

가 추가된다.
즉,

EXPLAIN
→ Optimizer의 예상


EXPLAIN ANALYZE
→ Optimizer의 예상 + 실제 실행 결과

라고 생각하면 된다.
EXPLAIN ANALYZE는 실제 Statement를 실행하여 각 Plan Node의 실제 실행 시간과 반환 Row 수를 측정한다.


6. EXPLAIN ANALYZE 사용 시 주의점

ANALYZE라는 단어 때문에 단순한 분석 명령처럼 생각하기 쉽지만 EXPLAIN ANALYZE는 실제로 SQL을 실행한다.
따라서 다음과 같은 SQL을 그대로 실행하면 안 된다.

EXPLAIN ANALYZE
DELETE FROM users;

실제로 DELETE가 수행된다.
데이터 변경 SQL을 분석해야 한다면 트랜잭션으로 감싸는 방법을 사용할 수 있다.

BEGIN;

EXPLAIN ANALYZE
DELETE FROM users
WHERE id = 100;

ROLLBACK;

PostgreSQL 공식 문서 역시 INSERT, UPDATE, DELETE, MERGE 등을 EXPLAIN ANALYZE로 확인할 때 실제 Side Effect가 발생한다는 점을 명확히 경고하고 있다.


7. 실행계획 트리 읽는 법

실행계획에서 초보자가 가장 헷갈리는 부분이 바로 트리 구조다.
다음 실행계획을 살펴보자.

Sort
  Sort Key: u.created_at
  -> Hash Join
       Hash Cond: (o.user_id = u.id)
       -> Seq Scan on orders o
       -> Hash
            -> Seq Scan on users u

SQL은 위에서 아래로 적혀 있지만 실행계획은 단순히 첫 번째 줄부터 순차적으로 실행하는 것이 아니다.
실행계획은 Tree 구조다.
위 구조를 그림으로 표현하면 다음과 같다.

                 Sort
                   │
               Hash Join
              /         \
     Seq Scan orders    Hash
                         │
                   Seq Scan users

기본적으로 부모 Node가 자식 Node에게 데이터를 요청한다.
따라서 실제 데이터를 만들어내기 시작하는 곳은 트리의 안쪽, 즉 자식 Node다.
대략 다음 흐름으로 이해할 수 있다.

Seq Scan users
        ↓
Hash 생성

Seq Scan orders
        ↓
Hash Join

        ↓

Sort

        ↓

결과 반환

그래서 실행계획을 분석할 때는 단순히 위에서 아래로 읽기보다는 들여쓰기를 보고 Parent와 Child 관계를 먼저 파악해야 한다.


8. 실행계획의 핵심 값

실행계획을 보면 다음과 같은 숫자들이 등장한다.

Seq Scan on users
(
    cost=0.00..431.00
    rows=5000
    width=64
)
(
    actual time=0.021..5.321
    rows=24870
    loops=1
)

각 숫자의 의미를 이해하는 것이 실행계획 분석의 핵심이다.


Cost

cost=0.00..431.00

Cost는 두 값으로 구성된다.

cost =
startup cost
..
total cost

즉,

0.00
↓
첫 번째 Row를 반환하기 전까지 필요한 예상 비용


431.00
↓
모든 Row를 반환하기까지 필요한 예상 비용

이다.
중요한 점은 Cost가 시간(ms)이 아니라는 것이다.

cost = 431 ≠ 431ms

Cost는 PostgreSQL이 여러 실행 방법을 비교하기 위해 사용하는 내부적인 비용 단위다.
PostgreSQL은 전통적으로 seq_page_cost = 1.0을 기준으로 디스크 Page 접근 비용 등을 상대적으로 표현한다.
따라서 Cost의 목적은

이 SQL은 431ms 걸릴 것이다.

를 의미하는 것이 아니라

Plan A Cost = 431

Plan B Cost = 820

→ Plan A가 더 저렴할 것으로 예상

와 같이 실행계획 후보를 비교하는 것이다.
또 하나 중요한 특징이 있다.
부모 Node의 Cost에는 일반적으로 자식 Node의 Cost가 포함된다.