Data & Analytics

JOIN 하나 잘못 쓰면 매출이 2배로 잡힌다 — 중복 없는 매출 집계의 모든 것

1:N JOIN에서 Row Explosion이 발생하는 원인과 정확한 매출 집계 방법

JOIN 하나 잘못 쓰면 매출이 2배로 잡힌다 — 중복 없는 매출 집계의 모든 것

JOIN 하나 잘못 쓰면 매출이 2배로 잡힌다 — 중복 없는 매출 집계의 모든 것

피벗 테이블 30분 vs SQL 30초, 근데 왜 숫자가 안 맞지?

"어, 이상하다. 왜 매출이 이렇게 높게 나오지?"

발표 직전에 재무팀 숫자와 2배 차이나는 걸 발견한 적 있으신가요? JOIN 결과가 1:N 관계에서 뻥튀기되면서 생기는 문제입니다 — 거의 모든 분석가가 한 번쯤 겪는 실수입니다.

이 글에서는 쿼리 패턴만 보여주는 게 아니라, 왜 매출이 뻥튀기되는지 원인을 진단하는 법중복 없이 정확하게 집계하는 기법을 깊이 있게 다룹니다.

1. 문제 상황: 내 쿼리 결과가 왜 2배인가?

주문 데이터로 월별 매출을 구하려고 합니다:

sql
SELECT
    strftime('%Y-%m', o.order_date) as month,
    SUM(o.total_amount) as revenue
FROM orders o
JOIN order_items oi ON o.order_id = oi.order_id
GROUP BY strftime('%Y-%m', o.order_date);

결과:

monthrevenue
2024-01156,234,000
2024-02178,456,000

재무팀 리포트:

monthrevenue
2024-0152,078,000
2024-0259,485,333

정확히 3배 차이. 왜 이런 일이 생겼을까요?

2. 원인: 1:N JOIN의 Row Explosion

데이터 구조 이해하기

text
orders 테이블 (부모)
┌──────────┬─────────────┐
│ order_id │ total_amount│
├──────────┼─────────────┤
│ 100      │ 50,000      │
└──────────┴─────────────┘

order_items 테이블 (자식) - 주문당 여러 상품
┌──────────┬────────────┬───────────┐
│ order_id │ product_id │ unit_price│
├──────────┼────────────┼───────────┤
│ 100      │ A          │ 20,000    │
│ 100      │ B          │ 15,000    │
│ 100      │ C          │ 15,000    │
└──────────┴────────────┴───────────┘

JOIN 결과 — 여기서 문제 발생

sql
SELECT o.order_id, o.total_amount, oi.product_id
FROM orders o
JOIN order_items oi ON o.order_id = oi.order_id
WHERE o.order_id = 100;

결과:

order_idtotal_amountproduct_id
100**50,000**A
100**50,000**B
100**50,000**C

`total_amount` 50,000원이 3번 나타납니다!

sql
SELECT SUM(total_amount) FROM ... -- 150,000원 (3배)

이것이 Row Explosion입니다. 부모 테이블의 값이 자식 테이블 행 수만큼 복제됩니다.

3. 해결책 1: JOIN을 안 하면 된다

가장 확실한 방법 — 애초에 orders 테이블만 쓰면 됩니다:

sql
-- ✅ 정확한 매출: orders만 사용
SELECT
    strftime('%Y-%m', order_date) as month,
    COUNT(*) as order_count,
    SUM(total_amount) as revenue
FROM orders
WHERE status = 'completed'
GROUP BY strftime('%Y-%m', order_date);

결과:

monthorder_countrevenue
2024-011,23452,078,000
2024-021,45659,485,333

원칙: 매출 같은 부모 테이블의 값을 집계할 때는 자식 테이블과 JOIN하지 마세요.

4. 해결책 2: 각 테이블을 따로 집계한 후 JOIN

상품 정보도 필요하다면? 각 테이블을 먼저 집계하고 나중에 합칩니다:

sql
WITH order_summary AS (
    -- orders 레벨 집계 (Row Explosion 없음)
    SELECT
        strftime('%Y-%m', order_date) as month,
        COUNT(*) as order_count,
        COUNT(DISTINCT customer_id) as customer_count,
        SUM(total_amount) as revenue
    FROM orders
    WHERE status = 'completed'
    GROUP BY strftime('%Y-%m', order_date)
),
item_summary AS (
    -- order_items 레벨 집계
    SELECT
        strftime('%Y-%m', o.order_date) as month,
        SUM(oi.quantity) as units_sold,
        COUNT(DISTINCT oi.product_id) as unique_products
    FROM order_items oi
    JOIN orders o ON oi.order_id = o.order_id
    WHERE o.status = 'completed'
    GROUP BY strftime('%Y-%m', o.order_date)
)
SELECT
    os.month,
    os.order_count,
    os.customer_count,
    os.revenue,
    ROUND(os.revenue / os.order_count, 0) as aov,  -- 평균 주문액
    its.units_sold,
    its.unique_products
FROM order_summary os
JOIN item_summary its ON os.month = its.month
ORDER BY os.month;

