포스트

DISTINCT 를 INDEX UNIQUE SCAN으로 제거

옵티마이저가 결과 row의 유일성을 이미 보장한다고 판단하면 DISTINCT를 위한 HASH/SORT UNIQUE 단계가 자동으로 사라집니다. PK·UNIQUE 인덱스 활용이 곧 옵티마이저에게 '중복 제거 불필요'임을 알려주는 일임을 실행계획으로 보여줍니다.

DISTINCT 를 INDEX UNIQUE SCAN으로 제거

옵티마이저가 결과 row 의 유일성을 이미 보장한다고 판단하면 — DISTINCT 를 위한 별도 단계(HASH UNIQUE / SORT UNIQUE)는 자동으로 사라집니다.
PK / UNIQUE 인덱스를 잘 활용하는 것이 곧 옵티마이저에게 “이건 중복 제거 안 해도 됩니다” 라고 알려주는 일입니다.

핵심 정리

  1. SELECT 절의 컬럼이 PK / UNIQUE 인덱스로 유일성이 보장되면 옵티마이저는 DISTINCT 를 자동 제거할 수 있습니다.
  2. FROM 절의 모든 테이블이 PK / UNIQUE 등치 검색으로 0 또는 1 row 만 내놓으면, 조인 결과 자체가 1 row 이하로 보장되어 DISTINCT 처리가 불필요해집니다.
  3. 실행계획에 HASH UNIQUE / SORT UNIQUE 가 보이면 — DISTINCT 처리에 별도 COST 가 들고 있다는 신호입니다. SELECT 절·WHERE 절·인덱스 구성을 점검해 옵티마이저가 유일성을 추론할 수 있게 정보를 보강할 여지가 있는지 확인합니다.

시나리오

같은 두 테이블·같은 인덱스. 옵티마이저가 어떤 조건에서 DISTINCT 를 별도 단계로 처리하고, 어떤 조건에서 그것을 제거하는지 비교하기 위한 예시입니다.

  • DEPARTMENT PK: DEPT_ID_PK (department_id)
  • LOCATION PK: LOC_ID_PK (location_id)
  • DEPARTMENT.LOCATION_IDunique 하지 않음 (여러 부서가 같은 위치를 공유 가능)

아래 두 쿼리는 결과값이 다른 별개의 SQL 입니다. 같은 시나리오의 before/after 가 아니라, DISTINCT 가 옵티마이저 단계에서 어떻게 처리되는지를 비교하기 위한 예시입니다.


1) 원본 — d.location_id 가 unique 하지 않음

DEPARTMENT 의 PK 는 department_id 이지만 SELECT 절에는 d.department_id, d.location_id 두 컬럼이 노출됩니다. d.location_id 자체는 unique 하지 않으므로 옵티마이저는 결과 row 의 유일성을 보장할 수 없다고 보고 DISTINCT 를 위한 별도 단계를 추가합니다.

1
2
3
SELECT DISTINCT d.department_id, d.location_id
  FROM department d, location l
 WHERE d.location_id = l.location_id;
1
2
3
4
5
6
7
8
9
10
11
12
-----------------------------------------------------------------------------
| Id | Operation              | Name       | Rows | Bytes | Cost | Time     |
-----------------------------------------------------------------------------
|  0 | SELECT STATEMENT       |            |      |       |    4 |          |
|  1 |  HASH UNIQUE           |            |   27 |   270 |    4 | 00:00:01 |  ← DISTINCT 처리 비용
|  2 |   NESTED LOOPS         |            |   27 |   270 |    3 | 00:00:01 |
|  3 |    TABLE ACCESS FULL   | DEPARTMENT |   27 |   189 |    3 | 00:00:01 |
|  4 |    INDEX UNIQUE SCAN   | LOC_ID_PK  |    1 |     3 |    0 |          |
-----------------------------------------------------------------------------
Predicate Information:
----------------------
  4 - access("D"."LOCATION_ID"="L"."LOCATION_ID")

Id 1HASH UNIQUEDISTINCT 처리를 위한 추가 단계 입니다. NL 조인이 끝난 27 row 를 다시 해시 빌드하여 중복을 걷어냅니다.


2) 수정 — PK 등치 술어로 결과 1건 보장

d.department_id = 10 등치 술어가 추가됐습니다. DEPARTMENT 의 PK 가 department_id 이므로 DEPT_ID_PK INDEX UNIQUE SCAN 으로 0 또는 1 row 만 나옵니다. 그 1 row 의 location_idLOCATIONLOC_ID_PK INDEX UNIQUE SCAN — 역시 0 또는 1 row.

