프로젝트의 서비스 운영을 앞두고, 운영 중 오류에 대응하기 위해 모니터링 툴로 Sentry를 도입하기로 했다. 이 과정에서 ErrorBoundary를 적용하여 오류 발생 시 UX, DX를 개선하는 작업을 했다. Sentry와 ErrorBoundary를 적용한 과정을 정리해 보려고 한다.
Sentry 도입
Sentry 초기화
(libs/sentry.ts)
import * as Sentry from '@sentry/react';
import { useEffect } from 'react';
import {
useLocation,
useNavigationType,
createRoutesFromChildren,
matchRoutes,
} from 'react-router-dom';
import { version } from '@/../package.json';
export const initSentry = () => {
Sentry.init({
dsn: import.meta.env.VITE_SENTRY_DSN,
release: version,
environment: 'production',
integrations: [
Sentry.reactRouterV6BrowserTracingIntegration({
useEffect,
useLocation,
useNavigationType,
createRoutesFromChildren,
matchRoutes,
}),
],
tracesSampleRate: 1.0,
tracePropagationTargets: [
'localhost',
/^https:\/\/(abcdedu\.com|www\.abcdedu\.com)$/,
],
});
};
(main.tsx)
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import { initSentry } from './libs/sentry';
initSentry();
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
우리는 React Router 6.4를 사용하고 있었기 때문에 RouterV6BrowerTracingIntegration을 사용했다. 각자 환경에 맞는 integration을 사용하면 된다.
Sourcemap 업로드
Production에서 오류 발생 시 어디에서 발생했는지 알 수 있도록 Sentry에 sourcemap을 업로드했다. Sourcemap이 없으면 오류 로깅 시 난독화된 코드가 나와 디버깅이 어렵다. Sourcemap 업로드 방법은 공식 문서에 나와있다. 우리 팀은 Vite를 사용 중이어서 @sentry/vite-plugin을 이용하여 설정했다.
import { sentryVitePlugin } from '@sentry/vite-plugin';
import react from '@vitejs/plugin-react-swc';
import { defineConfig, loadEnv } from 'vite';
import svgr from 'vite-plugin-svgr';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd());
return {
plugins: [
react(),
svgr(),
sentryVitePlugin({
org: ORG,
project: PROJECT,
authToken: env.SENTRY_AUTH_TOKEN,
// 생성된 sourcemap sentry에 업로드 후 삭제
sourcemaps: {
filesToDeleteAfterUpload: '**/*.map',
},
}),
],
build: {
sourcemap: mode === 'production', // Production에서만 sourcemap 생성
rollupOptions: {
output: {
manualChunks: id => {
if (id.includes('@sentry')) {
return '@sentry';
}
if (id.indexOf('node_modules') !== -1) {
const module = id.split('node_modules/').pop().split('/')[0];
return `vendor-${module}`;
}
},
},
},
},
};
});
Sentry에 sourcemap을 업로드한 후에는 보안을 위해 sourcemap을 삭제하는 것이 중요하다.
이렇게 설정하면 빌드 시 sourcemap이 Sentry에 업로드된다.
이제 오류 발생 시 sourcemap을 통해 오류 발생 근원지를 알 수 있다.
이렇게 Sentry와 연동하고 나면 다음과 같이 오류 발생 시 Sentry 이슈에 오류가 로깅된다.
const onClick = () => {
throw new Error('This is a test error!');
};
<button onClick={onClick}>오류 테스트용</button>
Axios 오류 발생 시 Sentry에 자동으로 로깅이 되고 있었다. 그런데 401 오류 같이 흔히 발생하는, 의도적으로 발생시킨 오류까지 전부 로깅하면 모니터링에 어려움이 있을 거라 생각했다. 또한 무료 플랜을 사용 중이었기 때문에 한 달에 오류 5000개로 제한이 있어 해결이 필요한, 문제가 되는 오류만 로깅해야 했다.
Axios interceptor를 사용 중이어서 서버 요청 오류 시 error가 던져지면서 모든 오류가 다 로깅이 되고 있었는데, Sentry 초기화에서 ignorePattern을 추가하여 Axios 기본 오류를 무시하도록 했다.
export const initSentry = () => {
Sentry.init({
dsn: import.meta.env.VITE_SENTRY_DSN,
...
ignoreErrors: [/AxiosError/i],
});
};
이후 interceptor에서 특정 오류들에 대해 Sentry에 로깅하는 코드를 작성했다. 네트워크 오류와 4~500번대 예기치 못한 오류를 로깅하도록 했다.
instance.interceptors.response.use(
response => {
const { result } = response.data;
return result;
},
async error => {
if (!error.response) {
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 ?? `네트워크 오류`}`,
);
});
return Promise.reject(error);
}
...
// 예기치 못한 4~500번대 오류 로깅
if (status >= 400 && ![401, 403, 409].includes(status)) {
const isServerError = status >= 500;
const errorType = isServerError ? 'Server Error' : 'Api Error';
Sentry.withScope(scope => {
scope.setLevel('error');
scope.setTag('error type', errorType);
Sentry.captureMessage(
`[${errorType}] ${error.config.url} \n${error.message}`,
);
});
}
return Promise.reject(error);
},
);
오류 로깅에는 captrueException과 captureMessage를 사용할 수 있는데, captureException은 Error 객체를 넘기고, captureMessage는 오류 메시지를 넘긴다.
이후 msw를 이용하여 API 오류 응답을 모킹하여 테스트를 수행했다.
또한 Slack과 연동하여 이슈(에러) 발생 시 알림을 받을 수 있도록 했다.
ErrorBoundary 적용
기존에는 API 요청이 이루어지는 부분에만 어느 정도의 예외 처리를 하고 있었다. 그러나 부족한 부분이 있었고 API 요청이 아닌 프론트엔드 오류, 브라우저와 관련된 오류 등에 대처할 수 없었다. 예외 처리를 조금 더 체계적이고 일관적으로 할 필요가 있다고 생각했고, 다시 시도 등의 버튼을 제공하여 사용자가 일시적인 오류에 대응할 수 있도록 해야 한다고 생각했다. 그래서 ErrorBoundary를 도입하기로 했다.
처음에는 Sentry에서 제공하는 ErrorBoundary를 사용할까 했는데, 조금 더 쉽게 사용하고 커스터마이징 하기 위해 React 공식 문서에서 제공하는 class 방식으로 구현했다.
(components/ErrorBoundary/DefaultFallback.tsx)
import { ArrowClockwise } from '@phosphor-icons/react';
interface DefaultFallbackProps {
fullScreen?: boolean;
onReset?: () => void;
}
export default function DefaultFallback({
fullScreen,
onReset,
}: DefaultFallbackProps) {
return (
<div
className={`flex-col-center py-80 gap-24 text-neutral-300 ${fullScreen && 'h-vh h-dvh'}`}
>
<ArrowClockwise size={36} />
<div className='text-center text-zinc-600 mb-16'>
<span className='text-18 font-semibold' role='alert'>
잠시 후 다시 시도해 주세요.
</span>
<p className='text-16 text-neutral-400'>
요청을 처리하는 중 오류가 발생했습니다.
</p>
</div>
<button
onClick={onReset}
className='px-16 py-8 rounded-full bg-primary-400 text-white'
>
다시 시도하기
</button>
</div>
);
}
(components/ErrorBoundary/index.tsx)
import * as Sentry from '@sentry/react';
import {
cloneElement,
Component,
ErrorInfo,
isValidElement,
PropsWithChildren,
ReactElement,
ReactNode,
} from 'react';
import { ApiError } from '@/libs/errors';
import { AccessErrorProps } from './AccessError';
import DefaultFallback from './DefaultFallback';
interface ErrorBoundaryProps {
fullScreenFallback?: boolean;
fallback?: ReactNode;
accessErrorFallback?: ReactElement<AccessErrorProps>;
onReset?: () => void;
onError?(error: Error, info: ErrorInfo): void;
}
interface ErrorBoundaryState {
error: Error | null;
hasError: boolean;
}
const initialState: ErrorBoundaryState = {
error: null,
hasError: false,
};
export default class ErrorBoundary extends Component<
PropsWithChildren<ErrorBoundaryProps>,
ErrorBoundaryState
> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = initialState;
}
static getDerivedStateFromError(error: Error) {
return { error, hasError: true };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
this.props.onError?.(error, errorInfo);
console.log(error.message, errorInfo.componentStack);
// JS 및 렌더링 과정에서 발생한 오류에 대해 sentry 로깅
Sentry.withScope(scope => {
scope.setLevel('error');
scope.setTag('error type', 'Runtime Error');
// scope.setExtra('componentStack', errorInfo); // Production에서는 해석이 힘들다.
Sentry.captureException(error);
});
}
onResetErrorBoundary = (onReset: () => void) => {
onReset();
this.reset();
};
reset() {
this.setState(initialState);
}
render() {
const { error, hasError } = this.state;
const {
fullScreenFallback,
fallback,
accessErrorFallback,
children,
onReset,
} = this.props;
if (hasError) {
if (fallback) return fallback;
// 접근 제한 Fallback
if (error instanceof ApiError && [401, 403, 404].includes(error.status)) {
if (isValidElement(accessErrorFallback))
return cloneElement(accessErrorFallback, { error });
}
return (
<DefaultFallback
fullScreen={fullScreenFallback}
onReset={onReset && (() => this.onResetErrorBoundary(onReset))}
/>
);
}
return children;
}
}
React Query를 사용 중이라면, 기본적으로 React Query가 상위로 오류를 전파하지 않기 때문에 ErrorBoundary가 오류를 잡을 수 있게 queryConfig에서 throwOnError를 true로 설정해주어야 한다.
import { DefaultOptions, QueryClient } from '@tanstack/react-query';
const queryConfig: DefaultOptions = {
queries: {
throwOnError: true,
refetchOnWindowFocus: false,
retry: false,
staleTime: 1000 * 60,
},
};
export const queryClient = new QueryClient({ defaultOptions: queryConfig });
ErrorBoundary를 API 요청이 포함된 일부 컴포넌트 상위에, 페이지 컴포넌트 상위에, 마지막으로 최상단 App 상위에 넣어 단계적으로 오류를 잡을 수 있도록 했다. 또한 페이지 이동과 같이 오류 상태를 초기화해야 하는 경우에는 ErrorBoundary에 key를 추가하여 오류를 초기화할 수 있다.
<ErrorBoundary
key={`${location.pathname}`}
onReset={() => window.location.reload()}
>
<Suspense fallback={<Loader/>}>
<Outlet />
</Suspense>
</ErrorBoundary>
컴포넌트 상위 ErrorBoundary
다시 시도 클릭 시 API 요청을 재시도하도록 했다.
페이지 상위 ErrorBoundary
다시 시도 클릭 시 페이지를 새로 고침 하도록 했다.
App 상위 ErrorBoundary
페이지 상위와 마찬가지로 다시 시도 클릭 시 페이지를 새로 고침 하도록 했다.
이렇게 서비스 운영 중 오류에 대응하기 위해 Sentry와 ErrorBoundary를 적용해 봤다. 처음 해보는 것이 많아서 부족한 점이 많겠지만 새로운 것을 시도해 봤다는 점, 기존보다 UX, DX가 개선됐다는 점에서 뿌듯한 것 같다. 유지보수하면서 차차 개선해 봐야겠다.
참고 자료
https://docs.sentry.io/platforms/javascript/guides/react/
https://tech.kakaopay.com/post/frontend-sentry-monitoring/
https://medium.com/@june.programmer/logging-sentry-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0-96043c62e136
https://blog.naver.com/dlaxodud2388/223303680940
'Develop > React' 카테고리의 다른 글
[React] useRef, scrollIntoView를 통한 스크롤 위치 이동 (0) | 2024.09.23 |
---|---|
Zustand 사용해 보기 (+ Redux와 비교) (0) | 2024.06.27 |
[React] 라이브러리 없이 Toast 구현하기 (2) | 2023.11.02 |
[React] ios 환경에서 input, textarea 화면 확대 방지하기 (+ 웹 접근성) (0) | 2023.10.13 |
[React] useReducer (0) | 2023.09.11 |