결과:

monthorderscustomersrevenueaovunitsproducts
2024-011,23498752,078,00042,2023,567234
2024-021,4561,12359,485,33340,8554,123256

5. 매출 집계에서 자주 빠뜨리는 것들

함정 1: 취소/환불 주문 포함

sql
-- ❌ 모든 주문 포함 (취소도!)
SELECT SUM(total_amount) as revenue FROM orders;

-- ✅ 완료된 주문만
SELECT SUM(total_amount) as revenue
FROM orders
WHERE status = 'completed';

-- ✅ 더 정확하게: 환불 차감
SELECT
    SUM(CASE WHEN status = 'completed' THEN total_amount ELSE 0 END) as gross_revenue,
    SUM(CASE WHEN status = 'refunded' THEN total_amount ELSE 0 END) as refunds,
    SUM(CASE WHEN status = 'completed' THEN total_amount ELSE 0 END) -
    SUM(CASE WHEN status = 'refunded' THEN total_amount ELSE 0 END) as net_revenue
FROM orders;

결과:

gross_revenuerefundsnet_revenue
52,078,0002,345,00049,733,000

함정 2: order_items에서 할인 누락

sql
-- ❌ 할인 무시
SELECT SUM(quantity * unit_price) as revenue FROM order_items;

-- ✅ 할인 포함
SELECT
    SUM(quantity * unit_price) as gross_revenue,
    SUM(discount_amount) as total_discount,
    SUM(quantity * unit_price - discount_amount) as net_revenue
FROM order_items;

함정 3: 타임존 문제

sql
-- ❌ UTC 시간 그대로 (한국과 9시간 차이)
SELECT
    strftime('%Y-%m-%d', order_date) as date,
    SUM(total_amount) as revenue
FROM orders
GROUP BY strftime('%Y-%m-%d', order_date);

-- ✅ 한국 시간으로 변환
SELECT
    strftime('%Y-%m-%d', datetime(order_date, '+9 hours')) as date_kst,
    SUM(total_amount) as revenue
FROM orders
GROUP BY strftime('%Y-%m-%d', datetime(order_date, '+9 hours'));

자정 근처 주문이 다른 날로 잡히면 일별 매출이 틀어집니다.

6. 실전: 재구매율과 LTV 분석

재구매율 계산

sql
WITH customer_orders AS (
    SELECT
        customer_id,
        COUNT(*) as order_count,
        SUM(total_amount) as total_spent,
        MIN(order_date) as first_order,
        MAX(order_date) as last_order
    FROM orders
    WHERE status = 'completed'
    GROUP BY customer_id
)
SELECT
    COUNT(*) as total_customers,
    SUM(CASE WHEN order_count = 1 THEN 1 ELSE 0 END) as one_time_buyers,
    SUM(CASE WHEN order_count > 1 THEN 1 ELSE 0 END) as repeat_buyers,
    ROUND(100.0 * SUM(CASE WHEN order_count > 1 THEN 1 ELSE 0 END) / COUNT(*), 1) as repurchase_rate,
    ROUND(AVG(order_count), 2) as avg_orders_per_customer,
    ROUND(AVG(total_spent), 0) as avg_ltv
FROM customer_orders;

결과:

total_customersone_timerepeatrepurchase_rateavg_ordersavg_ltv
5,4323,2452,18740.3%2.34156,789

고객 세그먼트별 재구매율

sql
WITH customer_orders AS (
    SELECT
        c.segment,
        o.customer_id,
        COUNT(*) as order_count,
        SUM(o.total_amount) as total_spent
    FROM orders o
    JOIN customers c ON o.customer_id = c.customer_id
    WHERE o.status = 'completed'
    GROUP BY c.segment, o.customer_id
)
SELECT
    segment,
    COUNT(*) as customers,
    SUM(CASE WHEN order_count > 1 THEN 1 ELSE 0 END) as repeat_buyers,
    ROUND(100.0 * SUM(CASE WHEN order_count > 1 THEN 1 ELSE 0 END) / COUNT(*), 1) as repurchase_rate,
    ROUND(AVG(total_spent), 0) as avg_ltv
FROM customer_orders
GROUP BY segment
ORDER BY repurchase_rate DESC;

결과:

segmentcustomersrepeat_buyersrepurchase_rateavg_ltv
vip23419884.6%892,345
regular3,4561,56745.3%178,234
new1,74242224.2%67,890

7. 전월 대비 성장률 (MoM)

sql
WITH monthly_revenue AS (
    SELECT
        strftime('%Y-%m', order_date) as month,
        SUM(total_amount) as revenue,
        COUNT(*) as orders,
        COUNT(DISTINCT customer_id) as customers
    FROM orders
    WHERE status = 'completed'
    GROUP BY strftime('%Y-%m', order_date)
)
SELECT
    month,
    revenue,
    orders,
    LAG(revenue) OVER (ORDER BY month) as prev_month_revenue,
    ROUND((revenue - LAG(revenue) OVER (ORDER BY month)) * 100.0 /
        LAG(revenue) OVER (ORDER BY month), 1) as mom_growth
