Post

리뷰 사진이 빨라지면 더 살까: GA4 전후 비교 설계와 빠져 있던 이벤트

리뷰 사진이 빨라지면 더 살까: GA4 전후 비교 설계와 빠져 있던 이벤트

앞 글에서 리뷰 사진을 탭해서 크게 보기까지 실제 사용자 기준 p50 1.7초가 걸리고, 그중 1.3초가 애니메이션이라는 것을 찾았다. 이 과제의 기준은 세 단계였다.

  1. 대부분의 사용자가 지금 어떤 경험을 하는지 숫자로 잰다.
  2. 확대에 걸리는 시간을 지금보다 50% 줄인다.
  3. (가능하면) 매출에 미친 효과를 잰다.

이 글은 개선을 배포하기 전에 한 두 가지 일을 정리한다.

  • 개선 전후를 무엇으로, 어떻게 비교할지 정했다.
  • 그러다 계측에 빈 곳이 보여서 메웠다. lightbox 안에서 사진을 넘길 때는 이벤트가 나가지 않고 있었다.

회사의 실제 GA4 수치(구매율, 매출, 세션 수 등)는 싣지 않았다. 글에 나오는 GA4 숫자는 모두 설명용으로 만든 것이다.

배포 전에 정해 둔 것

배포한 뒤에 정하면 늦는 것들이 있다.

  • 개선 전 숫자: Datadog RUM 데이터는 30일이 지나면 사라진다. 그래서 표시 시간은 앞 글의 조사 때(10월 13일) 기록해 두었고, GA4도 8월 31일부터 10월 2일까지 5주를 주별로 기록했다.
  • “표시 완료”의 정의와 판정 기준: 사진의 페이드인이 끝나 완전히 보인 시점을 표시 완료로 보고, 판정에는 p50을 쓰고, 목표는 개선 전 p50의 절반(845ms)으로 정했다. 결과를 본 뒤에 기준을 정하면, 좋게 나온 숫자를 골라 해석하기 쉽다.
  • 측정 기간에 하지 않을 것: 측정 기간에는 리뷰 사진 주변의 다른 변경도, 리뷰 사진 이벤트의 내용 변경도 넣지 않는다. 전후 비교가 무너지기 때문이다.

시간은 RUM, 행동은 GA4

보고 싶은 것은 두 가지였고, 도구를 나눠 썼다.

보고 싶은 것도구이유
사진이 다 보일 때까지의 시간Datadog RUM클릭마다 반응 시간을 자동으로 기록한다
구매 행동이 바뀌었는지GA4리뷰 사진 탭 이벤트를 전부 받는다

RUM은 일부 세션만 수집하도록 설정되어 있어서, 구매율처럼 행동을 비교하기에는 표본이 모자란다. 반대로 GA4는 탭 이벤트를 전부 받지만, 사진이 뜨기까지의 시간은 모른다. 두 도구는 세션을 세는 방식도 달라서, 두 쪽 숫자를 섞어서 계산하지 않기로 했다.

탭 이벤트가 GA4까지 가는 길

리뷰 사진 썸네일에는 onclick="ga_clickEvent('review_image_click')"가 붙어 있다. 이 함수는 GA4로 직접 보내지 않고, 페이지의 dataLayer 배열에 넣기만 한다.

1
2
3
4
5
6
7
8
9
10
11
function ga_clickEvent(name, category = "contents") {
    var dataLayer = window.dataLayer || [];

    dataLayer.push({
        "event": "custom_click",
        "ga4click": {
            "category": category,
            "label": name
        }
    });
}

dataLayer를 지켜보던 Google Tag Manager(GTM)가 이걸 꺼내서 GA4의 custom_click 이벤트로 보낸다. GA4 화면에서는 label이 custom_click_label로 보인다. 페이지는 넣기만 하고 GTM이 꺼내 가니, 생산자와 소비자가 나뉜 이벤트 큐와 같은 구조다. 이벤트를 어디로 보낼지는 GTM 설정으로 바꿀 수 있고, 페이지 코드는 손대지 않아도 된다.

지표는 총량이 아니라 비율로

