Tips & Tricks (업데이트: 2026. 6. 2.)

Claude Code로 A/B 테스트 구현하기: SaaS와 블로그 수익화 실전 가이드

Claude Code로 가설, 이벤트 스키마, 서버 분기, SQL 분석, 개인정보 동의, 롤백까지 안전하게 설계하는 방법.

Claude Code로 A/B 테스트 구현하기: SaaS와 블로그 수익화 실전 가이드

토글보다 가설이 먼저다

A/B 테스트는 화면 두 개를 무작위로 보여주는 기능이 아닙니다. SaaS에서는 무료 가입, 유료 전환 의향, 상담 예약 같은 수익 지표를 검증하고, 블로그에서는 광고, 제휴 링크, 뉴스레터, 템플릿 판매 흐름이 독자 경험을 해치지 않는지 확인하는 실험입니다. Claude Code는 분기 코드를 빠르게 만들 수 있지만, 가설, 이벤트 스키마, 표본 크기, 가드레일 지표, 롤백 기준이 없으면 결과가 좋아 보여도 의사결정에 쓰기 어렵습니다.

용어를 쉽게 정리하면 이렇습니다. Variant는 비교할 버전입니다. Exposure는 사용자가 어떤 버전을 처음 봤는지 기록하는 순간입니다. Guardrail metric은 나빠지면 안 되는 안전 지표로, LCP, 오류율, 구매 의향 클릭 등이 있습니다. False positive는 우연한 흔들림을 성공으로 착각하는 일입니다.

Claude Code에는 먼저 비즈니스 질문을 줍니다.

Next.js App Router 기반 SaaS/블로그의 A/B 테스트를 설계합니다.
목표는 단순 클릭 증가가 아니라 수익화 퍼널 개선입니다.

experiment id: pricing_page_offer_2026_06
hypothesis: 가격 페이지 CTA를 "Start free trial"에서 "Start with the free plan"으로 바꾸면 유료 의향 클릭을 떨어뜨리지 않고 가입 시작률이 오른다.
primary metric: signup_start_rate
guardrails: purchase_link_click_rate, p75 LCP, JavaScript error rate
필요한 결과물: 이벤트 스키마, 서버 측 배정, Cookie/localStorage 주의점, BigQuery 스타일 SQL, Playwright 검증, 롤아웃/롤백 체크리스트.

실전 예시는 최소 세 개 이상 준비합니다. SaaS 가격 페이지 CTA, 블로그 제휴 영역 위치, 뉴스레터 가입 문구, 온보딩 단계, 상담 예약 박스가 좋은 후보입니다. 기능 플래그는 Claude Code feature flags, 이벤트 설계는 Claude Code analytics implementation도 함께 보면 좋습니다.

사용 사례주요 지표가드레일흔한 실패
SaaS 가격 CTA가입 시작률유료 의향 클릭, 오류율, LCP가입은 늘지만 구매 가능성이 낮아짐
블로그 제휴 박스상품 링크 클릭률읽기 완료율, 이탈률, 속도수익 박스가 너무 빨리 나와 신뢰를 잃음
뉴스레터 폼구독 완료율스팸 가입, 해지율숫자는 늘지만 리스트 품질이 낮아짐
온보딩 화면첫 성공률문의량, 활성화 품질단기 완료율이 장기 이탈을 가림

이벤트 스키마를 먼저 고정한다

실험 후에 데이터 이름이 제각각이라는 사실을 발견하면 비용이 큽니다. 같은 클릭이 button_click, ctaClicked, signup_click으로 섞이면 분석은 사람이 손으로 보정해야 합니다. UI를 만들기 전에 Claude Code에게 타입이 있는 이벤트 계약을 만들게 하세요. Google Analytics를 쓴다면 공식 GA4 이벤트 문서Google tag 파라미터 문서를 기준으로 이름을 정합니다.

// lib/experiment-events.ts
export type ExperimentId = "pricing_page_offer_2026_06";
export type VariantId = "control" | "free_plan_copy";

