A/B 테스트의 핵심 메커니즘

무작위 배정, 가설 검정, 그리고 실무에서의 구현

A/B 테스트의 핵심은 단순한 랜덤 라우팅이 아니라, 통계적 유의성을 확보하기 위한 무작위 배정과 대조군 설정에 있다. 본 문서는 A/B 테스트의 기술적 정의, 가설 검정 프로세스, 실무 구현 방식, 그리고 한계와 대안까지 체계적으로 다룬다.

Experimentation
Statistics
저자

Kwangmin Kim

공개

2026년 03월 16일

1 A/B 테스트란 무엇인가

A/B 테스트는 가설 검정(Hypothesis Testing)의 실무적 적용이다. 기존 서비스(A, 대조군)와 변경된 서비스(B, 실험군) 간의 성과 차이가 단순한 우연(Chance)인지, 실제 인과관계(Causality)에 의한 것인지를 판별하는 실험 설계 프로세스이다.

단순히 사용자를 50:50으로 나누는 것이 아니라, 외생 변수를 통제하여 특정 변경 사항이 지표 변화의 유일한 원인임을 증명하는 것이 목적이다.

1.1 왜 A/B 테스트가 필요한가

“결제 버튼 색을 파란색으로 바꿨더니 전환율이 3% 올랐다.”

이 관찰만으로 버튼 색이 원인이라고 결론 내릴 수 있는가? 그렇지 않다. 같은 기간에 다음 요인들이 동시에 변했을 수 있다:

  • 마케팅 캠페인으로 유입된 더 구매 의욕이 높은 고객
  • 계절 효과 (성수기 진입)
  • 다른 UI 개선 효과

관찰 연구(Observational Study)는 이러한 교란 요인을 통제할 수 없다. 실험 연구(Experimental Study)인 A/B 테스트는 무작위 배정을 통해 처치(버튼 색 변경) 외 모든 요인을 두 그룹에서 동질하게 만든다. 따라서 결과의 차이를 처치의 인과적 효과로 귀속시킬 수 있다.

관찰 연구:  "버튼 색 바꿈 → 전환율 상승"   (교란 요인 존재, 인과 불명확)
A/B 테스트: "버튼 색만 다른 두 그룹을 무작위 배정 → 차이 측정"  (인과 추론 가능)

2 핵심 프로세스

A/B 테스트는 다음 단계로 진행된다.

2.1 1단계: 가설 수립

실험 전에 검정할 가설을 명확하게 문서화한다. 실험 결과를 본 뒤에 가설을 수정하면 데이터 스누핑(Data Snooping)이 되어 통계적 의미가 없어진다.

가설 수립 예시 (결제 버튼 색 변경)

  • 변경 사항: 결제 버튼 색을 회색 → 초록색으로 변경
  • 귀무가설 (\(H_0\)): 두 그룹의 전환율 차이가 없다 (\(p_A = p_B\))
  • 대립가설 (\(H_1\)): 초록 버튼의 전환율이 더 높다 (\(p_B > p_A\), 단측 검정)
  • 1차 지표: 결제 전환율 (CVR)
  • 2차 지표: 평균 주문 금액, 결제 소요 시간
  • 가드레일 지표: 페이지 로드 시간, 오류율 (악화되면 안 되는 지표)
  • MDE: 전환율 5% 대비 0.5%p 개선 (상대적 10% 향상)
  • 유의 수준: \(\alpha = 0.05\), 검정력: \(1 - \beta = 0.80\)
중요

가드레일 지표(Guardrail Metric)의 중요성

1차 지표가 개선되더라도 가드레일 지표가 악화되면 전체 출시를 보류한다. 예: 버튼 색 변경이 전환율을 높였지만 페이지 로드 시간이 200ms 증가했다면, 장기적으로 다른 사용자 경험을 해칠 수 있다.

단측 검정 vs 양측 검정 선택 기준

