Search

REMINDER 05-06. 존재 여부만 필요하면 JOIN + DISTINCT 대신 EXISTS를 고려하자

상황: 주문 이력이 한 건이라도 있는 GOLD 등급 회원의 ID를 중복 없이 조회한다. 실제 기본 테이블 startdbpg.ms_mbr과 startdbpg.tr_ord를 사용한다.

BAD SQL — JOIN으로 주문을 펼친 후 DISTINCT로 중복 제거

SELECT DISTINCT m.mbr_id FROM startdbpg.ms_mbr m JOIN startdbpg.tr_ord o ON o.mbr_id = m.mbr_id WHERE m.mbr_gd = 'GOLD';
SQL
복사
회원 한 명에게 주문이 여러 건 있으면 같은 회원 ID가 주문 건수만큼 JOIN 결과에 반복된다. 그 후 DISTINCT로 중복을 제거한다.

GOOD SQL — EXISTS로 주문 존재 여부만 판단

SELECT m.mbr_id FROM startdbpg.ms_mbr m WHERE m.mbr_gd = 'GOLD' AND EXISTS ( SELECT 1 FROM startdbpg.tr_ord o WHERE o.mbr_id = m.mbr_id );
SQL
복사
원하는 조건은 '주문이 있는 회원인가?'이므로 주문 자체의 모든 행을 결과로 결합할 이유가 없다. EXISTS는 존재 여부를 표현하고, 이번 실습에서는 실제로 Nested Loop Semi Join으로 처리되었다.
실제 데이터 검증
•
ms_mbr의 GOLD 회원: 3,146명
•
주문 이력이 있는 GOLD 회원: 3,124명
•
주문 이력이 없는 GOLD 회원: 22명
•
BAD, GOOD 각각 3,124행 반환. 양방향 차집합 검사(bad_only, good_only) 모두 0행.
•
ms_mbr.mbr_id는 실제 PRIMARY KEY (mbr_id)이며 NOT NULL이므로, 이 사례에서 DISTINCT가 회원 중복만 제거한다는 전제가 성립한다.
결과 동등성 검증 SQL
WITH bad AS ( SELECT DISTINCT m.mbr_id FROM startdbpg.ms_mbr m JOIN startdbpg.tr_ord o ON o.mbr_id = m.mbr_id WHERE m.mbr_gd = 'GOLD' ), good AS ( SELECT m.mbr_id FROM startdbpg.ms_mbr m WHERE m.mbr_gd = 'GOLD' AND EXISTS ( SELECT 1 FROM startdbpg.tr_ord o WHERE o.mbr_id = m.mbr_id ) ) SELECT (SELECT COUNT(*) FROM bad) AS bad_cnt, (SELECT COUNT(*) FROM good) AS good_cnt, (SELECT COUNT(*) FROM bad b WHERE NOT EXISTS ( SELECT 1 FROM good g WHERE g.mbr_id = b.mbr_id )) AS bad_only, (SELECT COUNT(*) FROM good g WHERE NOT EXISTS ( SELECT 1 FROM bad b WHERE b.mbr_id = g.mbr_id )) AS good_only; -- 실제 결과: bad_cnt=3124, good_cnt=3124, bad_only=0, good_only=0
SQL
복사
실측 비교
항목
BAD: JOIN + DISTINCT
GOOD: EXISTS
반환 회원 수
3,124
3,124
주요 계획 노드
Nested Loop → Unique
Nested Loop Semi Join
주문과 JOIN한 행 수
270,123행
존재 확인이므로 중복 행 확장 없음
회원당 주문 인덱스 측정
rows=86 loops=3146
rows=1 loops=3146
Buffers: shared hit
9,654
9,571
단일 실행시간
132.985ms
28.876ms

BAD SQL — 실제 실행계획

EXPLAIN (ANALYZE, BUFFERS, COSTS OFF) SELECT DISTINCT m.mbr_id FROM startdbpg.ms_mbr m JOIN startdbpg.tr_ord o ON o.mbr_id = m.mbr_id WHERE m.mbr_gd = 'GOLD';
SQL
복사
Unique (actual time=0.064..132.592 rows=3124 loops=1) Buffers: shared hit=9654 -> Nested Loop (actual time=0.054..100.901 rows=270123 loops=1) Buffers: shared hit=9654 -> Index Scan using ms_mbr_pk on ms_mbr m (actual time=0.027..3.971 rows=3146 loops=1) Filter: ((mbr_gd)::text = 'GOLD'::text) Rows Removed by Filter: 6853 Buffers: shared hit=161 -> Index Only Scan using tr_ord_fk01 on tr_ord o (actual time=0.007..0.018 rows=86 loops=3146) Index Cond: (mbr_id = (m.mbr_id)::text) Heap Fetches: 0 Buffers: shared hit=9493 Planning: Buffers: shared hit=306 Planning Time: 1.503 ms Execution Time: 132.985 ms
Plain Text
복사

GOOD SQL — 실제 실행계획

EXPLAIN (ANALYZE, BUFFERS, COSTS OFF) SELECT m.mbr_id FROM startdbpg.ms_mbr m WHERE m.mbr_gd = 'GOLD' AND EXISTS ( SELECT 1 FROM startdbpg.tr_ord o WHERE o.mbr_id = m.mbr_id );
SQL
복사
Nested Loop Semi Join (actual time=0.048..28.413 rows=3124 loops=1) Buffers: shared hit=9571 -> Seq Scan on ms_mbr m (actual time=0.022..2.788 rows=3146 loops=1) Filter: ((mbr_gd)::text = 'GOLD'::text) Rows Removed by Filter: 6853 Buffers: shared hit=132 -> Index Only Scan using tr_ord_fk01 on tr_ord o (actual time=0.008..0.008 rows=1 loops=3146) Index Cond: (mbr_id = (m.mbr_id)::text) Heap Fetches: 0 Buffers: shared hit=9439 Planning: Buffers: shared hit=324 Planning Time: 1.598 ms Execution Time: 28.876 ms
Plain Text
복사
실행계획 읽기: BAD는 GOLD 회원 3,146명을 기준으로 주문을 JOIN해 270,123행을 만든 뒤 Unique로 3,124행으로 축소했다. GOOD은 Nested Loop Semi Join으로 각 회원에 대해 주문이 존재하는지만 확인한다. 주문 측 Index Only Scan의 rows=1 loops=3146이 그 증거다. 두 실행계획은 회원 테이블의 접근 경로(Index Scan 대 Seq Scan)도 다르므로, 실행시간의 모든 차이를 Semi Join 하나로만 설명해서는 안 된다.

핵심

•
원하는 결과가 '관련 행이 존재하는가?'라면 JOIN + DISTINCT로 중복을 만든 후 제거하기보다 EXISTS로 존재 조건을 직접 표현하자. 실제 PostgreSQL 실행계획에서도 중복 확대 및 제거 작업이 없어지는 사례를 확인했다.
💡
참고·예외
•
EXISTS가 항상 더 빠르거나 항상 Nested Loop Semi Join이 되는 것은 아니다. 데이터 분포·인덱스·통계에 따라 Hash Semi Join 등 다른 계획이 가능하고, 옵티마이저가 JOIN + DISTINCT를 다른 방식으로 최적화할 수도 있다. 본 실행시간은 단일 실행 측정값으로 반복 측정 결과나 보장된 성능 비율이 아니다. 주문 건수·금액·주문별 정보가 필요하다면 EXISTS만으로는 요구사항을 충족하지 못한다.