Search

REMINDER 05-02. M:1:M 조인

상황: 회원별 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만으로 두 집합을 결합할 수 있는지 먼저 확인해야 한다. 실제 데이터 분포·인덱스에 따라 실행계획과 성능은 달라진다.