Cloudflare Workers로 A/B 테스트 — 엣지에서 실험 분기하기
클라이언트 사이드 A/B 테스트의 문제
핵심: A/B 테스트 스크립트가 렌더링을 차단하면, 사용자는 빈 화면을 본다.
전통적인 A/B 테스트 도구는 대부분 클라이언트 사이드 JavaScript로 동작한다. 페이지가 로드될 때 스크립트가 실행되고, 사용자를 실험 그룹에 배정한 뒤, DOM을 조작해서 변형(variant)을 보여주는 방식이다. 문제는 이 스크립트가 <head>에 렌더링 차단(render-blocking) 방식으로 삽입된다는 점이다. 느린 네트워크에서는 이 스크립트 하나 때문에 수백 밀리초의 빈 화면이 발생한다.
비유하면 이렇다. 놀이공원 입구에서 팔찌 색깔을 정해야 하는데, 팔찌 담당자가 늦게 와서 모든 입장객이 대기하는 상황이다. 팔찌 색깔 정하는 일을 입구가 아니라 **티켓 발권 시스템(서버/엣지)**에서 미리 해두면, 입장객은 기다릴 필요가 없다.
엣지에서 A/B 테스트한다는 건 뭔가?
| 방식 | 실험 배정 시점 | 렌더링 차단 | 깜빡임(FOUC) |
|---|---|---|---|
| 클라이언트 사이드 | 브라우저 로드 후 | 있음 | 있음 |
| 서버 사이드 | 서버 응답 전 | 없음 | 없음 |
| 엣지 (Workers) | CDN 레벨 | 없음 | 없음 |
Cloudflare Workers는 요청이 오리진 서버에 도달하기 전에 CDN 엣지에서 실행된다. 여기서 실험 그룹을 배정하고, 해당 그룹에 맞는 콘텐츠를 라우팅하면 된다. 클라이언트에 추가 스크립트가 필요 없으므로, 렌더링을 전혀 막지 않는다.
[💡 잠깐! 이 용어는?] 엣지 컴퓨팅(Edge Computing): 사용자와 가까운 CDN 노드에서 로직을 실행하는 방식. 오리진 서버까지 왕복하지 않아도 되므로 응답이 빠르다.
3단계 프레임워크
Philip Walton이 제안한 엣지 A/B 테스트의 핵심 흐름은 딱 세 단계다.
1단계: 사용자를 랜덤 그룹에 배정한다
Worker가 요청을 가로채서 xid(experiment ID) 쿠키를 확인한다. 쿠키가 없으면 새로 생성하고, 해시 함수로 0~99 사이의 숫자를 만들어 실험 그룹을 결정한다.
function getOrCreateExperimentId(request) {
const cookieHeader = request.headers.get('Cookie') || ''
const match = cookieHeader.match(/(?:^|;\s*)xid=([^;]+)/)
if (match) {
return match[1]
}
const bytes = new Uint8Array(16)
crypto.getRandomValues(bytes)
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')
}
function getExperimentGroup(xid, numGroups) {
let hash = 0
for (let i = 0; i < xid.length; i++) {
hash = (hash * 31 + xid.charCodeAt(i)) & 0xffffffff
}
return Math.abs(hash) % numGroups
}중요한 건 같은 xid는 항상 같은 그룹에 속한다는 점이다. 사용자가 새로고침해도, 다른 페이지로 이동해도 동일한 실험 경험을 유지한다.
2단계: 그룹에 맞는 콘텐츠를 서빙한다
디렉토리 구조로 변형을 관리한다. 컨트롤 그룹은 원본 경로를, 실험 그룹은 변형 서브디렉토리의 콘텐츠를 받는다.
/
├── index.html ← 컨트롤 그룹 (원본)
├── /variant-1/
│ └── index.html ← 실험 그룹 1
└── /variant-2/
└── index.html ← 실험 그룹 2Worker는 그룹 번호에 따라 요청 URL을 재작성(rewrite)한다.
async function handleRequest(request) {
const url = new URL(request.url)
const xid = getOrCreateExperimentId(request)
const group = getExperimentGroup(xid, 3)
if (group === 1) {
url.pathname = `/variant-1${url.pathname}`
} else if (group === 2) {
url.pathname = `/variant-2${url.pathname}`
}
const response = await fetch(url.toString(), request)
const newResponse = new Response(response.body, response)
newResponse.headers.append(
'Set-Cookie',
`xid=${xid}; Path=/; Max-Age=31536000; SameSite=Lax`
)
return newResponse
}
addEventListener('fetch', (event) => {
event.respondWith(handleRequest(event.request))
})3단계: 분석 데이터와 연동한다
xid 쿠키 값을 Google Analytics나 다른 분석 도구에 커스텀 디멘션으로 전송하면, 그룹별 지표를 비교할 수 있다. Worker에서 응답 HTML에 분석 스크립트의 설정값을 주입하는 방식도 가능하다.
[💡 잠깐! 이 용어는?] 커스텀 디멘션(Custom Dimension): Google Analytics 등에서 기본 제공하지 않는 사용자 정의 분류 기준. 실험 그룹 ID를 보내면 그룹별 데이터를 분리해서 볼 수 있다.
캐시 적중률의 함정
엣지에서 A/B 테스트를 할 때 놓치기 쉬운 부분이 캐시 적중률(cache hit rate) 차이다.
| 그룹 | 트래픽 비율 | 캐시 적중률 |
|---|---|---|
| 컨트롤 (원본) | ~90% | 11.07% |
| Variant 1 | ~5% | 1~2% |
| Variant 2 | ~5% | 1~2% |
컨트롤 그룹은 트래픽의 대부분을 차지하므로 캐시가 따뜻하게 유지된다. 반면 변형 그룹은 트래픽이 적어서 캐시 미스가 자주 발생한다. 이 차이가 성능 지표에 그대로 반영되면, "변형이 더 느리다"는 잘못된 결론을 내릴 수 있다.
해결 방법은 Render Time 지표를 쓰는 것이다.
Render Time = FCP - TTFB
FCP(First Contentful Paint)에서 TTFB(Time to First Byte)를 빼면, 네트워크/캐시 차이를 제거하고 순수하게 브라우저가 렌더링에 걸린 시간만 비교할 수 있다.
[💡 잠깐! 이 용어는?] FCP(First Contentful Paint): 브라우저가 텍스트나 이미지 등 첫 번째 콘텐츠를 화면에 그린 시점. TTFB(Time to First Byte): 요청을 보낸 뒤 서버로부터 첫 바이트가 도착한 시점. 네트워크 + 서버 처리 시간을 반영한다.
실험 결과: 렌더링 차단 CSS의 영향
Philip Walton은 실제로 렌더링을 차단하는 CSS를 변형 그룹에 주입해서 영향을 측정했다. 결과는 명확했다.
| 지표 | 컨트롤 | 변형 (blocking CSS) | 차이 |
|---|---|---|---|
| Render Time (p75) | ~320ms | ~560ms | +240ms |
| Render Time (p95) | ~680ms | ~1,020ms | +340ms |
p75에서 약 240ms, p95에서 약 340ms의 차이가 발생했다. 클라이언트 사이드 A/B 테스트 스크립트가 렌더링을 차단하면 이 정도 성능 손실이 발생한다는 걸 수치로 확인한 셈이다.
모범 사례
엣지 A/B 테스트를 실전에 적용할 때 기억할 포인트를 정리한다.
| 항목 | 권장 사항 |
|---|---|
| 에셋 공유 | CSS/JS/이미지는 변형 간 공유하고, HTML만 분기한다. 중복 에셋은 캐시 효율을 떨어뜨린다. |
| 동시 실험 수 | 2~3개 이하로 제한한다. 실험이 많을수록 캐시가 분산되고 분석이 복잡해진다. |
| 쿠키 수명 | 실험 기간보다 길게 설정한다. 중간에 쿠키가 만료되면 사용자가 다른 그룹으로 이동할 수 있다. |
| 성능 지표 | TTFB가 아닌 **Render Time(FCP - TTFB)**을 기준으로 비교한다. |
| 트래픽 배분 | 변형 그룹에도 충분한 트래픽을 배분해야 통계적 유의성을 확보할 수 있다. |
정리
- 클라이언트 사이드 A/B 테스트는 렌더링을 차단하고, 느린 네트워크에서 빈 화면을 유발한다.
- Cloudflare Workers를 사용하면 엣지에서 실험 그룹을 배정하고 콘텐츠를 라우팅할 수 있다. 클라이언트에 추가 스크립트가 필요 없다.
- 캐시 적중률 차이를 보정하려면 Render Time(FCP - TTFB) 지표를 사용한다.
- 변형 간 에셋을 공유하고, 동시 실험 수를 제한하는 것이 캐시 효율과 분석 정확도를 높이는 핵심이다.
결국 A/B 테스트의 목적은 사용자 경험 개선인데, 테스트 도구 자체가 경험을 해치면 본말이 전도된 거다. 엣지에서 처리하면 이 모순을 깔끔하게 해결할 수 있다.
참고:
- Philip Walton — Performant A/B Testing with Cloudflare Workers: https://philipwalton.com/articles/performant-a-b-testing-with-cloudflare-workers/
- Cloudflare Workers Docs: https://developers.cloudflare.com/workers/
관심 있을 만한 포스트
Vinext — Vite 위에서 Next.js를 1주일 만에 다시 만든 이야기
Cloudflare가 AI와 함께 단 일주일, $1,100의 API 비용으로 Next.js 호환 프레임워크를 Vite 위에 구축한 과정.
Cloudflare Workers Static Assets 운용 가이드 — 라우팅과 캐시를 안정적으로 잡는 방법
Workers Static Assets를 기준으로 SPA/SSR 혼합 서비스에서 라우팅, 캐시, Worker 실행 순서를 설계하는 실전 패턴을 정리한다.
SVG 아이콘 — 코드 배포 없이 프로덕트 팀이 직접 관리하는 법
CSS mask-image와 S3를 조합해 개발자 개입 없이 아이콘을 교체하는 패턴을 소개한다.
VS Code 1.116 — 에이전트 디버깅, 포그라운드 터미널, 내장 Copilot
2026년 4월 VS Code 1.116이 에이전트 경험, 터미널, Chat UX, 내장 브라우저를 개선한 핵심 변경사항을 정리한다.
Naver FE News 2026년 4월 — 49MB 웹 페이지부터 Temporal Stage 4까지
Naver FE News 2026년 4월호에서 프론트엔드 개발자가 주목할 6가지 소식을 선별해 정리한다.
VS Code 에이전트 — 실전 개발에서 쓸 수 있게 만드는 세 가지 축
VS Code 1.110이 도입한 컨텍스트 관리, 에이전트 제어, 확장성 기능이 AI 에이전트를 실무에 투입 가능하게 만든 방식을 분석한다.
node:vfs — Node.js에 가상 파일 시스템이 필요한 이유
Matteo Collina가 제안한 node:vfs 모듈이 해결하려는 4가지 문제와 아키텍처를 분석한다.
Astro OAuth — Google 로그인부터 캘린더 API 연동까지
Astro에서 OAuth 2.0으로 Google 로그인을 구현하고 Calendar API까지 연동하는 전체 과정을 코드와 함께 정리한다.