상황 검정 방향 이유
“B가 A보다 나을 것이다” (방향 예측 있음) 단측 검정 검정력이 높지만, 반대 방향 효과는 탐지 못함
“B와 A가 다를 것이다” (방향 예측 없음) 양측 검정 더 보수적, 방향 무관하게 차이 탐지
실무 기본값 양측 검정 예상치 못한 악화도 탐지해야 하므로

2.2 2단계: 무작위 배정 (Randomization)

사용자를 각 그룹에 할당할 때 편향(Bias)이 개입되지 않도록 하는 것이 첫 번째 조건이다. 무작위 배정을 통해 사용자의 인구통계학적 특성이나 과거 행동 패턴이 두 그룹에 균등하게 분포된다.

무작위 배정이 중요한 이유는, 두 그룹 간에 관찰 가능한 변수뿐 아니라 관찰 불가능한 변수(Unobservable Confounders)까지 균형을 맞출 수 있는 유일한 방법이기 때문이다. 이는 관찰 연구(Observational Study)와 실험 연구(Experimental Study)를 구분 짓는 가장 핵심적인 차이이다.

2.3 3단계: 독립 변수와 종속 변수 설정

  • 독립 변수(Independent Variable): 실험에서 의도적으로 조작하는 요소이다. 예를 들어 특정 기능의 유무, UI 변경, 가격 정책 변경 등이 해당한다.
  • 종속 변수(Dependent Variable): 독립 변수의 변화에 따라 측정되는 결과 지표이다. 클릭률(CTR), 전환율(CVR), 체류 시간, 구매 금액 등이 해당한다.

실험 설계 시 하나의 독립 변수만 변경하는 것이 원칙이다. 동시에 여러 변수를 변경하면 어떤 변수가 결과에 영향을 미쳤는지 분리할 수 없다. 여러 변수를 동시에 테스트하려면 다변량 테스트(Multivariate Test, MVT)를 설계해야 한다.

2.4 4단계: 통계적 검정

수집된 데이터를 바탕으로 귀무가설(\(H_0: p_A = p_B\))을 기각할 수 있는지 확인한다.

  • 연속형 지표 (평균 체류 시간, 평균 구매 금액): \(t\)-test 사용
  • 이산형 지표 (클릭 여부, 전환 여부): \(\chi^2\)-test 또는 \(Z\)-test for proportions 사용

검정 결과 산출되는 \(p\)-value가 사전에 설정한 유의 수준(\(\alpha\))보다 작으면 귀무가설을 기각하고, 두 그룹 간 차이가 통계적으로 유의하다고 판단한다.


3 실험 신뢰도를 결정하는 핵심 지표

3.1 유의 수준 (\(\alpha\))과 오류 유형

유의 수준은 실제로 차이가 없는데 차이가 있다고 판정할 확률(제1종 오류, Type I Error)이다.

오류 유형 정리

\(H_0\) 사실 (효과 없음) \(H_0\) 거짓 (효과 있음)
\(H_0\) 기각 (유의) 제1종 오류 (False Positive) \(P = \alpha\) 올바른 결정 (True Positive) \(P = 1-\beta\)
\(H_0\) 채택 (비유의) 올바른 결정 (True Negative) \(P = 1-\alpha\) 제2종 오류 (False Negative) \(P = \beta\)

현실적 예시

  • 제1종 오류 (False Positive): 버튼 색이 전환율에 영향이 없는데 “통계적으로 유의하다”고 결론 내림 → 효과 없는 변경을 전체 배포함 → 개발 비용 낭비
  • 제2종 오류 (False Negative): 버튼 색이 실제로 효과 있는데 “차이 없다”고 결론 내림 → 좋은 기능을 폐기함 → 기회 비용 손실

일반적으로 \(\alpha = 0.05\)를 사용하며, 이는 “100번 실험하면 5번은 거짓 양성이 나올 수 있다”는 의미이다.

경고

