상황: 2023년 12월 주문의 주문번호, 주문일시, 매장명, 주문금액을 조회한다. tr_ord는 주문 1건당 1행이지만, tr_ord_det는 주문 하나에 상세가 여러 건인 1:N 관계다. 상세 컬럼이나 상세 조건이 필요하지 않은데 상세 테이블을 JOIN하면 행이 늘어나고, 이를 DISTINCT로 다시 줄이는 불필요한 작업이 발생한다.
실습 테이블: startdbpg.tr_ord(주문), startdbpg.ms_shop(매장), startdbpg.tr_ord_det(주문상세). 실제 일반 테이블에서 실행해 검증했다.
BAD SQL — 불필요한 1:N JOIN으로 만든 중복을 DISTINCT로 제거
SELECT DISTINCT
o.ord_no,
o.ord_dtm,
s.shop_nm,
o.ord_amt
FROM startdbpg.tr_ord o
JOIN startdbpg.ms_shop s
ON s.shop_id = o.shop_id
LEFT JOIN startdbpg.tr_ord_det d
ON d.ord_no = o.ord_no
WHERE o.ord_dtm >= TIMESTAMP '2023-12-01'
AND o.ord_dtm < TIMESTAMP '2024-01-01';
SQL
복사
주문상세의 컬럼을 조회하거나 조건으로 사용하지 않는다. 그러나 상세 테이블과 JOIN하면서 주문 20,359건이 40,864행으로 증가하고, 이 중복을 DISTINCT의 HashAggregate로 다시 20,359행으로 줄인다.
GOOD SQL — 사용하지 않는 JOIN과 DISTINCT를 함께 제거
SELECT o.ord_no,
o.ord_dtm,
s.shop_nm,
o.ord_amt
FROM startdbpg.tr_ord o
JOIN startdbpg.ms_shop s
ON s.shop_id = o.shop_id
WHERE o.ord_dtm >= TIMESTAMP '2023-12-01'
AND o.ord_dtm < TIMESTAMP '2024-01-01';
SQL
복사
매장명 shop_nm을 얻기 위한 ms_shop JOIN만 유지한다. tr_ord.ord_no는 PK이고, ms_shop.shop_id도 PK이므로 이 JOIN은 주문 한 행을 여러 행으로 늘리지 않는다. 따라서 DISTINCT가 필요 없다.
실제 데이터·결과 검증
•
2023년 12월 주문: 20,359건
•
주문상세 JOIN 후: 40,864행
•
상세 2건 이상인 주문: 14,697건
•
BAD 결과 / GOOD 결과: 모두 20,359행
•
EXCEPT ALL 양방향 비교: 차이 0건
실제 실행계획 비교 (EXPLAIN (ANALYZE, BUFFERS, COSTS OFF))
비교 항목 | BAD | GOOD |
최종 반환 행 수 | 20,359 | 20,359 |
중간 JOIN 행 수 | 40,864 | 20,359 |
실행시간(각 1회) | 490.411ms | 16.710ms |
Buffers: shared hit | 61,291 (추가 read=96) | 309 |
주요 연산 | Nested Loop Left Join → HashAggregate | Hash Join |
BAD SQL — 실제 실행계획
HashAggregate (actual time=479.942..485.734 rows=20359 loops=1)
Group Key: o.ord_no, o.ord_dtm, s.shop_nm, o.ord_amt
Batches: 1 Memory Usage: 2321kB
Buffers: shared hit=61291 read=96
-> Nested Loop Left Join (actual time=6.443..445.767 rows=40864 loops=1)
Buffers: shared hit=61291 read=96
-> Hash Join (actual time=2.397..271.250 rows=20359 loops=1)
Hash Cond: ((o.shop_id)::text = (s.shop_id)::text)
Buffers: shared hit=309
-> Index Scan using tr_ord_x51 on tr_ord o (actual time=0.797..256.183 rows=20359 loops=1)
Index Cond: ((ord_dtm >= '2023-12-01 00:00:00'::timestamp without time zone) AND (ord_dtm < '2024-01-01 00:00:00'::timestamp without time zone))
Buffers: shared hit=305
-> Hash (actual time=1.568..1.569 rows=300 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 24kB
Buffers: shared hit=4
-> Seq Scan on ms_shop s (actual time=0.058..0.814 rows=300 loops=1)
Buffers: shared hit=4
-> Index Only Scan using tr_ord_det_fk01 on tr_ord_det d (actual time=0.007..0.008 rows=2 loops=20359)
Index Cond: (ord_no = o.ord_no)
Heap Fetches: 0
Buffers: shared hit=60982 read=96
Planning:
Buffers: shared hit=387
Planning Time: 56.707 ms
Execution Time: 490.411 ms
Plain Text
복사
GOOD SQL — 실제 실행계획
Hash Join (actual time=0.231..15.261 rows=20359 loops=1)
Hash Cond: ((o.shop_id)::text = (s.shop_id)::text)
Buffers: shared hit=309
-> Index Scan using tr_ord_x51 on tr_ord o (actual time=0.044..5.737 rows=20359 loops=1)
Index Cond: ((ord_dtm >= '2023-12-01 00:00:00'::timestamp without time zone) AND (ord_dtm < '2024-01-01 00:00:00'::timestamp without time zone))
Buffers: shared hit=305
-> Hash (actual time=0.158..0.160 rows=300 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 24kB
Buffers: shared hit=4
-> Seq Scan on ms_shop s (actual time=0.017..0.073 rows=300 loops=1)
Buffers: shared hit=4
Planning:
Buffers: shared hit=326
Planning Time: 1.589 ms
Execution Time: 16.710 ms
Plain Text
복사
실행계획 읽기: BAD에서는 tr_ord_det_fk01에 대한 Index Only Scan이 20,359회(loops) 수행되고, 결과가 40,864행으로 증가한다. 이후 HashAggregate가 중복을 제거한다. GOOD에서는 불필요한 주문상세 탐색과 HashAggregate가 모두 사라진다.
핵심
•
DISTINCT를 제거할 수 있는지 판단하기 전에, 중복을 발생시키는 JOIN이 꼭 필요한지 먼저 확인하자. 조회나 필터에 사용하지 않는 1:N JOIN을 제거하면, 결과의 중복과 그 중복을 제거하는 비용을 함께 없앨 수 있다.
참고·예외
•
주문상세의 컬럼을 출력하거나 주문상세 조건으로 주문을 선택해야 하는 경우에는 해당 JOIN을 단순 삭제하면 안 된다. 상세 존재 여부만 확인하는 목적이라면 EXISTS도 비교할 수 있다(SECTION 05). 여러 결과 행 자체가 업무적으로 필요하거나 중복 제거가 요구사항이라면 DISTINCT가 필요할 수 있다. 위 성능 수치는 각각 1회 실측 결과이므로 시간 차이의 절대적 배수는 일반화하지 않는다.


