init: company-haness 설계
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
"""
|
||||
Pro hybrid billing (committed base + metered overage) — Stripe Billing Meters sample.
|
||||
|
||||
Grounded in official Stripe docs (see refs/stripe-usage-billing.md for source URLs, cited 2026-07-07):
|
||||
- POST /v1/billing/meters stripe.billing.Meter.create (S2)
|
||||
- POST /v1/billing/meter_events stripe.billing.MeterEvent.create (S3, dedup via `identifier`)
|
||||
- POST /v1/prices stripe.Price.create (S5, metered + tiered overage)
|
||||
- POST /v1/subscriptions stripe.Subscription.create (S6, licensed base + metered item)
|
||||
|
||||
This is a reference implementation of the backend metering + billing slice. It is runnable against
|
||||
a Stripe *test* account (STRIPE_API_KEY=sk_test_...). No network calls happen at import time.
|
||||
|
||||
pip install stripe
|
||||
export STRIPE_API_KEY=sk_test_xxx
|
||||
python metering_sample.py --demo # provisions meter/prices in the test account
|
||||
|
||||
Design intent:
|
||||
* The committed base fee is a LICENSED price (fixed, predictable ARR).
|
||||
* Overage is a METERED price whose graduated tier-1 (the included allotment) costs 0, so only
|
||||
usage above the allotment is billed. Stripe does the aggregation + tiering; the app only emits
|
||||
idempotent meter events.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
import stripe
|
||||
|
||||
stripe.api_key = os.environ.get("STRIPE_API_KEY", "")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config — the value-metric + pricing parameters (to be finalized by CPO/CFO,
|
||||
# per exec packet user-decision-needed). Amounts are in the smallest currency unit.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CURRENCY = "usd"
|
||||
EVENT_NAME = "pro_api_call" # the value-metric event; keep <=100 chars, stable forever
|
||||
AGGREGATION_FORMULA = "sum" # sum | count | last (sum of `value` per period)
|
||||
INCLUDED_ALLOTMENT = 10_000 # units covered by the committed base fee (tier-1 = 0)
|
||||
BASE_FEE_AMOUNT = 2_000 # $20.00/mo committed base (licensed)
|
||||
OVERAGE_UNIT_AMOUNT = 2 # $0.02 per unit above the allotment (unit-margin floor guarded)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Provision the billing primitives (run once per environment)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class ProPlan:
|
||||
meter_id: str
|
||||
product_id: str
|
||||
base_price_id: str
|
||||
metered_price_id: str
|
||||
|
||||
|
||||
def create_meter() -> stripe.billing.Meter:
|
||||
"""POST /v1/billing/meters — defines how usage events aggregate over a period. (S2)"""
|
||||
return stripe.billing.Meter.create(
|
||||
display_name="Pro API Calls",
|
||||
event_name=EVENT_NAME,
|
||||
default_aggregation={"formula": AGGREGATION_FORMULA},
|
||||
value_settings={"event_payload_key": "value"},
|
||||
customer_mapping={"type": "by_id", "event_payload_key": "stripe_customer_id"},
|
||||
)
|
||||
|
||||
|
||||
def create_base_price(product_id: str) -> stripe.Price:
|
||||
"""POST /v1/prices — licensed committed base fee (predictable ARR). (S5)"""
|
||||
return stripe.Price.create(
|
||||
currency=CURRENCY,
|
||||
product=product_id,
|
||||
unit_amount=BASE_FEE_AMOUNT,
|
||||
recurring={"interval": "month"}, # default usage_type = licensed
|
||||
)
|
||||
|
||||
|
||||
def create_metered_overage_price(product_id: str, meter_id: str) -> stripe.Price:
|
||||
"""POST /v1/prices — metered price with graduated tiers: allotment free, overage billed. (S5)"""
|
||||
return stripe.Price.create(
|
||||
currency=CURRENCY,
|
||||
product=product_id,
|
||||
recurring={"interval": "month", "usage_type": "metered", "meter": meter_id},
|
||||
billing_scheme="tiered",
|
||||
tiers_mode="graduated",
|
||||
tiers=[
|
||||
{"up_to": INCLUDED_ALLOTMENT, "unit_amount": 0}, # included in base fee
|
||||
{"up_to": "inf", "unit_amount": OVERAGE_UNIT_AMOUNT}, # overage
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def provision_pro_plan() -> ProPlan:
|
||||
meter = create_meter()
|
||||
product = stripe.Product.create(name="Pro")
|
||||
base_price = create_base_price(product.id)
|
||||
metered_price = create_metered_overage_price(product.id, meter.id)
|
||||
return ProPlan(
|
||||
meter_id=meter.id,
|
||||
product_id=product.id,
|
||||
base_price_id=base_price.id,
|
||||
metered_price_id=metered_price.id,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Subscribe a customer to the hybrid plan
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def subscribe_customer(customer_id: str, plan: ProPlan) -> stripe.Subscription:
|
||||
"""
|
||||
POST /v1/subscriptions — one subscription, two items. (S6)
|
||||
|
||||
NOTE: the metered item must NOT carry a `quantity`; usage is reported via meter events.
|
||||
The Idempotency-Key header makes the create safe to retry.
|
||||
"""
|
||||
return stripe.Subscription.create(
|
||||
customer=customer_id,
|
||||
items=[
|
||||
{"price": plan.base_price_id, "quantity": 1}, # licensed committed fee
|
||||
{"price": plan.metered_price_id}, # metered overage — no quantity
|
||||
],
|
||||
idempotency_key=f"sub-create:{customer_id}:pro-v1",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Record usage — the hot path. Idempotent by construction.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _dedup_identifier(customer_id: str, unit_key: str) -> str:
|
||||
"""
|
||||
Deterministic identifier so at-least-once delivery / retries never double-count.
|
||||
Stripe enforces uniqueness within a rolling 24h window (S4), so `unit_key` must be
|
||||
unique per real-world usage unit within that window (e.g. request id, job id).
|
||||
"""
|
||||
raw = f"{EVENT_NAME}:{customer_id}:{unit_key}"
|
||||
return hashlib.sha256(raw.encode()).hexdigest()[:64]
|
||||
|
||||
|
||||
def record_usage(
|
||||
customer_id: str,
|
||||
value: int,
|
||||
unit_key: str,
|
||||
when: Optional[datetime] = None,
|
||||
) -> stripe.billing.MeterEvent:
|
||||
"""
|
||||
POST /v1/billing/meter_events — report one usage event. (S3)
|
||||
|
||||
* `identifier` = deterministic dedup key (idempotency within 24h).
|
||||
* `timestamp` = event time; Stripe accepts within past 35 days / +5 min (S4). Defaults to now.
|
||||
"""
|
||||
payload = {"stripe_customer_id": customer_id, "value": str(value)}
|
||||
kwargs = {
|
||||
"event_name": EVENT_NAME,
|
||||
"payload": payload,
|
||||
"identifier": _dedup_identifier(customer_id, unit_key),
|
||||
}
|
||||
if when is not None:
|
||||
kwargs["timestamp"] = int(when.replace(tzinfo=timezone.utc).timestamp())
|
||||
return stripe.billing.MeterEvent.create(**kwargs)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Overage estimate (UX-only). Stripe remains the ledger of record.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class UsageEstimate:
|
||||
aggregated_usage: int
|
||||
included_allotment: int = INCLUDED_ALLOTMENT
|
||||
overage_unit_amount: int = OVERAGE_UNIT_AMOUNT
|
||||
base_fee_amount: int = BASE_FEE_AMOUNT
|
||||
|
||||
@property
|
||||
def overage_units(self) -> int:
|
||||
return max(0, self.aggregated_usage - self.included_allotment)
|
||||
|
||||
@property
|
||||
def estimated_overage_charge(self) -> int:
|
||||
return self.overage_units * self.overage_unit_amount
|
||||
|
||||
@property
|
||||
def estimated_invoice_total(self) -> int:
|
||||
# Single invoice at renewal: committed base + metered overage (S6).
|
||||
return self.base_fee_amount + self.estimated_overage_charge
|
||||
|
||||
|
||||
def estimate_overage(aggregated_usage: int) -> UsageEstimate:
|
||||
"""
|
||||
Client-facing estimate for 60/80/100% nudges. This mirrors Stripe's tiering math for UX only;
|
||||
the authoritative amount is computed by Stripe at invoice finalization.
|
||||
"""
|
||||
return UsageEstimate(aggregated_usage=aggregated_usage)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Simple consuming-team interface (product/frontend call these two, nothing else)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class MeteringClient:
|
||||
"""Thin facade the rest of the product uses. Hides Stripe API surface + idempotency."""
|
||||
plan: ProPlan
|
||||
_seen: set = field(default_factory=set) # optional local guard; Stripe is source of truth
|
||||
|
||||
def report(self, customer_id: str, value: int, unit_key: str) -> None:
|
||||
record_usage(customer_id=customer_id, value=value, unit_key=unit_key)
|
||||
|
||||
def estimate(self, customer_id: str, aggregated_usage: int) -> UsageEstimate:
|
||||
return estimate_overage(aggregated_usage)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Demo
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _demo() -> None:
|
||||
if not stripe.api_key:
|
||||
raise SystemExit("Set STRIPE_API_KEY=sk_test_... to run the demo.")
|
||||
plan = provision_pro_plan()
|
||||
print("Provisioned:", plan)
|
||||
customer = stripe.Customer.create(name="Demo Co")
|
||||
sub = subscribe_customer(customer.id, plan)
|
||||
print("Subscription:", sub.id, "status:", sub.status)
|
||||
# Emit a couple of idempotent usage events (retry-safe by unit_key).
|
||||
req_id = str(uuid.uuid4())
|
||||
record_usage(customer.id, value=25, unit_key=req_id)
|
||||
record_usage(customer.id, value=25, unit_key=req_id) # duplicate -> deduped by identifier
|
||||
est = estimate_overage(aggregated_usage=12_500)
|
||||
print(f"Est. overage units={est.overage_units} charge={est.estimated_overage_charge} "
|
||||
f"invoice_total={est.estimated_invoice_total}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Stripe metered-overage sample")
|
||||
parser.add_argument("--demo", action="store_true", help="provision + run against test account")
|
||||
args = parser.parse_args()
|
||||
if args.demo:
|
||||
_demo()
|
||||
else:
|
||||
print(__doc__)
|
||||
@@ -0,0 +1,175 @@
|
||||
# Stripe Usage-Based / Metered Billing — Official API Reference
|
||||
|
||||
> Local reference distilled from official Stripe documentation for the Pro hybrid
|
||||
> (committed base + metered overage) billing slice.
|
||||
>
|
||||
> **Cited:** 2026-07-07 (Asia/Seoul). API surface: Billing Meters (v1) + Meter Events (v1/v2).
|
||||
> **Note:** Stripe now surfaces Metronome as its recommended platform for *new* high-volume
|
||||
> integrations, but the first-party **Billing Meters API documented here is fully supported**
|
||||
> and is the correct primitive for a self-serve metered-overage subscription. Sources below.
|
||||
|
||||
## Sources (official URLs)
|
||||
|
||||
| # | Topic | URL |
|
||||
|---|-------|-----|
|
||||
| S1 | Usage-based billing overview | https://docs.stripe.com/billing/subscriptions/usage-based |
|
||||
| S2 | Create a Billing Meter (`POST /v1/billing/meters`) | https://docs.stripe.com/api/billing/meter/create |
|
||||
| S3 | Create a Meter Event (`POST /v1/billing/meter_events`) | https://docs.stripe.com/api/billing/meter-event/create |
|
||||
| S4 | Meter Event object v2 (dedup window) (`POST /v2/billing/meter_events`) | https://docs.stripe.com/api/v2/billing/meter-events/object |
|
||||
| S5 | Create a Price (metered / tiered) (`POST /v1/prices`) | https://docs.stripe.com/api/prices/create |
|
||||
| S6 | Create a Subscription (multi-item) (`POST /v1/subscriptions`) | https://docs.stripe.com/api/subscriptions/create |
|
||||
| S7 | Recording usage | https://docs.stripe.com/billing/subscriptions/usage-based/recording-usage |
|
||||
| S8 | Advanced usage-based (flat fee + overage) | https://docs.stripe.com/billing/subscriptions/usage-based/advanced/compare |
|
||||
|
||||
---
|
||||
|
||||
## Data model (end to end)
|
||||
|
||||
```
|
||||
Billing Meter ──defines──▶ event_name + aggregation (sum/count/last)
|
||||
▲ │
|
||||
│ recurring.meter │ meter events (usage) reference event_name
|
||||
│ ▼
|
||||
Metered Price ──item──▶ Subscription ◀──item── Licensed base Price (committed fee)
|
||||
│ │
|
||||
tiered/per_unit ▼
|
||||
End of billing period: aggregated usage → invoice line item
|
||||
```
|
||||
|
||||
The **hybrid** = one Subscription carrying **two items**:
|
||||
1. a **licensed** base price (fixed committed fee, `usage_type` unset/licensed, has `quantity`), and
|
||||
2. a **metered** price linked to a Meter (`usage_type=metered`, **no `quantity`**), whose graduated
|
||||
tiers make the *included allotment* cost 0 and only bill the **overage** per unit.
|
||||
|
||||
---
|
||||
|
||||
## 1. Create a Billing Meter — `POST /v1/billing/meters` (S2)
|
||||
|
||||
A Meter specifies how to aggregate meter events over a billing period.
|
||||
|
||||
Key parameters:
|
||||
- `display_name` (string, required) — internal name, not shown to customers.
|
||||
- `event_name` (string, required, max 100 chars) — links meter to its events.
|
||||
- `default_aggregation.formula` (enum, required) — `sum` | `count` | `last`.
|
||||
- `value_settings.event_payload_key` (string) — payload key holding the numeric value (default `value`).
|
||||
- `customer_mapping.type` (enum) — `by_id`.
|
||||
- `customer_mapping.event_payload_key` (string) — payload key holding the customer id (default `stripe_customer_id`).
|
||||
- `event_time_window` (nullable enum, optional) — `hour` | `day` for pre-aggregated events.
|
||||
|
||||
Python signature:
|
||||
```python
|
||||
stripe.billing.Meter.create(
|
||||
display_name="Pro API Calls",
|
||||
event_name="pro_api_call",
|
||||
default_aggregation={"formula": "sum"},
|
||||
value_settings={"event_payload_key": "value"},
|
||||
customer_mapping={"type": "by_id", "event_payload_key": "stripe_customer_id"},
|
||||
)
|
||||
# -> billing.meter { id: "mtr_...", status: "active", ... }
|
||||
```
|
||||
|
||||
## 2. Record a Meter Event — `POST /v1/billing/meter_events` (S3, S4)
|
||||
|
||||
Reports one usage event. Aggregated into invoice line items at end of period.
|
||||
|
||||
Key parameters:
|
||||
- `event_name` (string, required) — must match a Meter's `event_name`.
|
||||
- `payload` (object, required) — must contain the meter's `customer_mapping.event_payload_key`
|
||||
(default `stripe_customer_id`) and `value_settings.event_payload_key` (default `value`).
|
||||
- `identifier` (string, optional) — **idempotency / dedup key**. Uniqueness is enforced within a
|
||||
**rolling 24-hour window**; a repeated `identifier` is not counted twice. If omitted, Stripe
|
||||
generates one. Recommend a globally unique id (UUID / deterministic business key). (S4)
|
||||
- `timestamp` (unix seconds, optional) — must be **within the past 35 calendar days or up to 5
|
||||
minutes in the future**; defaults to now. (S4)
|
||||
|
||||
Python signature:
|
||||
```python
|
||||
stripe.billing.MeterEvent.create(
|
||||
event_name="pro_api_call",
|
||||
payload={"stripe_customer_id": "cus_123", "value": "25"},
|
||||
identifier="pro_api_call:cus_123:2026-07-07T09:00Z:req_abc", # dedup within 24h
|
||||
timestamp=1751878800, # optional
|
||||
)
|
||||
```
|
||||
> v2 equivalent `POST /v2/billing/meter_events` returns `v2.billing.meter_event` and does
|
||||
> synchronous validation; same `identifier` 24h-uniqueness rule. (S4)
|
||||
|
||||
## 3a. Create a metered Price — `POST /v1/prices` (S5)
|
||||
|
||||
Link a price to the meter and choose per-unit or tiered.
|
||||
|
||||
Key parameters:
|
||||
- `currency` (required) — e.g. `usd`.
|
||||
- `product` or `product_data` (one required).
|
||||
- `recurring.interval` (required) — `day` | `week` | `month` | `year`.
|
||||
- `recurring.usage_type` = `metered` (required for usage billing).
|
||||
- `recurring.meter` = `<meter id>` (required for metered) — ties price to the Meter from step 1.
|
||||
- `billing_scheme` — `per_unit` (with `unit_amount`) or `tiered`.
|
||||
- For tiered overage: `tiers_mode` = `graduated` | `volume`, and `tiers[]` = `{up_to, unit_amount}`.
|
||||
|
||||
Graduated overage example (first N units free/included, rest billed):
|
||||
```python
|
||||
stripe.Price.create(
|
||||
currency="usd",
|
||||
product="prod_pro",
|
||||
recurring={"interval": "month", "usage_type": "metered", "meter": "mtr_..."},
|
||||
billing_scheme="tiered",
|
||||
tiers_mode="graduated",
|
||||
tiers=[
|
||||
{"up_to": 10000, "unit_amount": 0}, # included allotment (covered by base fee)
|
||||
{"up_to": "inf", "unit_amount": 2}, # $0.02/unit overage above the allotment
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
## 3b. Create the base (committed) Price — `POST /v1/prices` (S5)
|
||||
|
||||
Fixed committed fee = a licensed recurring price (no `usage_type=metered`):
|
||||
```python
|
||||
stripe.Price.create(
|
||||
currency="usd", product="prod_pro",
|
||||
unit_amount=2000, # $20.00 committed base
|
||||
recurring={"interval": "month"}, # licensed (default usage_type)
|
||||
)
|
||||
```
|
||||
|
||||
## 4. Create the hybrid Subscription — `POST /v1/subscriptions` (S6)
|
||||
|
||||
One subscription, two items. **Metered items must NOT include `quantity`.**
|
||||
```python
|
||||
stripe.Subscription.create(
|
||||
customer="cus_123",
|
||||
items=[
|
||||
{"price": "price_base", "quantity": 1}, # licensed committed fee
|
||||
{"price": "price_metered"}, # metered overage — no quantity
|
||||
],
|
||||
idempotency_key="sub-create:cus_123:pro-v1", # safe retry (request header)
|
||||
)
|
||||
```
|
||||
- Metered usage from the previous period is charged **alongside** the fixed base for the new period
|
||||
on a **single invoice** at each renewal. (S6, quantities doc)
|
||||
- Use the `Idempotency-Key` request header to safely retry create requests (returns the same object).
|
||||
|
||||
---
|
||||
|
||||
## Overage calculation (concept)
|
||||
|
||||
Overage is computed by Stripe, not the app: it aggregates meter events per customer per period via
|
||||
`default_aggregation.formula`, then applies the metered price's tiers. With `graduated` tiers where
|
||||
tier-1 (`up_to = included_allotment`) has `unit_amount = 0`, the customer pays only for units above
|
||||
the allotment:
|
||||
|
||||
```
|
||||
billed_overage = max(0, aggregated_usage - included_allotment) * overage_unit_amount
|
||||
invoice_total = committed_base_fee + billed_overage # single invoice at renewal
|
||||
```
|
||||
|
||||
App-side we only *emit events* and (optionally) *mirror a usage estimate* for UX; the ledger of
|
||||
record is Stripe's aggregation.
|
||||
|
||||
## Idempotency & correctness rules (official)
|
||||
|
||||
- **Meter events:** set a deterministic `identifier`; dedup is enforced for a rolling 24h window (S4).
|
||||
Choose one identifier per real-world usage unit so retries/at-least-once delivery never double-count.
|
||||
- **Timestamp:** only accepted within past 35 days / +5 min; late or clock-skewed events are rejected (S4).
|
||||
- **Write APIs (Meter/Price/Subscription create):** pass the `Idempotency-Key` header to make retries safe (S6).
|
||||
@@ -0,0 +1,99 @@
|
||||
# wf-churn-01 — DATA-ANALYST 계측·분석 설계 노트 (LENS-CUSTOMER, 정량)
|
||||
|
||||
> 정직성 제약: **실제 제품 로그/행동 데이터 없음.** 아래는 "무엇을 계측·분석해야 하는가"의 설계이며,
|
||||
> 근거는 방법론 + 업계 벤치마크(E1~E2)로만 제시한다. 실측치가 없으므로 E4/E5 주장·수치 확언은 하지 않는다.
|
||||
> 모든 임계값(예: activation 기준, PQL threshold)은 **가정(placeholder)**이며 실데이터로 보정해야 한다.
|
||||
|
||||
---
|
||||
|
||||
## 0. 문제 프레이밍 (정량 관점)
|
||||
- 대상 구간: **온보딩(가입/첫 진입) → 첫 결제(Pro 전환)**.
|
||||
- 관측 대상: free → Pro **전환율(conversion rate)** 이 낮다 → 이 구간의 **단계별 이탈(drop-off)** 을 계량해 병목 단계를 특정한다.
|
||||
- 이 lens는 **정량 계측/분석 설계만** 다룬다. 정성 UX 원인·제품 의사결정·타 역할 종합은 범위 밖.
|
||||
|
||||
---
|
||||
|
||||
## 1. 정의할 지표 (metric definitions)
|
||||
|
||||
### 1.1 Activation metric (활성화 = "가치를 처음 경험한 상태")
|
||||
- **정의 원칙**: activation은 "리텐션/전환과 상관이 가장 높은 초기 행동"으로 **데이터로 역산**해 정의한다(선험적 추측 금지).
|
||||
- **후보 activation event (가정, 검증 대상)**:
|
||||
- A1: 첫 핵심 액션 완료(제품의 aha-action 1회) — 예: 첫 산출물/처리 1건 완료.
|
||||
- A2: 첫 세션 내 핵심 액션 N회(예: 3회) 도달.
|
||||
- A3: 가입 후 X일(예: 7일) 내 재방문(Day-1/Day-7 return).
|
||||
- **채택 방법**: 각 후보에 대해 "activated 코호트 vs non-activated 코호트의 D30 유지·Pro 전환율 격차"를 비교해 **격차가 가장 큰 정의**를 activation으로 확정.
|
||||
- **핵심 파생지표**: Activation Rate = activated 신규 / 전체 신규(코호트 기준).
|
||||
|
||||
### 1.2 PQL (Product-Qualified Lead) — 전환 임박 시그널
|
||||
- **정의**: 무료 사용 중 "Pro 가치를 이미 체감했고 유료 한도/기능에 부딪힌" 사용자 = 결제 확률 높은 리드.
|
||||
- **후보 시그널(복합, 가정)**: 무료 한도 70~80% 소진 + 재방문(주 2회+) + 팀/공유 액션 + 유료 전용 기능 시도(paywall 히트).
|
||||
- **파생지표**: PQL 생성률, PQL→결제 전환율, PQL 생성까지 소요시간(TTV proxy).
|
||||
|
||||
### 1.3 Funnel step conversion (단계별 전환율)
|
||||
표준 온보딩→결제 퍼널을 단계로 쪼개 **각 단계 전환율·이탈률**을 계측:
|
||||
|
||||
| # | 단계(step) | 진입 정의 | 완료 정의(다음 단계 진입) | 계측 지표 |
|
||||
|---|---|---|---|---|
|
||||
| S0 | Signup | 가입 시작 | 계정 생성 완료 | signup completion rate |
|
||||
| S1 | Onboarding start | 계정 생성 | 온보딩 플로우 진입 | onboarding entry rate |
|
||||
| S2 | Setup/First-value | 온보딩 진입 | **activation event 도달** | activation rate (핵심) |
|
||||
| S3 | Habit/Return | activation | Day-7 재방문 | early retention |
|
||||
| S4 | Paywall exposure | 재방문 | 유료 한도/기능 접촉(PQL) | paywall hit rate |
|
||||
| S5 | Checkout start | paywall 접촉 | 결제 화면 진입 | intent rate |
|
||||
| S6 | First payment | checkout 진입 | 결제 성공(Pro) | checkout completion / free→Pro |
|
||||
|
||||
- **전환율 계산**: 각 단계 `Cn = 완료수 / 진입수`. 전체 free→Pro = ∏(S0..S6) 근사.
|
||||
- **관례**: window(예: 가입 후 30일) 고정한 **cohort-based conversion**으로 계산(단순 누적비율 금지 — 최근 코호트 미성숙 편향).
|
||||
|
||||
### 1.4 보조 지표
|
||||
- **Time-to-Value(TTV)**: signup → activation 소요시간(중앙값/분포).
|
||||
- **Time-to-Convert**: signup → first payment 소요시간(전환 latency 분포, 리드타임 설계용).
|
||||
- **Drop-off rate per step**: `1 - Cn`, 절대 이탈수 = 진입수 × (1-Cn).
|
||||
|
||||
---
|
||||
|
||||
## 2. 코호트 / 리텐션 분석 계획
|
||||
- **코호트 기준축**: (a) signup week(가입 주차), (b) acquisition channel/source, (c) activation 여부, (d) plan intent(무료 진입 경로).
|
||||
- **리텐션 커브**: D1/D7/D14/D30 return retention을 코호트별로. activated vs non-activated 분리 → activation의 리텐션 리프트 정량화.
|
||||
- **전환 코호트 분석**: 가입 주차별 30/60/90일 누적 free→Pro 전환율(코호트 성숙도 보정). 최근 코호트는 censored 표기.
|
||||
- **Survival 분석(권장)**: 전환까지 시간을 event로 본 Kaplan-Meier 곡선 — "언제 전환/이탈이 집중되는가" 구간 특정. 단계 간 이탈이 특정 일자에 몰리면 그 지점을 우선 조사.
|
||||
- **Funnel segmentation**: 위 퍼널을 채널·디바이스·온보딩 variant별로 분해해 **가장 이탈이 큰 (단계 × 세그먼트) 셀**을 탐지.
|
||||
|
||||
---
|
||||
|
||||
## 3. Drop-off 계측 이벤트 (instrumentation event map)
|
||||
각 단계 경계마다 이벤트를 심어 진입/완료/이탈을 관측한다. (이벤트명은 제안, 스키마는 설계)
|
||||
|
||||
- `signup_started`, `signup_completed`
|
||||
- `onboarding_step_viewed {step_id, index}` / `onboarding_step_completed {step_id}` / `onboarding_abandoned {last_step}`
|
||||
- `activation_event {type}` (1.1 확정 후 단일 표준 이벤트로)
|
||||
- `session_started` / `session_ended {duration, actions}` (재방문·retention 계산)
|
||||
- `paywall_viewed {trigger, feature, usage_pct}` (PQL·S4)
|
||||
- `checkout_started` / `checkout_completed {plan, amount}` / `checkout_failed {reason}` (결제 실패=이탈 vs 미의도 구분)
|
||||
- 공통 프로퍼티: `user_id(pseudonymous)`, `cohort_week`, `channel`, `timestamp`, `device`.
|
||||
- **이탈 정의**: 단계 진입 이벤트는 있으나 window 내 다음 단계 완료 이벤트 없음 = drop-off. 각 단계 마지막 이벤트를 **abandonment point**로 집계.
|
||||
- **PII/보안**: raw PII·이메일·결제 원문 미수집(pseudonymous id + 마스킹). tool-permission/redaction 정책 준수.
|
||||
|
||||
---
|
||||
|
||||
## 4. 원인을 좁히는 분석 (어떤 분석으로 좁힐지)
|
||||
1. **Funnel 병목 랭킹**: 단계별 절대 이탈수(진입수 × drop-off%) 내림차순 → 가장 큰 leak 단계 우선. (전환율%만 보면 트래픽 작은 단계 과대평가 위험 → 절대수 병행.)
|
||||
2. **Cohort × step heatmap**: 세그먼트별 이탈 편차 → 특정 채널/디바이스/온보딩 variant에 이탈 집중 여부.
|
||||
3. **Activation ↔ 전환 상관**: activated vs non-activated의 free→Pro 격차로 activation이 전환의 leading indicator인지 확인.
|
||||
4. **Time-to-event 분포**: 전환 latency로 넛지/트라이얼 타이밍 창(window) 도출.
|
||||
5. **(데이터 충분 시) 실험 설계**: 병목 단계에 A/B 테스트 프레임(온보딩 variant, paywall 타이밍) — 지금은 **설계만**, 결과 해석은 데이터 확보 후.
|
||||
|
||||
---
|
||||
|
||||
## 5. 가정 (assumptions) — 명시
|
||||
- (G1) 이벤트 트래킹 인프라가 아직 없거나 부분적 → **계측 스펙 신설**이 선행 과제.
|
||||
- (G2) free→Pro는 **셀프서브 self-serve** 결제 흐름을 가정(세일즈 주도 시 퍼널 단계 상이).
|
||||
- (G3) 사용자 식별이 로그인 기반으로 코호트 추적 가능하다고 가정(익명 세션만이면 pre-signup 단계 계측 제한).
|
||||
- (G4) 위 임계값(activation N회, 한도 70~80%, 30일 window)은 placeholder — 실데이터로 재보정 필수.
|
||||
|
||||
---
|
||||
|
||||
## 6. 근거 (evidence basis)
|
||||
- **E1 (방법론)**: funnel/cohort/activation/retention·survival 분석은 표준 product analytics 방법론.
|
||||
- **E2 (업계 벤치마크·관례)**: activation을 리텐션 상관으로 역산해 정의하는 관행, PQL 복합 시그널링, cohort-based conversion(누적비율 편향 회피)은 널리 통용되는 practice.
|
||||
- 실측 로그가 없어 **E3+ 주장·구체 전환 수치는 제시하지 않음**. 결론 confidence = **Med 이하**.
|
||||
@@ -0,0 +1,70 @@
|
||||
# wf-churn-01 — UX 리서처 정성 리서치 노트 (LENS-CUSTOMER)
|
||||
|
||||
역할: UX-RESEARCHER (UX 리서처 AI) · lens: LENS-CUSTOMER
|
||||
workflow: wf-churn-01 — 무료→Pro 전환율 저조, 온보딩→첫 결제 구간 이탈 원인 규명(정성 관점)
|
||||
작성일: 2026-07-07
|
||||
|
||||
> **정직성 경계(HONESTY)**: 실제 제품 이벤트 로그·유저 인터뷰 원본·세션리플레이는 **아직 없음**.
|
||||
> 아래 가설·매핑은 **방법론·UX 휴리스틱·공개 SaaS 시장자료 수준(E1~E2)** 이며, 검증 전 가설이다.
|
||||
> 정량 funnel 수치, 가격탄력성, 제품 결정, 타 역할 종합은 **본 역할 범위 밖**(각각 FAM-DATA / FAM-REVOPS / FAM-CPO).
|
||||
> confidence는 Med 이하로만 제시한다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 정성 리서치 방법 (설계 원칙)
|
||||
|
||||
이탈 원인은 "왜"를 물어야 하므로 정량 funnel(어디서 빠지나)만으로는 부족하다. 정성 3-트랙으로 원인을 규명한다.
|
||||
|
||||
### Track A — 이탈자/전환자 심층 인터뷰 (Depth Interview)
|
||||
- **대상 세그먼트(3그룹, 각 6~8명, 포화까지)**:
|
||||
1. 온보딩 완주 후 결제 미도달 무료 유저(핵심 이탈군)
|
||||
2. 활성화 이전 이탈(온보딩 중도 포기)
|
||||
3. 최근 Pro 전환 성공 유저(대조군 — aha-moment/전환 트리거 확인)
|
||||
- **기법**: 반구조화 인터뷰 + Critical Incident Technique(마지막으로 이탈을 결심한 순간 재구성) + Jobs-to-be-Done "Switch" 인터뷰(밀어낸 힘/끌어당긴 힘/불안/관성 4force).
|
||||
- **핵심 질문축**: 기대가치 vs 체감가치 gap, 결제 결심 직전의 불안(가격·해지·데이터), 대안(경쟁/무료 유지) 비교.
|
||||
|
||||
### Track B — 결제 구간 사용성 테스트 (Moderated Usability Test)
|
||||
- **대상**: 프록시 참가자 8~10명, think-aloud.
|
||||
- **과업**: "무료로 가입 → 첫 핵심 가치 경험 → Pro 업그레이드 및 결제 완료"까지 end-to-end.
|
||||
- **측정**: task success/시간, 에러·주저(hesitation) 지점, SEQ(Single Ease Question), 이탈 발화 코딩.
|
||||
- **집중 관찰**: paywall 등장 타이밍/맥락, 결제폼 마찰, 플랜 비교 인지부하.
|
||||
|
||||
### Track C — 경험 저니 분석 (Journey/Heuristic + 세션 관찰)
|
||||
- **산출**: 온보딩→aha→paywall→결제 완료의 경험 저니맵 + 감정 곡선 + friction 히트포인트.
|
||||
- **기법**: Nielsen 10 휴리스틱 워크스루, 인지부하/전환 마찰 휴리스틱, (데이터 가용 시)세션 리플레이·rage-click 관찰과 삼각측량.
|
||||
- **주의**: 세션 데이터/이벤트 로그는 FAM-DATA 소관 — 본 트랙은 정성 해석만, 수치 산출은 이관.
|
||||
|
||||
---
|
||||
|
||||
## 2. 이탈 friction 가설 (온보딩→첫 결제, 정성)
|
||||
|
||||
TTV(Time-to-Value) 관점에서 "가치를 체감하기 전에 결제를 요구"당하면 이탈한다는 것이 중심 가설.
|
||||
|
||||
| # | Friction 가설 | 저니 위치 | 근거등급 | 검증 트랙 |
|
||||
|---|---|---|---|---|
|
||||
| H1 | **가치 체감 前 paywall** — aha-moment 도달 전에 결제벽이 등장해 "왜 돈을 내야 하는지" 납득 안 됨 | onboarding→paywall | E1(휴리스틱) | A,B,C |
|
||||
| H2 | **온보딩 TTV 과대** — 첫 핵심가치까지 단계·설정이 많아 활성화 전 이탈(activation gap) | onboarding | E2(SaaS 벤치마크) | B,C |
|
||||
| H3 | **가치 불명확** — 무료 기능이 Pro 가치를 미리 보여주지 못해 업그레이드 동기 부재(value gap) | activation→paywall | E1 | A |
|
||||
| H4 | **결제 마찰·불안** — 카드 선입력/해지 불안/환불 정책 불투명이 결제 직전 주저 유발 | checkout | E1(휴리스틱) | B |
|
||||
| H5 | **플랜 인지부하** — 플랜/가격 비교가 복잡해 결정 회피(decision paralysis) | paywall | E1 | B |
|
||||
| H6 | **넛지 부재/오타이밍** — 한도 근접·가치 순간에 맞춘 컨텍스트 넛지가 없어 전환 창(window) 상실 | activation→paywall | E1 | A,C |
|
||||
| H7 | **신뢰·기대 gap** — 마케팅 약속과 첫 경험 불일치로 신뢰 하락, 지불의사 하락 | onboarding | E1 | A |
|
||||
|
||||
## 3. Aha-moment 매핑 (가설)
|
||||
|
||||
- **정의**: 유저가 제품의 핵심 약속을 처음으로 체감하는 순간(첫 성공적 산출/결과). 전환은 aha 이후에 붙어야 한다.
|
||||
- **가설 매핑**: 현재 퍼널은 aha-moment와 paywall의 **순서/거리**가 어긋나 있을 가능성 — paywall이 aha보다 앞서거나, aha 후 전환 넛지까지의 공백이 큼.
|
||||
- **검증 포인트**: 전환 성공군(Track A 그룹3) 인터뷰로 "결제를 결심하게 만든 바로 그 경험(트리거 이벤트)"을 역추적 → aha 정의를 데이터화(FAM-DATA와 협업해 이벤트로 계량)할 후보 시그널 도출.
|
||||
- **활성화 지표 후보(정성→정량 번역 입력)**: 첫 핵심산출 완료, N회 재방문, 팀 초대, 한도 X% 소진 등 — 수치 확정은 FAM-DATA/FAM-REVOPS로 이관.
|
||||
|
||||
## 4. 가정 (Assumptions)
|
||||
|
||||
- A1: 제품은 free→Pro 셀프서브 SaaS이며 온보딩→paywall→checkout 퍼널이 존재한다(과업 서술 기반 가정).
|
||||
- A2: 이탈은 단일 원인이 아니라 activation gap + value gap + checkout friction의 복합이다.
|
||||
- A3: 정량 funnel 드롭 지점은 별도 역할(FAM-DATA)이 제공하며, 본 노트는 그 "왜"를 정성으로 채운다.
|
||||
- A4: 인터뷰/UT 참가자 리크루팅과 인센티브 예산은 후속 승인 필요(리서치옵스).
|
||||
|
||||
## 5. 산출물 경계
|
||||
|
||||
- 본 역할 = 정성 이탈원인 가설 + 검증 리서치 설계 + 다음 액션 제안까지.
|
||||
- **결정/종합/우선순위 확정은 Orchestrator·EXEC-CPO** 소관(fan-out 워커 계약).
|
||||
Reference in New Issue
Block a user