\(\alpha\)를 지나치게 낮추면(예: 0.01) 제1종 오류는 줄어들지만, 실제 효과가 있는 변경 사항도 탐지하지 못하는 제2종 오류(\(\beta\))가 증가한다. 비즈니스 맥락에서 두 오류의 비용을 비교하여 적절한 균형점을 설정해야 한다.

3.2 통계적 검정력 (\(1-\beta\))

실제로 차이가 있을 때 이를 올바르게 탐지할 확률이다. 일반적으로 \(0.8\) 이상을 목표로 설정한다. 검정력이 낮으면 실제로 효과가 있는 기능 변경임에도 “효과 없음”으로 결론 내릴 위험이 크다.

검정력은 다음 세 가지 요인에 의해 결정된다:

\[1 - \beta = f(\alpha, \delta, n)\]

  • \(\alpha\): 유의 수준 (높을수록 검정력 증가)
  • \(\delta\): 효과 크기 (클수록 검정력 증가)
  • \(n\): 표본 크기 (클수록 검정력 증가)

직관: 검정력이 0.8이라는 것은 “실제 효과가 있는 실험을 100번 하면 80번은 올바르게 탐지한다”는 의미이다. 나머지 20번은 제2종 오류로 효과를 놓친다.

3.3 최소 탐지 가능 효과 (MDE, Minimum Detectable Effect)

비즈니스적으로 의미 있다고 판단하는 최소한의 지표 변화 폭이다. MDE가 작을수록 더 큰 표본이 필요하다.

예를 들어 전환율이 5%인 서비스에서 MDE를 1%p(상대적 20% 개선)로 설정하면, \(\alpha = 0.05\), \(1-\beta = 0.8\) 기준으로 약 그룹당 3,900명이 필요하다. MDE를 0.5%p로 줄이면 약 그룹당 15,700명으로 4배 증가한다.

MDE 설정 원칙: MDE는 비즈니스 관점에서 “이 정도 개선이면 출시할 가치가 있다”는 기준을 먼저 정하고, 그에 맞는 표본 크기를 역산한다. MDE를 작게 잡으면 더 민감한 실험이 되지만 더 오래 실험해야 한다.

3.4 표본 크기 산출 (Power Analysis)

실험 시작 전에 반드시 필요한 표본 크기를 계산해야 한다. 두 비율의 차이를 검정하는 경우:

\[n = \frac{(Z_{\alpha/2} + Z_\beta)^2 \cdot (p_A(1-p_A) + p_B(1-p_B))}{(p_A - p_B)^2}\]

여기서 \(p_A\)는 대조군의 기대 전환율, \(p_B\)는 실험군의 기대 전환율이다.

수치 예시: \(p_A = 0.05\), \(p_B = 0.06\), \(\alpha = 0.05\), \(1-\beta = 0.80\) (양측 검정)

\[Z_{\alpha/2} = Z_{0.025} = 1.96, \quad Z_\beta = Z_{0.20} = 0.842\]

\[n = \frac{(1.96 + 0.842)^2 \cdot (0.05 \times 0.95 + 0.06 \times 0.94)}{(0.05 - 0.06)^2} = \frac{7.849 \times 0.1039}{0.0001} \approx 8,155 \text{ (그룹당)}\]

힌트

Python의 statsmodels 라이브러리를 사용하면 간단하게 계산할 수 있다.

from statsmodels.stats.power import NormalIndPower
from statsmodels.stats.proportion import proportion_effectsize

# 전환율 5% → 6% 개선 탐지
effect_size = proportion_effectsize(0.05, 0.06)
analysis = NormalIndPower()
sample_size = analysis.solve_power(
    effect_size=effect_size,
    alpha=0.05,
    power=0.8,
    alternative='two-sided'
)
print(f"그룹당 필요 표본 수: {sample_size:.0f}")
# 출력: 그룹당 필요 표본 수: 3,844  (Cohen's h 기반 근사값)

