집합 연산·EXISTS로 Sort와 중복 제거 줄이기
UNION을 UNION ALL의 상호 배타 분기로 바꾸거나 EXISTS·NOT EXISTS로 존재만 확인해 불필요한 SORT UNIQUE를 줄입니다.
핵심 요약
UNION, INTERSECT, MINUS, DISTINCT는 여러 Row Source를 결합하거나 중복을 제거합니다. 실행계획에는 다음과 같은 Operation이 나타날 수 있습니다.
SORT UNIQUE
HASH UNIQUE
UNION-ALL
HASH JOIN SEMI
NESTED LOOPS SEMI
HASH JOIN ANTI
NESTED LOOPS ANTI
성능 개선의 대표 방향은 다음과 같습니다.
UNION
→ 두 분기가 상호 배타적이면 UNION ALL 검토
JOIN + DISTINCT
→ 오른쪽 행의 존재 여부만 필요하면 EXISTS 검토
LEFT JOIN + IS NULL
→ 미존재 판정만 필요하면 NOT EXISTS 검토
OR 조건
→ Branch별 다른 Access Path가 유리하면
중복·NULL을 보존하는 UNION ALL 분리 검토
그러나 중복 제거를 없애는 변경은 결과를 바꿀 위험이 큽니다. 다음 네 가지를 먼저 증명해야 합니다.
1. 두 Branch가 정말 겹치지 않는가?
2. 왼쪽과 오른쪽의 중복을 보존해야 하는가?
3. NULL이 UNKNOWN을 만들어 결과를 바꾸지 않는가?
4. 최종 결과 Grain과 반환 Column이 같은가?
범위
이 이론은 SQLP
SQL 고급활용 및 튜닝 → 소트 튜닝에서 집합 연산과 Semi·Anti Join을 이용해 불필요한 중복 제거를 줄이는 원리를 다룹니다. Index Order·Top-N의 상세 조건은 후속 이론 ID 927에서 별도로 다룹니다.
학습 목표
이 이론을 학습한 뒤에는 다음을 설명할 수 있어야 합니다.
UNION과UNION ALL의 중복 처리 차이를 설명한다.INTERSECT,MINUS의 기본 결과 의미를 설명한다.- Compound Query의 Column 수·데이터 타입·
ORDER BY제약을 판단한다. - 두 Branch가 상호 배타적인지 NULL까지 포함해 검증한다.
- Join으로 발생한 중복을
DISTINCT로 제거하는 구조와EXISTS를 비교한다. NOT EXISTS,NOT IN,MINUS의 NULL·중복 차이를 구분한다.LNNVL이 FALSE와 UNKNOWN을 함께 다루는 이유를 이해한다.- 실행계획과 실제 통계로 중복 제거 비용 감소를 검증한다.
1. 집합 연산의 기본 의미
두 Query 결과를 A와 B라고 가정합니다.
| 연산 | 기본 결과 의미 |
|---|---|
UNION | A와 B를 합치고 중복 Row 제거 |
UNION ALL | A와 B를 합치고 중복 보존 |
INTERSECT | A와 B에 모두 존재하는 중복 제거 결과 |
MINUS | A에는 있고 B에는 없는 중복 제거 결과 |
EXCEPT | Oracle 26ai에서 MINUS와 같은 의미로 지원되는 표준 이름 |
UNION
SELECT customer_id
FROM online_orders
UNION
SELECT customer_id
FROM store_orders;
A와 B 결합
→ 전체 결과에서 중복 제거
→ 서로 다른 customer_id만 반환
UNION ALL
SELECT customer_id
FROM online_orders
UNION ALL
SELECT customer_id
FROM store_orders;
A와 B 결합
→ 중복 제거 없음
→ 각 Branch가 반환한 Row를 모두 보존
UNION ALL은 일반적으로 전체 중복 제거 Workarea가 필요하지 않지만, 각 Branch 내부의 DISTINCT, GROUP BY, ORDER BY, Join·Window 작업까지 자동으로 제거하는 것은 아닙니다.
2. Compound Query의 구조 규칙
Set Operator로 연결하는 각 Query는 다음 조건을 만족해야 합니다.
2.1 SELECT 목록 개수
-- 잘못된 예
SELECT customer_id, customer_name
FROM customers
UNION
SELECT customer_id
FROM dormant_customers;
각 Component Query의 Select List 표현식 개수가 같아야 합니다.
2.2 데이터 타입 Group
대응하는 Column은 같은 데이터 타입 Group과 호환되어야 합니다.
문자 ↔ 문자
숫자 ↔ 숫자
날짜 ↔ 날짜·호환 가능한 시간 타입
문자와 숫자처럼 서로 다른 Type Group을 Set Operator가 임의로 변환해 준다고 가정하지 않습니다. 명시적 변환이 필요하면 결과 길이·정밀도·NULL 의미까지 검증합니다.
2.3 ORDER BY 위치
Compound Query의 최종 정렬은 전체 문장의 마지막에 작성합니다.
SELECT customer_id AS id
FROM online_orders
UNION ALL
SELECT customer_id AS id
FROM store_orders
ORDER BY id;
각 Branch 안의 ORDER BY가 최종 결합 결과 순서를 보장하는 것은 아닙니다. Branch별 Top-N 같은 별도 의미가 필요하면 Inline View 등으로 Query Block을 분리해야 합니다.
Select List에 표현식을 사용하고 그 표현식으로 최종 정렬하려면 첫 Component Query에서 Alias를 부여해 사용하는 것이 안전합니다.
3. UNION과 UNION ALL의 비용 차이
각 Branch가 100만 행을 반환한다고 가정합니다.
UNION
→ 최대 200만 입력 Row 결합
→ 중복 제거 Key 비교
→ Sort 또는 Hash Workarea 사용 가능
→ 결과 반환
UNION ALL
→ Branch 결과 연결
→ 전체 중복 제거 단계 없음
→ 결과 반환
UNION의 비용은 다음에 영향을 받습니다.
- 두 Branch의 총 입력 행 수
- Select List의 Row 폭
- 실제 Unique Row 수
- Sort·Hash Workarea Memory
- TEMP Spill 여부
- 병렬 재분배
- 최종
ORDER BY
결과가 같다는 증명 없이 UNION ALL로 바꾸면 성능은 좋아져도 중복 Row가 추가될 수 있습니다.
4. UNION ALL로 안전하게 바꾸는 조건
4.1 분기가 물리적으로 분리된 경우
SELECT payment_id, payment_date, amount
FROM payment_2025
UNION ALL
SELECT payment_id, payment_date, amount
FROM payment_2026;
두 Table이 기간별로 완전히 분리되고 중복 적재가 없다는 제약이 있다면 두 Branch가 겹치지 않을 수 있습니다.
4.2 조건이 논리적으로 상호 배타적인 경우
SELECT order_id, amount
FROM orders
WHERE amount < 100000
UNION ALL
SELECT order_id, amount
FROM orders
WHERE amount >= 100000;
Non-NULL amount라면 한 행이 두 Branch에 동시에 속하지 않습니다.
그러나 amount가 NULL이면 어느 Branch에도 포함되지 않습니다. 원래 SQL이 NULL Row를 포함했다면 결과가 달라집니다.
4.3 경계 조건을 정확히 나눈 경우
첫 Branch : order_date < DATE '2026-01-01'
둘째 Branch: order_date >= DATE '2026-01-01'
다음과 같이 경계를 겹치면 중복이 발생합니다.
첫 Branch : order_date <= DATE '2026-01-01'
둘째 Branch: order_date >= DATE '2026-01-01'
경계값, NULL, 중복 적재, 형변환을 모두 테스트해야 합니다.
5. OR 조건을 UNION ALL Branch로 분리한다
다음 SQL을 생각합니다.
SELECT order_id, customer_id, status
FROM orders
WHERE customer_id = :customer_id
OR status = 'URGENT';
두 조건에 각각 다른 Index가 유리하면 Branch 분리를 검토할 수 있습니다.
SELECT order_id, customer_id, status
FROM orders
WHERE customer_id = :customer_id
UNION ALL
SELECT order_id, customer_id, status
FROM orders
WHERE status = 'URGENT'
AND LNNVL(customer_id = :customer_id);
두 조건을 모두 만족하는 Row는 첫 Branch에서만 반환되어야 합니다. 두 번째 Branch는 첫 번째 조건이 TRUE인 Row를 제외해야 합니다.
LNNVL(condition)의 결과는 다음과 같습니다.
condition = TRUE → LNNVL = FALSE
condition = FALSE → LNNVL = TRUE
condition = UNKNOWN → LNNVL = TRUE
따라서 Nullable Column이 포함된 조건에서 단순 NOT(condition)보다 FALSE와 UNKNOWN을 함께 포함하는 배타 조건을 표현하는 데 사용할 수 있습니다.
수동 OR 분리는 반드시 다음을 검증합니다.
- 두 조건을 동시에 만족하는 Row
- 첫 조건만 만족하는 Row
- 둘째 조건만 만족하는 Row
- 두 조건 모두 불만족인 Row
- 비교 Column이 NULL인 Row
- Bind가 NULL인 경우
Optimizer가 자체적으로 OR Expansion을 수행할 수도 있으므로 수동 재작성 전 실제 Plan을 확인합니다.
6. Join + DISTINCT에서 중복이 생기는 이유
고객과 주문은 1:N 관계라고 가정합니다.
SELECT DISTINCT c.customer_id,
c.customer_name
FROM customers c
JOIN orders o
ON o.customer_id = c.customer_id
WHERE o.order_date >= :start_date;
처리 의미입니다.
고객 1행
→ 조건을 만족한 주문 수만큼 반복
→ DISTINCT가 고객 행을 다시 1행으로 축약
오른쪽 주문의 Column이나 Match 개수가 필요하지 않고 “주문이 존재하는 고객”만 필요하다면 Row를 늘렸다가 줄이는 구조입니다.
7. EXISTS와 Semi Join
SELECT c.customer_id,
c.customer_name
FROM customers c
WHERE EXISTS (
SELECT 1
FROM orders o
WHERE o.customer_id = c.customer_id
AND o.order_date >= :start_date
);
EXISTS는 Subquery가 한 행 이상 반환하면 TRUE입니다.
개념적인 Semi Join 의미입니다.
왼쪽 고객 1행
→ 오른쪽에서 첫 Match 확인
→ 존재 여부 확정
→ 같은 고객의 추가 주문 수는 최종 Row 수를 늘리지 않음
가능한 실행계획 Operation:
HASH JOIN SEMI
NESTED LOOPS SEMI
MERGE JOIN SEMI
FILTER
실제 Operation은 통계·Index·Transformation에 따라 달라질 수 있습니다.
8. Join + DISTINCT를 EXISTS로 바꿀 수 있는 조건
다음 조건을 모두 확인합니다.
- 최종 Select List가 왼쪽 Table Column만 사용하는가?
- 오른쪽에서 “한 건 이상 존재”만 필요한가?
- 오른쪽 Match 개수는 결과에 필요하지 않은가?
- 오른쪽 Aggregate·순번·최신 행 값이 필요하지 않은가?
- Inner Join의 존재 의미를 유지하는가?
- Outer Join의 NULL 보존 의미를 잘못 바꾸지 않는가?
- 왼쪽 Source 자체의 중복을 DISTINCT가 제거하고 있지는 않은가?
마지막 조건이 중요합니다.
JOIN + DISTINCT
→ 왼쪽 Source 자체의 동일 Select List Row까지 제거할 수 있음
EXISTS
→ 왼쪽 Source가 가진 중복 Row를 그대로 보존할 수 있음
왼쪽이 PK 기준 한 행씩이라는 보장이 있거나, 원래 요구가 왼쪽 Row 보존이면 의미가 같을 수 있습니다. 그렇지 않으면 별도의 DISTINCT가 여전히 필요할 수 있습니다.
9. NOT EXISTS와 Anti Join
미주문 고객을 조회합니다.
SELECT c.customer_id,
c.customer_name
FROM customers c
WHERE NOT EXISTS (
SELECT 1
FROM orders o
WHERE o.customer_id = c.customer_id
);
개념적인 Anti Join 의미입니다.
오른쪽 Match 존재
→ 왼쪽 Row 제거
오른쪽 Match 없음
→ 왼쪽 Row 반환
가능한 Operation:
HASH JOIN ANTI
NESTED LOOPS ANTI
MERGE JOIN ANTI
FILTER
10. NOT IN과 NULL 함정
다음 SQL을 비교합니다.
SELECT c.customer_id
FROM customers c
WHERE c.customer_id NOT IN (
SELECT o.customer_id
FROM orders o
);
Subquery에 NULL이 하나라도 포함되면 x <> NULL 비교가 UNKNOWN이 되어 기대한 Row가 반환되지 않을 수 있습니다.
10 NOT IN (20, NULL)
10 <> 20 → TRUE
10 <> NULL → UNKNOWN
TRUE AND UNKNOWN → UNKNOWN
WHERE 통과 실패
NOT EXISTS는 상관 비교가 TRUE인 Match 존재 여부를 확인하므로 일반적인 미존재 조회에서 NULL 의미를 더 직접적으로 표현할 수 있습니다.
SELECT c.customer_id
FROM customers c
WHERE NOT EXISTS (
SELECT 1
FROM orders o
WHERE o.customer_id = c.customer_id
);
단, 두 SQL이 항상 자동으로 같다고 단정하지 않습니다. 왼쪽 Key의 NULL 허용 여부와 요구사항을 함께 확인합니다.
11. MINUS와 NOT EXISTS의 차이
11.1 기본 형태
SELECT customer_id
FROM customer_candidate
MINUS
SELECT customer_id
FROM blocked_customer;
SELECT c.customer_id
FROM customer_candidate c
WHERE NOT EXISTS (
SELECT 1
FROM blocked_customer b
WHERE b.customer_id = c.customer_id
);
두 SQL은 “오른쪽에 없는 왼쪽 값”이라는 점은 유사하지만 다음이 다릅니다.
11.2 왼쪽 중복
왼쪽 입력이 다음과 같다고 가정합니다.
10
10
20
오른쪽에 20만 있다면:
MINUS 결과
10
NOT EXISTS 결과
10
10
MINUS는 기본 Set 연산이므로 결과 중복을 제거합니다. NOT EXISTS는 왼쪽 Source의 중복 Row를 보존합니다.
같은 결과가 필요하면 왼쪽 유일성을 증명하거나 DISTINCT 필요 여부를 판단해야 합니다.
11.3 NULL
Set 연산의 NULL 중복 처리와 상관 비교의 3값 논리는 서로 다릅니다. 양쪽에 NULL이 있을 때 결과를 실제로 검증해야 합니다.
12. INTERSECT와 EXISTS의 차이
SELECT customer_id
FROM campaign_customer
INTERSECT
SELECT customer_id
FROM purchase_customer;
INTERSECT는 양쪽에 존재하는 중복 제거 Set 결과를 만듭니다.
SELECT c.customer_id
FROM campaign_customer c
WHERE EXISTS (
SELECT 1
FROM purchase_customer p
WHERE p.customer_id = c.customer_id
);
EXISTS는 왼쪽 campaign_customer의 중복 Row를 보존할 수 있습니다.
따라서 다음을 확인합니다.
- 왼쪽 Key가 Unique한가?
- Select List가 Key 하나뿐인가?
- 중복 제거가 업무 요구인가?
- NULL Key를 어떻게 처리할 것인가?
13. 불필요한 DISTINCT를 찾는 방법
DISTINCT를 발견했다고 바로 삭제하지 않습니다. 먼저 중복이 발생한 위치를 찾습니다.
1. 최종 결과 한 행의 Grain을 정의
2. 각 Table의 Join Key가 Unique인지 확인
3. 1:N·N:M Join에서 Row가 증가하는지 확인
4. DISTINCT 입력 A-Rows와 출력 A-Rows 비교
5. 존재 조회면 EXISTS·Semi Join 검토
6. Join이 불필요하면 제거 가능성 검토
7. 결과 Row 수·NULL·중복을 회귀 테스트
다음과 같은 경우 DISTINCT는 증상만 가리는 표현일 수 있습니다.
- 잘못된 Join 조건
- 누락된 Join Predicate
- N:M 관계를 1:1로 가정
- 존재 여부 조회에 상세 Join 사용
- 여러 Detail Table을 동시에 Join해 Row가 곱해짐
14. 실행계획과 Runtime 검증
실제 수행 통계를 수집합니다.
SELECT /*+ GATHER_PLAN_STATISTICS */
customer_id
FROM online_orders
UNION
SELECT customer_id
FROM store_orders;
SELECT *
FROM TABLE(
DBMS_XPLAN.DISPLAY_CURSOR(
NULL,
NULL,
'ALLSTATS LAST +MEMSTATS +PREDICATE +NOTE'
)
);
확인 항목:
| 항목 | 판단 질문 |
|---|---|
| Set Operation | UNION-ALL, SORT UNIQUE, HASH UNIQUE 중 무엇이 나타나는가? |
| Semi·Anti Join | SEMI, ANTI, FILTER 중 어떤 형태로 변환됐는가? |
입력 A-Rows | 중복 제거 또는 Join 전 몇 행이 들어왔는가? |
출력 A-Rows | 최종 Unique Row·왼쪽 보존 Row는 몇 개인가? |
Starts | 상관 Subquery가 왼쪽 Row마다 반복됐는가? |
Buffers·Reads | Branch·Inner 탐색에서 실제 Block 작업량은 얼마인가? |
| Memory·TEMP | Unique Operation이 Spill했는가? |
| Predicate | Branch 배타 조건·상관 조건이 정확히 적용됐는가? |
| 결과 비교 | Row 수뿐 아니라 중복 횟수·NULL·Column 값이 같은가? |
EXISTS로 바꿨다고 항상 빠른 것은 아닙니다.
왼쪽 Row 수가 매우 많음
+ 오른쪽 상관 Index 없음
+ FILTER 반복 Starts 증가
→ 반복 탐색 비용이 커질 수 있음
반대로 Hash Semi Join이 한 번의 Scan으로 처리되거나 적절한 Index로 첫 Match를 빠르게 찾으면 유리할 수 있습니다. 실제 계획과 통계로 비교합니다.
15. 결과 동등성 테스트
재작성 전후를 다음 데이터로 비교합니다.
오른쪽 Match 0건
오른쪽 Match 1건
오른쪽 Match 여러 건
왼쪽 중복 Row
오른쪽 중복 Row
왼쪽 Key NULL
오른쪽 Key NULL
두 UNION Branch 동시 만족
Branch 경계값
Bind NULL
비교 항목:
- 전체 Row 수
- Key별 중복 횟수
- NULL Row 존재
- 각 Column 값
- 최종 정렬
- Aggregate 결과
- A-Rows·Buffers·TEMP·Elapsed Time
16. 혼동하기 쉬운 판단
| 잘못된 판단 | 정확한 기준 |
|---|---|
UNION ALL은 언제나 UNION과 같다 | 중복 보존 여부가 다름 |
| Branch 조건이 달라 보이면 상호 배타적 | 경계·NULL·동시 만족 검증 필요 |
NOT(condition)이면 NULL까지 안전하게 제외 | UNKNOWN은 TRUE가 아니므로 LNNVL 등 검토 |
EXISTS는 오른쪽 Match 수만큼 왼쪽을 반복 | 존재 여부만 판단하는 Semi Join 의미 |
| Join + DISTINCT는 항상 EXISTS로 변경 가능 | 왼쪽 중복·반환 Column·Outer Join 의미 확인 |
NOT IN과 NOT EXISTS는 항상 같다 | Subquery NULL과 왼쪽 NULL 주의 |
MINUS와 NOT EXISTS는 항상 같다 | MINUS는 왼쪽 중복 제거, NOT EXISTS는 보존 가능 |
INTERSECT와 EXISTS는 항상 같다 | INTERSECT는 Set 중복 제거 |
UNION ALL이면 Sort가 전혀 없음 | Branch 내부·최종 ORDER BY Sort는 남을 수 있음 |
| DISTINCT 제거 후 Row 수만 같으면 안전 | Key별 중복·NULL·Column 값도 비교 |
17. 적용 판단 순서
1. 최종 결과 Grain과 중복 허용 여부를 정의한다.
2. 집합 연산별 중복 보존 규칙을 기록한다.
3. Branch가 상호 배타적인지 경계·NULL까지 확인한다.
4. Compound Query의 Column 수와 데이터 타입을 확인한다.
5. 최종 ORDER BY 위치와 Alias를 확인한다.
6. DISTINCT 입력을 만드는 Join 관계를 분석한다.
7. 존재 여부만 필요하면 EXISTS·NOT EXISTS를 검토한다.
8. NOT IN·MINUS·INTERSECT의 NULL·중복 차이를 검증한다.
9. 변경 전후 결과 집합을 테스트 데이터로 비교한다.
10. ALLSTATS LAST에서 A-Rows·Starts·Buffers·Memory·TEMP를 비교한다.
11. 대표 Bind·데이터 분포·동시 부하에서 회귀를 확인한다.
개념 확인 문제
문제를 누르면 바로 아래에서 정답과 해설을 확인할 수 있습니다.
01UNION과 UNION ALL의 결과 의미 차이는 무엇인가?
UNION은 두 결과를 결합한 뒤 중복 Row를 제거하고, UNION ALL은 각 Branch의 중복을 그대로 보존합니다.
02Compound Query에서 각 Select List가 충족해야 하는 기본 조건은 무엇인가?
각 Component Query의 Select List 표현식 수가 같아야 하고, 대응하는 표현식은 서로 호환되는 데이터 타입 Group이어야 합니다.
03Compound Query 전체를 정렬하는 ORDER BY는 어디에 작성하는가?
전체 Compound Query의 마지막에 한 번 작성합니다. Branch 내부 정렬은 최종 결합 결과의 순서를 보장하지 않습니다.
04두 Branch가 상호 배타적임을 검증할 때 확인할 조건은 무엇인가?
두 조건을 동시에 만족하는 Row, 경계값, NULL, 중복 적재와 Bind NULL을 확인해야 합니다. 조건 문장이 다르다는 사실만으로 배타성이 증명되지는 않습니다.
05LNNVL(condition)은 TRUE·FALSE·UNKNOWN에 각각 어떤 결과를 반환하는가?
원래 조건이 TRUE이면 FALSE, FALSE 또는 UNKNOWN이면 TRUE입니다. Nullable 조건에서 첫 Branch의 TRUE Row만 제외하고 FALSE·UNKNOWN Row를 둘째 Branch에 포함하는 용도로 사용할 수 있습니다.
06Join + DISTINCT가 불필요한 중복 제거를 만들 수 있는 이유는 무엇인가?
1:N Join에서 오른쪽 Match 수만큼 왼쪽 행이 반복되고, 후속 DISTINCT가 다시 한 행으로 축약할 수 있기 때문입니다. 존재 여부만 필요하면 불필요한 Row 증폭일 수 있습니다.
07EXISTS와 Semi Join의 결과 의미는 무엇인가?
상관 조건을 만족하는 오른쪽 Row가 한 건 이상 존재하는 왼쪽 Row를 반환합니다. Semi Join은 오른쪽 Match 개수가 최종 왼쪽 Row 수를 늘리지 않습니다.
08NOT IN Subquery에 NULL이 있을 때 어떤 문제가 발생할 수 있는가?
NULL 비교가 UNKNOWN을 만들어 기대한 미존재 Row가 반환되지 않을 수 있습니다. NOT EXISTS와 NULL 의미를 비교해야 합니다.
09MINUS와 NOT EXISTS는 왼쪽 중복을 어떻게 다르게 처리할 수 있는가?
MINUS는 Set 결과이므로 왼쪽 중복을 제거하지만, NOT EXISTS는 조건을 만족하는 왼쪽 중복 Row를 그대로 보존할 수 있습니다.
10재작성 전후 실행계획과 결과 검증에서 확인해야 할 항목은 무엇인가?
결과 Row 수·Key별 중복·NULL·Column 값과 함께 Set·Semi·Anti Operation, 입력·출력 A-Rows, Starts, Buffers, Reads, Memory·TEMP, 응답시간을 비교해야 합니다.