처음에는 세션 체류 시간을 다음처럼 계산했다.
세션 시작 시간 = 첫 번째 이벤트 시간
세션 마지막 시간 = 마지막으로 들어온 이벤트 시간
Duration = 마지막 시간 - 시작 시간
코드로 표현하면 다음과 같다.
duration_ms = last_seen_at - started_at;
기존에는 last_seen_at 을 갱신할 때 다음 이벤트를 모두 사용했다:
- pageview
- heartbeat
- pagehide
즉, 마지막 이벤트가 들어온 시점까지 사용자가 계속 서비스를 이용하고 있었다고 판단했고, 여기에는 함정이 있었다.
기존 방식의 함정
예를 들어 실제 브라우저 상태가 다음과 같다고 하면:
12:00 pageview
12:20 화면이 hidden 상태가 됨
12:40 다시 visible 상태가 됨
12:50 heartbeat
첫 이벤트와 마지막 이벤트만 사용해서 체류 시간을 계산한다. 따라서, Duration은 50분이다. (12:50 - 12:00)
하지만, 상식적으로 생각하면 실제로 화면을 본 시간만 계산해야 하기 때문에:
12:00~12:20 = 20분
12:20~12:40 = hidden 상태이므로 제외
12:40~12:50 = 10분
실제 체류 시간은 30분이다. 기존 계산은 hidden 상태였던 20분까지도 체류 시간에 포함하고 있었다.
기존 Collector의 함정
잘못된 체류 시간을 계산하는 데에는 Collector의 문제도 영향이 있었다. 기존 Collector는 이벤트에 화면 상태를 넣지 않았다.
{
"type": "heartbeat",
"clientEventId": "...",
"path": "/home"
}
서버는 이 heartbeat가 화면이 보이는 상태에서 발생했는지, 다른 탭으로 이동한 뒤 발생했는지를 알 수 없었다. 또한, heartbeat 타이머는 화면 상태와 관계없이 계속 실행됐다.
heartbeatTimer = setInterval(
() => emitActivityEvent("heartbeat"),
10_000,
);
따라서, 사용자가 다른 탭으로 이동하더라도 heartbeat가 계속 전송될 수 있었다. 이때 기존의 구조를 살펴보자면:
pageview // 탭을 보다가
↓ // 다른 탭으로 이동
heartbeat
↓
heartbeat
↓ // heartbeat가 주기적으로 전송됨
heartbeat
heartbeat가 계속 들어오면 last_seen_at도 계속 뒤로 밀리게 된다. 결국 heartbeat를 '페이지가 아직 살아있다는 신호'가 아니라 '사용자가 계속 활동하고 있다는 신호' 처럼 사용한 것이 문제였다.
결론적으로 기존 구조에는 세 가지의 문제가 있었다:
last_seen_at을 세션 활동 시간처럼 사용heartbeat,pagehide,pageview를 거의 같은 방식으로 취급- 첫 이벤트부터 마지막 이벤트까지의 모든 시간을 Duration으로 계산
즉, 기존에는 화면 상태를 고려하지 않은 채 모든 이벤트를 세션의 활동으로 취급했기 때문에 화면이 보이지 않는 시간까지 Duration에 포함될 수 있었던 것이다.
화면 상태를 추가하여 해결
현재 Collector는 모든 분석 이벤트를 만들 때 document.visibilityState를 읽는다.
return JSON.stringify({
token: collectToken,
type,
visibilityState:
document.visibilityState === "visible"
? "visible"
: "hidden",
clientEventId,
path: location.pathname,
});
따라서 이벤트에는 다음 값이 들어갑니다.
{
"type": "heartbeat",
"path": "/home",
"visibilityState": "visible" // or "visibilityState": "hidden"
}
다만, 여기서 visible 은 사용자가 화면을 보고 있다는 뜻이 아니라 브라우저가 해당 문서를 화면에 보이는 상태로 판단했다는 의미이다. 사용자가 화면을 실제로 보고있느냐는 판단이 불가능하기 때문에 현실적인 범위 내에서 visible 을 중요한 기준으로 삼았다.
화면 상태에 따른 heartbeat 전송
현재 Collector는 화면 상태에 따라 heartbeat timer를 조절한다.
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") {
emitActivityEvent("heartbeat");
startHeartbeat();
return;
}
stopHeartbeat();
emitActivityEvent("heartbeat");
});
동작은 다음과 같다.
visible 상태
→ visible heartbeat 즉시 1회
→ 10초 heartbeat 시작
hidden 상태
→ heartbeat timer 중지
→ hidden heartbeat 1회
다시 visible 상태
→ visible heartbeat 즉시 1회
→ 10초 heartbeat 재시작
변경 전과 변경 후를 비교하면 다음과 같다:
변경 전
heartbeat
heartbeat
heartbeat
화면 상태를 알 수 없음
변경 후
heartbeat + visible
heartbeat + visible
heartbeat + hidden // hidden 상태로 전환
hidden 상태에서는 반복 전송하지 않음
heartbeat + visible // 다시 visible 상태
+heartbeat의 목적
heartbeat 는 사용자의 행동을 나타내는 신호가 아니라, 페이지가 보이는 동안의 시간을 서버가 주기적으로 확인하는 것에 가깝다. 예를 들어:
12:00 pageview visible → 페이지 뷰 발생
12:10 heartbeat visible → 보이는 상태가 계속되었음을 확인
12:20 heartbeat hidden → 보이는 상태가 끝났음을 알림
Materializer는 이렇게 계산하고, hidden 상태에서는 타이머를 멈춘다.
12:00 ~ 12:10
→ visible 시간으로 확정
12:10 ~ 12:20
→ hidden heartbeat가 올 때까지 visible 구간으로 확정
합계: 20분
또한, 사용자가 페이지를 보고 있다가:
- 브라우저가 갑자기 종료됨
- 휴대폰이 앱을 정리함
- 네트워크가 끊김
- 페이지가 비정상적으로 종료됨
같은 일이 생기면 마지막 hidden 이벤트가 서버에 도착하지 않을 수 있다. 이때 heartbeat가 있으면 서버는:
12:00 visible heartbeat
12:10 visible heartbeat
12:20 visible heartbeat
위 기록을 보고 적어도 12:20까지는 화면이 보였다고 유추할 수 있다. 다만, heartbeat는 필수 기능이 아니고 손실을 최대한 줄이기 위한 보완 기능이라고 할 수 있겠다. 왜냐하면 위에 언급한 경우들이 아니라면 visibilityState 만으로 충분히 계산할 수 있기 때문이다.
즉, heartbeat는 이벤트 유실에 대비한 보조 신호이다.