From 1a404a48d947214fd7d4df9502a34ae6afceece8 Mon Sep 17 00:00:00 2001 From: hyun907 Date: Sun, 26 Apr 2026 22:55:44 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20API=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EA=B3=B5=ED=86=B5=20=EC=B2=98=EB=A6=AC=20=EC=9C=A0=ED=8B=B8=20?= =?UTF-8?q?handleApiError=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/shared/api/client.ts | 8 +++-- apps/web/src/shared/api/handleApiError.ts | 43 +++++++++++++++++++++++ apps/web/src/shared/api/index.ts | 2 ++ 3 files changed, 50 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/shared/api/handleApiError.ts diff --git a/apps/web/src/shared/api/client.ts b/apps/web/src/shared/api/client.ts index 77010606..9d1c35be 100644 --- a/apps/web/src/shared/api/client.ts +++ b/apps/web/src/shared/api/client.ts @@ -81,10 +81,12 @@ export const apiClient = ky.create({ } if (error.response) { try { - const body = await error.response.text(); - error.message = `${error.message}: ${body}`; + const body = (await error.response.clone().json()) as { message?: string }; + if (body?.message) { + error.message = body.message; + } } catch { - // 응답 본문 읽기 실패 시 무시 + // JSON 파싱 실패 시 원본 message 유지 } } return error; diff --git a/apps/web/src/shared/api/handleApiError.ts b/apps/web/src/shared/api/handleApiError.ts new file mode 100644 index 00000000..cf092095 --- /dev/null +++ b/apps/web/src/shared/api/handleApiError.ts @@ -0,0 +1,43 @@ +import { HTTPError } from 'ky'; + +export interface ToastApi { + attention: (message: string) => void; +} + +export interface HandleApiErrorOptions { + /** 사용자에게 노출할 기본 실패 문구 (서버 메시지가 없거나 사용 불가일 때 사용) */ + fallback: string; + /** 토스트 인스턴스 (useToast() 결과) */ + toast: ToastApi; + /** 디버깅용 컨텍스트 (예: 'expense.update') */ + context?: string; + /** + * true이면 HTTP 응답에서 받은 서버 메시지(error.message)를 우선 노출. + * 네트워크 오류 등 응답이 없는 경우엔 fallback을 사용. + * 기본 false. + */ + preferServerMessage?: boolean; +} + +const SILENT_STATUS = new Set([401, 403]); + +/** + * mutation/쿼리의 onError에서 사용할 공통 핸들러. + * - 401/403은 client.ts에서 reissue/리다이렉트 처리하므로 토스트를 띄우지 않음 + * - 기본은 fallback 문구 노출. preferServerMessage=true이면 HTTPError의 서버 메시지 우선 + * - console.error를 일관 포맷으로 출력 + */ +export const handleApiError = (error: unknown, options: HandleApiErrorOptions) => { + const { fallback, toast, context, preferServerMessage = false } = options; + + if (error instanceof HTTPError && SILENT_STATUS.has(error.response.status)) { + return; + } + + console.error(`[API Error]${context ? ` ${context}` : ''}:`, error); + + const serverMessage = + preferServerMessage && error instanceof HTTPError && error.message ? error.message : null; + + toast.attention(serverMessage ?? fallback); +}; diff --git a/apps/web/src/shared/api/index.ts b/apps/web/src/shared/api/index.ts index 4516ae26..fe5b1bd0 100644 --- a/apps/web/src/shared/api/index.ts +++ b/apps/web/src/shared/api/index.ts @@ -2,4 +2,6 @@ export { apiClient, authenticatedApiClient } from './client'; export { api, authApi } from './methods'; export { API_BASE_URL, API_TIMEOUT, API_RETRY_LIMIT } from './constants'; export { ENDPOINT } from './endpoint'; +export { handleApiError } from './handleApiError'; export type { ApiResponse } from './types'; +export type { HandleApiErrorOptions, ToastApi } from './handleApiError'; From 0fe6b748760cdf84ee820da247e5de9dbce14ab6 Mon Sep 17 00:00:00 2001 From: hyun907 Date: Sun, 26 Apr 2026 22:57:05 +0900 Subject: [PATCH 2/3] =?UTF-8?q?refactor:=20mutation=20onError=EB=A5=BC=20h?= =?UTF-8?q?andleApiError=EB=A1=9C=20=EC=9D=BC=EC=9B=90=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/features/auth/model/useWithdraw.ts | 14 ++++++++------ .../expense/ui/ExpenseEditBottomSheet.tsx | 15 +++++++++++---- .../src/features/inquiry/model/useInquiryForm.ts | 9 +++++++-- .../ui/ExpenseRecordFunnel.tsx | 15 +++++++++++---- 4 files changed, 37 insertions(+), 16 deletions(-) diff --git a/apps/web/src/features/auth/model/useWithdraw.ts b/apps/web/src/features/auth/model/useWithdraw.ts index c42ac3c4..3af439d6 100644 --- a/apps/web/src/features/auth/model/useWithdraw.ts +++ b/apps/web/src/features/auth/model/useWithdraw.ts @@ -6,6 +6,7 @@ import { useToast } from '@/shared/ui'; import { clearAllUserStorage } from '@/shared/utils'; import { authQueries } from './authQueries'; import { useBridge } from '@/shared/lib/bridge'; +import { handleApiError } from '@/shared/api'; export const useWithdraw = () => { const router = useRouter(); @@ -29,12 +30,13 @@ export const useWithdraw = () => { toast.success('회원탈퇴가 완료되었어요'); router.replace('/login'); }, - onError: (error: Error) => { - const message = - error?.message && error.message !== 'Failed to fetch' - ? error.message - : '회원 탈퇴에 실패했어요. 다시 시도해주세요.'; - toast.attention(message); + onError: (error) => { + handleApiError(error, { + toast, + fallback: '회원 탈퇴에 실패했어요. 다시 시도해 주세요.', + context: 'auth.withdraw', + preferServerMessage: true, + }); }, }); diff --git a/apps/web/src/features/expense/ui/ExpenseEditBottomSheet.tsx b/apps/web/src/features/expense/ui/ExpenseEditBottomSheet.tsx index b9b7fb36..a3e8300b 100644 --- a/apps/web/src/features/expense/ui/ExpenseEditBottomSheet.tsx +++ b/apps/web/src/features/expense/ui/ExpenseEditBottomSheet.tsx @@ -24,6 +24,7 @@ import { } from '@/entities/expense'; import { expenseReportQueries } from '@/entities/expenseReport'; import { expenseEditNavigation } from '@/features/expense/lib/expenseEditNavigation'; +import { handleApiError } from '@/shared/api'; export interface ExpenseEditBottomSheetProps { isOpen: boolean; @@ -88,8 +89,11 @@ export const ExpenseEditBottomSheet = ({ onClose(); }, onError: (error) => { - console.error('지출 수정 실패:', error); - toast.attention('수정에 실패했어요. 다시 시도해 주세요.'); + handleApiError(error, { + toast, + fallback: '수정에 실패했어요. 다시 시도해 주세요.', + context: 'expense.update', + }); }, }); // 지출 삭제 mutation @@ -105,8 +109,11 @@ export const ExpenseEditBottomSheet = ({ onClose(); }, onError: (error) => { - console.error('지출 삭제 실패:', error); - toast.attention('삭제에 실패했어요. 다시 시도해 주세요.'); + handleApiError(error, { + toast, + fallback: '삭제에 실패했어요. 다시 시도해 주세요.', + context: 'expense.delete', + }); }, }); diff --git a/apps/web/src/features/inquiry/model/useInquiryForm.ts b/apps/web/src/features/inquiry/model/useInquiryForm.ts index 56d7d170..f5924620 100644 --- a/apps/web/src/features/inquiry/model/useInquiryForm.ts +++ b/apps/web/src/features/inquiry/model/useInquiryForm.ts @@ -5,6 +5,7 @@ import { useModal } from '@/shared/hooks'; import { isValidEmail, hasEmailError } from '@/shared/lib/validation'; import { inquiryQueries } from './inquiryQueries'; import { useToast } from '@/shared/ui'; +import { handleApiError } from '@/shared/api'; /** * 문의 폼 상태 및 동작을 관리하는 커스텀 훅 @@ -25,8 +26,12 @@ export const useInquiryForm = () => { toast.success('등록 완료, 곧 답변드릴게요!'); router.back(); }, - onError: () => { - toast.attention('문의 등록에 실패했어요. 다시 시도해주세요.'); + onError: (error) => { + handleApiError(error, { + toast, + fallback: '문의 등록에 실패했어요. 다시 시도해 주세요.', + context: 'inquiry.send', + }); }, }); diff --git a/apps/web/src/widgets/expenseRecordFunnel/ui/ExpenseRecordFunnel.tsx b/apps/web/src/widgets/expenseRecordFunnel/ui/ExpenseRecordFunnel.tsx index dddff983..b256ecd0 100644 --- a/apps/web/src/widgets/expenseRecordFunnel/ui/ExpenseRecordFunnel.tsx +++ b/apps/web/src/widgets/expenseRecordFunnel/ui/ExpenseRecordFunnel.tsx @@ -28,6 +28,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { expenseReportQueries } from '@/entities/expenseReport'; import { expenseQueries as entityExpenseQueries } from '@/entities/expense'; import { ROUTES } from '@/shared/constants'; +import { handleApiError } from '@/shared/api'; const STEP_NUMBER = { 금액날짜입력: 1, @@ -86,7 +87,16 @@ export const ExpenseRecordFunnel = () => { history.push('만족도입력', { usageHistory, categoryId }); }; - const { mutate: submitExpense, isPending } = useMutation(expenseQueries.recordMutation()); + const { mutate: submitExpense, isPending } = useMutation({ + ...expenseQueries.recordMutation(), + onError: (error) => { + handleApiError(error, { + toast, + fallback: '저장에 실패했어요. 다시 시도해 주세요.', + context: 'expense.create', + }); + }, + }); const handleSubmit = (emotionType: EmotionType) => { if (isPending) return; @@ -106,9 +116,6 @@ export const ExpenseRecordFunnel = () => { toast.success('소비 기록이 저장되었어요.'); router.push(ROUTES.HOME); }, - onError: () => { - toast.attention('저장에 실패했어요. 다시 시도해 주세요.'); - }, } ); }; From b14c4fb00cca38f9e2f3bf409c1c4d5ab69cf5ee Mon Sep 17 00:00:00 2001 From: hyun907 Date: Sun, 26 Apr 2026 22:57:20 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=EB=88=84=EB=9D=BD=EB=90=9C=20mutati?= =?UTF-8?q?on=20onError=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/steps/AddCategoryStep/AddCategoryStep.tsx | 8 ++++++++ .../features/review/model/useSpendingReview.ts | 8 ++++++++ .../src/widgets/addCategory/ui/AddCategory.tsx | 15 +++++++++++++++ .../widgets/notifications/ui/Notifications.tsx | 4 ++++ 4 files changed, 35 insertions(+) diff --git a/apps/web/src/features/expense/ui/steps/AddCategoryStep/AddCategoryStep.tsx b/apps/web/src/features/expense/ui/steps/AddCategoryStep/AddCategoryStep.tsx index fcecbef6..fed6c6a7 100644 --- a/apps/web/src/features/expense/ui/steps/AddCategoryStep/AddCategoryStep.tsx +++ b/apps/web/src/features/expense/ui/steps/AddCategoryStep/AddCategoryStep.tsx @@ -25,6 +25,7 @@ import { useExpenseFormStore } from '@/widgets/expenseRecordFunnel/model/store'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { categoryQueries } from '@/features/expense/model/categoryQueries'; import { CategoryDetailsDTO } from '@/features/expense/model/types'; +import { handleApiError } from '@/shared/api'; const VALID_NAME_REGEX = /^[가-힣ㄱ-ㅎㅏ-ㅣa-zA-Z0-9]*$/; @@ -60,6 +61,13 @@ export const AddCategoryStep = ({ onBack }: AddCategoryStepProps) => { useExpenseFormStore.setState({ shouldOpenCategorySheet: false }); onBack(); }, + onError: (error) => { + handleApiError(error, { + toast, + fallback: '카테고리 추가에 실패했어요. 다시 시도해 주세요.', + context: 'category.create', + }); + }, }); const [categoryName, setCategoryName] = useState(''); diff --git a/apps/web/src/features/review/model/useSpendingReview.ts b/apps/web/src/features/review/model/useSpendingReview.ts index cd972ad1..53da2539 100644 --- a/apps/web/src/features/review/model/useSpendingReview.ts +++ b/apps/web/src/features/review/model/useSpendingReview.ts @@ -11,6 +11,7 @@ import { ROUTES } from '@/shared/constants/routes'; import { useRetrospectExpenses } from './useRetrospectExpenses'; import { patchRemind } from '../api/patchRemind'; import { useReviewCarousel, type UseReviewCarouselReturn } from './useReviewCarousel'; +import { handleApiError } from '@/shared/api'; type UseSpendingReviewReturn = Pick< UseReviewCarouselReturn, @@ -63,6 +64,13 @@ export const useSpendingReview = ({ date }: UseSpendingReviewParams): UseSpendin toast.success('만족도 기록이 잘 저장되었어요!'); router.push(ROUTES.HOME); }, + onError: (error) => { + handleApiError(error, { + toast, + fallback: '저장에 실패했어요. 다시 시도해 주세요.', + context: 'review.saveRemind', + }); + }, }); const buildRemindBody = () => diff --git a/apps/web/src/widgets/addCategory/ui/AddCategory.tsx b/apps/web/src/widgets/addCategory/ui/AddCategory.tsx index d970c396..dabe392b 100644 --- a/apps/web/src/widgets/addCategory/ui/AddCategory.tsx +++ b/apps/web/src/widgets/addCategory/ui/AddCategory.tsx @@ -25,6 +25,7 @@ import { useExpenseFormStore } from '@/widgets/expenseRecordFunnel/model/store'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { categoryQueries } from '@/features/expense/model/categoryQueries'; import { CategoryDetailsDTO } from '@/features/expense/model/types'; +import { handleApiError } from '@/shared/api'; const VALID_NAME_REGEX = /^[가-힣ㄱ-ㅎㅏ-ㅣa-zA-Z0-9]*$/; @@ -68,6 +69,13 @@ export const AddCategory = () => { } toast.success('카테고리가 추가되었어요!'); }, + onError: (error) => { + handleApiError(error, { + toast, + fallback: '카테고리 추가에 실패했어요. 다시 시도해 주세요.', + context: 'category.create', + }); + }, }); const { mutate: updateCategory, isPending: isUpdatePending } = useMutation({ @@ -78,6 +86,13 @@ export const AddCategory = () => { toast.success('수정한 내용이 저장되었어요!'); router.back(); }, + onError: (error) => { + handleApiError(error, { + toast, + fallback: '카테고리 수정에 실패했어요. 다시 시도해 주세요.', + context: 'category.update', + }); + }, }); const isPending = isCreatePending || isUpdatePending; diff --git a/apps/web/src/widgets/notifications/ui/Notifications.tsx b/apps/web/src/widgets/notifications/ui/Notifications.tsx index 5a9e4a41..ba13ed2b 100644 --- a/apps/web/src/widgets/notifications/ui/Notifications.tsx +++ b/apps/web/src/widgets/notifications/ui/Notifications.tsx @@ -18,6 +18,10 @@ export const Notifications = ({ onBackClick }: NotificationsProps) => { onSuccess: () => { queryClient.invalidateQueries({ queryKey: notificationQueries.all }); }, + onError: (error) => { + // 읽음 처리는 백그라운드성 동작이라 토스트 없이 로그만 남긴다 + console.error('[API Error] notification.readAll:', error); + }, }); const handleBackClick = () => {