본문으로 건너뛰기
데이터분석가 취준생을 위한 실전 가이드 · 13/13시리즈 보기 →

신규 고객 4,334명이 이전 기록을 합치자 1,613명이 됐습니다: 윈도우 함수와 리텐션의 조용한 오류

· 약 12분
Datapopcorn CEO / AI automation educator

온라인몰 거래 데이터로 월별 신규 고객의 다음 달 재구매율을 SQL로 계산했더니, 에러 없이 나온 숫자 곳곳이 틀려 있었습니다. PARTITION BY 하나를 빠뜨리자 고객 4,334명의 첫 구매 월이 모두 같아졌고, 데이터가 시작하는 첫 달의 ‘신규’ 884명 중 808명은 사실 기존 고객이었습니다. 관측할 수 있는 이전 기록까지 합쳐 다시 세면 같은 기간의 신규 고객은 1,613명이고, 첫 달 코호트의 재구매율은 36.5%가 아니라 9.2%입니다. 복잡한 쿼리일수록 에러 없이 틀립니다.

이전 글에서 만든 SQLite 데이터베이스(retail.db)를 그대로 씁니다. SQLite는 3.25 버전부터 윈도우 함수를 지원하고, 최근 Python에 포함된 버전이면 충분합니다.

윈도우 함수는 행을 남긴 채로 묶어서 계산합니다​

GROUP BY는 여러 행을 묶어 한 행으로 줄입니다. 고객별 합계를 구하면 고객 수만큼의 행만 남습니다. 반면 윈도우 함수는 행을 그대로 두고, 각 행 옆에 묶음 단위의 계산 결과를 붙입니다.

-- GROUP BY: 고객마다 한 행
SELECT customer_id, MIN(ym) FROM p GROUP BY customer_id;

-- 윈도우 함수: 원래 행은 그대로, 각 행에 '그 고객의 첫 구매 월'이 붙음
SELECT customer_id, ym, MIN(ym) OVER (PARTITION BY customer_id) AS first_ym FROM p;

OVER (...) 안의 PARTITION BY customer_id가 “고객별로 따로 계산하라”는 뜻입니다. 순번을 매기는 ROW_NUMBER(), 누적 합계를 구하는 SUM() OVER (ORDER BY ...)도 같은 원리입니다. 이전 글에서 상품마다 가장 자주 쓰인 설명 하나를 고를 때 쓴 것도 ROW_NUMBER() OVER (PARTITION BY stock_code ...)였습니다.

리텐션은 분모와 분자를 먼저 적습니다​

이 글에서 계산할 지표를 먼저 정의합니다.

  • 코호트(cohort): 첫 구매 월이 같은 고객 묶음. 2011년 1월에 처음 산 고객은 ‘2011-01 코호트’입니다.
  • 다음 달 재구매율: 분모는 그 코호트의 고객 수, 분자는 그중 바로 다음 달에 한 번 이상 산 고객 수입니다.
  • 판매로 볼 줄: 고객 번호가 있고, 취소(C)·조정(A) 송장이 아니며, 수량과 단가가 양수인 상품 줄입니다.

이 세 줄이 없으면 같은 데이터로도 전혀 다른 숫자가 나옵니다. 아래에서 실제로 보여드립니다.

함정 1: PARTITION BY를 빠뜨리면 모두가 같은 코호트가 됩니다​

고객-월 단위로 줄인 표(p, 한 행 = 한 고객이 그 달에 한 번 이상 샀다)에서 첫 구매 월을 구했습니다.

query first_ym customers
0 PARTITION BY 있음 2010-12 884
1 PARTITION BY 있음 2011-01 416
2 PARTITION BY 있음 2011-02 380
3 PARTITION BY 있음 2011-03 452
4 PARTITION BY 있음 2011-04 300
5 PARTITION BY 있음 2011-05 284
6 PARTITION BY 있음 2011-06 242
7 PARTITION BY 있음 2011-07 187
8 PARTITION BY 있음 2011-08 169
9 PARTITION BY 있음 2011-09 299
10 PARTITION BY 있음 2011-10 357
11 PARTITION BY 있음 2011-11 323
12 PARTITION BY 있음 2011-12 41
13 PARTITION BY 없음 2010-12 4334

