Skip to content

Commit a03efcd

Browse files
authored
Merge pull request #411 from code-zero-to-one/hotfix/error-handling
feat: 전역 에러 핸들링 시스템 구현
2 parents 733ab24 + 4e53c0c commit a03efcd

47 files changed

Lines changed: 3903 additions & 1762 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,5 +43,8 @@ next-env.d.ts
4343

4444
*storybook.log
4545

46+
# sentry
47+
.sentryclirc
48+
4649
# package manager locks
4750
package-lock.json

CLAUDE.md

Lines changed: 125 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -125,10 +125,11 @@ export const getArchive = async (params: GetArchiveParams) => {
125125

126126
### 컴포넌트 구성
127127

128-
- `src/components/ui/` — shadcn/ui 기본 컴포넌트 (Button, Input, Dialog 등). 스타일: `new-york`. `components.json`에서 설정.
129-
- `src/components/layout/` — Header, Sidebar, Footer
130-
- `src/components/modals/`, `cards/`, `lists/`, `payment/`, `premium/` — 도메인별 그룹화된 컴포넌트
131-
- `src/features/`, `src/entities/`, `src/widgets/` — 부분적 FSD (Feature-Sliced Design) 구조, 향후 타입 기반 구조로 통합 예정
128+
- 공용 UI는 주로 `src/components/common/ui/` 아래에 위치한다. 예: `Button`, `Dialog`, `Toast`, `FloatingInquiryButton`
129+
- 공용 레이아웃은 `src/components/common/layout/` 아래에 위치한다. 예: `Header`, `AdminSideBar`
130+
- 공용 모달은 `src/components/common/modals/` 아래에 위치한다
131+
- 페이지 단위 조합 컴포넌트는 `src/components/pages/`, 도메인별 조합은 `payment/`, `discussion/`, `archive/`, `balance-game/`, `mentoring` 관련 디렉토리 등으로 분산되어 있다
132+
- `src/features/` 기반 구조와 전통적인 `components/`, `hooks/queries/` 구조가 공존한다. 신규 변경 시 xkdl 한 PR 안에서 구조를 섞어 바꾸지 않는다
132133

133134
### 스타일링
134135

@@ -145,6 +146,122 @@ export const getArchive = async (params: GetArchiveParams) => {
145146
3. Axios 인터셉터가 `AUTH001` 에러 감지 → 토큰 갱신 → 실패한 요청 재시도 (중복 갱신 방지를 위한 큐 사용)
146147
4. 미들웨어가 서버 측에서 네비게이션 시 토큰 검증, 유효하지 않으면 `/`로 리다이렉트
147148

149+
### 에러 핸들링
150+
151+
에러 처리는 `src/utils/error-handler.ts`를 중심으로 중앙 집중식 관리한다. `src/utils/error.ts``extractErrorCode()` 하위 호환성용 deprecated 래퍼다.
152+
153+
#### 핵심 파일
154+
155+
- `src/utils/error-handler.ts``analyzeError()`, `logError()`, `ErrorType`, `ErrorInfo`. 에러 코드-메시지 매핑(~40개), 한국어 fallback, Sentry 보고를 모두 담당
156+
- `src/config/query-client.ts``MutationCache` 글로벌 에러 핸들러. `onError`가 없는 mutation 실패 시 자동으로 에러 toast + Sentry 보고
157+
- `src/app/(service)/error.tsx`, `(landing)/error.tsx`, `(admin)/error.tsx` — route segment 에러 경계
158+
- `src/app/global-error.tsx` — root 에러 경계 (Sentry 자동 캡처)
159+
- `src/app/not-found.tsx` 및 각 route group의 `not-found.tsx`
160+
161+
#### 에러 분류 체계
162+
163+
`analyzeError()`는 3가지 에러 타입을 순서대로 분류한다:
164+
165+
1. **AxiosError**`isAxiosError()` 통과. HTTP 상태 코드 + API 에러 응답 추출
166+
2. **ApiError** — axios 인터셉터가 변환한 커스텀 에러. `isApiError()` 타입 가드로 `errorCode`, `statusCode` 보존
167+
3. **일반 Error / unknown** — fallback 처리
168+
169+
```
170+
AxiosError → isAxiosError() ✅ → HTTP 상태/에러 코드 추출
171+
ApiError → isApiError() ✅ → errorCode/statusCode 보존 (인터셉터 변환 에러)
172+
Error → instanceof Error → UNKNOWN 타입
173+
unknown → String(error) → 기본 메시지
174+
```
175+
176+
#### 에러 코드-메시지 매핑
177+
178+
`error-handler.ts``codeMessages` 객체에서 중앙 관리. 에러 코드 prefix별 분류:
179+
180+
| Prefix | 도메인 | 예시 |
181+
|--------|--------|------|
182+
| AUTH | 인증 | AUTH001(토큰 만료), AUTH002(권한 없음) |
183+
| CMM | 공통 | CMM001(입력값 오류), CMM006(접근 권한) |
184+
| MEM | 회원 | MEM002(회원 미존재), MEM003(중복 가입) |
185+
| GSM/GSA | 스터디 관리/신청 | GSM001(스터디 미존재), GSA003(정원 초과) |
186+
| HWK/EVL | 과제/평가 | HWK003(제출 기간 만료), EVL002(중복 평가) |
187+
| PAY 2xx | 결제 | PAY202(중복 승인), PAY207(금액 불일치) |
188+
| PAY 3xx | 환불 | PAY302(중복 환불), PAY307(환불 불가) |
189+
| FILE | 파일 | FILE001(업로드 실패), FILE002(형식 미지원) |
190+
191+
매핑에 없는 코드는 백엔드 `message`가 한국어이면 그대로 사용(`/[가-힣]/` 정규식). 에러 코드를 사용자에게 직접 노출하지 않는다.
192+
193+
#### Mutation 에러 글로벌 핸들러
194+
195+
`query-client.ts``MutationCache.onError`가 안전망 역할:
196+
197+
- `onError` 핸들러가 없는 mutation 실패 시 자동으로 에러 toast 표시 + Sentry 보고
198+
- 개별 `onError`가 있으면 글로벌 핸들러 스킵 (중복 방지)
199+
- Query 에러는 글로벌 핸들러 미적용 (다중 동시 실패 시 toast 폭주 방지)
200+
201+
#### 클라이언트 에러 처리 원칙
202+
203+
- 복구 가능한 실패 (`recoverable`): 사용자 흐름을 유지한다. Inline error를 우선하고, Toast를 보조적으로 사용한다. **브라우저 시스템 `alert()`는 사용하지 않는다** — Toast(`useToastStore`)를 사용한다
204+
- 사용자 판단이 필요한 실패 (`action required`): 다음 행동을 선택해야 할 때 Modal 또는 앱 내 확인 UI를 사용한다. 브라우저 시스템 `alert()`는 사용하지 않으며 기존의 디자인 시스템을 활용한다
205+
- 치명적 실패 (`fatal`, page-level): 특정 화면이 더 이상 정상 동작할 수 없을 때 route segment의 `error.tsx` 또는 client error boundary를 사용한다
206+
- 애플리케이션 전체 실패 (`critical`, app-level): hydration mismatch, 인증 컨텍스트 붕괴, 전역 provider 오류처럼 앱 전체에 영향을 주는 경우 `global-error.tsx`가 잡고 Sentry로 자동 보고
207+
208+
Toast 사용 패턴:
209+
210+
```typescript
211+
// 컴포넌트 내부 (React hook 사용)
212+
const showToast = useToastStore((state) => state.showToast);
213+
showToast('환불 요청이 접수되었습니다.', 'success');
214+
215+
// Hook / React 외부 (getState 사용)
216+
useToastStore.getState().showToast(errorInfo.userMessage, 'error');
217+
```
218+
219+
`<GlobalToast />``(service)`, `(landing)`, `(admin)` 세 레이아웃 모두에 마운트되어 있다.
220+
221+
#### 서버 에러 처리 원칙
222+
223+
- SSR/Server Component에서 필수 데이터 로딩에 실패해 페이지가 성립하지 않으면 예외를 다시 던져 `error.tsx`로 보낸다
224+
- 리소스가 존재하지 않는 케이스는 `notFound()`로 분기한다
225+
- `fetchQuery()` / `prefetchQuery()``queryFn``undefined`를 반환하면 안 된다. 404는 `notFound()`, 그 외는 반드시 `throw error`
226+
227+
예: `src/api/endpoints/group-study/get-group-study-detail.server.ts``GSM001`이면 `notFound()`, 나머지 에러는 `throw error` 한다
228+
229+
```typescript
230+
export default async function Page() {
231+
const data = await fetchData();
232+
return <PageView data={data} />;
233+
}
234+
```
235+
236+
불필요한 `try/catch`로 에러를 삼켜 `undefined`를 반환하지 않는다.
237+
238+
#### 운영 보안 원칙
239+
240+
- Production에서는 `stack trace`, 원문 서버 메시지, 내부 경로, 민감한 백엔드 응답을 사용자 화면에 직접 노출하지 않는다
241+
- 3개 `error.tsx` 모두 `process.env.NODE_ENV === 'development'` 게이팅 적용: technicalMessage, error.message, error.stack은 개발 환경에서만 표시
242+
- 사용자 화면에는 일반화된 `userMessage`, 필요 시 `errorCode`, `statusCode`, `digest` 정도만 노출한다
243+
- `digest`는 서버 로그 또는 Sentry에서 원인을 찾기 위한 추적용 식별자로 사용한다
244+
- API route에서도 production 응답에는 상세 `details`를 그대로 넣지 않는 방향을 기본 원칙으로 삼는다
245+
246+
#### 성공 페이지 원칙
247+
248+
- 스터디 생성, 스터디 참여, 결제 완료 같은 주요 성공 이벤트는 별도 success page 또는 완료 화면으로 사용자의 다음 행동을 명확히 안내한다
249+
- 브랜딩 요소는 환영 문구, 운영팀 메시지, 후속 행동 CTA 중심으로 넣고, 정보량이 많은 경우에도 핵심 CTA를 먼저 보이게 한다
250+
251+
#### 모니터링 (Sentry)
252+
253+
`@sentry/nextjs`가 통합되어 있다. 에러는 `logError()``Sentry.captureException()` 경로로 자동 보고된다.
254+
255+
- **SDK 설정 파일**: `sentry.client.config.ts`, `sentry.server.config.ts`, `sentry.edge.config.ts` (프로젝트 루트)
256+
- **Next.js instrumentation**: `src/instrumentation.ts` — 서버/엣지 런타임 초기화 + `onRequestError` 자동 캡처
257+
- **next.config.ts**: `withSentryConfig()` 래핑 — 소스맵 업로드, 트리셰이킹
258+
- **환경 변수**: `NEXT_PUBLIC_SENTRY_DSN` (런타임), `SENTRY_ORG` / `SENTRY_PROJECT` / `SENTRY_AUTH_TOKEN` (CI 소스맵 업로드)
259+
- **환경 감지**: `NEXT_PUBLIC_API_BASE_URL` 기반으로 `production` / `staging` / `development` 자동 분류
260+
- **필터링**: AUTH001(토큰 만료)은 정상 플로우이므로 `beforeSend`에서 Sentry 보고 제외
261+
- **성능**: `tracesSampleRate: 0.1` (10%), Session Replay는 에러 시에만 기록 (`replaysOnErrorSampleRate: 1.0`)
262+
- DSN이 없으면 Sentry가 초기화되지 않으므로, 로컬 개발에서는 환경 변수 없이도 정상 동작한다
263+
- 운영 단계에서는 Slack 즉시 알림을 연동할 수 있지만, 노이즈를 줄이기 위해 임계치와 대상 에러 범위를 먼저 정의한다
264+
148265
### 경로 별칭
149266

150267
`@/*``./src/*`에 매핑됨 (tsconfig.json에서 설정)
@@ -173,3 +290,7 @@ export const getArchive = async (params: GetArchiveParams) => {
173290
- `NEXT_PUBLIC_TOSS_CLIENT_KEY` — 토스페이먼츠
174291
- `NEXT_PUBLIC_CLARITY_PROJECT_ID` — Microsoft Clarity
175292
- `NEXT_PUBLIC_GTM_ID` — Google Tag Manager
293+
- `NEXT_PUBLIC_SENTRY_DSN` — Sentry DSN (없으면 Sentry 비활성화)
294+
- `SENTRY_ORG` — Sentry 조직 (CI 소스맵 업로드용)
295+
- `SENTRY_PROJECT` — Sentry 프로젝트 (CI 소스맵 업로드용)
296+
- `SENTRY_AUTH_TOKEN` — Sentry 인증 토큰 (CI 소스맵 업로드용)

next.config.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import bundleAnalyzer from '@next/bundle-analyzer';
2+
import { withSentryConfig } from '@sentry/nextjs';
23
import type { NextConfig } from 'next';
34
import type { RemotePattern } from 'next/dist/shared/lib/image-config';
45

@@ -17,15 +18,16 @@ const nextConfig: NextConfig = {
1718
const cspDirectives = [
1819
"default-src 'self'",
1920
// GTM, Clarity, 토스페이먼츠 스크립트 허용. 개발 환경에서는 'unsafe-eval' 추가(Next.js HMR 필요).
20-
`script-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://www.clarity.ms https://js.tosspayments.com${isDev ? " 'unsafe-eval'" : ''}`,
21+
`script-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://www.clarity.ms https://js.tosspayments.com https://browser.sentry-cdn.com${isDev ? " 'unsafe-eval'" : ''}`,
2122
"style-src 'self' 'unsafe-inline'",
2223
// 카카오·구글 프로필 이미지, 자사 API/CMS 이미지 도메인 허용
2324
"img-src 'self' data: blob: https://img1.kakaocdn.net https://lh3.googleusercontent.com https://api.zeroone.it.kr https://test-api.zeroone.it.kr https://www.zeroone.it.kr https://test-blog.zeroone.it.kr",
2425
// API, GA, Clarity, 토스, 카카오/구글 OAuth 연결 허용. 개발에서는 HMR WebSocket 추가.
25-
`connect-src 'self' https://api.zeroone.it.kr https://test-api.zeroone.it.kr https://www.google-analytics.com https://www.clarity.ms https://api.tosspayments.com https://kauth.kakao.com https://accounts.google.com${isDev ? ' ws://localhost:*' : ''}`,
26+
`connect-src 'self' https://api.zeroone.it.kr https://test-api.zeroone.it.kr https://www.google-analytics.com https://www.clarity.ms https://api.tosspayments.com https://kauth.kakao.com https://accounts.google.com https://*.ingest.sentry.io${isDev ? ' ws://localhost:*' : ''}`,
2627
// 토스 결제창 iframe 허용
2728
'frame-src https://pay.toss.im https://cert.tosspayments.com',
2829
"font-src 'self' data:",
30+
"worker-src 'self' blob:",
2931
].join('; ');
3032

3133
return [
@@ -154,4 +156,16 @@ const nextConfig: NextConfig = {
154156
},
155157
};
156158

157-
export default withBundleAnalyzer(nextConfig);
159+
const sentryConfig = withSentryConfig(nextConfig, {
160+
org: process.env.SENTRY_ORG,
161+
project: process.env.SENTRY_PROJECT,
162+
authToken: process.env.SENTRY_AUTH_TOKEN,
163+
silent: !process.env.SENTRY_AUTH_TOKEN,
164+
disableLogger: true,
165+
sourcemaps: {
166+
filesToDeleteAfterUpload: ['.next/static/**/*.map'],
167+
},
168+
widenClientFileUpload: true,
169+
});
170+
171+
export default withBundleAnalyzer(sentryConfig);

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"@radix-ui/react-switch": "^1.2.4",
4040
"@radix-ui/react-toggle": "^1.1.9",
4141
"@radix-ui/react-tooltip": "^1.2.8",
42+
"@sentry/nextjs": "^10.42.0",
4243
"@tailwindcss/postcss": "^4.0.6",
4344
"@tiptap/extension-code-block-lowlight": "^3.20.0",
4445
"@tiptap/extension-image": "^3.20.0",

sentry.client.config.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import * as Sentry from '@sentry/nextjs';
2+
3+
function detectEnvironment(): string {
4+
const apiUrl = process.env.NEXT_PUBLIC_API_BASE_URL ?? '';
5+
if (apiUrl.includes('api.zeroone.it.kr') && !apiUrl.includes('test-api'))
6+
return 'production';
7+
if (apiUrl.includes('test-api')) return 'staging';
8+
9+
return 'development';
10+
}
11+
12+
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN;
13+
14+
if (dsn) {
15+
Sentry.init({
16+
dsn,
17+
environment: detectEnvironment(),
18+
19+
// 성능 트레이싱: 10%만 샘플링 (비용 제어)
20+
tracesSampleRate: 0.1,
21+
22+
// Session Replay: 에러 발생 시에만 기록 (비용 제어)
23+
replaysSessionSampleRate: 0,
24+
replaysOnErrorSampleRate: 1.0,
25+
integrations: [Sentry.replayIntegration()],
26+
27+
beforeSend(event) {
28+
// AUTH001(토큰 만료)은 정상 플로우이므로 Sentry에 보고하지 않음
29+
const errorCode = event.extra?.errorCode as string | undefined;
30+
if (errorCode === 'AUTH001') return null;
31+
32+
return event;
33+
},
34+
});
35+
}

sentry.edge.config.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import * as Sentry from '@sentry/nextjs';
2+
3+
function detectEnvironment(): string {
4+
const apiUrl = process.env.NEXT_PUBLIC_API_BASE_URL ?? '';
5+
if (apiUrl.includes('api.zeroone.it.kr') && !apiUrl.includes('test-api'))
6+
return 'production';
7+
if (apiUrl.includes('test-api')) return 'staging';
8+
9+
return 'development';
10+
}
11+
12+
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN;
13+
14+
if (dsn) {
15+
Sentry.init({
16+
dsn,
17+
environment: detectEnvironment(),
18+
tracesSampleRate: 0.1,
19+
20+
beforeSend(event) {
21+
const errorCode = event.extra?.errorCode as string | undefined;
22+
if (errorCode === 'AUTH001') return null;
23+
24+
return event;
25+
},
26+
});
27+
}

sentry.server.config.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import * as Sentry from '@sentry/nextjs';
2+
3+
function detectEnvironment(): string {
4+
const apiUrl = process.env.NEXT_PUBLIC_API_BASE_URL ?? '';
5+
if (apiUrl.includes('api.zeroone.it.kr') && !apiUrl.includes('test-api'))
6+
return 'production';
7+
if (apiUrl.includes('test-api')) return 'staging';
8+
9+
return 'development';
10+
}
11+
12+
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN;
13+
14+
if (dsn) {
15+
Sentry.init({
16+
dsn,
17+
environment: detectEnvironment(),
18+
tracesSampleRate: 0.1,
19+
20+
beforeSend(event) {
21+
const errorCode = event.extra?.errorCode as string | undefined;
22+
if (errorCode === 'AUTH001') return null;
23+
24+
return event;
25+
},
26+
});
27+
}

0 commit comments

Comments
 (0)