상황: 회원별 2023년 3월 주문금액과 이벤트 응모 건수를 동시에 조회한다. 회원을 중심으로 주문(M):회원(1):이벤트 응모(M)의 M:1:M 관계가 성립한다. BIG 계열이 아닌 실제 기본 테이블 startdbpg.ms_mbr, startdbpg.tr_ord, startdbpg.tr_event_entry를 사용한다. 검증 대상 회원은 실제 데이터가 존재하는 M0190이다.
BAD SQL — 두 상세 테이블을 직접 JOIN하고 집계
SELECT m.mbr_id
,SUM(o.ord_amt) AS ord_amt
,COUNT(e.entry_no) AS entry_cnt
FROM startdbpg.ms_mbr m
LEFT JOIN startdbpg.tr_ord o
ON o.mbr_id = m.mbr_id
AND o.ord_dtm >= TIMESTAMP '2023-03-01'
AND o.ord_dtm < TIMESTAMP '2023-04-01'
LEFT JOIN startdbpg.tr_event_entry e
ON e.mbr_id = m.mbr_id
AND e.entry_dtm >= TIMESTAMP '2023-03-01'
AND e.entry_dtm < TIMESTAMP '2023-04-01'
WHERE m.mbr_id = 'M0190'
GROUP BY m.mbr_id;
SQL
복사
GOOD SQL — 주문·이벤트 응모를 각각 집계한 후 JOIN
SELECT m.mbr_id
,COALESCE(o.ord_amt, 0) AS ord_amt
,COALESCE(e.entry_cnt, 0) AS entry_cnt
FROM startdbpg.ms_mbr m
LEFT JOIN (
SELECT mbr_id, SUM(ord_amt) AS ord_amt
FROM startdbpg.tr_ord
WHERE mbr_id = 'M0190'
AND ord_dtm >= TIMESTAMP '2023-03-01'
AND ord_dtm < TIMESTAMP '2023-04-01'
GROUP BY mbr_id
) o ON o.mbr_id = m.mbr_id
LEFT JOIN (
SELECT mbr_id, COUNT(*) AS entry_cnt
FROM startdbpg.tr_event_entry
WHERE mbr_id = 'M0190'
AND entry_dtm >= TIMESTAMP '2023-03-01'
AND entry_dtm < TIMESTAMP '2023-04-01'
GROUP BY mbr_id
) e ON e.mbr_id = m.mbr_id
WHERE m.mbr_id = 'M0190';
SQL
복사
실제 데이터 검증
-- 해당 회원의 주문·이벤트 응모 건수를 독립적으로 확인
SELECT 'ORDER' AS data_type, COUNT(*) AS cnt,
SUM(ord_amt) AS ord_amt
FROM startdbpg.tr_ord
WHERE mbr_id = 'M0190'
AND ord_dtm >= TIMESTAMP '2023-03-01'
AND ord_dtm < TIMESTAMP '2023-04-01'
UNION ALL
SELECT 'EVENT', COUNT(*), NULL::numeric
FROM startdbpg.tr_event_entry
WHERE mbr_id = 'M0190'
AND entry_dtm >= TIMESTAMP '2023-03-01'
AND entry_dtm < TIMESTAMP '2023-04-01';
SQL
복사
실측 결과: 주문 4건 / 총 39,500원, 이벤트 응모 2건. 두 상세 테이블을 직접 JOIN하면 4 × 2 = 8개 조합이 만들어진다.
항목 | BAD: 직접 JOIN 후 집계 | GOOD: 각각 선집계 후 JOIN |
집계 이전 JOIN 행 수 | 8행 | 1행 |
ord_amt 합계 | 79,000원 (2배 오류) | 39,500원 (정확) |
entry_cnt | 8건 (4배 오류) | 2건 (정확) |
최종 결과 행 수 | 1행 | 1행 |
실제 실행계획 확인: EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)로 두 SQL을 실행했다.
•
BAD: Nested Loop Left Join에서 rows=4 다음 rows=8로 증가했고, 이벤트 2건을 담은 Materialize가 loops=4로 반복되었다. 집계 노드는 GroupAggregate.
•
GOOD: 주문·이벤트 각각 GroupAggregate로 회원별 1행으로 집계하고, 그 결과를 Nested Loop Left Join했다.
•
재측정 실행시간은 BAD 1.806ms, GOOD 1.653ms였으며 양쪽 모두 Buffers: shared hit=254였다. 이번 실습은 속도 차이가 아니라 집계 오류를 보여주는 사례다. 실행시간은 매번 달라질 수 있다.
핵심
•
M:1:M 조인에서 독립된 두 상세 테이블을 동시에 펼치면 각 행이 곱해져 SUM·COUNT가 증폭된다. 서로 다른 상세 테이블의 측정값을 회원 단위로 계산해야 한다면 각각 먼저 GROUP BY 한 뒤 1:1 결과를 JOIN하자.
참고·예외
•
기간 조건은 LEFT JOIN의 의미를 보존하기 위해 각 테이블의 ON 절에 넣었다. 응모가 한 건도 없더라도 회원을 유지하려는 경우 WHERE e.entry_dtm ...처럼 오른쪽 테이블을 직접 필터링하면 결과가 바뀔 수 있다. 주문과 응모 간 구체적인 업무 연결 관계가 존재하는 경우에는 회원 ID만으로 두 집합을 결합할 수 있는지 먼저 확인해야 한다. 실제 데이터 분포·인덱스에 따라 실행계획과 성능은 달라진다.


