Sentry와 에러지옥(with Errorboundary)

hyoki·2025년 2월 27일

서론

며칠간 나를 너무 힘들게한 문제가 있었다.
서비스 QA 도중, Sentry 오류가 발생하고 이후 모든 API 호출에서 오류가 발생했다고 보고 받았다.
하지만 아무리 시도해도 그 오류를 찾을 수 없었고, 결국 이슈는 잠정적으로 무시하고 넘어갔다.



우리는 React Query를 사용하고 있으며, 에러바운더리를 통해 에러를 처리한다.

  1. 에러가 발생하면 해당 에러를 상위 컴포넌트로 던진다.
  2. 에러바운더리가 이를 캐치한 후, 에러코드에 맞는 UI를 렌더링한다.

❓만약 Sentry 장애가 발생한다면?

그렇다면 연결을 재시도하거나 개별적인 문제 해결이 필요하다.
Sentry 오류가 서비스 전체에 영향을 미치지 않도록 해야 한다.

❗️문제상황

Sentry에서 오류가 발생하면, 이후 모든 API 호출이 영향을 받는다.

왜 그런가?
Sentry에서 오류가 발생하면 서비스에서 발생한 에러로 직결되도록 설계되어 있어서 오류가 제대로 로깅되지 않는다.

❗️센트리와 서비스 에러 간의 의존성을 분리하자
에러 로깅은 부가적인 작업이므로, 우선 사용자가 정상적으로 서비스를 잘 사용할 수 있도록 우선순위를 두자.



문제 1. 비동기 초기화 타이밍 문제

Sentry 오류 발생 가능성을 사전에 차단하자.

기존코드