PARTITION BY customer_id를 지우고 MIN(ym) OVER ()로 쓰면, 표 전체를 하나의 묶음으로 보고 전체의 최솟값 2010-12를 모든 행에 붙입니다. 4,334명 전원이 2010년 12월 코호트가 됩니다. 쿼리는 멀쩡히 실행되고 결과 형태도 그럴듯해서, 코호트 크기를 한 번 세어 보지 않으면 알아채기 어렵습니다.

함정 2: 마지막 달은 아직 관측이 끝나지 않았습니다​

PARTITION BY를 제대로 넣고 코호트별 다음 달 재구매율을 계산했습니다.

WITH p AS (SELECT DISTINCT customer_id, substr(invoice_date, 1, 7) AS ym
FROM order_lines WHERE /* 판매로 볼 줄 */ ...),
first AS (SELECT customer_id, MIN(ym) AS cohort FROM p GROUP BY customer_id)
SELECT f.cohort,
COUNT(DISTINCT f.customer_id) AS new_customers,
COUNT(DISTINCT p.customer_id) AS bought_next_month,
ROUND(100.0 * COUNT(DISTINCT p.customer_id) / COUNT(DISTINCT f.customer_id), 1) AS pct
FROM first f
LEFT JOIN p ON p.customer_id = f.customer_id
AND p.ym = strftime('%Y-%m', f.cohort || '-01', '+1 month')
GROUP BY f.cohort ORDER BY f.cohort;

위 쿼리는 구조를 보여주려고 판매 조건을 줄여 쓴 것입니다. 실제 조건까지 넣은 실행 코드는 글 끝의 전체 코드에 있습니다.

cohort new_customers bought_next_month pct
0 2010-12 884 323 36.5
1 2011-01 416 91 21.9
2 2011-02 380 71 18.7
3 2011-03 452 67 14.8
4 2011-04 300 63 21.0
5 2011-05 284 54 19.0
6 2011-06 242 42 17.4
7 2011-07 187 33 17.6
8 2011-08 169 34 20.1
9 2011-09 299 70 23.4
10 2011-10 357 84 23.5
11 2011-11 323 36 11.1
12 2011-12 41 0 0.0

표 아래쪽을 보면 2011-11 코호트는 11.1%, 2011-12 코호트는 0.0%입니다. 이 두 값은 실제 재구매율이 아닙니다. 고객들이 갑자기 떠난 걸까요? 데이터 기간을 확인하면 답이 나옵니다.

first_row last_row
0 2010-12-01 08:26:00 2011-12-09 12:50:00

데이터는 2011년 12월 9일 낮에 끝납니다. 11월 코호트의 ‘다음 달’은 9일 치만 관측됐고, 12월 코호트의 다음 달인 2012년 1월은 데이터에 아예 없습니다. 그런데 LEFT JOIN 뒤 COUNT는 짝이 없으면 0을 돌려주기 때문에, “관측할 수 없음”이 “재구매 0%”로 둔갑합니다. 이 표를 그래프로 그리면 마지막 두 달에 고객 이탈이 급증한 것처럼 보입니다.

다음 달이 온전히 관측되지 않은 코호트는 표에서 빼거나, 값 대신 ‘관측 중’으로 표시해야 합니다.

함정 3: 첫 달의 ‘신규’는 신규가 아닙니다​

표 맨 위의 2010-12 코호트는 884명, 재구매율 36.5%로 가장 높습니다. 2011년 코호트 중 최고치인 23.5%보다 13.0%p 높습니다. 이 데이터가 2010년 12월 1일부터 시작하기 때문에, 그 달에 산 모든 고객이 ‘첫 구매’로 잡힌 것입니다. 같은 파일의 이전 시트(2009~2010년)와 대조했습니다.

dec_2010_new bought_before
0 884 808