지표도구계산보고 싶은 것
표시 완료까지의 시간Datadog RUM@action.loading_time의 p50개선 전의 절반(845ms) 이하가 됐는가
구매율GA4Transactions ÷ Sessions구매로 이어졌는가 (주 지표)
세션당 매출GA4Purchase revenue ÷ Sessions매출로 이어졌는가 (주 지표)
세션당 탭 수GA4review_image_click 횟수 ÷ 탭한 세션 수리뷰 사진을 더 많이 보게 됐는가
탭 세션 비율GA4B의 세션 ÷ (A + B의 세션)리뷰 사진을 여는 사람이 늘었는가
세션당 장바구니 담기GA4Add to carts ÷ Sessions구매 직전 행동이 바뀌었는가 (보조)

매출 합계나 주문 수 같은 총량으로 비교하지 않는다. 주별 접속 수는 캠페인이나 계절에 따라 크게 출렁이기 때문이다. A와 B는 아래에서 정의한다.

A와 B: 탭한 세션과 탭하지 않은 세션

GA4 탐색 분석에서 세션 세그먼트 두 개를 만들어 비교했다.

조건A: 리뷰 사진을 탭하지 않은 세션B: 리뷰 사진을 탭한 세션
기기 = mobile포함포함
상품 상세를 봄 (view_item)포함포함
custom_click_label = review_image_click이 이벤트가 있는 세션은 통째로 뺀다포함
  • 개선 대상이 스마트폰이라 A·B 모두 모바일로 좁혔다.
  • 두 그룹 모두 상품 상세를 본 세션이다. 리뷰 사진을 탭할 기회가 있었던 세션끼리 비교하기 위해서다.
  • 탐색 분석은 자유 형식의 표로 만들고, 행에는 Nth week, 값에는 Transactions·Sessions·Purchase revenue·Add to carts를 넣고, A와 B를 세그먼트 비교로 나란히 놓는다. 설정을 문서에 그대로 적어 두었다. 개선 후에 다른 사람이 같은 숫자를 다시 뽑을 수 있게 하기 위해서다.

탭한 사람은 원래 더 산다: 이중차분

개선 전 숫자를 기록해 보면, B의 구매율이 A보다 높다. 그런데 이건 표시 속도와는 상관이 없다. 리뷰 사진까지 열어 보는 사람은 원래 구매 의욕이 높기 때문이다. 그래서 개선 후에 “B가 A보다 높다”는 것만으로는 아무것도 말할 수 없다.

그래서 차이 자체가 아니라 변화량의 차이를 본다. 이중차분(difference-in-differences)이라고 부르는 방법이다.

1
개선 효과 = (B의 개선 후 − B의 개선 전) − (A의 개선 후 − A의 개선 전)

A는 리뷰 사진을 열지 않았으니 표시 속도의 영향을 받지 않는다. 그러니 A의 변화는 캠페인이나 계절 같은 바깥 요인이라고 보고 빼 준다. 예를 들면 이렇다(설명용 가상의 숫자다).

그룹개선 전 구매율개선 후 구매율변화
B (탭함)5.0%5.6%+0.6%p
A (탭하지 않음)3.0%3.2%+0.2%p
개선 효과  +0.4%p

B만 보면 0.6%p 올랐지만, 그중 0.2%p는 A도 같이 오른 만큼이라 개선 덕분이라고 볼 수 없다. 단위는 구매율의 차이라서 %p다. 세션당 매출도 같은 식으로 계산한다.

이 방법에는 전제가 있다. 바깥 요인이 A와 B에 똑같이 작용해야 한다. 특정 상품 세일처럼 리뷰 사진을 자주 보는 사람에게 치우친 캠페인이 있는 주는 따로 적어 두고, 판정할 때 감안하기로 했다.

A를 함께 기록해 둔 덕분에 알아챈 것도 있었다. 개선 전 5주 중 한 주만 B의 구매율이 떨어져서 A와의 차이가 눈에 띄게 줄었다. 같은 주에 A는 평소대로였으니, 사이트 전체가 아니라 리뷰 쪽에 무슨 일이 있었을 수 있다. 그 주에 리뷰 관련 장애·배포·캠페인이 없었는지 확인할 거리로 적어 두었다.

