<인과추론> 1. 인과추론과 잠재 결과 프레임워크

|
정말 오랜만에 글을 쓴다. 예전에 한창 인과추론을 공부했었는데 따로 블로그에 정리하지를 못했었다. 이번 기회에 다시 배운 내용을 복습하면서 나도 언제든 참고할 수 있도록 양질의 글을 만들어두는 것을 목표로 하려고 한다.
여기에 나온 글은 모두 참고 자료에 있는 내용을 정리한 것입니다.

1. 인과추론이란?

“ 연관관계(Association)는 인과관계(Causation)이 아니다. “

연관 관계는 두 개의 수치나 확률변수가 같이 움직이는 것이고, 인과관계는 한 변수의 변화가 다른 변수의 변화를 일으키는 것이다. 인과추론이란 연관관계로부터 인과관계를 추론하고, 이 값이 언제 어떻게 다른지 이해하는 학문이다.

“ No causation without manipulation. ”

그럼 인과추론의 목적은 무엇일까? 인과추론은 오롯이 현실을 이해하기 위해서 존재한다. 즉, 원인에 개입하여 내가 원하는 결과를 만들어내기 위해서 원인과 결과의 관계를 알아내는 것이다. 실제 실무에서도 “액션을 위한 분석”이 중요하고 결국 이 분석을 통해서 제품(=서비스)의 변화가 어떤 결과를 가져왔어요?에 대한 답을 제시하는 것이 중요하기 때문에 이 목적은 계속 마음에 품고 가져가면 좋을 것 같다.

  • How does fertilizer affect crop yields?
    • → How would crop yields change if we change the amount of ..
  • How does education affect income?
    • → How would income change if we change the amount of ..

연관관계와 인과관계의 예시로 다음의 상황을 가정해보자.

어린이 장난감을 판매하는 기업이 크리스마스 기간 전에 할인을 하는 것이 좋을지 정가에 판매하는 것이 좋을지 의사결정을 하려고 한다. 다행히 나에게는 참고할 수 있는 과거 데이터가 존재한다

이를 인과추론 용어로 풀어보면 할인 여부(is_on_sale) = T주간 판매량(amount) = Y 에 미치는 효과를 파악하고자 하는 것이다.

그럼 실제 데이터를 보기 위해, T에 따른 Y 값들을 박스플롯으로 그려보면 어떻게 될까?

250323_boxplot.png

  • 이렇게만 보면 할인을 한 (On Sale) 상점들의 판매량이 더 많았던 것처럼 보인다. 하지만 조금 더 깊이 생각해보면, 대기업일수록 할인을 할 수 있는 여유가 있기 때문에 할인 기업들의 판매량이 더 높게 집계되었을 수 있다.
  • 그리고 현실에서도, 많은 사람들이 위와 같은 관측 데이터(혹은 직관)를 가지고 잘못된 판단을 하고 있을 확률이 높다. 아래 예시들을 곰곰이 생각해보자.
    • 여름철에 아이스크림 판매량이 증가하면 익사 사고도 함께 증가한다.
    • 손글씨 연습을 하면 학생들의 성적이 오른다.
    • 행운의 부적을 가지고 있는 학생들은 성적이 더 오른다.

사실 이와 같은 문제를 쉽게 해결하려면, 동일한 회사가 동일한 조건에서 할인을 한 상황과 그렇지 않은 상황을 동시에 관측하여 실제 효과를 추정하면 된다. 하지만 평행 우주에서 데이터를 관측하고 돌아오지 않는 이상 이것이 불가능하다는 것을 우리 모두는 알고있다.


2. 잠재 결과 프레임워크와 인과 효과

잠재 결과 프레임워크(Potential Outcome Framework)란 인과추론에서 널리 사용되는 방법론으로, 개입이 결과에 미치는 효과를 평가하는 데 사용된다. 이름에서 짐작할 수 있듯이 “만약 이랬다면 결과가 어떻게 바뀌었을까?”라는 생각을 체계화 한 것이다.

우선, 인과추론의 용어를 익히고 가자.

  • 처치 (Treatment, T 또는 D): 구하려는 효과에 대한 개입
  • 결과 (Outcome, Y) : 영향을 주려고 하는 변수
  • 즉, 인과추론의 목표는 T가 Y에 미치는 영향을 학습하는 과정

만약 내가 어제 공부를 더 했더라면 오늘 시험 점수가 더 높아졌을까? 라는 생각에서 아래와 같은 개념들이 존재한다.

  • 사실적 결과 (Factual Outcome)
    • 처리 T가 1일 때의 잠재적 Y
    • = 관측할 수 있는 잠재 결과
    • = 어제 공부를 한 나의 시험 결과
  • 반사실적 결과 (Counterfactual -)
    • 처리 T가 0일 때의 잠재적 Y
    • = 관측할 수 없는 다른 결과
    • = 어제 공부를 하지 않은 나의 시험 결과

이때,

  • $Y_{1i}$를 실험 대상 i가 처치 받은 잠재 결과
  • $Y_{0i}$를 실험 대상 i가 처치 받지 않은 잠재 결과라고 할 때,
  • 잠재 결과 $Y_i = T_iY_{1i} + (1-T_i)Y_{0i} = Y_{0i} + (Y_{1i} - Y_{0i})T_i$ 로 표현할 수 있고,
  • 개별 유닛에 대한 인과 효과 ITE(Individual Treatment Effect)는 다음으로 정의된다.

    \[\tau_i = Y_{1i}-Y_{0i}\]
이 식이 성립하기 위한 몇 가지 조건이 존재하는데, 이는 별도 글에서 다루도록 하겠다.


하지만 현실 세계에서 개별 유닛에 대한 잠재 결과를 관측한다는 것은 불가능하다. 따라서 집단의 평균 개념으로 문제에 접근한다. 우선 아래 세 개념을 익혀두도록 하자.

  • 평균처치효과(ATE, Average -): 처치 T가 Y에 평균적으로 미치는 영향

[ATE = E[\tau_i] =E[Y_{1i} - Y_{0i}] = \frac{\partial}{\partial t}E[Y_i]]

  • 실험군에 대한 평균처치효과(ATT, ATE on the treated): 처치 받은 대상에 대한 평균 처치효과

    \[ATT = E[Y_{1i} - Y_{0i} | T=1]\]
  • 조건부 평균처치효과(CATE, Conditional ATE): 공변량 X를 갖는 그룹에 대한 처치효과

    • ex) 신기능 A는 신규 가입 유저의 리텐션을 얼마나 상승시켰을까?
    \[CATE = E[Y_{1i} - Y_{0i} | X= x]\]


3. 현실 세계에서의 인과 효과

실제 예시를 통해 처치 효과를 계산해보자.

우리가 평행 우주의 데이터를 모두 관측할 수 있어서, 아래와 같은 완전한 데이터를 보유한 상황을 가정할 것이다. (현실에서는 y0 또는 y1 둘 중 하나의 값만 관측할 수가 있다)

y0 y1 t (할인 여부) x (크리스마스까지 남은 시간, 공변량) y (판매량) te (처치효과)
200 220 0 0 200 20
120 140 0 0 120 20
300 400 0 1 300 100
450 500 1 0 500 50
600 600 1 0 600 0
600 800 1 1 800 200
  • ATE: te의 평균 = 65
    • 가격할인(T)을 하면 평균적으로 판매량(Y)을 65개 늘린다고 해석할 수 있다.
  • ATT: t가 1일 때 te의 평균 = 83.33
    • 가격할인(T)을 한 회사는 평균적으로 판매량(Y)이 83.33개 증가했다.
  • CATE(x=1) : x =0 일 때의 te의 평균 = 22.5
    • 크리스마스 주간(x=0)에 가격을 할인(T)했을 때 판매량이 평균 22.5개 증가했다.


이제 현실로 돌아와 실제 데이터로 동일한 효과를 추정한다고 가정해보자.

y0 y1 t (할인 여부) x (크리스마스까지 남은 시간, 공변량) y (판매량) te (처치효과)
200 - 0 0 200 -
120 - 0 0 120 -
300 - 0 1 300 -
- 500 1 0 500 -
- 600 1 0 600 -
- 800 1 1 800 -

글의 처음에서 봤던 것처럼, 그냥 실험군의 평균과 대조군의 평균을 비교하면 안 될까?

  • ATE = (t=1일 때의 평균) - (t=0일 때의 평균) = 426.67 ?

이는 연관 관계를 인과 관계로 착각하는 중대한 오류이고, 절대 이런 식으로 사고해서는 안 된다.

  • 실제로 할인한 회사(t=1)와 그렇지 않은 회사(t=0)가 다르고, 평행 우주 데이터를 보면 실험군의 Y0가 대조군보다 훨씬 높은 것을 확인할 수 있다. 즉, 할인을 하는 가게들은 대부분 애초에 판매량이 높은 가게가 많다.

이처럼 인과관계와 연관관계를 다르게 만드는 요소를 편향(bias)이라고 부르는데, 쉽게 이해해보자면 데이터에 영향을 주고 있는 요인들이라고도 할 수 있겠다. 앞서 봤던 예시에서, 편향에 대한 요소를 정성적으로 표현해보면 다음과 같다.

여름철에 아이스크림 판매량이 증가하면 익사 사고도 함께 증가한다.

  • 아이스크림 판매량이 증가했다면 기온도 높았을 것이다. 기온이 높았기 때문에 수영을 하는 사람들이 늘어나 익사 사고도 증가하지 않았을까?

손글씨 연습을 하면 학생들의 성적이 오른다.

  • 손글씨를 잘 쓰는 학생들은 대개 정리하는 습관이 잘 들여진 학생이었을 가능성이 높아 보인다. 손글씨 연습을 하면 성적이 오른다는 것은 잘못된 가정같다.

행운의 부적을 가지고 있는 학생들은 성적이 더 오른다.

  • 좋은 성적을 받기를 간절히 희망하는 학생들일수록 부적과 같은 미신을 믿을 가능성이 높지 않을까? 부적이 성적을 올려주는 게 아니라, 이미 간절함을 가진 노력/성실 학생들이 부적을 소지하고 있을 것 같다.


다음 글에서는 편향과 인과추론의 식별/추정 개념에 대해 알아보겠다.


4. 참고 자료

<분석방법론> AARRR 분석 파헤치기

|
오랜만에 작성하는 글이다. 데이터 분석에서 AARRR을 토대로 하는 분석 방법이 많이 언급되는데, 도대체 뭐길래 그럴까? 이번 글에서 자세히 알아보자.

