navigator.sendBeacon — 탭이 닫혀도 API 요청을 안전하게 보내는 법

navigator.sendBecon 탭이 닫혀도 문제 없이 보내는 API 요청

navigator.sendBeacon — 탭이 닫혀도 API 요청을 안전하게 보내는 법

메타설명:

탭을 닫아도 API 요청이 사라지지 않는 방법, sendBeacon의 실전 패턴과 fetch keepalive, Service Worker 폴백 전략까지 한 번에 정리합니다.


사용자가 탭을 닫는 순간, 당신의 분석 데이터는 어디로 사라질까요? 세션 종료 이벤트, 마지막 클릭 로그, 결제 직전 이탈 정보 — 이 모든 것이 브라우저가 페이지를 내려버리는 찰나에 조용히 소멸합니다. navigator.sendBeacon은 바로 이 문제를 해결하기 위해 탄생한 API입니다. 하지만 "그냥 sendBeacon 쓰면 되는 거 아닌가요?"라고 생각한다면, 실전에서 마주치는 CORS 오류, 64KB 페이로드 초과, 오프라인 환경에서의 데이터 유실을 아직 겪어보지 않은 것입니다. 이 글에서는 기초 설명은 건너뛰고, 실무에서 바로 쓸 수 있는 3단 폴백 전략, CORS 우회 패턴, fetch keepalive와의 선택 기준, Service Worker 연동 아키텍처까지 한 번에 다룹니다.


1. 3단 폴백 전략: visibilitychange → pagehide → sendBeacon

단순히 beforeunload에 sendBeacon을 거는 코드는 이미 많이 알려져 있습니다. 문제는 브라우저와 OS 조합에 따라 이벤트 발생 순서가 다르다는 점입니다. iOS Safari는 beforeunload를 아예 발생시키지 않고, 일부 모바일 브라우저는 unload를 건너뜁니다. 따라서 세 가지 이벤트를 계층적으로 조합해야 합니다.

type BeaconPayload = Record<string, unknown>;

let flushed = false;

function flushAnalytics(payload: BeaconPayload): void {
  if (flushed) return; // 중복 전송 방지
  flushed = true;

  const sent = sendBeaconSafe('/api/analytics', payload);

  if (!sent) {
    // sendBeacon이 false를 반환하면 localStorage에 임시 저장
    persistToLocalStorage(payload);
  }
}

// 1단: visibilitychange (권장 — 페이지가 백그라운드로 전환될 때)
document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') {
    flushAnalytics(collectPayload());
  }
});

// 2단: pagehide (모바일 Safari 등 visibilitychange 누락 케이스 대비)
window.addEventListener('pagehide', (event) => {
  // persisted === true이면 bfcache에 들어간 것 → 아직 종료 아님
  if (!event.persisted) {
    flushAnalytics(collectPayload());
  }
});

// 3단: beforeunload (데스크탑 브라우저 최후 보루, 단 동기 처리만 보장)
window.addEventListener('beforeunload', () => {
  flushAnalytics(collectPayload());
});

핵심 포인트: flushed 플래그로 세 이벤트가 중복 실행되어도 요청이 한 번만 나가도록 막아야 합니다. visibilitychange가 가장 먼저, 가장 안정적으로 트리거되므로 여기에 최우선 처리를 두세요.


2. CORS 오류를 피하는 Blob/FormData 패턴

sendBeaconContent-Type을 자유롭게 지정할 수 없습니다. 문자열을 그냥 넘기면 브라우저가 text/plain으로 보내지만, application/json으로 명시하려는 순간 CORS preflight가 트리거되고, sendBeacon은 preflight를 기다리지 않으므로 요청이 실패합니다.

해결책은 두 가지입니다.

방법 A: text/plain Blob으로 JSON 담기

function sendBeaconSafe(url: string, payload: BeaconPayload): boolean {
  const body = new Blob(
    [JSON.stringify(payload)],
    { type: 'text/plain' } // ← application/json이 아닌 text/plain
  );
  return navigator.sendBeacon(url, body);
}

서버에서는 Content-Typetext/plain이어도 body를 JSON으로 파싱하면 됩니다. Node.js Express 기준:

// 서버 측 (Express)
app.post('/api/analytics', express.text({ type: 'text/plain' }), (req, res) => {
  const data = JSON.parse(req.body); // text/plain이지만 JSON 파싱 가능
  res.sendStatus(204);
});

방법 B: FormData 사용

function sendBeaconWithFormData(url: string, payload: BeaconPayload): boolean {
  const form = new FormData();
  form.append('data', JSON.stringify(payload));
  return navigator.sendBeacon(url, form);
  // Content-Type: multipart/form-data → CORS simple request 조건 충족
}

FormData는 CORS 단순 요청(simple request) 조건을 충족하므로 preflight 없이 크로스 오리진 전송이 가능합니다. 단, 서버에서 multipart 파싱이 필요하다는 점을 팀과 사전에 맞춰두세요.