4 수치 예시: 손으로 계산하는 A/B 검정

4.1 상황 설정

  • 대조군 A: 1,000명 노출, 50명 전환 → \(\hat{p}_A = 0.050\)
  • 실험군 B: 1,000명 노출, 63명 전환 → \(\hat{p}_B = 0.063\)
  • 전환율 차이: \(0.063 - 0.050 = 0.013\) (1.3%p)
  • 귀무가설: \(p_A = p_B\), 양측 검정, \(\alpha = 0.05\)

4.2 두 비율 차이의 Z-검정

1단계: 합동 비율(Pooled Proportion) 계산

\[\hat{p} = \frac{x_A + x_B}{n_A + n_B} = \frac{50 + 63}{1000 + 1000} = \frac{113}{2000} = 0.0565\]

2단계: 표준 오차 계산

\[SE = \sqrt{\hat{p}(1-\hat{p})\left(\frac{1}{n_A} + \frac{1}{n_B}\right)} = \sqrt{0.0565 \times 0.9435 \times \frac{2}{1000}} = \sqrt{0.0001067} \approx 0.01033\]

3단계: Z-통계량 계산

\[Z = \frac{\hat{p}_B - \hat{p}_A}{SE} = \frac{0.063 - 0.050}{0.01033} \approx 1.259\]

4단계: p-value 산출 (양측)

\[p\text{-value} = 2 \times P(Z > 1.259) = 2 \times (1 - \Phi(1.259)) \approx 2 \times 0.104 = 0.208\]

결론: \(p = 0.208 > \alpha = 0.05\) → 귀무가설을 기각하지 못한다. 1.3%p의 차이가 관찰되었지만, 이 표본 크기(각 1,000명)에서는 통계적으로 유의하지 않다. 더 많은 표본이 필요하거나, MDE를 1.3%p 이상으로 올려야 한다.

노트

통계적 유의성 ≠ 실무적 유의성

p-value가 작다고 해서 효과가 크다는 의미는 아니다. 표본이 충분히 크면 0.01%p 차이도 통계적으로 유의해질 수 있다. 반드시 효과 크기(Effect Size)와 함께 해석해야 한다.

기준 설명
p-value < 0.05 우연에 의한 결과일 가능성이 낮다
효과 크기 (예: 1.3%p) 실제 비즈니스 영향이 얼마나 큰가
95% 신뢰구간 효과의 불확실성 범위

세 가지를 함께 보고해야 의사결정이 가능하다.


5 실험 전 검증: A/A 테스트

5.1 A/A 테스트란

실험 시스템 자체의 신뢰성을 검증하기 위해, 두 그룹 모두에게 동일한 버전(A)을 보여주는 테스트이다.

A/A 테스트에서 통계적으로 유의한 차이가 나오면 실험 시스템에 문제가 있다는 신호이다:

  • 해시 함수의 편향: 특정 사용자 군이 한 버킷에 몰리는 현상
  • 로깅 오류: 한 그룹의 이벤트가 누락되거나 중복 집계
  • 쿠키 공유: 여러 사용자가 같은 기기를 사용하는 경우

A/A 테스트 결과가 기대대로라면 (\(p\)-value가 균등하게 분포, 즉 5%의 확률로만 유의한 결과가 나옴), 본 A/B 실험을 신뢰할 수 있다.

import numpy as np
from scipy import stats

np.random.seed(42)
n_simulations = 1000
p_true = 0.05  # 두 그룹 모두 동일한 전환율
n_per_group = 1000

false_positive_count = 0
for _ in range(n_simulations):
    group_a = np.random.binomial(1, p_true, n_per_group)
    group_b = np.random.binomial(1, p_true, n_per_group)
    _, p_val = stats.ttest_ind(group_a, group_b)
    if p_val < 0.05:
        false_positive_count += 1