1. AARRR이란?

AARRR이란 미국의 스타트업 액셀러레이터 500 Startups의 창립자 데이브 맥클루어(Dave Mcclure)가 고안한 마케팅 분석 프레임워크로써 일종의 퍼널 분석이다. 이 분석법을 활용하여 각 단계별로 움직이는 고객들을 나누어 분석하고, 여러 문제점을 관리할 수 있게 된다.

이름에서 유추할 수 있는 것처럼 고객의 라이프사이클을 다섯 단계로 나누고, 이들의 앞글자를 따서 만들었다. 각각의 의미는 다음과 같다.

  • Acquisition 유입/획득: 고객이 웹/앱으로 유입되는 단계
  • Activation 활성화: 고객이 실제로 반응을 하는가. 사용자들의 첫 번째 “행복한” 방문 경험을 측정하는 단계
  • Retention 유지: 고객들이 다시 돌아오는가
  • Referral 리퍼럴/바이럴: 고객들이 제품을 다른 고객들에게도 추천해주는가
  • Revenue 수익: 이러한 고객의 활동들이 매출로 이어지는가

AARRR은 해적 지표(Pirate Metrics, Pirate Funnel)라고도 불리는데 데이브가 AARRR에 대하여 발표한 이 영상을 보면 그 이유와 AARRR 전체 발표를 들을 수 있다. (AARRR을 이어서 발음하면 아ㄹㄹ~처럼 해적이 내는 소리가 나서..)

AARRR

2. 단계별로 살펴보기

그럼 각 단계별로 어떤 내용들을 살펴봐야할까?

Acquisition

고객이 최초로 서비스로 유입되는 단계를 의미한다. 고객들은 여러 채널을 통해 서비스로 유입을 하게 되는데 여기서 중점적으로 봐야하는 내용들은 다음과 같다.

  • 가장 많이 유입되는 채널은 어디인가? (largest-volume #)
  • 가장 저렴한 채널은 어디인가? (lowest-cost $)
  • 가장 효과가 좋은 채널은 어디인가? (best-performing %)

즉, 서비스로 유입되는 고객들이 어디서 얼마나 들어오고, 어떤 채널의 고객들의 가치가 더 높은지를 인지하는 것이다. 이 단계에서 볼 수 있는 지표들은 다음과 같다.

  • DAU / MAU / 신규 유저 등
  • CAC / LTV 등

Activation

고객이 처음으로 접하는 “행복한” 경험을 의미한다. 즉, 서비스의 입장에서 잠깐 스쳐 지나간 고객들은 다 제외하고 어떤 행동을 했을 때 실제로 우리 서비스를 경험했다고 정의할 것인가에 관한 부분이다.

여기서는 내가 원하는 행동을 고객이 활발히 했는가를 측정하게 된다. 예를 들어, 페이스북이라면 일정 개수 이상의 포스트를 읽거나, 좋아요를 누르거나 댓글을 작성하는 것들이 이러한 활성화의 예시가 될 수 있다.

  • 랜딩 페이지 검정이 중요하다
  • 많은 AB 테스트를 하고, 빠르게 반복하라
  • Bounce rate / PageView / avg.PV / DT(duration time) 등을 측정할 수 있다

Retention

고객들이 다시 서비스로 돌아오는가? 재방문에 대한 지표들을 추적하는 단계이다.

  • 이를 높이기 위한 방법들로는 메일링/메시징/푸시알림/… 등 수많은 방법이 있을 것이다
  • Retention&churn rate / 평균 재방문 주기 등을 측정할 수 있다
  • 더 나아가 고객 세그먼트별로 retention의 차이도 볼 수 있다

Referral

고객들 사이에서 서비스에 대한 추천이 발생하는가?

  • Dave는 반드시 서비스에 대한 품질이 보장되었을 때 바이럴 마케팅을 진행하라는 점을 강조한다. (그렇지 않으면 나쁜 퀄리티에 대한 입소문이 나기 때문에)
  • SNS share rate / 사용자 언급 댓글수 등을 측정할 수 있다

Revenue

이러한 고객의 활동이 매출로 이어지는가?

  • ATV (Average Transaction value)
  • IPT (Item Per Transaction)
  • ARPU / ARPPU 등

Example Conversion Metrics

AARRR

AARRR의 다섯 가지 단계는 위의 표처럼 나타낼 수 있다. 즉, 고객이 최초 유입되는 시점부터 매출이 발생하기까지의 과정을 말그대로 퍼널로 표현하되, 그러한 기준들을 내 서비스에 적합하도록 명확히 선정하여 관리하는 것이다.

다섯 가지 단계이지만 각 단계별로 여러 지표를 선정하는 등 다양하게 서비스의 용도에 맞게 사용하면 된다.

3. AARRR, 이렇게 활용해라 (by Dave)

AARRR은 스타트업을 위한 메트릭으로 고안되었다. 이미 나온지 15년(..!)이나 된 개념이지만 여기에 스타트업들이 갖춰야 할 행동 양식이 다 담겨있다고 본다. Dave가 강조한 부분들을 정리해보았다.

  1. 마케팅 측면에서
    • 여러 마케팅 채널을 설계하고 테스트하라
    • high volume, high conversion, low cost 채널을 선택하라
    • 랜딩 페이지 뿐 아니라 더 깊게 측정하라
    • 가장 low한 레벨에서 채널과 고객을 세그먼트화하여 측정하고 선택해라
  2. 제품 개발 측면에서
    • 80%의 시간은 기존 제품의 최적화에, 20%의 시간을 새로운 기능 개발에 투자하라
    • 계속해서 가설을 세우고 A/B 테스트를 아주 많이 해라
    • 그리고 전환이 향상되는 것을 측정하라
  3. 설립자들에게
    • 최대한 덜해라. 많이 빼라
    • 하지만, 반드시 측정하고 반복하라
    • 전환의 향상에 집중하라
    • 고객의 라이프사이클에 대한 가설을 세우고 이를 고도화하라
    • 지표를 개발하라

이 내용들을 보면 현재의 서비스 기업들에서 BA, DA, 또는 그로쓰 관련 직무에서 하고 있는 업무와 별 차이가 없다는 것을 알 수 있다.

결국 내가 생각하는 핵심은 이런 것 같다.

1) 시간의 흐름에 따라 비교하며 수치적 변화를 분석할 수 있는 측정 가능한 프레임 워크를 만들고, (여기서는 AARRR)

2) 이러한 분석을 전체 level 뿐 아니라 더 drill down해서 쪼개어 깊은 단계에서 보고,

3) 각 스텝별 지표를 측정하며 bottleneck과 drop-off가 있는 부분들을 찾아 여러 실험을 반복해서 빠르게 진행하며 지표의 개선을 확인해라

4. AARRR, 어떻게 적용할까

AARRR을 기계적으로 활용하기 보다는 정말로 내 서비스를 사용하는 고객들의 사이클을 분석한다는 관점으로 접근해야 좋은 지표들이 나올 수 있다. 일단은 내가 관찰한 데이터와 경험을 토대로 서비스의 사이클을 AARRR으로 녹여내고, 세부 단계별로 전환율을 측정하면서 스텝별로 놓친 부분이 있는지 확인하는 것도 하나의 방법이 될 수 있다.

전체 사이클을 세웠다면, 이제 계속해서 전환을 측정하고 비교해야 한다. 지금 가장 전환이 떨어지는 구간은 어디인가? 해당 부분에 대한 개선을 진행했을 때, 실제로 전환이 높아졌는가? 전환이 떨어지는 여러 단계 중, 어느 단계를 극복하면 가장 큰 효과를 가져올까? 이러한 질문들을 검증하기 위해 가설을 세우고, AB 테스트를 진행하게 된다.

전체적인 전환 지표를 점검했다면? 더 향상시킬 부분은 없을까? 이제 고객을 세그먼트화하여 개별 고객 그룹에 대한 지표를 비교할 수도 있다. 이 그룹은 전체적으로 전환이 떨어지는데 향상시킬 수 있는 방법은 없을까? 20-30대 여성 그룹은 전체 지표가 가장 좋은데, 이들을 메인 타겟으로 여러 이벤트를 진행할 수 있겠구나.

결국 AARRR은 내 서비스를 이용하는 고객의 라이프사이클을 잘 정리해서, 개별 스텝을 효율적으로 바꾸는 작업이라고 생각한다. AARRR이 가져다주는 가장 큰 이점은, 이러한 복잡해보이는 라이프사이클을 ‘AARRR’이라는 단계로 구분지은 것이고, 매 단계를 측정하고 최적화할 수 있도록 프레임을 제공해주는 것이다. 특히 한 번에 하나의 지표(OMTM)에 집중하여 사용하기 쉽게 해준다.

AARRR은 스타트업이 가장 중요한 성장 지표를 찾을 수 있도록 도와주는 아주 간단한 프레임워크이다. 이는 고객의 라이프사이클을 단순화하고 일정한 목표를 가도록 해준다. 즉, bottleneck과 drop-off를 찾아서, 퍼널을 최적화하고 고객의 가장 중요한 행동들을 기록할 수 있다.

5. 마치며

AARRR에 대해 정리해두었던 내용을 드디어 글로 옮겼다. 😀 조금 더 구체적인 글을 쓰려고 했었는데 쓰다보니 전반적인 개념에 대해서만 훑는 글을 쓰게 된 것 같다.

그래도, 원하는 바는 다 전달한 것 같아 홀가분하다.

다음 글은 어떤 분야가 될지 모르겠는데, 너무 늦지 않게 또 써보려고 한다 !

6. 참고 글

<분석방법론> RFM 분석 파헤치기

|
지난 글에서 다루었던 코호트 분석에 이어서, 이번에는 고객 분류의 한 종류인 RFM 기법에 대해 알아보자! 😎

1. RFM 분석이란?

RFM 분석은 고객의 가치를 분석하기 위한 기법으로, 정확히 말하면 고객을 R-F-M의 차원에서 등급을 매기는 분석을 의미한다. RFM 분석은 Optimal Selection for Direct Mail(1995)이라는 이메일 캠페인에 관한 논문에서 최초로(?) 다뤄졌다고 알려져 있다. 다만 이미 논문에서도 RFM 기법을 최초 소개하는 것이 아닌, 이미 널리 쓰이고 있다고 언급하기 때문에 실제 쓰임은 더 오래되었을 것이다.

