문제 설명
구독 이벤트 데이터를 이용하여 이탈 위험이 높은 고객을 찾는 문제였다.
이탈 위험 고객은 다음 조건을 모두 만족해야 한다.
- 마지막 이벤트가 취소(Cancel)가 아닌 활성 상태
- 구독 이력 중 Downgrade가 1회 이상
- 현재 구독 금액이 과거 최고 구독 금액의 50% 이하
- 구독 기간이 60일 이상
출력 컬럼은 다음과 같다.
- user_id
- current_plan
- current_monthly_amount
- max_historical_amount
- days_as_subscriber
이 문제는 현재 상태(Current Row)와 과거 전체 이력(Aggregation)을 동시에 활용하는 대표적인 문제였다.
내가 처음 생각한 접근
처음에는 문제를 보고
사용자별 과거 구독 이력 집계
↓
현재 구독 상태 확인
↓
조건 비교
순서로 접근했다.
그래서 먼저 사용자별로
- 최고 구독 금액
- 가입 기간
- Downgrade 횟수
를 집계한 뒤,
ROW_NUMBER()를 이용하여 마지막 이벤트를 가져오는 방식으로 풀려고 했다.
이 접근 자체는 정답과 동일했다.
하지만 현재 행과 과거 집계값을 구분하지 못한 부분이 있었다.
내가 헷갈렸던 부분
1. 이미 계산한 MAX()를 또 계산하려고 했다.
집계는 집계가 필요한 시점에서 한 번만 수행하고, as 로 변수를 저장해야함. 이후에는 계산한 값을 그대로 사용하는 것이 중요하다.
2. 현재 값과 과거 집계값을 비교해야 하는데 둘 다 현재 값이 되어버렸다.
위와 같은 문제가 발생.
monthly_amount / max_amount
처럼
CTE에서 미리 계산한 최고 금액을 사용해야 했다.
최종 해결 방법
1단계. 전체 구독 이력을 집계한다.
먼저 사용자별로
- 최고 구독 금액
- Downgrade 횟수
- 가입 기간
을 계산한다.
이 데이터는
과거 전체 이력
을 대표한다.
2단계. 현재 상태를 가져온다.
ROW_NUMBER() + rn = 1 를 이용하여
사용자별 가장 최근 이벤트를 선택한다.
이 데이터는
현재 상태
를 의미한다.
3단계. 두 데이터를 JOIN한다.
과거 집계값과
현재 상태를 하나의 행으로 합친다.
이제
현재 금액
↓
과거 최고 금액
을 비교할 수 있다.
4단계. 문제 조건을 적용한다.
- 마지막 이벤트 확인
- Downgrade 여부 확인
- 현재 금액 비교
- 가입 기간 확인
을 모두 수행한다.
내가 작성한 쿼리
(생략)
정답 쿼리
-- 이탈 위험 고객 찾기
-- - 마지막 이벤트가 : active subscription
-- - 구독 기록에서 downgrade가 1 이상
-- - 현재 구독액 <= 기록 상 maximum plan revenue * 0.5
-- - 60일 이상 구독자
-- order by days_as_subscriber desc, user_id asc
WITH m as (select user_id, max(event_date),
max(monthly_amount) as max_amount,
SUM(case when event_type = 'downgrade' THEN 1 ELSE 0 END) downgrade_cnt,
datediff(max(event_date), min(event_date)) days
from subscription_events
group by user_id
),
r as (select *, row_number() over(partition by user_id order by event_date desc) rn
from subscription_events
)
select m.user_id,
plan_name as current_plan,
monthly_amount as current_monthly_amount,
max_amount as max_historical_amount,
days as days_as_subscriber
from m
JOIN r ON m.user_id = r.user_id
where rn=1 AND r.event_type <> 'cancel'
AND downgrade_cnt >=1
AND monthly_amount / max_amount <= 0.5
AND days>=60
group by user_id
order by days_as_subscriber desc, user_id asc
오답 및 시행착오
가장 큰 실수는
집계가 끝난 뒤에도 집계 함수를 다시 사용하려고 한 것이었다.
이미 사용자당 한 행으로 줄어든 상태에서는
MAX(monthly_amount)
를 다시 계산해도
현재 금액만 반환한다.
또한
현재 금액과 과거 최고 금액을 비교해야 하는데,
둘 다 현재 금액이 되어버려
조건이 제대로 동작하지 않았다.
이번 문제를 통해
"집계값은 집계 단계에서 끝내고 + as 꼭 ! , 이후에는 계산된 값을 사용하는 것"이 중요하다는 것을 배웠다.
정답 풀이 해설
이 문제를 처음 봤다면 떠올려야 하는 사고 흐름
현재 상태가 필요한가?
↓
ROW_NUMBER()로 마지막 이벤트 추출
↓
과거 전체 이력이 필요한가?
↓
GROUP BY로 집계
↓
현재 상태와 과거 집계 JOIN
↓
현재 값과 과거 최고값 비교
↓
조건 만족 고객만 추출
이 문제는 "현재 상태"와 "과거 이력"을 동시에 사용하는 전형적인 패턴이다.
둘을 하나의 쿼리에서 해결하려고 하기보다,
각각 필요한 데이터를 만든 뒤 JOIN하는 것이 가장 자연스럽다.
이번 문제에서 배운 패턴
전체 이력 집계
↓
현재 상태 추출
↓
현재 상태와 집계 결과 JOIN
↓
현재 값과 과거 값 비교
↓
조건 필터링
비슷한 문제가 나오면 어떻게 접근할까?
다음과 같은 문제에서도 같은 패턴을 사용할 수 있다.
- 고객 이탈 분석
- 최신 주문 상태 분석
- 직원 최근 평가 분석
- 최신 구독 상태 분석
공통적으로
"현재 상태 + 과거 전체 이력"이 동시에 필요하다면
먼저 두 데이터를 각각 만든 뒤 JOIN하는 패턴을 떠올린다.
오늘 배운 점
현재 상태와 과거 집계는 서로 다른 데이터이다.
집계가 끝난 후에는 집계 함수를 다시 사용하는 것이 아니라,
이미 계산한 집계값을 그대로 사용하는 것이 가장 중요하다.
핵심 한 줄 요약
현재 상태와 과거 이력을 동시에 사용하는 문제는 집계 CTE + ROW_NUMBER() CTE + JOIN 패턴으로 해결하며, 집계가 끝난 뒤에는 집계 함수를 다시 사용하지 않고 계산된 값을 재사용하는 것이 핵심이다.
'TIL' 카테고리의 다른 글
| [TIL178] leetcode - Trips and Users (0) | 2026.06.27 |
|---|---|
| [TIL177] Emotionally Consistent Users 찾기 (0) | 2026.06.26 |
| [TIL175] leetcode - Find Golden Hour Customers (0) | 2026.06.26 |
| [TIL174] leetcode - Find Books with Polarized Opinions (0) | 2026.06.25 |
| [TIL173] leetcode - Find Stores with Inventory Imbalance (0) | 2026.06.25 |