1
2
3
4
SELECT DISTINCT d.department_id, l.city, l.location_id
  FROM department d, location l
 WHERE d.location_id = l.location_id
   AND d.department_id = 10;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
------------------------------------------------------------------------------------
| Id | Operation                     | Name       | Rows | Bytes | Cost | Time     |
------------------------------------------------------------------------------------
|  0 | SELECT STATEMENT              |            |      |       |    3 |          |
|  1 |  NESTED LOOPS                 |            |    1 |    22 |    2 | 00:00:01 |  ← UNIQUE 단계 사라짐
|  2 |   TABLE ACCESS BY INDEX ROWID | DEPARTMENT |   27 |   270 |    3 | 00:00:01 |
|  3 |    INDEX UNIQUE SCAN          | DEPT_ID_PK |    1 |       |    0 |          |
|  4 |   TABLE ACCESS BY INDEX ROWID | LOCATION   |   23 |   345 |    1 | 00:00:01 |
|  5 |    INDEX UNIQUE SCAN          | LOC_ID_PK  |    1 |     3 |    0 |          |
------------------------------------------------------------------------------------
Predicate Information:
----------------------
  3 - access("D"."DEPARTMENT_ID"=10)
  5 - access("D"."LOCATION_ID"="L"."LOCATION_ID")

Id 1 위에 있던 HASH UNIQUE사라졌습니다. NL 조인 결과 자체가 0 또는 1 row 임을 옵티마이저가 알아차렸기 때문입니다.


분석

왜 원본은 HASH UNIQUE 가 추가되는가

옵티마이저는 SELECT 결과 row 가 이미 unique 한지를 다음 정보로 판단합니다.

  • SELECT 절에 노출된 컬럼이 어떤 PK / UNIQUE 인덱스를 cover 하는가
  • FROM 절의 각 테이블이 결과에 몇 row 기여하는가 (cardinality estimation)

원본 쿼리는 d.department_id(PK) + d.location_id(non-unique) 두 컬럼을 SELECT 합니다. department_id 만 봤을 때는 이미 unique 하지만, location_id 가 SELECT 에 추가되면서 옵티마이저는 보수적으로 “이 두 컬럼 조합이 unique 한지 확정 못 한다” 고 판단해 HASH UNIQUE 를 끼워 넣습니다. 또한 DEPARTMENT 에 대한 등치 술어가 없어 TABLE ACCESS FULL 로 27 row 를 모두 읽으니, 조인 결과 row 수도 27 로 추정되어 중복 가능성이 더욱 살아 있는 셈입니다.

왜 수정 쿼리는 HASH UNIQUE 가 사라지는가

d.department_id = 10 술어 + DEPT_ID_PK 가 결합하면 옵티마이저는 다음을 추론합니다.

  • DEPARTMENT 에서 0 또는 1 row 만 나옴 (PK 등치 검색)
  • 그 1 row 의 location_idLOCATION 을 PK 조회 → 역시 0 또는 1 row
  • 두 1-row 의 NL 조인 결과 = 최대 1 row → DISTINCT 적용 대상이 애초에 없음

결과 cardinality 가 1 임을 옵티마이저가 알면, DISTINCT이미 만족된 제약 이 되어 별도 단계로 변환되지 않습니다.

DISTINCT 는 단순히 “중복을 지워줘” 가 아니라 “이 SELECT 결과가 unique 하지 않을 가능성이 있다” 는 정보를 옵티마이저에게 주는 신호입니다.
PK / UNIQUE 인덱스로 유일성이 이미 보장된 자리에서는 옵티마이저가 알아서 비용 0 으로 처리합니다.

비교 요약

조건원본수정
WHERE 술어join 조건만join 조건 + department_id = 10 (PK 등치)
SELECT 결과 unique 보장부분 (department_id 만)전체 (PK 등치로 결과 1 row 확정)
DISTINCT 처리 단계HASH UNIQUE (Id 1)(없음)
DEPARTMENT 접근TABLE ACCESS FULLINDEX UNIQUE SCAN + ROWID
총 Cost43

정리

세 줄로 압축하면:

  1. SELECT 결과의 유일성이 PK / UNIQUE 인덱스로 보장되면 옵티마이저는 DISTINCT 를 자동 제거합니다.
  2. WHERE 절의 PK 등치 술어 (= 상수) 는 cardinality = 1 을 보장하여 위 효과를 강력하게 trigger 합니다.
  3. 실행계획에서 HASH UNIQUE / SORT UNIQUE 가 보이면 — SELECT 절·WHERE 절·인덱스 구성을 점검해 옵티마이저가 유일성을 추론할 수 있게 정보를 보강할 수 있는지 확인합니다.
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.