GA4에서 걸린 것 두 가지

“세션 키 이벤트율”은 쓰지 않는다

GA4에는 “세션 키 이벤트율”이라는 비율 지표가 있어서 구매율로 쓰면 될 것 같았다. 그런데 이 속성에서는 session_start가 키 이벤트로 등록되어 있어서, 모든 세션이 키 이벤트를 가진 셈이 되고 값이 거의 100%가 된다. 그래서 구매율은 Transactions ÷ Sessions로 직접 계산했다.

기간 합계가 주별 합보다 작다

5주 전체로 낸 세션 수가 주별 세션 수를 더한 값보다 작게 나왔다. 그래서 기간 합계로 나눈 값과 주별 값이 맞지 않았다. 원인은 두 가지로 봤다.

  • 주를 걸친 세션은 주별로는 두 주에 한 번씩 세지만, 기간 합계에서는 한 번만 센다.
  • GA4는 세션 수 같은 값을 근사치로 집계한다.

그래서 비교는 기간 합계가 아니라 주별 값으로 하기로 했다.

세션당 탭 수는 숫자가 부풀어 있지 않은지도 봤다. GA4의 DebugView에서 탭 한 번에 이벤트가 한 번만 나가는 것을 확인했다.

판정 기준

지표“효과 있음”으로 보는 조건
표시 완료까지의 시간배포 후 3~4주를 합친 RUM p50이 845ms 이하 (주마다 보기에는 클릭 수가 적다)
구매율주별 (B − A)가 개선 전 5주의 최댓값을 3~4주 연속 넘는다
세션당 매출구매율과 같은 방식
탭 세션 비율, 세션당 탭 수비교할 그룹이 없으므로, 개선 전 주별 범위를 3~4주 연속 벗어나는지 본다
  • 개선 전 5주 동안 (B − A)가 오르내린 범위를 잡음으로 본다. 개선 후에도 이 범위 안에서 움직이면 “효과는 확인할 수 없다”고 판정한다.
  • 매출에 차이가 없어도 표시 시간과 탭 수가 좋아졌다면 “경험은 좋아졌지만, 매출 영향은 잴 수 있는 크기가 아니었다”고 결론 낸다.
  • 개선 후 세션당 탭 수가 줄어도 그것만으로 실패라고 보지 않는다. 지금은 반응이 늦어서 같은 사진을 여러 번 탭하는 사람이 있을 수 있다. 그 중복 탭이 사라져도 탭 수는 줄기 때문이다.

재지 않기로 한 것

  • 불러오기 실패와 이탈: 사진을 받다가 실패하면 스피너가 계속 돌고, 사진이 뜨기 전에 닫아 버리는 사람도 있을 것이다. 하지만 lightbox에는 실패를 처리하는 코드(onerror)가 없어서, 지금 데이터로는 둘을 구분할 수 없다.
  • lightbox 안에서 사진 넘기기: review_image_click은 썸네일을 탭할 때만 나간다. lightbox 안에서 몇 장을 봤는지는 알 수 없다. 그래서 세션당 탭 수는 “썸네일을 몇 번 탭했는가”로 읽는다.

두 번째 항목은 계획을 마무리한 다음 날 메웠다.

빈 곳 메우기: 사진을 넘길 때의 이벤트

리뷰에는 사진을 두 장까지 붙일 수 있다. lightbox 안에서 “다음/이전”으로 두 번째 사진으로 넘겨도 이벤트가 나가지 않으니, 리뷰 사진이 실제로 몇 장 보였는지 알 수 없었다. PR 하나로 메웠는데, 커밋 세 개를 거치며 방식이 두 번 바뀌었다.

첫 시도: lightbox의 메서드를 감싸기

