12. collect.js 실행 흐름 — 실제 시나리오와 상태 변화

실제 기준: apps/collector/src/collect.js

collect.js의 실제 실행 흐름을 네 가지 시나리오로 묶어 관리한다. Mermaid 화살표에는 호출 함수, 변수 초기화, 상태 변경, 분기 결과를 함께 기록한다.

목차

  1. 일반 문서 최초 진입
  2. prerender 문서 활성화
  3. SPA pathname 변경과 중복 제거
  4. Beacon과 keepalive fallback

1. 일반 문서 최초 진입

시작 조건

실행 흐름

sequenceDiagram
    autonumber
    participant B as 브라우저 문서
    participant C as collect.js IIFE
    participant H as History API
    participant T as 전송 계층

    B->>C: IIFE 실행
    C->>C: script = document.currentScript 초기화
    C->>C: HTMLScriptElement 여부 검사
    C->>C: window.top !== window.self 여부 검사
    C->>C: collectToken = script.dataset.id 초기화
    C->>C: __foundlyCollectorLoaded 확인 후 true로 변경
    C->>C: endpoint = script.src 기준으로 초기화
    C->>C: createClientEventId() 정의
    C->>C: _serializePageview() 정의
    C->>C: emitPageview(serializedBody) 정의
    C->>C: lastEmittedPathname = undefined 초기화
    C->>C: initialPageviewEmitted = false 초기화
    C->>C: prerendered = document.prerendering === true<br/>현재 값 false
    C->>T: sendBeacon(endpoint, install payload) 호출
    C->>H: pushState/replaceState wrapper 등록
    C->>H: popstate listener 등록
    C->>C: emitInitialPageview() 호출
    C->>C: initialPageviewEmitted: false -> true
    C->>C: prerendered: false -> false
    C->>C: emitCurrentPageview() 호출
    C->>C: pathname = location.pathname 초기화
    C->>C: undefined !== pathname 확인
    C->>C: serializedBody = _serializePageview() 호출
    C->>C: clientEventId 생성 및 payload 직렬화
    C->>C: lastEmittedPathname = pathname 변경
    C->>T: emitPageview(serializedBody) 호출
    T-->>C: sendBeacon true 반환 -> fetch 없이 종료

결과

2. prerender 문서 활성화

시작 조건

실행 흐름

sequenceDiagram
    autonumber
    participant B as prerender 문서
    participant C as collect.js IIFE
    participant H as History API
    participant T as 전송 계층

    B->>C: IIFE 실행<br/>document.prerendering === true
    C->>C: prerendered = true 초기화
    C->>C: lastEmittedPathname = undefined 초기화
    C->>C: initialPageviewEmitted = false 초기화
    C->>T: sendBeacon(endpoint, install payload) 호출
    C->>H: pushState/replaceState wrapper 등록
    C->>H: popstate listener 등록
    C->>C: visibilitychange listener 등록
    H->>C: prerender 중 pushState 또는 popstate 발생
    C->>C: handleRouteChange() 호출
    C->>C: prerendered === true 확인 후 return
    C-->>B: pageview 없음<br/>현재 경로만 활성화 시점까지 유지

    B->>C: visibilitychange 발생<br/>document.visibilityState = visible
    C->>C: visible 여부 확인
    C->>C: emitInitialPageview() 호출
    C->>C: initialPageviewEmitted: false -> true
    C->>C: prerendered: true -> false
    C->>C: emitCurrentPageview() 호출
    C->>C: pathname = location.pathname 초기화
    C->>C: serializedBody = _serializePageview() 호출
    C->>C: clientEventId 생성 및 payload 직렬화
    C->>C: lastEmittedPathname = pathname 변경
    C->>T: emitPageview(serializedBody) 호출
    T-->>C: pageview 전송 완료
    C->>C: visibilitychange listener 제거

결과

3. SPA pathname 변경과 중복 제거

공통 시작 상태

query/hash-only 변경은 무시

