PH pullh
현장 노트 / 가입 전환율 3%를 움직인 JavaScript 실험 로그
실험 로그 5분 JavaScript

가입 전환율 3%를 움직인 JavaScript 실험 로그

버튼 색을 바꾼 이야기가 아니다. 카피, 이벤트, 대기 시간을 같이 만지며 제품 팀이 어떻게 학습했는지 적었다.

JavaScript 현장 이야기 커버 이미지

그로스 실험은 흔히 UI 스킨 바꾸기로 오해됩니다. 실제로는 사용자가 어느 순간에 멈추는지를 해석하고, 그 해석을 짧은 코드로 검증하는 일에 가깝습니다. 그리고 이 로그가 남기려는 교훈은 그보다 앞에 있습니다. 검증하기 전에 먼저 계측을 의심해야 한다는 것입니다.

아래는 가입 퍼널을 3주 동안 세 번 건드리면서 남긴 기록입니다. 순서대로 읽어 주시길 권합니다. 첫 번째 실험이 성공처럼 보였다가 무너지는 과정이 이 로그의 절반이기 때문입니다.

실험 0 · 처음 본 숫자

시작은 퍼널 대시보드였습니다. 가입 폼 진입에서 가입 완료까지의 전환율이 데스크톱에서는 62%인데 모바일에서는 48%로 찍혀 있었습니다. 14%p 차이는 기기 차이만으로 설명하기 어려운 크기였습니다.

같은 주에 고객 지원 채널에 이런 문의가 반복해서 들어왔습니다. “가입 버튼을 눌렀는데 아무 일도 안 일어나서 다시 눌렀습니다.” 이 문장 하나가 사실 이 글의 결말을 이미 담고 있었지만, 그때 저희는 알아채지 못했습니다. 대신 회의실에서 나온 첫 번째 결론은 훨씬 익숙한 쪽이었습니다. 히어로 카피가 약해서 사용자가 확신을 못 얻는다는 것이었습니다.

실험 1 · 히어로 카피 교체

가설. 폼 상단 문구가 기능 나열에 가까워서 가입 동기를 만들지 못한다. 결과 중심 문장으로 바꾸면 완료율이 오른다.

변경. 히어로 문구와 제출 버튼 라벨을 바꾼 변형 B를 만들고, 방문자를 50 대 50으로 나눴습니다. 변형 할당은 클라이언트에서 처리했고, 완료 시점에 signup_complete 이벤트를 보냈습니다.

결과. 6일 뒤 대시보드에서 변형 B의 전환율이 48%에서 62%로 올라 있었습니다. 데스크톱을 단숨에 따라잡은 숫자였습니다.

해석. 이 시점의 팀 분위기는 좋았습니다. 다만 상승 폭이 너무 컸습니다. 카피 한 줄로 14%p가 움직였다는 건, 경험상 카피가 좋았다는 뜻보다는 무언가 잘못 세고 있다는 뜻일 가능성이 높습니다. 그래서 릴리스 대신 검증을 먼저 하기로 했습니다.

실험 1이 거짓이었던 이유

가장 먼저 확인한 건 분모였습니다. 변형 B에서 폼 진입 이벤트가 덜 잡히면 비율이 부풀 수 있기 때문입니다. 그런데 진입 이벤트 수는 두 변형이 거의 같았습니다. 분모는 멀쩡했습니다.

그렇다면 분자를 봐야 했습니다. 전환 수를 세션 단위로 쪼개서, 한 세션에서 완료 이벤트가 몇 번 발생했는지 분포를 뽑았습니다.

QUERY — 변형별 세션당 signup_complete 발생 횟수 분포

SELECT variant,
       events_per_session,
       COUNT(*) AS sessions
FROM (
  SELECT session_id,
         variant,
         COUNT(*) AS events_per_session
  FROM events
  WHERE name = 'signup_complete'
    AND day BETWEEN :start_day AND :end_day
  GROUP BY session_id, variant
) t
GROUP BY variant, events_per_session
ORDER BY variant, events_per_session;

variant      events_per_session   sessions
control               1             4,812
control               2                37
variant_b             1             3,905
variant_b             2               911   <-- 여기
variant_b             3                46

-- 실제 가입 계정 수(서버 기준): control 4,849 / variant_b 3,951

대조군에서는 한 세션에 완료 이벤트가 두 번 이상 찍힌 경우가 0.8%였는데, 변형 B에서는 그 비율이 20%에 가까웠습니다. 결정적인 건 마지막 줄이었습니다. 서버가 실제로 생성한 계정 수는 변형 B가 오히려 조금 적었습니다. 전환율이 오른 게 아니라, 이벤트가 여러 번 발화하면서 분자만 부풀어 있었던 것입니다.

