Sentry를 이용한 프론트엔드 에러 추적
0. Overview 🎯 프론트엔드 개발과 배포, 그리고 CI/CD 구축까지 마친 상황입니다. 그런데 배포해놓은 서비스에서 에러가 발생하면 어떤 단계를 밟아야 할까요? 우선은 에러의 원인을 파악해야 할 것입니다. 이를 에러 탐지라고 하겠습니다. 그 다음 에러에 대
0. Overview 🎯
프론트엔드 개발과 배포, 그리고 CI/CD 구축까지 마친 상황입니다. 그런데 배포해놓은 서비스에서 에러가 발생하면 어떤 단계를 밟아야 할까요? 우선은 에러의 원인을 파악해야 할 것입니다. 이를 에러 탐지라고 하겠습니다. 그다음 에러에 대한 신속하고 정확한 대응이 필요하겠죠. 이를 에러 대응이라고 하겠습니다.
에러 대응은 고사하고, 에러 탐지는 매우 어려운 작업입니다. 인프라 관련 이슈인지 데이터 이슈인지, 프론트엔드 이슈인지 백엔드 이슈인지 파악이 어렵기 때문입니다. 심지어 이미 꼼꼼한 QA를 마치고 배포된 시스템이기 때문에 오히려 에러 탐지는 더 어렵습니다. 우리의 예상 범위 안에 있는 에러가 아니기 때문일 테죠.
1. 프론트엔드에서의 에러 🎯
프론트엔드의 오류는 데이터 영역 / 화면 영역에서의 오류, 외부 요인에 의한 오류 / 런타임 오류로 구분할 수 있습니다.
데이터 영역 / 화면 영역에서의 오류는 QA나 스테이징 환경에서도, 사용자가 에러 상황을 경험하기 전에 해결할 수 있습니다. 개발자가 예측할 수 있는 에러라고 볼 수 있습니다. 하지만, 클라이언트에서 발생하는 오류를 브라우저 개발자 도구 콘솔에서 확인할 수 있다고 한들, 사용자가 가지고 있는 디바이스나 버전이 매우 다양하고, 에러가 발생할 때마다 모든 다바이스를 확보하는 것에는 현실적인 어려움이 있습니다.
브라우저의 정책 변경이나 특이한 상황에서만 발생하는 런타임 에러는 QA나 스테이징 환경에서 재현되지 않는 경우가 대부분입니다. 따라서 외부 요인에 의한 오류 / 런타임 오류는 개발자가 예측할 수 없는 에러라고 볼 수 있습니다.
프론트엔드에서의 에러는 예측 자체가 불가능하거나, 예측 가능한 에러라도 변수가 너무 많다는 점을 알 수 있습니다. 그렇다면 이제, 어떻게 이러한 에러에 대응해야 할지 논의하는 것이 좋겠습니다.
2. Sentry 🎯
Sentry는 실시간 로그 취합 및 분석 도구이자 모니터링 플랫폼입니다. 즉, 에러 탐지를 위한 프론트엔드 모니터링 툴 중 하나입니다. Sentry의 특징은 다음과 같습니다.
2-1. 이벤트 로그에 대한 다양한 정보 제공 ✍️
Sentry는 발생한 이벤트 로그에 대해 다양한 정보를 제공합니다.
1. Exception & Message: 이벤트 로그 메세지 및 코드 라인 정보 2. Device: 이벤트 발생 장비 정보 3. Browser: 이벤트 발생 브라우저 정보 4. OS: 이벤트 발생 OS 정보 5. Breadcrumbs: 이벤트 발생 과정
기본적으로 제공되는 정보 외에도, Context 기능을 통해 추가적인 정보를 수집할 수 있습니다. 이에 대해서는 하단에서 자세히 다룹니다.
2-2. 비슷한 오류 통합 ✍️
https://docs.sentry.io/concepts/data-management/event-grouping/
Sentry는 Issue Grouping 기능을 통해, 비슷한 이벤트 로그를 하나의 이슈로 통합합니다. 유사한 에러를 파악하고 추적하는 데 큰 도움이 되겠죠.
2-3. 다양한 플랫폼 지원 ✍️
https://sentry.io/platforms/
Sentry는 .NET, Android, Dart, Go, Node, Python, Rust, Unity, Unreal Engine 등 다양한 플랫폼을 지원합니다.
2-4. 다양한 알림 채널 지원 ✍️
https://docs.sentry.io/organization/integrations/
발생한 이슈에 대해 실시간으로 알림을 받을 수 있도록 Slack, Jira, Github 등의 다양한 채널을 지원합니다.
3. 기본적인 데이터 쌓기 🎯
https://docs.sentry.io/platforms/javascript/guides/react/
React를 기준으로 Sentry를 설정하고 데이터를 쌓을 수 있는 기본 기능에 대해 살펴보도록 하겠습니다.
3-1. Install & Configure ✍️
3-1-1. Install ⚙️
Sentry는 런타임 내에서 SDK를 사용하여 데이터를 캡쳐합니다. @sentry/react, @sentry/tracing 패키지를 설치합니다.
# using npm
$ npm install --save @sentry/react @sentry/tracing
# using yarn
$ yarn add @sentry/react @sentry/tracing
# using pnpm
$ pnpm add @sentry/react @sentry/tracing3-1-2. Configure ⚙️
import React from 'react';
import ReactDOM from 'react-dom';
import * as Sentry from '@sentry/react';
import { BrowserTracing } from '@sentry/tracing';
import App from './App';
Sentry.init({
dsn: 'dsn key',
release: 'release version',
environment: 'production',
normalizeDepth: 6,
integrations: [
new Sentry.Integrations.Breadcrumbs({ console: true }),
new BrowserTracing(),
],
});
ReactDOM.render(<App />, document.getElementById('root'));Sentry 설정에 필요한 기본 정보는 다음과 같습니다.
1. dsn: 이벤트를 전송하기 위한 식별 키 2. release: 애플리케이션 버전 3. environment: 애플리케이션 환경 4. normalizeDepth: 컨텍스트 데이터를 정규화 5. integrations: 플랫폼 SDK 별 통합 구성 설정
특별히 React SDK는 자동으로 JavaScript 오류를 탐지하고 Sentry로 전송할 수 있도록 ErrorBoundary 컴포넌트를 제공합니다.
import React from 'react';
import * as Sentry from '@sentry/react';
<Sentry.ErrorBoundary
fallback={<p>에러가 발생하였습니다. 잠시 후 다시 시도해주세요.</p>}
>
<Example />
</Sentry.ErrorBoundary>;3-2. Capture Errors ✍️
Sentry는 두 가지 이벤트 전송 API를 제공합니다.
3-2-1. captureException ⚙️
captureException API를 통해, error 객체나 문자열을 전송할 수 있습니다.
import * as Sentry from '@sentry/react';
try {
aFunctionThatMightFail();
} catch (err) {
Sentry.captureException(err);
}3-2-2. captureMessage ⚙️
captureMessage API를 통해, error 상황에 대한 문자열을 전송할 수 있습니다.
Sentry.captureMessage('에러가 발생하였습니다!');4. 풍부한 데이터 쌓기 🎯
지금까지의 설정으로도 Sentry를 사용할 수는 있습니다. 하지만 추가적인 설정을 더한다면, 더 많은 오류를 추적할 수 있습니다.
4-1. Scope ✍️
Sentry는 scope 단위로 이벤트 데이터를 관리합니다. 이벤트가 전송되면, 해당 이벤트의 데이터를 현재 scope의 추가 정보와 병합합니다.
4-1-1. configureScope ⚙️
configureScope 설정은 글로벌 scope와 유사한 맥락으로, 현재 범위 내에서 데이터를 재구성할 때 사용합니다.
import * as Sentry from '@sentry/react';
Sentry.configureScope((scope: Sentry.Scope) => {
scope.setUser({
id: 28,
email: 'minkwan@example.com',
});
});4-1-2. withScope ⚙️
withScope 설정은 로컬 scope와 유사한 맥락으로, 한 번의 범위 내에서 데이터를 재구성할 때 사용합니다.
현재 범위의 복제본을 생성하고 설정한 추가적인 정보를 병합합니다. 함수 호출이 완료될 때까지 격리된 상태로 유지합니다.
import * as Sentry from '@sentry/react';
Sentry.withScope((scope: Sentry.Scope) => {
scope.setTag('my-tag', 'my value');
scope.setLevel(Sentry.Severity.Warning);
Sentry.captureException(new Error('my 에러'));
});
Sentry.captureException(new Error('일반 에러'));withScope는 일회성 스코프입니다. 함수 본문 안에서만 유효한 임시 스코프를 생성하고, 해당 함수가 끝나면 즉시 폐기됩니다. 따라서 마지막 라인의 captureException에서는 withScope에서 설정한 태그 정보가 전송되지 않습니다.
4-2. Context ✍️
Scope가 컨테이너라면, Context는 Scope의 컨텐츠라고 볼 수 있습니다.
API 에러의 경우, 요청 데이터와 응답 데이터의 오류를 확인해야 하는데요, 이런 정보는 SDK에서 기본적으로 제공하지 않기에, context를 통해 추가적인 정보를 전송하여 애플리케이션이 어떤 영향을 받았는지 쉽게 확인할 수 있습니다.
axios를 사용할 경우 다음과 같이 context를 설정할 수 있습니다.
import * as Sentry from '@sentry/react';
const { method, url, params, data, headers } = error.config;
const { data, status } = error.response;
Sentry.setContext('API Request Detail', {
method,
url,
params,
data,
headers,
});
Sentry.setContext('API Response Detail', {
status,
data,
});4-3. Customized Tags ✍️
tag는 키와 값의 쌍으로 구성된 문자열입니다.
tag는 인덱싱 되는 요소이기 때문에, 관련 이벤트에 빠르게 접근할 수 있도록 합니다. 이슈 검색이나 트래킹을 신속하게 진행할 수 있겠죠.
API 에러는 network, timeout, not found, internal server error 등 종류가 다양합니다. network와 일반적인 API 에러를 구분하여 tag를 설정하면, 케이스를 구분하여 이슈를 파악할 수 있고 이슈 빈도 확인 등의 분석도 가능하게 됩니다. 이슈 알람을 받을 수 있는 조건에 특정 tag 조건을 설정하면, 원하는 알람을 생성할 수도 있겠네요.
import * as Sentry from '@sentry/react';
Sentry.setTag('api', 'network');
Sentry.setTag('api', 'internalServerError');tag의 키는 32글자, 값은 200자까지 가능하므로 주의해서 사용하는 것이 좋겠습니다.
4-4. Level ✍️
이벤트마다 Level을 설정하여 이벤트의 중요도를 식별할 수 있습니다.
export declare enum Severity {
Fatal = 'fatal',
Error = 'error',
Warning = 'warning',
Log = 'log',
Info = 'info',
Debug = 'debug',
Critical = 'critical',
}다음과 같이 이벤트의 level을 설정할 수 있습니다.
import * as Sentry from '@sentry/react';
Sentry.setLevel(Sentry.Severity.Warning);4-5. Issue Grouping ✍️
Sentry의 각 이벤트는 fingerprint를 가지고 있습니다.
fingerprint는 이벤트 내에 수집된 stacktrace, exception, message와 같은 정보들을 기반으로 내재되어 있는 그룹화 알고리즘으로 생성됩니다. fingerprint가 동일한 이벤트들은 자동으로 하나의 이슈로 그룹화됩니다. 내재화된 알고리즘에 기반하여 자동으로 그룹화되기 때문에 이벤트들이 예상한 것과 다른 이슈로 보일 때가 있습니다.
API 에러는 400, 404, 500 등 다양합니다. Sentry는 요청 URI가 같으면 HTTP Status가 다르더라도 같은 이슈로 그룹하하는데, 이렇게 되면 각 HTTP Status 별로 발생하는 이벤트 분포 확인이나 이슈 분석이 어려워지겠죠.
import * as Sentry from '@sentry/react';
const { method, url } = error.config;
const { status } = error.response;
Sentry.setFingerprint([method, status, url]);위와 같이 method, status, url을 fingerprint 조건으로 설정하면, 해당 조건에 부합하는 이벤트들을 하나의 이슈로 그룹화할 수 있게 됩니다.
같은 경로의 api 요청이라도, 다음과 같이 그룹핑이 다르게 적용되는 것입니다.
1. GET-400-/api/users → 이슈 A 2. GET-404-/api/users → 이슈 B 3. GET-500-/api/users → 이슈 C
5. 에러 확장하기 🎯
Sentry에서 이슈 타이틀은 자바스크립트에 내장되어 있는 빌트인 에러 객체로 생성되어 전송된 에러 객체의 이름에 기반하고 있습니다. 말이 좀 어려운데요, 다음과 같은 상황입니다.
throw new Error("로그인 실패");
throw new Error("토큰 만료");
throw new Error("권한 없음");세 가지 이슈는 서로 다른 상황이지만, 모두 이슈 타이틀이 Error로 나타나게 됩니다.
captureMessage를 이용하여 문자열만 이벤트로 전송하게 되면 빌트인 에러 객체를 사용하지 않아도 됩니다만, stack trace 등 오류에 대한 다양한 정보를 얻으려면 빌트인 에러 객체를 이용하여 오류를 생성 및 핸들링하고 Sentry에 전송하는 것이 좋습니다.
빌트인 에러 객체를 사용하는 것이 월등히 좋지만 빌트인 에러 객체 자체 이름을 기반으로 이슈 타이틀이 결정되기 때문에, 이슈가 섞여버리게 됩니다. 이슈 타이틀 자체도 구체적이지 않기에 문제가 되죠.
문제를 해결하기 위해서는 빌트인 에러 객체를 상속한 새로운 에러 객체를 생성하여 Sentry에 전송하면 됩니다.
class AuthError extends Error {
constructor(message: string) {
super(message);
this.name = 'AuthError';
}
}
Sentry.captureError(new AuthError('인증 에러 발생'));위 방식과 같이 빌트인 에러 객체(Error)를 상속하여 AuthError로 커스텀하고, AuthError에 이벤트를 전송하면 Error 대신 AuthError라는 이슈 타이틀로 보이게 됩니다.
Axios와 TypeScript를 사용하고 있는데 API 에러를 별도의 에러 객체로 핸들링하고 싶다면 다음과 같이 에러 객체를 확장할 수 있겠습니다.
class ApiError<T = unknown> extends Error implements AxiosError<T> {
config: AxiosRequestConfig;
code?: string;
request?: any;
response?: AxiosResponse<T>;
isAxiosError: boolean;
toJSON: () => any;
constructor(error: AxiosError<T>, message?: string) {
super(message ?? error.message);
const errorStatus = error.response?.status || 0;
let name = 'ApiError';
switch (errorStatus) {
case HTTP_STATUS.BAD_REQUEST: // 400
name = 'ApiBadRequestError';
break;
case HTTP_STATUS.UNAUTHORIZED: // 401
name = 'ApiUnauthorizedError';
break;
case HTTP_STATUS.FORBIDDEN: // 403
name = 'ApiForbiddenError';
break;
case HTTP_STATUS.NOT_FOUND: // 404
name = 'ApiNotFoundError';
break;
case HTTP_STATUS.INTERNAL_SERVER_ERROR: // 500
name = 'ApiInternalServerError';
break;
}
}
this.name = name;
this.stack = error.stack;
this.config = error.config;
this.code = error.code;
this.request = error.request;
this.response = error.response;
this.isAxiosError = error.isAxiosError;
this.toJSON = error.toJSON;
}6. 알람 설정하기 🎯
발생하는 모든 이벤트를 모니터링하고 알람을 받는 것은 매우 비생산적인 일입니다. 따라서 서비스의 성격에 따라 알람을 받기 원하는 조건과 임계치(threshold)를 잘 설정해야 합니다. 위에서 소개했던 기능들은 알람에서 알람 조건을 설정할 때 유용하게 사용될 수 있습니다.
API 오류 중 500 에러에 대해서만 알람을 받고 싶다면 tag과 level 기능을 이용하여 설정해 볼 수 있습니다. 예를 들어 API 500 에러에 대해 level을 Error로, 이벤트의 tag를 api로 설정하면 관련한 이벤트를 추려낼 수가 있게 됩니다. 이 알람 조건은 이벤트나 이슈 검색 조건으로 활용될 수도 있습니다.
추적하고자 하는 이벤트에 대한 알람 조건과 임계치를 설정하고 모니터링한다면, 의도치 않은 오류에 대한 신속한 탐지와 발 빠른 대응이 가능하고 오류를 분석하여 사용자 경험을 개선할 수 있을 것입니다.
7. 내 프로젝트에 적용 🎯
저의 개인 프로젝트의 모든 API 요청은 서버 상태의 효율적인 관리를 위해 Tanstack Query로 래핑 되어 있습니다. useQuery나 useMutation에 등록된 API 요청 함수에서는, 통합적인 처리를 위해 axiosInstance를 사용하고 있습니다.
7-1. Sentry 기본 설정 ✍️
애플리케이션 진입점에서 Sentry를 초기화하고, React 렌더링 에러를 잡기 위해 ErrorBoundary로 앱 전체를 감싸줍니다.
import { createRoot } from "react-dom/client";
import "./reset.css";
import App from "./App.tsx";
import {
RouterProvider,
ThemeProviderWrapper,
TanstackQueryProvider,
} from "./providers/index.ts";
import * as Sentry from "@sentry/react";
Sentry.init({
dsn: "dsn key",
release: "release version",
environment: "development",
integrations: [
Sentry.browserTracingIntegration(),
Sentry.replayIntegration(),
],
tracesSampleRate: 1.0,
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0,
});
createRoot(document.getElementById("root")!).render(
<RouterProvider>
<Sentry.ErrorBoundary fallback={<p>UI 렌더링 중 에러가 발생했습니다.</p>}>
<TanstackQueryProvider>
<ThemeProviderWrapper>
<App />
</ThemeProviderWrapper>
</TanstackQueryProvider>
</Sentry.ErrorBoundary>
</RouterProvider>
);7-2. 커스텀 에러 클래스 생성 ✍️
Sentry 대시보드에서 API 에러를 상태 코드별로 명확하게 그룹화하기 위해 커스텀 에러 클래스를 만듭니다. 커스텀 에러 클래스를 통해 Sentry 활용도를 극대화할 수 있습니다.
import { AxiosError } from "axios";
export class ApiError extends Error {
public readonly statusCode: number;
public readonly originalError: AxiosError;
constructor(error: AxiosError) {
super(error.message);
this.originalError = error;
this.statusCode = error.response?.status || 0;
switch (this.statusCode) {
case 400:
this.name = "ApiBadRequestError";
break;
case 401:
this.name = "ApiUnauthorizedError";
break;
case 404:
this.name = "ApiNotFoundError";
break;
case 500:
this.name = "ApiInternalServerError";
break;
default:
this.name =
error.isAxiosError && !error.response
? "NetworkError"
: "UnhandledApiError";
break;
}
}
}7-3. API 에러 리포팅 전담 ✍️
axiosInstance에 Interceptor를 설정하여 모든 API 에러를 중앙에서 감지하고 Sentry로 보고합니다. 에러 리포팅의 핵심이라고 볼 수 있습니다.
import axios, { AxiosError } from "axios";
import * as Sentry from "@sentry/react";
import { ApiError } from "./ApiError";
type HeaderType = "auth" | "contentType";
export const API_URL = import.meta.env.VITE_API_URL || "http://localhost:3000/";
export const axiosInstance = axios.create({
baseURL: API_URL,
withCredentials: true,
});
axiosInstance.interceptors.response.use(
(response) => response,
(error: AxiosError) => {
Sentry.withScope((scope) => {
scope.setContext("API Request", {
url: error.config?.url,
method: error.config?.method,
data: error.config?.data,
});
scope.setContext("API Response", {
status: error.response?.status,
data: error.response?.data,
});
scope.setTag("type", "api");
if (error.response?.status) {
scope.setTag("status_code", error.response.status);
}
scope.setFingerprint([
"{{default}}",
error.config?.method || "unknown method",
String(error.response?.status || "unknown status"),
error.config?.url?.replace(/\/\d+/g, "/:id") || "unknown url",
]);
const apiError = new ApiError(error);
Sentry.captureException(apiError);
});
return Promise.reject(error);
}
);
.
.
.
(생략)7-4. 전역 UI 피드백 설정 ✍️
이제 Sentry 리포팅은 끝났으니, 사용자에게 보여줄 공통 에러 메시지를 defaultOptions를 통해 설정합니다.
import {
QueryClient,
QueryClientProvider,
QueryCache,
} from "@tanstack/react-query";
import { ReactNode, useState } from "react";
import { toast } from "react-hot-toast";
export default function TanstackQueryProvider({
children,
}: {
children: ReactNode;
}) {
const [queryClient] = useState(
() =>
new QueryClient({
queryCache: new QueryCache({
onError: (error) => {
console.error("A query failed globally:", error);
toast.error(
`데이터를 가져오는 중 오류가 발생했습니다: ${error.message}`
);
},
}),
defaultOptions: {
mutations: {
onError: (error: Error) => {
console.error("A mutation failed:", error);
toast.error("요청에 실패했습니다. 잠시 후 다시 시도해주세요.");
},
},
queries: {
retry: false,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}7-5. Sentry Dashboard ✍️
위와 유사한 방식으로 코드를 작성하고, Sentry Dashboard를 검색합니다.
Sentry Dashboard에서 가입을 하면 온보딩 과정에서 dsn key를 제공해 줍니다. 해당 키를 우리가 초기 설정에서 작성한 dsn에 붙여줍니다. 민감 정보에 해당하니 환경 변수 처리를 해야겠죠?
온보딩에서 연결 테스트를 위해 일부러 에러를 내는 버튼에 대한 코드도 제공해 줍니다. 작성한 뒤 버튼을 클릭해 보세요.

8. 마치며 🎯
사실 이번 글은 카카오페이 FE 개발자 클로이 님이, 테크 블로그에 작성하신 글의 우라까이 수준입니다. 워낙 내용이 촘촘하게 잘 정리되어 있으니, 원문을 읽어보시는 것을 권장합니다.
https://tech.kakaopay.com/post/frontend-sentry-monitoring/
More to read
Amazon VPC Architecture 이해하기
새로운 프로젝트를 기획하며, 개발에서 무엇을 가장 먼저 고민해야 하는지 다시 돌아보게 되었습니다.한때는 프론트엔드가 모든 설계의 출발점이라고 믿었습니다. 유저가 무엇을 보고, 어떤 흐름에서 머무르고 이탈하는지에 대한 이해 없이 서비스를 만든다는 건 불가능하다고 생각했기
'원사이트'프론트엔드 관점으로 알고리즘 이해하기
오랜만에 방법론에 관한 글을 쓰게 되었습니다. 최근 상황은 이렇습니다. SSAFY에서는 하루에 엄청난 양의 알고리즘 문제들을 과제로 수행하게 됩니다. 그 과정에서, '구현력'이 매우 떨어진다는 생각이 들었습니다. 완전히 어려운 문제라면 '아쉬움'이라는 감정조차 느끼지
SubnetVPC 설계의 시작: IP와 Subnet
반복되는 루틴 속에서 얻은 안정감을 발판 삼아, 이제는 기술적 스펙트럼을 넓히기 위한 개인 프로젝트에 착수하고자 합니다.이번 프로젝트의 목표는 단순한 포트폴리오 구축을 넘어, 실제 서비스 수준의 블로그 시스템 구현과 다국어 처리 적용 등 실무에 가까운 역량을 한 단계