export type ExperimentEvent =
  | {
      event_name: "experiment_exposure";
      experiment_id: ExperimentId;
      variant: VariantId;
      anonymous_id: string;
      page_path: string;
    }
  | {
      event_name: "cta_click";
      experiment_id: ExperimentId;
      variant: VariantId;
      anonymous_id: string;
      cta_id: "pricing_primary" | "article_bottom" | "sidebar_offer";
      page_path: string;
    }
  | {
      event_name: "purchase_link_click";
      experiment_id: ExperimentId;
      variant: VariantId;
      anonymous_id: string;
      product_id: string;
      value_usd: number;
      page_path: string;
    }
  | {
      event_name: "guardrail_metric";
      experiment_id: ExperimentId;
      variant: VariantId;
      anonymous_id: string;
      metric_name: "lcp_ms" | "js_error" | "bounce";
      value: number;
      page_path: string;
    };

declare global {
  interface Window {
    gtag?: (command: "event", name: string, params: Record<string, unknown>) => void;
  }
}

export function trackExperimentEvent(event: ExperimentEvent) {
  if (typeof window === "undefined") return;

  window.gtag?.("event", event.event_name, {
    experiment_id: event.experiment_id,
    variant: event.variant,
    anonymous_id: event.anonymous_id,
    page_path: event.page_path,
    ...event,
  });
}

이벤트에는 이메일, 이름, 회사명, 자유 입력 텍스트를 넣지 않습니다. 광고나 분석 저장에 동의가 필요한 지역이라면 태그 전송보다 먼저 동의 상태를 초기화해야 합니다. Google의 consent mode 가이드를 참고하세요.

서버 측에서 Variant를 배정한다

localStorage만 쓰면 구현은 쉽지만 첫 화면 깜빡임, 로그인 전후 배정 변경, 시크릿 모드 초기화, 저장소 차단 문제가 생깁니다. MDN은 localStorage를 origin 단위로 저장되고 브라우저 세션을 넘어 유지되는 저장소라고 설명합니다. 참고: MDN localStorage. 하지만 첫 서버 렌더링의 기준으로 쓰기에는 부족합니다.

Next.js App Router에서는 Route Handler가 작고 안전한 출발점입니다. 공식 route.ts 문서는 Web Request/Response API로 요청을 처리하는 방법을 설명합니다. Cookie 설정은 NextResponse 문서를 확인하세요. Next.js 16에서는 Middleware가 Proxy로 이름이 바뀌었으므로 엣지 재작성은 proxy.js 문서를 봅니다.

// app/api/experiments/assign/route.ts
import { NextRequest, NextResponse } from "next/server";

export const runtime = "edge";

type Variant = "control" | "free_plan_copy";

const EXPERIMENTS = {
  pricing_page_offer_2026_06: {
    cookieName: "ab_pricing_page_offer_2026_06",
    variants: [
      { id: "control", weight: 50 },
      { id: "free_plan_copy", weight: 50 },
    ] satisfies Array<{ id: Variant; weight: number }>,
  },
};

function hashToBucket(input: string) {
  let hash = 2166136261;
  for (let index = 0; index < input.length; index += 1) {
    hash ^= input.charCodeAt(index);
    hash = Math.imul(hash, 16777619);
  }
  return Math.abs(hash) % 100;
}

function chooseVariant(experimentId: keyof typeof EXPERIMENTS, anonymousId: string): Variant {
  const experiment = EXPERIMENTS[experimentId];
  const bucket = hashToBucket(`${experimentId}:${anonymousId}`);
  let cumulative = 0;

  for (const variant of experiment.variants) {
    cumulative += variant.weight;
    if (bucket < cumulative) return variant.id;
  }

  return experiment.variants[0].id;
}

