Data & Analytics•
JOIN 하나 잘못 쓰면 매출이 2배로 잡힌다 — 중복 없는 매출 집계의 모든 것
1:N JOIN에서 Row Explosion이 발생하는 원인과 정확한 매출 집계 방법

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);결과:
| month | revenue |
|---|---|
| 2024-01 | 156,234,000 |
| 2024-02 | 178,456,000 |
재무팀 리포트:
| month | revenue |
|---|---|
| 2024-01 | 52,078,000 |
| 2024-02 | 59,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_id | total_amount | product_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);결과:
| month | order_count | revenue |
|---|---|---|
| 2024-01 | 1,234 | 52,078,000 |
| 2024-02 | 1,456 | 59,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;결과:
| month | orders | customers | revenue | aov | units | products |
|---|---|---|---|---|---|---|
| 2024-01 | 1,234 | 987 | 52,078,000 | 42,202 | 3,567 | 234 |
| 2024-02 | 1,456 | 1,123 | 59,485,333 | 40,855 | 4,123 | 256 |
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_revenue | refunds | net_revenue |
|---|---|---|
| 52,078,000 | 2,345,000 | 49,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_customers | one_time | repeat | repurchase_rate | avg_orders | avg_ltv |
|---|---|---|---|---|---|
| 5,432 | 3,245 | 2,187 | 40.3% | 2.34 | 156,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;결과:
| segment | customers | repeat_buyers | repurchase_rate | avg_ltv |
|---|---|---|---|---|
| vip | 234 | 198 | 84.6% | 892,345 |
| regular | 3,456 | 1,567 | 45.3% | 178,234 |
| new | 1,742 | 422 | 24.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;결과:
| month | revenue | orders | prev_revenue | mom_growth |
|---|---|---|---|---|
| 2024-06 | 67,234,000 | 1,678 | 59,485,333 | +13.0% |
| 2024-05 | 59,485,333 | 1,456 | 54,234,000 | +9.7% |
| 2024-04 | 54,234,000 | 1,345 | 52,078,000 | +4.1% |
| 2024-03 | 52,078,000 | 1,234 | 48,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;결과:
| category | orders | units | revenue | share |
|---|---|---|---|---|
| Electronics | 567 | 1,234 | 23,456,000 | 34.9% |
| Clothing | 789 | 2,345 | 18,234,000 | 27.1% |
| Home | 456 | 987 | 15,678,000 | 23.3% |
| Food | 345 | 1,567 | 9,876,000 | 14.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;결과:
| cohort | M0 | M1 | M2 | M3 |
|---|---|---|---|---|
| 2024-01 | 234 | 89 | 67 | 52 |
| 2024-02 | 267 | 102 | 78 | - |
| 2024-03 | 298 | 115 | - | - |
M1 리텐션 = 89/234 = 38.0%
10. 매출 리포트 체크리스트
쿼리 작성 전 확인
- [ ] 매출 정의 확인 (Gross vs Net, 세금/배송비 포함 여부)
- [ ] 주문 상태 필터 (completed, shipped, paid 중 어떤 것?)
- [ ] 날짜 범위와 타임존 (UTC vs Local)
- [ ] 1:N 관계 확인 (Row Explosion 위험)
- [ ] 환불/취소 처리 방법
쿼리 실행 후 확인
- [ ] 결과 행 수가 예상과 맞는가?
- [ ] 총 매출이 재무팀 데이터와 일치하는가?
- [ ] 0과 NULL이 예상대로 처리되었는가?
- [ ] 이상치가 결과를 왜곡하고 있지 않은가?
핵심 정리: 정확한 매출 집계 3원칙
- Row Explosion을 경계하라: 1:N JOIN 후 부모 테이블 값을 집계하면 뻥튀기된다
- 매출 정의를 명확히 하라: Gross vs Net, 상태 필터, 세금/배송비 포함 여부를 팀과 합의
- 검증을 습관화하라: 쿼리 결과를 재무팀 데이터나 다른 소스와 교차 검증
이 세 가지 원칙을 지키면 "이 숫자 맞아요?"라는 질문에 자신 있게 답할 수 있습니다.
다음 글에서는 Window 함수로 전월 대비, 누적 합계, 순위 같은 복잡한 분석을 깔끔하게 처리하는 방법을 다룹니다.