AB Compass에서 가장 공들인 부분이 두 가지입니다. 같은 사용자를 항상 같은 그룹에 배정하는 결정적 해싱과, 두 그룹의 전환율 차이가 통계적으로 의미 있는지 판단하는 통계 엔진입니다. 둘 다 상용 툴에서는 자동으로 처리해주는 기능인데, 직접 구현하면서 각 수치가 어디서 나오는지 코드 수준에서 확인할 수 있었습니다.
오늘은 두 모듈의 구현 과정을 자세히 정리해보겠습니다.
1. 결정적 그룹 배정

A/B 테스트에서 그룹 배정에 요구되는 조건이 있습니다. 같은 사용자는 재방문해도 항상 같은 그룹에 있어야 하고, 여러 실험이 동시에 진행될 때 각 실험의 배정이 서로 독립적이어야 합니다. 단순히 random으로 배정하면 재방문 시 그룹이 바뀔 수 있습니다. 그렇다고 서버에 배정 이력을 저장하는 방식은 관리 부담이 생깁니다.
해시 기반 결정적 배정이 이 두 조건을 모두 만족합니다. 같은 입력값은 항상 같은 해시 값을 내기 때문에, 서버 저장 없이도 재현이 가능합니다.
1.1 구현 방식
사용자 ID와 실험별 고유 salt를 user_id:salt 형태로 합쳐서 MD5로 해싱합니다. 해싱 결과는 16진수 문자열인데, 이걸 정수로 변환한 뒤 10000으로 나눈 나머지를 다시 10000으로 나누면 0~1 사이의 버킷 값이 나옵니다. 이 버킷 값이 설정한 배정 비율(variant_a_ratio)보다 작으면 A 그룹, 크면 B 그룹으로 배정합니다.
import hashlib
def assign_variant(user_id: str, experiment_salt: str, variant_a_ratio: float) -> str:
key = f"{user_id}:{experiment_salt}"
hash_int = int(hashlib.md5(key.encode()).hexdigest(), 16)
bucket = (hash_int % 10000) / 10000
return "A" if bucket < variant_a_ratio else "B"
예를 들어 user_id가 "user_001"이고 salt가 "abc123"이면, "user_001:abc123"을 MD5로 해싱합니다. 해시 결과를 정수로 바꾸면 수십 자리 숫자가 나오는데, 이를 10000으로 나눈 나머지를 다시 10000으로 나누면 예를 들어 0.3742 같은 값이 나옵니다. 배정 비율이 0.5라면 0.3742 < 0.5이므로 A 그룹에 배정됩니다. 이 사용자가 언제 다시 들어와도 "user_001:abc123" 조합은 항상 같은 해시 값을 내기 때문에 결과가 바뀌지 않습니다.
1.2 salt가 필요한 이유
salt 없이 user_id만 해싱하면 문제가 생깁니다. 실험 A와 실험 B가 동시에 진행될 때 같은 user_id는 두 실험에서 항상 같은 그룹에 배정됩니다. 실험 A에서 B 그룹인 사용자가 실험 B에서도 항상 B 그룹이라면, 두 실험의 배정이 독립적이지 않습니다. 실험 A의 효과가 실험 B 결과에 섞일 수 있습니다.
실험마다 다른 salt를 쓰면 같은 user_id라도 실험별로 다른 해시 값이 나옵니다. AB Compass는 실험 생성 시 secrets.token_hex(8)로 16자리 랜덤 salt를 자동 생성해서 각 실험에 할당합니다.
2. 통계 엔진 구현