async function startApp() {
	if (import.meta.env.PROD) {
      import('@sentry/react').then(async (Sentry) => {
        Sentry.init({ ... });
      });
 	}
}
startApp().then(() => {
 ...
}

문제점

Sentry를 동적으로 import하고 초기화하는데, 앱이 실행된 후 Sentry 초기화가 이루어지면 초기화 전에 발생한 에러를 처리할 수 없다. 이로 인해 오류가 발생할 수 있다.

해결코드

async function startApp() {
  if (import.meta.env.PROD) {
    const Sentry = await import('@sentry/react');
    try {
      Sentry.init({
		...
        beforeSend: (event) => {
          if (getSentryInitialized()) {
            return event;
          }
          return null;
        },
      });
      setSentryInitialized(true);
    } catch (error) {
      	console.error('Sentry initialization failed:', error);
      	setSentryInitialized(false);
    }
 }
...
}
startApp();

추가로, 모듈 스코프 변수인 setSentryInitialized() 로 초기화 성공 여부를 관리했다.

문제 2. API 요청 실패와 센트리 간의 상관관계

Sentry 로깅 실패와 API 요청 간의 의존성을 끊자.

해결방법

  1. 에러 로깅 시도 전에 Sentry 상태를 확인한다.
  2. 로깅 실패가 API 요청 처리에 영향을 미치지 않도록 한다.

해결코드

if (!error.response) {
  if (import.meta.env.PROD) {
    try {
      const requestUrl = error.config?.url || 'URL 정보 없음';
      Sentry.withScope((scope) => {
        scope.setLevel('error');
        scope.setTag('error type', 'Network Error');
        Sentry.captureMessage(`[Network Error] ${requestUrl} \n${error.message ?? `네트워크 오류`}`);
      });
    } catch (sentryError) {
        console.error('Sentry logging failed:', sentryError);
    }
  }
  return Promise.reject(error);
}

일부만 가져왔는데, 원래코드에서 try-catch문을 통해 Sentry 로깅 실패 시에도 API 요청은 정상적으로 처리되도록 했다.



🖥️ 테스트

useEffect(() => {
  const testSentryError = async () => {
    try {
      // 존재하지 않는 엔드포인트로 요청
      await fetchInstance.get('/non-existent-endpoint');
    } catch (error) {
      console.log('API 요청은 실패했지만 계속 실행됨');
    }
    // 실제 동작하는 API 엔드포인트로 요청
    try {
      const response = await fetchInstance.get('/banners');
      console.log('이 요청은 성공해야 함:', response.data);
    } catch (error) {
      console.log('이 에러는 Sentry와 무관해야 함:', error);
    }
  };
  testSentryError();
}, []);

DSN를 없는 값으로 바꿔 오류를 발생시키고, API 호출에 미치는 영향을 확인했다.
결과적으로 콘솔에 정상적으로 오류가 출력되는 것을 확인했다.


❓추가의문

  1. 만약 Sentry 오류가 발생하면, 이후 API 요청에서 오류가 발생할 경우 에러 로깅을 못 하는데, Sentry를 사용하는 의미가 있을까?
    -> Sentry 재연결 시도가 필요할 것 같다.
  2. Sentry 연결 실패 시 재연결 시도하는 방법은 무엇인가?
    -> 아래에서 retry 사용
  3. Sentry에서 오류가 발생할 경우는 무엇이 있을까? 재연결 시도하면 해결될까?
    -> 네트워크 오류, DSN 오류, Sentry 서버 자체의 장애, Rate Limiting(API 요청의 제한), API key나 인증 오류, SDK 초기화 오류

이러한 의문을 바탕으로, Sentry 장애 시 이후 에러 로깅 방법에 대해 조사했다.


방법1. Sentry 장애 시 로그를 임시 저장 후 복구 시 전송

예를 들어, 로컬 스토리지에 큐로 로그를 저장하는 방식이 있다.
하지만 로컬 스토리지에 저장하면 용량이나 민감 정보 문제 등이 발생할 수 있으므로 적합하지 않다. 애초에 로컬에 저장하는게 웃기다(?)

방법2. Sentry 장애 시 재시도 ✅

Sentry는 기본적으로

  1. 초기화 단계에서 configuration을 설정
  2. 에러 발생 시 HTTP 요청으로 에러 정보를 전송하는 방식이다.

따라서 지속적인 연결을 유지하는 방식이 아니라, 필요할 때마다 HTTP 요청을 보내는 방식이므로 재연결보다는 재시도라는 표현이 더 적합하다.
우리 서비스에서 Sentry 장애가 발생할 수 있는 부분은 두 가지가 있다.

2-1. Sentry 초기화시 장애 발생하는 경우

initSentryWithRetry.ts
초기화 실패 시, 최대 3번까지 재시도한다.

export const initSentryWithRetry = async (retryCount = 0, maxRetries = 3): Promise<boolean> => {
  try {
    Sentry.init({
      dsn: import.meta.env.VITE_SENTRY_DSN,
      ...
      beforeSend: (event) => {
        if (getSentryInitialized()) {
          return event;
        }
        return null;
      },
    });
    setSentryInitialized(true);
    return true;
  } catch (error) {
    if (retryCount < maxRetries) {
      console.warn(`Sentry initialization failed, retrying... (${retryCount + 1}/${maxRetries})`);
      await new Promise((resolve) => {
        setTimeout(resolve, 1000);
      });
      return initSentryWithRetry(retryCount + 1, maxRetries);
    }
    console.error('Failed to initialize Sentry after maximum retries', error);
    return false;
  }
};

2-2. 에러 전송시 장애 발생하는 경우

captureErrorWithRetry.ts
에러 전송 실패 시, 최대 3번까지 재시도한다.

export const captureErrorWithRetry = async (
  error: unknown,
  options?: {
    tags?: Record<string, string>;
    level?: Sentry.SeverityLevel;
  },
  retryCount = 0,
  maxRetries = 3,
): Promise<boolean> => {
  if (!getSentryInitialized()) {
    return false;
  }

  try {
    if (options?.tags || options?.level) {
      Sentry.withScope((scope) => {
      ...
      });
    } else if (error instanceof Error) {
      Sentry.captureException(error);
    } else {
      Sentry.captureMessage(String(error));
    }
    return true;
  } catch (captureError) {
    if (retryCount < maxRetries) {
      ...
      await new Promise((resolve) => {
        setTimeout(resolve, 1000);
      });
      return captureErrorWithRetry(error, options, retryCount + 1, maxRetries);
    }
    // 실패 시 대체 로직
    return false;
  }
};

이와 같은 방식으로 각 에러 처리에 맞는 재시도 로직을 작성할 수 있다.



마무리

이번 작업을 통해 Sentry 오류 처리 방법을 완전히 정리했다

재시도 테스트를 진행하려 했지만,

기본적으로 네트워크 요청을 비동기적으로 처리하므로, 네트워크 오류를 감지하기 어려운 상황에서는 재시도가 되지 않을 가능성이 크다.

라는 결론을 얻었다.
따라서 재시도 테스트는 일단 보류했으며, 추가적인 학습이 필요할 것 같다.
어쨌든 이제 사용자 입장에서는 Sentry 장애로 인한 오류가 발생하지 않을 것이다!

profile
프론트엔드 개발자

0개의 댓글