5. 분석 데이터 전송: fetch·sendBeacon

일반적인 fetch의 문제점

분석 코드는 다음과 같이 작성할 수 있습니다:

fetch("/api/analytics", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    event: "page_view",
    path: location.pathname,
  }),
});

페이지가 열려 있는 동안에는 대부분 정상적으로 동작합니다. 하지만 다음과 같은 상황에서는 문제가 생길 수 있습니다.

페이지가 사라지면 브라우저는 해당 페이지가 시작한 비동기 작업을 중단할 수 있습니다. await fetch()를 사용한다고 해서 페이지가 네트워크 요청이 끝날 때까지 기다려주는 것도 아닙니다. 즉, 다음 코드도 페이지 종료를 막아주지는 않습니다.

await fetch("/api/analytics", {
  method: "POST",
  body: JSON.stringify(data),
});

fetch의 문제는 데이터를 보내는 기능이 부족해서가 아니라, 요청을 시작한 페이지의 생명주기와 네트워크 요청이 함께 끝날 수 있다는 점입니다. MDN의 keepalive 설명도 keepalive가 없으면 페이지가 unload된 뒤 요청이 중단될 수 있는 상황을 별도로 설명합니다.

fetch keepalive는 무엇을 해결하는가

keepalive는 페이지가 종료된 뒤에도 브라우저가 해당 요청을 계속 처리할 수 있도록 요청의 생명주기를 페이지 종료와 어느 정도 분리하는 옵션입니다.

fetch("/api/analytics", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
  },
  body: JSON.stringify(data),
  keepalive: true,
});

이렇게 하면 사용자가 페이지를 떠나더라도 요청이 바로 취소되지 않을 가능성이 높아집니다.

keepalive의 장점은 일반 fetch의 기능을 그대로 사용할 수 있다는 것입니다.

그래서 다음과 같은 상황에서는 fetch keepalive가 적합합니다.

하지만 keepalive가 모든 문제를 해결해주는 것은 아닙니다.

fetch keepalive의 한계

데이터 크기 제한

keepalive 요청의 body에는 제한이 있습니다. 일반적으로 약 64KiB 크기 제한을 고려해야 합니다. 여러 개의 keepalive 요청을 동시에 쌓으면 브라우저의 대기 중 요청 한도를 함께 사용할 수도 있습니다. Fetch API의 RequestInit 문서와 Beacon 표준 모두 이 제한을 명시하고 있습니다.

따라서 페이지를 떠나는 순간에 모든 사용자 행동 기록을 한꺼번에 보내기보다는, 페이지가 열려 있을 때 주기적으로 배치 전송하는 편이 안전합니다.

오프라인 저장이나 재시도 기능이 아님

keepalive는 요청을 계속 처리할 기회를 높여주는 기능이지, 데이터를 디스크에 안전하게 저장하는 기능은 아닙니다.

다음과 같은 상황에서는 데이터가 유실될 수 있습니다.

정말 유실을 줄여야 한다면 IndexedDB 등에 이벤트를 저장하고, 나중에 다시 전송하는 별도의 클라이언트 큐가 필요합니다.

응답을 받을 수 있어도 실제로 활용하기 어렵다

fetch keepalive는 응답을 받을 수 있습니다. 하지만 요청 직후 페이지가 사라지면 응답을 처리할 JavaScript 실행 환경도 함께 사라질 수 있습니다.

따라서 페이지 종료 직전의 분석 전송에서는 다음처럼 응답 결과에 의존하는 로직을 만들면 안 됩니다.

const response = await fetch("/api/analytics", {
  method: "POST",
  body: JSON.stringify(data),
  keepalive: true,
});

if (response.ok) {
  // 페이지가 이미 종료되었을 수도 있음
}

응답 확인이 중요한 요청이라면 페이지가 살아 있을 때 미리 전송하는 것이 더 적절합니다.

sendBeacon의 목적