print(f"A/A 테스트 거짓 양성 비율: {false_positive_count/n_simulations:.3f}")
# 기대값: ~0.05 (5%), 크게 벗어나면 시스템 문제

6 실무 구현: Sticky Bucket과 해시 기반 배정

6.1 왜 단순 랜덤이 아닌가

단순히 랜덤하게 나누기만 하면 두 가지 문제가 발생한다:

  1. 표본 크기가 작을 때 우연한 불균형: 소규모 트래픽에서는 특정 사용자 군이 한쪽에 쏠릴 수 있다.
  2. 경험의 비일관성: 같은 사용자가 접속할 때마다 다른 버전을 보면 실험 결과가 오염된다.

6.2 해시 기반 배정 (Hash-based Assignment)

실무에서는 해시 함수(예: MurmurHash)를 활용하여 사용자 ID를 일관되게 특정 버킷에 할당한다.

import hashlib

def assign_bucket(user_id: str, experiment_id: str, num_buckets: int = 100) -> int:
    """사용자를 결정론적으로 버킷에 배정한다."""
    key = f"{user_id}:{experiment_id}"
    hash_val = int(hashlib.md5(key.encode()).hexdigest(), 16)
    return hash_val % num_buckets

def assign_variant(user_id: str, experiment_id: str,
                   treatment_pct: int = 50) -> str:
    """실험군/대조군 배정 (treatment_pct % 가 실험군)."""
    bucket = assign_bucket(user_id, experiment_id)
    return "treatment" if bucket < treatment_pct else "control"

# 사용 예시
user_id = "user_12345"
experiment_id = "btn_color_v2"
variant = assign_variant(user_id, experiment_id)
print(f"{user_id}{variant}")
# 동일한 user_id, experiment_id 조합은 항상 같은 결과 반환

이 방식의 장점은 다음과 같다:

  • 결정론적(Deterministic): 같은 사용자 ID는 항상 같은 그룹에 할당된다.
  • Sticky Bucket: 실험 기간 내내 동일한 경험을 유지한다.
  • 독립적: 다른 실험의 배정 결과에 영향을 받지 않는다 (experiment_id를 해시 입력에 포함하기 때문).

6.3 트래픽 램프업 (Traffic Ramp-up)

실무에서는 처음부터 50:50으로 배정하지 않고, 점진적으로 실험군 비율을 높이는 전략을 사용한다:

  • 1단계: 1%의 트래픽으로 시작하여 시스템 안정성 확인
  • 2단계: 5% → 10% → 25%로 점진적 확대
  • 3단계: 50%까지 확대하여 본 실험 진행

이를 통해 실험군에 치명적인 버그가 있을 경우 소수의 사용자만 영향을 받게 된다.


7 실험 결과 분석 코드

7.1 두 비율의 Z-검정 (이분형 지표)

import numpy as np
from scipy import stats

def ab_test_proportions(n_a, x_a, n_b, x_b, alpha=0.05, alternative='two-sided'):
    """
    두 비율 차이에 대한 Z-검정.

    Parameters
    ----------
    n_a, x_a : 대조군 노출 수, 전환 수
    n_b, x_b : 실험군 노출 수, 전환 수
    alpha     : 유의 수준
    alternative: 'two-sided', 'larger' (B > A), 'smaller'
    """
    p_a = x_a / n_a
    p_b = x_b / n_b
    p_pool = (x_a + x_b) / (n_a + n_b)

    se = np.sqrt(p_pool * (1 - p_pool) * (1/n_a + 1/n_b))
    z_stat = (p_b - p_a) / se

    if alternative == 'two-sided':
        p_value = 2 * (1 - stats.norm.cdf(abs(z_stat)))
    elif alternative == 'larger':
        p_value = 1 - stats.norm.cdf(z_stat)
    else:
        p_value = stats.norm.cdf(z_stat)

    # 95% 신뢰구간 (차이에 대한)
    se_diff = np.sqrt(p_a*(1-p_a)/n_a + p_b*(1-p_b)/n_b)
    ci_low  = (p_b - p_a) - 1.96 * se_diff
    ci_high = (p_b - p_a) + 1.96 * se_diff

    print(f"대조군 전환율: {p_a:.4f} ({x_a}/{n_a})")
    print(f"실험군 전환율: {p_b:.4f} ({x_b}/{n_b})")
    print(f"절대 차이: {p_b - p_a:+.4f}")
    print(f"상대 개선율: {(p_b - p_a)/p_a * 100:+.1f}%")
    print(f"Z-통계량: {z_stat:.4f}")
    print(f"p-value: {p_value:.4f}")
    print(f"95% CI: [{ci_low:.4f}, {ci_high:.4f}]")
    print(f"결론: {'유의 (H0 기각)' if p_value < alpha else '비유의 (H0 채택 불가)'}")