대표적인 RFM의 구현법에서, R-F-M은 각각 다음을 의미한다. R이 가장 중요하다고 여겨지며, 그 다음이 F와 M이다.

  • Recency: 고객이 얼마나 최근에 구매를 했는가?
  • Frequency: 고객이 (주어진 기간 동안) 얼마나 자주 구매를 했는가?
  • Monetary: 고객이 (주어진 기간 동안) 구매에 얼마를 지출하였는가?

이미지 출처: https://clevertap.com/blog/rfm-analysis/ RFM Table

즉, RFM 분석(RFM Analysis / RFM Scoring)을 다시 풀어서 설명하자면 각각의 R-F-M 차원에서 개별 고객들이 얼마나 잘 하고 있는지 등급을 매기는 기법을 의미한다. 이 설명이 RFM의 전부일 만큼 RFM은 계산이 간단하고 직관적이다. 이제 RFM 기법을 적용하는 과정을 알아보자.

RFM은 오래된 기법이고 직관적인 방법인 만큼 구현 방식이 다양하게 소개되어있다. 여기서는 가장 일반적으로 받아들여지는 RFM 구현을 소개하고, 아래에서 여러가지 방식들도 소개하겠다.

2. RFM 분석 프로세스

일반적인 RFM 분석은 아래의 프로세스로 진행된다.

  1. RFM 분석의 기본이 되는 데이터를 준비한다. (고객ID, 최근 구매 날짜, 특정기간 내 구매 횟수, 특정기간 내 구매 금액)
  2. R-F-M 개별 등급의 카테고리 수를 정한다. 일반적으로 5개 등급을 부여하는데, 이렇게 되면 각각 5개 등급씩 총 5x5x5=125개의 등급 조합이 생긴다. (이 개별 조합을 셀cell이라고 부른다.)
  3. 이제 개별 고객을 각 등급에 할당해야 한다. 가장 단순하게는, R-F-M 개별에서 점수가 높은 순으로 정렬한 뒤, 5분위로 나누어 가장 높은 등급이 5가 되도록 한다.
    • Recency는 최근 구매 날짜가 가까운 순서로
    • Frequency는 구매 횟수가 가장 큰 순서로
    • Monetary는 구매 총액이 가장 큰 순서로 (또는 구매 평균 금액)
  4. R-F-M에 대해 모두 등급을 할당하면, 이렇게 개별 고객에게 만들어진 조합이 RFM 셀이 된다. ex) 5-1-3, 5-1-4, 1-3-5

여기까지는 RFM 세그멘테이션을 진행하는 방법이고, 더 나아가 마케팅적 적용은 아래의 단계를 거칠 수 있다. 해당 내용은 이 글을 옮긴 내용이다. (원문과 세그멘테이션 구현 방식은 다르므로 주의, 글의 원래 내용은 Arthur Hughes의 Strategic database marketing에서 나온다)

  1. 만약 이메일 마케팅을 진행한다면, 전체 고객 중 테스트 표본을 선발한다. 이 테스트 표본에 대해 RFM 세그멘테이션을 해둔다.
  2. 테스트 표본에 대해 이메일 마케팅을 진행하고 결과(전환과 비용)를 기록한다.
  3. 손익분기점이 되는 응답률, 또는 타겟 비율을 선정한다.
    • 만약 손익분기점이 되는 응답률이 목표라면 응답률은 (응답당 비용/응답당 수익)으로 계산할 수 있다.
  4. 테스트 표본의 RFM 셀별 응답률을 계산하고, 위에서 선정한 응답률보다 높은 셀만 타겟으로 선정한다.
  5. 만약 표본이 전체 고객 집단을 잘 대표한다면 표본의 셀과 전체 고객의 셀은 유사한 결과를 보일 것이다. 따라서 전체 고객에서 RFM 세그멘테이션을 다시 진행하고, 타겟 셀에만 선별적인 이메일 마케팅을 진행한다.

3. RFM 분석의 의미, 한계점

RFM 분석의 기본 아이디어는 파레토 법칙에 있다. 상위 20%의 고객에게서 80%의 수익이 나온다는 부분에 집중하기 때문에 기본적으로는 우수 고객들에게 집중하는 분석이다. 거기에 더하여, 과거 구매 데이터를 기반으로 하기 때문에 구매하지 않는 대다수의 고객들은 마케팅의 대상에서 제외된다.

그럼에도 과거에 널리 쓰였던 이유는 RFM 분석의 쉬운 적용과 직관성에 있다. 구매 데이터만 있다면 누구든 손쉽게 RFM 세그멘테이션을 진행할 수 있고, RFM 각 디멘션이 나타내는 바가 직관적이기 때문이다. 다만 여전히 과거 데이터를 기반으로 분류하는 것에서 그치기 때문에 예측 분석 기법이 발달된 지금은 선호도가 떨어진다고 생각된다.

RFM의 장점은 위에서 언급한 것처럼 명확하기 때문에 몇가지 한계점을 나열하자면 아래와 같다. 출처는 A review of the application of RFM model, 2010.

  • R-F-M 변수간 상호 독립성이 보장되지 않기 때문에 중복(redundancy, double counting) 이슈가 있다. 즉, Frequency가 높은 고객은 자연스레 Monetary도 높기 때문에 변수간 독립성이 성립되기 어렵다.
  • 고객 풀을 나누는 데는 좋은 기법이지만, 마케팅 캠페인에 적용하기에는 훨씬 더 나은 예측 기법이 많다. 왜냐하면 RFM은 결국 구매에 관해 점수를 매기는 것이지 특정 마케팅 전환(ex, 메일의 응답률)에 대한 점수를 매긴 것이 아니기 때문이다.
  • 위와 비슷한데, RFM은 현재 온라인 커머스 시장에 있는 무수한 고객 행동 데이터를 활용하지 못한다. 오직 구매 데이터만을 활용할 뿐이다.
  • RFM을 통해서는 앞으로 새로 등장할 고객들에 대한 예측을 할 수 없다.
  • 산업마다 각 R-F-M이 갖는 중요도가 다르지만 이들이 정량적으로 측정되기 어렵다.

4. RFM 구현의 다양성

RFM 분석은 구현이 쉬운 만큼 구현 방법도 매우 다양하다. 위에서 언급한 A review… 논문에서 이러한 다양한 구현법에 대해 언급해주는데 몇 가지를 알아보고 파이썬을 통해 직접 RFM 세그멘테이션을 구현해보도록 하겠다.

1) 개별 등급을 나누는 방법

개별 등급을 나누는 방법은 크게 두 가지가 있다. Thoughts on RFM scoring, John Miglautsch, 2000을 참고했다.

Customer Quintile Method

위 프로세스에서 소개한 방법으로, 단순히 크기로 정렬하여 20%씩 할당하는 방식이다. 상위 20%는 5등급을, 하위 20%는 1등급을 받게 된다. (RFM에서는 일반적으로 등급이 클수록 좋다)

이 방식은 간편하지만 한계점들이 있다.

  • 일반적으로 구매 빈도(F)는 1회인 고객이 많게는 60%까지도 될 수 있다. 이러한 경우 동일한 행동을 한 고객들이 서로 다른 등급으로 취급될 수 있다. (spill over, bracket creep problem)
  • Relative Sensitivity: 5등급의 고객만 살펴볼 때, 이들은 상위 20%~0%까지라고 표현할 수 있다. 이때, 상위 0%와 20%는 행동값이 크게 다를 수 있는데, 이들이 같은 등급에 묶이는 문제가 발생한다. 마찬가지로, 상위 20%와 4등급의 상위 21% 고객의 행동값은 비슷하지만, 이들을 임의로 쪼개는 경향도 발생한다. 이를 논문에서는 상대적 민감성이라고 표현한다.

Behavior Quintile Method (by John Wirth)

고객의 행동값을 기준으로 5분위수를 만드는 방법이다. 논문에서는 위에서 소개한 고객 5분위수 방법처럼 정렬을 하되 등급간 Monetary의 합이 같도록 비율을 조정한다고 나와있는데, R-F-M 각각의 설명에서는 분류한 방식이 약간씩 다르다. 글에서는 각각의 설명을 따르도록 하겠다.

Recency 일반적으로는 달력 기반 방식을 쓰는 게 선호된다. Wirth는 0-3개월/4-6개월/7-12개월/13-24개월/25개월이상의 Recency 등급을 제안했다.

Frequency 그럼에도 여전히 구매 빈도(F)가 문제가 된다. 여기서 Ted Miglautsch가 새로운 기법을 제안하는데, 이 프로세스는 아래와 같다.

  1. 1회 구매자들에게는 1등급을 준다.
  2. 1등급을 제외하고, 남은 고객들의 빈도를 평균내어 평균보다 낮은 그룹에게 2등급을 준다.
  3. 반복하여 4등급까지 만들고, 남은 고객들은 5등급을 할당한다.

Monetary 5등급에 속한 5명의 고객이 총 100만원의 매출을 만들었다면 1등급은 100명의 고객이 100만원의 매출을 만드는 식으로 할당한다.

2) R-F-M 등급의 의존성

일단 의존성이라고 표현했는데, 두가지 방식을 설명하면 다음과 같다.

  • a. R/F/M 개별로 등급을 매긴 뒤, 단순히 등급을 묶는 방식.
  • b. R 등급을 매긴 뒤, R 등급의 1~5등급별로 F 등급을 매긴다. 마찬가지로 RF 등급쌍별로 M 등급을 매긴다.

즉 a 방법은 각 차원을 독립적으로 구성하고, b 방법은 앞의 차원에 의존적이게 구성이 된다. a의 장점은 간단하고 모든 셀이 동일한 사이즈로 구성된다는 점이고, b의 장점은 차원을 연계하여 상대적인 위치를 알 수 있다는 점인 것 같다.

예를 들어, 5-1-3 고객과 1-1-3 고객이 있다고 가정해보자. a 방식에서는 두 고객 모두 빈도(F) 점수가 1점이며, 실제로도 같은 수준의 빈도를 가지고 있을 것이다. b 방식에서는 그렇지 않다. 5-1-3 고객은 가장 최근에 방문한(R=5) 고객들 중 상대적으로 빈도가 낮은 고객 그룹이며, 1-1-3 고객은 아주 예전에 방문한(R=1) 고객들 중 상대적으로 빈도가 낮은 고객 그룹이 된다.

3) RFM 점수 만들기

