> ## Content Index
> Fetch the complete content index at: https://mlog.me/llms.txt
> Use this file to discover other available public pages before exploring further.

# PostgreSQL 복잡한 JOIN 쿼리 성능 최적화: 행 증폭 제거·선집계·부분 인덱스
- URL: https://mlog.me/postgresql-query-optimization-pre-aggregation-partial-index/
- Published: 2026-08-20T13:11:34.000Z
- Updated: 2026-09-23T03:25:19.000Z
- Description: PostgreSQL의 1:N×1:N JOIN에서 발생하는 행 증폭을 선집계로 제거하고, 상태별 UNION ALL·부분 인덱스·EXPLAIN·pg_stat_statements로 검증하는 실전 튜닝 과정을 정리한다.
- Author: mLog
- Tags: 데이터베이스, PostgreSQL, SQL 튜닝

노선·예약처럼 1:N 관계가 여러 개 겹친 PostgreSQL 조회를 점검할 때, 당시 운영 통계에서 평균 실행 시간 약 **0.3초**, 높은 `shared_blks_hit`와 거의 늘지 않는 읽기 블록 수를 관찰했다. 캐시를 많이 활용한다는 사실만으로 최적화가 끝났다고 볼 수는 없었다. 이 글은 그 관찰을 출발점으로 조인 행 수와 집계 구조를 살펴보고, 선집계·상태 조건 분리·부분 인덱스를 비교하는 방법을 정리한다. 약 0.3초라는 수치 자체는 아래 개선안의 적용 전후 성능 향상을 입증하는 결과표가 아니다.

노선, 정류장 시간, 예약, 좌석, 즐겨찾기처럼 1:N 관계가 여러 개 겹친 조회는 SQL 한 문장으로 만들 수 있다. 하지만 **한 번에 조인할 수 있다는 것과 효율적으로 조인된다는 것은 다른 문제**다. 특히 `1:N × 1:N` 조인 뒤 `GROUP BY`, `COUNT(DISTINCT ...)`, `SUM(...)`으로 결과를 수습하는 구조는 중간 행 수를 크게 늘리고 집계 오류까지 만들 수 있다.

이 글은 실제 PostgreSQL 조회를 튜닝하면서 확인한 내용을 공개용 테이블명으로 바꿔 정리한 기록이다. 핵심은 다음 세 가지다.

1. 큰 테이블을 모두 조인한 뒤 집계하지 말고, 각 1:N 테이블을 **업무 키 단위로 먼저 집계**한다.
2. 서로 겹치지 않는 상태 조건은 `OR`와 `UNION ALL` 두 형태로 비교하고, 결과가 같은지 확인한 뒤 실행계획에 맞는 형태를 고른다.
3. 인덱스는 개수보다 **실제 WHERE·JOIN 조건과 선두 컬럼 순서**가 중요하며, 적용 전후는 반드시 실행계획과 운영 통계로 검증한다.

