Outsider's Dev Story

Stay Hungry. Stay Foolish. Don't Be Satisfied.
RetroTech 팟캐스트 44BITS 팟캐스트

Google Analytics를 이용한 블로그 콘텐츠 인게이지먼트(Engagement) 추적

지난달에 올린 블로그 업데이트 정리에서 "글을 얼마나 읽는지"를 추적하는 내용을 추가했다는 내용을 적었다. 블로그에 Google Analytics를 붙여 놓은 지는 오래됐지만, 그냥 당연히 붙여야 하니까 했을 뿐 따로 분석하거나 특별한 추적을 하거나 하진 않는 편이었다. 그냥 글 쓰고 공부하는 것만으로도 바쁘기도 했고... 그러다가 방문자도 점점 늘어가고 Growth Hacking같은 개념도 떠오르면서 내가 맘대로 할 수 있는 블로그에서 이런 연습이나 테스트를 해볼 기회를 놓치는 게 아깝게 느껴졌고 조금씩 개선을 하고 있었다.

Google Analytics(이하 GA)에서 중요한 목표 설정도 안 해놓고 있었는데 작년에 GA로 블로그 분석하기를 보고 글을 접한 사람들이 실제로 글을 읽는가라는 목표는 블로그에서 중요한 목표라는 생각이 들었고 실제로 얼마나 읽는지 궁금하기도 했다.

추적

그래서 나도 방문자들이 블로그 글을 얼마나 읽는지 궁금해서 추적코드를 작성하기 시작했다. 강규영 님이 공유해 주신 코드도 있었지만, 블로그의 특성도 약간 달랐고 내가 목표로 하는 설정도 있어서 좀 더 찾다 보니 Advanced Content Tracking with Universal Analytics라는 글을 찾았다. 코드를 참고해서 현재 내 블로그에 맞게 수정을 했다.

var $entryContent = $('.entry-content');
if ($entryContent.length) {
  // Debug flag
  var debugMode = false;

  // Set some flags for tracking & execution
  var isStartScroll = false;
  var endContent = false;
  var didComplete = false;

  // Set some time variables to calculate reading time
  var beginning = (new Date()).getTime();
  var scrollStart;
  var totalTime = 0;
  var timeToRead = 60; // default 60s

  // Get some information about the current page
  var pageTitle = document.title;
  var $currentPost = [];
  var currentTitle = pageTitle;

  // Track the aticle load
  if (!debugMode) {
    ga('send', 'event', 'Reading', 'ArticleLoaded', pageTitle);
  } else {
    console.log('The page has loaded. Woohoo.');
  }

  // Check the location and track user
  function trackLocation() {
    if (!$currentPost.length) {
      $currentPost = $($entryContent.splice(0, 1));
      currentTitle = $currentPost.prev('.post-title').text() || pageTitle;
      if ($currentPost.length) {
        isStartScroll = false;
      }
    }

    var bottom = $(window).height() + $(window).scrollTop();
    var height = $(document).height();

    // If user starts to scroll send an event
    if ($currentPost.length && bottom > $currentPost.offset().top && !isStartScroll) {
      var wordCount = $currentPost.text().split(/\s+/).length;
      timeToRead = Math.floor(wordCount/180/2 * 60) || timeToRead; // 1분에 180개 단어 기준으로 절반 시간을 기준으로 삼음
      scrollStart = (new Date()).getTime();
      var timeToScroll = Math.round((scrollStart - beginning) / 1000);
      if (!debugMode) {
        ga('send', 'event', 'Reading', 'timeToRead', currentTitle, timeToRead);
        ga('send', 'event', 'Reading', 'StartReading', currentTitle, timeToScroll);
      } else {
        console.log('time to Reqd ' + timeToRead);
        console.log('started reading ' + timeToScroll);
      }
      isStartScroll = true;
      endContent = false;
    }

    // If user has hit the bottom of the content send an event
    if ($currentPost.length && bottom >= $currentPost.offset().top + $currentPost.innerHeight() && !endContent) {
      var contentScrollEnd = (new Date()).getTime();
      var timeToContentEnd = Math.round((contentScrollEnd - scrollStart) / 1000);
      if (!debugMode) {
        if (timeToContentEnd < timeToRead) {
          ga('send', 'event', 'Reader', 'Scanner', currentTitle, timeToContentEnd);
        } else {
          ga('send', 'event', 'Reader', 'Reader', currentTitle, timeToContentEnd);
        }
        ga('send', 'event', 'Reading', 'ContentBottom', currentTitle, timeToContentEnd);
        ga('send', 'event', 'Reading', 'ContentReadindgSpeed', currentTitle, timeToContentEnd/timeToRead);
      } else {
        if (timeToContentEnd < timeToRead) {
          console.log('Scanner');
        } else {
          console.log('Reader');
        }
        console.log('end content section '+ timeToContentEnd);
        console.log('end content rate ' + timeToContentEnd/timeToRead);
      }
      endContent = true;
      $currentPost = [];
    }

    // If user has hit the bottom of page send an event
    if (bottom >= height && !didComplete) {
      var end = (new Date()).getTime();
      totalTime = Math.round((end - scrollStart) / 1000);
      if (!debugMode) {
        ga('send', 'event', 'Reading', 'PageBottom', pageTitle, totalTime);
      } else {
        console.log('bottom of page '+totalTime);
      }
      didComplete = true;
    }
  }

  // Track the scrolling and track location
  $(window).scroll(function() {
    window.requestAnimationFrame(trackLocation);
  });
}

