SQL JOIN 중복으로 SUM 합계가 늘어날 때: 재현과 해결
AI Editter
SQL에서 JOIN을 하나 추가했더니 매출 합계가 두 배가 되는 경우가 있습니다. 원본 테이블에 중복 데이터가 없어도 생깁니다. 주문 하나에 상품 여러 개와 결제 여러 건을 동시에 붙이면, 상품 행 수 × 결제 행 수만큼 조합이 만들어지고 그 결과를 SUM하기 때문입니다. 이 문제는 SELECT DISTINCT를 붙이는 것보다 집계 단위를 바로잡는 것이 중요합니다.
120원 주문이 240원으로 집계되는 최소 예제
아래는 실제 고객 데이터가 아닌 재현용 데이터입니다. 주문 1번에 60원짜리 상품 두 개, 70원과 50원의 분할 결제가 있습니다. 상품 합계와 결제 합계는 각각 120원이어야 합니다. 예제의 JOIN·집계 결과는 SQLite 메모리 DB에서 실행해 확인했습니다. PostgreSQL의 VALUES CTE에서도 같은 관계를 재현할 수 있습니다.
WITH
orders(order_id) AS (VALUES (1)),
items(item_id, order_id, amount) AS (
VALUES (11, 1, 60), (12, 1, 60)
),
payments(payment_id, order_id, amount) AS (
VALUES (21, 1, 70), (22, 1, 50)
)
SELECT o.order_id,
COUNT(*) AS joined_rows,
SUM(i.amount) AS item_total,
SUM(p.amount) AS paid_total
FROM orders o
LEFT JOIN items i ON i.order_id = o.order_id
LEFT JOIN payments p ON p.order_id = o.order_id
GROUP BY o.order_id;
결과는 joined_rows=4, item_total=240, paid_total=240입니다. 주문별로 GROUP BY했는데도 금액이 틀립니다. GROUP BY는 이미 늘어난 네 행을 모을 뿐, 각 금액이 어떤 원본 행에서 왔는지 알아서 복구하지 않습니다.
| 상품 ID | 상품 금액 | 결제 ID | 결제 금액 |
|---|---|---|---|
| 11 | 60 | 21 | 70 |
| 11 | 60 | 22 | 50 |
| 12 | 60 | 21 | 70 |
| 12 | 60 | 22 | 50 |
각 왼쪽 행에 조건을 만족하는 오른쪽 행이 연결된다는 JOIN의 동작은 PostgreSQL 테이블 표현식 문서에서 확인할 수 있습니다. 데이터 품질 문제가 아니라 관계의 다중성이 만드는 결과일 수 있다는 점이 핵심입니다.
해결: 각각 주문 한 행으로 집계한 다음 붙인다
위 CTE 데이터 뒤에 다음 집계 CTE를 이어 붙여 실행합니다. 상품과 결제에서 먼저 주문별 한 행을 만든 뒤 주문에 연결하므로 결과의 단위가 유지됩니다.
WITH
orders(order_id) AS (VALUES (1), (2)),
items(item_id, order_id, amount) AS (
VALUES (11, 1, 60), (12, 1, 60)
),
payments(payment_id, order_id, amount) AS (
VALUES (21, 1, 70), (22, 1, 50)
),
item_totals AS (
SELECT order_id, SUM(amount) AS item_total
FROM items GROUP BY order_id
),
payment_totals AS (
SELECT order_id, SUM(amount) AS paid_total
FROM payments GROUP BY order_id
)
SELECT o.order_id,
COALESCE(i.item_total, 0) AS item_total,
COALESCE(p.paid_total, 0) AS paid_total
FROM orders o
LEFT JOIN item_totals i ON i.order_id = o.order_id
LEFT JOIN payment_totals p ON p.order_id = o.order_id
ORDER BY o.order_id;
주문 1번은 120, 120, 상품·결제가 없는 주문 2번은 0, 0이 나옵니다. 여기서 COALESCE는 “관련 행 없음”을 0원으로 표현하려는 보고서 정책입니다. 값 누락을 0으로 바꾸면 안 되는 업무라면 NULL을 유지해야 합니다. 집계 함수의 NULL 처리도 확인하세요.
SUM(DISTINCT amount)가 우연히 맞는 이유
위 예제에 SUM(DISTINCT i.amount)를 적용하면 상품 두 개가 모두 60원이어서 결과가 60원이 됩니다. DISTINCT는 상품 ID가 아니라 금액 값의 중복을 제거합니다. 결제는 70과 50이 달라 우연히 120이 나오지만, 같은 금액의 분할 결제가 두 번 생기면 다시 틀립니다.
SELECT DISTINCT도 SELECT에 포함된 열 조합을 기준으로 동작합니다. 이미 SUM으로 잘못 집계한 결과 한 행에 DISTINCT를 붙여도 금액은 바뀌지 않습니다. COUNT(DISTINCT item_id)는 상품 개수를 셀 때 도움이 될 수 있으나 상품 금액 전체를 올바르게 합치는 일반적인 대체 방법은 아닙니다.
결제 여부만 필요한 경우에는 EXISTS가 더 분명하다
결제 금액이 필요 없고 “결제가 있는 주문”만 고르는 목적이라면 결제 테이블을 결과에 합치지 않아도 됩니다.
SELECT o.order_id
FROM orders o
WHERE EXISTS (
SELECT 1 FROM payments p
WHERE p.order_id = o.order_id
);
다만 실패한 결제, 취소, 환불을 어떻게 처리할지는 업무 정의가 필요합니다. status='success' 같은 필터는 실제 원본 집계 단계에 넣어야 하고, 주문 생성일 기준 보고서인지 결제 발생일 기준 보고서인지도 정해야 합니다. 둘은 월말 수치가 달라질 수 있습니다.
운영 쿼리를 고칠 때 확인할 네 가지 표본
- 상품 2건·결제 2건처럼 양쪽에 여러 행이 있는 주문을 고릅니다.
- 같은 금액의 상품이나 결제가 반복되는 주문을 포함합니다. DISTINCT 오용을 찾을 수 있습니다.
- 결제가 없는 주문과 환불된 주문을 확인합니다. LEFT JOIN 뒤 WHERE 조건으로 미결제 주문이 사라지지 않는지 봅니다.
- 수정 전후 총액만 비교하지 말고 주문별 차이 목록을 뽑아 원본 내역과 대조합니다.
조인 키가 order_id 하나로 충분한지도 확인하세요. 테넌트별로 주문 번호가 중복된다면 tenant_id가 함께 필요할 수 있습니다. 먼저 “최종 결과 한 행이 무엇을 뜻하는가”를 적고, 연결하는 각 테이블이 그 키당 몇 행인지 세면 합계가 부풀어 오르는 지점을 찾기 쉬워집니다.