상황: GOLD 등급 회원을 모두 조회하고, 픽업 완료(PKUP) 주문이 있다면 주문번호도 함께 표시한다. 실제 일반 테이블 startdbpg.ms_mbr, startdbpg.tr_ord에서 검증했다. ms_mbr에는 GOLD 회원 3,146명이 있고 그중 22명은 픽업 완료 주문이 전혀 없다.
BAD SQL — 오른쪽 테이블 조건을 WHERE에 지정
SELECT m.mbr_id, o.ord_no
FROM startdbpg.ms_mbr m
LEFT JOIN startdbpg.tr_ord o
ON o.mbr_id = m.mbr_id
WHERE m.mbr_gd = 'GOLD'
AND o.ord_st = 'PKUP';
SQL
복사
LEFT JOIN으로 보존한 회원 행이라도 매칭되는 주문이 없으면 o.ord_st는 NULL이다. 이때 WHERE o.ord_st = 'PKUP'은 참이 아니므로 그 회원이 결과에서 사라진다. 실제 계획도 외부 조인이 아닌 Nested Loop(내부 조인)로 처리되었다.
GOOD SQL — 오른쪽 테이블 조건을 ON에 지정
SELECT m.mbr_id, o.ord_no
FROM startdbpg.ms_mbr m
LEFT JOIN startdbpg.tr_ord o
ON o.mbr_id = m.mbr_id
AND o.ord_st = 'PKUP'
WHERE m.mbr_gd = 'GOLD';
SQL
복사
ON 조건은 픽업 완료 주문 중 어떤 행이 회원과 매칭될지 정한다. 매칭되는 주문이 없어도 LEFT JOIN은 회원 행을 유지하고 o.ord_no에 NULL을 반환한다.
실제 데이터 비교
측정 항목 | BAD: 상태 조건 WHERE | GOOD: 상태 조건 ON |
GOLD 등급 회원(원본 데이터) | 3,146명 | 3,146명 |
조회 결과에 포함된 회원 | 3,124명 | 3,146명 |
ord_no IS NULL인 결과 | 0건 | 22건 |
최종 반환 행 수 | 270,117행 | 270,139행 |
실제 JOIN 노드 | Nested Loop | Nested Loop Left Join |
Buffers | shared hit=248171 | shared hit=248171 |
재측정 실행시간 | 367.408ms | 375.619ms |
누락 회원 검증 SQL: 실제 GOLD 회원 M3391은 주문이 한 건도 없으며 BAD에서는 0행, GOOD에서는 ord_no=NULL인 1행이 반환되었다.
-- 결과: GOOD, M3391, NULL (BAD는 결과 없음)
SELECT 'BAD' AS sql_type, m.mbr_id, o.ord_no
FROM startdbpg.ms_mbr m
LEFT JOIN startdbpg.tr_ord o
ON o.mbr_id = m.mbr_id
WHERE m.mbr_gd = 'GOLD'
AND o.ord_st = 'PKUP'
AND m.mbr_id = 'M3391'
UNION ALL
SELECT 'GOOD' AS sql_type, m.mbr_id, o.ord_no
FROM startdbpg.ms_mbr m
LEFT JOIN startdbpg.tr_ord o
ON o.mbr_id = m.mbr_id
AND o.ord_st = 'PKUP'
WHERE m.mbr_gd = 'GOLD'
AND m.mbr_id = 'M3391';
SQL
복사
미매칭 회원 건수 검증 SQL
SELECT COUNT(*) AS gold_members,
COUNT(*) FILTER (
WHERE NOT EXISTS (
SELECT 1
FROM startdbpg.tr_ord o
WHERE o.mbr_id = m.mbr_id
AND o.ord_st = 'PKUP'
)
) AS no_pickup_members
FROM startdbpg.ms_mbr m
WHERE m.mbr_gd = 'GOLD';
-- 실제 결과: gold_members=3146, no_pickup_members=22
SQL
복사
실측 실행계획 — BAD
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT m.mbr_id, o.ord_no
FROM startdbpg.ms_mbr m
LEFT JOIN startdbpg.tr_ord o
ON o.mbr_id = m.mbr_id
WHERE m.mbr_gd = 'GOLD'
AND o.ord_st = 'PKUP';
SQL
복사
Nested Loop (actual time=0.071..344.879 rows=270117 loops=1)
Buffers: shared hit=248171
-> Seq Scan on ms_mbr m (actual time=0.034..2.823 rows=3146 loops=1)
Filter: ((mbr_gd)::text = 'GOLD'::text)
Rows Removed by Filter: 6853
Buffers: shared hit=132
-> Index Scan using tr_ord_fk01 on tr_ord o (actual time=0.007..0.095 rows=86 loops=3146)
Index Cond: ((mbr_id)::text = (m.mbr_id)::text)
Filter: ((ord_st)::text = 'PKUP'::text)
Rows Removed by Filter: 0
Buffers: shared hit=248039
Planning:
Buffers: shared hit=330
Planning Time: 3.142 ms
Execution Time: 367.408 ms
Plain Text
복사
실측 실행계획 — GOOD
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT m.mbr_id, o.ord_no
FROM startdbpg.ms_mbr m
LEFT JOIN startdbpg.tr_ord o
ON o.mbr_id = m.mbr_id
AND o.ord_st = 'PKUP'
WHERE m.mbr_gd = 'GOLD';
SQL
복사
Nested Loop Left Join (actual time=0.052..352.127 rows=270139 loops=1)
Buffers: shared hit=248171
-> Seq Scan on ms_mbr m (actual time=0.024..2.818 rows=3146 loops=1)
Filter: ((mbr_gd)::text = 'GOLD'::text)
Rows Removed by Filter: 6853
Buffers: shared hit=132
-> Index Scan using tr_ord_fk01 on tr_ord o (actual time=0.007..0.097 rows=86 loops=3146)
Index Cond: ((mbr_id)::text = (m.mbr_id)::text)
Filter: ((ord_st)::text = 'PKUP'::text)
Rows Removed by Filter: 0
Buffers: shared hit=248039
Planning:
Buffers: shared hit=330
Planning Time: 1.571 ms
Execution Time: 375.619 ms
Plain Text
복사
실행계획 읽기: BAD는 WHERE가 오른쪽 테이블의 NULL을 거부하므로 옵티마이저가 Nested Loop로 처리했고, GOOD은 미매칭 회원을 남겨야 하므로 Nested Loop Left Join이 유지되었다. 두 계획 모두 주문 인덱스 tr_ord_fk01(mbr_id)를 loops=3146번 접근했다. 따라서 이 실습은 속도 향상이 아니라 결과의 의미 차이를 검증하는 사례다. 실행시간은 캐시·부하에 따라 변동한다.
핵심
•
LEFT JOIN에서 오른쪽 테이블 조건을 WHERE에 두면 매칭되지 않은 왼쪽 행이 사라질 수 있다. 왼쪽 회원을 모두 보존하고 특정 주문만 붙이려면 주문 상태 조건을 ON에 둔다.
참고·예외
•
오른쪽 조건을 무조건 ON으로 옮기는 것은 규칙이 아니다. 실제 업무 요구가 '픽업 완료 주문이 있는 GOLD 회원만 조회'라면, JOIN(INNER JOIN)을 명시하는 편이 의도에 맞는다. 또한 ON과 WHERE의 조건 배치는 단순한 성능 튜닝이 아니라 조회 결과 자체를 바꾸는 행위다.