처음에는 lightbox 객체의 start(열기)와 changeImage(사진 바꾸기)를 감싸는 함수로 바꿔치기했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
(function() {
    if (typeof lightbox === 'undefined' || typeof ga_clickEvent !== 'function') {
        return;
    }

    var originalStart = lightbox.start;
    var originalChangeImage = lightbox.changeImage;
    var isOpening = false;

    lightbox.start = function($link) {
        isOpening = true;
        try {
            originalStart.call(this, $link);
        } finally {
            isOpening = false;
        }
    };

    lightbox.changeImage = function(imageNumber) {
        if (!isOpening) { // 열 때의 첫 사진은 썸네일의 onclick이 이미 잰다
            ga_clickEvent('review_image_click');
        }
        originalChangeImage.call(this, imageNumber);
    };
})();

Spring AOP의 @Around처럼 원래 메서드 앞뒤에 끼어드는 방식이다. 다만 Spring AOP는 프록시라서, 같은 객체 안에서 this로 부르는 호출에는 끼어들지 못한다. 여기서는 객체의 속성 자체를 바꿔치기했기 때문에, lightbox 내부의 this.changeImage(...) 호출도 감싼 함수를 탄다.

그래서 플래그가 필요했다. start()는 열면서 안에서 changeImage()를 부른다. 그대로 두면 열 때마다 썸네일의 onclick과 이 코드가 둘 다 이벤트를 보내서 두 번 세게 된다. isOpening이 켜져 있는 동안의 changeImage()는 빼야 했다. 대신 버튼뿐 아니라 키보드(←/→, p/n)로 넘기는 것까지 잡을 수 있었다.

기존 이벤트의 뜻을 바꾸지 않는다

첫 시도는 사진을 넘길 때도 review_image_click을 보냈다. 그러면 이 이벤트 수의 뜻이 “썸네일을 탭한 수(lightbox를 연 수)”에서 “lightbox에 보인 사진 수”로 바뀐다.

위의 계측 계획은 이 이벤트에 기대고 있다. B 세그먼트도, 세션당 탭 수도 review_image_click으로 만든다. 배포 시점부터 뜻이 바뀌면, 개선 전후를 같은 잣대로 비교할 수 없다. “측정 기간에는 이벤트의 내용을 바꾸지 않는다”는 규칙에도 어긋난다.

그래서 두 번째 커밋에서 사진 넘기기를 새 이벤트 review_image_slide로 나눴다.

이벤트 (label)나가는 때비고
review_image_click썸네일 탭 (lightbox 열기)그대로. 수의 뜻이 바뀌지 않는다
review_image_slidelightbox 안의 “다음/이전” 버튼새로 추가. 다음과 이전을 구분하지 않는다

이름은 사이트에 이미 있던 비슷한 이벤트(…_review_image_slide_…)에 맞췄다. 이전으로 돌아가는 것도 세기 때문에 next라는 말은 쓰지 않았다. 두 이벤트를 더하면 lightbox에 보인 사진 수가 된다(키보드로 넘긴 것은 빼고).

마지막: 버튼에 직접 붙이기

플래그로 열기와 넘기기를 구분하는 코드는 흐름을 따라가기 어려웠다. 키보드로 넘기는 것까지 잴 필요도 없었다. 개선 대상인 스마트폰에는 키보드가 없다. 그래서 세 번째 커밋에서 lightbox가 만드는 “다음/이전” 버튼에 클릭 핸들러를 직접 붙이는 방식으로 바꿨다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
 * 리뷰 사진 lightbox 안의 "다음/이전" 버튼으로 사진을 넘기는 것을
 * GA 이벤트(review_image_slide)로 잰다.
 *
 * 버튼은 lightbox.js가 만들기 때문에 템플릿에 onclick을 쓸 수 없다.
 * 또 lightbox.js 쪽 핸들러가 return false로 전파를 막으므로,
 * document에 위임하지 않고 버튼에 직접 붙인다.
 * 키보드(←→ / p·n)로 넘기는 것은 재지 않는다.
 */
(function() {
    if (typeof ga_clickEvent !== 'function') {
        return;
    }

    $('#lightbox').find('.lb-prev, .lb-next').on('click', function() {
        ga_clickEvent('review_image_slide');
    });
})();

이 짧은 코드에는 제약 두 가지가 들어 있다.

