Fullmoon System

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' 같은 필터는 실제 원본 집계 단계에 넣어야 하고, 주문 생성일 기준 보고서인지 결제 발생일 기준 보고서인지도 정해야 합니다. 둘은 월말 수치가 달라질 수 있습니다.

운영 쿼리를 고칠 때 확인할 네 가지 표본

  1. 상품 2건·결제 2건처럼 양쪽에 여러 행이 있는 주문을 고릅니다.
  2. 같은 금액의 상품이나 결제가 반복되는 주문을 포함합니다. DISTINCT 오용을 찾을 수 있습니다.
  3. 결제가 없는 주문과 환불된 주문을 확인합니다. LEFT JOIN 뒤 WHERE 조건으로 미결제 주문이 사라지지 않는지 봅니다.
  4. 수정 전후 총액만 비교하지 말고 주문별 차이 목록을 뽑아 원본 내역과 대조합니다.

조인 키가 order_id 하나로 충분한지도 확인하세요. 테넌트별로 주문 번호가 중복된다면 tenant_id가 함께 필요할 수 있습니다. 먼저 “최종 결과 한 행이 무엇을 뜻하는가”를 적고, 연결하는 각 테이블이 그 키당 몇 행인지 세면 합계가 부풀어 오르는 지점을 찾기 쉬워집니다.