sequenceDiagram
    autonumber
    participant A as SPA 앱
    participant H as History API
    participant C as collect.js

    A->>H: pushState(state, title, "/home?tab=2") 호출
    H->>H: originalMethod.apply(this, args) 실행
    H-->>C: result 반환
    H->>C: handleRouteChange() 호출
    C->>C: prerendered === false 확인
    C->>C: emitCurrentPageview() 호출
    C->>C: pathname = location.pathname -> "/home"
    C->>C: pathname === lastEmittedPathname 비교 -> true
    C-->>A: return<br/>serializedBody 생성 없음, 전송 없음

    A->>H: replaceState(state, title, "/home#pricing") 호출
    H->>H: originalMethod.apply(this, args) 실행
    H-->>C: result 반환
    H->>C: handleRouteChange() 호출
    C->>C: emitCurrentPageview() 호출
    C->>C: pathname = "/home"과 lastEmittedPathname 비교 -> true
    C-->>A: return<br/>query/hash 변경은 pageview가 아님

pathname 변경은 새 pageview

sequenceDiagram
    autonumber
    participant A as SPA 앱
    participant H as History API
    participant C as collect.js
    participant T as 전송 계층

    A->>H: pushState(state, title, "/pricing") 호출
    H->>H: originalMethod.apply(this, args) 실행
    H-->>C: result 반환
    H->>C: handleRouteChange() 호출
    C->>C: emitCurrentPageview() 호출
    C->>C: pathname = "/pricing" 초기화
    C->>C: "/pricing" !== "/home" 확인
    C->>C: serializedBody = _serializePageview() 호출
    C->>C: 현재 URL의 UTM 값과 새 clientEventId 직렬화
    C->>C: lastEmittedPathname: "/home" -> "/pricing"
    C->>T: emitPageview(serializedBody) 호출
    T-->>C: pageview 전송 시도

    A->>H: 브라우저 뒤로가기 -> popstate 발생
    H->>C: handleRouteChange() 호출
    C->>C: emitCurrentPageview() 호출
    C->>C: pathname = "/home" 초기화
    C->>C: "/home" !== "/pricing" 확인
    C->>C: serializedBody 생성
    C->>C: lastEmittedPathname: "/pricing" -> "/home"
    C->>T: emitPageview(serializedBody) 호출

핵심 불변식

4. Beacon과 keepalive fallback

pageview 본문 생성

sequenceDiagram
    autonumber
    participant C as collect.js
    participant P as pageview payload
    participant T as 전송 API

    C->>C: emitCurrentPageview() 호출
    C->>C: pathname 비교 통과
    C->>C: _serializePageview() 호출
    C->>P: currentUrl = new URL(location.href) 초기화
    C->>P: clientEventId = createClientEventId() 초기화
    C->>P: token/type/path/referrer/UTM 값 직렬화
    C->>C: serializedBody 변수에 JSON 문자열 저장
    C->>C: lastEmittedPathname = pathname 변경
    C->>T: emitPageview(serializedBody) 호출

Beacon 성공

sequenceDiagram
    participant C as emitPageview
    participant N as navigator.sendBeacon

    C->>N: typeof sendBeacon === function 확인
    C->>N: sendBeacon(endpoint, serializedBody) 호출
    N-->>C: true 반환
    C->>C: return 실행
    Note over C,N: fetch 호출 없음<br/>동일 pageview에 재시도 없음

Beacon 미지원·false·예외

sequenceDiagram
    participant C as emitPageview
    participant N as navigator.sendBeacon
    participant F as fetch keepalive

    alt sendBeacon 미지원
        C->>C: typeof sendBeacon !== function
    else sendBeacon이 false 반환
        C->>N: sendBeacon(endpoint, serializedBody) 호출
        N-->>C: false 반환
    else sendBeacon이 예외 발생
        C->>N: sendBeacon(endpoint, serializedBody) 호출
        N-->>C: 예외 -> catch 실행
    end
    C->>F: fetch(endpoint, { method: POST, body: serializedBody, keepalive: true }) 호출
    F-->>C: Promise rejection이면 .catch(() => {})로 조용히 종료
    Note over C,F: serializedBody와 clientEventId는 재생성하지 않고 그대로 재사용

핵심 불변식

2026-08-12 확정: 세션 종료와 Duration 정책

현재 실행 흐름에서 heartbeat는 visible 상태에서만 10초 간격으로 동작한다. hidden 전환 시 타이머를 멈추고 hidden 경계 이벤트를 보내며, visible 복귀 시 즉시 visible heartbeat를 보낸다. pagehide는 마지막 보완 신호일 뿐 세션을 즉시 닫지 않는다. 모든 새 이벤트는 visibilityState를 visible | hidden으로 보낸다.