실제 기준: apps/collector/src/collect.js
collect.js의 실제 실행 흐름을 네 가지 시나리오로 묶어 관리한다. Mermaid 화살표에는 호출 함수, 변수 초기화, 상태 변경, 분기 결과를 함께 기록한다.
목차
- 일반 문서 최초 진입
- prerender 문서 활성화
- SPA pathname 변경과 중복 제거
- Beacon과 keepalive fallback
1. 일반 문서 최초 진입
시작 조건
window.top === window.self인 최상위 문서다.
document.currentScript가 HTMLScriptElement다.
- script의
data-id가 fid_... 값으로 존재한다.
globalThis.__foundlyCollectorLoaded가 아직 설정되지 않았다.
document.prerendering === false다.
실행 흐름
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 없이 종료
결과
- install payload 1회가 먼저 전송된다.
- 현재 pathname에 대한 pageview 1회가 전송된다.
lastEmittedPathname은 현재 pathname으로 변경된다.
- collector 내부 예외는
catch {}에서 삼켜져 호스트 페이지 실행을 막지 않는다.
2. prerender 문서 활성화
시작 조건
document.prerendering === true다.
- 실제 Chromium에서는 이때
document.visibilityState === "hidden"으로 관찰된다.
- prerender 판별은
visibilityState의 "prerender" 값이 아니라 document.prerendering을 사용한다.
실행 흐름
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 제거
결과
- prerender 중에는 install payload만 전송되고 pageview는 전송되지 않는다.
- prerender 중의 route 변경은
handleRouteChange()에서 return한다.
- 활성화 후 현재 pathname 기준 pageview가 정확히 1회 전송된다.
- 활성화 후
prerendered가 false가 되므로 이후 SPA route 변경은 정상 처리된다.
3. SPA pathname 변경과 중복 제거
공통 시작 상태
initialPageviewEmitted = true
prerendered = false
lastEmittedPathname = "/home"
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) 호출
핵심 불변식
- 비교 기준은
location.pathname 하나다.
- query string이나 hash만 바뀌면
lastEmittedPathname이 바뀌지 않는다.
- pathname이 바뀌면 새 payload와 새
clientEventId를 만든다.
initialPageviewEmitted가 false인 동안에는 route 변경이 pageview를 만들지 않는다.
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는 재생성하지 않고 그대로 재사용
핵심 불변식
_serializePageview()는 emitPageview() 호출 전에 한 번만 실행된다.
- Beacon과 fallback fetch는 같은
serializedBody와 같은 clientEventId를 사용한다.
- fallback은 한 번만 시도하며 별도 재시도 loop가 없다.
- 전송 실패가 호스트 페이지의 예외로 전파되지 않는다.
2026-08-12 확정: 세션 종료와 Duration 정책
현재 실행 흐름에서 heartbeat는 visible 상태에서만 10초 간격으로 동작한다. hidden 전환 시 타이머를 멈추고 hidden 경계 이벤트를 보내며, visible 복귀 시 즉시 visible heartbeat를 보낸다. pagehide는 마지막 보완 신호일 뿐 세션을 즉시 닫지 않는다. 모든 새 이벤트는 visibilityState를 visible | hidden으로 보낸다.