884명 중 808명(91%)은 2010년 12월 이전에 이미 산 적이 있는 고객입니다. 오래 거래해 온 단골이 신규로 섞였으니 재구매율이 높게 나온 겁니다. 이전 시트까지 합쳐, 관측할 수 있는 기록 안에서의 첫 구매 월로 다시 계산했습니다. 두 시트는 2010년 12월 1~9일이 겹치므로, 이전 시트는 12월 1일 전까지만 씁니다.

cohort new_customers bought_next_month pct
0 2010-12 76 7 9.2
1 2011-01 72 12 16.7
2 2011-02 125 20 16.0
3 2011-03 179 32 17.9
4 2011-04 106 26 24.5
5 2011-05 111 26 23.4
6 2011-06 108 25 23.1
7 2011-07 101 21 20.8
8 2011-08 107 28 26.2
9 2011-09 188 51 27.1
10 2011-10 221 70 31.7
11 2011-11 191 27 14.1
12 2011-12 28 0 0.0

차이가 큽니다.

코호트한 시트만 사용이전 기록까지 합침
2010-12884명, 36.5%76명, 9.2%
2011-01416명, 21.9%72명, 16.7%
2011-10357명, 23.5%221명, 31.7%
13개월 신규 고객 합계4,334명1,613명

(2011-11, 2011-12 코호트는 다음 달이 온전히 관측되지 않아 비교에서 뺐습니다.)

한 시트만 쓰면 기간 전체에서 신규 고객이 4,334명이지만, 2009년 12월부터의 기록으로 보면 이 기간에 처음 산 고객은 1,613명입니다. 2011년 1월 ‘신규’ 416명 중 344명도 2010년 12월에만 사지 않았을 뿐 그 전에 거래한 기존 고객이었습니다. 코호트 간 비교도 달라집니다. 한 시트만 보면 첫 달 코호트의 재구매율이 가장 높지만, 이전 기록까지 합치면 다음 달이 온전히 관측되는 2010-12~2011-10 코호트 중 첫 달이 9.2%로 가장 낮고 2011년 10월이 31.7%로 가장 높습니다.

다만 이것으로 경계 문제가 사라진 건 아닙니다. 합친 데이터도 2009년 12월에 시작하므로, 그 전에 산 고객은 여전히 알 수 없습니다. 1,613명도 ‘관측 가능한 기록 기준 신규’이지 절대적인 신규 고객 수는 아닙니다. 경계는 없앨 수 없고, 분석 기간보다 앞선 기록을 충분히 확보해 경계를 분석 기간 밖으로 밀어내는 것이 최선입니다.

함정 4: 분모를 바꾸면 다른 지표가 됩니다​

“다음 달 재구매율”을 그 달에 산 고객 전체를 분모로 계산하면 이렇게 나옵니다.

ym active bought_next_month pct
0 2011-01 739 260 35.2
1 2011-06 990 365 36.9

같은 2011년 1월인데 신규 고객 기준(한 시트) 21.9%, 전체 구매 고객 기준 35.2%입니다. 6월은 17.4%와 36.9%로 두 배 넘게 차이 납니다. 둘 다 맞는 계산이지만 서로 다른 지표입니다. 전자는 “새로 온 고객이 얼마나 남는가”, 후자는 “이번 달 고객 중 다음 달에도 오는 비율”입니다. 보고서에 ‘재구매율’이라고만 쓰면 읽는 사람은 어느 쪽인지 알 수 없습니다.

검증: 한 코호트를 손으로 세어 봅니다​

복잡한 쿼리는 전체를 한 번에 믿지 말고 한 조각을 떼어 다른 방법으로 세어 봅니다. 2011-01 코호트를 pandas로 직접 계산해 SQL 결과와 대조했습니다.

pandas: 416 명 중 91 명 (21.9%) | SQL: [[416.0, 91.0, 21.9]]

416명 중 91명으로 정확히 같습니다. 다만 이 검증이 확인해 주는 건 “쿼리가 정의대로 계산했다”는 것까지입니다. pandas 쪽도 같은 시트만 봤기 때문에, 첫 달 경계 문제는 이 방법으로는 잡히지 않습니다. 그래서 **계산 검증(손으로 세기)과 정의 검증(경계 월, 기간 밖 기록 확인)**을 따로 해야 합니다.