통계 분석 모듈은 세 가지 함수로 구성됩니다. 유의성 검정, SRM 진단, 필요 표본 수 계산입니다. scipy의 검정 함수를 그대로 쓰지 않고 수식을 직접 구현한 이유는 각 계산 단계에서 어떤 값이 나오는지 확인하고 싶었기 때문입니다.
2.1 2-표본 z-검정
두 그룹의 전환율 차이가 통계적으로 유의한지 판단합니다. 핵심은 합동 표준오차(Pooled Standard Error)를 계산해서 z-score를 구하는 것입니다. 합동 표준오차는 두 그룹의 합동 전환율을 기반으로 계산합니다. 두 그룹이 같은 전환율을 가진다는 귀무가설 아래에서 기대되는 표준오차이기 때문입니다.
import math
from scipy import stats
def analyze_experiment(a_total, a_converted, b_total, b_converted, alpha=0.05):
p_a = a_converted / a_total
p_b = b_converted / b_total
diff = p_b - p_a
# 합동 전환율과 합동 표준오차
p_pool = (a_converted + b_converted) / (a_total + b_total)
se_pool = math.sqrt(p_pool * (1 - p_pool) * (1/a_total + 1/b_total))
# z-score와 p-value
z = diff / se_pool
p_value = 2 * (1 - stats.norm.cdf(abs(z)))
# 신뢰구간 (각 그룹 독립 표준오차 사용)
se_diff = math.sqrt(p_a*(1-p_a)/a_total + p_b*(1-p_b)/b_total)
z_crit = stats.norm.ppf(1 - alpha/2)
ci_lower = diff - z_crit * se_diff
ci_upper = diff + z_crit * se_diff
# lift: 기준 그룹 대비 상대 개선율
lift = diff / p_a if p_a > 0 else 0
return {
"p_value": p_value,
"z_score": z,
"ci_lower": ci_lower,
"ci_upper": ci_upper,
"lift": lift,
"significant": p_value < alpha,
}
신뢰구간과 z-검정에서 표준오차를 다르게 계산하는 점이 처음엔 헷갈렸습니다. z-검정에서는 귀무가설 아래 합동 전환율을 쓰고, 신뢰구간에서는 각 그룹의 실제 전환율을 독립적으로 씁니다. 목적이 다르기 때문입니다. z-검정은 "두 그룹이 같다"는 가정 아래에서 차이를 판단하고, 신뢰구간은 실제 관찰된 차이의 불확실성 범위를 표현합니다.
2.2 SRM 진단
SRM(Sample Ratio Mismatch)은 실제 배정 비율이 설정값과 다르게 나오는 상황입니다. 배정 비율을 50:50으로 설정했는데 실제로 45:55로 나왔다면, 배정 로직에 문제가 있거나 특정 그룹의 이탈이 다르게 발생했을 가능성이 있습니다. 이 상태에서 유의성 검정을 해도 결과를 신뢰할 수 없습니다.
카이제곱 검정으로 진단합니다. 설정한 배정 비율대로 사용자가 배정됐다면 기대되는 각 그룹의 수를 계산하고, 실제 관찰된 수와 비교합니다. 차이가 클수록 카이제곱 통계량이 커지고 p-value가 작아집니다. AB Compass는 p-value 0.001 미만을 SRM으로 판단합니다. 일반적인 유의수준 0.05보다 엄격한 기준을 쓴 이유는, SRM은 실험 자체의 신뢰성 문제이기 때문에 보수적으로 판단하는 게 낫다고 생각했기 때문입니다.
def check_srm(a_total, b_total, expected_a_ratio=0.5, threshold=0.001):
total = a_total + b_total
expected_a = total * expected_a_ratio
expected_b = total * (1 - expected_a_ratio)
# 카이제곱 통계량 직접 계산
chi2 = ((a_total - expected_a)**2 / expected_a) + \
((b_total - expected_b)**2 / expected_b)
p_value = 1 - stats.chi2.cdf(chi2, df=1)
return {
"chi2": chi2,
"p_value": p_value,
"has_srm": p_value < threshold,
"actual_a_ratio": a_total / total,
}
2.3 필요 표본 수 계산
실험을 시작하기 전에 얼마나 많은 표본이 필요한지 미리 계산합니다. 기준 전환율(baseline)과 탐지하고 싶은 최소 효과 크기(MDE)를 입력하면 그룹당 필요한 표본 수가 나옵니다. 유의수준 0.05, 검정력 0.8을 기본값으로 씁니다.
def required_sample_size(baseline, mde, alpha=0.05, power=0.8):
p1 = baseline
p2 = baseline * (1 + mde) # 상대적 MDE
p_avg = (p1 + p2) / 2
z_alpha = stats.norm.ppf(1 - alpha/2) # 1.96 (alpha=0.05)
z_beta = stats.norm.ppf(power) # 0.84 (power=0.8)
n = (
z_alpha * math.sqrt(2 * p_avg * (1 - p_avg))
+ z_beta * math.sqrt(p1*(1-p1) + p2*(1-p2))
)**2 / (p2 - p1)**2
return int(math.ceil(n))
MDE를 상대적 lift로 정의한 점이 포인트입니다. 기준 전환율이 10%일 때 MDE 0.05는 절대적 5%p 개선이 아니라 상대적 5% 개선, 즉 10% → 10.5%를 탐지한다는 의미입니다. 절대적 MDE보다 상대적 MDE가 비즈니스 맥락에서 더 직관적입니다.
3. 분석 결과 해석 방법
세 가지 지표를 함께 봐야 올바른 결론을 낼 수 있습니다.
| 상황 | 판단 | 액션 |
|---|---|---|
| 유의 + SRM 없음 + 표본 충분 | ✅ 신뢰할 수 있는 결과 | 배포 결정 가능 |
| 유의 + SRM 있음 | ⚠️ 결과 신뢰 불가 | 배정 로직 점검 후 재실험 |
| 유의 + 표본 부족 | ⚠️ 조기 종료 위험 | 표본 충분해질 때까지 대기 |
| 비유의 + 표본 충분 | ✅ 효과 없음으로 판단 | 변경안 미배포 |
| 비유의 + 표본 부족 | ❓ 판단 보류 | 표본 더 모은 후 재판단 |
정리
수식을 직접 코드로 옮기다 보니 교과서에서는 당연하게 넘겼던 부분들이 새롭게 보였습니다. z-검정과 신뢰구간에서 표준오차를 다르게 계산하는 이유, SRM 기준으로 0.001을 쓰는 이유, MDE를 상대적으로 정의하는 이유 등을 직접 고민하면서 결정해야 했습니다. 3편에서는 트래픽 시뮬레이터와 Gemini API를 연동한 AI 실험 설계 모듈 구현을 다루겠습니다. 😊
'Project' 카테고리의 다른 글
| [AB Compass] 시뮬레이터와 AI 설계 자동화 (0) | 2026.06.11 |
|---|---|
| [AB Compass] A/B 테스트 툴, 직접 만들어보기 (0) | 2026.06.11 |
| [Team Project] 네이버 플레이스 광고/체험단 리뷰 판별 모델 개발 - 모델링 및 페이지 표출 (3) (2) | 2026.04.10 |
| [Team Project] 네이버 플레이스 광고/체험단 리뷰 판별 모델 개발 - EDA (2) (0) | 2026.01.19 |
| [Kaggle] IEEE-CIS Fraud Detection - 모델링 (3) (0) | 2026.01.13 |
HELLO WORLD
안녕하세요. 데이터로 말하는 분석가 모모입니다.
데이터를 구조화하고 분석하는 과정과 실무에 활용되는 도구 중심의 내용을 기록합니다.