Search

REMINDER 05-05. 불필요한 JOIN은 제거하되 데이터 무결성을 먼저 확인하자

상황: 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을 자동 제거하는 것은 아니다. 본 사례의 실행시간은 단일 측정 결과이므로 성능 차이는 반복 실행 및 환경에 따라 달라진다.