이커머스 A/B 테스트: 전환율 최적화 실전 가이드

이커머스

A/B 테스트전환율 최적화실험 설계이커머스분석

이 글은 누구를 위한 것인가

  • 상품 페이지, 체크아웃, 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% 기준으로 샘플 크기를 미리 계산하고, 계획한 기간이 끝날 때까지 기다린다. 해시 기반 버킷팅으로 같은 사용자는 항상 같은 변형을 보게 해 실험 오염을 방지한다.