상황: 2023년 3월의 주문 건수만 집계하면 되는데, 결과에 사용하지 않는 회원 테이블까지 INNER JOIN한다. 실제 일반 테이블 startdbpg.tr_ord와 startdbpg.ms_mbr을 대상으로 실행하여 결과와 실행계획을 비교했다.
BAD SQL — 주문 건수에 불필요한 회원 테이블 JOIN
SELECT COUNT(*) AS ord_cnt
FROM startdbpg.tr_ord o
JOIN startdbpg.ms_mbr m
ON m.mbr_id = o.mbr_id
WHERE o.ord_dtm >= TIMESTAMP '2023-03-01'
AND o.ord_dtm < TIMESTAMP '2023-04-01';
SQL
복사
GOOD SQL — 회원 테이블을 제거하고 NULL 의미를 보존
SELECT COUNT(*) AS ord_cnt
FROM startdbpg.tr_ord o
WHERE o.ord_dtm >= TIMESTAMP '2023-03-01'
AND o.ord_dtm < TIMESTAMP '2023-04-01'
AND o.mbr_id IS NOT NULL;
SQL
복사
왜 o.mbr_id IS NOT NULL을 추가하는가? 실제 tr_ord.mbr_id는 NULL 허용이다. 원래의 INNER JOIN은 회원 ID가 NULL인 주문을 제외한다. 따라서 단순히 JOIN만 삭제하면 NULL 주문까지 집계되어 향후 결과가 달라질 수 있다. IS NOT NULL을 남겨 원래 의미를 보존한다.
실제 무결성 검증
•
tr_ord_fk01: FOREIGN KEY (mbr_id) REFERENCES startdbpg.ms_mbr(mbr_id)이며 convalidated=true로 확인했다.
•
tr_ord.mbr_id의 is_nullable=YES. 따라서 NOT NULL은 보장되지 않는다.
•
2023년 3월 전체 주문 17,582건, 해당 기간 회원 ID NULL 0건.
•
해당 기간 유효한 회원 ID인데 회원 마스터에 존재하지 않는 주문 0건(NOT EXISTS 확인).
•
실제 BAD와 GOOD 모두 ord_cnt = 17582 반환.
검증 SQL (BAD·GOOD 결과 비교)
SELECT 'BAD' AS sql_type, COUNT(*) AS ord_cnt
FROM startdbpg.tr_ord o
JOIN startdbpg.ms_mbr m
ON m.mbr_id = o.mbr_id
WHERE o.ord_dtm >= TIMESTAMP '2023-03-01'
AND o.ord_dtm < TIMESTAMP '2023-04-01'
UNION ALL
SELECT 'GOOD' AS sql_type, COUNT(*) AS ord_cnt
FROM startdbpg.tr_ord o
WHERE o.ord_dtm >= TIMESTAMP '2023-03-01'
AND o.ord_dtm < TIMESTAMP '2023-04-01'
AND o.mbr_id IS NOT NULL;
SQL
복사
실측 결과
항목 | BAD | GOOD |
주문 건수 | 17,582 | 17,582 |
접근·JOIN | tr_ord • ms_mbr • Hash Join | tr_ord만 접근, JOIN 없음 |
Buffers: shared hit | 292 | 262 |
실행시간 (1회 측정) | 19.336ms | 7.506ms |
BAD SQL — 실제 실행계획
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT COUNT(*) AS ord_cnt
FROM startdbpg.tr_ord o
JOIN startdbpg.ms_mbr m
ON m.mbr_id = o.mbr_id
WHERE o.ord_dtm >= TIMESTAMP '2023-03-01'
AND o.ord_dtm < TIMESTAMP '2023-04-01';
SQL
복사
Aggregate (actual time=19.125..19.127 rows=1 loops=1)
Buffers: shared hit=292
-> Hash Join (actual time=4.299..17.395 rows=17582 loops=1)
Hash Cond: ((o.mbr_id)::text = (m.mbr_id)::text)
Buffers: shared hit=292
-> Index Scan using tr_ord_x51 on tr_ord o (actual time=0.038..4.946 rows=17582 loops=1)
Index Cond: ((ord_dtm >= '2023-03-01 00:00:00'::timestamp without time zone) AND (ord_dtm < '2023-04-01 00:00:00'::timestamp without time zone))
Buffers: shared hit=262
-> Hash (actual time=4.154..4.155 rows=9999 loops=1)
Buckets: 16384 Batches: 1 Memory Usage: 500kB
Buffers: shared hit=30
-> Index Only Scan using ms_mbr_pk on ms_mbr m (actual time=0.030..1.605 rows=9999 loops=1)
Heap Fetches: 0
Buffers: shared hit=30
Planning:
Buffers: shared hit=340
Planning Time: 1.652 ms
Execution Time: 19.336 ms
Plain Text
복사
GOOD SQL — 실제 실행계획
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT COUNT(*) AS ord_cnt
FROM startdbpg.tr_ord o
WHERE o.ord_dtm >= TIMESTAMP '2023-03-01'
AND o.ord_dtm < TIMESTAMP '2023-04-01'
AND o.mbr_id IS NOT NULL;
SQL
복사
Aggregate (actual time=7.346..7.348 rows=1 loops=1)
Buffers: shared hit=262
-> Index Scan using tr_ord_x51 on tr_ord o (actual time=0.041..5.620 rows=17582 loops=1)
Index Cond: ((ord_dtm >= '2023-03-01 00:00:00'::timestamp without time zone) AND (ord_dtm < '2023-04-01 00:00:00'::timestamp without time zone))
Filter: (mbr_id IS NOT NULL)
Buffers: shared hit=262
Planning:
Buffers: shared hit=145
Planning Time: 0.677 ms
Execution Time: 7.506 ms
Plain Text
복사
실행계획 읽기: BAD는 tr_ord_x51로 17,582건을 찾은 뒤, 회원 테이블 ms_mbr_pk를 9,999건 읽고 Hash Join을 수행했다. GOOD은 같은 주문 17,582건을 읽고 mbr_id IS NOT NULL만 검사하여 Aggregate로 집계했다. 회원 접근과 Hash Join이 제거되어 공유 버퍼 접근도 감소했다.
핵심
•
조회·필터·집계에 필요하지 않은 테이블이라면, 조인을 제거해도 결과가 보존되는지 PK·FK·NULL 조건부터 검증한 뒤 제거하자. 이 사례는 실제로 불필요한 Hash Join이 수행됨을 확인했다.
참고·예외
•
회원 마스터의 존재 여부 자체를 검증해야 하는 업무라면 JOIN을 임의로 제거하면 안 된다. 실제 FK가 없거나 검증되지 않았으면 IS NOT NULL 조건만으로 회원 존재를 보장할 수 없다. 또한 PostgreSQL이 모든 불필요한 JOIN을 자동 제거하는 것은 아니다. 본 사례의 실행시간은 단일 측정 결과이므로 성능 차이는 반복 실행 및 환경에 따라 달라진다.