첫째, 버튼은 템플릿에 없다. lightbox.js가 읽히는 순간 <body> 끝에 lightbox의 HTML을 붙이면서 만든다. 그래서 템플릿에 onclick을 쓸 수 없고, 이 스크립트를 lightbox.js 바로 다음에 읽어서 이미 만들어진 버튼에 붙인다.

1
2
<script src="/js/lightbox2/js/lightbox.js"></script>
<script src="/js/lightbox_review_image_tracking.js"></script>

둘째, document에 위임할 수 없다. 보통 나중에 생기는 요소에는 $(document).on('click', '.lb-next', ...)처럼 상위 요소에 위임해서 붙인다. 그런데 lightbox.js가 버튼에 붙인 핸들러는 끝에서 return false를 한다. jQuery 핸들러에서 return false는 기본 동작을 막을 뿐 아니라 전파도 멈춘다. 클릭이 document까지 올라오지 않으니 위임한 핸들러는 불리지 않는다. 같은 요소에 직접 붙인 핸들러끼리는 전파와 상관없이 모두 실행되므로, 버튼에 직접 붙이면 된다.

lightbox는 첫 사진에서는 “이전” 버튼을, 마지막 사진에서는 “다음” 버튼을 숨긴다(wrapAround: false). 그래서 눌러도 사진이 바뀌지 않는 클릭이 세어질 일은 없다.

확인과 배포

개발 환경에서 브라우저 개발자 도구로 dataLayer를 지켜보며 확인했다.

  • 썸네일을 탭하면 review_image_click이 한 건만 들어가고, review_image_slide는 들어가지 않는다.
  • “다음/이전”을 누를 때마다 review_image_slide가 한 건씩 들어간다.
  • 키보드로 넘기면 아무것도 들어가지 않는다.
  • PC와 스마트폰 템플릿에서 모두 같고, 리뷰어 프로필처럼 lightbox를 쓰는 다른 화면은 그대로다.

10월 20일에 배포하고, 운영 환경에서도 같은 항목과 GA4 실시간 보고서를 확인했다. GTM에서 label을 걸러 쓰고 있다면 새 label을 추가해야 하므로, GA 데이터를 쓰는 분석 담당자와 공유할 내용으로 릴리스 노트에 적었다.

다시 본다면

마지막 방식은 “lightbox.js가 읽히는 순간 버튼을 만든다”는 v2.8.2의 동작에 기대고 있다. lightbox2는 v2.9.0부터 HTML을 DOM 준비가 끝난 뒤($(document).ready)에 만든다. 앞 글에서 본 사진 페이드인 옵션(imageFadeDuration)이 들어온 버전이기도 하다. 그 버전으로 올리면 이 스크립트가 실행되는 시점에는 #lightbox가 아직 없다. $('#lightbox')는 빈 결과가 되고, 아무 오류 없이 이벤트만 나가지 않게 된다.

스크립트를 $(function () { ... })로 감싸 두면 어느 버전에서도 동작한다. jQuery는 ready 콜백을 등록한 순서대로 실행하므로, 먼저 읽힌 lightbox가 버튼을 만든 뒤에 이 콜백이 붙는다. 지금 코드도 “lightbox.js 바로 다음에 읽는다”는 순서는 지키고 있지만, 그 순서만으로 충분한 건 v2.8.2이기 때문이다.

배운 점

  • “얼마나 빨라졌나”(RUM)와 “행동이 바뀌었나”(GA4)는 나눠서 재고, 두 도구의 숫자를 섞지 않는다
  • 원래 더 사는 그룹과 비교할 때는 차이가 아니라 변화량의 차이를 본다(이중차분)
  • 총량이 아니라 비율로, 기간 합계가 아니라 주별 값으로 비교한다
  • 이벤트의 뜻을 바꾸면 전후 비교가 깨진다. 새로운 행동은 새 이벤트로 잰다
  • jQuery 핸들러의 return false는 전파를 멈춘다. 남이 만든 요소에 이벤트를 걸 때는 위임이 통하는지부터 본다
This post is licensed under CC BY 4.0 by the author.