RFM 세그멘테이션을 진행했다면 최종적으로 RFM 점수를 만들어 고객별 순위를 매길 수 있다. 이 단계는 선택적으로 진행하면 되고 아래의 구현 방식들이 있다.

  • a. 단순히 R/F/M의 점수를 더한다. 5-5-5이면 15점이 된다.
  • b. Judgment based RFM (also known as Hard-Coding) 개별로 가중치를 준다. 이 방식은 도메인별로 마케터의 재량에 따라 가중치를 줄 수 있도록 해준다.

5. RFM 분석 해보기

실제 데이터로 RFM 분석을 해 볼 시간이다. 데이터는 지난번 코호트 분석에 관한 글에서 썼던 것을 사용한다. 단, RFM 분석은 셀마다 어느정도 표본이 필요하기 때문에 1000명이 아닌 10000명씩 샘플링한다! 해당 데이터는 여기서 바로 받을 수도 있다.

앞서 소개한 여러가지 방법들 중, 아래의 방법을 사용한다.

  • 데이터가 5개월동안의 이벤트를 포함하기 때문에 Recency 값은 (1주/2주/3주/4주/4주+)로 등급을 나눈다. 이커머스에서 주로 사용하는 전환 기준인 28일을 기준점으로 삼았다. 날짜 계산의 기준은 데이터의 마지막 날짜인 2월 29일로 한다.
  • Frequency는 4번에서 소개한 Ted Miglautsch의 방법을 사용한다. 기준 기간은 데이터 전체(5개월)로 하겠다.
  • Monetary는 단순히 크기순으로 5분위수를 나누도록 하겠다. (customer quintile method) 기준 기간은 위와 마찬가지.

1) 구매 데이터 불러오기

일단 sqlite에 새롭게 1만 샘플 데이터를 넣자. 파일별 고유 유저 1만 명을 샘플링한 것이기 때문에 실제 사이즈는 80만 개의 행이 조금 넘는다.

import sqlite3

# 일반적으로 connect는 host에 연결하는 행위이지만, SQLite은 곧바로 DB에 연결한다.
con = sqlite3.connect('ecommerce10000.db')

# 명령어를 실행해주기 위한 커서 선언
cur = con.cursor()

# event 테이블을 만들어준다. 일단 모두 text 타입으로 선언한다.
cur.execute("""
    CREATE TABLE events
        (event_time text, event_type text, product_id text, category_id text, category_code text, brand text, price text, user_id text, user_session text)
""")

import pandas as pd

# 아까 만든 데이터를 불러온다.
data = pd.read_csv('ecommerce_cosmetics_sampled_10000.csv')

lists = []
# 튜플 형태로 리스트에 넣어준다
for idx, row in data.iterrows():
    lists.append(tuple([*row.values]))