방문자가 스크롤 하는 위치를 추적해서 각 상태를 GA 이벤트로 만들어서 추적하고 각 값을 설정해서 나중에 볼 수 있도록 했다. 이벤트 카테고리는(처음에는 custom dimension을 쓰는 게 적합해 보였는데 해보니 이용도로는 안 맞는 것 같았다. 나중에 코드에서 버그도 발견했는데 custom dimension이 문제가 아니라 내 착각일 수도 있다.) ReadingReader를 만들었다. Reading은 글을 읽기 시작했는지 다 읽었는지 등 글을 읽는 과정에 관계된 이벤트를 수집하고 Reader를 글을 다 읽었을 때 속도를 분석해서 글을 훑어본 Scanner인지 꼼꼼히 살펴본 Reader인지를 구분했다.

글마다 분량이 다르므로 요즘 많이 쓰는 TimeToRead 즉 글을 읽는데 걸리는 시간을 사용했다.(해보고 나니 강규영 님처럼 스크롤 속도랑 조합하는 게 더 간단한 것 같기도...) 위키피디아를 참고하면 영어의 경우 모니터에서는 분당 평균 180단어를 읽는다는 연구결과가 있는데 한글의 경우는 어떤지 자료를 찾지 못했다. 난 글을 빨리 읽는 편은 아니지만 내가 직접 테스트를 대충해보니 180단어씩 읽으면 예상보다 시간이 너무 오래걸렸다. 더군다나 Medium처럼 서버에서 TimeToRead를 측정하는 것은 아니고 JavaScript로 본문의 단어 수를 뽑아와서 세는 것이므로 아주 엄격하진 않았다. 중간에 예제코드가 많은 경우에 단어 수가 많이 나왔다. 너무 엄격하게 할 필요는 없는 것 같아서 분당 90단어를 기준으로 해서 방문자가 기대한 TimeToRead만큼 읽는지 그것보다 빨리 읽는지를 측정했다. 그래서 예상한 TimeToRead보다 적은 시간 동안 글을 읽으면 Scanner로 분류했다.

코드가 좀 복잡한데 $('.entry-content')는 내 블로그에서 글이 포함되는 영역이다. 개별 글이면 이 영역이 페이지에 1개만 있지만, 페이지를 넘기면서 보거나 검색 등을 했을 때는 한 페이지에 이 영역이 여러 개 있을 수도 있으므로 계속 이어서 읽을 때 이 부분을 만나면 글을 읽기 시작한 것으로 간주하고 해당 영역이 끝나면 글을 다 읽을 것으로 간주해서 속도를 확인했다. 스크롤에 대한 성능 저하를 줄이려고 requestAnimationFrame을 사용해서 측정했다.

Reading이벤트로는 글이 로딩되면 ArticleLoaded 이벤트가 발생하고 글을 읽기 시작할 때 StartReading 이벤트가 발생하고 이때 timeToRead이벤트로 각 글에서 계산한 TimeToRead가 얼마인지를 같이 기록했다. 콘텐츠 끝에 도착하면 ContentBottom 이벤트로 걸린 시간을 기록하고 ContentReadindgSpeed이벤트로 TimeToRead로 계산한 읽기 속도를 기록했다. 그리고 글과는 관련 없지만 페이지 끝에 도착하면 PageBottom 이벤트를 기록했다. 이렇게 다양하게 남길 필요까지는 없지만, 디버깅 목적도 있고 세세하게 남겨놓으면 좀 도움이 될까 싶어서 일단 남겨놓고 봤다.

