A/B 테스트 마스터 노트 시리즈 3편. Optimizely·VWO 같은 상용 플랫폼이 내부에서 어떻게 동작하는지 — 프론트엔드 Feature Flag, Node.js·Express 추적 서버, CSV 이벤트 저장, /track·/results 엔드포인트, 카이제곱 통계 계산, Race Condition 방지까지 처음부터 빌드해 보는 한 편.
이 글은 A/B 테스트 마스터 노트 시리즈의 세 번째 편입니다. 2편(설계)에서 가설·지표·타겟팅을 잡았다면, 이번엔 그 모든 걸 실행하는 시스템을 직접 구축합니다.
A/B 테스트를 수동으로 관리하면 금방 한계에 부딪혀요. 어떤 사용자가 어떤 그룹에 배정됐는지, 어떤 실험이 현재 실행 중인지, 결과 데이터를 어떻게 모을지 — 체계적인 시스템이 필요합니다. Optimizely·VWO·Google 실험 같은 상용 플랫폼이 내부에서 하는 일과 같은 걸 Node.js와 Express로 직접 짜 봅니다.
처음 시스템 구축이 어렵게 느껴지는 이유
이유는 두 가지예요.
첫째, 프론트엔드와 백엔드가 따로 돌아갑니다. React 앱이 3000번 포트에서, 추적 서버가 3030번 포트에서 따로 떠요. 그러면 CORS·이벤트 전송 형식·UUID 일관성 — 두 서버 사이를 잇는 디테일이 한꺼번에 들어옵니다.
둘째, 이벤트 데이터를 어디에·어떻게 저장할지 결정해야 해요. CSV·관계형 DB·NoSQL·Kafka·서드파티 추적 — 선택지가 많아 처음에는 뭐가 맞는지 보이지 않습니다.
해결법은 한 가지예요. MVP는 무조건 CSV로 시작하세요. Excel·Pandas로 바로 분석 가능하고, 코드도 단순합니다. 트래픽이 늘면 그때 PostgreSQL이나 ClickHouse로 옮기면 돼요. 처음부터 인프라를 정교하게 짜면 학습 곡선만 가팔라집니다.
전체 아키텍처 — 두 서버, 한 데이터 폴더
시스템은 세 부분으로 나뉘어요.
┌─────────────────────────────────────────┐
│ Frontend (React App) │
│ - 사용자 그룹 배정 (Bucketing) │
│ - Feature Flag 적용 │
│ - 이벤트 발생 시 추적 서버에 POST │
│ Port: 3000 │
└──────────────┬──────────────────────────┘
│ HTTP POST /track
│ HTTP GET /results
▼
┌─────────────────────────────────────────┐
│ Backend Tracking Server │
│ (Node.js + Express) │
│ - 이벤트 수신 및 CSV 저장 │
│ - 통계적 유의성 계산 │
│ - 결과 API 제공 │
│ Port: 3030 │
└──────────────┬──────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ Data Storage │
│ - Bucket.csv (그룹 배정 기록) │
│ - purchase.csv (구매 이벤트) │
│ - add_to_cart.csv (장바구니) │
│ - [이벤트명].csv (이벤트별 분리) │
└─────────────────────────────────────────┘
세 부분 — 프론트엔드 / 추적 서버 / 데이터. 한 항목씩 풀어 갑니다.
프론트엔드 실험 서비스 — Framework Agnostic
가장 중요한 설계 원칙 한 줄. A/B 테스트 로직은 프레임워크 독립적으로 짜라. Redux·Zustand·Jotai 어떤 상태 관리 라이브러리를 써도 같은 코드가 돌아가야 합니다. 7편(베스트 프랙티스)에서 다시 다루는 핵심 패턴이에요.
// services/experiments/index.js — 실험 진입점
import showCartTest from './showCartTest';
import addToCartModalTest from './addToCartModalTest';
import bestReviewsTest from './bestReviewsTest';
import inventoryTest from './inventoryTest';
import mouseOverTest from './mouseOverTest';
// 알파벳 순서 정렬 — 실험이 쌓여도 관리 용이
const experiments = [
addToCartModalTest,
bestReviewsTest,
inventoryTest,
mouseOverTest,
showCartTest,
];
export function activateExperiment(experimentName) {
const experiment = experiments.find(e => e.name === experimentName);
if (!experiment || !experiment.active) return;
// 이미 배정된 경우 재실행 X
if (experiment.selectedVariation) return;
// 타겟팅 조건 확인
if (!checkTargeting(experiment)) return;
// Variation 무작위 배정
const variationKeys = Object.keys(experiment.variations);
const randomIndex = Math.floor(Math.random() * variationKeys.length);
experiment.selectedVariation = variationKeys[randomIndex];
// 버케팅 이벤트 추적
track('Bucket', [experimentName, experiment.selectedVariation]);
}
export function runExperiment(experimentName, data) {
const experiment = experiments.find(e => e.name === experimentName);
// 안전한 폴백 — null 반환 시 호출자의 else 분기로
if (!experiment || !experiment.active) return null;
if (!experiment.selectedVariation) return null;
const variationFn = experiment.variations[experiment.selectedVariation];
// data가 있으면 변환 함수, 없으면 boolean 반환
if (data !== undefined) {
return variationFn(data);
}
return experiment.selectedVariation !== 'original';
}
function checkTargeting(experiment) {
if (!experiment.targeting) return true;
const currentPath = window.location.pathname;
if (experiment.targeting.path && experiment.targeting.path.match) {
if (!currentPath.match(experiment.targeting.path.match)) return false;
}
if (experiment.targeting.device !== undefined) {
if (!experiment.targeting.device) return false;
}
return true;
}
이 한 모듈이 시리즈 전체의 토대예요. activateExperiment는 사용자를 그룹에 배정하고, runExperiment는 어느 변형이 실행될지 반환합니다.
여기서 정말 중요한 시험 함정 — runExperiment가 null을 반환할 때를 항상 호출자의 else 분기에서 처리하도록 설계됐어요. 1편에서 봤던 "Original은 else에" 패턴이 시스템 레벨에서 보장됩니다.
백엔드 추적 서버 — Express로 두 엔드포인트
서버가 하는 일은 단순해요. 이벤트 받아서 CSV에 저장하고, 결과 분석 요청에 통계까지 붙여 응답.
초기 설정
// experiments/index.js (백엔드 서버)
const express = require('express');
const cors = require('cors');
const fs = require('fs');
const chiSquare = require('chi-square-ab-testing');
const app = express();
const port = 3030;
// 미들웨어
app.use(express.json()); // JSON 본문 파싱
app.use(express.urlencoded({ extended: true })); // URL 인코딩 파싱
app.use(cors());
// CORS 필요 이유: 프론트(3000)에서 백엔드(3030)로 요청 시
// 브라우저 보안 정책으로 기본 차단 → cors 미들웨어로 해결
app.listen(port, () => {
console.log(`Tracking server running on port ${port}`);
});
여기서 시험 함정이 하나 있어요. CORS 설정을 잊으면 프론트엔드에서 백엔드 호출이 다 차단됩니다. 브라우저 콘솔에 "blocked by CORS policy" 에러가 뜨고, 모든 추적 이벤트가 사라져요. 개발 환경에서는 app.use(cors()) 한 줄이면 충분하지만, 프로덕션에서는 origin 화이트리스트로 제한해야 합니다.
POST /track — 이벤트 수신
app.post('/track', (req, res) => {
const { uuid, event, data } = req.body;
// 백엔드에서 타임스탬프 생성 — 클라이언트 시간은 신뢰 불가
const now = Date.now();
// 추가 데이터 처리 (배열 또는 단일 값)
const extraFields = Array.isArray(data) ? data : [data];
// CSV 행 생성
// 스키마: uuid, event, timestamp, [추가 데이터...]
const csvRow = `${uuid},${event},${now},${extraFields.join(',')}\n`;
// 이벤트별 파일에 추가 (분리 저장 — 스키마 유연성)
fs.appendFile(`./${event}.csv`, csvRow, (err) => {
if (err) console.error('Error writing to file:', err);
});
res.send('OK');
});
이벤트별로 별도 파일에 저장하는 게 핵심. Bucket.csv·purchase.csv·add_to_cart.csv 식으로 나뉘면 이벤트마다 다른 스키마를 가질 수 있고 분석도 편해요.
GET /results — 통계까지 붙인 응답
가장 복잡하지만 한 번 이해하면 다른 모든 분석의 토대가 되는 코드.
app.get('/results', (req, res) => {
const { experimentName, metric } = req.query;
const results = {};
const bucketedUsers = []; // 중복 카운트 방지용
// === Step 1: 목표 달성 사용자 목록 ===
let usersWhoDidMetric = [];
try {
const metricData = fs.readFileSync(`./${metric}.csv`, 'utf8');
// 동기 읽기 — 비동기면 데이터 준비 전에 분석 코드 실행 위험
usersWhoDidMetric = metricData.split('\n').reduce((acc, row) => {
if (!row) return acc; // 빈 줄 방어 (CSV 끝 \n 이슈)
const fields = row.split(',');
acc.push(fields[0]); // UUID만 추출
return acc;
}, []);
} catch (e) {
// 파일 없음 = 아직 해당 이벤트 없음
}
// === Step 2: 버킷 데이터로 그룹별 사용자 집계 ===
try {
const bucketData = fs.readFileSync('./Bucket.csv', 'utf8');
bucketData.split('\n').forEach(row => {
if (!row) return;
const fields = row.split(',');
// 스키마: uuid, event, time, experimentName, variation
const uuid = fields[0];
const rowExperimentName = fields[3];
const variation = fields[4];
if (rowExperimentName !== experimentName) return;
if (bucketedUsers.includes(uuid)) return; // 중복 방지
bucketedUsers.push(uuid);
if (!results[variation]) {
results[variation] = { users: 0, [metric]: 0, conversionRate: 0 };
}
results[variation].users++;
if (usersWhoDidMetric.includes(uuid)) {
results[variation][metric]++;
}
});
} catch (e) {
return res.json({ error: 'Bucket data not found' });
}
// === Step 3: 전환율 계산 ===
Object.keys(results).forEach(variation => {
const r = results[variation];
r.conversionRate = r.users > 0 ? r[metric] / r.users : 0;
});
// === Step 4: 통계적 유의성 (카이제곱) ===
const table = Object.keys(results).map(variation => [
results[variation].users,
results[variation][metric] // 동적 변수 — 괄호 표기법!
]);
const statSig = chiSquare(table);
// 반환값: 0~1 사이 신뢰 수준 (0.95 = 95%)
res.json({ statSig, results });
});
여기서 시험 함정이 하나 있어요. results[variation].metric 와 results[variation][metric] 는 완전히 다릅니다. 점 표기법은 'metric'이라는 글자 그대로의 속성을 찾고, 괄호 표기법은 metric 변수의 값('purchase' 등)을 속성명으로 사용해요. 동적 변수에는 괄호 표기법 필수.
응답 예시:
{
"statSig": 0.99,
"results": {
"show_cart": {
"users": 52,
"purchase": 12,
"conversionRate": 0.231
},
"original": {
"users": 48,
"purchase": 3,
"conversionRate": 0.063
}
}
}
99% 신뢰 수준에 변형이 대조군의 약 3.7배 전환율 — 강한 증거입니다.
개별 실험 파일 — 네 가지 패턴
실험 한 개당 한 파일. 패턴이 네 가지 정도로 나뉘어요.
패턴 (1) Boolean 반환 — 표시할까 말까
// services/experiments/showCartTest.js
const showCartTest = {
name: 'show_cart_test',
active: true,
activateOnPageView: false, // 페이지 뷰마다 자동 활성화 X
// App.js에서 수동으로 activateExperiment 호출
targeting: null, // 모든 페이지
variations: {
original: () => false, // false → else 분기
show_cart: () => true, // true → if 분기
},
selectedVariation: null,
};
가장 단순. 새 기능을 보여줄지 말지만 결정.
패턴 (2) 데이터 변환 — 함수형
배열을 받아 변형하는 실험.
const bestReviewsTest = {
name: 'bestReviewsTest',
active: true,
activateOnPageView: true,
targeting: {
path: { match: /product/ }
},
variations: {
original: (data) => data, // 그대로 반환
showBestReviews: (data) => {
return [...data].sort((a, b) => {
if (a.rating < b.rating) return 1; // 내림차순
if (a.rating > b.rating) return -1;
return 0;
});
}
}
};
// 사용:
// const sortedReviews = runExperiment('bestReviewsTest', reviews);
패턴 (3) 조건부 Boolean — 데이터에 따라
const inventoryTest = {
name: 'inventoryTest',
active: true,
activateOnPageView: true,
targeting: true,
variations: {
original: () => false,
showInventory: (productData) => {
return productData.inventory < 10;
}
}
};
// 사용:
// const showAlert = runExperiment('inventoryTest', product);
// if (showAlert) { /* 재고 경고 표시 */ }
패턴 (4) 데이터 주입 — 다른 콘텐츠 보여주기
const checkoutSuccessTest = {
name: 'checkoutSuccessTest',
active: true,
activateOnPageView: true,
targeting: {
path: { match: 'checkout\/success' }
},
variations: {
original: [], // 빈 배열 → items.length === 0 으로 숨김
showVoting: [
{ id: 1, image: 'new-product-1.png', name: 'New Sunglasses A' },
{ id: 2, image: 'new-product-2.png', name: 'New Sunglasses B' },
{ id: 3, image: 'new-product-3.png', name: 'New Sunglasses C' },
]
},
};
데이터 스키마 — CSV 파일 분리 저장
Bucket.csv:
uuid, event, timestamp, experimentName, variation
abc123, Bucket, 1703001234567, show_cart_test, show_cart
def456, Bucket, 1703001235678, show_cart_test, original
purchase.csv:
uuid, event, timestamp
abc123, purchase, 1703001290000
ghi789, purchase, 1703001295000
이벤트별 분리 저장의 장점 두 가지.
- 스키마 유연성 — 이벤트마다 다른 컬럼 가질 수 있음
- 분석 편의성 — Pandas로 각 이벤트만 빠르게 로드 가능
UUID 관리 — localStorage로 재방문 추적
1편에서 잠깐 봤던 UUID 패턴, 이번엔 재방문 사용자까지 확장.
// services/experiments.js
export function getUuid() {
let uuid = window.localStorage.getItem('uuid');
if (!uuid) {
uuid = Math.random().toString(36).substring(2) +
Math.random().toString(36).substring(2);
window.localStorage.setItem('uuid', uuid);
}
return uuid;
}
// 재방문 고객 표시
export function markAsExistingCustomer() {
window.localStorage.setItem('existingCustomer', 'true');
}
export function isExistingCustomer() {
return window.localStorage.getItem('existingCustomer') === 'true';
}
// App.js에서 사용
useEffect(() => {
activateExperiment('checkout_success_test');
if (isExistingCustomer()) {
track('Return to site');
}
}, []);
// CheckoutSuccess.jsx에서 결제 완료 시 표시
useEffect(() => {
markAsExistingCustomer();
}, []);
결제 완료 시 LocalStorage에 표시하고, 다음 방문 시 'Return to site' 이벤트를 발생. 이 이벤트의 빈도가 No Dead Ends 테스트의 핵심 지표가 돼요.
트래픽 시뮬레이터 — Puppeteer로 빠른 데이터 수집
실제 사용자를 기다리는 대신 헤드리스 브라우저로 시뮬레이션.
// simulate.test.js
const puppeteer = require('puppeteer');
const NUM_VISITORS = 100;
const BATCH_SIZE = 4; // 동시 실행 — 5 이상은 dev 서버 과부하
const PURCHASE_RATE = 0.08; // 실제(3%)보다 높여 빠른 데이터 수집
async function simulateUser() {
const browser = await puppeteer.launch({ headless: true });
const page = await browser.newPage();
await page.goto('http://localhost:3000');
const action = Math.random();
if (action < 0.3) {
// 30% 이탈
await browser.close();
return;
}
if (action < 0.7) {
// 40% 장바구니 추가 후 이탈
await page.click('.add-to-cart-button');
await browser.close();
return;
}
// 30% 구매 시도
if (Math.random() < PURCHASE_RATE) {
await page.click('.add-to-cart-button');
await page.click('.checkout-button');
await fillCheckoutForm(page);
await page.click('.submit-order');
}
await browser.close();
}
여기서 시험 함정이 하나 있어요. 시뮬레이션 시 동시 브라우저 5개 이상은 위험합니다. React dev 서버의 hot-reload·HMR이 동시 요청을 견디지 못하고 화이트 스크린이 떠요. 4개 정도가 적정 균형.
Race Condition — useEffect 실행 순서 문제
마지막 큰 함정. React의 자식 컴포넌트 useEffect가 부모 컴포넌트 초기화보다 먼저 실행될 수 있어요.
실행 순서:
1. App 컴포넌트 렌더링 시작
2. 자식 Voting 렌더링
3. Voting의 useEffect 실행 → runExperiment 호출
(이 시점에 실험이 아직 초기화 안 됨 → undefined 반환!)
4. App의 useEffect 실행 → 실험 초기화
(너무 늦음)
해결법:
// 잘못된 방법
function Voting() {
useEffect(() => {
const items = runExperiment('checkoutSuccessTest');
setItems(items); // items가 undefined → TypeError 위험
}, []); // 빈 배열
}
// 올바른 방법
function Voting() {
// 컴포넌트 최상단에서 실험 값 읽기
const checkoutSuccessData = runExperiment('checkoutSuccessTest');
// 렌더링마다 최신 값 가져옴
useEffect(() => {
if (checkoutSuccessData) {
setItems(checkoutSuccessData);
}
}, [checkoutSuccessData]); // 의존성 배열에 추가
}
핵심 — 실험 값을 의존성 배열에 추가해서 값이 준비될 때까지 자동으로 재실행되게 합니다.
흔한 함정 정리
포트 충돌
프론트(3000)와 백엔드가 같은 포트면 충돌. 항상 분리.
CSV 빈 줄 버그
CSV 행 끝에 \n이 있으면 split 결과 마지막에 빈 문자열이 들어가요.
// 파일 끝: "abc,purchase,123\n"
// split('\n') → ["abc,purchase,123", ""]
bucketData.split('\n').forEach(row => {
if (!row) return; // 필수 방어 코드
// ...
});
중복 카운트
새로고침할 때마다 버케팅 이벤트가 다시 기록되면 사용자 수가 부풀려져요. 분석 시 UUID 중복 체크 필수.
const bucketedUsers = [];
bucketData.split('\n').forEach(row => {
if (!row) return;
const uuid = row.split(',')[0];
if (bucketedUsers.includes(uuid)) return; // 중복 건너뜀
bucketedUsers.push(uuid);
// 결과 계산
});
시험 직전 한 번 더 — 자주 헷갈리는 함정 모음
여기까지가 3편의 핵심입니다. 시험 직전 또는 실무에서 헷갈릴 때 다시 펼쳐 볼 수 있게 압축 노트로 마무리할게요.
- 시스템 = 프론트(Feature Flag) + 백엔드(추적/분석) + 데이터(CSV)
- 프론트 포트 3000, 백엔드 포트 3030 — 반드시 분리
- 실험 로직은 프레임워크 독립적 — Redux 등에 종속 X
experiments/index.js= 실험 진입점 (알파벳 순 정렬)activateExperiment= 그룹 배정,runExperiment= 변형 실행runExperiment는 null 반환 가능 → 호출자 else 분기로 안전 폴백- Express 미들웨어 —
express.json(),express.urlencoded({extended:true}),cors() - CORS 누락 = 모든 요청 차단 (브라우저 보안 정책)
- 타임스탬프는 백엔드에서 생성 (
Date.now()) — 클라이언트 시간 신뢰 X - 이벤트별 CSV 분리 저장 — 스키마 유연성·분석 편의
/trackPOST — uuid, event, data, timestamp 기록/resultsGET — 그룹별 집계 + 카이제곱 통계- 동적 변수는 괄호 표기법 (
results[variation][metric]) - 점 표기법 (
r.metric) = 'metric'이란 글자 속성 찾음 (X) - 카이제곱 입력 =
[[users1, conv1], [users2, conv2]] - 실험 패턴 4개 — Boolean / 데이터 변환 / 조건부 Boolean / 데이터 주입
- UUID는 localStorage에 저장 (재방문 동일 ID)
markAsExistingCustomer+Return to site= No Dead Ends 추적- Puppeteer 시뮬레이터 — 동시 4개가 적정 (5+ 위험)
- Race Condition — 자식 useEffect가 부모 초기화보다 먼저 실행
- 해결 — 실험 값을 의존성 배열에 추가해 자동 재실행
- CSV 빈 줄 방어 —
if (!row) return;필수 - 중복 카운트 방지 — 처리한 UUID 배열에 기록
- 정규식 슬래시 =
\/이스케이프 ('checkout\/success') - nodemon = 파일 변경 시 자동 재시작 (개발 환경)
시리즈 다른 편
같은 시리즈의 다른 글들도 같은 톤으로 묶어 정리되어 있어요. 3편 시스템이 잡히면 4편 실제 사례에서 이 시스템을 어떻게 다양한 비즈니스 문제에 적용하는지 보입니다.
- 1편 — A/B 테스트 입문 (대조군·전환율·유의성)
- 2편 — 테스트 설계 (가설·지표·편향 방지)
- 3편 — A/B 테스트 시스템 (현재 글)
- 4편 — 실제 사례 분석 (5가지 패턴)
- 5편 — 통계 심화 (카이제곱·베이지안·다중 비교)
- 6편 — 구현 패턴 (React·Node·Pandas)
- 7편 — 베스트 프랙티스 (실험 문화·흔한 실수)
공식 문서: Express 공식 가이드와 chi-square-ab-testing npm에서 더 깊이 갈 수 있어요.
다음 글(4편)에서는 이 시스템 위에 다섯 가지 실제 테스트를 올려 봅니다. 슬라이딩 장바구니·이미지 갤러리·결제 완료 투표·재고 부족 배너·마우스 오버 — 각 테스트의 가설·지표·구현·결과를 한 흐름으로 풀어 가요.