# executemany를 통해 한 번에 데이터를 넣는다
cur.executemany("""INSERT INTO events VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",lists)

# 트랜잭션을 수행하는 모든 함수는 반드시 커밋을 해줘야 실제 트랜잭션이 수행된다
con.commit()
# 데이터 쿼리
cur.execute("""
SELECT
    user_id,
    DATE(SUBSTR(event_time, 1, 10)) AS event_time, -- 날짜로 변경해준다
    price
FROM events
WHERE event_type = 'purchase'
LIMIT 1
""").fetchall()
[('380113376', '2019-12-01', '0.95')]

(사용자id, 날짜, 구매금액) 순으로 하나의 레코드만 살펴보았다. 여기에 추가적으로 필요한 데이터는 이 상태로 데이터를 불러와서 파이썬으로 작업을 해도 되는데, 나는 쿼리를 통해 필요한 데이터를 최대한 만들어두도록 하겠다.

문제는 현재 데이터가 주문당 데이터가 아닌, 개별 품목별 데이터라는 점이다. 따라서 1회 주문에서 A, B, C 세 상품을 구매했어도 세 개의 레코드로 분리되어 저장되어있다. 실제 raw data가 어떤 의미로 개별 저장되었는지는 알 수 없지만, 최대한 ‘주문당’ 데이터가 되도록 사용자별로 같은 (event_time, user_session) 쌍은 하나의 주문으로 취급하도록 하겠다.

cur = con.cursor()

# 데이터 쿼리
results = cur.execute("""
WITH
base AS (
SELECT
    user_id,
    user_session,
    event_time,
    price
FROM events
WHERE event_type = 'purchase'
)
,per_order AS (
SELECT
    user_id,
    user_session,
    event_time,
    SUM(price) AS order_price,
    COUNT(*) AS order_item_cnt
FROM base
GROUP BY user_id, user_session, event_time
)
,rfm AS (
SELECT
    user_id,
    CAST(JulianDay('2020-02-29') - JulianDay(MAX(DATE(SUBSTR(event_time, 1, 10)))) AS INTEGER) AS recency, --SQLite은 datediff 함수가 없다
    COUNT(*) AS frequency,
    SUM(order_price) AS monetary
FROM per_order
GROUP BY user_id
)

SELECT * FROM rfm
""").fetchall()

con.close()

results[:1]
[('101779631', 32, 1, 41.45)]

이제 뽑아낸 데이터를 pandas를 활용해 데이터프레임으로 바꾸고, 각 데이터의 분포를 확인해보자.

import pandas as pd

df = pd.DataFrame(results, columns=[i[0] for i in cur.description])
df.hist()
array([[<AxesSubplot:title={'center':'recency'}>,
        <AxesSubplot:title={'center':'frequency'}>],
       [<AxesSubplot:title={'center':'monetary'}>, <AxesSubplot:>]],
      dtype=object)

png

Recency는 비교적 골고루 분포하고 있고, Frequency는 예상대로 왼쪽으로 매우 치우쳐져 있는 것을 확인할 수 있다.

2) R-F-M 등급 부여하기

이제 개별 사용자에 대해 RFM 등급을 부여한다. 위에서 언급한대로 각각 적용하는 방식이 다르므로, 개별 함수를 만들어서 적용해줄 예정이다.

def recency(r):
    if r <= 7:
        return 5
    elif r <= 14:
        return 4
    elif r <= 21:
        return 3
    elif r <= 28:
        return 2
    else:
        return 1

# 개별 값에 대해 반복하기 때문에 map 함수
df['R'] = df['recency'].map(lambda x: recency(x))
df[['recency', 'R']].head(3)
recency R
0 32 1
1 29 1
2 48 1
def frequency(f):
    # 원본을 건드리지 않기 위해 새로 만듦
    s = pd.Series([0 for _ in range(len(f))])

    # 빈도가 1이면 1등급을 준다
    s[f == 1] = 1
    state = 2
    while state <= 4:
        # 아직 등급이 부여되지 않은 값들에 대해(값이 0), 평균보다 작은 값에 등급을 부여한다
        s[(s == 0) & (f < f[s == 0].mean())] = state
        state += 1

    # 남은 것들은 5등급을 준다
    s[s==0] = 5

    return s

# 전체 값에 대해 접근하기 때문에 Series 전체를 함수에 넘김
df['F'] = frequency(df['frequency'])
df[['frequency', 'F']].head(3)
frequency F
0 1 1
1 1 1
2 3 3
def monetary(m):
    # copy를 만들어서 오름차순 정렬한다
    s = m.copy()
    qc = pd.qcut(s, 5, labels=False)

    return (qc + 1)

df['M'] = monetary(df['monetary'])
df[['monetary', 'M']].head(3)
monetary M
0 41.45 3
1 36.34 3
2 125.97 5

3) RFM 분포 살펴보기

이제 등급 부여가 모두 완료되었다. 완성된 모습을 살펴보자.

df['Cell'] = df['R'].map(str) + df['F'].map(str) + df['M'].map(str)
df.head()
user_id recency frequency monetary R F M Cell
0 101779631 32 1 41.45 1 1 3 113
1 103274988 29 1 36.34 1 1 3 113
2 104808268 48 3 125.97 1 3 5 135
3 107945915 0 4 479.00 5 3 5 535
4 111782974 125 1 6.95 1 1 1 111
df.groupby(['R']).agg({'user_id': 'count'})
user_id
R
1 3328
2 259
3 283
4 254
5 334
df.groupby(['F']).agg({'user_id': 'count'})
user_id
F
1 3242
2 715
3 359
4 96
5 46

예상대로 1등급(1회 구매자) 인원이 60%를 넘는다.

df.groupby(['M']).agg({'user_id': 'count', 'monetary': 'sum'})
user_id monetary
M
1 892 9226.76
2 891 19309.39
3 892 34033.14
4 891 55043.85
5 892 166014.13

4) RFM 시각화

만들어둔 RFM 분류를 잘 활용하기 위해 시각화를 진행해보자. Visualizing RFM Segmentation을 참고하였다.

우선 R-F 행렬을 만들어보자. RFM을 동시에 시각화하면 3차원으로 표현해야 하는데, 그렇게 되면 가독성이 상당히 떨어지기 때문에 대개 2차원으로 살펴본다.

rf_matrix = df.groupby(['R', 'F']).agg({
    'user_id': 'count', 
    'monetary': 'sum'}).reset_index()
rf_matrix['avg_sales'] = rf_matrix['monetary']/rf_matrix['user_id']

이 행렬을 아래처럼 피벗해준다. sort_index를 거꾸로 하여 우측 상단으로 갈수록 좋은 등급을 나타낼 수 있도록 해준다. 피벗 이후에는 색상까지 입힌다. matplotlib으로 시각화를 해도 되지만, 이렇게 행렬 기반 시각화는 판다스 데이터프레임 내에서 하는 게 편해서 그냥 진행했다.

rf_pivot = rf_matrix\
    .pivot(index='F', columns='R', values='user_id')\
    .fillna(0)\
    .sort_index(ascending=False)
    
rf_pivot\
    .style\
    .background_gradient(cmap ='Blues', axis=None, low=0.2)
R 1 2 3 4 5
F
5 13 2 5 13 13
4 32 8 16 20 20
3 192 28 41 43 55
2 472 57 63 46 77
1 2619 164 158 132 169

이 테이블은 R-F 등급별 속한 사용자 수를 나타낸다. 예상대로 1-1이 상당히 많고, 높은 등급으로 갈수록 사용자가 희박해진다. 데이터 수가 적을수록 RFM의 분류를 진행하는 과정도 상당히 껄끄러워지고, 얻어갈 수 있는 인사이트도 줄어든다. 이번 예시에서는 어느정도 규모가 있는 데이터 셋을 사용하였기 때문에 이러한 어려움이 생기지는 않았다.

그 다음으로는 R-F 등급별 평균 구매 금액을 살펴본다. 전체 구매 금액으로 보면 당연히 사용자가 많은 1-1 그룹이 압도적이기 때문에, 평균 구매 금액을 보는 것이다.

rf_pivot = rf_matrix\
    .pivot(index='F', columns='R', values='avg_sales')\
    .fillna(0)\
    .sort_index(ascending=False)
    
rf_pivot\
    .style\
    .background_gradient(cmap ='Blues', axis=None, low=0.2)
R 1 2 3 4 5
F
5 363.550769 262.710000 514.260000 418.566154 316.460769
4 189.534063 403.881250 256.005625 319.256000 298.229000
3 142.825156 133.373929 158.582195 162.031163 153.661091
2 86.742373 84.342281 103.742222 76.111522 92.381688
1 38.107690 41.905305 41.418481 36.477879 38.424497

예상대로 등급이 높아질수록(5-5) 평균적으로 지출하는 금액이 크다. 여기서 몇가지 셀에 대한 추론을 해보자면 아래와 같을 수 있겠다.

  • (R=1, F=5): 한동안 자주 구매하다가 방문한 지 매우 오래된 고객. 재방문 쿠폰을 제시해보자
  • (R=3, F=5): 한동안 자주 구매하다가 방문한 지 오래된 고객. 평균 지출 금액이 가장 크다. 잠재적인 최고 고객이므로 적극적으로 마케팅을 해서 R 등급을 끌어올리자
  • (R=5, F=5): 최근까지도 들어왔고 구매도 자주하는 고객. 충성고객으로 놓쳐서는 안 된다. 꾸준한 관리가 필요

이러한 컨셉은 clevertap이라는 마케팅 솔루션 블로그에서 참고했다. 더 나아가, 커머스마다 해당 패턴은 매우 다양할 것이므로 다양한 해석이 가능해진다.

5) RFM을 가지고 무엇을 해야할까

또 어떤 분석을 할 수 있을까? 결국 세그멘테이션의 한 종류인 RFM의 핵심도 그룹(cell)간의 차이점을 발견하고 그 차이에 맞는 마케팅을 적용하는 것일 것이다.

  • 위에서는 그룹별 방문자수, 평균지출금액만 살펴보았지만 그 외의 요소들을 볼 수도 있다. 앞선 예시처럼 고객별 캠페인 응답률을 시각화하여 그룹별 차이를 볼 수도 있다.
  • 전체적인 윤곽을 보고, 지나치게 몰려있는 셀이 있다면 해당 셀을 drill down하여 원인을 파악해볼 수 있다.
  • 시간에 따른 RFM 분포의 변화를 살펴보고, 캠페인의 방향이 의도대로 가는지 확인할 수 있다.

6. 마치며

RFM 분석에 관해 정리하며 쓰다보니 글이 길어진 것 같다. 그래도 내가 예전에 구현하며 어려움을 겪었던 부분들이 많았기에 해당 부분을 더 집중적으로 다룰 수 있었다. 특히 처음 구현하는 입장에서는 찾아보는 RFM마다 적용하는 방식이 다르기에 뭐가 맞지라는 의문이 많이 드는데, 그러한 부분들을 짚고 넘어갈 수 있어서 만족한다 😎 . 다음에는 세그멘테이션에 관한 좀 더 브로드한 내용을 다루거나 마케팅 성과 측정에 관한 이야기를 해볼까 한다 😙

<분석방법론> 코호트 분석 파헤치기

|
이번 시리즈에서는 실전 데이터 분석을 다룬다. 주로 SaaS 기업에서 행해지는 널리 알려진 여러 기법들을 소개하고, 실제 구현해보는 시간을 갖는다. 코호트 분석(Cohort Analysis)이 그 시작이다.

1. 코호트 분석이란? Cohort Analysis

코호트(동질 집단)란 무엇일까? 특정 기간동안 공통된 특성이나 경험을 공유하는 사용자 집단을 의미한다. 코로나 발생 초기에, 병원에서 코호트 격리를 한다는 기사가 많이 나왔는데 거기서 사용되는 의미와 동일하다.

코호트 분석(동질 집단 분석)은 이러한 유사 성질을 갖는 코호트를 만들어, 시간에 따른 각 코호트의 행동과 여러 지표들을 분석할 때 사용하는 기법이다.

여러 출처로부터 다양한 정의를 내리자면,

  • 코호트 분석은 동종 집단이 나타내는 시간적 변모 양태를 분석하여 예측하고자 하는 연구이다.
  • 코호트 분석은 사용자를 기간에 따라 그룹으로 분류하여 그룹의 행동과 유지율을 분석할 때 활용하는 기법이다.
  • 코호트 분석은 시간 경과에 따라 유저 그룹을 추적하는 방법이다.

여기서 중요한 점은, 특정 기간 경험을 공유한다는 점과, 유사 속성을 갖는다라는 점이다.

코호트 테이블

이제 실제 코호트 분석에 쓰이는 테이블을 살펴보자. 출처는 GA에 있는 샘플 차트이고, 조건은 아래와 같다.

  • 코호트 유형은 ‘획득 날짜’이다. 즉, 최초로 사이트에 접속한 날짜를 기준으로 코호트를 만들겠다는 의미이다.
  • 코호트의 크기는 ‘일별’이다. 일 단위로 코호트를 분리하겠다는 의미이다.
  • 측정 항목은 사용자 유지율이고, 7일간의 데이터를 살펴본다.

Cohort Table

위 이미지에서 행(날짜 데이터)이 코호트이다. 간단히 해석해보자.

  • 4월 10일, 1714명의 사용자가 최초로 접속했다. (사실 정말로 최초는 아닐테지만, 코호트의 시작점이라 그렇다).
  • 4월 10일에 방문한 고객 중, 다음 날(+1일 열) 3.68%의 고객만이 재방문했다(유지되었다).
  • 4월 10일에 방문한 고객 중, 6일 뒤(+6일 열), 0.88%의 고객만이 재방문했다.
  • 4월 14일, 2149명의 사용자가 최초로 접속했다. (신규 유저)
  • 4월 14일에 방문한 고객 중, 다음 날(+1일 열) 3.82%의 고객만이 재방문했다.

그럼 4월 14일에 방문한 총 고객은 몇 명일까? 2149명일까? 정답은 ‘그렇지 않다’이다. 코호트에 표기된 숫자는 새롭게 획득된 사용자 수를 의미한다. 즉, 4월 14일에 새롭게 유입된 고객이 2149명이라는 의미이다. 코호트 테이블만을 보고 4월 14일의 전체 방문자는 알 수 없다. 다만, 코호트 내의 값으로 어느정도 계산은 할 수 있다. 4월 14일의 방문자는 코호트 내의 아래 방문자들로 구성된다.

  • 4월 14일의 신규 유입
  • 4월 13일의 +1일 유저들 (재방문)
  • 4월 12일의 +2일 유저들 (재방문)
  • 4월 11일의 +3일 유저들 (재방문)
  • 4월 10일의 +4일 유저들 (재방문)
  • 그리고 코호트 기간을 벗어나는, 이전 방문자들 중 재방문자들

코호트 테이블의 구현

여기서 짚고 넘어가야 할 부분이 있다. 코호트 분석에서 집계 방식은 크게 두 가지가 있다.

  1. 코호트의 유입 날짜를 기준으로 하는 것
  2. 코호트의 유입 날짜와 직전 기간을 기준으로 하는 것 (Rolling retention)

예를 들어, 사용자 A가 4월 10일의 코호트에 속하는데, 4월 11일과 4월 13일에 방문했다고 해보자. 1번 방식으로는 4월 11일, 4월 13일이 모두 집계되지만 2번 방식에서는 4월 11일이 마지막 집계가 된다. 왜냐하면 4월 12일에는 방문을 하지 않았기 때문에, 직전 기간이 12일이 되는 13일도 집계가 되지 않는 것임.

이에 대한 자세한 내용을 찾아보려 했는데, 구체적으로 다룬 내용을 찾지 못했다. 나중에 찾게 되면 추가하겠음. GA는 1번 방식을 택하고 있다.

2. 코호트 분석의 의미

코호트 분석이 왜 중요한걸까? 개인적으로는 행동 분석(Behavioral Analytics)을 기반으로 하는 분석 기법들이 대부분 동일한 목적을 갖는다고 생각하는데, 데이터를 전체로 보는 것이 아닌 부분으로 나누어 보아야 행동 분석의 의미가 있기 때문이다.

  • 이벤트를 기반으로 데이터를 나누게 되면, 퍼널 분석이나 AARRR과 같이 고객의 각 행동(이벤트)을 단계별로 나누어 각 단계를 면밀히 관찰할 수 있다.
  • 사용자를 기반으로 데이터를 나누게 되면, 일반적인 세그먼트나 세그먼트의 일종인 코호트 분석, RFM 분석처럼 유사한 고객별로 면밀히 관찰할 수 있다.

이벤트를 중심으로 살펴보든, 사용자를 중심으로 살펴보든 유사한 특성을 지닌 단계(그룹)별로 나누어 보는 게 데이터 분석이 훨씬 용이하기 때문이다. 더 나아가, 사용자를 기반으로 타겟을 쪼개고 그 타겟별로 이벤트 기반 분석을 할 수 있다. 즉, 세그먼트별로 퍼널이 어떻게 다른지도 비교해 볼 수 있고 더 세분화된 분석이 가능하다. 이러한 세분화된 분석은 맞춤 마케팅을 가능하게 해준다.

좀 더 직접적인 의미를 찾자면, 코호트 분석은 고객의 유지율(그리고 이탈률)을 분석하는 데 탁월한 지표이다. 사용자가 감소하는 시기를 포착하여 개선방안을 제시할 수 있기 때문이다. 예를 들어, 사용자가 급격히 이탈하는 지점이 발생하면 그 시기에 대한 조사를 통해 문제가 있는 부분을 개선할 수 있고, 푸시 알림 등을 통해 이탈한 고객을 다시 유입시킬 수도 있다.

3. 코호트 분석 해보기

이제 실제 데이터셋을 통해 코호트 테이블을 그려보고, 간단한 분석을 진행해보자.

1) 데이터 소개

캐글에서 데이터를 가져왔고, 여기서 다운로드 받을 수 있다. ‘2019-Oct’ 부터 ‘2020-Feb’ 까지 5개 파일 모두 다운받으면 된다.

이 과정이 귀찮은 사람을 위해 이미 정제한 데이터도 올려두겠다. 여기를 눌러서 다운로드하기

데이터는 화장품을 다루는 이커머스가 출처이며, ‘상품 클릭 view’, ‘장바구니 담기 cart’, ‘장바구니 제외 remove_from_cart’, ‘구매 purchase’ 네 가지 이벤트에 대한 정보를 담고 있다. 아쉬운 점은 단순히 상품 클릭이 아닌, 사이트에 최초 접속한 레코드가 없다는 점인데 그냥 감안하고 진행하도록 하겠다.

2) 데이터 정제

여기에는 2019년 10월부터 2020년 2월까지 총 5개월의 데이터가 담겨있다. 하지만 개별 데이터가 너무 크기 때문에, 각 월별로 고유한 1000명의 데이터만 뽑아서 샘플링한 데이터를 쓴다.

더 구체적으로, 고유한 1000명에 더해 이전 기간의 코호트를 유지하기 위해 앞선 기간의 유저는 그대로 유지하는 방식을 택한다. 즉,

  • 파일별 1000명을 샘플링하여, 해당 1000명의 데이터를 모두 가져온다.
  • 이전 기간의 사용자를 기록해서, 이후 파일에서도 동일한 사용자가 나오면 포함한다.
import pandas as pd
import random

whole_target_pool = set()

for file in ['2019-Dec', '2019-Nov', '2019-Oct', '2020-Feb', '2020-Jan']:
    target = pd.read_csv('{}.csv'.format(file))
    sample_targets = random.sample(list(target.user_id.unique()), 1000)

    whole_target_pool.update(sample_targets)

    # 1000명의 샘플에 있거나, 이미 뽑은 앞선 목록에 있으면 타겟으로 넣는다.
    new_target = target[target.user_id.isin(sample_targets) | target.user_id.isin(whole_target_pool)]

    print(len(new_target.user_id.unique()), new_target.shape)
    new_target.to_csv('{}-1000.csv'.format(file), index=False)
1000 (9418, 9)
1141 (16677, 9)
1241 (17031, 9)
1270 (21948, 9)
1482 (27129, 9)

이제 5개의 파일을 하나로 합쳐서, 최종 파일을 만든다. 앞서 말한대로 이미 정제된 파일은 위에 링크를 달아두었다.

whole_df = pd.DataFrame()

for file in ['2019-Dec', '2019-Nov', '2019-Oct', '2020-Feb', '2020-Jan']:
    target = pd.read_csv('{}-1000.csv'.format(file))
    whole_df = pd.concat([whole_df, target])

print(whole_df.shape)
whole_df.head(3)
(92203, 9)
event_time event_type product_id category_id category_code brand price user_id user_session
0 2019-12-01 04:09:27 UTC view 5724233 1487580005092295511 NaN NaN 14.60 504201396 10c40773-05d3-4fd8-af4c-16e8c9999bf4
1 2019-12-01 05:18:49 UTC view 5900639 1487580005713052531 NaN ingarden 4.44 502359395 8dad7a10-5dc3-4c94-9880-bedbf53eeb1a
2 2019-12-01 05:46:02 UTC view 5651938 1487580012902088873 NaN NaN 6.33 574133915 4c7bf0a0-5f68-414c-b126-93b372b26a65
whole_df.to_csv("ecommerce_cosmetics_sampled.csv", index=False)

3) SQLite에 데이터 쌓기

다른 방식으로 진행해도 되지만, 일반적으로 데이터가 DB에 들어있다고 가정하고 SQL을 통해 코호트 테이블의 기초 데이터를 만들고자 한다.

여기서는 데이터베이스로 SQLite을 사용한다. SQLite은 이름 그대로 정말 라이트하게 사용할 수 있는 SQL DB 엔진이다. 별도의 설정도 필요 없고, 그냥 설치하고 곧바로 파이썬 소스로 데이터를 넣어주면 된다. 나는 맥OS를 쓰는데, 이미 설치가 되어 있어 생략했다. 여기서 최신 버전을 다운받자.

관계형 DB에서 가장 먼저 할 일은, DB 서버에 연결하고, 개별 DB에 접속하는 일이다. SQLite은 별도로 서버 개념이 없기 때문에, 실제로 코드를 돌리는 곳에 데이터를 저장한다.

아래서 쓰인 SQLite 관련 도큐멘테이션은 여기를 참고하자. 별도로 다루지는 않는다.

import sqlite3

# 일반적으로 connect는 host에 연결하는 행위이지만, SQLite은 곧바로 DB에 연결한다.
con = sqlite3.connect('ecommerce.db')

# 명령어를 실행해주기 위한 커서 선언
cur = con.cursor()

# event 테이블을 만들어준다. 일단 모두 text 타입으로 선언한다.
cur.execute("""
    CREATE TABLE events
        (event_time text, event_type text, product_id text, category_id text, category_code text, brand text, price text, user_id text, user_session text)
""")

import pandas as pd

# 아까 만든 데이터를 불러온다.
data = pd.read_csv('ecommerce_cosmetics_sampled_10000.csv')

lists = []
# 튜플 형태로 리스트에 넣어준다
for idx, row in data.iterrows():
    lists.append(tuple([*row.values]))

# executemany를 통해 한 번에 데이터를 넣는다
cur.executemany("""INSERT INTO events VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",lists)

# 트랜잭션을 수행하는 모든 함수는 반드시 커밋을 해줘야 실제 트랜잭션이 수행된다
con.commit()

# 연결을 닫는다
con.close()

이제 데이터가 생성되었는지 확인해 볼 차례다. 위에서 SQLite을 설치했다면, 터미널을 열고 해당 소스를 실행시킨 위치로 이동하자. 즉, ecommerce.db 파일이 생성된 위치로 가면 된다.

해당 위치에서 아래 명령어를 실행한다.

sqlite3 ecommerce.db #이제 sqlite3 DB에 접속했다

# sqlite에서는 시스템 명령어를 쓸 때 '.'을 앞에 붙인다
sqlite> .tables #전체 테이블 목록을 읽어온다
events #아까 생성한 events 테이블이 있어야 정상이다

sqlite> .headers on #출력 시 헤더를 보여주도록 설정을 바꾼다

sqlite> SELECT COUNT(*) FROM events; #테이블의 전체 행 개수를 불러온다
95806 #랜덤 샘플링이기 때문에 값은 약간씩 다르다

sqlite> SELECT * FROM events LIMIT 1;
# 결과가 출력된다

sqlite> .exit #혹은 .quit으로 쉘을 종료한다

데이터가 쌓인 것을 확인했다. 이번 글의 중점은 코호트 분석이므로 데이터를 탐색하고, 들여다보고, 빈 값을 채우는 등의 프로세스는 생략한다.

4) SQL로 데이터 기반 만들기