리텐션 쿼리 점검표​

점검방법
코호트가 제대로 나뉘었나코호트별 고객 수를 세어 한 달에 몰리지 않았는지 확인 (PARTITION BY 누락)
분모·분자가 무엇인가쿼리 옆에 한 줄로 적기. 신규 고객 기준인지, 전체 구매 고객 기준인지
마지막 달데이터 마지막 날짜를 확인하고, 다음 달이 온전히 관측되지 않은 코호트는 빼거나 표시
첫 달데이터 시작 전 기록이 있는지 확인. 없으면 첫 달 코호트는 ‘신규’로 해석하지 않기
계산한 코호트를 다른 도구로 직접 세어 대조

AI에게 리텐션 쿼리를 맡길 때​

월별 신규 고객의 다음 달 재구매율을 구하는 SQL을 짜 줘. 코호트와 비교 월을 어떻게 정했는지, 분모와 분자가 각각 무엇인지 적어 주고, 데이터가 시작하는 달과 끝나는 달에서 값이 어떻게 왜곡될 수 있는지도 설명해 줘. 코호트별 고객 수를 확인하는 점검 쿼리도 같이 줘.

면접에서 나올 수 있는 질문​

윈도우 함수와 GROUP BY의 차이는요? GROUP BY는 행을 묶어 줄이고, 윈도우 함수는 행을 그대로 둔 채 묶음 단위 계산 결과를 각 행에 붙입니다. 순위, 누적 합계, 고객별 첫 구매일처럼 행마다 맥락이 필요한 계산에 씁니다.

리텐션 그래프 마지막 달이 급락했습니다. 무엇부터 확인하겠습니까? 데이터 마지막 날짜입니다. 다음 달이 온전히 관측되지 않았으면 급락은 이탈이 아니라 관측 부족입니다. 이 데이터에서는 11월 코호트 11.1%, 12월 코호트 0.0%가 그 예입니다.

데이터 첫 달의 신규 고객 수가 유난히 많다면요? 데이터 시작 전에 이미 거래한 고객이 신규로 잡혔을 가능성이 큽니다. 이전 기록과 대조하거나, 첫 달 코호트는 분석에서 빼고 해석합니다. 이 데이터에서는 첫 달 ‘신규’ 884명 중 808명이 기존 고객이었습니다.

전체 코드​

이전 글의 build_retail_db.py로 만든 retail.db가 있는 폴더에서 실행합니다.

import sqlite3
import pandas as pd

pd.set_option("display.width", 200)
con = sqlite3.connect("retail.db")
q = lambda sql: pd.read_sql(sql, con)

# 판매로 볼 줄: 고객 번호 있음, 취소(C)·조정(A) 아님, 수량·단가 양수, 상품 코드
SALE = """customer_id IS NOT NULL AND invoice NOT LIKE 'C%' AND invoice NOT LIKE 'A%'
AND quantity > 0 AND price > 0 AND stock_code GLOB '[0-9][0-9][0-9][0-9][0-9]*'"""
# 고객-월 단위로 줄인 표 (한 행 = 한 고객이 그 달에 한 번 이상 샀다)
P = f"SELECT DISTINCT customer_id, substr(invoice_date, 1, 7) AS ym FROM order_lines WHERE {SALE}"

print("[1] 첫 구매 월: PARTITION BY 있음 vs 없음")
print(q(f"""
WITH p AS ({P})
SELECT 'PARTITION BY 있음' AS query, first_ym, COUNT(DISTINCT customer_id) AS customers
FROM (SELECT customer_id, MIN(ym) OVER (PARTITION BY customer_id) AS first_ym FROM p)
GROUP BY first_ym
UNION ALL
SELECT 'PARTITION BY 없음', first_ym, COUNT(DISTINCT customer_id)
FROM (SELECT customer_id, MIN(ym) OVER () AS first_ym FROM p)
GROUP BY first_ym"""), "\n")

