diff --git a/.github/scripts/sentry-daily-digest.sh b/.github/scripts/sentry-daily-digest.sh
index 6e7fa458..5814f011 100644
--- a/.github/scripts/sentry-daily-digest.sh
+++ b/.github/scripts/sentry-daily-digest.sh
@@ -12,10 +12,11 @@ if [ "$DRY_RUN" != "1" ]; then
fi
case "$SENTRY_ENV" in
- production) LABEL="[WEB·PROD]" ;;
- staging) LABEL="[WEB·STG]" ;;
- *) LABEL="[WEB·$(printf '%s' "$SENTRY_ENV" | tr '[:lower:]' '[:upper:]')]" ;;
+ production) STAGE="PROD" ;;
+ staging) STAGE="STG" ;;
+ *) STAGE=$(printf '%s' "$SENTRY_ENV" | tr '[:lower:]' '[:upper:]') ;;
esac
+LABEL="[${SENTRY_PLATFORM:-WEB}·$STAGE]"
API="https://sentry.io/api/0"
WORK=$(mktemp -d)
diff --git a/.github/workflows/sentry-daily-digest.yml b/.github/workflows/sentry-daily-digest.yml
index 0910edd6..0ce79ef6 100644
--- a/.github/workflows/sentry-daily-digest.yml
+++ b/.github/workflows/sentry-daily-digest.yml
@@ -24,11 +24,24 @@ jobs:
sparse-checkout: .github/scripts
persist-credentials: false
- - name: Sentry 요약 전송 (허거덩 봇)
+ - name: 웹 Sentry 요약 전송 (허거덩 봇)
env:
SENTRY_TOKEN: ${{ secrets.SENTRY_READ_TOKEN }}
SENTRY_ORG: piki-92
SENTRY_PROJECT: piki-web
+ SENTRY_PLATFORM: WEB
+ SENTRY_ENV: production
+ DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }}
+ CHANNEL_ID: ${{ secrets.DISCORD_SENTRY_CHANNEL_ID }}
+ run: bash .github/scripts/sentry-daily-digest.sh
+
+ - name: 앱 Sentry 요약 전송 (허거덩 봇)
+ if: ${{ !cancelled() }}
+ env:
+ SENTRY_TOKEN: ${{ secrets.SENTRY_READ_TOKEN }}
+ SENTRY_ORG: piki-92
+ SENTRY_PROJECT: piki-app
+ SENTRY_PLATFORM: APP
SENTRY_ENV: production
DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }}
CHANNEL_ID: ${{ secrets.DISCORD_SENTRY_CHANNEL_ID }}
diff --git a/apps/web/instrumentation.ts b/apps/web/instrumentation.ts
index 21051d4a..d7434563 100644
--- a/apps/web/instrumentation.ts
+++ b/apps/web/instrumentation.ts
@@ -1,8 +1,12 @@
import * as Sentry from '@sentry/nextjs';
+import { isAxiosError } from 'axios';
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') await import('./sentry.server.config');
if (process.env.NEXT_RUNTIME === 'edge') await import('./sentry.edge.config');
}
-export const onRequestError = Sentry.captureRequestError;
+export const onRequestError: typeof Sentry.captureRequestError = (error, request, errorContext) => {
+ if (isAxiosError(error)) return;
+ Sentry.captureRequestError(error, request, errorContext);
+};
diff --git a/apps/web/src/apis/client.ts b/apps/web/src/apis/client.ts
index 6396f10b..2c1edc75 100644
--- a/apps/web/src/apis/client.ts
+++ b/apps/web/src/apis/client.ts
@@ -8,7 +8,7 @@ import { QUERY_ACTION } from '@/consts/queryAction';
import { ROUTES } from '@/consts/route';
import { CLIENT_TYPE } from '@/consts/webBridge';
import type { ApiErrorResponseT } from '@/types/api';
-import { captureError } from '@/utils/captureError';
+import { captureApiError } from '@/utils/captureError';
import { deleteCookie, getCookie } from '@/utils/cookie';
import { handleSessionExpired } from '@/utils/handleSessionExpired';
import { getLoginPath } from '@/utils/loginRedirect';
@@ -82,31 +82,7 @@ clientApi.interceptors.response.use(
return Promise.reject(error);
}
- /** 5xx·네트워크 오류만 수집 (4xx는 예상된 흐름이라 제외) */
- const status = error.response?.status;
- const shouldReport =
- error.code !== 'ERR_CANCELED' &&
- (error.code === 'ERR_NETWORK' || (typeof status === 'number' && status >= 500));
-
- if (shouldReport) {
- const method = originalRequest?.method?.toUpperCase() ?? 'UNKNOWN';
- const path = originalRequest?.url?.split('?')[0] ?? 'unknown';
-
- /** 디코/대시보드 제목을 알아보기 쉽게 (원본 axios 정보는 extra 유지) */
- const apiError = new Error(`API ${status ?? error.code} ${method} ${path}`);
- apiError.name = 'ApiError';
-
- captureError(apiError, {
- tags: { source: 'api-client' },
- extra: {
- url: originalRequest?.url,
- method: originalRequest?.method,
- status,
- code: error.code,
- apiCode: error.response?.data?.code,
- },
- });
- }
+ captureApiError(error, 'api-client');
return Promise.reject(error);
}
diff --git a/apps/web/src/apis/server.ts b/apps/web/src/apis/server.ts
index f31a7c0a..2207a6f3 100644
--- a/apps/web/src/apis/server.ts
+++ b/apps/web/src/apis/server.ts
@@ -6,7 +6,7 @@ import { ENDPOINTS } from '@/consts/api';
import { QUERY_ACTION } from '@/consts/queryAction';
import { CLIENT_TYPE } from '@/consts/webBridge';
import type { ApiErrorResponseT } from '@/types/api';
-import { captureError } from '@/utils/captureError';
+import { captureApiError } from '@/utils/captureError';
import { getLoginPath } from '@/utils/loginRedirect';
import { isWebview } from '@/utils/webBridge';
@@ -59,30 +59,7 @@ serverApi.interceptors.response.use(
redirect(getLoginPath(null, QUERY_ACTION.VALUE.WITHDRAWN_ACCOUNT));
}
- /** 5xx·네트워크 오류만 수집 (4xx는 예상된 흐름이라 제외) */
- const status = error.response?.status;
- const shouldReport =
- error.code !== 'ERR_CANCELED' &&
- (error.code === 'ERR_NETWORK' || (typeof status === 'number' && status >= 500));
-
- if (shouldReport) {
- const method = error.config?.method?.toUpperCase() ?? 'UNKNOWN';
- const path = error.config?.url?.split('?')[0] ?? 'unknown';
-
- const apiError = new Error(`API ${status ?? error.code} ${method} ${path}`);
- apiError.name = 'ApiError';
-
- captureError(apiError, {
- tags: { source: 'api-server' },
- extra: {
- url: error.config?.url,
- method: error.config?.method,
- status,
- code: error.code,
- apiCode: error.response?.data?.code,
- },
- });
- }
+ captureApiError(error, 'api-server');
return Promise.reject(error);
}
diff --git a/apps/web/src/app/error.tsx b/apps/web/src/app/error.tsx
index c563c821..3a410b4a 100644
--- a/apps/web/src/app/error.tsx
+++ b/apps/web/src/app/error.tsx
@@ -1,6 +1,5 @@
'use client';
-import * as Sentry from '@sentry/nextjs';
import Link from 'next/link';
import { useEffect } from 'react';
@@ -8,6 +7,7 @@ import { WarningIconFill } from '@/assets/icons';
import { buttonStyles } from '@/components/button/button.style';
import ErrorScreen from '@/components/common/error-screen';
import { ROUTES } from '@/consts/route';
+import { captureNonApiError } from '@/utils/captureError';
import { getApiErrorMessage } from '@/utils/getApiErrorMessage';
type Props = {
@@ -17,7 +17,7 @@ type Props = {
function Error({ error, reset }: Props) {
useEffect(() => {
- Sentry.captureException(error);
+ captureNonApiError(error);
}, [error]);
const description = getApiErrorMessage(error);
diff --git a/apps/web/src/app/global-error.tsx b/apps/web/src/app/global-error.tsx
index b2dd8e52..eb07ae99 100644
--- a/apps/web/src/app/global-error.tsx
+++ b/apps/web/src/app/global-error.tsx
@@ -1,6 +1,5 @@
'use client';
-import * as Sentry from '@sentry/nextjs';
import Link from 'next/link';
import { useEffect } from 'react';
@@ -8,6 +7,7 @@ import { WarningIconFill } from '@/assets/icons';
import { buttonStyles } from '@/components/button/button.style';
import { ROUTES } from '@/consts/route';
import '@/styles/globals.css';
+import { captureNonApiError } from '@/utils/captureError';
type Props = {
error: Error & { digest?: string };
@@ -16,7 +16,7 @@ type Props = {
export default function GlobalError({ error, reset }: Props) {
useEffect(() => {
- Sentry.captureException(error);
+ captureNonApiError(error);
}, [error]);
return (
@@ -28,7 +28,7 @@ export default function GlobalError({ error, reset }: Props) {
오류가 발생했어요
-
+
잠시 후 다시 시도해 주세요
diff --git a/apps/web/src/hooks/useFcmTokenSync.ts b/apps/web/src/hooks/useFcmTokenSync.ts
index 8cbdab0f..306a17bd 100644
--- a/apps/web/src/hooks/useFcmTokenSync.ts
+++ b/apps/web/src/hooks/useFcmTokenSync.ts
@@ -5,7 +5,7 @@ import { useCallback, useEffect } from 'react';
import { postFcmToken } from '@/apis/postFcmToken';
import { useWebBridgeMessage } from '@/hooks/useWebBridgeMessage';
-import { captureError } from '@/utils/captureError';
+import { captureNonApiError } from '@/utils/captureError';
import { getCookie } from '@/utils/cookie';
import { WebBridge, isWebview as isWebviewFn } from '@/utils/webBridge';
@@ -24,15 +24,11 @@ export const useFcmTokenSync = () => {
message.type === WEBBRIDGE_MESSAGE_TYPE.APP_RES_PUSH_PERMISSION_STATUS &&
message.payload.token
) {
- postFcmToken(message.payload.token, message.payload.deviceId).catch(error =>
- captureError(error, { tags: { source: 'fcm-token-sync' } })
- );
+ postFcmToken(message.payload.token, message.payload.deviceId).catch(captureNonApiError);
}
if (message.type === WEBBRIDGE_MESSAGE_TYPE.APP_RES_FCM_TOKEN) {
- postFcmToken(message.payload.token, message.payload.deviceId).catch(error =>
- captureError(error, { tags: { source: 'fcm-token-sync' } })
- );
+ postFcmToken(message.payload.token, message.payload.deviceId).catch(captureNonApiError);
}
}, [])
);
diff --git a/apps/web/src/utils/captureError.test.ts b/apps/web/src/utils/captureError.test.ts
new file mode 100644
index 00000000..574748ff
--- /dev/null
+++ b/apps/web/src/utils/captureError.test.ts
@@ -0,0 +1,61 @@
+import * as Sentry from '@sentry/nextjs';
+import type { AxiosResponse, InternalAxiosRequestConfig } from 'axios';
+import { AxiosError } from 'axios';
+import { describe, expect, it, vi } from 'vitest';
+
+import type { ApiErrorResponseT } from '@/types/api';
+
+import { captureApiError } from './captureError';
+
+vi.mock('@sentry/nextjs', () => ({ captureException: vi.fn() }));
+
+const config = { method: 'post', url: '/api/v1/wishes/123?x=1' } as InternalAxiosRequestConfig;
+
+const axiosError = ({ code, status }: { code?: string; status?: number }) => {
+ const response = status
+ ? ({ status, data: { data: null, code: 'WISH-004' } } as AxiosResponse)
+ : void 0;
+
+ return new AxiosError('failed', code, config, void 0, response);
+};
+
+const captured = () => vi.mocked(Sentry.captureException).mock.calls;
+
+describe('captureApiError', () => {
+ it('5xx 는 메서드·status·경로로 ApiError 를 보낸다', () => {
+ vi.mocked(Sentry.captureException).mockClear();
+
+ captureApiError(axiosError({ code: 'ERR_BAD_RESPONSE', status: 500 }), 'api-client');
+
+ const [error, context] = captured()[0] ?? [];
+ expect((error as Error).name).toBe('ApiError');
+ expect((error as Error).message).toBe('API 500 POST /api/v1/wishes/123');
+ expect(context).toMatchObject({
+ tags: { source: 'api-client', errorType: 'server' },
+ fingerprint: ['api', 'POST', '500', '/api/v1/wishes/:id'],
+ });
+ });
+
+ it('응답 없는 서버 네트워크 오류는 ERR_NETWORK 가 아니어도 보낸다', () => {
+ vi.mocked(Sentry.captureException).mockClear();
+
+ captureApiError(axiosError({ code: 'ECONNREFUSED' }), 'api-server');
+ captureApiError(axiosError({ code: 'ECONNABORTED' }), 'api-client');
+
+ expect(captured().map(([error]) => (error as Error).message)).toEqual([
+ 'API ECONNREFUSED POST /api/v1/wishes/123',
+ 'API ECONNABORTED POST /api/v1/wishes/123',
+ ]);
+ expect(captured()[0]?.[1]).toMatchObject({ tags: { errorType: 'network' } });
+ });
+
+ it('4xx 와 취소된 요청은 보내지 않는다', () => {
+ vi.mocked(Sentry.captureException).mockClear();
+
+ captureApiError(axiosError({ code: 'ERR_BAD_REQUEST', status: 401 }), 'api-client');
+ captureApiError(axiosError({ code: 'ERR_BAD_REQUEST', status: 404 }), 'api-client');
+ captureApiError(axiosError({ code: 'ERR_CANCELED' }), 'api-client');
+
+ expect(captured()).toHaveLength(0);
+ });
+});
diff --git a/apps/web/src/utils/captureError.ts b/apps/web/src/utils/captureError.ts
index 3f9799cd..4b13f9a5 100644
--- a/apps/web/src/utils/captureError.ts
+++ b/apps/web/src/utils/captureError.ts
@@ -1,10 +1,15 @@
import * as Sentry from '@sentry/nextjs';
+import type { AxiosError } from 'axios';
+import { isAxiosError } from 'axios';
+
+import type { ApiErrorResponseT } from '@/types/api';
type CaptureErrorContextT = {
/** 검색/그룹핑용 태그 (예: { source: 'api-client' }) */
tags?: Record;
/** 디버깅용 부가 정보 (요청 URL, status 등) */
extra?: Record;
+ fingerprint?: string[];
};
/** 잡은(caught) 에러를 Sentry에 수동 리포트 — 태그/컨텍스트를 일관되게 부착 */
@@ -12,5 +17,44 @@ export const captureError = (error: unknown, context?: CaptureErrorContextT) =>
Sentry.captureException(error, {
tags: context?.tags,
extra: context?.extra,
+ fingerprint: context?.fingerprint,
+ });
+};
+
+const isServerOriginError = (error: unknown) =>
+ error instanceof Error && 'digest' in error && typeof error.digest === 'string';
+
+/** API(axios) 에러는 인터셉터(apis/client.ts·server.ts)가 5xx·네트워크만 단독 수집 — 여기선 그 외 에러만 보낸다 */
+export const captureNonApiError = (error: unknown) => {
+ if (isAxiosError(error) || isServerOriginError(error)) return;
+ Sentry.captureException(error);
+};
+
+/** fingerprint 용 — 리소스 ID 가 달라도 같은 엔드포인트면 한 이슈로 묶는다 */
+export const normalizeApiPath = (path: string) =>
+ path.replace(/\/\d+(?=\/|$)/g, '/:id').replace(/\/[0-9a-f]{8}-[0-9a-f-]{27}(?=\/|$)/gi, '/:id');
+
+/** 5xx 와 응답 없는 네트워크 오류만 수집 */
+export const captureApiError = (error: AxiosError, source: string) => {
+ const status = error.response?.status;
+ if (error.code === 'ERR_CANCELED' || (typeof status === 'number' && status < 500)) return;
+
+ const method = error.config?.method?.toUpperCase() ?? 'UNKNOWN';
+ const path = error.config?.url?.split('?')[0] ?? 'unknown';
+ const reason = String(status ?? error.code ?? 'NETWORK');
+
+ const apiError = new Error(`API ${reason} ${method} ${path}`);
+ apiError.name = 'ApiError';
+
+ captureError(apiError, {
+ tags: { source, errorType: status ? 'server' : 'network' },
+ fingerprint: ['api', method, reason, normalizeApiPath(path)],
+ extra: {
+ url: error.config?.url,
+ method: error.config?.method,
+ status,
+ code: error.code,
+ apiCode: error.response?.data?.code,
+ },
});
};
diff --git a/apps/web/src/utils/queryClient.ts b/apps/web/src/utils/queryClient.ts
index 233fdc5f..59d8c62e 100644
--- a/apps/web/src/utils/queryClient.ts
+++ b/apps/web/src/utils/queryClient.ts
@@ -1,4 +1,3 @@
-import * as Sentry from '@sentry/nextjs';
import {
MutationCache,
QueryCache,
@@ -15,13 +14,14 @@ import {
isServerOrNetworkError,
isWithdrawnAccountError,
} from '@/utils/apiError';
+import { captureNonApiError } from '@/utils/captureError';
import { getApiErrorMessage } from '@/utils/getApiErrorMessage';
/** REF: https://tanstack.com/query/latest/docs/framework/react/guides/advanced-ssr */
const makeQueryClient = () => {
return new QueryClient({
- /** mutation — 사용자 액션 실패: 토스트 + (Sentry) 로깅 */
+ /** mutation — 사용자 액션 실패: 토스트 + (API 외 에러만) Sentry 로깅 */
mutationCache: new MutationCache({
onError: (error, _variables, _context, mutation) => {
/** 401·탈퇴 계정은 전역 인터셉터(apis/client.ts)가 refresh·redirect로 처리 → 이중 토스트 방지 */
@@ -30,7 +30,7 @@ const makeQueryClient = () => {
/** 5xx·네트워크는 전역이 단독 처리 (개별 onError는 5xx를 건드리지 않음) */
if (isServerOrNetworkError(error)) {
toast.error(getApiErrorMessage(error));
- Sentry.captureException(error);
+ captureNonApiError(error);
return;
}
@@ -39,11 +39,9 @@ const makeQueryClient = () => {
toast.error(getApiErrorMessage(error));
},
}),
- /** query — 데이터 fetch 실패: 토스트 X, 로깅만 (표시는 boundary/isError가 담당) */
+ /** query — 데이터 fetch 실패: 토스트 X, API 외 에러만 로깅 (표시는 boundary/isError가 담당) */
queryCache: new QueryCache({
- onError: _error => {
- Sentry.captureException(_error);
- },
+ onError: captureNonApiError,
}),
defaultOptions: {
queries: {
diff --git a/apps/web/src/utils/refreshClientToken.ts b/apps/web/src/utils/refreshClientToken.ts
index df3f3782..04572cf3 100644
--- a/apps/web/src/utils/refreshClientToken.ts
+++ b/apps/web/src/utils/refreshClientToken.ts
@@ -1,8 +1,9 @@
import { type AuthTokensT, WEBBRIDGE_MESSAGE_TYPE } from '@piki/core';
-import axios from 'axios';
+import axios, { isAxiosError } from 'axios';
import { ENDPOINTS } from '@/consts/api';
import { CLIENT_TYPE } from '@/consts/webBridge';
+import { captureApiError } from '@/utils/captureError';
import { setCookie } from './cookie';
import { WebBridge, isWebview } from './webBridge';
@@ -60,9 +61,14 @@ const performRefresh = async (): Promise => {
export const refreshClientToken = (): Promise => {
if (inflightRefresh) return inflightRefresh;
- inflightRefresh = performRefresh().finally(() => {
- inflightRefresh = null;
- });
+ inflightRefresh = performRefresh()
+ .catch(error => {
+ if (isAxiosError(error)) captureApiError(error, 'api-client');
+ throw error;
+ })
+ .finally(() => {
+ inflightRefresh = null;
+ });
return inflightRefresh;
};
diff --git a/docs/spec/error-handling-policy.md b/docs/spec/error-handling-policy.md
index b20e2788..ac546db8 100644
--- a/docs/spec/error-handling-policy.md
+++ b/docs/spec/error-handling-policy.md
@@ -7,7 +7,7 @@
## TL;DR
- **4xx (클라이언트/비즈니스 오류)** → **개별(호출부) 처리.** 원인별로 사용자 행동이 달라지므로 맞춤 UX.
-- **5xx · 네트워크 오류** → **전역 처리.** 사용자가 할 수 있는 게 없으므로 공통 "일시적 오류" 토스트 + Sentry.
+- **5xx · 네트워크 오류** → **전역 처리.** 사용자가 할 수 있는 게 없으므로 공통 "일시적 오류" 토스트 + Sentry(인터셉터 단독 수집).
- **401 (인증 만료)** → **전역 인터셉터**가 토큰 refresh 후 자동 재시도, 실패 시 로그인 redirect.
- **인증/권한 게이팅** → **layout 가드(SSR)** 가 진입 시점에 차단.
- **조용히 실패 금지** → 개별 onError가 없어도 **전역 안전망**이 최소 토스트를 보장.
@@ -19,9 +19,10 @@
```text
① 전역 인터셉터 apis/client.ts → 401 자동 refresh / 재시도 / 로그인 redirect
+ → 5xx·네트워크 Sentry 수집 (API 에러의 유일한 수집 창구)
② Layout 가드 app/**/layout.tsx (SSR) → 진입 시점 인증·권한 차단 (getMe 등)
③ 전역 MutationCache onError utils/queryClient.ts
- → 5xx·네트워크 공통 처리 (+ Sentry)
+ → 5xx·네트워크 공통 토스트
→ 4xx인데 개별 처리 없으면 generic 토스트
④ 개별 onError 각 use*Mutation 훅 → 4xx 맞춤 UX (토스트/이동/롤백/전용 다이얼로그)
⑤ Error boundary app/error.tsx / global-error.tsx
@@ -44,7 +45,7 @@
| **404** | 리소스 없음 | **layout ② or 개별 ④** | 진입 게이팅은 layout · 액션 실패는 개별 |
| **409** | 충돌(만료/중복/이미 시작) | **개별** | 전용 다이얼로그/안내 (원인별 UX가 갈리면 `code` 2차 분기 — 아래 참고) |
| **413** | 용량 초과 | **개별 (+ 사전검증)** | 업로드 전 클라 용량 체크 우선 |
-| **5xx · 네트워크** | 서버/통신 오류 | **전역 MutationCache ③** | 공통 "일시적 오류" 토스트 + Sentry |
+| **5xx · 네트워크** | 서버/통신 오류 | **전역 MutationCache ③** | 공통 "일시적 오류" 토스트 (Sentry 는 인터셉터 ①) |
---
@@ -76,7 +77,7 @@
### ③ 5xx · 네트워크 — 전역 MutationCache onError (`utils/queryClient.ts`)
- **5xx(500/502/503…)와 네트워크 오류(`error.response` 없음)는 전역이 단독으로 처리한다.**
- - `getApiErrorMessage` 토스트 + `Sentry.captureException`
+ - `getApiErrorMessage` 토스트. Sentry 수집은 인터셉터가 하므로 여기선 API 외 에러만 `captureNonApiError` 로 보낸다
- 5xx도 문구는 code로 갈린다 — `COMMON-RETRYABLE`(502) / `COMMON-SERVER-BUSY`(503) / `COMMON-SERVER-ERROR`(500).
에러 바운더리(`app/error.tsx`)도 같은 카탈로그를 쓴다 (code 없으면 `SERVER_ERROR_MESSAGE`)
- **개별 onError는 5xx를 건드리지 않는다** (아래 개발 규칙 참고) → 토스트 중복 방지
@@ -121,7 +122,7 @@
| ---------------------------------------------- | ------------------------------------------------------------- | --------------- |
| **suspense query** (`useSuspenseQuery` 등) | throw → `app/error.tsx` 에러바운더리 (이미 있음) | 추가 작업 없음 |
| **일반 query** (`useQuery`/`useInfiniteQuery`) | 컴포넌트에서 **`isError` 분기** → "불러오기 실패 + 재시도" UI | 해당 컴포넌트만 |
-| **전역 `QueryCache.onError`** | **Sentry 로깅만** (토스트 X) | 설정 1곳 |
+| **전역 `QueryCache.onError`** | **API 외 에러만 Sentry 로깅** (토스트 X) | 설정 1곳 |
- 일반 query는 **"빈 상태(데이터 없음)"와 "불러오기 실패"를 반드시 구분**해 표시할 것 (예: 알림 목록)
- 예외: **react-query 밖 수동 재조회**(예: `useTournament` 라운드 동기화)는 배경 refetch 스팸이 없고 재시도 액션이 유일한 복구 수단이라 토스트 허용
@@ -286,11 +287,12 @@ import {
isServerOrNetworkError,
isWithdrawnAccountError,
} from '@/utils/apiError';
+import { captureNonApiError } from '@/utils/captureError';
import { getApiErrorMessage } from '@/utils/getApiErrorMessage';
const makeQueryClient = () =>
new QueryClient({
- // mutation — 사용자 액션 실패: 토스트 + 로깅
+ // mutation — 사용자 액션 실패: 토스트 + (API 외 에러만) 로깅
mutationCache: new MutationCache({
onError: (error, _vars, _ctx, mutation) => {
// 401·탈퇴 계정은 인터셉터가 refresh·redirect 로 처리 → 이중 토스트 방지
@@ -298,7 +300,7 @@ const makeQueryClient = () =>
if (isServerOrNetworkError(error)) {
toast.error(getApiErrorMessage(error));
- // Sentry.captureException(error);
+ captureNonApiError(error); // API 5xx 는 인터셉터가 이미 수집
return; // 5xx는 전역 독점 → 개별은 5xx 토스트 금지
}
// 4xx: 개별 onError 있으면 양보, 없으면 generic fallback
@@ -306,11 +308,9 @@ const makeQueryClient = () =>
toast.error(getApiErrorMessage(error));
},
}),
- // query — 데이터 fetch 실패: 토스트 X, 로깅만 (표시는 boundary/isError)
+ // query — 데이터 fetch 실패: 토스트 X, API 외 에러만 로깅 (표시는 boundary/isError)
queryCache: new QueryCache({
- onError: _error => {
- // Sentry.captureException(_error); // 로깅만! 토스트 금지
- },
+ onError: captureNonApiError,
}),
defaultOptions: {
queries: { retry: 1, staleTime: 60_000, refetchOnWindowFocus: false },
@@ -323,12 +323,29 @@ const makeQueryClient = () =>
---
+## Sentry 수집 기준
+
+한 에러는 한 곳에서만 수집한다. 여러 곳에서 보내면 제목이 다른 이슈로 갈라져 횟수가 이중 집계된다.
+
+| 에러 | 수집 지점 | Sentry 제목 예시 |
+| ------------------------------------------ | ------------------------------------------------------------------------------------- | ----------------------------- |
+| API 5xx · 응답 없는 네트워크 오류 | `captureApiError` — 인터셉터 ① (`apis/client.ts` · `apis/server.ts`) · 토큰 갱신 실패 | `ApiError: API 500 GET /경로` |
+| API 4xx | 수집 안 함 — 화면에서 처리 (토스트·이동·인라인) | 없음 |
+| API 외 에러 (렌더 오류, S3 업로드 실패 등) | `captureNonApiError` — QueryCache·MutationCache·에러 바운더리 ⑤ | 원래 에러 이름 |
+| 서버(RSC·SSR)에서 난 API 외 에러 | `onRequestError` (`instrumentation.ts`) — axios 에러는 건너뜀 | 원래 에러 이름 |
+
+서버에서 난 에러는 Next 가 직렬화해 `digest` 를 붙여 브라우저 에러 바운더리로 넘긴다. axios 에러인지 알 수 없는 형태라 `captureNonApiError` 는 `digest` 가 있으면 건너뛴다 — 서버 `onRequestError` 가 이미 수집했기 때문이다.
+
+인터셉터는 모든 API 에러를 같은 줄에서 만들어 스택 트레이스가 같다. 그래서 `fingerprint: ['api', method, status, 정규화 경로]` 를 지정해 엔드포인트·status 별로 이슈를 나눈다. 경로의 숫자·UUID 세그먼트는 `:id` 로 바꿔 리소스 ID 별로 갈라지지 않게 한다. `errorType` 태그(`server` · `network`)로 서버 장애와 네트워크 단절을 구분해 검색한다.
+
+---
+
## "도달 불가" 케이스는 어떻게?
정상 UI 흐름에선 발생할 수 없는 status(예: 남의 위시 403)는:
- **개별 전용 UX를 만들지 않는다** (죽은 코드 방지)
-- 대신 **전역 안전망 + Sentry**로 덮어 "조용히 실패/크래시"만 막는다
-- 만약 Sentry에 실제로 찍히면 → "불가능하다던 가정이 깨졌다"는 신호로 삼아 원인 추적
+- 대신 **전역 안전망**(generic 토스트)으로 덮어 "조용히 실패/크래시"만 막는다
+- 4xx 는 Sentry 수집 대상이 아니다. 가정이 깨졌는지 꼭 알아야 하는 지점이면 그 자리에서 `captureError(error, { tags: { source: '...' } })` 로 명시적으로 보고한다
원칙 한 줄: **명시적 처리는 "도달 가능 + 사용자 행동이 바뀌는 것"만. 그 외 전부는 전역 안전망으로 "조용히 실패 금지".**