export async function GET(request: NextRequest) {
  const experimentId = request.nextUrl.searchParams.get("experiment");

  if (experimentId !== "pricing_page_offer_2026_06") {
    return NextResponse.json({ error: "Unknown experiment" }, { status: 404 });
  }

  const experiment = EXPERIMENTS[experimentId];
  const testAnonymousId = request.headers.get("x-test-anonymous-id");
  const existingCookie = request.cookies.get(experiment.cookieName)?.value;
  const anonymousId = testAnonymousId ?? existingCookie ?? crypto.randomUUID();
  const variant = chooseVariant(experimentId, anonymousId);

  const response = NextResponse.json({
    experimentId,
    variant,
    anonymousId,
  });

  response.cookies.set(experiment.cookieName, anonymousId, {
    httpOnly: true,
    sameSite: "lax",
    secure: process.env.NODE_ENV === "production",
    path: "/",
    maxAge: 60 * 60 * 24 * 30,
  });

  return response;
}

Cookie도 주의가 필요합니다. MDN의 secure cookie configurationSecure, HttpOnly, SameSite를 설명합니다. 로그인 SaaS는 해시된 사용자 ID를, 공개 블로그는 짧은 익명 Cookie를, 광고 분석은 CMP 동의 상태를 우선하는 식으로 나눠 설계합니다.

기능 플래그로 롤아웃과 롤백을 분리한다

실험 코드는 배포하되 노출 비율은 설정으로 바꿀 수 있어야 합니다. Vercel 환경이라면 공식 Vercel Flags를 검토할 수 있고, 초기에는 YAML 설정만으로도 충분합니다.

# config/experiments.yaml
experiments:
  pricing_page_offer_2026_06:
    status: running
    owner: masa
    hypothesis: "Free-plan copy increases signup starts without hurting paid intent."
    allocation_percent: 50
    variants:
      control: 50
      free_plan_copy: 50
    primary_metric: signup_start_rate
    guardrails:
      - purchase_link_click_rate
      - p75_lcp_ms
      - js_error_rate
    rollback:
      if_js_error_rate_increases_by: 0.02
      if_p75_lcp_ms_worse_by_ms: 300
      action: "set allocation_percent to 0 and keep logging exposure for audit"

오류율이 오르거나 LCP가 300ms 이상 나빠지거나 유료 의향 클릭이 줄면 새 버전 노출을 멈춥니다. 좋은 결과가 보여도 바로 100%로 올리지 말고 10%, 50%, 100% 순서로 확장합니다.

SQL로 분석하되 거짓 승리를 피한다

분모는 exposure입니다. 버전을 보지 않은 사용자를 넣으면 실험이 아니라 전체 트래픽 비교가 됩니다. 같은 사용자가 여러 variant를 본 경우에는 제외하거나 원인을 조사합니다. BigQuery를 쓴다면 SAFE_DIVIDE로 0 나누기 오류를 피합니다.

-- BigQuery Standard SQL
WITH exposure_raw AS (
  SELECT
    anonymous_id,
    experiment_id,
    ARRAY_AGG(variant ORDER BY event_timestamp LIMIT 1)[OFFSET(0)] AS variant,
    MIN(event_timestamp) AS first_exposed_at,
    COUNT(DISTINCT variant) AS variant_count
  FROM `project.dataset.events`
  WHERE event_name = 'experiment_exposure'
    AND experiment_id = 'pricing_page_offer_2026_06'
  GROUP BY anonymous_id, experiment_id
),
exposure AS (
  SELECT anonymous_id, experiment_id, variant, first_exposed_at
  FROM exposure_raw
  WHERE variant_count = 1
),
events_after_exposure AS (
  SELECT
    e.variant,
    e.anonymous_id,
    ev.event_name,
    ev.value_usd,
    ev.value_ms
  FROM exposure e
  LEFT JOIN `project.dataset.events` ev
    ON ev.anonymous_id = e.anonymous_id
   AND ev.experiment_id = e.experiment_id
   AND ev.event_timestamp >= e.first_exposed_at
)
SELECT
  variant,
  COUNT(DISTINCT anonymous_id) AS exposed_users,
  COUNT(DISTINCT IF(event_name = 'cta_click', anonymous_id, NULL)) AS cta_users,
  SAFE_DIVIDE(
    COUNT(DISTINCT IF(event_name = 'cta_click', anonymous_id, NULL)),
    COUNT(DISTINCT anonymous_id)
  ) AS cta_click_rate,
  COUNT(DISTINCT IF(event_name = 'purchase_link_click', anonymous_id, NULL)) AS purchase_intent_users,
  SAFE_DIVIDE(
    COUNT(DISTINCT IF(event_name = 'purchase_link_click', anonymous_id, NULL)),
    COUNT(DISTINCT anonymous_id)
  ) AS purchase_intent_rate,
  AVG(IF(event_name = 'guardrail_metric' AND value_ms IS NOT NULL, value_ms, NULL)) AS avg_guardrail_ms,
  SUM(IF(event_name = 'guardrail_metric' AND value_usd IS NOT NULL, value_usd, 0)) AS revenue_proxy_usd