이 글은 노선·정류장 시간·예약·즐겨찾기를 한 SQL에서 결합할 때 대상 노선을 먼저 제한하고, 하위 데이터를 노선 단위로 집계하는 구성에 초점을 둔다. 한 SQL과 여러 SQL 중 무엇을 선택할지, 페이지 처리와 조회 시점의 일관성을 어떻게 맞출지는 [JOIN과 고정 횟수 분리 조회의 선택 기준](https://mlog.me/postgresql-join-group-by-vs-split-query-performance/)에서 별도로 비교한다.

> 아래 테이블·컬럼 이름은 공개를 위해 일반화했다. 실제 서비스명, 서버 주소, 사용자 번호, 원본 SQL 식별자는 포함하지 않았다.

## 1\. 조회 조건

예시 업무 조건은 다음과 같다.

- 특정 기관과 운행일의 노선을 조회한다.
- 운행일은 `:base_date`라는 `YYYYMMDD` 문자열로 전달된다.
- 예약 상태가 `S`이면 그대로 포함한다.
- 예약 상태가 `W`이면 현재 SQL 문 시작 시각을 기준으로 최근 10분 이내 데이터만 포함한다.
- 노선별 시작·종료 시각, 좌석 정보, 예약 수와 사용자 즐겨찾기 여부를 함께 반환한다.

예약 조건만 떼어 보면 다음과 같다.

```sql
WITH params AS (
    SELECT (statement_timestamp() AT TIME ZONE 'Asia/Seoul') AS as_of
)
SELECT v.*
FROM reservation v
CROSS JOIN params p
WHERE v.reservation_date = to_date(:base_date, 'YYYYMMDD')
  AND (
         v.state = 'S'
      OR (
           v.state = 'W'
           AND v.reserved_at >= p.as_of - interval '10 minutes'
           AND v.reserved_at <= p.as_of
         )
  );

```

`statement_timestamp()`는 현재 트랜잭션 시작 시각이 아니라 **현재 SQL 문이 시작된 시각**을 반환한다. 위 예시는 이를 params의 as\_of로 정하고, W 상태를 as\_of - 10분 이상·as\_of 이하로 제한한다. 정확히 10분 전과 기준 시각은 포함하고 미래 시각은 제외한다. 한 SQL 안에서는 기준이 일관되지만, 별도 SQL들의 결과를 비교할 때는 동일한 as\_of 값을 전달해야 한다.

단, 위 예시는 `reserved_at`이 한국 로컬 시각을 담는 `timestamp without time zone`일 때 맞는 형태다. 컬럼이 `timestamptz`라면 위 params의 SELECT를 다음처럼 바꾸고, 하한과 상한은 그대로 p.as\_of를 사용한다. 아래 전체 최적화 SQL도 같은 원칙으로 params의 as\_of 정의만 바꿔야 한다.

```sql
-- reserved_at이 timestamptz이면 params 안의 SELECT를 교체
SELECT statement_timestamp() AS as_of

```

날짜·시간 타입이 섞인 상태에서 암묵적 변환에 기대면 결과와 인덱스 사용 여부가 달라질 수 있다. 먼저 컬럼 타입부터 확인한다.

```sql
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_schema = 'public'
  AND table_name = 'reservation'
  AND column_name IN ('reservation_date', 'reserved_at');

```

## 2\. 첫 번째 병목: 1:N × 1:N 조인의 행 증폭

한 노선에 정류장 시간이 20개, 예약이 30개 있다고 가정해 보자. 두 테이블을 노선에 동시에 조인하면 집계 전 단계에서 최대 `20 × 30 = 600`개 행이 만들어질 수 있다.

```sql
SELECT
    r.route_id,
    MIN(t.arrival_time) AS start_time,
    MAX(t.arrival_time) AS end_time,
    COUNT(DISTINCT v.reservation_id) AS reservation_count
FROM route_master r
LEFT JOIN route_time t
       ON t.org_id = r.org_id
      AND t.route_id = r.route_id
LEFT JOIN reservation v
       ON v.org_id = r.org_id
      AND v.route_id = r.route_id
WHERE r.org_id = :org_id
GROUP BY r.route_id;

```

최종 결과가 노선당 한 행이어도 PostgreSQL은 그 전에 늘어난 행을 조인하고 정렬하거나 해시로 묶어야 한다. `COUNT(DISTINCT ...)`는 중복 결과를 가려줄 수 있지만 중간 처리 비용까지 없애지는 않는다.

더 위험한 것은 `SUM`이다. 예약 금액이나 좌석 수를 같은 구조에서 합산하면 정류장 수만큼 반복되어 실제보다 크게 계산될 수 있다. 따라서 성능 문제이기 전에 **집계 정확성 문제**로 봐야 한다.

## 3\. 해결 원칙: 각 테이블을 노선당 한 행으로 줄인 뒤 조인

정류장 시간은 노선별 `MIN/MAX`, 예약은 노선별 `COUNT`, 즐겨찾기는 사용자·노선별 한 행으로 먼저 만든다. 이후 노선 기준으로 1:1 조인하면 중간 행 수가 통제된다.

### 공개용으로 정리한 최적화 SQL

아래 SQL은 원래 조회를 일반화한 개선 후보다. 같은 결과를 반환하는지부터 확인하고, 동일한 조건의 실행계획과 반복 측정으로 실제 효과를 검증해야 한다.

```sql
WITH params AS (
    SELECT
        to_date(:base_date, 'YYYYMMDD') AS service_date,
        extract(
            isodow FROM to_date(:base_date, 'YYYYMMDD')
        )::integer AS iso_day_of_week,
        :org_id::bigint AS org_id,
        :user_id::bigint AS user_id,
        :route_type::text AS route_type,
        (statement_timestamp() AT TIME ZONE 'Asia/Seoul') AS as_of
),
route_base AS (
    SELECT
        r.org_id,
        r.route_id,
        r.vehicle_id,
        r.route_name,
        r.seat_type
    FROM route_master r
    CROSS JOIN params p
    WHERE r.org_id = p.org_id
      AND r.day_of_week = p.iso_day_of_week
      AND r.route_type = p.route_type
      AND r.use_yn = 'Y'
),
route_key AS (
    SELECT DISTINCT org_id, route_id
    FROM route_base
),
favorite_route AS (
    SELECT DISTINCT f.org_id, f.route_id
    FROM user_favorite_route f
    JOIN route_key k
      ON k.org_id = f.org_id
     AND k.route_id = f.route_id
    CROSS JOIN params p
    WHERE f.user_id = p.user_id
      AND f.use_yn = 'Y'
),
route_time_agg AS (
    SELECT
        t.org_id,
        t.route_id,
        MIN(t.arrival_time) AS start_time,
        MAX(t.arrival_time) AS end_time
    FROM route_time t
    JOIN route_key k
      ON k.org_id = t.org_id
     AND k.route_id = t.route_id
    GROUP BY t.org_id, t.route_id
),
reservation_candidates AS (
    SELECT
        v.org_id,
        v.route_id,
        v.reservation_id
    FROM reservation v
    JOIN route_key k
      ON k.org_id = v.org_id
     AND k.route_id = v.route_id
    CROSS JOIN params p
    WHERE v.reservation_date = p.service_date
      AND v.state = 'S'

    UNION ALL

    SELECT
        v.org_id,
        v.route_id,
        v.reservation_id
    FROM reservation v
    JOIN route_key k
      ON k.org_id = v.org_id
     AND k.route_id = v.route_id
    CROSS JOIN params p
    WHERE v.reservation_date = p.service_date
      AND v.state = 'W'
      AND v.reserved_at >= p.as_of - interval '10 minutes'
      AND v.reserved_at <= p.as_of
),
reservation_count AS (
    SELECT
        org_id,
        route_id,
        COUNT(DISTINCT reservation_id) AS reservation_count
    FROM reservation_candidates
    GROUP BY org_id, route_id
)
SELECT
    CASE WHEN f.route_id IS NULL THEN 'N' ELSE 'Y' END AS favorite_yn,
    r.route_id,
    r.vehicle_id,
    r.route_name,
    t.start_time,
    t.end_time,
    r.seat_type,
    COALESCE(c.reservation_count, 0) AS reservation_count
FROM route_base r
LEFT JOIN favorite_route f
       ON f.org_id = r.org_id
      AND f.route_id = r.route_id
LEFT JOIN route_time_agg t
       ON t.org_id = r.org_id
      AND t.route_id = r.route_id
LEFT JOIN reservation_count c
       ON c.org_id = r.org_id
      AND c.route_id = r.route_id
ORDER BY favorite_yn DESC, t.start_time;

```

이 구조의 핵심은 CTE 개수가 아니라 대상 제한·선집계·최종 조인의 논리적 단계를 분리하는 데 있다. CTE를 나열한 순서가 물리 실행 순서를 보장하지는 않는다. 실제 스캔과 조인 순서는 플래너가 선택하므로 실행계획으로 확인한다.

1. `route_base`에서 대상 노선을 먼저 줄인다.
2. `route_key`를 통해 하위 테이블이 읽어야 할 노선 키 범위를 제한한다.
3. 정류장 시간과 예약을 각각 노선당 한 행으로 집계한다.
4. 마지막 SELECT는 대부분 1:1 관계만 조인한다.

`route_key`에 `DISTINCT`가 필요한지는 `route_base`의 유일성에 따라 결정한다. 이미 `(org_id, route_id)`가 유일하다면 불필요한 `DISTINCT`는 제거하는 편이 낫다.

## 4\. `OR`를 `UNION ALL`로 나눈 이유

두 예약 상태 조건은 서로 겹치지 않는다.

- 첫 번째 분기: `state = 'S'`
- 두 번째 분기: `state = 'W'`이면서 최근 10분

따라서 중복 제거가 필요한 `UNION`이 아니라 `UNION ALL`을 사용할 수 있다. 이 구조는 각 분기가 자기 조건에 맞는 부분 인덱스를 사용하기 쉬워지고, 실행계획도 읽기 쉬워진다.

다만 `OR`를 항상 `UNION ALL`로 바꾸면 빨라지는 것은 아니다. 데이터 분포와 PostgreSQL 버전에 따라 BitmapOr가 더 효율적일 수도 있다. 변경 전후를 같은 바인드 값과 비슷한 캐시 상태에서 측정해야 한다.

## 5\. `COUNT(DISTINCT)`를 계속 써야 할까?

위 예제에는 안전하게 `COUNT(DISTINCT reservation_id)`를 남겨두었다. 하지만 `reservation_candidates`에서 예약 한 건이 정확히 한 행임이 스키마와 조인 조건으로 보장된다면 다음처럼 줄일 수 있다.

```sql
COUNT(*) AS reservation_count

```

`COUNT(DISTINCT)`는 중복 가능성을 해결하는 도구이지, 불완전한 조인 조건을 가리는 보험으로 쓰면 안 된다. 다음 순서로 확인한다.

아래 진단은 앞의 전체 `WITH` 절을 함께 사용하고 마지막 `SELECT`를 이 쿼리로 바꿔 실행한다. `reservation_candidates`는 별도 영구 테이블이 아니다.

```sql
SELECT reservation_id, COUNT(*)
FROM reservation_candidates
GROUP BY reservation_id
HAVING COUNT(*) > 1;

```

결과가 없고 유일성이 제약조건으로도 보장된다면 `COUNT(*)` 전환을 검토할 수 있다.

## 6\. 부분 인덱스 설계

원래 조건은 `S`와 `W`의 검색 방식이 다르다. 하나의 넓은 인덱스보다 상태별 부분 인덱스가 더 작고 명확할 수 있다.

```sql
CREATE INDEX CONCURRENTLY idx_reservation_s_route_date
ON reservation (org_id, route_id, reservation_date)
INCLUDE (reservation_id)
WHERE state = 'S';

CREATE INDEX CONCURRENTLY idx_reservation_w_route_date_time
ON reservation (org_id, route_id, reservation_date, reserved_at)
INCLUDE (reservation_id)
WHERE state = 'W';

```

`org_id`, `route_id`, `reservation_date`는 동등 조건이고, `reserved_at`은 범위 조건이다. B-tree 다중 컬럼 인덱스는 일반적으로 선두 동등 조건 뒤에 첫 범위 조건을 두는 구성이 효율적이다.

`INCLUDE (reservation_id)`는 검색 키가 아니라 결과 반환에 필요한 값을 인덱스 리프에 포함한다. 조건과 테이블 가시성 상태가 맞으면 index-only scan 가능성을 높일 수 있지만, 인덱스가 커지고 쓰기 비용도 늘어난다. 무조건 추가하지 말고 실행계획과 인덱스 크기를 함께 본다.

정류장 시간 집계에는 다음 후보를 검토할 수 있다.

```sql
CREATE INDEX CONCURRENTLY idx_route_time_route_arrival
ON route_time (org_id, route_id, arrival_time);

```

운영 서버에서 `CREATE INDEX CONCURRENTLY`는 일반 인덱스 생성보다 오래 걸리고 추가 작업이 필요하지만 쓰기를 장시간 막는 위험을 줄여준다. 그래도 CPU·I/O 부하는 발생하므로 저부하 시간에 진행하고 완료 후 유효성을 확인한다.

```sql
SELECT
    indexrelid::regclass AS index_name,
    indisvalid,
    indisready
FROM pg_index
WHERE indexrelid IN (
    'idx_reservation_s_route_date'::regclass,
    'idx_reservation_w_route_date_time'::regclass,
    'idx_route_time_route_arrival'::regclass
);

```

부분 인덱스는 쿼리의 WHERE 조건이 인덱스의 조건을 논리적으로 포함한다고 플래너가 판단할 수 있어야 사용된다. 상태 값을 함수로 감싸거나 표현을 지나치게 바꾸면 인덱스를 인식하지 못할 수 있다.

상태까지 바인드 변수로 일반화한 prepared statement는 계획 시점에 `state = 'S'` 또는 `state = 'W'`임을 증명하지 못해 해당 부분 인덱스를 사용하지 못할 수 있다. 이 예시처럼 분기마다 상태 리터럴이 고정되어 있을 때 적용 가능성이 더 명확하다. 또한 변하는 현재 시각은 인덱스 predicate로 만들 수 없으므로, `W` 상태만 부분 조건으로 두고 `reserved_at`을 인덱스 키로 둔다.

## 7\. 인덱스를 막는 흔한 표현

문자 컬럼의 공백을 없애려고 다음처럼 조건 컬럼을 함수로 감싸는 경우가 있다.

```sql
WHERE trim(route_type) = :route_type

```

일반 `(route_type)` 인덱스는 이 표현에 직접 맞지 않는다. 데이터에 실제 패딩이 없다면 불필요한 `trim()`을 제거한다. 패딩이 업무 규칙이라 제거할 수 없다면 데이터 정규화 또는 표현식 인덱스를 별도로 검토한다.

```sql
CREATE INDEX CONCURRENTLY idx_route_master_trimmed_type
ON route_master (org_id, trim(route_type));

```

표현식 인덱스도 쓰기 비용과 저장 공간을 사용한다. 먼저 데이터 품질을 확인하는 것이 우선이다.

```sql
SELECT COUNT(*) AS padded_rows
FROM route_master
WHERE route_type <> trim(route_type);

```

## 8\. 실행계획은 이렇게 비교한다

인덱스를 만들었다는 사실만으로 성공이라고 판단하지 않는다. 같은 파라미터로 변경 전·후 실행계획을 비교한다.

```sql
ANALYZE route_master;
ANALYZE route_time;
ANALYZE reservation;

EXPLAIN (
    ANALYZE,
    BUFFERS,
    VERBOSE,
    SETTINGS
)
SELECT ...;

```

`EXPLAIN ANALYZE`는 쿼리를 실제로 실행한다. SELECT라도 운영 부하가 큰 쿼리는 스테이징 또는 저부하 시간에 측정해야 한다.

확인할 항목은 다음과 같다.

- 각 노드의 `actual rows`와 예상 `rows` 차이
- `loops`가 비정상적으로 큰 노드
- 조인 직후 행 수가 급증하는 지점
- `Rows Removed by Filter`
- `shared hit`와 `shared read`
- 임시 파일을 의미하는 temp read/write
- Sort 또는 Hash의 메모리 사용과 디스크 spill 여부
- 새 부분 인덱스의 실제 사용 여부

실제 시간과 행 수는 `loops`당 평균으로 표시되는 항목이 있으므로 전체 작업량을 볼 때는 반복 횟수를 함께 본다. 상위 노드의 버퍼 수치에는 하위 노드 사용량이 포함되므로 각 노드 값을 단순 합산해서도 안 된다.

당시 운영 통계에서는 평균 실행 시간이 약 0.3초 수준이고 `shared_blks_hit`가 높으며 실제 읽기 블록은 거의 없는 모습도 확인했다. 이것은 캐시가 잘 활용된다는 뜻이지, 쿼리 구조가 최적이라는 뜻은 아니다. 호출 수가 많다면 0.3초도 누적 부하가 된다.

## 9\. `pg_stat_statements`로 누적 비용 확인

단일 실행계획은 한 번의 측정이고, `pg_stat_statements`는 운영 중 누적 통계를 보여준다.

```sql
SELECT
    queryid,
    calls,
    rows,
    total_exec_time,
    mean_exec_time,
    shared_blks_hit,
    shared_blks_read,
    temp_blks_read,
    temp_blks_written
FROM pg_stat_statements
WHERE query ILIKE '%route_master%'
ORDER BY total_exec_time DESC
LIMIT 20;

```

평균 시간만 보면 호출 빈도가 높은 쿼리를 놓칠 수 있다. `calls`, `total_exec_time`, `mean_exec_time`, 블록 읽기와 임시 파일을 같이 봐야 한다. 다른 사용자의 SQL 원문과 `queryid`를 보려면 권한이 필요할 수 있다.

누적 블록 수는 `calls`로 나눠 실행당 사용량도 함께 확인한다. `shared_blks_read`는 PostgreSQL이 공유 블록을 읽은 횟수이며 운영체제 캐시가 개입할 수 있으므로 물리 디스크 I/O 횟수와 완전히 같다고 단정하지 않는다. `rows` 역시 중간 조인 행이 아니라 최종 반환 또는 변경 행의 누계다.

적용 직후에는 짧은 구간만 보고 결론 내리지 않는다. 대표 트래픽이 누적된 뒤 같은 기준으로 비교한다. 통계 초기화는 전체 관측 기준을 바꾸므로 공동 운영 환경에서 임의로 실행하지 않는다.

## 10\. 뷰와 머티리얼라이즈드 뷰를 먼저 만들 필요는 없다

일반 VIEW는 SQL을 저장해 읽기 쉽게 만들지만 결과를 미리 계산해 보관하지 않는다. 따라서 뷰로 감싼다는 이유만으로 행 증폭이나 집계 비용이 사라지지는 않는다.

MATERIALIZED VIEW는 결과를 저장하므로 조회는 빨라질 수 있지만 다음 비용이 생긴다.

- 새 데이터가 즉시 반영되지 않는다.
- 갱신 주기와 `REFRESH` 비용을 관리해야 한다.
- 실시간 “최근 10분” 조건과 잘 맞지 않을 수 있다.

먼저 원본 쿼리의 조인 범위와 집계 순서를 고치고 인덱스를 검증한다. 그래도 변하지 않는 대규모 집계가 반복될 때 머티리얼라이즈드 뷰를 검토하는 순서가 안전하다.

## 11\. 최종 체크리스트

- \[ \] 여러 1:N 테이블을 동시에 조인해 행이 곱해지지 않는가?
- \[ \] `SUM` 값이 조인 때문에 반복 계산되지 않는가?
- \[ \] 각 하위 테이블을 최종 업무 키 단위로 먼저 집계했는가?
- \[ \] `LEFT JOIN`한 테이블의 조건을 WHERE에 두어 사실상 INNER JOIN으로 바꾸지 않았는가?
- \[ \] 서로 배타적인 상태 조건은 `UNION ALL` 분리가 더 명확한가?
- \[ \] `COUNT(DISTINCT)`가 실제로 필요한가?
- \[ \] 날짜와 timestamp/timestamptz 타입을 맞춰 비교하는가?
- \[ \] 인덱스 선두 컬럼이 실제 동등 조건과 조인 키를 반영하는가?
- \[ \] 부분 인덱스의 predicate가 쿼리 조건과 맞는가?
- \[ \] `TRIM`, `CAST`, 함수 적용이 일반 인덱스 사용을 막지 않는가?
- \[ \] `EXPLAIN (ANALYZE, BUFFERS)`에서 actual rows·loops·buffer를 비교했는가?
- \[ \] `pg_stat_statements`로 호출 수와 누적 실행 시간을 확인했는가?

## 추가 실행 검증: 600행 조인에서 예약 수만 맞으면 놓치는 문제

2026-09-09에 이 글의 행 증폭 예제를 별도 환경에서 실행했다. 환경은 Node.js 24.19.0, PGlite 0.5.8에 포함된 PostgreSQL 18.3(WebAssembly·메모리 DB)이다. 운영 데이터 대신 노선 1개, 정류장 시간 20개, 예약 30개를 만들었다. 합계 중복도 확인하려고 검증용 `amount` 컬럼을 추가하고 예약마다 100을 넣었다. 따라서 정상 합계는 3,000이다.

본문 2절의 조인 형태와 3절의 노선 키별 선집계 부분을 비교한 실제 결과는 다음과 같다. 즐겨찾기·좌석·부분 인덱스를 포함한 전체 업무 쿼리의 검증은 아니다.

| 확인 항목                            | 정류장·예약 동시 조인         | 각 테이블 선집계 후 조인 |
| -------------------------------- | -------------------- | -------------- |
| 최종 집계 직전 원본 조인 행 / 선집계 결과 간 조인 행 | 600                  | 1              |
| 예약 수                             | COUNT(DISTINCT)로 30  | 30             |
| 검증용 예약 금액 합계                     | 60,000               | 3,000          |
| 시작·종료 시각                         | 08:00:00 \~ 08:19:00 | 동일             |

여기서 눈에 띈 점은 **예약 수와 시작·종료 시각만 검사하면 원본도 정상으로 보인다는 것**이다. `COUNT(DISTINCT reservation_id)`는 30을 반환했지만, 같은 조인에서 금액은 정류장 수인 20배로 반복되었다. 그래서 개선안의 검증에는 최종 예약 수뿐 아니라 조인 행 수와 중복에 민감한 합계도 함께 넣었다. 600행은 `COUNT(*)`, 선집계 1행은 반환 행 수로 확인한 논리적 조인 결과이며, 실행계획의 전체 처리량이나 성능 배수는 아니다.

시간 조건도 별도로 검사했다. `reserved_at`은 `timestamp without time zone`, 기준 시각은 서울 현지 시각 `2026-09-09 10:00:00`으로 고정했다. 날짜·상태·경계를 달리한 예약 8개를 넣자 `OR`와 `UNION ALL` 모두 같은 4개 ID를 반환했다. 정확히 09:50:00인 W와 10:00:00인 W는 포함했고, 하한보다 1마이크로초 전·상한보다 1마이크로초 후인 W는 제외했다. 다른 상태와 다른 운행일도 제외되었다. 양방향 `EXCEPT ALL`의 차집합은 0행이었다.

이번 결과로 확인한 것은 **이 최소 데이터에서의 행 증폭·합계 복구·시간 경계의 결과 일치**다. 앞서 기록한 운영 평균 약 0.3초를 재측정한 것이 아니며, 선집계나 `UNION ALL`의 운영 성능 향상·부분 인덱스 효과까지 입증한 결과로 해석하지 않는다.

## 마무리

복잡한 조회를 튜닝할 때 가장 먼저 볼 것은 “쿼리를 몇 번 실행하느냐”가 아니라 **집계 전에 몇 행을 만들고 있느냐**다. 데이터베이스 왕복을 줄이려고 모든 테이블을 한 번에 조인했더라도, 그 결과로 수백·수천 배의 중간 행이 생기면 오히려 더 느리고 집계까지 틀릴 수 있다.

이번 사례의 결론은 단순하다.

> 대상 노선을 먼저 제한하고, 정류장 시간과 예약을 노선 단위로 선집계한다. 결과가 같은지 확인한 뒤 상태 조건과 부분 인덱스 후보를 실행계획·운영 통계로 비교한다.

인덱스는 마지막 처방이 아니라 쿼리 구조와 함께 설계해야 한다. 그리고 가장 좋은 SQL은 예쁘게 보이는 SQL이 아니라, 실제 데이터 분포에서 행 수와 I/O를 줄였다는 것을 실행계획으로 증명한 SQL이다.

## 공식 참고자료

- [PostgreSQL: WITH Queries와 CTE 최적화](https://www.postgresql.org/docs/current/queries-with.html?ref=mlog.me)
- [PostgreSQL: Using EXPLAIN](https://www.postgresql.org/docs/current/using-explain.html?ref=mlog.me)
- [PostgreSQL: EXPLAIN](https://www.postgresql.org/docs/current/sql-explain.html?ref=mlog.me)
- [PostgreSQL: Partial Indexes](https://www.postgresql.org/docs/current/indexes-partial.html?ref=mlog.me)
- [PostgreSQL: Multicolumn Indexes](https://www.postgresql.org/docs/current/indexes-multicolumn.html?ref=mlog.me)
- [PostgreSQL: CREATE INDEX](https://www.postgresql.org/docs/current/sql-createindex.html?ref=mlog.me)
- [PostgreSQL: ANALYZE](https://www.postgresql.org/docs/current/sql-analyze.html?ref=mlog.me)
- [PostgreSQL: Date/Time Functions and Operators](https://www.postgresql.org/docs/current/functions-datetime.html?ref=mlog.me)
- [PostgreSQL: pg\_stat\_statements](https://www.postgresql.org/docs/current/pgstatstatements.html?ref=mlog.me)