이 글은 누구를 위한 것인가
- 상품 페이지, 체크아웃, CTA 버튼 등을 데이터 기반으로 최적화하려는 팀
- A/B 테스트 프레임워크를 직접 구축하려는 개발자
- 통계적으로 올바른 실험 설계를 적용하려는 팀
들어가며
"버튼 색상을 바꿨더니 전환율이 20% 올랐다"는 주장을 믿으려면 통계적 유의성이 필요하다. 샘플 크기가 부족하면 노이즈를 신호로 착각한다. 올바른 A/B 테스트는 가설 수립 → 샘플 크기 계산 → 실험 → 통계 검증 순서로 진행한다.
이 글은 bluefoxdev.kr의 이커머스 A/B 테스트 가이드 를 참고하여 작성했습니다.
1. A/B 테스트 설계 원칙
[실험 설계 체크리스트]
1. 가설 명확화: "CTA를 '구매하기'→'지금 주문'으로 바꾸면 CTR이 증가한다"
2. 주요 지표(Primary Metric): 구매 전환율
3. 부수 지표(Guardrail Metric): 반품률, 페이지 이탈률
4. 샘플 크기 계산 (통계적 파워 80%, 유의수준 5%)
5. 실험 기간: 최소 1주일 (주간 패턴 반영)
6. 버킷팅 일관성: 같은 사용자는 항상 같은 변형
[샘플 크기 계산]
기준 전환율: 3%
최소 탐지 효과: 0.5% 개선 (상대적 16.7%)
유의수준(α): 0.05
통계적 파워(1-β): 0.80
→ 필요 샘플: 변형당 약 15,000명
[버킷팅 전략]
해시 기반: hash(userId + experimentId) % 100
장점: 결정론적, DB 불필요, 빠름
단점: 실험 간 버킷 상관관계 가능
쿠키/세션: 비로그인 사용자 포함
주의: 쿠키 삭제 시 변형 변경 위험
2. A/B 테스트 구현
import crypto from 'crypto';
interface Experiment {
id: string;
name: string;
variants: { id: string; weight: number }[]; // weight 합계 = 100
targetSegment?: { newUsers?: boolean; minOrderCount?: number };
startAt: Date;
endAt?: Date;
status: 'DRAFT' | 'RUNNING' | 'PAUSED' | 'COMPLETED';
}
class ExperimentEngine {
private experiments: Map<string, Experiment> = new Map();
// 사용자에게 변형 할당 (결정론적 해시)
assignVariant(experimentId: string, userId: string): string | null {
const exp = this.experiments.get(experimentId);
if (!exp || exp.status !== 'RUNNING') return null;
if (exp.endAt && exp.endAt < new Date()) return null;
// 해시 기반 버킷팅 (0-99)
const hash = crypto.createHash('md5').update(`${userId}:${experimentId}`).digest('hex');
const bucket = parseInt(hash.slice(0, 8), 16) % 100;
// 가중치 기반 변형 선택
let cumulative = 0;
for (const variant of exp.variants) {
cumulative += variant.weight;
if (bucket < cumulative) return variant.id;
}
return null;
}
// 실험 이벤트 추적
async trackEvent(params: {
experimentId: string;
variantId: string;
userId: string;
event: 'impression' | 'conversion';
value?: number; // 전환 시 주문금액
}) {
await db.experimentEvent.create({ data: params });
}
// 실험 결과 분석 (이항 비율 z-검정)
async analyzeResults(experimentId: string) {
const events = await db.experimentEvent.groupBy({
by: ['variantId', 'event'],
_count: true,
where: { experimentId },
});
const stats: Record<string, { impressions: number; conversions: number }> = {};
for (const e of events) {
if (!stats[e.variantId]) stats[e.variantId] = { impressions: 0, conversions: 0 };
if (e.event === 'impression') stats[e.variantId].impressions = e._count;
if (e.event === 'conversion') stats[e.variantId].conversions = e._count;
}
const results: Record<string, any> = {};
const control = stats['control'];
for (const [variantId, data] of Object.entries(stats)) {
const convRate = data.conversions / data.impressions;
results[variantId] = { convRate: (convRate * 100).toFixed(2) + '%', ...data };
if (variantId !== 'control' && control) {
const pValue = this.zTest(control, data);
const lift = ((convRate - control.conversions / control.impressions) / (control.conversions / control.impressions) * 100).toFixed(1);
results[variantId].pValue = pValue.toFixed(4);
results[variantId].lift = lift + '%';
results[variantId].significant = pValue < 0.05;
}
}
return results;
}
private zTest(control: { impressions: number; conversions: number }, variant: { impressions: number; conversions: number }): number {
const p1 = control.conversions / control.impressions;
const p2 = variant.conversions / variant.impressions;
const pooled = (control.conversions + variant.conversions) / (control.impressions + variant.impressions);
const se = Math.sqrt(pooled * (1 - pooled) * (1 / control.impressions + 1 / variant.impressions));
const z = Math.abs(p2 - p1) / se;
return 2 * (1 - normalCDF(z)); // 양측 검정
}
}
function normalCDF(x: number): number {
return (1 + erf(x / Math.sqrt(2))) / 2;
}
function erf(x: number): number {
const t = 1 / (1 + 0.3275911 * Math.abs(x));
const y = 1 - (((((1.061405429 * t - 1.453152027) * t) + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * Math.exp(-x * x);
return x >= 0 ? y : -y;
}
const db = {} as any;
마무리
A/B 테스트의 실수는 대부분 "샘플 부족"과 "조기 종료"다. 통계적 파워 80% 기준으로 샘플 크기를 미리 계산하고, 계획한 기간이 끝날 때까지 기다린다. 해시 기반 버킷팅으로 같은 사용자는 항상 같은 변형을 보게 해 실험 오염을 방지한다.