콘텐츠로 이동

실무 SQL 패턴

사용 테이블

orders — 주문 (상태, 금액, 일시)

order_items — 주문 상세 (수량, 단가)

products — 상품 (이름, 가격, 재고, 브랜드)

categories — 카테고리 (부모-자식 계층)

reviews — 리뷰 (평점, 내용)

carts — 장바구니 (상태)

cart_items — 장바구니 상품 (수량)

coupons — 쿠폰 (할인율, 유효기간)

coupon_usage — 쿠폰 사용 내역

학습 범위

LAG, ROW_NUMBER, Cart Abandonment, Coupon ROI, Time Pattern

1. 뷰 분석: 매출 성장률 (LAG 패턴)

v_revenue_growth 뷰의 구조를 분석한 후, 이 뷰를 직접 재현하세요. 월별 매출과 전월 대비 성장률(%)을 계산합니다. LAG 윈도우 함수를 사용합니다.

힌트 1: - LAG(revenue, 1) OVER (ORDER BY year_month)로 전월 매출 참조 - 성장률 = (당월 - 전월) / 전월 * 100 - 첫 번째 월은 전월 데이터가 없으므로 NULL

정답
WITH monthly AS (
    SELECT
        SUBSTR(ordered_at, 1, 7) AS year_month,
        ROUND(SUM(total_amount), 2) AS revenue,
        COUNT(*) AS order_count
    FROM orders
    WHERE status NOT IN ('cancelled', 'returned', 'return_requested')
    GROUP BY SUBSTR(ordered_at, 1, 7)
)
SELECT
    year_month,
    revenue,
    order_count,
    LAG(revenue, 1) OVER (ORDER BY year_month) AS prev_month_revenue,
    ROUND(100.0 * (revenue - LAG(revenue, 1) OVER (ORDER BY year_month))
        / LAG(revenue, 1) OVER (ORDER BY year_month), 1) AS mom_growth_pct
FROM monthly
ORDER BY year_month;

실행 결과 (총 120행 중 상위 7행)

year_month revenue order_count prev_month_revenue mom_growth_pct
2016-01 14,194,769.00 34 NULL NULL
2016-02 12,984,335.00 23 14,194,769.00 -8.50
2016-03 14,154,562.00 29 12,984,335.00 9.00
2016-04 16,878,372.00 30 14,154,562.00 19.20
2016-05 28,570,768.00 37 16,878,372.00 69.30
2016-06 23,793,991.00 30 28,570,768.00 -16.70
2016-07 29,696,984.00 29 23,793,991.00 24.80

2. 카테고리별 상위 N개 상품 (ROW_NUMBER 패턴)

각 카테고리에서 매출 상위 3개 상품을 선발하세요.

힌트 1: - ROW_NUMBER() OVER (PARTITION BY category ORDER BY revenue DESC) - CTE에서 순위를 매긴 후, 외부 쿼리에서 WHERE rn <= 3 필터

정답
WITH product_sales AS (
    SELECT
        cat.name AS category,
        p.name AS product_name,
        ROUND(SUM(oi.quantity * oi.unit_price), 2) AS revenue,
        SUM(oi.quantity) AS units_sold
    FROM order_items AS oi
    INNER JOIN orders     AS o   ON oi.order_id   = o.id
    INNER JOIN products   AS p   ON oi.product_id = p.id
    INNER JOIN categories AS cat ON p.category_id = cat.id
    WHERE o.status NOT IN ('cancelled', 'returned', 'return_requested')
    GROUP BY cat.name, p.name
),
ranked AS (
    SELECT *,
        ROW_NUMBER() OVER (PARTITION BY category ORDER BY revenue DESC) AS rn
    FROM product_sales
)
SELECT category, product_name, revenue, units_sold, rn AS rank
FROM ranked
WHERE rn <= 3
ORDER BY category, rn;

실행 결과 (총 109행 중 상위 7행)

category product_name revenue units_sold rank
2in1 레노버 ThinkPad X1 2in1 실버 554,231,700.00 297 1
2in1 HP Envy x360 15 실버 326,727,400.00 269 2
2in1 HP Pavilion x360 14 블랙 319,615,200.00 216 3
AMD AMD Ryzen 9 9900X 601,913,300.00 1600 1
AMD MSI Radeon RX 7900 XTX GAMING X 화이트 585,793,600.00 386 2
AMD ASUS Dual RX 9070 실버 515,058,400.00 383 3
AMD 소켓 ASRock X670E Steel Legend 실버 370,658,400.00 704 1

3. 장바구니 이탈 분석

장바구니에 상품을 담았지만 주문하지 않은 "이탈 고객"의 규모와 패턴을 파악하세요. 이탈 장바구니의 총 수, 이탈 상품 금액, 가장 많이 이탈된 상품 TOP 10을 분석하세요.

힌트 1: - carts.status가 'abandoned'인 장바구니 - cart_items -> products로 상품 정보 포함 - 이탈 금액 = 수량 x 가격

정답
-- 이탈 장바구니 요약
SELECT
    COUNT(DISTINCT c.id)    AS abandoned_carts,
    COUNT(ci.id)            AS abandoned_items,
    ROUND(SUM(ci.quantity * p.price), 2) AS lost_revenue
FROM carts AS c
INNER JOIN cart_items AS ci ON c.id = ci.cart_id
INNER JOIN products  AS p  ON ci.product_id = p.id
WHERE c.status = 'abandoned';

실행 결과 (1행)

abandoned_carts abandoned_items lost_revenue
899 2747 2,618,976,000.00

4. 쿠폰 효과 분석

