A/B 테스트 구현 — React·Node·Pandas 패턴

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

A/B 테스트 마스터 노트 시리즈 6편. React 컴포넌트 패턴(useEffect·useState·조건부 렌더링), Node.js Express 백엔드 패턴, Pandas 데이터 분석 패턴을 한 흐름으로 묶어 풀어 가는 한 편. useEffect 비동기·sort 부호·정규식 이스케이프 같은 흔한 버그도 해결책과 함께.

이 글은 A/B 테스트 마스터 노트 시리즈의 여섯 번째 편입니다. 3편(시스템)에서 시스템 아키텍처를, 4편(사례)에서 실전 적용을 봤다면, 이번엔 구현 디테일을 한 흐름으로 묶습니다.

React 컴포넌트 패턴·Node.js 백엔드 패턴·Pandas 분석 패턴 — 세 영역의 코드 패턴이 모이는 자리예요. 그리고 실제로 발생했던 흔한 버그들 — useEffect 비동기 처리, sort 부호 실수, 정규식 이스케이프, 파일명 공백 — 까지 해결책과 함께 정리합니다.

처음 구현이 어렵게 느껴지는 이유

이유는 두 가지예요.

첫째, 세 가지 다른 환경(React·Node·Pandas)을 동시에 다뤄야 합니다. React에서는 useEffect 의존성과 비동기 처리, Node에서는 미들웨어와 CORS, Pandas에서는 데이터 정제와 groupby — 각 환경의 디테일이 한꺼번에 들어와요.

둘째, 버그가 묘하게 한 줄 차이입니다. useEffect의 빈 배열 한 개, sort의 부호 한 개, 정규식의 슬래시 하나 — 결과는 완전히 달라지는데 코드만 보면 어디가 문제인지 잘 안 보여요.

해결법은 한 가지예요. 각 환경마다 "내가 매번 맞추는 패턴"을 5~6개 외우세요. React는 빈 배열 useEffect / 의존성 배열 추가, Node는 CORS / express.json 미들웨어, Pandas는 dropna / groupby + size — 이 핵심 패턴들이 잡히면 90% 코드가 자동으로 흘러갑니다.

프론트엔드 React 구현 — 7가지 핵심 패턴

패턴 (1) 프로젝트 구조

src/
├── App.js                     # 진입점, 실험 초기화
├── components/
│   ├── cart/
│   │   ├── Cart.jsx
│   │   ├── AddToCartModal.jsx
│   │   └── AddToCartModal.css
│   ├── product/
│   │   ├── ProductCard.jsx
│   │   └── ProductGallery.jsx
│   └── Voting.jsx
├── redux/
│   ├── store.js
│   └── cartSlice.js
├── routes/
│   ├── index.jsx
│   ├── Category.jsx
│   ├── Product.jsx
│   ├── Checkout.jsx
│   └── CheckoutSuccess.jsx
└── services/
    ├── api.js
    └── experiments/
        ├── index.js
        ├── showCartTest.js
        ├── addToCartModalTest.js
        ├── bestReviewsTest.js
        ├── inventoryTest.js
        └── mouseOverTest.js

핵심 — services/experiments/가 별도 폴더로 분리. Redux와 무관한 순수 자바스크립트 모듈로 짭니다.

패턴 (2) 앱 초기화

// App.js
import { useEffect } from 'react';
import { Outlet } from 'react-router-dom';
import { onLocationChange, track, isExistingCustomer } from './services/experiments';

function App() {
  useEffect(() => {
    // 마운트 시 1회만 실행 (빈 배열이 핵심)
    
    // 실험 플랫폼 초기화
    onLocationChange(window.location.pathname);
    
    // 재방문 사용자 감지
    if (isExistingCustomer()) {
      track('Return to site');
    }
  }, []); // 빈 배열 — 1회만!
  
  return (
    <div className="app">
      <header>{/* 네비게이션 */}</header>
      
      {/* 모달은 어느 페이지에서든 떠야 함 */}
      <Cart />
      <AddToCartModal />
      
      <Outlet />
    </div>
  );
}

