2편에서 그룹 배정과 통계 엔진을 다뤘는데, 이 두 모듈만으로는 실험을 완전히 돌려볼 수가 없습니다. 실제 트래픽이 없으면 테스트할 데이터가 없기 때문입니다. 그래서 만든 게 트래픽 시뮬레이터입니다. 가상 사용자를 만들어서 노출과 전환 이벤트를 생성하면 통계 분석 모듈을 실제처럼 돌려볼 수 있습니다.
여기에 Gemini API를 연동해서 자연어 아이디어를 실험 설계안으로 자동 변환하는 기능도 추가했습니다. 오늘은 두 모듈의 구현 과정을 정리해보겠습니다. 🤖
1. 트래픽 시뮬레이터
실제 A/B 테스트 환경에서는 사용자들이 실험에 노출되고, 그 중 일부가 전환(구매, 클릭, 회원가입 등)을 합니다. 시뮬레이터는 이 과정을 코드로 재현합니다. 가상 사용자를 생성하고, 2편에서 만든 해시 배정으로 A/B 그룹을 나눈 뒤, 각 그룹의 전환 확률에 따라 전환 여부를 결정합니다.
1.1 시뮬레이션 로직

시뮬레이터의 핵심은 그룹별 전환 확률을 다르게 설정하는 겁니다. A 그룹은 기준 전환율(baseline_rate)을, B 그룹은 기준 전환율에 예상 효과(treatment_effect)를 적용한 값을 씁니다. treatment_effect는 상대적 lift로 정의했기 때문에 baseline이 0.1이고 effect가 0.05라면 B 그룹의 전환율은 0.1 × 1.05 = 0.105가 됩니다.
def simulate_traffic(experiment, num_users, seed=None):
if seed is not None:
random.seed(seed)
salt = experiment["salt"]
ratio = experiment["variant_a_ratio"]
baseline = experiment["baseline_rate"]
effect = experiment["treatment_effect"]
rate_a = baseline
rate_b = baseline * (1 + effect) # 상대적 lift 적용
for i in range(num_users):
user_id = f"sim_user_{i}"
variant = assign_variant(user_id, salt, ratio)
# 그룹별 전환 확률로 전환 여부 결정
conv_rate = rate_a if variant == "A" else rate_b
converted = random.random() < conv_rate
각 가상 사용자에게 sim_user_0, sim_user_1 형태의 ID를 부여합니다. 이 ID가 2편에서 구현한 해시 배정 함수로 들어가서 A/B 그룹이 결정됩니다. 실제 사용자처럼 ID 기반으로 배정하기 때문에 배정 로직을 그대로 검증할 수 있습니다.
1.2 재현성과 누적 시뮬레이션
seed를 고정하면 같은 조건에서 항상 같은 결과가 나옵니다. 통계 로직을 디버깅할 때 재현성이 중요하기 때문에 seed 고정 옵션을 넣었습니다.
누적 시뮬레이션도 지원합니다. 처음에 1000명으로 시뮬레이션하고 나서 추가로 500명을 더 돌리면, 기존 데이터에 이어서 쌓입니다. 이미 배정된 사용자 수를 start_idx로 추적해서 새 사용자 ID가 기존과 겹치지 않게 합니다. 실제 실험처럼 트래픽이 점진적으로 쌓이는 상황을 재현할 수 있어서, 조기 종료 위험을 시뮬레이션해보는 데 유용합니다.
# 기존 데이터 이어서 쌓기
existing = storage.load_exposures(experiment["id"])
start_idx = len(existing) # 기존 사용자 수부터 이어서 ID 생성
for i in range(num_users):
user_id = f"sim_user_{start_idx + i}"
...
2. AI 실험 설계 자동화

