diff --git a/src/features/home/api/search.ts b/src/features/home/api/search.ts index d6e5558..7a9f22e 100644 --- a/src/features/home/api/search.ts +++ b/src/features/home/api/search.ts @@ -35,6 +35,6 @@ export const useSearchHistory = () => { onSuccess: () => { console.log("검색 히스토리 저장"); }, - onError: err => console.log(err), + // onError: err => console.log(err), }); }; diff --git a/src/features/mypage/api/account.ts b/src/features/mypage/api/account.ts index fb63ae0..3473d77 100644 --- a/src/features/mypage/api/account.ts +++ b/src/features/mypage/api/account.ts @@ -1,6 +1,10 @@ import api from "@/shared/api/api"; import { API_ENDPOINTS } from "@/shared/consts/endpoints"; +import { USER_ERROR } from "@/shared/consts/errorCodes"; import { useMutation } from "@tanstack/react-query"; +import { toast } from "react-toastify"; +import type { AxiosError } from "axios"; +import useUserStore from "@/shared/model/useUserStore"; export const deleteAccount = async () => { const { data } = await api.patch(API_ENDPOINTS.users.me.withdrawal); @@ -8,9 +12,14 @@ export const deleteAccount = async () => { }; export const useDeleteAccount = (onSuccess: () => void) => { - return useMutation({ + return useMutation>({ mutationFn: deleteAccount, onSuccess: () => onSuccess?.(), - onError: err => console.error(err), + onError: e => { + if (e?.response?.data?.code === USER_ERROR.ALREADY_WITHDRAWN) { + toast.error(e.response.data.message); + useUserStore.getState().logout(); + } + }, }); }; diff --git a/src/features/mypage/api/myEdit.ts b/src/features/mypage/api/myEdit.ts index b9d7809..7ac3169 100644 --- a/src/features/mypage/api/myEdit.ts +++ b/src/features/mypage/api/myEdit.ts @@ -9,6 +9,9 @@ import { API_ENDPOINTS } from "@/shared/consts/endpoints"; import { SHARED_QUERY_KEY } from "@/shared/consts/queryKeys"; import { HOME_QUERY_KEY } from "@/features/home/consts/queryKeys"; import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { USER_ERROR } from "@/shared/consts/errorCodes"; +import { toast } from "react-toastify"; +import type { AxiosError } from "axios"; // 내 관심사 수정 =>mypage export const putMyInterst = async (body: InterestDataDto) => { @@ -25,7 +28,12 @@ export const usePutMyInterst = () => { HOME_QUERY_KEY.POSTS_RECOMMEND, ] as const; - return useMutation({ + return useMutation< + unknown, + AxiosError<{ code: string; message: string }>, + InterestDataDto, + { previous: InterestTypeDto[] | undefined; previousRecommend: unknown } + >({ mutationFn: (body: InterestDataDto) => putMyInterst(body), onMutate: async (payload: InterestDataDto) => { @@ -43,7 +51,11 @@ export const usePutMyInterst = () => { return { previous, previousRecommend }; }, - onError: (_err, _payload, context) => { + onError: (e, _, context) => { + if (e?.response?.data?.code == USER_ERROR.INVALID_INTEREST) { + toast.error(e.response.data.message); + } + if (context?.previous) { queryClient.setQueryData(queryKey, context.previous); } @@ -78,6 +90,5 @@ export const usePatchMyProfile = (onSuccess?: () => void) => { }); onSuccess?.(); }, - onError: err => console.log(err), }); }; diff --git a/src/features/onboarding/api/onboarding.ts b/src/features/onboarding/api/onboarding.ts index af1b662..25a2ce5 100644 --- a/src/features/onboarding/api/onboarding.ts +++ b/src/features/onboarding/api/onboarding.ts @@ -1,8 +1,11 @@ import { useMutation } from "@tanstack/react-query"; import { useNavigate } from "react-router-dom"; +import type { AxiosError } from "axios"; import api from "@/shared/api/api"; import { API_ENDPOINTS } from "@/shared/consts/endpoints"; import type { OnboardingRequestType } from "./onboarding.types"; +import { USER_ERROR } from "@/shared/consts/errorCodes"; +import { toast } from "react-toastify"; export const postOnboarding = async (body: OnboardingRequestType) => { const res = await api.post(API_ENDPOINTS.onboarding.complete, body); @@ -12,9 +15,17 @@ export const postOnboarding = async (body: OnboardingRequestType) => { export const useSubmitOnboarding = () => { const navigate = useNavigate(); - return useMutation({ + return useMutation< + unknown, + AxiosError<{ code: string; message: string }>, + OnboardingRequestType + >({ mutationFn: (body: OnboardingRequestType) => postOnboarding(body), onSuccess: () => navigate("/"), - onError: err => console.log(err), + onError: err => { + if (err.response?.data.code === USER_ERROR.INVALID_INTEREST) { + toast.error(err.response.data.message); + } + }, }); }; diff --git a/src/main.tsx b/src/main.tsx index d52e33d..4bcaa17 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,7 +1,7 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import "@/app/styles/index.css"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { QueryClient, QueryClientProvider, MutationCache } from "@tanstack/react-query"; import axios from "axios"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { ThemeProvider } from "@/app/providers/ThemProvider.tsx"; @@ -9,16 +9,30 @@ import { HelmetProvider } from "react-helmet-async"; import App from "@/app/App"; import router from "@/app/routes"; import { initGlobalNavigate } from "@/shared/lib/globalNavigate"; +import { toast } from "react-toastify"; initGlobalNavigate((path) => router.navigate(path)); +const isServerError = (error: unknown) => + axios.isAxiosError(error) && + (error.response?.status === 500 || error.response?.status === 503); + const queryClient = new QueryClient({ + mutationCache: new MutationCache({ + onError: (error) => { + if (isServerError(error)) { + toast.error("서버에 문제가 발생했습니다. 잠시 후 다시 시도해주세요."); + } + }, + }), defaultOptions: { queries: { retry: (failureCount, error) => { if (axios.isAxiosError(error) && !error.response) return false; + if (isServerError(error)) return false; return failureCount < 3; }, + throwOnError: (error) => isServerError(error), }, }, }); diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index a658a01..8eda461 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -1,9 +1,10 @@ -import { http, HttpResponse } from "msw"; +import { http, HttpResponse, passthrough } from "msw"; import { AUTH_ERROR, - ACTIVITY_ERROR, - USER_ERROR, + BOOKMARK_ERROR, POST_ERROR, + READPOST_ERROR, + USER_ERROR, COMMON_ERROR, } from "@/shared/consts/errorCodes"; @@ -44,23 +45,37 @@ export const scenarioAuth = { /** 북마크 에러 시나리오 */ export const scenarioBookmark = { - /** 북마크 추가 => 이미 북마크한 게시글 */ + // POST /api/v1/activities/bookmarks + /** 북마크 추가 => 이미 북마크한 게시글 (BOOKMARK409_1) */ alreadyBookmarked: http.post(url("/api/v1/activities/bookmarks"), () => - err(ACTIVITY_ERROR.ALREADY_BOOKMARKED, "이미 북마크한 게시글입니다.", 409), + err(BOOKMARK_ERROR.ALREADY_BOOKMARKED, "이미 북마크한 게시글입니다.", 409), + ), + /** 북마크 추가 => 게시글을 찾을 수 없음 (POST404_1) */ + postPostNotFound: http.post(url("/api/v1/activities/bookmarks"), () => + err(POST_ERROR.NOT_FOUND, "게시글을 찾을 수 없습니다.", 404), ), - /** 북마크 삭제 => 북마크를 찾을 수 없음 */ + // DELETE /api/v1/activities/bookmarks + /** 북마크 삭제 => 북마크를 찾을 수 없음 (BOOKMARK404_1) */ bookmarkNotFound: http.delete(url("/api/v1/activities/bookmarks"), () => - err(ACTIVITY_ERROR.BOOKMARK_NOT_FOUND, "북마크를 찾을 수 없습니다.", 404), + err(BOOKMARK_ERROR.BOOKMARK_NOT_FOUND, "북마크를 찾을 수 없습니다.", 404), + ), + /** 북마크 삭제 => 게시글을 찾을 수 없음 (POST404_1) */ + deletePostNotFound: http.delete(url("/api/v1/activities/bookmarks"), () => + err(POST_ERROR.NOT_FOUND, "게시글을 찾을 수 없습니다.", 404), ), }; -/** 게시글 에러 시나리오 */ -export const scenarioPost = { - /** 게시글 조회 => 찾을 수 없음 */ - notFound: http.get(url("/api/v2/posts/*"), () => +/** 읽은 게시글 에러 시나리오 (POST /api/v1/activities/read-posts) */ +export const scenarioReadPost = { + /** 게시글을 찾을 수 없음 (POST404_1) */ + postNotFound: http.post(url("/api/v1/activities/read-posts"), () => err(POST_ERROR.NOT_FOUND, "게시글을 찾을 수 없습니다.", 404), ), + /** 조회수 증가 실패 (READ_POST500_1) — 전역 toast 동작 확인 */ + readPostFailed: http.post(url("/api/v1/activities/read-posts"), () => + err(READPOST_ERROR.READ_POST, "조회수 증가에 실패했습니다.", 500), + ), }; /** 유저 에러 시나리오 */ @@ -83,8 +98,8 @@ export const scenarioUser = { /** 공통 에러 시나리오 */ export const scenarioCommon = { - /** 서버 에러 */ - internalServer: http.get(url("/api/*"), () => + /** 서버 에러 (전체) — query: ErrorBoundary fallback / mutation: toast */ + internalServer: http.all(url("/api/*"), () => err( COMMON_ERROR.INTERNAL_SERVER, "서버 에러, 관리자에게 문의 바랍니다.", @@ -92,8 +107,50 @@ export const scenarioCommon = { ), ), - /** 서비스 점검 */ - serviceUnavailable: http.get(url("/api/*"), () => + /** 서버 에러 (query만) — GET만 막아 mutation은 정상 동작 */ + internalServerQuery: http.get(url("/api/*"), () => + err( + COMMON_ERROR.INTERNAL_SERVER, + "서버 에러, 관리자에게 문의 바랍니다.", + 500, + ), + ), + + /** 서버 에러 (mutation만) — POST/PATCH/DELETE만 막아 query는 정상 동작 */ + internalServerMutation: [ + http.post(url("/api/v1/auth/refresh"), () => passthrough()), + http.post(url("/api/*"), () => + err( + COMMON_ERROR.INTERNAL_SERVER, + "서버 에러, 관리자에게 문의 바랍니다.", + 500, + ), + ), + http.patch(url("/api/*"), () => + err( + COMMON_ERROR.INTERNAL_SERVER, + "서버 에러, 관리자에게 문의 바랍니다.", + 500, + ), + ), + http.delete(url("/api/*"), () => + err( + COMMON_ERROR.INTERNAL_SERVER, + "서버 에러, 관리자에게 문의 바랍니다.", + 500, + ), + ), + http.put(url("/api/*"), () => + err( + COMMON_ERROR.INTERNAL_SERVER, + "서버 에러, 관리자에게 문의 바랍니다.", + 500, + ), + ), + ], + + /** 서비스 점검 — query: ErrorBoundary fallback / mutation: toast */ + serviceUnavailable: http.all(url("/api/*"), () => err( COMMON_ERROR.SERVICE_UNAVAILABLE, "서버가 일시적으로 사용중지 되었습니다.", @@ -115,18 +172,24 @@ export const scenarioNetwork = { export const handlers = [ // 인증 - // scenarioAuth.refreshMismatch, // 401: refresh 불일치 => 즉시 로그아웃 확인 - // scenarioAuth.withdrawn, // 403: 탈퇴 회원 => 즉시 로그아웃 확인 - // 북마크 - // scenarioBookmark.alreadyBookmarked, // 409: 중복 북마크 - // scenarioBookmark.bookmarkNotFound, // 404: 북마크 없음 + // scenarioAuth.refreshMismatch, // 401: refresh 불일치 => 즉시 로그아웃 -O + // scenarioAuth.withdrawn, // 403: 탈퇴 회원 => 즉시 로그아웃 -O + // 북마크 POST + // scenarioBookmark.alreadyBookmarked, // 409: 중복 북마크 -O + // scenarioBookmark.postPostNotFound, // 404: 게시글 없음 + // 북마크 DELETE + // scenarioBookmark.bookmarkNotFound, // 404: 북마크 없음 -O + // scenarioBookmark.deletePostNotFound, // 404: 게시글 없음 + // 읽은 게시글 POST + // scenarioReadPost.postNotFound, // 404: 게시글 없음 + // scenarioReadPost.readPostFailed, // 500: 조회수 증가 실패 => 전역 toast -O // 유저 - // scenarioUser.invalidInterest, // 400: 유효하지 않은 관심사 - // scenarioUser.alreadyWithdrawn, // 400: 이미 탈퇴한 회원 + // scenarioUser.invalidInterest, // 400: 유효하지 않은 관심사 + // scenarioUser.alreadyWithdrawn, // 400: 이미 탈퇴한 회원 -O // 공통 - // scenarioCommon.internalServer, // 500: 서버 에러 - // scenarioCommon.serviceUnavailable, // 503: 서비스 점검 + // scenarioCommon.internalServer, // 500: 서버 에러 + // ...scenarioCommon.internalServerMutation, + // scenarioCommon.serviceUnavailable, // 503: 서비스 점검 // 네트워크 - // scenarioNetwork.offline, // 연결 끊김 (ERR_NETWORK) - // scenarioNetwork.bookmarkOffline,// 북마크 API만 연결 끊김 + // scenarioNetwork.offline, // 연결 끊김 (ERR_NETWORK) -O ]; diff --git a/src/shared/api/activity.ts b/src/shared/api/activity.ts index c58ef0a..26bca7b 100644 --- a/src/shared/api/activity.ts +++ b/src/shared/api/activity.ts @@ -1,12 +1,13 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { QueryKey } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; import type { ActivityPostType, ReadPostType } from "./activity.types"; import api from "./api"; import { SHARED_QUERY_KEY } from "../consts/queryKeys"; import { updateBookmarkState } from "../lib/updateBookmarkState"; -import { - API_ENDPOINTS, - getActivityPostsEndpoint, -} from "../consts/endpoints"; +import { API_ENDPOINTS, getActivityPostsEndpoint } from "../consts/endpoints"; +import { BOOKMARK_ERROR, POST_ERROR } from "@/shared/consts/errorCodes"; +import { toast } from "react-toastify"; export type { ActivityPostType }; @@ -21,7 +22,12 @@ export const usePostBookmark = () => { const queryClient = useQueryClient(); const postsQueryKey = [SHARED_QUERY_KEY.POSTS] as const; - return useMutation({ + return useMutation< + unknown, + AxiosError<{ code: string; message: string }>, + number, + { previousQueries: [QueryKey, unknown][] } + >({ mutationFn: (postId: number) => postBookmark(postId), onMutate: async postId => { await queryClient.cancelQueries({ queryKey: postsQueryKey }); @@ -37,8 +43,19 @@ export const usePostBookmark = () => { return { previousQueries }; }, - onError: () => - queryClient.invalidateQueries({ queryKey: postsQueryKey }), + onError: (e, _, context) => { + const code = e?.response?.data?.code; + if ( + code === BOOKMARK_ERROR.ALREADY_BOOKMARKED || + code === POST_ERROR.NOT_FOUND + ) { + toast.error(e.response?.data.message); + } + context?.previousQueries.forEach(([queryKey, data]) => { + queryClient.setQueryData(queryKey, data); + }); + queryClient.invalidateQueries({ queryKey: postsQueryKey }); + }, }); }; @@ -53,7 +70,12 @@ export const useDeleteBookmark = () => { const queryClient = useQueryClient(); const postsQueryKey = [SHARED_QUERY_KEY.POSTS] as const; - return useMutation({ + return useMutation< + unknown, + AxiosError<{ code: string; message: string }>, + number, + { previousQueries: [QueryKey, unknown][] } + >({ mutationFn: (postId: number) => deleteBookmark(postId), onMutate: async postId => { await queryClient.cancelQueries({ queryKey: postsQueryKey }); @@ -68,8 +90,19 @@ export const useDeleteBookmark = () => { return { previousQueries }; }, - onError: () => - queryClient.invalidateQueries({ queryKey: postsQueryKey }), + onError: (e, _, context) => { + const code = e?.response?.data?.code; + if ( + code === BOOKMARK_ERROR.BOOKMARK_NOT_FOUND || + code === POST_ERROR.NOT_FOUND + ) { + toast.error(e.response?.data.message); + } + context?.previousQueries.forEach(([queryKey, data]) => { + queryClient.setQueryData(queryKey, data); + }); + queryClient.invalidateQueries({ queryKey: postsQueryKey }); + }, }); }; @@ -103,13 +136,20 @@ export const usePostReadPost = () => { const queryClient = useQueryClient(); const postsQueryKey = [SHARED_QUERY_KEY.POSTS] as const; - return useMutation({ + return useMutation< + unknown, + AxiosError<{ code: string; message: string }>, + ReadPostType + >({ mutationFn: (body: ReadPostType) => postReadPosts(body), onSuccess: () => { queryClient.invalidateQueries({ queryKey: postsQueryKey }); }, - onError: err => { - console.log(err); + onError: e => { + const code = e?.response?.data?.code; + if (code === POST_ERROR.NOT_FOUND) { + toast.error(e.response?.data.message); + } }, }); }; diff --git a/src/shared/consts/errorCodes.ts b/src/shared/consts/errorCodes.ts index fc8d18b..667ff93 100644 --- a/src/shared/consts/errorCodes.ts +++ b/src/shared/consts/errorCodes.ts @@ -1,38 +1,15 @@ // 공통 error export const COMMON_ERROR = { - BAD_REQUEST: "COMMON400", - VALIDATION: "COMMON400_VALIDATION", - TYPE: "COMMON400_TYPE", - FORMAT: "COMMON400_FORMAT", - PARAM: "COMMON400_PARAM", - UNAUTHORIZED: "COMMON401", - FORBIDDEN: "COMMON403", - NOT_FOUND: "COMMON404", INTERNAL_SERVER: "COMMON500", SERVICE_UNAVAILABLE: "COMMON503", } as const; export const AUTH_ERROR = { - TYPE_MISMATCH: "AUTH400_TYPE_MISMATCH", - INVALID: "AUTH401_INVALID", EXPIRED: "AUTH401_EXPIRED", - MALFORMED: "AUTH401_MALFORMED", - SIGNATURE: "AUTH401_SIGNATURE", - UNSUPPORTED: "AUTH401_UNSUPPORTED", - EMPTY_CLAIMS: "AUTH401_EMPTY_CLAIMS", - INVALID_REFRESH: "AUTH401_INVALID_REFRESH", - REFRESH_MISSING: "AUTH401_REFRESH_MISSING", REFRESH_MISMATCH: "AUTH401_REFRESH_MISMATCH", KAKAO_TOKEN: "AUTH401_KAKAO_TOKEN", WITHDRAWN: "AUTH403_WITHDRAWN", - FORBIDDEN: "AUTH403_FORBIDDEN", - USER_NOT_FOUND: "AUTH404_USER", - KAKAO_API: "AUTH500_KAKAO_API", -} as const; - -export const POST_ERROR = { - NOT_FOUND: "POST404_1", } as const; export const USER_ERROR = { @@ -41,21 +18,32 @@ export const USER_ERROR = { NOT_FOUND: "USER404_1", } as const; -export const ACTIVITY_ERROR = { - BOOKMARK_NOT_FOUND: "ACTIVITY404_1", - ALREADY_BOOKMARKED: "ACTIVITY409_1", +export const BOOKMARK_ERROR = { + BOOKMARK_NOT_FOUND: "BOOKMARK404_1", + ALREADY_BOOKMARKED: "BOOKMARK409_1", +} as const; + +export const POST_ERROR = { + NOT_FOUND: "POST404_1", +} as const; + +export const READPOST_ERROR = { + READ_POST: "READ_POST500_1", } as const; export type CommonErrorCode = (typeof COMMON_ERROR)[keyof typeof COMMON_ERROR]; export type AuthErrorCode = (typeof AUTH_ERROR)[keyof typeof AUTH_ERROR]; -export type PostErrorCode = (typeof POST_ERROR)[keyof typeof POST_ERROR]; export type UserErrorCode = (typeof USER_ERROR)[keyof typeof USER_ERROR]; -export type ActivityErrorCode = - (typeof ACTIVITY_ERROR)[keyof typeof ACTIVITY_ERROR]; +export type BookmarkErrorCode = + (typeof BOOKMARK_ERROR)[keyof typeof BOOKMARK_ERROR]; +export type PostErrorCode = (typeof POST_ERROR)[keyof typeof POST_ERROR]; +export type ReadPostErrorCode = + (typeof READPOST_ERROR)[keyof typeof READPOST_ERROR]; export type ErrorCode = | CommonErrorCode | AuthErrorCode - | PostErrorCode | UserErrorCode - | ActivityErrorCode; + | BookmarkErrorCode + | PostErrorCode + | ReadPostErrorCode; diff --git a/src/shared/ui/ErrorBoundary.tsx b/src/shared/ui/ErrorBoundary.tsx index ff301e3..2506cae 100644 --- a/src/shared/ui/ErrorBoundary.tsx +++ b/src/shared/ui/ErrorBoundary.tsx @@ -22,7 +22,7 @@ export const DefaultErrorFallback = ({
-

"서버에 문제가 발생했습니다."

+

서버에 문제가 발생했습니다.

잠시 후 다시 시도해주세요.