패턴 (3) Redux 상태 관리

// redux/cartSlice.js
import { createSlice } from '@reduxjs/toolkit';

const initialState = {
  items: [],
  showCart: false,
  showAddToCartModal: false,
};

const cartSlice = createSlice({
  name: 'cart',
  initialState,
  reducers: {
    addToCart: (state, action) => {
      const existing = state.items.find(item => item.id === action.payload.id);
      if (existing) {
        existing.quantity++;
      } else {
        state.items.push({ ...action.payload, quantity: 1 });
      }
    },
    
    toggleCart: (state) => {
      state.showCart = !state.showCart;
    },
    
    toggleAddToCartModal: (state) => {
      state.showAddToCartModal = !state.showAddToCartModal;
    },
  }
});

export const { addToCart, toggleCart, toggleAddToCartModal } = cartSlice.actions;
export default cartSlice.reducer;

패턴 (4) useEffect 비동기 처리

가장 자주 쓰는 패턴인데 가장 자주 실수도 나옵니다.

import { useState, useEffect } from 'react';
import { API } from '../../services/api';

function AddToCartModal() {
  const [recs, setRecs] = useState([]);
  
  useEffect(() => {
    // 잘못된 방법 — useEffect 콜백에 async 직접 사용 금지
    // useEffect(async () => {  ← 에러!
    //   const products = await API.getProducts();
    // }, []);
    
    // 올바른 방법 — 내부에 async 함수 선언 후 즉시 호출
    async function loadRecommendations() {
      const products = await API.getProducts();
      
      const _recs = [];
      while (_recs.length < 3) {
        const randomIndex = Math.floor(Math.random() * products.length);
        const candidate = products[randomIndex];
        if (!_recs.some(r => r.id === candidate.id)) {
          _recs.push(candidate);
        }
      }
      
      setRecs(_recs);
    }
    
    loadRecommendations();
  }, []);
  
  return (/* ... */);
}

여기서 시험 함정이 하나 있어요. useEffect 콜백 함수 자체를 async로 만들면 React가 에러를 띄웁니다. 이유 — useEffect 콜백은 cleanup 함수를 반환해야 하는데 async 함수는 Promise를 반환해버려요. 내부에 별도 async 함수를 선언하고 즉시 호출하는 패턴이 표준.

패턴 (5) 조건부 렌더링

// 패턴 5-1: && 연산자
{showLowInventoryMessage && (
  <p>Only {product.inventory} units remaining!</p>
)}

// 패턴 5-2: 빈 Fragment 반환 (모달)
function AddToCartModal() {
  const cart = useSelector(state => state.cart);
  
  if (!cart.showAddToCartModal) return <></>;
  
  return (
    <div className="overlay">{/* 모달 내용 */}</div>
  );
}

// 패턴 5-3: 데이터 길이 기반
function Voting() {
  const [items, setItems] = useState([]);
  if (items.length === 0) return <></>;
  return (/* 투표 UI */);
}

패턴 (6) Race Condition 방지

3편에서 다뤘던 핵심 함정. 자식 컴포넌트의 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]); // 의존성 배열에 추가!
}

핵심 — 실험 값을 의존성 배열에 추가해 값이 준비되면 자동 재실행되게 합니다.

패턴 (7) onClick에 인자 전달

// 잘못된 방법 — 즉시 실행
<button onClick={vote('up')}>Up</button>
// vote('up')가 즉시 평가되어 반환값이 onClick에 할당됨

// 올바른 방법 — 화살표 함수로 감싸기
<button onClick={() => vote('up')}>Up</button>
// 클릭 시에만 실행되는 콜백

여기서 시험 함정이 하나 있어요. JSX의 {} 안은 즉시 평가돼요. onClick={vote('up')}은 컴포넌트 렌더링 시점에 vote 함수를 실행하고 그 반환값(보통 undefined)을 onClick에 할당합니다. 화살표 함수로 감싸 "클릭 시점에 실행될 함수"로 만들어야 해요.

백엔드 Node/Express 구현