sendBeacon은 작고 중요하지 않은 응답을 기다리지 않는 데이터를 브라우저가 비동기적으로 전송하도록 만든 API입니다.

navigator.sendBeacon(
  "/api/analytics",
  JSON.stringify({
    event: "session_end",
    duration: 42,
  }),
);

이 API의 대표적인 목적은 바로 분석 데이터 전송입니다.

사용자가 페이지를 떠날 때 분석 데이터를 보내기 위해 과거에는 동기식 XHR을 사용하기도 했습니다. 하지만 동기식 요청은 페이지 종료와 다음 페이지 이동을 지연시키고 사용자 경험을 나쁘게 만듭니다.

sendBeacon은 다음을 목표로 합니다.

내부적으로 sendBeacon()은 keepalive가 활성화된 fetch 요청을 시작하는 방식으로 정의되어 있습니다. 즉, 둘은 완전히 별개의 전송 방식이라기보다, sendBeacon이 분석 전송 목적에 맞게 단순화된 API라고 이해하면 됩니다. Beacon 표준에서도 sendBeacon 요청에 자동으로 keepalive 플래그가 설정된다고 설명합니다.

fetch keepalive와 sendBeacon의 차이

구분fetchfetch keepalivesendBeacon
주요 목적일반적인 API 요청페이지 종료 뒤에도 이어질 수 있는 요청종료 직전 분석 데이터 전송
HTTP 메서드자유로움자유로움POST
커스텀 헤더가능가능직접 설정 불가
응답 확인가능가능불가능
큰 데이터상대적으로 적합약 64KiB 제한 고려약 64KiB 제한 고려
페이지 종료 대응약함강해짐목적 자체가 페이지 종료 대응
재시도·오프라인 저장직접 구현직접 구현제공하지 않음
사용 난이도보통보통매우 간단

sendBeacon은 fetch keepalive보다 기능이 제한적입니다. 대신 제한된 기능 덕분에 사용 목적이 분명하고 코드가 간단합니다.

다음 조건이면 sendBeacon을 선택하면 됩니다.

반대로 다음 조건이면 fetch keepalive가 적합합니다.

sendBeacon은 언제 호출해야 하는가

많은 코드가 unload나 beforeunload 이벤트에서 데이터를 보내려고 하지만, 이 이벤트들은 특히 모바일 환경에서 신뢰하기 어렵습니다.

브라우저는 페이지를 숨긴 뒤에도 unload 이벤트를 호출하지 않을 수 있고, beforeunload나 unload 이벤트를 사용하면 브라우저의 뒤로가기·앞으로가기 캐시인 bfcache에도 영향을 줄 수 있습니다.

일반적으로는 visibilitychange 이벤트에서 페이지 상태가 hidden이 되었을 때 전송하는 방식이 권장됩니다.

const events = [];

document.addEventListener("visibilitychange", () => {
  if (document.visibilityState !== "hidden" || events.length === 0) {
    return;
  }

  const body = JSON.stringify({ events });

  if (navigator.sendBeacon?.("/api/analytics", body)) {
    return;
  }

  // sendBeacon을 지원하지 않거나 데이터를 대기열에 넣지 못했다면 fetch keepalive를 fallback으로 사용 
  void fetch("/api/analytics", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body,
    keepalive: true,
  }).catch(() => {});
});

다만 이 코드도 “전송 성공”을 보장하는 것은 아닙니다. sendBeacon()의 반환값 true는 서버가 데이터를 받았다는 뜻이 아니라, 브라우저가 데이터를 전송 대기열에 넣었다는 뜻입니다. false는 브라우저가 데이터를 대기열에 넣지 못했다는 의미입니다. MDN sendBeacon() 문서는 이 반환값의 의미를 명확히 구분합니다.

*먼저 간단한 sendBeacon 으로 데이터 전송을 시도하고, 지원하지 않는다면 fetch keepalive를 사용한다.