SQL 튜터 세션 정리

SQL
LeetCode
조건부 집계, Window Function, Correlated Subquery, LEFT JOIN NULL 함정, INNER JOIN 필터링, COUNT DISTINCT 활용
Published

2026.04.20

SQL 튜터 세션 정리

날짜: 2026-04-20


오늘 푼 문제

# 문제 난이도 핵심 개념
1 LeetCode 1193. Monthly Transactions I Medium DATE_FORMAT, 조건부 집계 SUM(CASE WHEN), CTE vs CASE 직접 사용
2 LeetCode 1174. Immediate Food Delivery II Medium RANK() Window Function, Correlated Subquery (MIN), SUM vs COUNT 함정
3 LeetCode 550. Game Play Analysis IV Medium LEFT JOIN NULL 활용, INTERVAL 날짜 연산, 분모 컬럼 선택
4 LeetCode 1070. Product Sales Analysis III Medium CTE + INNER JOIN 필터링, “all entries 반환” vs 집계 구분
5 LeetCode 1045. Customers Who Bought All Products Medium COUNT DISTINCT, Scalar Subquery, HAVING 필터링

풀이 코드

1. LeetCode 1193. Monthly Transactions I

월별/국가별로 전체 거래 수·전체 금액·승인된 거래 수·승인된 금액을 집계하는 문제.

WITH t1 AS (
    SELECT id,
           DATE_FORMAT(trans_date, '%Y-%m') AS `month`,
           country,
           amount,
           CASE WHEN state = 'approved' THEN 1 ELSE 0 END AS is_approved
    FROM Transactions
)
SELECT `month`,
       country,
       COUNT(id) AS trans_count,
       SUM(is_approved) AS approved_count,
       SUM(amount) AS trans_total_amount,
       SUM(is_approved * amount) AS approved_total_amount
FROM t1
GROUP BY `month`, country;

대안 풀이 (CTE 없이 CASE를 집계 함수에 직접 삽입)

SELECT DATE_FORMAT(trans_date, '%Y-%m') AS `month`,
       country,
       COUNT(*) AS trans_count,
       SUM(CASE WHEN state = 'approved' THEN 1 ELSE 0 END) AS approved_count,
       SUM(amount) AS trans_total_amount,
       SUM(CASE WHEN state = 'approved' THEN amount ELSE 0 END) AS approved_total_amount
FROM Transactions
GROUP BY `month`, country;

2. LeetCode 1174. Immediate Food Delivery II

고객별 첫 번째 주문 중 즉시 배달(order_date = customer_pref_delivery_date) 비율을 구하는 문제.

방법 A — RANK() Window Function 활용

WITH t1 AS (
    SELECT  RANK() OVER(PARTITION BY customer_id
            ORDER BY order_date ASC) rnk,
            CASE WHEN order_date = customer_pref_delivery_date THEN 'immediate' ELSE 'scheduled' END AS order_type
    FROM Delivery
)
SELECT ROUND(SUM(CASE WHEN order_type = 'immediate' THEN 1 ELSE 0 END) / COUNT(order_type) * 100, 2) AS immediate_percentage
FROM t1
WHERE rnk = 1;

방법 B — Correlated Subquery (MIN) 활용

SELECT ROUND(SUM(CASE WHEN order_date = customer_pref_delivery_date THEN 1 ELSE 0 END) / COUNT(order_date) * 100, 2) AS immediate_percentage
FROM Delivery
WHERE order_date = (SELECT MIN(order_date)
                    FROM Delivery d2
                    WHERE d2.customer_id = Delivery.customer_id);

3. LeetCode 550. Game Play Analysis IV

최초 로그인 다음날에도 로그인한 플레이어 비율을 구하는 문제.

WITH t1 AS (
    SELECT player_id, MIN(event_date) AS first_day
    FROM Activity
    GROUP BY player_id
)
SELECT ROUND(
    SUM(CASE WHEN t1.player_id IS NULL THEN 0 ELSE 1 END) / COUNT(DISTINCT Activity.player_id),
    2) AS fraction
FROM Activity
LEFT JOIN t1
    ON Activity.player_id = t1.player_id
    AND Activity.event_date = t1.first_day + INTERVAL 1 DAY;

4. LeetCode 1070. Product Sales Analysis III

각 상품이 처음 판매된 연도의 모든 판매 기록을 반환하는 문제.

WITH t1 AS (
    SELECT product_id, MIN(year) AS first_year
    FROM Sales
    GROUP BY product_id
)
SELECT s.product_id,
       t1.first_year,
       quantity,
       price
FROM Sales s
INNER JOIN t1
    ON s.product_id = t1.product_id
    AND s.year = t1.first_year;

5. LeetCode 1045. Customers Who Bought All Products

모든 상품을 구매한 고객 ID를 찾는 문제.

방법 A — CTE + frac = 1