FROM monthly_revenue
ORDER BY month DESC
LIMIT 6;

결과:

monthrevenueordersprev_revenuemom_growth
2024-0667,234,0001,67859,485,333+13.0%
2024-0559,485,3331,45654,234,000+9.7%
2024-0454,234,0001,34552,078,000+4.1%
2024-0352,078,0001,23448,765,000+6.8%

8. 카테고리별 매출 분석 — Row Explosion 피하기

상품 카테고리별 매출을 구할 때도 주의해야 합니다:

sql
-- ❌ 잘못된 방법: orders.total_amount로 카테고리 매출 계산
-- 한 주문에 여러 카테고리가 있으면 total_amount가 중복 집계됨

-- ✅ 올바른 방법: order_items 레벨에서 계산
SELECT
    p.category,
    COUNT(DISTINCT o.order_id) as order_count,
    SUM(oi.quantity) as units_sold,
    SUM(oi.quantity * oi.unit_price - oi.discount_amount) as revenue,
    ROUND(SUM(oi.quantity * oi.unit_price - oi.discount_amount) * 100.0 /
          SUM(SUM(oi.quantity * oi.unit_price - oi.discount_amount)) OVER (), 1) as revenue_share
FROM order_items oi
JOIN orders o ON oi.order_id = o.order_id
JOIN products p ON oi.product_id = p.product_id
WHERE o.status = 'completed'
  AND o.order_date >= DATE('now', '-30 days')
GROUP BY p.category
ORDER BY revenue DESC;

결과:

categoryordersunitsrevenueshare
Electronics5671,23423,456,00034.9%
Clothing7892,34518,234,00027.1%
Home45698715,678,00023.3%
Food3451,5679,876,00014.7%

9. 코호트 분석: 첫 구매 월별 리텐션

sql
WITH customer_cohort AS (
    -- 고객별 첫 구매월 (코호트 정의)
    SELECT
        customer_id,
        strftime('%Y-%m', MIN(order_date)) as cohort_month
    FROM orders
    WHERE status = 'completed'
    GROUP BY customer_id
),
monthly_activity AS (
    -- 고객별 활동 월
    SELECT DISTINCT
        o.customer_id,
        cc.cohort_month,
        strftime('%Y-%m', o.order_date) as activity_month
    FROM orders o
    JOIN customer_cohort cc ON o.customer_id = cc.customer_id
    WHERE o.status = 'completed'
)
SELECT
    cohort_month,
    COUNT(DISTINCT CASE WHEN activity_month = cohort_month THEN customer_id END) as M0,
    COUNT(DISTINCT CASE WHEN activity_month =
        strftime('%Y-%m', DATE(cohort_month || '-01', '+1 month')) THEN customer_id END) as M1,
    COUNT(DISTINCT CASE WHEN activity_month =
        strftime('%Y-%m', DATE(cohort_month || '-01', '+2 month')) THEN customer_id END) as M2,
    COUNT(DISTINCT CASE WHEN activity_month =
        strftime('%Y-%m', DATE(cohort_month || '-01', '+3 month')) THEN customer_id END) as M3
FROM monthly_activity
WHERE cohort_month >= strftime('%Y-%m', DATE('now', '-6 months'))
GROUP BY cohort_month
ORDER BY cohort_month;

결과:

cohortM0M1M2M3
2024-01234896752
2024-0226710278-
2024-03298115--

M1 리텐션 = 89/234 = 38.0%

10. 매출 리포트 체크리스트

쿼리 작성 전 확인

  • [ ] 매출 정의 확인 (Gross vs Net, 세금/배송비 포함 여부)
  • [ ] 주문 상태 필터 (completed, shipped, paid 중 어떤 것?)
  • [ ] 날짜 범위와 타임존 (UTC vs Local)
  • [ ] 1:N 관계 확인 (Row Explosion 위험)
  • [ ] 환불/취소 처리 방법

쿼리 실행 후 확인

  • [ ] 결과 행 수가 예상과 맞는가?
  • [ ] 총 매출이 재무팀 데이터와 일치하는가?
  • [ ] 0과 NULL이 예상대로 처리되었는가?
  • [ ] 이상치가 결과를 왜곡하고 있지 않은가?

핵심 정리: 정확한 매출 집계 3원칙

  1. Row Explosion을 경계하라: 1:N JOIN 후 부모 테이블 값을 집계하면 뻥튀기된다
  2. 매출 정의를 명확히 하라: Gross vs Net, 상태 필터, 세금/배송비 포함 여부를 팀과 합의
  3. 검증을 습관화하라: 쿼리 결과를 재무팀 데이터나 다른 소스와 교차 검증

이 세 가지 원칙을 지키면 "이 숫자 맞아요?"라는 질문에 자신 있게 답할 수 있습니다.

다음 글에서는 Window 함수로 전월 대비, 누적 합계, 순위 같은 복잡한 분석을 깔끔하게 처리하는 방법을 다룹니다.