AB 테스트 인프라
여러 AB 테스트를 동시에 돌릴 수 있는 구조를 만들었습니다. 실험마다 그룹이 따로 정해져서, 하나가 끝나길 기다리지 않고 문구·UI 실험을 여러 개 같이 돌릴 수 있습니다.
여러 실험을 동시에
이 구조의 핵심은 여러 AB 테스트를 동시에 돌릴 수 있다는 것입니다. 실험마다 그룹을 나누는 기준이 달라서, 한 유저가 A 실험에서 어느 그룹인지와 B 실험에서 어느 그룹인지가 따로 정해집니다. 그래서 실험을 하나씩 순서대로 돌리지 않고 여러 개를 같이 돌리고, 결과도 실험별로 따로 볼 수 있습니다.
- 로그인 유저는 유저 ID의 숫자로 그룹을 정합니다. 테스트 번호의 각 자릿수를 위치로 삼아 유저 ID에서 숫자를 골라 조합하고, 그 값을 그룹 수로 나눈 나머지로 A, B, C…를 정합니다.
- 테스트마다 유저 ID에서 고르는 자리가 다릅니다(예: 1번 테스트는
1, 7번 테스트는0247). 그래서 같은 유저라도 실험마다 그룹이 따로 정해집니다. - 배정 결과는 localStorage의
expData에{ 테스트 번호: 그룹 }형태로 테스트마다 따로 저장합니다. 동시에 돌리는 실험 수가 늘어도 구조는 그대로입니다.
한 줄로 나누는 실험 분기
// 테스트 1번을 A~E 다섯 그룹으로 나눈다
const testId = 1;
const testType = getAbTestType(testId, 'E'); // 'A' | 'B' | 'C' | 'D' | 'E'
// 클릭 이벤트에 그룹 정보를 함께 보낸다
trackGTMEvent('카카오톡 친구추가 팝업-다음에할게요/클릭', {
experimentType: testType,
experimentNumber: testId,
});
화면 코드에서는 테스트 번호와 그룹 수만 넘기면 됩니다. 배정된 그룹은 트래킹 이벤트에 함께 실어 보내, 분석 도구에서 그룹별로 나눠 봅니다. 위 코드는 카카오톡 채널 추가 바텀시트의 문구를 다섯 그룹으로 나눠 실험할 때 쓴 형태입니다. 컴포넌트가 그룹을 prop으로도 받을 수 있게 열어 두어, 특정 화면에서는 그룹을 고정할 수 있습니다.
로그인 전후에도 같은 그룹
- 비로그인 유저는 10자리 랜덤 숫자를 발급해 같은 방식으로 배정하고, 결과는 임시 키(
r_테스트번호)에 저장합니다. - 로그인해 유저 ID가 생기면 임시 키의 그룹을 정식 키로 옮기고 임시 키는 지웁니다. 비로그인 때 본 화면이 로그인 후에도 그대로 이어집니다.
배정이 실패해도 화면은 그대로
유저 ID에 숫자가 없는 경우처럼 배정 계산이 실패하면, 랜덤 ID를 새로 발급해 다시 배정합니다. 실험 때문에 화면이 깨지는 일이 없도록 했습니다.
그룹 배정 함수 전체 코드
위에서 설명한 그룹 계산, 로그인 전후 처리, 실패 시 재배정이 모두 이 함수 하나에 들어 있습니다.
export default function getAbTestType(testNum, testMaxType) {
let tempExpData = JSON.parse(localStorage.getItem('expData'));
function getType(id, isRandom) {
try {
// 테스트 번호의 자릿수로 유저 ID에서 숫자를 골라 그룹(A, B, C…)을 계산
let testTypeToNum = testMaxType?.toUpperCase()?.codePointAt() - 64;
let userIdOnlyNum = id.replace(/[^0-9]/g, '').toString();
const testNumTemp = testNum.toString().split('');
const typeRes = testNumTemp.map((num) => {
return userIdOnlyNum?.slice(parseInt(num), parseInt(num) + 1) ?? '';
});
const res_type = String.fromCharCode((parseInt(typeRes.join('')) % testTypeToNum) + 65);
// 결과를 테스트별 키로 저장 (비로그인은 r_ 임시 키)
localStorage.setItem(
'expData',
JSON.stringify({
...tempExpData,
[isRandom ? `r_${testNum}` : testNum]: res_type,
}),
);
return res_type;
} catch (e) {
// 계산이 실패하면 랜덤 ID로 다시 배정
console.error(e);
const randomId = parseInt(Math.random() * (9999999999 - 1000000000) + 1000000000);
return getType(randomId.toString(), true);
}
}
// 이미 배정된 그룹이 있으면 그대로 사용
if (!!tempExpData?.[`${testNum}`]) {
return tempExpData?.[`${testNum}`];
}
// 비로그인 때 받은 그룹을 로그인 후 정식 키로 옮김
if (!!tempExpData?.[`r_${testNum}`] && !!localStorage.getItem('userID')) {
const randomTestRes = tempExpData?.[`r_${testNum}`];
delete tempExpData?.[`r_${testNum}`];
localStorage.setItem(
'expData',
JSON.stringify({
...tempExpData,
[testNum]: randomTestRes,
}),
);
return randomTestRes;
}
// 아직 비로그인이면 임시 키의 그룹을 그대로 사용
if (!!tempExpData?.[`r_${testNum}`]) {
return tempExpData?.[`r_${testNum}`];
}
// 처음 들어온 유저: 로그인 상태면 유저 ID로, 아니면 랜덤 ID로 배정
if (!!localStorage.getItem('userID')) {
return getType(localStorage.getItem('userID'));
}
const randomId = parseInt(Math.random() * (9999999999 - 1000000000) + 1000000000);
return getType(randomId.toString(), true);
}
QA 설정 패널
- 화면에 떠 있는 설정 버튼에서 테스트 번호와 그룹을 입력하면, 함수가 읽는 것과 같은 키로 저장한 뒤 새로고침해 바로 그 그룹의 화면을 볼 수 있습니다.
- 페이지 전체에 한 번만 적용하는 단순 A/B(
abType)도 같은 패널에서 함께 바꿉니다. - 앱 환경 여부, 분석 디버그 도구 노출 여부, 화면 크기·user agent·앱 버전·스크롤 상태 같은 진단 정보도 같은 패널에서 확인할 수 있게 했습니다.
참여한 A/B 실험과 결과
실무자로 참여한 실험 가운데 결과 수치가 남아 있는 것들입니다. 차이가 없었던 실험도 그대로 적었습니다.
| 실험 | 결과 |
|---|---|
| 큐레이션 코디 전체 담기에 신뢰 문구 띄우기 | 전체 담기 전환율 A(노출) 3.6%, B(미노출) 1.4% |
| 쇼핑백 담기 바텀시트에 코디 팁·추천 상품 추가 | 장바구니 담기 A 234/9,453, B 317/8,980으로 유의미한 차이. 구매는 차이 없음 |
| 장바구니 하단 개인화 추천 구좌 | 장바구니 전환 A 3.03%, B 2.62% / 구매 전환 A 0.180%, B 0.211%. 통계적으로 유의하지 않음 |
| 구매·행동 기반 개인화 코디 제안 | 기존 겨울 코디 제안(A)이 개인화 추천(B)보다 구매 전환이 높음 |
| 여정 쿠폰 최소 주문 금액 | 목표 지표(객단가)와 가드레일 지표(구매 전환율) 모두 차이 없음 |
| 9,900원 슬랙스 '재고 얼마 안 남음' 표시 | 회원가입률·구매 전환율 모두 큰 차이 없음 |
동료의 평가
AB 테스트 분기는 저희 회사에서 가장 잘 구성된 architecture 중 하나라 나누는데 공수가 그리 들지 않습니다
2025년 3월, 스타일퀴즈 질문 구성을 실험으로 나눌지 논의하던 Slack 스레드에서, 이 구조로 실험을 함께 돌리던 PM이 남긴 말입니다.