# 예시: 대조군 전환율 5%, 실험군 전환율 6.3%
ab_test_proportions(n_a=1000, x_a=50, n_b=1000, x_b=63)

7.2 연속형 지표의 t-검정 (평균 비교)

import numpy as np
from scipy import stats

def ab_test_means(control_data, treatment_data, alpha=0.05):
    """
    두 그룹 평균 차이에 대한 Welch's t-검정.
    등분산 가정이 없어 더 일반적으로 적용 가능하다.
    """
    n_c, n_t = len(control_data), len(treatment_data)
    mean_c, mean_t = np.mean(control_data), np.mean(treatment_data)
    std_c, std_t = np.std(control_data, ddof=1), np.std(treatment_data, ddof=1)

    t_stat, p_value = stats.ttest_ind(control_data, treatment_data, equal_var=False)

    se_diff = np.sqrt(std_c**2/n_c + std_t**2/n_t)
    ci_low  = (mean_t - mean_c) - 1.96 * se_diff
    ci_high = (mean_t - mean_c) + 1.96 * se_diff

    print(f"대조군: 평균={mean_c:.2f}, 표준편차={std_c:.2f}, n={n_c}")
    print(f"실험군: 평균={mean_t:.2f}, 표준편차={std_t:.2f}, n={n_t}")
    print(f"평균 차이: {mean_t - mean_c:+.2f}")
    print(f"t-통계량: {t_stat:.4f}")
    print(f"p-value: {p_value:.4f}")
    print(f"95% CI: [{ci_low:.2f}, {ci_high:.2f}]")
    print(f"결론: {'유의 (H0 기각)' if p_value < alpha else '비유의'}")

# 예시: 평균 세션 시간 비교
np.random.seed(42)
control   = np.random.exponential(scale=120, size=500)   # 평균 120초
treatment = np.random.exponential(scale=130, size=500)   # 평균 130초

ab_test_means(control, treatment)

8 흔한 실수와 함정

8.1 1. Peeking Problem (조기 종료)

실험 도중 결과를 반복적으로 확인하고, p-value가 유의해지는 순간 실험을 종료하는 관행이다. 이는 제1종 오류를 크게 높인다.

왜 문제인가: 표본이 누적되는 과정에서 p-value는 자연스럽게 오르락내리락한다. 충분히 자주 확인하면 어느 순간 우연히 \(p < 0.05\)가 나온다.

실험 계획: n=5,000에서 한 번 검정
실제 행동: n=100, 200, 500, 1000, ... 마다 확인하다가 유의한 순간 종료

실제 제1종 오류율: 0.05 (계획) → 최대 0.30 이상 (실제)

대안: - 사전에 정한 표본 크기에 도달할 때까지 기다린다 - Sequential Testing (예: Alpha-spending 함수)을 사용하면 중간 확인이 가능하다

8.2 2. Multiple Testing (다중 검정 문제)

여러 지표를 동시에 검정하면 제1종 오류 확률이 누적된다.

\[P(\text{적어도 하나 False Positive}) = 1 - (1 - \alpha)^m\]

