프론트엔드 에러 핸들링, 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가 전역 관문 역할을 합니다.

함정

3. 표현: 사용자에게 어떻게 보여줄 것인가

전파 정책이 정해진 뒤, throw하지 않은 경우 toast, throw한 경우 Error Boundary fallback이 사용자 경험을 담당합니다. operational error는 boundary까지 올리지 않습니다.

toast vs fallback 선택 기준

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로 맞춥니다:

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 작성 원칙:

Sentry Dashboard: error rate·담당자별 이슈 추적

Slack 알림만으로는 “전체적으로 나아지고 있는가”를 알기 어렵습니다. Ownership Rules로 assign된 Issue를 Dashboard에서 추적합니다.

위젯 1: Error rate (시계열)

# Discover query: feature별 error rate
event.type:error tag:feature.checkout | timeseries(1h) by(level)

위젯 2: Open Issues by Owner (담당자별)

# Issues search: 담당자별 unresolved
is:unresolved level:error | count() by(assigned)

위젯 3: KPI 카드 (선택)

메신저 알림: 가독성과 멘션

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 즉시 vs error 점진 감소

함정

정리: 5단계 체크리스트

  1. 분류: operational error는 커스텀 Error, HTTP 실패는 ApiError wrap. isOperational로 programmer bug 구분.
  2. 전파: MutationCache.onError + meta.throwOnError, feature 단위 Error Boundary 배치.
  3. 표현: toast(가벼운 실패) vs fallback UI(페이지 복구). toUserMessage + operational 분기.
  4. 기록: Tag/Context, beforeSend fingerprint, level 지정. Tag naming은 Ownership Rules와 1:1.
  5. 운영: Ownership Rules 자동 assign, Dashboard(error rate·팀별 open Issues), fatal 즉시 Slack+멘션, error burn-down.

throw + captureException은 시작점일 뿐입니다. 에러 타입을 정의하고, 앱 내부 전파·UX 표현을 분리하고, Sentry에 정규화해 기록한 뒤, Ownership Rules와 Dashboard로 운영까지 챙겨야 지속적으로 에러를 예방하고 줄일 수 있습니다.