이제 실제 데이터를 만들어보자. 그 전에, 어떤 코호트 분석을 할 것인지 정하고 가자.

  • 코호트의 유형은? 최초 상품 클릭(view) 기준 코호트
  • 코호트의 크기는? 1주 단위
  • 코호트의 측정 항목은? 유지율
  • 코호트의 전체 기간은? 9주
  • 코호트 집계 방식은? 일반적인 방식. 기준 롤링을 하지 않는다. 즉, 코호트 기준일에만 부합하면 집계한다.

데이터는 어떻게 만들까? 코호트 테이블을 행렬로 볼 때, 각 원소는 (기준 코호트, 기준 코호트로부터의 경과일)의 쌍이라고 할 수 있다.

즉, (코호트1, 코호트1 +1주), (코호트1, 코호트1 +2주)… 를 기준으로 그룹을 만든 것이 코호트 테이블이 된다.

con = sqlite3.connect('ecommerce.db')
cur = con.cursor()

result = con.execute("""
WITH 
base AS (
-- 문자열을 날짜로 바꿔주기 위한 용도
SELECT
    user_id,
    -- '주간'만 빼내는데, 연도가 바뀌면 계산이 틀어지기 때문에 현재 연도에서 가장 낮은 연도인 2019년을 뺀 만큼에 52를 곱한 값을 더해준다
    -- 즉, 2019년 마지막 주는 52가 되고, 2020년의 첫 주는 1 + (2020-2019)*52 = 53이 된다
    STRFTIME('%W', DATE(SUBSTR(event_time, 1, 10))) + (STRFTIME('%Y', DATE(SUBSTR(event_time, 1, 10))) - 2019) * 52 AS event_week
FROM events
WHERE event_type = 'view'
-- 9개의 주간으로 나누기 위해 기간을 제한해준다
AND STRFTIME('%W', DATE(SUBSTR(event_time, 1, 10))) + (STRFTIME('%Y', DATE(SUBSTR(event_time, 1, 10))) - 2019) * 52 <= 47
)
,first_view AS (
-- 우선 사용자별로 최초 유입 월을 찾는다. 이게 코호트가 된다.
SELECT
    user_id,
    MIN(event_week) AS cohort
FROM base
GROUP BY user_id
)
,joinned AS (
-- 기존 데이터에 위에서 찾은 코호트를 조인해준다. 그리고 기존 이벤트 월과 코호트 월의 차이를 빼준다
SELECT
    t1.user_id,
    t2.cohort,
    t1.event_week,
    t1.event_week - t2.cohort AS week_diff
FROM base t1
LEFT JOIN first_view t2
ON t1.user_id = t2.user_id
)

-- (기준 코호트, 기준 코호트로부터의 경과주) 쌍을 만들어 고유한 사용자 수를 센다
SELECT
    cohort,
    week_diff,
    COUNT(DISTINCT user_id)
FROM joinned
GROUP BY cohort, week_diff
ORDER BY cohort ASC, week_diff ASC
""").fetchall()

이제 데이터 추출이 끝났다. 판다스로 가져와서 행렬로 만들고, 추가적인 시각화까지 진행해보자.

5) 코호트 테이블 만들기

# 데이터프레임으로 만들고
# 컬럼의 이름을 바꿔주고
# 피벗 기능을 이용해 코호트 테이블 형태로 만들어준다
# 빈 값은 0으로 채운다
pivot_table = pd.DataFrame(result)\
    .rename(columns={0: 'cohort', 1: 'duration', 2: 'value'})\
    .pivot(index='cohort', columns='duration', values='value')\
    .fillna(0)

pivot_table
duration 0 1 2 3 4 5 6 7 8
cohort
39 3402.0 576.0 456.0 398.0 355.0 248.0 226.0 261.0 253.0
40 2790.0 405.0 296.0 230.0 204.0 185.0 189.0 186.0 0.0
41 2324.0 315.0 198.0 141.0 116.0 140.0 143.0 0.0 0.0
42 2185.0 244.0 147.0 137.0 137.0 136.0 0.0 0.0 0.0
43 2113.0 196.0 114.0 132.0 107.0 0.0 0.0 0.0 0.0
44 2101.0 180.0 193.0 145.0 0.0 0.0 0.0 0.0 0.0
45 1988.0 233.0 148.0 0.0 0.0 0.0 0.0 0.0 0.0
46 2229.0 277.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
47 2323.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0

