3716. Find Churn Risk Customers
Find Churn Risk Customers - LeetCode
Can you solve this real interview question? Find Churn Risk Customers - Table: subscription_events +------------------+---------+ | Column Name | Type | +------------------+---------+ | event_id | int | | user_id | int | | event_date | date | | event_type
leetcode.com
1. Problem
단순히 구독을 해지한 사람이 아니라, 해지 징후가 보이는 '위험 고객'을 다음 4가지 기준으로 추출해야 한다.
- 조건 1: 현재 구독 상태가 활성 중이어야 한다 (마지막 이벤트가 cancel이 아님).
- 조건 2: 과거에 최소 한 번 이상의 downgrade 이력이 있어야 한다.
- 조건 3: 현재 플랜의 수익이 역대 최대 수익의 50% 미만이어야 한다.
- 조건 4: 첫 가입일로부터 마지막 이벤트 발생일까지의 기간이 60일 이상이어야 한다.
2. Solution: CTE를 활용한 시점별 데이터 모듈화
가장 첫 시점, 마지막 시점, 그리고 최대 금액 시점을 각각의 임시 테이블로 정의한 뒤 조인하여 최종 결과를 도출한다.
# 첫 구독
WITH first AS (
SELECT f.user_id, f.event_date, f.event_type, f.plan_name, f.monthly_amount
FROM ( SELECT user_id, event_date,event_type, plan_name, monthly_amount,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY event_date ASC) AS first_date_num
FROM subscription_events ) f
WHERE f.first_date_num = 1
),
# 마지막 구독
last AS (
SELECT l.user_id, l.event_date, l.event_type, l.plan_name, l.monthly_amount
FROM ( SELECT user_id, event_date,event_type, plan_name, monthly_amount,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY event_date DESC) AS last_date_num
FROM subscription_events) l
WHERE l.last_date_num = 1 AND l.event_type != 'cancel' # 조건1 마지막활동이 구독취소가 아닐 것
),
# 돈 많이 낸 구독
highest AS (
SELECT user_id, MAX(monthly_amount) AS max_historical_amount
FROM subscription_events
GROUP BY user_id
),
# 강등 이력 여부
down AS (
SELECT user_id
FROM subscription_events
GROUP BY user_id
HAVING SUM(CASE WHEN event_type = 'downgrade' THEN 1 ELSE 0 END) >= 1 # 조건2 강등된 적 있음
)
# 메인 쿼리
SELECT d.user_id,
l.plan_name AS current_plan,
l.monthly_amount AS current_monthly_amount,
h.max_historical_amount,
DATEDIFF(l.event_date, f.event_date) AS days_as_subscriber
FROM down d
JOIN first f ON d.user_id = f.user_id
JOIN last l ON d.user_id = l.user_id
JOIN highest h ON d.user_id = h.user_id
WHERE l.monthly_amount < h.max_historical_amount*0.5 # 조건3 현재 구독료가 과거 최고구독료 보다 50%이하
AND DATEDIFF(l.event_date, f.event_date) >= 60 # 조건4 구독기간 60일 이상
ORDER BY days_as_subscriber DESC, d.user_id ASC;
3. Takeaway: 이 쿼리의 객관적 분석
- 비즈니스 로직의 분리: 복잡한 4가지 조건을 한 번에 풀려 하지 않고 first, last, highest, down이라는 네 개의 논리적 단위로 쪼개었다. 이는 쿼리의 가독성을 높일 뿐만 아니라 유지보수를 매우 쉽게 만든다.
- ROW_NUMBER()의 영리한 활용:
- 동일한 테이블을 여러 번 참조하지만, ORDER BY를 ASC(최초), DESC(최신), amount DESC(최고가)로 다르게 주어 필요한 시점의 행만 정확히 낚아챘다.
(1) HAVING MAX(event_date) 만으로는 마지막 행을 가져올 수 없다
처음에 많이 착각하는 부분이다.
# 틀린 쿼리
GROUP BY user_id HAVING MAX(event_date)
이렇게 쓰면 마지막 이벤트가 필터링될 것처럼 보이지만, 이는 단순히 "날짜 값"만 반환할 뿐이다. MAX(event_date)는 값 하나만 반환한다.그 날짜에 해당하는 event_type, plan_name, monthly_amount는 가져오지 못한다. 즉, "마지막 날짜"는 구했지만
"마지막 행 전체"는 구하지 못한 상태가 된다.
해결 방법은
- MAX(event_date) 서브쿼리 + JOIN
- 또는 ROW_NUMBER() 사용
이다.
(2) WHERE에 downgrade 조건을 넣으면 데이터가 사라진다
# 틀린 쿼리
WHERE event_type = 'downgrade'
처음에는 이렇게 쓰기 쉽다. 하지만 이렇게 하면 해당 유저의 다른 이벤트들이 전부 제거된다. 이 문제의 조건은: "과거에 downgrade가 최소 1번 있었는가?"
GROUP BY user_id
HAVING SUM(CASE WHEN event_type = 'downgrade' THEN 1 ELSE 0 END) >= 1
HAVING은 그룹 결과에 대한 조건이기 때문에 맞는 위치이다. 즉, 특정 행을 필터링하는 것이 아니라 유저 단위 집계 조건이다. 반드시 이렇게 써야 한다.
| 위치 | 무엇을 필터링? |
| WHERE | 개별 행(row) |
| HAVING | 그룹 집계 결과 |
downgrade “이력이 있었는지”는 유저 단위 집계 결과니까 HAVING이 맞다.
(3) 현재 요금은 GROUP BY로 구할 수 없다
많이 실수하는 포인트이다.
# 틀린 쿼리
MAX(monthly_amount)
이건 과거 최대 요금이다. 하지만 우리가 필요한 것은: 현재 요금 (마지막 이벤트 기준)
현재 요금은 집계값이 아니다. 특정 행의 값이다. 따라서 반드시 마지막 이벤트를 먼저 구하고 그 행에서 monthly_amount를 가져와야 한다.
# 마지막 구독
last AS (
SELECT l.user_id, l.event_date, l.event_type, l.plan_name, l.monthly_amount
FROM ( SELECT user_id, event_date,event_type, plan_name, monthly_amount,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY event_date DESC) AS last_date_num
FROM subscription_events) l
)
(4) cancel 이벤트를 고려하지 않으면 조건이 자동 통과된다
문제에서 cancel 이벤트의 monthly_amount는 0이다. 만약 마지막 이벤트가 cancel인데 이를 걸러내지 않으면:
WHERE current_amount < max_amount * 0.5
는 자동으로 TRUE가 된다. 0은 항상 최대값의 50%보다 작기 때문이다. 즉, cancel 유저가 잘못 포함될 위험이 있다. 반드시 마지막 이벤트가 cancel이 아닌지 확인해야 한다.
# 마지막 구독
last AS (
SELECT l.user_id, l.event_date, l.event_type, l.plan_name, l.monthly_amount
FROM ( SELECT user_id, event_date,event_type, plan_name, monthly_amount,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY event_date DESC) AS last_date_num
FROM subscription_events) l
WHERE l.last_date_num = 1 AND l.event_type != 'cancel' # 조건1 마지막활동이 구독취소가 아닐 것
),
(5) 최고 요금은 굳이 ROW_NUMBER가 필요 없다
처음에는 이렇게 접근하기 쉽다.
# 틀린 쿼리
ROW_NUMBER() OVER ( PARTITION BY user_id ORDER BY monthly_amount DESC )
하지만 이 문제에서 필요한 것은 "최고 요금이었던 행”이 아니라 최고 요금 값 자체 단일값이다. 따라서 더 간단하게 해결할 수 있다. 불필요하게 윈도우 함수를 쓰면 쿼리가 복잡해지고 가독성이 떨어진다.
MAX(monthly_amount)
# 돈 많이 낸 구독
highest AS (
SELECT user_id, MAX(monthly_amount) AS max_historical_amount
FROM subscription_events
GROUP BY user_id
)
💡 팁
- 실무적 제언: 이 분석은 SaaS(구독 모델) 기업에서 매우 핵심적이다. 이미 해지한 뒤에 붙잡는 것은 비용이 많이 들지만, 이 쿼리로 추출된 '위험군'에게 맞춤형 혜택을 제공하면 훨씬 저렴한 비용으로 고객을 유지(Retention)할 수 있다.
'Data Science > SQL' 카테고리의 다른 글
| [SQL/오답] 연속 행동 패턴 분석: 그룹화 기법(DATE_SUB)을 활용한 스트릭 계산 (Leetcode3832 Hard) (0) | 2026.02.20 |
|---|---|
| [SQL/오답] 유저별 지배적 반응과 비율 계산 (Leetcode3808 Medium) (0) | 2026.02.20 |
| [SQL/오답] 골든아워 고객 찾기: 다중 조건 집계와 시간대 필터링 TIME함수 (Leetcode3705 Medium) (0) | 2026.02.19 |
| [SQL/오답] 평점 양극화 분석: 집계 함수와 HAVING 절의 제약 사항 (Leetcode3642 Medium) (0) | 2026.02.19 |
| [SQL/풀이] 상/하반기 연비 비교: 단순 집계를 넘어선 성과 분석 (LeetCode3601 Medium) (1) | 2026.02.18 |