class="layout-aside-right paging-number">
본문 바로가기
TIL

[TIL176] leetcode - 이탈 위험 고객(Churn Risk Customers) 찾기

by heestory323 2026. 6. 26.

문제 설명

구독 이벤트 데이터를 이용하여 이탈 위험이 높은 고객을 찾는 문제였다.

이탈 위험 고객은 다음 조건을 모두 만족해야 한다.

  • 마지막 이벤트가 취소(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 패턴으로 해결하며, 집계가 끝난 뒤에는 집계 함수를 다시 사용하지 않고 계산된 값을 재사용하는 것이 핵심이다.