diff --git a/src/app/detail/[postingId]/not-found.tsx b/src/app/detail/[postingId]/not-found.tsx new file mode 100644 index 00000000..8402ae7b --- /dev/null +++ b/src/app/detail/[postingId]/not-found.tsx @@ -0,0 +1,4 @@ +import NotFoundErrorPage from "@/shared/error/ui/404"; +export default function PostDetailNotFound() { + return ; +} diff --git a/src/app/detail/[postingId]/page.tsx b/src/app/detail/[postingId]/page.tsx index c49906e5..ba6ea8fa 100644 --- a/src/app/detail/[postingId]/page.tsx +++ b/src/app/detail/[postingId]/page.tsx @@ -7,6 +7,7 @@ import { getPostDetail } from "@/entities/post/api/getPostDetail.server"; import { getUser } from "@/entities/user/api/getUser.server"; import PostDetailPageClient from "@/widgets/postDetail/ui/DetailPage.client"; import type { PostDetail } from "@/entities/post/model/types/post"; +import { handleError } from "@/shared/error/handleError"; export default async function Page({ params, @@ -17,18 +18,26 @@ export default async function Page({ const id = Number(postingId); const queryClient = new QueryClient(); - await queryClient.prefetchQuery({ - queryKey: ["postDetail", id], - queryFn: () => getPostDetail(id), - }); + try { + await queryClient.fetchQuery({ + queryKey: ["postDetail", id], + queryFn: () => getPostDetail(id), + }); + } catch (e) { + handleError(e); + } const post = queryClient.getQueryData(["postDetail", id]); if (post && post.sellerId) { - await queryClient.prefetchQuery({ - queryKey: ["seller", post.sellerId], - queryFn: () => getUser(post.sellerId), - }); + try { + await queryClient.prefetchQuery({ + queryKey: ["seller", post.sellerId], + queryFn: () => getUser(post.sellerId), + }); + } catch (e) { + handleError(e); + } } return ( diff --git a/src/app/error.tsx b/src/app/error.tsx new file mode 100644 index 00000000..1a900dd4 --- /dev/null +++ b/src/app/error.tsx @@ -0,0 +1,11 @@ +"use client"; +import ServerErrorPage from "@/shared/error/ui/500"; +export default function GlobalError({ error }: { error: Error }) { + return ( + + + + + + ); +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index ebcdb216..55c07498 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,52 +1,10 @@ -"use client"; - -import React, { useEffect, useState } from "react"; -import { LoginForm } from "@/features/auth/ui/LoginForm/LoginForm"; -import { Modal } from "@/shared/ui/Modal/Modal"; -import { useRouter } from "next/navigation"; -import { useQueryClient } from "@tanstack/react-query"; -import { useAuthStore } from "@/features/auth/model/auth.store"; -import { useModalStore } from "@/shared/model/modal.store"; - -export default function LoginPage() { - const router = useRouter(); - const queryClient = useQueryClient(); - const [errorMessage, setErrorMessage] = useState(null); - const { setIsLogined } = useAuthStore(); - const { openModal, closeModal } = useModalStore(); - - useEffect(() => { - queryClient.clear(); - }, []); +import { Suspense } from "react"; +import LoginPageClient from "@/widgets/main/ui/Client/LoginPage.client"; +export default function Page() { return ( -
-

로그인

- { - openModal("normal", { - message: "로그인에 성공하였습니다.", - onClick: () => { - closeModal(); - setIsLogined(true); - router.push("/"); //메인 페이지 이동 - }, - }); - }} - onError={(msg) => { - setErrorMessage(msg); - }} - /> - - {!!errorMessage && ( - setErrorMessage(null)} - className="" - /> - )} -
+ + + ); } diff --git a/src/app/my/page.tsx b/src/app/my/page.tsx index a8f55534..4b11121d 100644 --- a/src/app/my/page.tsx +++ b/src/app/my/page.tsx @@ -7,21 +7,26 @@ import { import { getMyPosts } from "@/entities/user/api/getMyPosts.server"; import { getMyProfile } from "@/entities/user/api/getMyProfile.server"; import MyPageClient from "@/widgets/mypage/ui/Client/MyPage.client"; +import { handleError } from "@/shared/error/handleError"; const DEFAULT_TAB = "selling"; export default async function Page() { const queryClient = new QueryClient(); - await queryClient.prefetchQuery({ - queryKey: ["userProfile"], - queryFn: getMyProfile, - }); + try { + await queryClient.fetchQuery({ + queryKey: ["userProfile"], + queryFn: getMyProfile, + }); - await queryClient.prefetchQuery({ - queryKey: ["myPosts", DEFAULT_TAB], - queryFn: () => getMyPosts(DEFAULT_TAB), - }); + await queryClient.fetchQuery({ + queryKey: ["myPosts", DEFAULT_TAB], + queryFn: () => getMyPosts(DEFAULT_TAB), + }); + } catch (e) { + handleError(e); + } return ( diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx new file mode 100644 index 00000000..c83bc0fc --- /dev/null +++ b/src/app/not-found.tsx @@ -0,0 +1,5 @@ +import NotFoundErrorPage from "@/shared/error/ui/404"; + +export default function GlobalNotFound() { + return ; +} diff --git a/src/app/page.tsx b/src/app/page.tsx index d91cdc64..e358a99c 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -5,6 +5,7 @@ import { } from "@tanstack/react-query"; import { getPosts } from "@/entities/post/api/getPosts.server"; import HomePageClient from "@/widgets/main/ui/Client/HomePage.client"; +import { handleError } from "@/shared/error/handleError"; export default async function Page({ searchParams, @@ -18,17 +19,21 @@ export default async function Page({ const queryClient = new QueryClient(); - await queryClient.prefetchInfiniteQuery({ - queryKey: ["posts", initialCategory, initialSort, initialKeyword], - queryFn: ({ pageParam = 1 }) => - getPosts({ - category: initialCategory, - sort: initialSort, - page: pageParam, - keyword: initialKeyword, - }), - initialPageParam: 1, - }); + try { + await queryClient.fetchInfiniteQuery({ + queryKey: ["posts", initialCategory, initialSort, initialKeyword], + queryFn: ({ pageParam = 1 }) => + getPosts({ + category: initialCategory, + sort: initialSort, + page: pageParam, + keyword: initialKeyword, + }), + initialPageParam: 1, + }); + } catch (e) { + handleError(e); + } return ( diff --git a/src/entities/chat/lib/useChatMessages.ts b/src/entities/chat/lib/useChatMessages.ts index 2f0ae757..374c8cd8 100644 --- a/src/entities/chat/lib/useChatMessages.ts +++ b/src/entities/chat/lib/useChatMessages.ts @@ -3,19 +3,18 @@ import { useInfiniteQuery, useQueryClient, } from "@tanstack/react-query"; -import { useEffect, useRef, useMemo } from "react"; +import { useRef, useMemo } from "react"; import { apiFetch } from "@/shared/api/fetcher"; import { MessagesResponse, MessageProps, Chat } from "../model/types"; -import { useModalStore } from "@/shared/model/modal.store"; export const useChatMessages = (chatId: number | null) => { const queryClient = useQueryClient(); const scrollContainerRef = useRef(null); const messagesEndRef = useRef(null); - const { openModal, closeModal } = useModalStore(); const { data, + isError, error, fetchNextPage, hasNextPage, @@ -38,15 +37,6 @@ export const useChatMessages = (chatId: number | null) => { lastPage.hasNext ? lastPage.nextCursor : undefined, }); - useEffect(() => { - if (error) { - openModal("normal", { - message: "메시지 로딩 중 에러가 발생했습니다.", - onClick: closeModal, - }); - } - }, [error]); - const messages: MessageProps[] = useMemo(() => { if (!data) return []; @@ -121,6 +111,7 @@ export const useChatMessages = (chatId: number | null) => { hasNextPage, isMessagesFirstLoading, isMessagesLoading, + isError, error, scrollContainerRef, messagesEndRef, diff --git a/src/entities/chat/ui/ChattingRoom/ChattingRoom.tsx b/src/entities/chat/ui/ChattingRoom/ChattingRoom.tsx index 5bc67d22..b48f0106 100644 --- a/src/entities/chat/ui/ChattingRoom/ChattingRoom.tsx +++ b/src/entities/chat/ui/ChattingRoom/ChattingRoom.tsx @@ -18,6 +18,7 @@ import { getPostDetail } from "@/entities/post/api/getPostDetail"; import { getUser } from "@/entities/user/api/getUser"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { createChattingRoom } from "@/features/chat/api/createChattingRoom"; +import { handleError } from "@/shared/error/handleError"; export const ChattingRoom = ({ postingId, @@ -36,31 +37,46 @@ export const ChattingRoom = ({ data: post, isLoading: isPostLoading, isError: isPostingError, + error: postingError, } = useQuery({ queryKey: ["postDetail", postingId], queryFn: () => getPostDetail(postingId), }); + if (isPostingError) { + handleError(postingError); + } + const { data: otherUser, isLoading: isOtherUserLoading, isError: isOtherUserError, + error: otherUserError, } = useQuery({ queryKey: ["otherUser", otherId], queryFn: () => getUser(otherId), }); + if (isOtherUserError) { + handleError(otherUserError); + } + const { messages, pushMessageToCache, fetchMoreMessages, hasNextPage, - isMessagesFirstLoading, + isError: isChatMessagesError, + error: chatMessagesError, isMessagesLoading, scrollContainerRef, messagesEndRef, } = useChatMessages(chatId); + if (isChatMessagesError) { + handleError(chatMessagesError); + } + const { postStatus: currentPostStatus, dealStatus: currentDealStatus, diff --git a/src/features/chat/ui/ChatList.tsx b/src/features/chat/ui/ChatList.tsx index 8959d2f3..437c6b1b 100644 --- a/src/features/chat/ui/ChatList.tsx +++ b/src/features/chat/ui/ChatList.tsx @@ -5,6 +5,7 @@ import { fetchChatList } from "../model/chat.api"; import type { Chat } from "@/entities/chat/model/types"; import { useQuery } from "@tanstack/react-query"; import { DealStatus } from "@/entities/chat/model/types"; +import { handleError } from "@/shared/error/handleError"; interface ChatListProps { onSelect: (info: { @@ -29,12 +30,12 @@ const ChatList = ({ onSelect, tab = "all" }: ChatListProps) => { queryFn: () => fetchChatList(role), }); - if (isLoading) { - return

불러오는 중...

; + if (isError) { + handleError(error); } - if (isError) { - console.error("채팅 목록 불러오기 실패:", error); + if (isLoading) { + return

불러오는 중...

; } if (!chats?.length) { diff --git a/src/shared/api/fetcher.server.ts b/src/shared/api/fetcher.server.ts index 176cf5dd..a29359c4 100644 --- a/src/shared/api/fetcher.server.ts +++ b/src/shared/api/fetcher.server.ts @@ -1,5 +1,5 @@ import { cookies } from "next/headers"; -import { redirect } from "next/navigation"; +import { AuthorizationError, NotFoundError, ServerError } from "../error/error"; const BASE_URL = process.env.NEXT_PUBLIC_API_URL; @@ -34,7 +34,9 @@ export async function serverFetch( const refreshToken = cookieStore.get("refreshToken")?.value; if (!refreshToken) { - redirect("/login"); + throw new AuthorizationError( + "세션이 만료되었습니다.\n다시 로그인 해주세요.", + ); } const refreshRes = await fetch("/api/auth/refresh", { @@ -59,13 +61,23 @@ export async function serverFetch( ...restOptions, }); } else { - redirect("/login"); + throw new AuthorizationError( + "세션이 만료되었습니다.\n다시 로그인 해주세요.", + ); } } + if (res.status === 404) { + throw new NotFoundError(); + } + + if (res.status >= 500) { + throw new ServerError(); + } + if (!res.ok) { const text = await res.text(); - throw new Error(text || `API Error ${res.status}`); + throw new ServerError(text); } return res.json() as Promise; diff --git a/src/shared/api/fetcher.ts b/src/shared/api/fetcher.ts index 34afef4e..1c54e836 100644 --- a/src/shared/api/fetcher.ts +++ b/src/shared/api/fetcher.ts @@ -1,5 +1,5 @@ import { useAuthStore } from "@/features/auth/model/auth.store"; -import { useModalStore } from "@/shared/model/modal.store"; +import { AuthorizationError, NotFoundError, ServerError } from "../error/error"; const BASE_URL = process.env.NEXT_PUBLIC_API_URL; @@ -15,7 +15,6 @@ export async function apiFetch( const { headers, noAuth, useBaseUrl = true, ...restOptions } = options; const { accessToken, setAccessToken, logout } = useAuthStore.getState(); - const { openModal, closeModal } = useModalStore.getState(); const defaultHeaders: HeadersInit = { "Content-Type": "application/json", @@ -42,17 +41,9 @@ export async function apiFetch( if (!refreshed.ok) { logout(); - - openModal("normal", { - message: "세션이 만료되었습니다. 다시 로그인 해주세요.", - buttonText: "확인", - onClick: () => { - closeModal(); - location.replace("/login"); - }, - }); - - throw new Error("세션 만료"); + throw new AuthorizationError( + "세션이 만료되었습니다.\n다시 로그인 해주세요.", + ); } const { accessToken: newToken } = await refreshed.json(); @@ -69,6 +60,13 @@ export async function apiFetch( } if (!res.ok) { + if (res.status === 404) { + throw new NotFoundError(); + } + + if (res.status >= 500) { + throw new ServerError(); + } let message = `API Error ${res.status}`; try { @@ -79,7 +77,7 @@ export async function apiFetch( if (text) message = text; } - throw new Error(message); + throw new ServerError(message); } return res.json() as Promise; diff --git a/src/shared/error/error.ts b/src/shared/error/error.ts new file mode 100644 index 00000000..66b57be4 --- /dev/null +++ b/src/shared/error/error.ts @@ -0,0 +1,27 @@ +export class BaseError extends Error { + constructor( + message: string, + public readonly status?: number, + ) { + super(message); + this.name = this.constructor.name; + } +} + +export class AuthorizationError extends BaseError { + constructor(message = "인증이 필요합니다.") { + super(message, 401); + } +} + +export class NotFoundError extends BaseError { + constructor(message = "요청한 리소스를 찾을 수 없습니다.") { + super(message, 404); + } +} + +export class ServerError extends BaseError { + constructor(message = "서버 오류가 발생했습니다.") { + super(message, 500); + } +} diff --git a/src/shared/error/handleError.ts b/src/shared/error/handleError.ts new file mode 100644 index 00000000..ce700811 --- /dev/null +++ b/src/shared/error/handleError.ts @@ -0,0 +1,19 @@ +import { notFound, redirect } from "next/navigation"; +import { AuthorizationError, NotFoundError, ServerError } from "./error"; + +export function handleError(error: unknown) { + console.log(error); + if (error instanceof AuthorizationError) { + redirect("/login?expired=true"); + } + + if (error instanceof NotFoundError) { + notFound(); //가장 가까운 not-found.tsx로 이동 + } + + if (error instanceof ServerError) { + throw error; + } + + throw error; +} diff --git a/src/shared/error/ui/404.tsx b/src/shared/error/ui/404.tsx new file mode 100644 index 00000000..f9e43b0b --- /dev/null +++ b/src/shared/error/ui/404.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { useEffect } from "react"; +import { useChatStore } from "@/features/chat/model/chat.store"; +import Link from "next/link"; + +export default function NotFoundErrorPage({ message }: { message?: string }) { + const { unmount } = useChatStore(); + + useEffect(() => { + unmount(); + }, [unmount]); + + return ( +
+

페이지를 찾을 수 없습니다

+

+ {message ?? "요청한 페이지가 존재하지 않습니다."} +

+ + 홈으로 돌아가기 + +
+ ); +} diff --git a/src/shared/error/ui/500.tsx b/src/shared/error/ui/500.tsx new file mode 100644 index 00000000..7b9ceedb --- /dev/null +++ b/src/shared/error/ui/500.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { useEffect } from "react"; +import { useChatStore } from "@/features/chat/model/chat.store"; + +export default function ServerErrorPage({ message }: { message?: string }) { + const { unmount } = useChatStore(); + + useEffect(() => { + unmount(); + }, [unmount]); + + return ( +
+

서버 오류 발생

+

+ {message ?? "잠시 후 다시 시도해주세요."} +

+
+ ); +} diff --git a/src/widgets/main/ui/Client/LoginPage.client.tsx b/src/widgets/main/ui/Client/LoginPage.client.tsx new file mode 100644 index 00000000..ec7ee67a --- /dev/null +++ b/src/widgets/main/ui/Client/LoginPage.client.tsx @@ -0,0 +1,50 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { LoginForm } from "@/features/auth/ui/LoginForm/LoginForm"; +import { Modal } from "@/shared/ui/Modal/Modal"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useAuthStore } from "@/features/auth/model/auth.store"; +import { useChatStore } from "@/features/chat/model/chat.store"; +export default function LoginPageClient() { + const router = useRouter(); + const searchParams = useSearchParams(); + const expired = searchParams.get("expired"); + + const [errorMessage, setErrorMessage] = useState(null); + const { setIsLogined, logout } = useAuthStore(); + const { unmount } = useChatStore(); + + useEffect(() => { + if (expired === "true") { + unmount(); + logout(); + setErrorMessage("세션이 만료되었습니다.\n다시 로그인해주세요."); + } + }, [expired]); + + return ( +
+

로그인

+ { + setIsLogined(true); + router.push("/"); //메인 페이지 이동 + }} + onError={(msg) => { + setErrorMessage(msg); + }} + /> + + {!!errorMessage && ( + setErrorMessage(null)} + className="" + /> + )} +
+ ); +} diff --git a/src/widgets/mypage/ui/Client/MyPage.client.tsx b/src/widgets/mypage/ui/Client/MyPage.client.tsx index 17dfa2ba..54ec3ca7 100644 --- a/src/widgets/mypage/ui/Client/MyPage.client.tsx +++ b/src/widgets/mypage/ui/Client/MyPage.client.tsx @@ -11,6 +11,7 @@ import { useModalStore } from "@/shared/model/modal.store"; import { usePostCreateModal } from "@/features/createPost/lib/usePostCreateModal"; import { getMyPosts, getMyProfile } from "@/entities/user/api/mypage"; import type { Post } from "@/entities/post/model/types/post"; +import { handleError } from "@/shared/error/handleError"; const options = [ { label: "판매중 상품", value: "selling" }, @@ -34,20 +35,32 @@ export default function MyPageClient({ defaultTab }: { defaultTab: string }) { data: userProfile, isLoading: profileLoading, refetch: refetchUserProfile, + isError: isUserProfileError, + error: userProfileError, } = useQuery({ queryKey: ["userProfile"], queryFn: getMyProfile, }); + if (isUserProfileError) { + handleError(userProfileError); + } + const { data: postsData, isLoading: postsLoading, refetch: refetchPosts, + isError: isUserPostsError, + error: userPostsError, } = useQuery<{ data: Post[] }>({ queryKey: ["myPosts", selectedTab], queryFn: () => getMyPosts(selectedTab), }); + if (isUserPostsError) { + handleError(userPostsError); + } + const { openPostCreateModal } = usePostCreateModal({ onSuccess: async () => { await refetchPosts(); diff --git a/src/widgets/postDetail/ui/DetailPage.client.tsx b/src/widgets/postDetail/ui/DetailPage.client.tsx index 31526411..2d9b6e49 100644 --- a/src/widgets/postDetail/ui/DetailPage.client.tsx +++ b/src/widgets/postDetail/ui/DetailPage.client.tsx @@ -5,6 +5,8 @@ import { getPostDetail } from "@/entities/post/api/getPostDetail"; import { PostDetailSection } from "@/widgets/postDetail/ui/PostDetailSection"; import { SellerPostsSection } from "@/widgets/postDetail/ui/SellerPostsSection"; import { PostDetailSkeleton } from "@/widgets/postDetail/ui/DetailSkeleton"; +import { handleError } from "@/shared/error/handleError"; +import { notFound } from "next/navigation"; export default function PostDetailPageClient({ postingId, @@ -13,20 +15,27 @@ export default function PostDetailPageClient({ }) { const id = Number(postingId); - const { data: post, isLoading } = useQuery({ + const { + data: post, + isLoading, + isError, + error, + } = useQuery({ queryKey: ["postDetail", id], queryFn: () => getPostDetail(id), staleTime: Infinity, }); + if (isError) { + handleError(error); + } + if (isLoading) { return ; } if (!post) { - return ( -

게시글 정보를 찾을 수 없습니다.

- ); + notFound(); } return ( diff --git a/src/widgets/postDetail/ui/PostDetailSection.tsx b/src/widgets/postDetail/ui/PostDetailSection.tsx index 30b14ee3..ea4ef82b 100644 --- a/src/widgets/postDetail/ui/PostDetailSection.tsx +++ b/src/widgets/postDetail/ui/PostDetailSection.tsx @@ -19,6 +19,7 @@ import { useAuthStore } from "@/features/auth/model/auth.store"; import { PostDetail } from "@/entities/post/model/types/post"; import PostStatusBadge from "@/entities/post/ui/badge/PostStatusBadge"; import { getChattingRoomStatus } from "@/features/chat/api/getChattingRoomStatus"; +import { handleError } from "@/shared/error/handleError"; export function PostDetailSection({ post }: { post: PostDetail }) { const router = useRouter(); @@ -27,17 +28,33 @@ export function PostDetailSection({ post }: { post: PostDetail }) { const { openModal, closeModal } = useModalStore(); const openChat = useChatStore((s) => s.mount); - const { data: seller } = useQuery({ + const { + data: seller, + isError: isSellerError, + error: sellerError, + } = useQuery({ queryKey: ["seller", post.sellerId], queryFn: () => getUser(post.sellerId), enabled: !!post.sellerId, }); - const { data: chattingRoomStatus } = useQuery({ + if (isSellerError) { + handleError(sellerError); + } + + const { + data: chattingRoomStatus, + isError: isChattingRoomStatusError, + error: chattingRoomStatusError, + } = useQuery({ queryKey: ["findRoom", post.postingId], queryFn: () => getChattingRoomStatus(post.postingId), }); + if (isChattingRoomStatusError) { + handleError(chattingRoomStatusError); + } + const { toggleLike, isLoading: isLikeLoading } = useLike(post.postingId); const handleToggleLike = () => toggleLike(!post.isFavorite);