원인은 코드에 있었습니다. 변형 B에서만 가입 폼의 마운트 함수가 두 번 호출되는 경로가 있었고, 그때마다 submit 리스너가 새로 등록됐습니다. SPA 라우팅에서 화면을 떠날 때 리스너를 정리하지 않은 것도 겹쳤습니다.

BUG — 리스너가 중복 등록되던 코드

function mountSignupForm(el) {
  el.addEventListener('submit', async (event) => {
    event.preventDefault();
    await createAccount(readForm(el));
    track('signup_complete', { variant: currentVariant() });
  });
}

// 라우터가 /signup에 진입할 때 한 번 호출됩니다.
router.on('/signup', () => mountSignupForm(document.querySelector('#signup')));

// 그런데 변형이 확정되면 다시 그리려고 한 번 더 호출했습니다.
// 대조군은 기본 마크업이라 이 경로를 타지 않았고, 변형 B만 두 번 등록됐습니다.
experiment.onAssign(() => mountSignupForm(document.querySelector('#signup')));

수정은 두 겹으로 했습니다. 리스너에 정리 경로를 붙여 마운트가 중복돼도 누적되지 않게 만들고, 전송 자체도 멱등하게 바꿨습니다. 계측 코드는 버그가 나면 조용히 틀린 숫자를 만들기 때문에, 한쪽만 고치는 걸로는 마음이 놓이지 않았습니다.

FIX — 정리 가능한 리스너 + 멱등한 전송

const sent = new Set();

function trackOnce(key, name, payload) {
  if (sent.has(key)) return;
  sent.add(key);
  track(name, payload);
}

function mountSignupForm(el) {
  const controller = new AbortController();

  el.addEventListener('submit', async (event) => {
    event.preventDefault();
    const account = await createAccount(readForm(el));
    // 서버가 돌려준 계정 id를 키로 씁니다. 같은 가입은 몇 번 호출돼도 한 번만 나갑니다.
    trackOnce(`signup_complete:${account.id}`, 'signup_complete', {
      variant: currentVariant(),
    });
  }, { signal: controller.signal });

  return () => controller.abort();   // 언마운트 시 반드시 호출합니다.
}

// 라우터가 화면을 떠날 때 정리 함수를 실행하도록 연결합니다.
router.on('/signup', () => {
  const unmount = mountSignupForm(document.querySelector('#signup'));
  return unmount;
});

고친 계측으로 실험 1을 다시 돌렸을 때 변형 B의 전환율은 48.4%였습니다. 대조군과의 차이는 통계적으로 의미 없는 수준이었습니다. 카피는 아무것도 바꾸지 못했습니다. 이벤트 리스너와 정리 시점을 다루는 기본기는 JavaScript 비동기 가이드에 정리해 두었는데, 이 버그도 결국 그 범주의 실수였습니다.

실험 2 · 제출 후 대기 시간

가설. 지원 채널에 반복해서 들어온 “눌렀는데 반응이 없다”가 진짜 신호다. 제출 직후 화면이 멈춰 있는 시간이 이탈을 만든다.

변경. 먼저 측정했습니다. 제출 클릭부터 다음 화면 전환까지 걸린 시간을 재 보니 p50이 900ms, p95가 1,900ms였습니다. 모바일 네트워크에서는 더 길었습니다. 확인해 보니 가입 API가 응답을 돌려주기 전에 환영 메일 발송과 외부 CRM 동기화를 동기로 처리하고 있었습니다. 이 둘을 서버 큐로 넘기고, 클라이언트에서는 클릭 즉시 버튼을 잠그고 진행 상태를 보여 주도록 바꿨습니다.

CHANGE — 후속 작업 분리 + 즉시 피드백

// before: 응답이 올 때까지 화면에 아무 변화가 없었습니다. (p95 1,900ms)
button.addEventListener('click', async () => {
  const res = await api.signup(form);
  location.href = res.nextUrl;
});

// after: 상태를 먼저 바꾸고, 느린 후속 작업은 서버 큐로 넘겼습니다.
button.addEventListener('click', async () => {
  setPending(true);                    // 버튼 잠금 + 진행 표시
  try {
    const res = await api.signup(form, {
      defer: ['welcome_email', 'crm_sync'],
    });
    location.href = res.nextUrl;       // p95 640ms
  } catch (err) {
    setPending(false);
    showFormError(err);                // 실패도 화면에서 보이게 남깁니다.
  }
});

결과. 모바일 완료율이 48%에서 50.3%로 올라갔습니다. 중복 제출로 인한 서버 측 충돌 오류도 눈에 띄게 줄었는데, 버튼 잠금이 그 부작용까지 같이 걷어낸 덕분이었습니다.

