프론트엔드 에러 핸들링, Sentry 설치가 전부가 아닙니다
프론트엔드에서 에러 처리를 처음 정비할 때 흔한 패턴은 두 가지입니다. API 호출이 실패하면
throw하고, catch 블록에서 Sentry.captureException(error)를 호출합니다. Sentry를
붙였으니 "에러 핸들링은 끝"이라고 생각하기 쉽습니다.
운영해 보면 이 방식만으로는 부족합니다. axios/fetch 실패마다 captureException을 호출하면 Sentry Issues에 같은 API 500이 수백 건 쌓이고, stack trace와 message만 조금씩 달라 triage가 불가능해집니다. 사용자 측면에서도 "재고 부족"은 toast로 충분한데 결제 실패는 페이지 fallback UI가 필요한 상황에서, 모든 에러를 같은 방식으로 처리하기 어렵습니다.
에러는 한 번에 처리할 대상이 아니라 분류 → 전파 → 표현 → 기록 → 운영의 5단계로 나눠 설계해야 합니다. 이 글에서는 커스텀 Error 클래스, React Query mutationCache, Error Boundary, Sentry Tag/beforeSend, Ownership Rules, Dashboard, fatal/error 알림까지 순서대로 쌓는 패턴을 공유합니다. 코드 예시는 익명 API를 사용한 일반화된 TypeScript/React 패턴입니다.
flowchart LR
subgraph step1 [분류]
CustomError["Custom Error / ApiError"]
end
subgraph step2 [전파]
RQ["mutationCache throwOnError"]
end
subgraph step3 [표현]
Toast["Toast / Fallback"]
end
subgraph step4 [기록]
Report["reportError + level"]
BeforeSend["beforeSend normalize"]
end
subgraph step5 [운영]
Dashboard["Dashboard error rate / by assignee"]
Ownership["Ownership Rules"]
Slack["Slack + @mention"]
end
CustomError --> RQ --> Toast
CustomError --> Report --> BeforeSend --> Sentry["Sentry Issues"]
Sentry --> Dashboard
Sentry --> Ownership --> Slack
1. 분류: 이 에러는 무엇인가
axios raw error나 Error('something went wrong')는 message와 stack이 제각각이라 이후
전파·표현·기록·운영 어디에서도 일관되게 분기할 수 없습니다. 먼저 에러 타입을 정의해
operational error(재고 부족, 권한 없음)와 programmer bug(예상 밖 예외)를 구분합니다.
instanceof 결과가 이후 4단계 정책의 입력이 됩니다.
비즈니스 규칙 위반은 버그가 아니라 의도된 operational error입니다. 베이스 클래스와 도메인별
서브클래스를 정의하고, isOperational 플래그로 “사용자에게 설명 가능한 에러”와 “프로그래머 버그”를
구분합니다.
// errors/app-error.ts
export class AppError extends Error {
constructor(
message: string,
public readonly code: string,
public readonly isOperational = true,
) {
super(message);
this.name = this.constructor.name;
Object.setPrototypeOf(this, new.target.prototype);
}
}
export class ValidationError extends AppError {
constructor(message: string, public readonly field?: string) {
super(message, "VALIDATION_ERROR");
}
}
export class UnauthorizedError extends AppError {
constructor(message = "로그인이 필요합니다.") {
super(message, "UNAUTHORIZED");
}
}
export class OutOfStockError extends AppError {
constructor(public readonly productId: string) {
super("재고가 부족합니다.", "OUT_OF_STOCK");
}
}
HTTP 응답 에러는 별도의 ApiError로 wrap합니다. axios의 raw error는 message와 stack이 제각각이라
Sentry에서 그룹핑하기 어렵습니다. API 클라이언트 한곳에서 변환해 두면 이후 레이어가 일관된 형태를 받습니다.
// errors/api-error.ts
export class ApiError extends Error {
constructor(
message: string,
public readonly status: number,
public readonly method: string,
public readonly endpoint: string,
public readonly responseBody?: unknown,
) {
super(message);
this.name = "ApiError";
Object.setPrototypeOf(this, new.target.prototype);
}
}
// api/client.ts · axios interceptor 예시
import axios from "axios";
import { ApiError } from "../errors/api-error";
const client = axios.create({ baseURL: "https://api.example.com" });
client.interceptors.response.use(
(response) => response,
(error) => {
if (axios.isAxiosError(error) && error.response) {
const { status, config, data } = error.response;
throw new ApiError(
data?.message ?? error.message,
status,
(config?.method ?? "GET").toUpperCase(),
config?.url ?? "unknown",
data,
);
}
throw error;
},
);
export { client };
도메인 로직에서는 HTTP status를 직접 해석하지 않고, 비즈니스 규칙에 맞는 Error를 throw합니다.
// features/checkout/submit-order.ts
export async function submitOrder(cartId: string) {
const cart = await fetchCart(cartId);
if (cart.items.some((item) => item.stock === 0)) {
throw new OutOfStockError(cart.items.find((i) => i.stock === 0)!.productId);
}
try {
return await client.post("/orders", { cartId });
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
throw new UnauthorizedError();
}
throw error;
}
}
programmer error는 5장 운영에서 level: fatal 후보가 되고, operational error는 Sentry·알림 모두
생략합니다. Error message에 PII(이메일, 토큰, 카드번호)를 넣지 마세요. orderId처럼 디버깅에
필요한 식별자만 이후 context에 담습니다.
2. 전파: 에러를 어디까지 올릴 것인가
타입이 정해지면 앱 내부에서 에러를 어디까지 전달할지 결정합니다. mutation 에러를 전부 toast로 처리하면 결제 실패처럼 페이지 fallback이 필요한 케이스를 놓치고, 전부 Error Boundary로 올리면 “닉네임 변경 실패”에 전체 화면 에러 UI가 뜹니다.
React Query v5의 MutationCache.onError에서 mutation meta로 전파 정책을 분리합니다.
기록(4장)과 표현(3장)은 여기서 호출만 하고, 정책은 각 섹션에서 설명합니다.
import {
QueryClient,
MutationCache,
} from "@tanstack/react-query";
import { reportError } from "./sentry/report-error";
import { toUserMessage } from "./errors/to-user-message";
import { showToast } from "./ui/toast";
declare module "@tanstack/react-query" {
interface Register {
mutationMeta: {
throwOnError?: boolean;
feature?: string;
};
}
}
export const queryClient = new QueryClient({
mutationCache: new MutationCache({
onError: (error, _variables, _context, mutation) => {
reportError(error); // → 4장 기록
if (mutation.meta?.throwOnError) {
throw error; // → 3장 표현 (Error Boundary)
}
showToast(toUserMessage(error)); // → 3장 표현 (Toast)
},
}),
});
mutation meta로 전파 정책 지정
// 닉네임 변경: toast로 충분, throwOnError 없음
const updateProfile = useMutation({
mutationFn: (name: string) => client.patch("/profile", { name }),
meta: { feature: "profile" },
});
// 결제: Error Boundary까지 전파
const checkout = useMutation({
mutationFn: submitOrder,
meta: { throwOnError: true, feature: "checkout" },
});
Error Boundary는 전역 1개만 두기보다 route/feature 단위로 배치합니다. fallback UI 내용은
3장에서 다룹니다. query는 throwOnError: true + Suspense/Error Boundary 조합이 일반적이고,
mutation은 mutationCache.onError가 전역 관문 역할을 합니다.
함정
-
meta.throwOnErrormutation을 Error Boundary 바깥에서 호출하면 uncaught promise rejection이 됩니다. -
mutationCache에서
throw error는 microtask에서 실행됩니다. React 18+에서 boundary가 catch하지만, 테스트 시 async flush가 필요할 수 있습니다.
3. 표현: 사용자에게 어떻게 보여줄 것인가
전파 정책이 정해진 뒤, throw하지 않은 경우 toast, throw한 경우 Error Boundary fallback이 사용자 경험을 담당합니다. operational error는 boundary까지 올리지 않습니다.
toast vs fallback 선택 기준
- toast: 재고 부족, 닉네임 변경 실패, 사용자가 바로 재시도할 수 있는 가벼운 실패
- fallback UI: 결제 실패, checkout 전체 불능, 페이지·flow 단위 복구 UI 필요
toUserMessage: operational 분기
// errors/to-user-message.ts
export function toUserMessage(error: unknown): string {
if (error instanceof AppError) {
return error.message;
}
if (error instanceof ApiError && error.status < 500) {
return error.message;
}
return "일시적인 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.";
}
OutOfStockError 같은 operational error는 throwOnError를 쓰지 않고 toast로
처리합니다.
Error Boundary fallback UI
import { Component, type ReactNode } from "react";
type Props = { children: ReactNode; fallback: ReactNode };
type State = { hasError: boolean };
class FeatureErrorBoundary extends Component<Props, State> {
state: State = { hasError: false };
static getDerivedStateFromError() {
return { hasError: true };
}
render() {
if (this.state.hasError) return this.props.fallback;
return this.props.children;
}
}
function CheckoutPage() {
return (
<FeatureErrorBoundary fallback={<CheckoutErrorFallback />}>
<CheckoutForm />
</FeatureErrorBoundary>
);
}
4. 기록: Sentry에 어떻게 남길 것인가
programmer error만 Sentry에 남깁니다. Issues가 [502] GET /orders/:id 하나로 묶여도
누구에게 알릴지·어떻게 추적할지는 5장 운영에서 별도 설계합니다. Tag naming은 Ownership
Rules와 1:1로 맞춥니다.
reportError: operational 생략
import * as Sentry from "@sentry/react";
import { ApiError } from "../errors/api-error";
import { AppError } from "../errors/app-error";
export function reportError(error: unknown) {
if (error instanceof AppError && error.isOperational) {
return;
}
const level =
error instanceof ApiError && error.status >= 500 ? "fatal" : "error";
Sentry.captureException(error, { level });
}
Tag vs Context
Tag는 Issues 필터·Ownership Rules·Dashboard group by에 쓰이고, Context는 이벤트 상세 패널 디버깅용입니다.
| 구분 | Tag | Context |
|---|---|---|
| 용도 | 필터·Ownership Rules·Dashboard 그룹핑 | 이벤트 상세 패널에서 디버깅 |
| 값의 형태 | 짧은 문자열, cardinality 낮음 | 객체·배열 (중첩 가능) |
| 예시 | feature.checkout, http.status |
order: { orderId, step } |
이벤트 단위로는 Sentry.withScope로 격리합니다. setTag를 전역에 남겨 두면 다음
capture까지 값이 유지되는 함정이 있습니다.
export function captureCheckoutError(
error: unknown,
ctx: { orderId: string; step: string },
) {
Sentry.withScope((scope) => {
scope.setTag("feature.checkout", "true");
scope.setTag("checkout.step", ctx.step);
scope.setContext("order", {
orderId: ctx.orderId,
step: ctx.step,
});
Sentry.captureException(error);
});
}
Tag naming convention은 Ownership Rules와 1:1로 맞춥니다:
feature.{name}: 기능 단위 (feature.checkout)http.status: HTTP 상태 코드api.method,api.endpoint: 정규화된 API 경로
beforeSend: API 에러 normalize
같은 GET /orders/123, GET /orders/456 실패가 path param 때문에 별도 Issue로
쌓입니다. beforeSend에서 endpoint + status 기준으로 fingerprint를 고정합니다.
// sentry/normalize-endpoint.ts
const UUID_RE =
/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi;
const NUMERIC_ID_RE = /\/\d+(?=\/|$)/g;
export function normalizeEndpoint(raw: string): string {
return raw
.replace(UUID_RE, ":id")
.replace(NUMERIC_ID_RE, "/:id")
.split("?")[0];
}
Sentry.init({
dsn: process.env.SENTRY_DSN,
initialScope: { tags: { app: "shop-web" } },
beforeSend(event, hint) {
const error = hint.originalException;
if (!(error instanceof ApiError)) return event;
const endpoint = normalizeEndpoint(error.endpoint);
event.fingerprint = [
"api-error",
error.method,
endpoint,
String(error.status),
];
event.message = `[${error.status}] ${error.method} ${endpoint}`;
event.tags = {
...event.tags,
"http.status": String(error.status),
"api.method": error.method,
"api.endpoint": endpoint,
};
if (error.responseBody) {
event.contexts = {
...event.contexts,
response: { body: error.responseBody },
};
}
return event;
},
});
Before: message만 다른 동일 API 502가 수십 Issue로 분산.
After: [502] GET /orders/:id 하나로 묶이고, Tag 필터로 triage 가능.
Context에는 디버깅에 꼭 필요한 필드만 담습니다. PII와 high-cardinality 값은 Tag·Context·Slack 어디에도 넣지 마세요.
5. 운영: 팀이 어떻게 추적·알림·처리할 것인가
기록·정규화만으로는 on-call이 Issues 목록을 수동으로 열어봐야 합니다. Sentry Dashboard로 추세를 보고, fatal은 즉시, error는 계획적으로 줄이는 운영 루프가 필요합니다. 담당자 지정은 수동 assign보다 Ownership Rules를 최대한 활용합니다.
level: fatal vs error
| level | 의미 | 알림 정책 | 대응 목표 |
|---|---|---|---|
| fatal | 결제·인증 등 핵심 flow 불능, 5xx 급증 | 즉시, 첫 발생 또는 짧은 threshold | on-call 즉시 조치, incident |
| error | 비핵심 기능 실패, 간헐적 4xx/5xx | 지연·요약, digest, 주간 리뷰 | 스프린트 백로그, 점진적 감소 |
4장 reportError에서 checkout 5xx는 fatal, 그 외 programmer error는
error로 기록합니다. Alert Rule은 level:fatal과 Tag(feature.checkout,
http.status) 조합으로 설정합니다.
Ownership Rules: 담당자 자동 지정
Issue가 생길 때마다 수동 assign하면 triage가 밀립니다. Sentry Ownership Rules를
.sentry/ownership 또는 Project Settings → Ownership Rules에 정의해 두면, 이벤트 Tag·path·module에
따라 Owner Team이 자동 assign됩니다. Slack 알림·Dashboard 집계 모두 이 assign 결과를
사용합니다.
4장 feature.* Tag naming을 Ownership Rules와 1:1로 맞춘 이유가 여기 있습니다. Tag가 없으면 Rule이
매칭되지 않고 Issue가 Unassigned로 쌓입니다.
# .sentry/ownership · Tag 기반 (프론트엔드 권장)
tags.feature.checkout:true #checkout-team
tags.feature.profile:true #profile-team
tags.feature.catalog:true #catalog-team
# path/module 기반 (코드 위치가 feature와 1:1일 때)
path:src/features/checkout/** #checkout-team
path:src/features/profile/** #profile-team
# beforeSend normalize 결과 활용
tags.api.endpoint:"POST /orders/:id" #checkout-team
tags.http.status:502 #platform-oncall
# fallback: 어떤 Rule에도 매칭되지 않은 Issue
* #frontend-platform
Ownership Rules 작성 원칙:
-
Tag 우선: 2장 mutation
meta.feature→ 4장feature.*Tag → 5장 Ownership Rule. 이 체인이 끊기면 Unassigned. -
팀 단위 assign:
#checkout-team처럼 Sentry Team을 Owner로 지정. 개인 assign은 triage 미팅에서만. -
CODEOWNERS 연동: repo에
CODEOWNERS가 있으면 Sentry가 import해 path Rule 초안을 만들 수 있습니다. Tag Rule은 별도로 유지·보완합니다.
Sentry Dashboard: error rate·담당자별 이슈 추적
Slack 알림만으로는 “전체적으로 나아지고 있는가”를 알기 어렵습니다. Ownership Rules로 assign된 Issue를 Dashboard에서 추적합니다.
위젯 1: Error rate (시계열)
-
Discover
Errors over time, filter:level:fatal/level:error분리 - group by:
feature.checkout등 feature Tag - release marker와 겹쳐 배포 후 spike 감지
# Discover query: feature별 error rate
event.type:error tag:feature.checkout | timeseries(1h) by(level)
위젯 2: Open Issues by Owner (담당자별)
- Ownership Rules assign 결과 기준 group, “checkout-team open 12건, profile-team 3건”
- filter:
is:unresolved level:error(fatal은 별도 KPI 위젯) - 주간 triage에서 팀별 Resolve / Ignore / backlog 결정
# Issues search: 담당자별 unresolved
is:unresolved level:error | count() by(assigned)
위젯 3: KPI 카드 (선택)
- Unresolved error Issues count (목표: N건 이하)
- Fatal events in last 24h (목표: 0)
- Top endpoints by error count:
api.endpointTag group
메신저 알림: 가독성과 멘션
Alert Rule에서 Ownership Rules로 assign된 Owner Team을 Slack 알림에 포함합니다. Sentry Slack integration은 Issue Owner를 멘션 대상으로 쓸 수 있습니다.
🚨 [FATAL] checkout · POST /orders/:id · 502
Issue: SHOP-WEB-123 · 3 events in 5m
Owner: #checkout-team
→ https://sentry.io/organizations/.../issues/123/
- fatal: Owner Team 멘션, title·endpoint·status·event count만 (stack trace는 링크)
-
error:
#eng-errors-digest채널, 일 1회 요약 또는 threshold(50 events/h) 초과 시
운영 루프: fatal 즉시 vs error 점진 감소
- fatal: Alert Rule → Slack Owner Team 멘션 → incident → resolve → Dashboard fatal line 확인
- error: 주간 triage, Dashboard error rate·팀별 open count review → Resolve/regression/Ignore → open count 목표
- 4장 fingerprint 덕분에 동일 API 502 300건이 1 Issue → Dashboard·digest 모두 1줄 집계
함정
- 모든 에러를 fatal로 올리면 alert fatigue → on-call이 fatal을 무시
- operational error(재고 부족)까지 capture·Slack 멘션 → 노이즈 (1·4장 분류·기록 생략과 연결)
- Tag 없이 Ownership Rules만 path 기반 → minified bundle에서 path 매칭 실패. Tag Rule을 primary로
- Dashboard 없이 Issues 목록만 보면 추세·팀별 부하 파악 불가
정리: 5단계 체크리스트
-
분류: operational error는 커스텀 Error, HTTP 실패는
ApiErrorwrap.isOperational로 programmer bug 구분. -
전파:
MutationCache.onError+meta.throwOnError, feature 단위 Error Boundary 배치. -
표현: toast(가벼운 실패) vs fallback UI(페이지 복구).
toUserMessage+ operational 분기. -
기록: Tag/Context,
beforeSendfingerprint,level지정. Tag naming은 Ownership Rules와 1:1. - 운영: Ownership Rules 자동 assign, Dashboard(error rate·팀별 open Issues), fatal 즉시 Slack+멘션, error burn-down.
throw + captureException은 시작점일 뿐입니다. 에러 타입을 정의하고, 앱 내부 전파·UX
표현을 분리하고, Sentry에 정규화해 기록한 뒤, Ownership Rules와 Dashboard로 운영까지 챙겨야 지속적으로 에러를
예방하고 줄일 수 있습니다.
새 글 알림 받기
새로운 글이 발행되면 이메일로 알려드립니다.