COHORT = f"""
WITH p AS ({P}),
first AS (SELECT customer_id, MIN(ym) AS cohort FROM p GROUP BY customer_id)
SELECT f.cohort,
COUNT(DISTINCT f.customer_id) AS new_customers,
COUNT(DISTINCT p.customer_id) AS bought_next_month,
ROUND(100.0 * COUNT(DISTINCT p.customer_id) / COUNT(DISTINCT f.customer_id), 1) AS pct
FROM first f
LEFT JOIN p ON p.customer_id = f.customer_id
AND p.ym = strftime('%Y-%m', f.cohort || '-01', '+1 month')
GROUP BY f.cohort ORDER BY f.cohort"""
print("[2] 신규 고객의 다음 달 재구매율 (2010-2011 시트만 사용)")
cohort = q(COHORT)
print(cohort, "\n")

print("[3] 데이터 기간과 경계")
print(q("SELECT MIN(invoice_date) AS first_row, MAX(invoice_date) AS last_row FROM order_lines"))
print(q(f"""
SELECT COUNT(*) AS dec_2010_new, SUM(customer_id IN (
SELECT customer_id FROM order_lines_prev
WHERE {SALE} AND invoice_date < '2010-12-01')) AS bought_before
FROM (SELECT DISTINCT customer_id FROM order_lines
WHERE {SALE} AND substr(invoice_date, 1, 7) = '2010-12')"""), "\n")

print("[4] 이전 시트까지 합쳐 관측 가능한 기록 안의 첫 구매 월로 다시 계산 (겹치는 12월 1~9일은 한 번만)")
print(q(f"""
WITH p AS (
{P}
UNION
SELECT DISTINCT customer_id, substr(invoice_date, 1, 7) FROM order_lines_prev
WHERE {SALE} AND invoice_date < '2010-12-01'
),
first AS (SELECT customer_id, MIN(ym) AS cohort FROM p GROUP BY customer_id)
SELECT f.cohort,
COUNT(DISTINCT f.customer_id) AS new_customers,
COUNT(DISTINCT p.customer_id) AS bought_next_month,
ROUND(100.0 * COUNT(DISTINCT p.customer_id) / COUNT(DISTINCT f.customer_id), 1) AS pct
FROM first f
LEFT JOIN p ON p.customer_id = f.customer_id
AND p.ym = strftime('%Y-%m', f.cohort || '-01', '+1 month')
WHERE f.cohort BETWEEN '2010-12' AND '2011-12'
GROUP BY f.cohort ORDER BY f.cohort"""), "\n")

print("[5] 분모를 바꾸면: 그 달에 산 고객 전체 중 다음 달에도 산 비율")
print(q(f"""
WITH p AS ({P})
SELECT a.ym,
COUNT(DISTINCT a.customer_id) AS active,
COUNT(DISTINCT b.customer_id) AS bought_next_month,
ROUND(100.0 * COUNT(DISTINCT b.customer_id) / COUNT(DISTINCT a.customer_id), 1) AS pct
FROM p a LEFT JOIN p b
ON b.customer_id = a.customer_id AND b.ym = strftime('%Y-%m', a.ym || '-01', '+1 month')
WHERE a.ym IN ('2011-01', '2011-06')
GROUP BY a.ym"""), "\n")

print("[6] 검증: 2011-01 코호트를 pandas로 직접 세기")
lines = q(f"SELECT customer_id, substr(invoice_date, 1, 7) AS ym FROM order_lines WHERE {SALE}")
first = lines.groupby("customer_id")["ym"].min()
jan = set(first[first == "2011-01"].index)
feb = set(lines.loc[lines["ym"] == "2011-02", "customer_id"])
print("pandas:", len(jan), "명 중", len(jan & feb), "명", f"({len(jan & feb) / len(jan):.1%})",
"| SQL:", cohort.loc[cohort["cohort"] == "2011-01", ["new_customers", "bought_next_month", "pct"]].values.tolist())

참고 자료​

실행 환경은 Python 3.9.6, pandas 2.3.3, SQLite 3.51.0입니다.