FROM events_after_exposure
GROUP BY variant
ORDER BY variant;

표본 크기는 시작 전에 정해야 합니다. 매일 들여다보다가 좋아 보이는 순간 멈추면 false positive가 늘어납니다. 여러 버전, 많은 세그먼트, 사후 주요 지표 변경, 광고 캠페인과 동시 시작도 위험합니다. Claude Code에는 p값 계산보다 최소 표본, 관찰 기간, 제외 규칙, 중단 조건을 문서화하게 하세요.

Playwright로 동작을 확인한다

출시 전에는 같은 익명 ID가 같은 variant를 받는지, 알 수 없는 실험 ID가 404인지, 수익화 CTA가 한 번만 렌더링되는지 확인합니다. Playwright의 testexpect, 자동 재시도 assertions를 기준으로 작성합니다.

// tests/experiments.spec.ts
import { test, expect } from "@playwright/test";

test.describe("pricing_page_offer_2026_06", () => {
  test("keeps assignment stable for the same anonymous id", async ({ request, baseURL }) => {
    const url = `${baseURL}/api/experiments/assign?experiment=pricing_page_offer_2026_06`;
    const headers = { "x-test-anonymous-id": "demo-user-42" };

    const first = await request.get(url, { headers });
    const second = await request.get(url, { headers });

    expect(first.ok()).toBeTruthy();
    expect(second.ok()).toBeTruthy();
    expect(await first.json()).toMatchObject(await second.json());
  });

  test("rejects unknown experiments", async ({ request, baseURL }) => {
    const response = await request.get(`${baseURL}/api/experiments/assign?experiment=missing`);

    expect(response.status()).toBe(404);
  });

  test("renders one monetization CTA on the pricing page", async ({ page }) => {
    await page.goto("/pricing?e2e_anonymous_id=demo-user-42");

    await expect(page.getByTestId("pricing-cta")).toBeVisible();
    await expect(page.getByTestId("pricing-cta")).toHaveCount(1);
  });
});

Masa가 이 흐름을 기사 CTA 검증에 적용했을 때 가장 도움이 된 것은 SQL보다 사전 이벤트 표였습니다. 어떤 클릭을 수익화 CTA로 볼지, exposure를 언제 남길지, 여러 variant에 들어간 사용자를 어떻게 처리할지 먼저 정하니 Claude Code 수정 요청이 줄었습니다. 작은 테스트에서는 Playwright가 localStorage 깜빡임을 잡아 서버 Cookie 방식으로 되돌릴 수 있었습니다.

실험 수를 늘리는 것보다 중요한 것은 각 실험이 하나의 질문에 안전하게 답하게 하는 것입니다. 실제 퍼널에 적용하려면 Claude Code trainingproduct templates도 함께 확인하세요.

#Claude Code #A/B 테스트 #SaaS #블로그 수익화 #Next.js #분석
무료

무료 PDF: Claude Code 치트시트

이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.

개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.

Masa

작성자 소개

Masa

Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.