쿠폰을 사용한 주문과 사용하지 않은 주문의 평균 주문 금액을 비교하세요. 쿠폰별 사용 횟수, 할인 총액, ROI(쿠폰 매출 / 할인액)를 계산합니다.

힌트 1: - coupon_usage -> coupons로 쿠폰 정보 - 비쿠폰 주문: coupon_usage에 없는 주문 - ROI = 매출 / 할인액

정답
-- 쿠폰 사용 vs 미사용 주문 비교
WITH coupon_orders AS (
    SELECT DISTINCT order_id FROM coupon_usage
)
SELECT
    CASE WHEN co.order_id IS NOT NULL THEN '쿠폰 사용' ELSE '쿠폰 미사용' END AS segment,
    COUNT(*) AS order_count,
    ROUND(AVG(o.total_amount), 2) AS avg_order_value,
    ROUND(SUM(o.total_amount), 2) AS total_revenue
FROM orders AS o
LEFT JOIN coupon_orders AS co ON o.id = co.order_id
WHERE o.status NOT IN ('cancelled', 'returned', 'return_requested')
GROUP BY CASE WHEN co.order_id IS NOT NULL THEN '쿠폰 사용' ELSE '쿠폰 미사용' END;

실행 결과 (2행)

segment order_count avg_order_value total_revenue
쿠폰 미사용 33,034 981,325.10 32,417,093,466.00
쿠폰 사용 1664 1,413,662.58 2,352,334,541.00

5. 시간대별 주문 패턴

시간대(0~23시)별 주문 수, 평균 주문 금액, 주말/평일 비교를 보여주세요.

힌트 1: - 시간 추출: CAST(SUBSTR(ordered_at, 12, 2) AS INTEGER) - 요일: CAST(STRFTIME('%w', ordered_at) AS INTEGER) (0=일, 6=토) - 주말: 0(일)과 6(토)

정답
SELECT
    CAST(SUBSTR(ordered_at, 12, 2) AS INTEGER) AS hour,
    COUNT(*) AS order_count,
    ROUND(AVG(total_amount), 2) AS avg_order_value,
    SUM(CASE WHEN CAST(STRFTIME('%w', ordered_at) AS INTEGER) IN (0, 6)
        THEN 1 ELSE 0 END) AS weekend_orders,
    SUM(CASE WHEN CAST(STRFTIME('%w', ordered_at) AS INTEGER) NOT IN (0, 6)
        THEN 1 ELSE 0 END) AS weekday_orders,
    ROUND(1.0 * SUM(CASE WHEN CAST(STRFTIME('%w', ordered_at) AS INTEGER) IN (0, 6) THEN 1 ELSE 0 END)
        / NULLIF(SUM(CASE WHEN CAST(STRFTIME('%w', ordered_at) AS INTEGER) NOT IN (0, 6) THEN 1 ELSE 0 END), 0), 2)
        AS weekend_weekday_ratio
FROM orders
WHERE status NOT IN ('cancelled')
GROUP BY CAST(SUBSTR(ordered_at, 12, 2) AS INTEGER)
ORDER BY hour;

실행 결과 (총 24행 중 상위 7행)

hour order_count avg_order_value weekend_orders weekday_orders weekend_weekday_ratio
0 451 987,171.96 123 328 0.38
1 327 1,008,633.48 102 225 0.45
2 158 963,984.31 45 113 0.4
3 188 1,068,912.30 52 136 0.38
4 185 1,076,325.90 62 123 0.5
5 345 1,165,922.99 95 250 0.38
6 606 922,406.57 196 410 0.48

6. 보너스: 24시간 x 7요일 히트맵

시간대별 x 요일별 주문 수 히트맵 매트릭스를 만드세요.

힌트 1: - 24행(시간) x 7열(요일) + 합계 - SUM(CASE WHEN STRFTIME('%w', ...) = 'N' THEN 1 ELSE 0 END)

정답
SELECT
    CAST(SUBSTR(ordered_at, 12, 2) AS INTEGER) AS hour,
    SUM(CASE WHEN STRFTIME('%w', ordered_at) = '1' THEN 1 ELSE 0 END) AS mon,
    SUM(CASE WHEN STRFTIME('%w', ordered_at) = '2' THEN 1 ELSE 0 END) AS tue,
    SUM(CASE WHEN STRFTIME('%w', ordered_at) = '3' THEN 1 ELSE 0 END) AS wed,
    SUM(CASE WHEN STRFTIME('%w', ordered_at) = '4' THEN 1 ELSE 0 END) AS thu,
    SUM(CASE WHEN STRFTIME('%w', ordered_at) = '5' THEN 1 ELSE 0 END) AS fri,
    SUM(CASE WHEN STRFTIME('%w', ordered_at) = '6' THEN 1 ELSE 0 END) AS sat,
    SUM(CASE WHEN STRFTIME('%w', ordered_at) = '0' THEN 1 ELSE 0 END) AS sun,
    COUNT(*) AS total
FROM orders
WHERE status NOT IN ('cancelled')
GROUP BY CAST(SUBSTR(ordered_at, 12, 2) AS INTEGER)
ORDER BY hour;

실행 결과 (총 24행 중 상위 7행)

hour mon tue wed thu fri sat sun total
0 69 67 60 62 70 66 57 451
1 48 58 38 37 44 51 51 327
2 22 25 17 23 26 25 20 158
3 28 36 21 26 25 27 25 188
4 28 14 27 31 23 26 36 185
5 57 42 49 53 49 56 39 345
6 101 77 70 68 94 90 106 606