위 상태는 유지되는 고유 유저수를 보여주고 있기 때문에 비율로 바꾸어 주겠다. 또한 판다스에서 제공하는 background_gradient를 이용해 히트맵을 그려준다.

# 첫 번째 기간으로 나누어 비율로 만들어주고
# %가 나오도록 포맷팅을 해주고
# 색을 입혀준다

round(pivot_table.div(pivot_table[0], axis='index'), 2)\
    .style.format({k: '{:,.0%}'.format for k in pivot_table})\
    .background_gradient(cmap ='Blues', axis=None, vmax=0.2) 
duration 0 1 2 3 4 5 6 7 8
cohort
39 100% 17% 13% 12% 10% 7% 7% 8% 7%
40 100% 15% 11% 8% 7% 7% 7% 7% 0%
41 100% 14% 9% 6% 5% 6% 6% 0% 0%
42 100% 11% 7% 6% 6% 6% 0% 0% 0%
43 100% 9% 5% 6% 5% 0% 0% 0% 0%
44 100% 9% 9% 7% 0% 0% 0% 0% 0%
45 100% 12% 7% 0% 0% 0% 0% 0% 0%
46 100% 12% 0% 0% 0% 0% 0% 0% 0%
47 100% 0% 0% 0% 0% 0% 0% 0% 0%

6) 해석하기. 가져갈 수 있는 의미

이제 코호트 테이블을 읽어보자. 왼쪽의 39는 2019년의 39번째 주를 의미한다. 그리고 오른쪽의 duration에서 1~8은 코호트 주로부터 그만큼 경과한 주를 의미한다.

  • 모든 코호트에서 1기간이 경과했을 때의 유지율은 평균 10%가 조금 넘는다.
  • 코호트39에서 8기간이 경과했을 때의 유지율은 9%이다. 즉 39번째 주에 방문하여 상품을 본 고객 중, 8주 이후에도 똑같이 행동한 고객은 그 중 9%라는 의미이다. (단, 이 고객이 1~7주차 사이에도 그랬는지는 알 수 없다.)
  • 3기간이 경과했을 때의 유지율은 4~8% 정도로 매우 낮은 편이다.

전반적으로, 유지율이 매우 낮은 것을 알 수 있다. 물론 상품을 본(view) 이벤트를 기준으로 하기 때문에 사이트 방문이라고는 볼 수 없지만, 일반적으로 이커머스에서 유지율(재방문율)은 낮아도 20% 정도인 업체가 많다. 이런 경우 대부분 유입 트래픽 광고를 엄청나게 뿌려대지만 당장 그 때만 고객이 유입하고 이후에 리타겟팅이 안 되는 경우가 많다.

또 어떤 데이터를 읽을 수 있을까? 테이블만 봐서는 사실 잘 와닿지 않는다. 우리는 이 분석을 통해 얻어가고자 하는 바를 미리 정의하지 않았기 때문이다. 미리 정의하지 않았기 때문에 코호트 분석을 통해 찾아내고자 하는 내용이 없었고, 그에 맞는 평가 지표도 선정되지 않았다.

7) 또다른 코호트 접근

이번에는 10월 한 달 동안 사용자가 본 브랜드별로 코호트를 만든다고 가정해보자. 마케터인 나는 브랜드별로 사용자의 성향이 다르고, 방문 성향도 다를 것이라고 생각한다. 따라서 브랜드에 따라 코호트를 나누었다.

일반적으로 코호트는 상호배타적(mutually exclusive)이다. 즉, 코호트끼리 겹치지 않는다. 아래 예시에서는 번거로움을 피하고자 그런 과정을 거치지 않았는데, 이 점은 주의하기 바람!
result_2 = con.execute("""
WITH 
base AS (
-- 문자열을 날짜로 바꿔주기 위한 용도
SELECT
    user_id,
    STRFTIME('%W', DATE(SUBSTR(event_time, 1, 10))) + (STRFTIME('%Y', DATE(SUBSTR(event_time, 1, 10))) - 2019) * 52 AS event_week,
    event_type,
    brand
FROM events
-- 9개의 주간으로 나누기 위해 기간을 제한해준다
WHERE STRFTIME('%W', DATE(SUBSTR(event_time, 1, 10))) + (STRFTIME('%Y', DATE(SUBSTR(event_time, 1, 10))) - 2019) * 52 <= 47
AND brand IS NOT NULL
AND event_type = 'view'
AND DATE(SUBSTR(event_time, 1, 10)) >= '2019-10-01'
AND DATE(SUBSTR(event_time, 1, 10)) <= '2019-10-31'
)
,first_view AS (
SELECT
    user_id,
    brand AS cohort,
    MIN(event_week) AS cohort_time
FROM base
GROUP BY user_id, brand
)
,joinned AS (
SELECT
    t1.user_id,
    t2.cohort,
    t1.event_week,
    t1.event_week - t2.cohort_time AS week_diff
FROM base t1
LEFT JOIN first_view t2
ON t1.user_id = t2.user_id
AND t1.brand = t2.cohort
)

SELECT
    cohort,
    week_diff,
    COUNT(DISTINCT user_id)
FROM joinned
GROUP BY cohort, week_diff
ORDER BY cohort ASC, week_diff ASC
""").fetchall()
# 데이터프레임으로 만들고
# 컬럼의 이름을 바꿔주고
# 피벗 기능을 이용해 코호트 테이블 형태로 만들어준다
# 빈 값은 0으로 채운다
pivot_table_2 = pd.DataFrame(result_2)\
    .rename(columns={0: 'cohort', 1: 'duration', 2: 'value'})\
    .pivot(index='cohort', columns='duration', values='value')\
    .fillna(0)\
    .sort_values(by=[0], ascending=False)\
    .iloc[:10, :]

# 상위 10개만 잘랐다
pivot_table_2
duration 0 1 2 3 4
cohort
runail 1641.0 182.0 104.0 67.0 15.0
irisk 1354.0 126.0 77.0 52.0 25.0
masura 840.0 99.0 52.0 26.0 11.0
grattol 730.0 106.0 50.0 29.0 11.0
estel 649.0 28.0 13.0 8.0 3.0
jessnail 595.0 38.0 13.0 6.0 3.0
ingarden 560.0 61.0 28.0 15.0 4.0
kapous 512.0 24.0 14.0 5.0 1.0
bpw.style 467.0 46.0 30.0 13.0 4.0
uno 466.0 34.0 19.0 9.0 5.0
# 첫 번째 기간으로 나누어 비율로 만들어주고
# %가 나오도록 포맷팅을 해주고
# 색을 입혀준다

round(pivot_table_2.div(pivot_table_2[0], axis='index'), 2)\
    .style.format({k: '{:,.0%}'.format for k in pivot_table_2})\
    .background_gradient(cmap ='Blues', axis=None, vmax=0.2) 
duration 0 1 2 3 4
cohort
runail 100% 11% 6% 4% 1%
irisk 100% 9% 6% 4% 2%
masura 100% 12% 6% 3% 1%
grattol 100% 15% 7% 4% 2%
estel 100% 4% 2% 1% 0%
jessnail 100% 6% 2% 1% 1%
ingarden 100% 11% 5% 3% 1%
kapous 100% 5% 3% 1% 0%
bpw.style 100% 10% 6% 3% 1%
uno 100% 7% 4% 2% 1%

이번에는 어떨까? 브랜드 코호트마다 확실히 유지율이 다른 것을 알 수 있다. 특정 브랜드를 찾았던 고객은 계속해서 방문을 하고, 다른 브랜드들은 그렇지 않다. 여기에는 브랜드의 속성이 주는 재구매주기라든지 여러 요소들이 작용할 것이다. 브랜드를 떠나 화장품의 카테고리별로 보는 것도 좋은 분석이 될 수 있다.

4. 마치며

코호트 분석에 대해 정리해두었더 내용들을 글로 옮길 수 있어 마음이 편안하다. 😃 아무쪼록 누구에게든 도움이 되었으면 좋겠고, 앞으로도 데이터 분석과 관련한 글들을 계속해서 작성할 예정이다!

생각중인 주제로는 퍼널 분석, RFM 세그멘테이션, LTV, 전환기여(FirstTouch, LastTouch) 등이 있는데, 다양하게 다루어보도록 하겠다 :)

<에어플로우> 아파치 에어플로우(Airflow)에 대해 알아보기

|
회사에서 태스크를 관리하기 위한 용도로 아파치 에어플로우를 사용했었다. 이 글을 통해 처음부터 시작하며 겪었던 어려움이나 혼동했던 부분들을 공유하고자 한다.

1. 아파치 에어플로우란?

아파치 에어플로우는 Python 기반으로 태스크를 등록하고, 워크플로우를 관리하고, 모니터링하는 도구이다. 간단하게 기능을 요약하자면 다음과 같다.

  • 특정 워크플로우(DAG이라고 한다)를 간편한 파이썬 코드를 통해 등록할 수 있다.
  • DAG 사이의 순서나, 조건부 실행 등 복잡한 플로우를 설계할 수 있다.
  • 또한 Operator를 통해, 다른 프레임워크들과의 통합을 간단하게 해두었다.
  • 등록한 DAG의 스케줄링이 세부적으로 가능하다.
  • 스케일링이 가능하다.
  • 간편한 WebUI를 통해 모니터링과 DAG 관리가 가능하다.

기타 태스크 관리 도구나 단순한 Crontab을 통해 태스크를 관리했던 사람이라면 에어플로우가 가져다주는 이점이 매우 크다는 것을 알 것이다. 일단 최초 세팅이 끝나고 나면, DAG만 기계적으로 추가해주면 되기 때문이다.

하지만, 이 편리함과 더불어 에어플로우를 경험하며 겪게 되는 불편함들도 있는데, 이러한 부분들은 아래에서 다루도록 하겠다.

2. 개념 및 동작 원리 소개

에어플로우에서 주로 쓰이는 개념은 다음과 같다.

DAG

Directed Cyclic Graph의 약자로, 순환하지 않고 방향이 있는 그래프라고 생각하면 된다. 아래 그림은 여기서 따왔다.

01