미들웨어 + CORS

const express = require('express');
const cors = require('cors');
const fs = require('fs');
const chiSquare = require('chi-square-ab-testing');

const app = express();

app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cors());

const port = 3030;
app.listen(port, () => {
  console.log(`Tracking server on port ${port}`);
});

POST /track — 이벤트 수신

app.post('/track', (req, res) => {
  const { uuid, event } = req.body;
  let { data } = req.body;
  
  if (!uuid || !event) {
    return res.status(400).send('Missing required fields');
  }
  
  const now = Date.now();
  
  // 데이터 정규화
  const extraFields = data ? (Array.isArray(data) ? data : [data]) : [];
  
  const csvRow = `${uuid},${event},${now}${extraFields.length > 0 ? ',' + extraFields.join(',') : ''}\n`;
  
  fs.appendFile(`./${event}.csv`, csvRow, (err) => {
    if (err) console.error('Write error:', err);
  });
  
  res.send('OK');
});

GET /results — 통계까지

app.get('/results', (req, res) => {
  const { experimentName, metric } = req.query;
  
  if (!experimentName || !metric) {
    return res.status(400).json({ error: 'Missing query parameters' });
  }
  
  const results = {};
  const bucketedUsers = [];
  
  // 지표 파일 읽기
  let usersWhoDidMetric = [];
  try {
    const metricData = fs.readFileSync(`./${metric}.csv`, 'utf8');
    usersWhoDidMetric = metricData.split('\n').reduce((acc, row) => {
      if (!row) return acc; // 빈 줄 방어!
      const fields = row.split(',');
      acc.push(fields[0]);
      return acc;
    }, []);
  } catch (e) {}
  
  // 버킷 데이터 처리
  try {
    const bucketData = fs.readFileSync('./Bucket.csv', 'utf8');
    bucketData.split('\n').forEach(row => {
      if (!row) return;
      
      const fields = row.split(',');
      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: 'No bucket data' });
  }
  
  // 전환율 계산
  Object.keys(results).forEach(variation => {
    const r = results[variation];
    r.conversionRate = r.users > 0 ? r[metric] / r.users : 0;
  });
  
  // 카이제곱 계산
  const table = Object.keys(results).map(variation => [
    results[variation].users,
    results[variation][metric]
  ]);
  
  const statSig = chiSquare(table);
  
  res.json({ statSig, results });
});

Python/Pandas 데이터 분석 — 워크플로우

기본 데이터 로드와 정제

import pandas as pd

# 데이터 로드
purchases_df = pd.read_csv('purchase.csv', header=None)
images_df = pd.read_csv('view_product_image.csv', header=None)

# 컬럼명 지정
purchases_df.columns = ['uuid', 'event', 'time']
images_df.columns = ['uuid', 'event', 'time']

# CSV 끝의 빈 컬럼 제거 (hanging comma 이슈)
purchases_df = purchases_df.dropna(how='all', axis=1)
images_df = images_df.dropna(how='all', axis=1)

사용자별 집계 + 병합

# 사용자별 이미지 조회 횟수
image_view_counts = images_df.groupby('uuid').size().reset_index(name='view_count')

# 구매 데이터 병합 (Left join)
merged = image_view_counts.merge(
    purchases_df[['uuid']].drop_duplicates(),
    on='uuid',
    how='left',
    indicator=True  # '_merge' 컬럼 추가
)

# 구매 여부 Boolean
merged['purchased'] = merged['_merge'] == 'both'

# 조회 횟수별 구매율
result = merged.groupby('view_count').agg(
    total_users=('uuid', 'count'),
    purchased_users=('purchased', 'sum')
)
result['conversion_rate'] = result['purchased_users'] / result['total_users']

print(result)

A/B 테스트 결과 분석

# 버킷 데이터 로드
bucket_df = pd.read_csv('Bucket.csv', header=None)
bucket_df.columns = ['uuid', 'event', 'timestamp', 'experiment_name', 'variation']

# 특정 실험 + 중복 제거
experiment_name = 'show_cart_test'
exp_bucket = bucket_df[bucket_df['experiment_name'] == experiment_name]
exp_bucket = exp_bucket.drop_duplicates(subset=['uuid'])

# 구매자 set
purchase_df = pd.read_csv('purchase.csv', header=None)
purchase_df.columns = ['uuid', 'event', 'timestamp']
purchasers = set(purchase_df['uuid'].unique())

# 구매 여부
exp_bucket['purchased'] = exp_bucket['uuid'].isin(purchasers)

# 그룹별 집계
summary = exp_bucket.groupby('variation').agg(
    users=('uuid', 'count'),
    purchases=('purchased', 'sum')
)
summary['conversion_rate'] = summary['purchases'] / summary['users']

print(summary)
# variation | users | purchases | conversion_rate
# original  | 48    | 3         | 0.0625
# show_cart | 52    | 12        | 0.2308

여기서 시험 함정이 하나 있어요. drop_duplicates(subset=['uuid']) 를 안 하면 사용자가 새로고침해서 같은 그룹에 여러 번 기록된 경우 사용자 수가 부풀려져요. A/B 분석에서 중복 제거는 거의 항상 필요합니다.

이벤트 추적 시스템

클라이언트 추적 함수

const TRACKING_SERVER = 'http://localhost:3030';

export async function track(eventName, data) {
  try {
    await fetch(`${TRACKING_SERVER}/track`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        uuid: getUuid(),
        event: eventName,
        data: data || []
      })
    });
  } catch (err) {
    // 추적 실패는 조용히 — UX에 영향 X
    console.warn('Tracking failed:', err);
  }
}

export function getUuid() {
  let uuid = window.localStorage.getItem('uuid');
  if (!uuid) {
    uuid = Math.random().toString(36).slice(2) + 
           Math.random().toString(36).slice(2);
    window.localStorage.setItem('uuid', uuid);
  }
  return uuid;
}

여기서 시험 함정이 하나 있어요. 추적 실패는 try-catch로 조용히 처리하세요. 추적 서버가 다운되어도 사용자 경험에 영향이 가면 안 됩니다. 추적 호출이 사용자 액션을 블록하면 가장 심한 안티패턴이에요.

추적할 이벤트 목록

// 1. Bucketing
track('Bucket', [experimentName, variation]);

// 2. 구매
track('purchase');

// 3. 장바구니 추가
track('add to cart', [product.id]);

// 4. 이미지 조회
track('View Product Image', [product.id, imageIndex]);

// 5. 조건부 이진 지표
if (cart.items.length > 1) {
  track('More than one item');
}

// 6. 사이트 복귀
if (window.localStorage.getItem('existingCustomer')) {
  track('Return to site');
}

CSS 패턴 — 오버레이 모달

.overlay {
  position: fixed;        /* 스크롤해도 고정 */
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: rgba(0, 0, 0, 0.5);
  display: flex;
  justify-content: center;
  align-items: center;
}

.modal {
  background: white;
  padding: 2em;
  border-radius: 8px;
}

.modal .thumbnail {
  height: 8em;
  width: 8em;
  background-size: cover;
  background-position: center;
}

여기서 시험 함정이 하나 있어요. 모달 클릭 시 닫히지 않게 하려면 e.stopPropagation() 필수.

<div className="overlay" onClick={close}>
  <div className="modal" onClick={e => e.stopPropagation()}>
    {/* 모달 내용 */}
  </div>
</div>

오버레이 클릭 → 모달 닫힘. 모달 자체 클릭 → 이벤트 전파 차단해서 닫히지 않음.

흔한 버그 5가지 — 한 줄 차이

(1) useEffect 의존성 배열 누락

// 버그: 카운트가 계속 증가
useEffect(() => {
  pickVariation();
}); // 의존성 배열 없음 → 매 렌더링마다 실행

// 수정
useEffect(() => {
  pickVariation();
}, []); // 빈 배열 = 마운트 시 1회만

(2) sort 부호 오류

// 버그: 오름차순 (낮은 평점 먼저)
data.sort((a, b) => {
  if (a.rating > b.rating) return 1;
  if (a.rating < b.rating) return -1;
  return 0;
});

// 수정: 내림차순 (높은 평점 먼저)
data.sort((a, b) => {
  if (a.rating < b.rating) return 1;   // a가 작으면 뒤로
  if (a.rating > b.rating) return -1;
  return 0;
});

(3) 정규식 슬래시 이스케이프

// 버그
targeting: {
  path: { match: 'checkout/success' }
  // /가 정규식 구분자로 해석될 수 있음
}

// 수정
targeting: {
  path: { match: 'checkout\/success' }
  // 또는 정규식 리터럴
  // match: /checkout\/success/
}

(4) onClick 즉시 실행

// 버그
<button onClick={vote('up')}>Up</button>

// 수정
<button onClick={() => vote('up')}>Up</button>

(5) 파일명 공백

에러: Cannot resolve component 'AddToCartModal'

원인: 파일명이 ' AddToCartModal.jsx' (앞에 공백)

해결: 파일명에서 공백 제거

데이터 검증 체크리스트

function validateData() {
  // 1. bucket.csv에 실험 이름 올바르게 기록?
  // 2. 같은 UUID가 같은 variation에 배정?
  // 3. 빈 줄·손상 데이터 없음?
  // 4. 타임스탬프 합리적?
  
  // 브라우저 검증:
  // Application 탭 → Local Storage → uuid·existingCustomer 확인
  
  // 서버 검증:
  // cat Bucket.csv | head -5
  // wc -l Bucket.csv
}

시험 직전 한 번 더 — 자주 헷갈리는 함정 모음

여기까지가 6편의 핵심입니다. 시험 직전 또는 실무에서 헷갈릴 때 다시 펼쳐 볼 수 있게 압축 노트로 마무리할게요.

  • 실험 서비스(experiments/index.js) = 프레임워크 독립적
  • React 프로젝트 구조 — components / redux / routes / services 분리
  • App.js useEffect 빈 배열 = 앱 로드 시 1회 (실험 초기화)
  • Redux는 UI 제어만 — 실험 로직과 분리
  • useEffect에서 async 직접 X — 내부 async 함수 즉시 호출
  • 조건부 렌더링 3패턴 — && / 빈 Fragment / 데이터 길이 체크
  • Race Condition 방지 — 실험 값을 의존성 배열에 추가
  • onClick에 인자 전달은 화살표 함수로 감싸기
  • Express 미들웨어 — express.json + urlencoded + cors
  • CORS 누락 = 모든 요청 차단
  • 이벤트별 CSV 분리 저장 (스키마 유연성)
  • 동적 변수는 괄호 표기법 (r[metric])
  • 빈 줄 방어 — if (!row) return;
  • 중복 카운트 방지 — bucketedUsers 배열 체크
  • Pandas — dropna 로 hanging comma 처리
  • groupby + size + reset_index(name=...) = 사용자별 집계 표준
  • A/B 분석 시 drop_duplicates(subset=['uuid']) 필수
  • merge + indicator=True = '_merge' 컬럼 자동 생성
  • 추적 실패는 try-catch 조용히 — UX 영향 X
  • localStorage UUID = 재방문 동일 ID
  • 모달 패턴 — overlay onClick close + modal e.stopPropagation()
  • 5가지 흔한 버그 — useEffect 의존성 / sort 부호 / 정규식 / onClick / 파일명 공백
  • 빈 useEffect 배열 = 마운트 시 1회 (가장 자주 쓰는 패턴)

시리즈 다른 편

같은 시리즈의 다른 글들도 같은 톤으로 묶어 정리되어 있어요. 6편 구현 위에 7편 베스트 프랙티스가 마무리.

공식 문서: React Hooks 공식 가이드Pandas 사용자 가이드에서 더 깊이 갈 수 있어요.

다음 글(7편)에서는 시리즈를 마무리하며 실험 문화·코드 설계 원칙·결과 해석 가이드·흔한 실수 10가지를 정리합니다.

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

답글 남기기

error: Content is protected !!