카테고리 없음

브라우저 기본 기능을 활용한 클라이언트 오류 수집 전략

UCANON 2025. 8. 28. 20:41

 

프론트엔드 애플리케이션이 복잡해질수록, 사용자 환경에서 발생하는 오류를 빠르게 탐지하고 수집하는 것은 서비스 품질 유지의 핵심입니다. 서버 로그만으로는 확인하기 어려운 클라이언트 단 오류를 어떻게 안정적으로 모을 수 있을까요?

이번 글에서는 외부 라이브러리 없이 브라우저 기본 기능만으로 오류 수집 시스템을 구축하는 방법을 정리해보겠습니다.


1. 수집 대상 정의

브라우저 환경에서 발생하는 오류는 크게 네 가지로 나눌 수 있습니다.

  1. 실행 오류 (JavaScript Runtime Error)
    • 예: TypeError: undefined is not a function
    • 수집 방법: window.onerror
  2. 리소스 로드 오류 (Resource Error)
    • 예: 이미지, CSS, JS 파일 로드 실패
    • 수집 방법: window.addEventListener('error', …, true)
    • ⚠️ 참고: window.onerror로는 리소스 로드 오류를 잡을 수 없고, 반드시 capture=true 옵션을 준 이벤트 리스너에서만 감지됩니다.
  3. 비처리 프로미스 오류 (Unhandled Promise Rejection)
    • 예: fetch() 실패 후 .catch 미처리
    • 수집 방법: window.onunhandledrejection
  4. 성능/네트워크 힌트 (선택 사항)
    • 예: 느린 요청, 대용량 자원 로드
    • 수집 방법: PerformanceObserver (resource/longtask 관찰)

2. 오류 수집 스니펫 (개선 버전)

아래 코드는 오류를 캡처하고, 일정 간격 또는 언로드 시점에 서버로 전송하는 예시입니다.

<script>
(function () {
  const ENDPOINT = "/client-error";
  const queue = [];
  const MAX_PAYLOAD_SIZE = 60000; // 보수적으로 60KB 이하
  const SAMPLE_RATE = 0.1; // 10% 샘플링
  const sentErrors = new Set();

  function shouldSample() {
    return Math.random() <= SAMPLE_RATE;
  }

  function isDuplicate(error) {
    const key = btoa(unescape(encodeURIComponent(
      `${error.type}-${error.msg || error.reason}-${error.stack || ''}`
    ))); // 해시 기반 중복 체크
    if (sentErrors.has(key)) return true;
    sentErrors.add(key);
    setTimeout(() => sentErrors.delete(key), 300000);
    return false;
  }

  function push(error) {
    if (!shouldSample()) return;
    if (isDuplicate(error)) return;

    queue.push({
      ...error,
      timestamp: Date.now(),
      userAgent: navigator.userAgent,
      url: location.href
    });

    if (queue.length >= 5) flush();
  }

  function flush() {
    if (!queue.length) return;

    const payload = JSON.stringify({
      ts: Date.now(),
      url: location.href,
      items: queue.splice(0)
    });

    if (new Blob([payload]).size > MAX_PAYLOAD_SIZE) {
      console.warn('Payload too large, splitting...');
      return;
    }

    if (navigator.sendBeacon) {
      const ok = navigator.sendBeacon(ENDPOINT, payload);
      if (!ok) fallback(payload);
    } else {
      fallback(payload);
    }
  }

  function fallback(payload) {
    fetch(ENDPOINT, {
      method: "POST",
      body: payload,
      headers: { "Content-Type": "application/json" }
    }).catch(() => {
      try {
        const img = new Image();
        const encoded = encodeURIComponent(payload);
        if (encoded.length < 2000) {
          img.src = `${ENDPOINT}?data=${encoded}`;
        }
      } catch (e) {}
    });
  }

  window.addEventListener("beforeunload", flush);
  window.addEventListener("pagehide", flush);

  window.onerror = (msg, src, line, col, err) => {
    push({
      type: "js-error",
      msg: String(msg).slice(0, 500),
      src,
      line,
      col,
      stack: err?.stack?.slice(0, 3000)
    });
  };

  window.addEventListener("error", e => {
    if (e.target && e.target !== window) {
      push({
        type: "resource-error",
        tag: e.target.tagName,
        src: e.target.src || e.target.href
      });
    }
  }, true);

  window.onunhandledrejection = e => {
    const reason = e.reason instanceof Error ?
      e.reason.message :
      String(e.reason).slice(0, 500);

    push({
      type: "unhandled-rejection",
      reason,
      stack: e.reason?.stack?.slice(0, 3000)
    });
  };

  setInterval(flush, 30000);
})();
</script>

3. navigator.sendBeacon의 특징과 한계

오류 수집에서 가장 중요한 포인트는 언로드 시점에서도 로그 유실 없이 서버에 도착하는 것입니다.

  • 장점
    • 브라우저 종료/이탈 시에도 안정적으로 전송
    • 비동기 처리라 페이지 종료 지연 없음
    • POST 전송만 지원
  • 제한
    • 응답을 받을 수 없음 (fire-and-forget)
    • 브라우저별 크기 제한 존재 (보통 64KB, 보수적으로 60KB 이하 권장)
    • 지원 현황: Chrome 39+, Firefox 31+, Safari 11.1+, Edge 14+
    • 미지원: Internet Explorer

👉 따라서 sendBeacon → 일반 fetch → 이미지 태그 순으로 fallback을 준비하는 것이 최적입니다.


4. 운영 시 반드시 고려할 점

  1. 소스맵 연계
    • 번들된 스택을 소스맵으로 원본 코드에 매핑
    • 보안상 소스맵은 프로덕션에 노출하지 않고 별도 서버에 저장
  2. CORS 정책
    • 로그 서버가 다른 도메인이라면 Access-Control-Allow-Origin 필수
    • Preflight 최소화를 위해 Content-Type: text/plain 고려
  3. 민감정보 마스킹
  4. url.replace(/([?&])(password|token|key|secret)=[^&]*/gi, '$1$2=***');
  5. 샘플링 & 중복 억제
    • 동일 오류는 일정 시간(예: 5분)에 1회만 전송
    • 트래픽 기반 동적 샘플링
  6. 호환성 안내
    • navigator.userAgent는 차후 Deprecation 예정 → UA Client Hints도 고려
    • performance.memory는 Chrome 실험적 기능 → 지원 제한 있음

5. 서버 수집 예시

app.post('/client-error', (req, res) => {
  const errors = req.body.items;
  errors.forEach(error => {
    if (error.stack) {
      error.originalStack = mapStackTrace(error.stack);
    }
    logger.error({ ...error, ip: req.ip, userAgent: req.get('User-Agent') });
    if (error.type === 'js-error') {
      sendAlert(error);
    }
  });
  res.status(200).send('OK');
});

6. 결론

  • 브라우저 기본 기능만으로도 충분히 프로덕션 수준의 오류 수집이 가능합니다.
  • 핵심은 sendBeacon을 우선 사용하면서, 크기 제한·호환성·민감정보·성능을 꼼꼼히 관리하는 것입니다.
  • 샘플링과 중복 억제를 통해 효율적으로 운영하면 서비스 품질과 사용자 경험 개선에 큰 도움이 됩니다.