\(\alpha = 0.05\), \(m = 20\) (20개 지표) 이면:

\[P \approx 1 - 0.95^{20} \approx 0.64\]

즉, 20개 지표를 동시에 검정하면 아무 효과가 없어도 64%의 확률로 적어도 하나의 유의한 결과가 나온다.

대안: - Bonferroni 보정: \(\alpha' = \alpha / m\) (보수적) - FDR 제어: Benjamini-Hochberg 방법 (덜 보수적) - 사전에 1차 지표 1~2개를 지정하고, 나머지는 탐색적(secondary)으로 분류

8.3 3. Simpson’s Paradox (심슨의 역설)

전체 수준에서는 B가 더 낫지만, 세그먼트별로 보면 A가 더 나은 현상이다.

                모바일      데스크탑      전체
대조군 CVR     2%          8%           5%
실험군 CVR     1.5%        7%           5.5%  ← 전체는 실험군이 높음!

이유: 실험군에 모바일 사용자(전환율 낮음)가 더 많이 배정됨

무작위 배정이 완벽히 이루어졌다면 이 문제는 발생하지 않지만, 세그먼트별로 분석할 때 반드시 확인이 필요하다.

8.4 4. 효과 크기를 무시한 결정

p-value가 0.001이라도 효과가 비즈니스적으로 의미 없을 수 있다.

대조군 CVR: 10.000%
실험군 CVR: 10.001%  (0.001%p 차이)
p-value: 0.002  (표본 n = 10,000,000)

→ 통계적으로 유의하지만, 출시할 가치가 있는가?

항상 절대적 효과 크기, 상대적 개선율, 95% 신뢰구간을 함께 보고한다.


9 한계와 대안

9.1 SUTVA 위배 문제

A/B 테스트의 근본 가정인 SUTVA(Stable Unit Treatment Value Assumption)는 “한 사용자의 처치 여부가 다른 사용자의 결과에 영향을 미치지 않는다”는 가정이다. 그러나 다음과 같은 경우 이 가정이 위배된다:

  • 네트워크 효과: SNS에서 실험군 사용자가 새 기능으로 콘텐츠를 생성하면, 대조군 사용자도 해당 콘텐츠를 소비하게 된다.
  • 마켓플레이스: 실험군에서 가격을 인하하면, 대조군 사용자의 구매 행동에도 영향을 미친다.
  • 공유 자원: 실험군이 서버 자원을 더 많이 사용하면, 대조군의 응답 속도가 느려질 수 있다.

9.2 신기 효과 (Novelty Effect)

새로운 UI나 기능을 접한 사용자가 일시적으로 관심을 보이는 현상이다. 단기적 지표 상승이 지속 가능한 개선인지, 일시적 호기심인지 구분하기 위해 실험 기간을 충분히 확보해야 한다 (일반적으로 최소 2주, 이상적으로 4주).

9.3 대안적 실험 설계

문제 상황 대안 핵심 원리
네트워크 간섭 클러스터 랜덤화(Cluster Randomization) 개별 사용자 대신 그래프 클러스터(예: 학교, 지역) 단위로 배정
시간적 간섭 스위치백 테스트(Switchback Test) 시간대를 번갈아 가며 A/B를 적용 (예: 오전 A, 오후 B)
장기 효과 측정 홀드아웃 분석(Holdback Analysis) 실험 종료 후에도 소규모 대조군을 유지하여 장기 효과 모니터링
다중 변수 테스트 다변량 테스트(MVT) 여러 독립 변수의 조합을 동시에 테스트

10 외부 요인의 통제: CUPED

실험 기간 중 발생하는 외부 요인(계절성, 대규모 마케팅 캠페인, 경쟁사 이벤트 등)이 완전히 통제되지 않을 경우, \(p\)-value가 낮더라도 실제 인과관계를 보장하기 어려울 수 있다.

CUPED(Controlled-experiment Using Pre-Experiment Data)는 실험 이전 기간의 데이터를 공변량(Covariate)으로 활용하여 분산을 줄이고, 동일한 표본 크기에서 더 높은 검정력을 확보한다.

10.1 핵심 아이디어

실험 이전 행동이 실험 중 행동을 예측할 수 있다면 (예: 지난 주 구매 횟수가 이번 주 구매 횟수를 예측), 공변량으로 분산의 일부를 설명하여 검정 대상 분산을 줄인다. 표본 크기는 그대로이지만 신호 대비 잡음(SNR)이 개선된다.

\[\hat{\tau}_{\text{CUPED}} = \hat{\tau} - \theta(\bar{X}_{\text{treatment}} - \bar{X}_{\text{control}})\]

여기서 \(\bar{X}\)는 실험 이전 기간의 지표 평균이고, \(\theta\)는 공변량의 회귀 계수이다.

\[\theta = \frac{\text{Cov}(Y, X_{\text{pre}})}{\text{Var}(X_{\text{pre}})}\]

CUPED를 적용하면 분산이 최대 50%까지 감소하여, 실질적으로 표본 크기를 2배로 늘린 것과 동일한 효과를 얻을 수 있다.

10.2 CUPED 구현 코드

import numpy as np
from scipy import stats

np.random.seed(42)
n = 1000

# 실험 전 구매 횟수 (공변량)
x_pre_control   = np.random.poisson(lam=3, size=n)
x_pre_treatment = np.random.poisson(lam=3, size=n)

# 실험 중 구매 횟수 (처치 효과 = 0.5)
true_effect = 0.5
y_control   = x_pre_control   * 0.8 + np.random.normal(0, 2, n)
y_treatment = x_pre_treatment * 0.8 + true_effect + np.random.normal(0, 2, n)

# --- 일반 t-검정 ---
t_raw, p_raw = stats.ttest_ind(y_control, y_treatment)

# --- CUPED 보정 ---
X_pre = np.concatenate([x_pre_control, x_pre_treatment])
Y     = np.concatenate([y_control, y_treatment])

# theta 추정: Cov(Y, X_pre) / Var(X_pre)
theta = np.cov(Y, X_pre)[0, 1] / np.var(X_pre, ddof=1)

# 보정된 결과 변수
y_control_adj   = y_control   - theta * (x_pre_control   - X_pre.mean())
y_treatment_adj = y_treatment - theta * (x_pre_treatment - X_pre.mean())

t_cuped, p_cuped = stats.ttest_ind(y_control_adj, y_treatment_adj)

print(f"일반 t-검정:  t={t_raw:.3f}, p={p_raw:.4f}")
print(f"CUPED 검정:  t={t_cuped:.3f}, p={p_cuped:.4f}")
print(f"분산 감소율: {1 - np.var(y_control_adj)/np.var(y_control):.1%}")
# CUPED 적용 시 p-value가 더 작아지고 분산이 줄어든 것을 확인

11 실험 체크리스트

11.1 실험 시작 전

11.2 실험 중

11.3 실험 종료 후


12 핵심 요약

개념 핵심 내용
무작위 배정 관찰 불가능한 교란 요인까지 통제하는 유일한 방법
제1종 오류 (\(\alpha\)) False Positive, 효과 없는 것을 있다고 판단
제2종 오류 (\(\beta\)) False Negative, 효과 있는 것을 놓침
검정력 실제 효과를 탐지할 확률, \(\alpha \cdot \delta \cdot n\)의 함수
MDE 비즈니스 관점에서 의미 있는 최소 변화폭
A/A 테스트 실험 시스템 자체의 신뢰성 검증
Peeking 중간 확인 후 조기 종료 → 제1종 오류 급증
CUPED 실험 전 데이터로 분산을 줄여 검정력 향상

Subscribe

Enjoy this blog? Get notified of new posts by email: