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편 베스트 프랙티스가 마무리.
- 1편 — A/B 테스트 입문 (대조군·전환율·유의성)
- 2편 — 테스트 설계 (가설·지표·편향 방지)
- 3편 — A/B 테스트 시스템 (Feature Flag·실험 플랫폼)
- 4편 — 실제 사례 분석 (5가지 패턴)
- 5편 — 통계 심화 (카이제곱·베이지안·다중 비교)
- 6편 — 구현 패턴 (현재 글)
- 7편 — 베스트 프랙티스 (실험 문화·흔한 실수)
공식 문서: React Hooks 공식 가이드와 Pandas 사용자 가이드에서 더 깊이 갈 수 있어요.
다음 글(7편)에서는 시리즈를 마무리하며 실험 문화·코드 설계 원칙·결과 해석 가이드·흔한 실수 10가지를 정리합니다.