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/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/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/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/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'; 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/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('저장에 실패했어요. 다시 시도해 주세요.'); - }, } ); }; 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 = () => {