A/B 테스트 시스템 — Feature Flag와 실험 플랫폼

2026-05-03확률과 통계 마스터 노트

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].metricresults[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 분리 저장 — 스키마 유연성·분석 편의
  • /track POST — uuid, event, data, timestamp 기록
  • /results GET — 그룹별 집계 + 카이제곱 통계
  • 동적 변수는 괄호 표기법 (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편 실제 사례에서 이 시스템을 어떻게 다양한 비즈니스 문제에 적용하는지 보입니다.

공식 문서: Express 공식 가이드chi-square-ab-testing npm에서 더 깊이 갈 수 있어요.

다음 글(4편)에서는 이 시스템 위에 다섯 가지 실제 테스트를 올려 봅니다. 슬라이딩 장바구니·이미지 갤러리·결제 완료 투표·재고 부족 배너·마우스 오버 — 각 테스트의 가설·지표·구현·결과를 한 흐름으로 풀어 가요.

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!