운영 대시보드의 주간 점검 화면을 보던 기획 쪽에서 질문이 왔다.
“유료 회원이 1,000명인데 화면 모수는 800명이에요. 유료 회원보다 모수가 작으면 이상한 거 아닌가요?”
(이 글의 숫자는 전부 설명용으로 지어낸 값이다.) 틀린 숫자는 없었다. 둘이 다른 질문에 답하고 있었을 뿐이다.
상황: ‘사용률’의 분모가 둘
current : users of this content / all users who studied this week
expected: paid users of this content / all paid users this week (studied or not)
분모가 “그 주 학습한 회원”이면 질문은 “학습하러 온 사람 중 몇 %가 이걸 골랐나”다. 콘텐츠끼리 매력을 비교할 때 맞다. 분모가 “유료 회원 전체”면 질문은 “돈 내는 사람 중 몇 %에게 이게 닿았나”다. 결제한 만큼 쓰이고 있는지 볼 때 맞다.
| 유료 회원 | 그 주 학습 | 콘텐츠 A 이용 | 학습자 기준 | 유료 전체 기준 | |
|---|---|---|---|---|---|
| 1주차 | 1,000 | 800 | 200 | 25% | 20% |
| 2주차 | 1,000 | 600 | 180 | 30% ↑ | 18% ↓ |
학습하러 온 회원은 줄었는데 남은 사람이 A 를 많이 써서 학습자 기준은 올랐다. 유료 전체로 보면 A 가 닿은 사람은 줄었다. 같은 이름의 지표가 같은 주에 반대로 움직인다.
분모를 섞으면 생기는 왜곡
더 큰 문제는 원래 분모에 여러 종류의 회원이 섞여 있었다는 점이다.
learners this week = (paid & learned) + (trial & learned) + (others & learned)
paid roster = (paid & learned) + (paid & idle)
학습자 모수에는 체험 회원과 유료 명단에 없는 회원(테스트 계정, 교사 접속 등)이 들어 있고, 학습 안 한 유료 회원은 빠져 있다. “유료 명단 > 모수”는 당연한 결과였다. 이런 분모를 섞어 두면 비교가 조용히 틀어진다.
- 유료 사용률(분모: 유료 전체)과 체험 사용률(분모: 체험 중 학습자)을 나란히 두면 체험 쪽은 비학습자가 빠진 분모라 높게 나온다. “체험 회원이 더 열심히 쓴다”는 착시다.
- 학습 기록에서 뽑은 모수로 “미이용자”를 세면 모수가 이미 뭔가를 쓴 사람이라 미이용자가 거의 없다. 한두 명 차이로 비율이 요동친다.
- 구독이 끝나면 안 생기는 기록에서 뽑은 모수는 구독 종료 회원이 조용히 빠진다. 실제로 이렇게 원천이 다른 세 계열이 한 표에 “미이용자(명)”라는 같은 라벨로 나란히 있던 적이 있다. 라벨이 같아도 모수가 다르면 다른 숫자다.
회원을 어떻게 나눴나
분모를 “유료 회원 전체”와 “체험 회원 전체” 두 개로 나눴다. 둘 다 학습 여부와 무관하다.
- 유료 = 결제 시스템에서 매일 받아 두는 유료 회원 명단에 그 주 하루라도 있던 회원. 구독 데이터의 회원 유형으로 정하지 않은 건, 구독상 정회원과 결제가 확인되는 회원 사이에 꽤 차이가 났기 때문이다.
- 체험 = 체험 구독 기간이 그 주와 겹치고 실제로 시작한(진행 중 · 종료) 회원. 시작 전 상태는 뺐다.
- 겹치면 유료로만. 체험 중 그 주에 결제한 회원이 두 분모에 다 들어가면 합이 맞지 않는다. 체험 집합에서 유료 집합을 뺀다.
- 테스트 계정은 둘 다에서 뺀다.
-- 그 주 체험 모수 (유료와 겹치는 회원은 애플리케이션에서 뺀다)
SELECT DISTINCT s.user_id
FROM trial_subscription s JOIN users u ON u.id = s.user_id
WHERE s.state IN ('ACTIVE', 'ENDED')
AND s.begin_date <= :weekEnd AND (s.finish_date IS NULL OR s.finish_date >= :weekStart)
AND u.account_kind NOT IN ('TEST', 'STAFF');
결제 명단에 없는 정회원 구독, 테스트 계정, 교사 접속은 어느 모수에도 들어가지 않는다. 일부러 뺐고, 뺐다고 화면 도움말에 적었다. 구분이 더 많은 인기 콘텐츠 화면에서는 우선순위를 정해, 한 회원의 하루가 한 구분에만 들어가게 했다.
test account > teacher login > paid roster > trial > subscribed but not on roster
분류 신호는 쓰기 전에 교차표로 본다
테스트 계정을 가르던 옛 규칙 중에 “배송 대상 아님” 플래그를 테스트 신호로 쓰는 게 있었다. 실제 데이터로 교차표를 내 보니 학습자 대부분에게 붙어 있었다. 배송을 그만둔 뒤로 신규 가입자 거의 전부에 붙는 값이었다. 그대로 썼으면 진짜 회원 대부분을 테스트로 뺄 뻔했다.
분모 = 모수 전체, 분자 = 모수 안의 이용자
for (Population p : List.of(Population.PAID, Population.TRIAL)) {
Optional<Set<Long>> members = membersOf(p, week);
if (members.isEmpty()) continue; // 명단을 못 구함 -> 이 모수는 건너뜀 (지우지 않음)
for (ContentUsage c : usages) {
long users = c.userIds().stream().filter(members.get()::contains).count();
rows.add(new WeeklyRow(week, p, c.contentId(), users, members.get().size()));
}
}
계획 학습 완료율처럼 원래 분모가 “편성받은 회원”인 지표는 분모를 바꾸지 않고 모수로 좁히기만 했다. 지표마다 답하는 질문은 그대로 두고 대상 집단만 고르는 것이다. KPI 첫 칸에는 “모수 n명 · 그중 그 주 학습 m명”을 같이 띄워 두 질문 모두에 답하게 했다.
저장: 모수별로 행을 따로
주간 집계 테이블에 모수 칸을 넣고 PK 에 포함했다.
ALTER TABLE content_week_stat
ADD COLUMN population VARCHAR(8) NOT NULL DEFAULT 'LEARNER',
DROP PRIMARY KEY,
ADD PRIMARY KEY (week_begin, content_id, learn_mode, population);
기존 행은 LEARNER(그 주 학습자 기준)로 남는다. 이 보기는 화면에서 뺐지만 옛 호출은 그대로 동작하고, 필요하면 다시 꺼낼 수 있다. 비율이 아니라 분자와 분모를 둘 다 저장해 나중에 다시 계산할 수 있게 했다.
못 구한 주는 지우지 않는다
- 유료 명단을 못 구했거나 체험 회원 API 가 실패한 주 → 그 모수는 건너뛴다. 직전 값이 남는다.
- 체험 회원이 정말 0명인 주 → 그 주 체험 행만 지운다.
“0명”과 “모름”은 다르다. 실패를 0 으로 저장하면 사용률이 0% 로 찍혀 진짜 급락처럼 보인다. 명단 보관이 시작되기 전의 주는 비워 두고 화면에 이유를 안내했다.
배포 순서도 분모 문제였다. 화면을 먼저 내면 옛 집계 서버는 모수 파라미터를 모르니 무시하고 학습자 기준 값을 돌려준다. 그 숫자가 “유료 회원” 라벨을 달고 나온다. 그래서 DDL → 집계 서버 → 화면 → 백필 순서로 고정했다.
저장은 잘게, 분류는 조회 때
인기 콘텐츠 화면에서는 “계획 학습 vs 스스로 학습”을 나눠야 했는데 정의가 확정되지 않았다. 메뉴로 나눌지, “그날 계획에 없던 콘텐츠”로 나눌지. 그래서 학습 경로를 잘게 저장하고 묶는 건 조회 때 했다.
route menu relation to plan
1 plan plan date = study date
2 plan past plan (catch-up)
3 plan future plan (ahead)
4 free in today's plan
5 free in another day's plan
6 free not in any plan
“계획 학습 = 1~4, 스스로 학습 = 5~6” 은 코드 상수 한 곳에만 있다. 정의가 바뀌면 상수만 바꾸고, 저장된 행은 그대로다. 회원 구분도 같다. 행마다 계정 유형, 학습 당시 구독 유형, 그날 유료 명단에 있었는지를 같이 저장했다. 나중에 “정회원은 구독 유형이 아니라 결제 명단 기준”으로 정의가 바뀌었을 때 조회 매핑만 바꿨다.
반면 처음에 저장하지 않았던 “교사 접속” 신호는 재집계로 채워야 했다. 저장하지 않은 신호는 나중에 공짜로 생기지 않는다. 원천 보관 기간이 짧은 값(예: 한 달만 남는 명단 이력)은 더 조심했다. 명단을 못 받는 날은 NULL(모름)로 두고, 다시 집계할 때 기존 값을 이어 써서 정보를 잃지 않게 했다.
화면: 모수를 항상 보이게
처음엔 기준을 툴팁(?)과 차트 아래 회색 한 줄에 적었다. 반응은 “어떤 영역인지 모르겠다”였다. 같은 유료 명단을 화면마다 “유료 명단”, “활성 이용자”로 다르게 불렀고, 박스 색은 모수와 상관없이 섞여 있었다.
모수 계열마다 고정된 이름 · 색을 붙였다. 유료 회원 명단(청록) · 전체 이용자(보라) · 가입 요청(주황), 나중에 체험 회원 색을 하나 더했다.
- 차트 헤더 배지, 박스 위 3px 색 띠, 하단 기준 줄 앞에 같은 배지, 요약 카드에도 배지
- 화면 맨 위에 “이 화면의 모수” 범례 — 그 화면에 나오는 계열만, 한 줄 정의와 함께
이제 색이 곧 모수다. 주간 점검 화면 위에는 [유료 회원 전체] [체험 회원 전체] 버튼을 두고, 고르면 KPI · 카드 · 표가 모두 그 모수로 바뀐다.
결과
- “유료보다 모수가 작다”는 질문이 사라졌다. 유료 모수가 곧 유료 명단 전체이고, 그중 학습한 사람 수가 옆에 있다.
- 개발 서버에서 모수 크기와 분자 합을 원본 SQL 과 대조해 같은 값을 확인했다.
배운 것
- 지표 이름보다 분모를 먼저 정하자. “사용률”은 분모가 정해져야 비로소 질문이 된다.
- 한 회원은 한 모수에만. 겹칠 때 우선순위를 정하고, 뺄 대상은 명시적으로 빼고 적는다. 분류 신호는 쓰기 전에 교차표로 확인한다.
- 모수는 툴팁이 아니라 이름 · 색으로 항상 보이게. 같은 모수는 어디서나 같은 이름으로.
- 저장은 잘게, 분류는 조회 때. 정의는 바뀐다. 단, 저장하지 않은 신호는 나중에 못 만든다.
- “0”과 “모름”을 구분하자. 못 구한 주는 지우지 말고 직전 값을 두고, 비어 있는 이유를 화면에 적는다.