"결제 버튼 색상을 바꾸면 전환율이 오를까?" 같은 아이디어가 있어도 이걸 실험 설계안으로 구조화하려면 생각보다 신경 쓸 게 많습니다. 측정 지표를 명확히 하고, 실험 기간을 정하고, 통제해야 할 변수를 정리하고, 발생할 수 있는 리스크를 체크해야 합니다. Gemini API로 이 과정을 자동화했습니다.
2.1 시스템 프롬프트 설계
AI가 일관된 형태의 설계안을 출력하게 만드는 게 핵심이었습니다. 자유 형식 텍스트로 받으면 앱에서 파싱하기 어렵기 때문에, 시스템 프롬프트로 JSON 스키마를 지정해서 항상 같은 구조로 출력하도록 강제했습니다.
SYSTEM_PROMPT = """
You are an A/B testing experiment design assistant for a Korean product team.
Return only valid JSON. Do not wrap the JSON in markdown.
Schema:
{
"name": "short experiment name in Korean",
"hypothesis": "structured hypothesis in Korean",
"variant_a": "control description",
"variant_b": "treatment description",
"primary_metric": "main metric",
"baseline_rate": 0.10,
"treatment_effect": 0.05,
"variant_a_ratio": 0.5,
"duration_days": 14,
"risk_notes": ["risk or caveat 1", "risk or caveat 2"]
}
"""
baseline_rate와 treatment_effect를 숫자로 받도록 지정한 게 포인트입니다. AI가 생성한 설계안을 앱에서 바로 실험 입력값으로 쓸 수 있어야 하기 때문에, 텍스트가 아닌 숫자 형태로 강제했습니다.
2.2 JSON 파싱과 예외 처리
AI 응답이 항상 깔끔한 JSON으로 오지 않습니다. 마크다운 코드 블록으로 감싸거나, JSON 앞뒤에 설명 텍스트가 붙는 경우가 있습니다. 이런 경우를 처리하는 파싱 함수를 별도로 만들었습니다.
def _strip_markdown_json(text: str) -> str:
# ```json ... ``` 형태로 감싸진 경우 추출
fenced = re.search(
r"```(?:json)?\s*(.*?)```",
text,
flags=re.DOTALL | re.IGNORECASE
)
if fenced:
return fenced.group(1).strip()
# JSON 객체만 추출 ({ ... } 범위)
start = text.find("{")
end = text.rfind("}")
if start != -1 and end != -1 and end > start:
return text[start:end + 1].strip()
return text
파싱 후에도 값이 유효한지 검증합니다. baseline_rate, treatment_effect, variant_a_ratio는 0~1 사이 숫자여야 하고, duration_days는 정수여야 합니다. AI가 잘못된 값을 넣어도 앱이 깨지지 않도록 clamp 처리를 합니다.
def _normalize_design(result: dict) -> dict:
defaults = {
"baseline_rate": 0.10,
"treatment_effect": 0.05,
"variant_a_ratio": 0.5,
"duration_days": 14,
"risk_notes": [],
}
normalized = {**defaults, **(result or {})}
# 범위를 벗어난 값 보정
normalized["baseline_rate"] = _clamp_float(normalized["baseline_rate"], 0.001, 0.999)
normalized["treatment_effect"] = _clamp_float(normalized["treatment_effect"], 0.001, 1.0)
normalized["variant_a_ratio"] = _clamp_float(normalized["variant_a_ratio"], 0.001, 0.999)
normalized["duration_days"] = max(1, int(float(normalized["duration_days"] or 14)))
return normalized
3. 회고
3.1 직접 구현하면서 알게 된 것들
상용 툴을 쓸 때는 몰랐던 것들이 직접 구현하면서 명확해졌습니다. 가장 인상적이었던 건 SRM입니다. "배정 비율이 다르게 나오면 실험이 잘못됐다"는 말은 알고 있었는데, 실제로 카이제곱 검정으로 어떻게 잡아내는지 코드로 짜보고 나니 왜 이 방법을 쓰는지 이해됐습니다. z-검정과 신뢰구간에서 표준오차를 다르게 계산해야 한다는 것도 직접 구현해보기 전까지는 몰랐습니다.
시뮬레이터를 만들면서는 조기 종료 위험이 얼마나 실질적인 문제인지 체감했습니다. seed를 고정하고 표본을 100명씩 추가하면서 p-value가 어떻게 변하는지 보면, 표본이 적을 때 p-value가 우연히 0.05 아래로 떨어지는 경우가 꽤 자주 나타납니다. 이 상태에서 실험을 종료하면 잘못된 결론이 나올 수 있다는 걸 눈으로 확인하니까 훨씬 와닿았습니다.
3.2 아쉬운 점과 다음 목표
이번 구현에서 빠진 것들이 있습니다. CUPED(Controlled-experiment Using Pre-Experiment Data)는 사전 데이터를 활용해서 분산을 줄이고 필요 표본 수를 낮추는 기법인데, 구현 복잡도 때문에 이번 범위에서 제외했습니다. 순차 검정(Sequential Testing)도 마찬가지입니다. 엿보기 문제를 해결하면서 조기 종료를 허용하는 방법인데, 통계적으로 더 깊이 공부해야 제대로 구현할 수 있을 것 같습니다.
베이지안 A/B 테스트도 해보고 싶습니다. 빈도주의 접근법으로 구현했는데, "B가 A보다 좋을 확률이 95%"처럼 확률로 결과를 해석하는 베이지안 방식이 비즈니스 맥락에서 더 직관적으로 전달될 때가 많거든요. 다음 프로젝트에서는 이 부분을 추가해보려고 합니다. 😊
'Project' 카테고리의 다른 글
| [AB Compass] 해싱과 통계 엔진, 핵심 로직 구현 (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
안녕하세요. 데이터로 말하는 분석가 모모입니다.
데이터를 구조화하고 분석하는 과정과 실무에 활용되는 도구 중심의 내용을 기록합니다.