WITH t1 AS (
    SELECT customer_id,
           COUNT(DISTINCT product_key) / (SELECT COUNT(product_key) FROM Product) AS frac
    FROM Customer
    GROUP BY customer_id
)
SELECT customer_id
FROM t1
WHERE frac = 1;

방법 B — HAVING으로 간결하게

SELECT customer_id
FROM Customer
GROUP BY customer_id
HAVING COUNT(DISTINCT product_key) = (SELECT COUNT(*) FROM Product);

핵심 개념 정리

DATE_FORMAT() — 날짜를 원하는 문자열로 변환

함수 2018-12-18에 적용한 결과 타입
DATE_FORMAT(d, '%Y-%m') '2018-12' 문자열
LEFT(d, 7) '2018-12' 문자열
EXTRACT(YEAR_MONTH FROM d) 201812 숫자
  • %Y → 4자리 연도, %m → 2자리 월
  • 참고: date_parse는 MySQL 함수가 아니라 Presto/Trino 함수 — 헷갈리지 말 것

조건부 집계(Conditional Aggregation) 패턴

-- 1) 조건에 맞는 행의 개수
SUM(CASE WHEN 조건 THEN 1 ELSE 0 END)

-- 2) 조건에 맞는 행의 값 합계
SUM(CASE WHEN 조건 THEN value ELSE 0 END)
-- == SUM(flag * value)
원리 설명
SUM(flag) 1을 전부 더하면 = 해당 조건 행의 개수. 0은 더해도 영향 없음
SUM(flag * value) 1 * value = value, 0 * value = 0 → 조건 맞는 행만 합산됨

⚠️ WHERE로 조건을 걸면 안 되는 이유: 조건 불일치 행이 사라져 전체 집계(분모)가 망가짐. “전체”와 “조건부” 집계를 동시에 하려면 조건부 집계 패턴 필수.

COUNT vs SUM — 조건부 집계 시 흔한 함정

-- ❌ 잘못된 방식: ELSE 0이 있으면 COUNT가 0도 세어버림
COUNT(CASE WHEN order_type = 'immediate' THEN 1 ELSE 0 END)

-- ✅ 올바른 방식 1: ELSE 없이 COUNT (NULL은 무시됨)
COUNT(CASE WHEN order_type = 'immediate' THEN 1 END)

-- ✅ 올바른 방식 2: SUM 사용 (0은 더해도 영향 없음)
SUM(CASE WHEN order_type = 'immediate' THEN 1 ELSE 0 END)

LEFT JOIN에서 분모 컬럼 선택 원칙

LEFT JOIN 후 오른쪽 테이블 컬럼은 매칭 실패 시 NULL이 됨.

컬럼 COUNT 결과
COUNT(DISTINCT 왼쪽테이블.player_id) 전체 플레이어 수 ✅
COUNT(DISTINCT 오른쪽테이블.player_id) NULL 무시 → 매칭된 행만 카운트 ❌

분모처럼 “전체”를 세야 할 때는 반드시 왼쪽 테이블(FROM 절 테이블)의 컬럼을 사용할 것.

MySQL 날짜 연산

-- ❌ 정수 연산으로 변환됨
event_date + 120160302 (숫자)

-- ✅ 올바른 날짜 연산
event_date + INTERVAL 1 DAY

“return all entries” vs 집계 구분

문장 패턴 의미
"return all entries/records" 행 그대로 반환 → 집계 ❌
"find the total / sum / count" 집계 필요 → SUM/COUNT ✅
"may have multiple entries" 데이터 구조 설명 → 집계 여부와 무관

RANK() vs Correlated Subquery (MIN) 비교

RANK() Window Function MIN 서브쿼리
구조 CTE 필요 단일 쿼리
가독성 단계가 명확 더 간결·직관적
성능 전체 스캔 1회 (대용량 유리) 행 수만큼 서브쿼리 반복
실무 데이터 크거나 순위 재사용 시 유리 소규모·빠른 작성 시 유리

COUNT DISTINCT — 중복 제거 후 집계

-- ❌ 중복 포함: 같은 상품을 3번 사도 3으로 카운트
COUNT(product_key)

-- ✅ 중복 제거: 고유 상품 종류만 카운트
COUNT(DISTINCT product_key)

“This table may contain duplicate rows” 조건이 있을 때는 항상 DISTINCT 필요 여부를 체크할 것.


오늘의 메타 정리

  • 잘했던 것: CTE + JOIN 구조를 매 문제마다 자연스럽게 활용. 풀이 방향을 스스로 설계하고 피드백으로 다듬는 패턴 정착.
  • 틀렸던 포인트 모음: COUNT(ELSE 0) 함정 / DATE + 1 정수 변환 / WHERE절 분모 오염 / SUM으로 집계 vs 행 그대로 반환 혼동 / COUNT DISTINCT 누락
  • 배운 관점: 문제 조건 문장(“may have multiple entries”, “return all entries”)을 SQL 의도로 정확히 번역하는 독해력이 핵심.

다음에 공부할 것