각 DAG은 에어플로우의 핵심 개념으로써 관련 태스크들을 선언하고, 묶고, 그들간의 흐름을 정리하고, 동작에 관련된 메타 정보를 선언해주는 공간이다.

단일 파이썬 파일 하나가 DAG에 대응되는 개념이며, 해당 파일 내에서 선언해주는 방식으로 진행된다.

자세한 DAG의 선언에 관한 내용은 여기를 읽어보자. 단, 왼쪽 메뉴의 버전을 꼭 확인하자.

Task & Operator

태스크는 DAG 내부에서 실행되는 가장 기본적인 단위이다. 단일 태스크, 또는 태스크들이 모여 DAG을 구성하고, 그들 사이에 의존성이나 실행 규칙 같은 것들을 선언할 수 있다.

태스크의 종류는 크게 세 가지가 있다.

  • Operator: 에어플로우 자체적으로, 또는 프로바이더가 제공해주는 템플릿화된 태스크를 의미한다. 예를 들어 EmailOperator를 사용하면 이메일에 관한 기능을 직접 구현할 필요 없이 쉽게 가져다 쓸 수 있고, MySqlOperator를 사용하면 MySQL에 손쉽게 연결하여 필요한 기능을 쓸 수 있다.
  • Sensor: 오퍼레이터와 비슷하지만, 외부의 이벤트가 발생할 때까지 기다리는 태스크를 의미한다.
  • TaskFlow: 데코레이터 @task 형태로 쓰이며, 사용자 지정 파이썬 함수를 의미한다.

여기서 에어플로우의 편리함이 보이는데, 정말 무수한 종류의 Operator를 통해 편하게 기능 구현을 할 수 있다는 점이다. 해당 목록은 여기서 살펴보자. 카산드라, 드루이드, 하이브, AWS, Azure, GCP 등 수많은 플랫폼에 연결이 가능하다.

동작 원리

hello_world라는 DAG이 있다고 가정하자. DAG은 아래처럼 3개의 태스크로 구성되어 있다. 문법은 고려하지 않고, 그냥 어떤 형태로 되어있는지만 참고하자.

hi = make_hi()
ready_to_say_hi = ready_hi(hi)
say_hi(ready_to_say_hi) 

이때, 에어플로우는 대략적으로 아래와 같은 과정을 거친다.

  1. 스케줄러가 DAG의 선언을 감지하고, 메타DB에 반영한다.
  2. 스케줄러가 해당 시간에 동작해야 하는 hello_world DAG을 발견하고, executor에 전달한다.
  3. executor가 단일 executor로 동작하거나, 분산 처리가 가능할 경우 worker들에게 배분한다.
  4. 최초로 make_hi 태스크를 실행한다. 이때 결과값을 hi라는 변수에 저장하는데, 이를 로컬 머신이 아닌 메타DB에 저장한다. (XCom)
  5. ready_hi가 메타DB에 있는 hi를 다시 가져와서 다음 태스크를 실행한다. 마찬가지로 결과를 또 저장한다.
  6. 똑같은 원리로 say_hi를 실행한다.
  7. 실행 결과를 메타DB에 저장하고, DagRUN을 업데이트한다.

동작에 관한 내용은 이 정도로 하고, 사용하며 겪은 단점과 주의점을 살펴보도록 하겠다. (태스크 관리에 대해 에어플로우만큼의 세분화된 기능을 제공해주는 툴은 사실상 없다고 보기 때문에 장점은 따로 다루지 않겠다. 그냥 전반적인 기능이 주는 편리함!)

3. What airflow IS NOT! 에어플로우에 대한 오해

일단 내가 에어플로우에 가지고 있던 잘못된 오해들을 먼저 다루겠다.

1. 에어플로우는 워크 플로우를 분배해주지, 비동기 분산처리를 해주는 시스템이 아니다.

에어플로우를 도입하기 전에는 Celery를 기반으로 자체적인 태스크 시스템을 구축하여 사용하고 있었다. 이때는 자연스럽게 비동기 호출을 통해 태스크를 비동기로 무수히 많이 찍어냈고, 그런 동작을 사용함에 있어 어려움이 없었다.

그런데 에어플로우로 마이그레이션을 하고 나서, 도통 구현하기 어려운 부분들이 있었다. 예를 들어, 특정 스트림 파이프에서 데이터를 퍼내는 작업을 진행할 때 나는 이 파이프를 빠르게 비워내기 위해 비동기로 여러 워커를 돌리고 싶었다. 예를 들어 데이터가 100덩어리가 있으면 1덩어리씩 넘어가면서 비동기 태스크를 100번 호출하는 식으로 구현을 하려 했는데, 이러한 동작이 불가능했다.

그 이유는 에어플로우의 태스크는 미리 정의된 방식대로 동작하기 때문이다. 다이나믹하게 태스크를 선언하는 것은 기술적으로 가능은 하지만, 개별 태스크 관리가 어려워진다. 동적으로 선언을 했을 때, 실패 시 동작은 어떻게 할 것이며 나중에 WebUI에서 로그를 살필 때도 태스크가 사라질 수 있기 때문에 문제가 된다.

추가적으로, 에어플로우는 자체적으로 비동기 실행을 지원하지 않는다. 다른 파이썬 라이브러리를 통해서는 직접 구현해야만 한다. 비동기를 지원하지 않는 이유는 비동기 태스크들이 허용된다면 정기적으로 돌아야 하는 스케줄된 태스크들의 실행에 영향을 줄 수 있기 때문일 것이다. (동작 slot을 차지하여 제 시간에 실행이 안 되는 등..)

2. 에어플로우는 이벤트 기반 동작을 하지 않는다.

에어플로우는 이벤트를 기반으로 동작하지 않는다. 즉, 서버의 역할처럼 대기를 하고 있다가 특정 요청이 들어왔을 때 태스크를 수행하는 용도가 아니다. (어찌어찌 그렇게 구현할 수는 있겠지만)

에어플로우는 철저히 스케줄링을 위한 시스템이다.

단, 내부 태스크에 대한 이벤트 동작은 존재한다. 위에서 간단히 설명한 Sensor는 다른 Dag이나 Task가 종료됨을 감지하여, 종료 이후 해당 Task가 돌도록 설정할 수 있다.

3. 에어플로우는 데이터를 주고받기 적합한 시스템이 아니다.

위에서 설명한 XCom 시스템을 통해 에어플로우는 데이터를 주고받는다. 알아두어야 할 점은, 파이썬에서 하듯이 태스크의 결과값을 변수에 저장하는 행위 자체가 메타DB에 접근하는 일이라는 사실이다.

즉, A 태스크에서 B 태스크로 변수를 넘기면, 이 정보는 로컬 머신에서 전달되는 것이 아닌, 메타DB를 거쳐서 나온다. 따라서 변수 자체가 이러한 트랜잭션을 늘리는 행위이고, 더 나아가 큰 데이터를 주고받는 순간 에어플로우 자체에서 통신 제한에 걸리거나 그 외 성능 저하 이슈를 겪을 수 있다.

이는 애초에 워커가 분산되어있는 시스템을 가정하고 있기 때문에 데이터의 중재소로써 메타DB를 사용하기 때문이다.

4. 에어플로우의 단점

이번에는 에어플로우의 단점을 살펴보자.

1. 잔버그가 많다. 불친절하다.

에어플로우 사이트에 들어가면 상당히 문서화가 잘 되어 있음을 알 수 있다. 하지만 동시에, 에어플로우를 깊게 파다보면 문서화가 드문드문 되어있고 버전에 잘 맞지 않는 부분들이 있다는 것도 알 수 있다.

더 나아가, 에어플로우를 사용하다보면 알 수 없는 DAGRUN 실패나 메타DB 관련 버그가 종종 일어난다. 특히 메타DB 관련 버그는 고치기도 어렵기 때문에 초기화가 가능한 상태면 그냥 초기화하곤 했다.

물론 에어플로우에 대한 이해가 부족하여 버그를 더 많이 겪은 걸 수 있지만, 에어플로우 깃헙을 들어가보면 2.x 이상의 버전에서 에러 케이스가 많고, 현재 알려진 버그도 상당히 많다.

2. 워커간에 코드 동기화가 번거롭다.

에어플로우 자체적으로 워커 A와 B 사이의 코드를 동기화해주는 기능이 없다. 따라서 깃이나 기타 배포 서비스를 통해 두 워커의 DAG 코드를 일치시켜주어야 한다.

3. (놀랍게도) 로그 관리를 직접 해주어야 한다.

에어플로우는 크게 두 종류의 로그를 만들어낸다.

  1. 메타DB에 쌓이는 로그
  2. 로컬 머신에 쌓이는 로그

정말 놀랍게도, 이 두 로그는 자동으로 로테이트되지 않는다. 기간이 지나면 자동으로 삭제되지 않고, 사용자가 직접 제거해주어야 한다.

1번의 메타DB는 airflow-maintenance-dags라는 커스텀 DAG을 사용하면 되고, 2번은 logrotate 와 같은 툴을 사용하면 된다.

로그를 제거하지 않으면 용량이 터지는 경우가 무조건 발생하기 때문에 꼭 사전에 세팅해주고, 잘 제거가 되는지 확인해주자.

4. 태스크가 많아지면 메타DB와 WebUI가 버벅거린다.

예전에 고객사별로 태스크를 관리할 필요가 있었는데, 그렇게되면 개별 DagRUN에 200~300개의 태스크가 생기곤 했었다. 이 경우, 돌고있는 수만큼 태스크가 메타DB에 연결하기 때문에 메타DB의 부하가 커지고, 이를 UI로 확인하려고 해도 WebUI도 버벅거리게 된다.

이는 단점이라기보다도 에어플로우의 사용에 적합하지 않은 경우가 아니었나 생각하지만, 그래도 생각난 김에 적어보았다.

5. 마무리하며

에어플로우는 분명히 좋은 툴이다. 간단한 코드 몇 줄로 태스크 사이의 의존성을 나타낼 수 있고, 스케줄링, 로그까지 남겨주기 때문에 간편하다. 다만 사용함에 있어 꼭 알아야 할 부분들이 잘 전달되어 있지 않은 것도 사실이다.

내가 에어플로우를 처음 접할 때 너무나도 궁금했던 내용들이었는데, 쉽게 정보를 구할 수 없었다. 그동안의 의미있는 삽질을 통해, 구글링을 통해, 에어플로우 슬랙을 통해 알게된 내용들을 이렇게 정리하여 공유할 수 있어 기쁘다. 😙