3. sendBeacon vs fetch keepalive — 상황별 의사결정 트리

"그냥 fetchkeepalive: true 옵션 붙이면 되는 거 아닌가요?" 많이 받는 질문입니다. 둘은 같은 문제를 풀지만 쓰임새가 다릅니다. 아래 트리를 따라 선택하세요.

API 요청을 페이지 종료 시 안전하게 보내야 한다
│
├─ 응답(response body)이 필요한가?
│   ├─ YES → fetch keepalive: true  ✅
│   └─ NO  →  아래로 계속
│
├─ 커스텀 헤더(Authorization, X-Api-Key 등)가 필요한가?
│   ├─ YES → fetch keepalive: true  ✅
│   └─ NO  →  아래로 계속
│
├─ 페이로드가 64KB를 초과하는가?
│   ├─ YES → fetch keepalive: true  ✅  (단, 브라우저별 ~640KB 제한 존재)
│   └─ NO  →  아래로 계속
│
└─ 단순 이벤트 로그/분석 데이터인가?
    └─ YES → navigator.sendBeacon  ✅  (더 가볍고 확실)

실전 예시: ky 라이브러리에서 keepalive 사용하기

ky는 내부적으로 fetch를 래핑하므로, hooks나 두 번째 인자로 keepalive 옵션을 그대로 전달할 수 있습니다.

import ky from 'ky';

async function sendWithKy(payload: BeaconPayload): Promise<void> {
  try {
    await ky.post('/api/analytics', {
      json: payload,
      keepalive: true,        // 페이지 종료 후에도 요청 유지
      retry: 0,               // 종료 시점에는 재시도 금지
      headers: {
        'Authorization': `Bearer ${getToken()}`,
      },
    });
  } catch {
    // keepalive fetch도 실패할 수 있음 → 폴백으로 sendBeacon 시도
    sendBeaconSafe('/api/analytics', payload);
  }
}

주의: keepalive: true인 fetch 요청들의 총 페이로드 합계가 브라우저별로 제한됩니다(Chrome 기준 전체 합산 ~640KB). 대용량 데이터는 사전에 분할하거나 다른 전략을 써야 합니다.


4. sendBeacon false 반환 시 localStorage 폴백 패턴

sendBeacon은 큐 등록 실패 시 false를 반환합니다. 네트워크가 없거나, 페이로드가 64KB를 초과하거나, 브라우저 정책에 의해 차단됐을 때 발생합니다. 이 순간을 놓치면 데이터는 영원히 사라집니다.

const PENDING_KEY = 'beacon_pending_queue';

function persistToLocalStorage(payload: BeaconPayload): void {
  try {
    const existing = JSON.parse(localStorage.getItem(PENDING_KEY) ?? '[]');
    existing.push({ payload, timestamp: Date.now() });
    localStorage.setItem(PENDING_KEY, JSON.stringify(existing));
  } catch {
    // localStorage 접근 불가(시크릿 모드 용량 초과 등) → 조용히 포기
  }
}

function retryPendingBeacons(): void {
  try {
    const queue: Array<{ payload: BeaconPayload; timestamp: number }> =
      JSON.parse(localStorage.getItem(PENDING_KEY) ?? '[]');

    if (queue.length === 0) return;

    const stillPending: typeof queue = [];

    for (const item of queue) {
      // 24시간 이상 된 데이터는 폐기
      if (Date.now() - item.timestamp > 86_400_000) continue;

      const sent = sendBeaconSafe('/api/analytics', item.payload);
      if (!sent) stillPending.push(item);
    }

    localStorage.setItem(PENDING_KEY, JSON.stringify(stillPending));
  } catch { /* silent */ }
}

// 다음 세션 시작 시 재전송 시도
document.addEventListener('DOMContentLoaded', retryPendingBeacons);

이 패턴은 간단하지만 강력합니다. 네트워크 오류로 인한 전송 실패를 세션 경계를 넘어 복구할 수 있습니다.


5. Service Worker + Background Sync로 오프라인까지 대비하기

localStorage 폴백은 브라우저가 켜져 있을 때만 작동합니다. 오프라인 상태에서 탭이 닫히는 최악의 시나리오까지 커버하려면 Service Worker의 Background Sync API와 결합해야 합니다.

전체 아키텍처

[메인 스레드]
   ↓ sendBeacon 실패 또는 오프라인 감지
[IndexedDB에 페이로드 저장]
   ↓ navigator.serviceWorker 에 sync 이벤트 등록
[Service Worker]
   ↓ 네트워크 복구 시 'sync' 이벤트 수신
[IndexedDB에서 꺼내 fetch로 전송]
   ↓ 성공 시 IndexedDB에서 삭제
// === 
댓글 0개

댓글

댓글을 불러오는 중...

지원 문의