해석. 사용자는 느린 것보다 반응이 없는 것을 더 못 견딥니다. 640ms도 빠른 시간은 아니지만, 그동안 화면이 움직이고 있다는 사실이 숫자보다 크게 작용했습니다. 실패 경로를 화면에 남기는 처리도 같이 들어갔는데, 이 부분의 원칙은 JavaScript 에러 처리 가이드에서 다룬 방식을 따랐습니다.

실험 3 · 카피, 다시

가설. 실험 1의 카피 변경은 계측 버그에 묻혀 제대로 평가된 적이 없다. 이제 계측을 믿을 수 있으니 다시 판단할 수 있다.

변경. 이번에는 히어로 문구가 아니라 이탈이 가장 많은 지점의 문장을 바꿨습니다. 이벤트 이름을 click-1 같은 형태에서 signup_form_field_blur처럼 맥락이 드러나게 정리한 뒤 보니, 이탈이 몰린 곳은 상단 카피가 아니라 휴대폰 번호 입력란이었습니다. 왜 필요한지 설명이 없어서였습니다. 라벨 아래 한 줄 설명을 붙이고, 선택 입력이라는 사실을 명시했습니다.

결과. 모바일 완료율이 50.3%에서 51.4%로 올라갔습니다. 실험 2와 합치면 출발점 대비 3%p 남짓입니다.

해석. 카피는 효과가 있었습니다. 다만 저희가 처음에 짐작한 위치가 아니었습니다. 이벤트 이름을 사람이 읽을 수 있게 바꾸지 않았다면 그 위치를 영영 찾지 못했을 것입니다.

TIP

이 로그는 여러 팀에서 반복적으로 나타나는 상황을 하나의 사례로 재구성한 예시입니다. 여기 적힌 3%p를 비롯한 모든 수치는 이야기의 앞뒤를 맞추기 위해 구성한 값이며, 실제 서비스의 측정 결과가 아닙니다. 같은 변경이 다른 제품에서 같은 폭으로 움직인다는 뜻으로 읽지 말아 주세요.

세 번의 실험이 남긴 것

가장 값비싼 순간은 실험 1이 성공처럼 보였던 6일이었습니다. 그때 곧바로 전체 배포를 했다면, 저희는 아무 효과도 없는 카피를 성공 사례로 등록하고 다음 분기 로드맵을 그 위에 세웠을 것입니다. 잘못된 계측은 틀린 답을 주는 게 아니라 그럴듯한 답을 줍니다. 그래서 더 위험합니다.

그 이후로 팀에 규칙이 하나 생겼습니다. 실험 결과가 기대보다 크면 축하하기 전에 계측부터 의심합니다. 구체적으로는 세션당 이벤트 발생 횟수 분포를 보고, 클라이언트 집계와 서버 기록을 대조합니다. 이 두 가지는 실험을 설계할 때 같이 만들어 둡니다.

  • 측정값이 기대보다 크면 먼저 계측을 의심합니다. 분모뿐 아니라 분자도 셉니다.
  • 클라이언트 집계는 서버가 남긴 사실과 대조할 수 있어야 합니다. 대조할 수 없는 지표는 의사결정에 쓰지 않습니다.
  • 전송 코드는 멱등하게 만듭니다. 계정 id처럼 서버가 만든 값을 키로 쓰면 중복 실행을 흡수할 수 있습니다.
  • 리스너를 등록하는 코드에는 정리하는 코드를 붙여서 씁니다. SPA에서 누락된 정리는 대개 계측 오류로 먼저 드러납니다.
  • 이벤트 이름은 사람이 읽고 위치를 떠올릴 수 있게 적습니다. 이름이 나쁘면 데이터가 있어도 해석이 막힙니다.
  • 변수는 시각적인 것보다 대기 시간과 문장에서 먼저 찾습니다. 반응 없는 1초는 어떤 카피보다 강하게 이탈을 만듭니다.

계측 코드도 결국 코드입니다. 저희는 이 일을 겪은 뒤 중복 전송 방지 로직에 테스트를 붙였습니다. 같은 폼을 두 번 마운트했을 때 이벤트가 한 번만 나가는지 확인하는 짧은 테스트인데, 작성 방식은 JavaScript 테스트 가이드에 있는 형태 그대로입니다. 실험 코드를 실험이 끝난 뒤 지우기 쉽게 두는 습관도 같이 들였습니다.

Next Read

JavaScript 실무 가이드로 이어서 보기

사용자 반응을 보고 실험을 다시 거는 루프에서는 계측과 응답 속도가 곧 실력이 됩니다. JavaScript는 그 루프를 가장 짧게 만들어 주는 도구입니다.