추적 결과

분석 쪽을 잘 알지 못해서 제대로 수치를 읽어내는 것인지는 모르지만 일단 지난달의 데이터를 보자.

GA의 콘텐츠 인게이지먼트 관련 이벤트

위에서 얘기했듯이 Scanner와 Reader를 제외하고는 읽기 관련한 이벤트이다. 103,620명이 글을 열었는데(ArticleLoaded) 95,384명일 글을 읽기 시작(StartReading)하는 걸로 보아 8% 정도가 본문을 읽기도 전에 빠져나간다. 글을 읽기 시작한 사람 중에서는 61,916명이 글을 끝까지 보았으므로(ContentBottom) 글을 읽은 사람 중에는 64% 정도가 글을 끝까지 본다. 글을 다 본 사람 중에는 76.3%가 글을 훑어본 Scanner로 분류되었고 23.6%가 글을 꼼꼼히 읽은 Reader로 분류되었다. Reader를 목표로 잡아놨으므로 전체 방문자를 기준으로 하면 15% 정도가 목표 달성한 것으로 나오고 있다.

위 표에서 이벤트값은 초 단위로 기록한 것이다. 그래서 기록에 남은 내 글의 평균 TimeToLead는 135로 나오므로 2분 15초이다. 이상하게도 글을 읽기 시작(StartReading)까지 10분이나 걸리는데 헤더가 그렇게 길지 모르는데 왜 그런지 모르겠다. 페이지를 띄워놓고 다른 일을 하거나 일부값을 잘못 기록하는 게 아닐까 싶다. ㅠ ContentReadingSpead는 실제로 글을 읽은 시간 / TimeToRead를 한 값으로 평균으로 하면 1.4배 더 걸린다는 의미이다. 실제로 값을 좀 자세히 보면 Scanner는 글을 보는데 26초밖에 안 걸리므로 쓱~ 스크롤 해서 본다는 걸 알 수 있고 Reader는 40분 가까이 걸리는 걸 보면(그것도 평균이!!) 예제 등을 다 따라 해 보면서 엄청나게 꼼꼼하게 보는 것이 아니라면 기록을 잘못하고 있다는 것이다. 중간에 페이지를 멈춰놓은 채로 다른 작업을 한다거나 자리를 비운다거나 하면서 잘못된 큰 값이 들어오는 것 같다.(이건 보정이 좀 필요할 것으로 보인다.)

글마다 아주 짧거나 간단한 글도 있고 길고 복잡한 글도 있는데 위 내용은 이벤트별로 전체 통계를 보는 것이므로 각 수치에 대한 대략적인 느낌만 들 뿐이지 상세한 내용은 주지 못한다.

GA에서 Reader로 분류한 이벤트의 글별 수치

이벤트를 기록할 때 라벨로 글의 제목을 같이 기록을 하고 있으므로 각 이벤트의 내용을 글별로도 볼 수 있다. Reader로 분류된 사람들을 글별로 보면 확실히 글을 다 읽었을 때의 시간이 엄청 높게 나온다. Github를 이용하는 전체 흐름 이해하기 #1를 이해하려고 아무리 열심히 보았다고 하더라도 2시간 동안 글을 보고 있다는 것은 잘 이해가 되지 않고 뉴스레터의 글도 30분 정도나 걸리는 걸 보면 확실히 페이지를 띄워놓고 자리를 비우거나 관련 링크 등을 보고 돌아오고 하면서 시간이 오래 걸리는 게 아닐까 싶다. 아직 발견 못 한 코드의 버그가 없다면(이거 은근히 디버깅하기가 어렵;;) 예외 상황을 보완하는 코드를 좀 추가해야 할 것 같은데 상당히 번거로운 작업이 될 것 같아서 언제 할지는 잘 모르겠다.

GA에 수집된 글별 TimeToRead의 값

TimeToRead 이벤트를 보면 글별로 내가 측정한 예상 읽기 시간도 볼 수 있다. 엄격하게 측정한 것은 아니라서 글의 특성별로 편차가 좀 있기는 하지만 대충 글에서 느껴지는 길이 정도로는 시간이 나오는 것으로 보인다.

2016/07/19 18:52 2016/07/19 18:52