- Published on
Sentry에서 에러는 보이는데 수정하기가 어렵다면?
들어가며
Sentry를 설치하고 에러를 모니터링하다 보면, 너무 많은 에러 로그에 압도되거나 정작 중요한 이슈를 놓치기 쉽습니다. "무엇을 수집하고, 무엇을 무시할 것인가?"는 기술의 문제가 아니라 설계의 문제입니다.
이 글에서는 프론트엔드 에러를 효과적으로 분류하고 전파하는 전략부터, Sentry를 활용한 실무적인 관측성(Observability) 설계까지 다룹니다.
1) 에러 분류: 장애인가, 상태인가?
모든 실패를 Error라는 이름으로 퉁치면, 서비스는 불친절해지고 운영은 노이즈로 가득 찹니다. 중요한 것은 에러의 성격에 따라 **우선순위(Severity)**와 대응 전략을 분류하는 것입니다.
Expected vs Unexpected (상태 vs 장애)
- Expected (예상 가능한 에러): 제품 스펙에 포함된 실패입니다. - 예: 로그인 실패, 권한 없음, 유효성 검증 실패, 잔액 부족. - 전략: **장애가 아니라 '상태'**입니다. Sentry Alert를 울리기보다는 UI가 친절하게 다음 액션을 제시해야 합니다.
- Unexpected (예상치 못한 에러): 시스템 결함입니다. - 예: 서버 500 오류, 네트워크 단절,
Cannot read properties of undefined. - 전략: 명백한 장애입니다. 격리(Boundary)하고, 즉시 로깅하여 개발자가 개입해야 합니다.
치명도(Severity): “무엇이 얼마나 망가졌나?”
에러가 났을 때 "얼마나 망가졌는지"에 따라 보여줄 UX도 달라져야 합니다.
| Level | 설명 | UX 전략 | Sentry |
|---|---|---|---|
| Critical | 앱 사용 불가 (화이트아웃, 결제/로그인 불가) | 전면 에러 페이지 + 복구 버튼 | Fatal / Error |
| Recoverable | 특정 기능만 실패 (좋아요, 댓글), 나머지는 정상 | 해당 위젯만 Fallback 표시 | Warning |
| Silent | 백그라운드 실패 (로그 전송 등), 유저는 모름 | 무시하거나 재시도 | Info / Drop |
팁: "에러를 어디서 처리할지"는 곧 "유저에게 어떤 화면을 보여줄지"와 연결됩니다. 분류가 곧 설계입니다.
2) 에러 전파 전략 (Propagation)
에러를 감지했을 때, 이를 어떻게 전파할지 결정해야 합니다.
Throw vs Result
- Throw 기반: 에러를 던져 상위 경계(Error Boundary)나 호출자가 처리하게 합니다. 예외적인 상황(Unexpected)에 적합합니다.
- Result 기반: 성공/실패를 값(
{ success: boolean, data?, error? })으로 반환해 호출자가 명시적으로 처리합니다. 비즈니스 로직(Expected)에 적합합니다.
안티 패턴: 에러 삼키기와 무의미한 에러
다음 두 가지 패턴은 장기적으로 팀의 생산성을 떨어뜨립니다.
- 에러 삼키기:
catch후null을 반환하면, 호출부는 성공한 줄 알고 로직을 진행하다 더 큰 런타임 버그를 만듭니다. - 의미 없는 에러 문자열:
throw new Error("에러가 발생했습니다")는 원인 추적을 불가능하게 만들고, 유저에게 보여줄 메시지도 없습니다.
3) 공통 언어 AppError
팀 내에서 일관된 에러 처리를 위해 직렬화 가능한 규격인 AppError를 정의합니다. 특히 Next.js 환경에서는 서버(RSC)에서 클라이언트로 에러가 넘어올 때 정보가 유실되는 것을 방지합니다.
export enum ErrorCode {
AUTH_EXPIRED = 'AUTH_EXPIRED',
BUSINESS_RULE = 'BUSINESS_RULE',
NETWORK = 'NETWORK',
TIMEOUT = 'TIMEOUT',
SERVER = 'SERVER',
UNKNOWN = 'UNKNOWN',
}
export class AppError extends Error {
constructor(
public readonly code: ErrorCode,
message: string,
public readonly options: {
status?: number
userMessage?: string // 유저에게 안전하게 보여줄 메시지
level?: 'fatal' | 'error' | 'warning' | 'info'
recoveryAction?: 'reload' | 'goHome' | 'retry' | 'goLogin'
context?: Record<string, unknown>
cause?: unknown
}
) {
super(message)
this.name = 'AppError'
}
}
4) 계층별 방어선 (Multi-layered Defense)
에러는 발생 지점에서 무조건 처리하는 것이 아니라, 처리할 수 있는 계층까지 끌어올리되(전파), 서비스 전체가 같이 죽지 않도록(격리) 설계합니다.
| 계층 | 책임 | 대표 기술 |
|---|---|---|
| Data (API) | 에러 표준화(Normalize) + 공통 정책(재시도/타임아웃) | Axios Interceptor, Fetch Wrapper |
| Logic (Domain) | 비즈니스 규칙 검증 + 상태 전이 보호 | Zod, State Machine |
| UI (Component) | 격리(Isolation) + 복구(Recovery) + 안내(Guidance) | Error Boundary, Toast |
| Monitor (Ops) | 노이즈 제거 + 재현 가능한 정보 수집 | Sentry, Sampling |
4-1) Data Layer: Normalize & Policy
백엔드 에러를 AppError로 변환(Normalize)하여 UI가 상태 코드(401, 500) 대신 code나 recoveryAction을 보고 처리하도록 합니다.
// Axios Interceptor 예시
import axios, { AxiosError } from 'axios'
function toAppError(error: unknown): AppError {
if (error instanceof AppError) return error
if (axios.isAxiosError(error)) {
const e = error as AxiosError<any>
const status = e.response?.status
const serverMessage = e.response?.data?.message
// 인증 만료: Warning 레벨로 갱신 유도
if (status === 401) {
return new AppError(ErrorCode.AUTH_EXPIRED, 'Auth expired', {
status,
userMessage: '세션이 만료되었습니다. 다시 로그인해 주세요.',
recoveryAction: 'goLogin',
level: 'warning',
cause: error,
})
}
// 비즈니스 에러: Info 레벨 (Sentry 알림 불필요)
if (status && status >= 400 && status < 500) {
return new AppError(ErrorCode.BUSINESS_RULE, serverMessage ?? 'Bad request', {
status,
level: 'info',
userMessage: serverMessage,
cause: error,
})
}
// 서버 장애: Error 레벨
return new AppError(ErrorCode.SERVER, 'Server error', {
status: status ?? 500,
userMessage: '일시적인 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.',
level: 'error',
cause: error,
})
}
return new AppError(ErrorCode.UNKNOWN, 'Unknown error', { cause: error })
}
api.interceptors.response.use(
(res) => res,
async (error) => throw toAppError(error)
)
재시도(Retry)는 “멱등성”이 기준 자동 재시도는 체감 품질을 올리지만, 잘못 쓰면 장애를 증폭시킵니다.
- GET처럼 멱등(Idempotent)한 요청: 2~3회 재시도 고려
- 결제/예약 생성 같은 POST: 서버가 멱등키를 지원하지 않으면 자동 재시도 금지
Tip: 타임아웃은 '명시적으로' Fetch를 쓴다면 AbortController로 무한 대기를 막습니다.
const REQUEST_TIMEOUT_MS = 10_000
export async function fetchWithTimeout(input: RequestInfo, init?: RequestInit) {
const controller = new AbortController()
const timeoutId = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)
try {
return await fetch(input, { ...init, signal: controller.signal })
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') {
throw new AppError(ErrorCode.TIMEOUT, 'Request timeout', {
userMessage: '요청이 지연되고 있습니다. 네트워크를 확인해 주세요.',
level: 'warning',
cause: e,
})
}
throw e
} finally {
window.clearTimeout(timeoutId)
}
}
4-2) Logic Layer: 비즈니스 에러는 상태로
Expected Error는 UI 흐름의 일부입니다.
- 폼 검증: Inline Error (필드 옆 붉은 글씨)
- 권한 없음: Toast 알림 후 로그인 모달 띄우기
- Fail Fast: 데이터 입력 시점(Zod)에 검증하여 UI 렌더링 단계로 오염된 데이터가 넘어가지 않게 방지합니다.
4-3) UI Layer: 격리, 복구, 그리고 안내
Granular Error Boundaries 앱 전체를 감싸는 하나의 Boundary보다는, 위젯 단위로 경계를 나누어 부분 장애가 전체로 번지지 않게 합니다.
// 특정 영역만 Fallback으로 대체하는 경계 컴포넌트
export class WidgetErrorBoundary extends React.Component<
{ fallback: React.ReactNode; onError?: (error: unknown) => void },
{ hasError: boolean }
> {
state = { hasError: false }
static getDerivedStateFromError() {
return { hasError: true }
}
componentDidCatch(error: unknown) {
this.props.onError?.(error)
}
render() {
if (this.state.hasError) return this.props.fallback
return this.props.children
}
}
UX Writing: 자책 금지 + 다음 행동 제시
- ❌ "에러가 발생했습니다" (막막함)
- ⭕ "연결이 잠시 끊겼어요. 네트워크를 확인한 뒤 다시 시도해 주세요." (원인 + 해결책)
5) Next.js error.tsx의 한계와 Sentry 통합
Next.js의 error.tsx는 클라이언트 컴포넌트라 서버 에러의 상세 내용을 알기 어렵고, 에러 발생 시점의 맥락(Context)이 유실되기 쉽습니다. 이를 보완하기 위한 기술적 전략들을 소개합니다.
✅ 해결책 1: Sentry.captureException 직접 호출
error.tsx 진입 시점에 에러를 명시적으로 리포팅합니다. 주의: Sentry는 기본적으로 중복된 에러를 묶어주지만, useEffect 의존성을 잘 관리해야 무한 루프를 막을 수 있습니다.
'use client';
import * as Sentry from '@sentry/nextjs';
import { useEffect } from 'react';
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
useEffect(() => {
Sentry.captureException(error, {
tags: { section: 'global-error-boundary' }
});
}, [error]);
return (
<div>
<h2>문제가 발생했습니다.</h2>
<button onClick={() => reset()}>다시 시도</button>
</div>
);
}
✅ 해결책 2: Source Map 누락 ("범인이 가면을 씀")
Sentry에 찍힌 에러가 a.js:1:4532처럼 알아볼 수 없는 외계어로 나온다면, 브라우저가 코드를 압축(Minify)했기 때문입니다.
- 해결: 빌드 시(
next build)@sentry/nextjs플러그인을 통해 **소스 맵(Source Map)**을 Sentry 서버에 업로드하세요. 이것이 되어야PaymentButton.tsx:42라인이 보입니다.
✅ 해결책 3: Trace ID 연결 ("서버와 클라이언트의 벽")
클라이언트 로그엔 "서버 500", 서버 로그엔 "DB 에러"가 따로 찍힌다면 원인을 연결하기 어렵습니다.
- 해결: Trace ID가 프론트에서 서버로 잘 전달되는지(Header Propagation) 확인하세요. Sentry 상세 화면 'Trace' 탭에서 브라우저 클릭부터 DB 쿼리까지 이어져야 합니다.
✅ 해결책 4: User Feedback Widget 활용
에러가 났을 때 사용자에게 "어떤 상황이었나요?"를 묻는 팝업을 띄우세요.
- 효과: 개발자가 로그만 보고 추측하는 것보다, "결제 버튼 눌렀는데 먹통 됨"이라는 사용자의 한 문장이 훨씬 강력한 힌트가 됩니다.
6) Sentry 운영 가이드: Alert Fatigue 피하기
모든 에러를 알림으로 받으면, 결국 아무도 보지 않게 됩니다(양치기 소년).
Alert 전략: Leveling & Filtering
- Leveling:
Fatal/Error: 즉시 슬랙 알림 (시스템 장애 - 결제 불가, 로그인 불가)Warning: 일일 리포트로 확인 (비즈니스 예외 - 재고 부족, 입력 오류)Info: 로깅만 남김 (단순 추적)
- Filtering: 통제 불가능한 브라우저 에러(Script error, Adblock)는 수집 단계(
beforeSend)에서 아예 걸러냅니다. - Fingerprinting: 같은 원인의 에러가 수십 개로 쪼개지지 않게, 에러 메시지나 엔드포인트 기준으로 그룹핑합니다.
Context & Tag 전략 ("앞뒤 맥락이 없음")
에러 메시지만 보는 것은 영화의 결말만 보는 것과 같습니다. **"비즈니스 맥락"**을 함께 수집해야 합니다.
- Breadcrumbs: 에러 발생 직전 사용자가 "결제 버튼"을 눌렀는지, "뒤로 가기"를 했는지 발자취를 남깁니다.
- Tags: 필터링 가능한 데이터(OS, 브라우저 버전, 사용자 등급, API 버전)를 남깁니다.
- Context: 디버깅에 필요한 데이터(요청 파라미터, 장바구니 상태)를 남깁니다. (단, 개인정보 제외)
7) 퍼널별 에러 설계 시나리오
서비스의 각 단계에서 발생할 수 있는 '모호한 에러'를 '명확한 액션 플랜'으로 바꾸는 설계 예시입니다.
① 유입 및 탐색 (Inbound & Discovery)
사용자가 상품을 검색하거나 상세 페이지에 진입할 때 발생하는 에러입니다.
- 필수 데이터: 검색 키워드, 필터 조건, Referrer, Page ID
- Tip: 404 Not Found가 비정상적으로 많다면, 마케팅에 사용된 외부 링크(URL)에 오타가 있는지 확인해야 합니다.
// 상세 페이지 진입 실패 시 Context 예시
Sentry.withScope((scope) => {
scope.setTag('page_type', 'product_detail')
scope.setContext('product_info', {
productId: id,
category: categoryName,
})
Sentry.captureException(error)
})
② 예약 (Booking)
사용자가 날짜를 선택하고 옵션을 고르는 복잡한 상태 값의 단계입니다.
- 필수 데이터: 사용자가 선택한 날짜 조합, 당시의 재고 API 응답값
- 전략: "이미 예약된 날짜입니다"라는 메시지가 클라이언트에서 떴다면, 단순 사용자 실수(Warning)인지 백엔드 데이터 동기화 오류(Error)인지 구분해야 합니다.
③ 결제 (Checkout & Payment) 🌟
돈이 오가는 가장 중요한 단계입니다. traceId와 orderId가 생명입니다.
- 필수 데이터: Order ID, 결제 수단, PG사 응답 코드, 에러 메시지 전체
- 주의: 카드번호 등 민감정보(PII)는 반드시 마스킹 처리하세요.
try {
await processPayment(paymentData)
} catch (error) {
Sentry.captureException(error, {
tags: {
payment_method: 'kakaopay',
pg_provider: 'tosspayments',
},
extra: {
// extra는 context로 대체 가능
orderId: 'ORD-12345',
errorCode: error.response?.code, // PG사 전용 에러 코드
},
})
}
퍼널별 관측성 설계 (Context & Tags)
단순 스택 트레이스보다 중요한 것은 **"비즈니스 맥락"**입니다.
| 단계 | 핵심 기록 데이터 | 목표 |
|---|---|---|
| 탐색 | 검색어, 필터 조건, Referer | 유입 경로 및 데이터 유효성 검증 |
| 예약 | 선택 날짜, 재고 상태, 사용자 등급 | 비즈니스 로직 오류 판별 |
| 결제 | 주문 ID, PG사 응답, 결제 수단 | 금전적 손실 방지 및 빠른 장애 대응 |
Key takeaway: 에러가 발생했을 때 **"누가, 어떤 기기로, 무엇을 하려고 하다가, 어떤 값 때문에 실패했는가"**를 한눈에 볼 수 있도록 태그를 심는 것부터 시작해 보세요.
6) 프론트엔드에서 자주 터지는 시나리오 플레이북
6-1) Chunk Load Error (배포 직후 구버전 자산)
SPA에서 “배포 직후 구버전 유저”가 오래된 HTML/manifest로 새 청크를 못 찾는 경우가 있습니다.
- 대응: 감지 시
window.location.reload()로 새 버전 강제 로드 - UX: “업데이트가 적용되어 새로고침합니다” 같은 안내
간단한 방어 코드(무한 새로고침 루프 방지 포함):
const CHUNK_RELOAD_GUARD_KEY = 'chunk_reload_guard_v1'
function isChunkLoadLikeError(reason: unknown) {
const message =
reason instanceof Error ? reason.message : typeof reason === 'string' ? reason : ''
return (
message.includes('ChunkLoadError') ||
message.includes('Loading chunk') ||
message.includes('Failed to fetch dynamically imported module')
)
}
export function setupChunkLoadErrorHandler() {
const tryReloadOnce = () => {
const alreadyReloaded = sessionStorage.getItem(CHUNK_RELOAD_GUARD_KEY) === '1'
if (alreadyReloaded) return
sessionStorage.setItem(CHUNK_RELOAD_GUARD_KEY, '1')
window.location.reload()
}
window.addEventListener('error', (event) => {
if (isChunkLoadLikeError(event.error)) tryReloadOnce()
})
window.addEventListener('unhandledrejection', (event) => {
if (isChunkLoadLikeError(event.reason)) tryReloadOnce()
})
}
6-2) 오프라인/불안정 네트워크
online/offline이벤트로 네트워크 상태를 감지- 오프라인 시 중요 전송(결제/예약) 버튼을 비활성화하고 사유를 안내
UI에서 쓰기 좋은 형태(배너/버튼 비활성화 등):
import { useEffect, useState } from 'react'
export function useIsOnline() {
const [isOnline, setIsOnline] = useState(() => navigator.onLine)
useEffect(() => {
const onOnline = () => setIsOnline(true)
const onOffline = () => setIsOnline(false)
window.addEventListener('online', onOnline)
window.addEventListener('offline', onOffline)
return () => {
window.removeEventListener('online', onOnline)
window.removeEventListener('offline', onOffline)
}
}, [])
return isOnline
}
6-3) API 타임아웃
- 무한 대기를 없애고, 타임아웃을 “사용자 경험의 일부”로 다루기
- “요청이 지연됩니다” + “다시 시도” 제공
6-4) 인증 만료(401/403)
- 401은 세션 만료로 취급해 로그인 유도
- 403은 권한 없음으로 취급해 접근 가능한 경로 안내
마치며
개발 과정에서 명확한 가이드가 없다면 에러 처리는 자칫 우선순위에서 밀리기 쉽습니다. 하지만 설계 없는 기계적인 수집은 '데이터의 홍수'만 만들 뿐, 정작 문제의 본질을 해결해 주지는 못합니다.
결국 에러 모니터링의 성패는 얼마나 많이 수집하느냐가 아니라, 얼마나 수정 가능한 형태로 설계하느냐에 달려 있습니다. 오늘 다룬 에러 분류 체계와 전파 전략, 그리고 Sentry의 실전 활용 팁들을 정리해보는 시간이었습니다.