diff --git a/services/one-app/package.json b/services/one-app/package.json index fd3fc926..da8b052f 100644 --- a/services/one-app/package.json +++ b/services/one-app/package.json @@ -15,6 +15,7 @@ }, "dependencies": { "@faker-js/faker": "^9.0.3", + "@sentry/react": "^8.38.0", "@tanstack/react-query": "^5.59.16", "axios": "^1.7.7", "clsx": "^2.1.1", @@ -22,6 +23,7 @@ "next": "14.2.16", "react": "^18", "react-dom": "^18", + "react-error-boundary": "^4.0.13", "tailwind-merge": "^2.5.4", "zod": "^3.23.8", "zustand": "^5.0.0" diff --git a/services/one-app/src/__test__/1__StartPage.spec.tsx b/services/one-app/src/__test__/1__StartPage.spec.tsx index 4015daf1..7678f5c9 100644 --- a/services/one-app/src/__test__/1__StartPage.spec.tsx +++ b/services/one-app/src/__test__/1__StartPage.spec.tsx @@ -1,7 +1,9 @@ import { render, screen } from '@testing-library/react'; -import Page from '@/app/(site)/page'; +import Page from '@/app/page'; it.skip('App Router: Works with Server Components', () => { render(); - expect(screen.getByRole('heading', { name: /App Router/i })).toHaveTextContent('App Router'); -}); \ No newline at end of file + expect( + screen.getByRole('heading', { name: /App Router/i }), + ).toHaveTextContent('App Router'); +}); diff --git a/services/one-app/src/app/(site)/_component/LoggedIn.tsx b/services/one-app/src/app/(site)/_component/LoggedIn.tsx index 21b41274..2d61ecd8 100644 --- a/services/one-app/src/app/(site)/_component/LoggedIn.tsx +++ b/services/one-app/src/app/(site)/_component/LoggedIn.tsx @@ -17,6 +17,15 @@ export default function LoggedIn() { } return ( -

Logged in: {isLoggedIn ? 'O' : 'X'}

+
+

Logged in: {isLoggedIn ? 'O' : 'X'}

+ +
); -} \ No newline at end of file +} diff --git a/services/one-app/src/app/(site)/_component/UseQueryComponent.tsx b/services/one-app/src/app/(site)/_component/UseQueryComponent.tsx new file mode 100644 index 00000000..3527a96a --- /dev/null +++ b/services/one-app/src/app/(site)/_component/UseQueryComponent.tsx @@ -0,0 +1,26 @@ +'use client'; + +import React from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { apiClient } from '@/app/api'; +import SpinnerIcon from '@/common/assets/icons/loading-spinner'; + +const UseQueryComponent = () => { + const errorQuery = useQuery({ + queryKey: ['error', 'useQuery'], + queryFn: () => apiClient.get('/api/example-error'), + }); + + console.log({ error: errorQuery?.error }); + + if (errorQuery.isLoading) + return ( +
+ +
+ ); + + return
UseQueryComponent
; +}; + +export default UseQueryComponent; diff --git a/services/one-app/src/app/(site)/login/callback/page.tsx b/services/one-app/src/app/(site)/login/callback/page.tsx index 67839f55..6a815615 100644 --- a/services/one-app/src/app/(site)/login/callback/page.tsx +++ b/services/one-app/src/app/(site)/login/callback/page.tsx @@ -5,7 +5,7 @@ import { useRouter, useSearchParams } from 'next/navigation'; import { useShallow } from 'zustand/shallow'; import { requestLogin } from '../_lib/requestLogin'; -import { useTemporaryAuthStore } from '@/store/auth'; +import { useTemporaryAuthStore } from '@/store/authStore'; import { isValidSocialSignInType } from '@/model/Auth'; import { AuthService } from '@/common/service/AuthService'; diff --git a/services/one-app/src/app/(site)/login/nickname/page.tsx b/services/one-app/src/app/(site)/login/nickname/page.tsx index 5e8ca78a..9bf226f8 100644 --- a/services/one-app/src/app/(site)/login/nickname/page.tsx +++ b/services/one-app/src/app/(site)/login/nickname/page.tsx @@ -8,7 +8,7 @@ import { useShallow } from 'zustand/shallow'; import { updateUser } from '../../my/_lib/updateUser'; import { useCheckNickname } from '../_lib/useCheckNickname'; import { renderIndicatorIcon } from '../_lib/utilityFunctions'; -import { useTemporaryAuthStore } from '@/store/auth'; +import { useTemporaryAuthStore } from '@/store/authStore'; import { AuthService } from '@/common/service/AuthService'; import { cn } from '@/common/utils/cn'; import ArrowLeftIcon from '@/common/assets/icons/arrow-left'; diff --git a/services/one-app/src/app/(site)/my/_lib/updateUser.ts b/services/one-app/src/app/(site)/my/_lib/updateUser.ts index 3862d311..48b24064 100644 --- a/services/one-app/src/app/(site)/my/_lib/updateUser.ts +++ b/services/one-app/src/app/(site)/my/_lib/updateUser.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; import axios from 'axios'; -import type { TemporaryUserAuthData } from '@/store/auth'; +import type { TemporaryUserAuthData } from '@/store/authStore'; import { API_BASE_URL } from '@/common/constants/env'; import { APIResponseCode, RESPONSE_MESSAGES } from '@/common/constants/api'; diff --git a/services/one-app/src/app/_components/ErrorCatcher.tsx b/services/one-app/src/app/_components/ErrorCatcher.tsx new file mode 100644 index 00000000..24afd935 --- /dev/null +++ b/services/one-app/src/app/_components/ErrorCatcher.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { useEffect } from 'react'; + +import { useAppErrorStore } from '@/store/appErrorStore'; +import { captureError } from '@/common/utils/error/captureError'; +import { isRequestFailedError } from '@/common/errors/RequestError'; +import { RESPONSE_MESSAGES } from '@/common/constants/api'; + +/** + * @description + * 리액트 쿼리에서 의도적으로 throw한 에러를 처리합니다. + * 1. 센트리로 에러를 전송합니다. + * 2. 유저에게 토스트 UI를 통해 에러 상황을 전달합니다. + * 3. 예측하지 못한 런타임 에러 등은 error.tsx로 전파합니다. + */ +export const ErrorCatcher = ({ children }: React.PropsWithChildren) => { + const { appError: error } = useAppErrorStore(); + + useEffect(() => { + if (!error) return; + + captureError(error); // 센트리로 에러 전송 + + if (!isRequestFailedError(error) || !isPredictableError(error)) throw error; // error.tsx로 전파 + + // TODO: 토스트 컴포넌트 만들어서 alert 대체하기 + // toast.error(RESPONSE_MESSAGES[error.errorCode], { + // showingTime: 3000, + // position: 'bottom', + // bottom: '8rem', + // }); + alert(RESPONSE_MESSAGES[error.errorCode]); + }, [error]); + + return children; +}; + +const isPredictableError = (error: Error): boolean => { + return Object.values(RESPONSE_MESSAGES).includes(error.message); +}; diff --git a/services/one-app/src/app/_components/PathTracker.tsx b/services/one-app/src/app/_components/PathTracker.tsx new file mode 100644 index 00000000..eea9a4f8 --- /dev/null +++ b/services/one-app/src/app/_components/PathTracker.tsx @@ -0,0 +1,18 @@ +'use client'; + +import Cookies from 'js-cookie'; +import { useEffect } from 'react'; +import { useCurrentPath } from '@/common/hooks/useCurrentPath'; + +export const PathTracker = () => { + const currentPath = useCurrentPath(); + + useEffect(() => { + const previousPath = Cookies.get('currentPath') || 'null'; + + Cookies.set('currentPath', currentPath, { expires: 7 }); + Cookies.set('previousPath', previousPath, { expires: 7 }); + }, [currentPath]); + + return null; +}; diff --git a/services/one-app/src/app/_components/RQProvider.tsx b/services/one-app/src/app/_components/RQProvider.tsx index dbff1579..70100d72 100644 --- a/services/one-app/src/app/_components/RQProvider.tsx +++ b/services/one-app/src/app/_components/RQProvider.tsx @@ -1,30 +1,54 @@ 'use client'; -import React, { useState } from 'react'; -import { QueryClientProvider, QueryClient } from '@tanstack/react-query'; +import React from 'react'; +import { + QueryClientProvider, + QueryClient, + QueryCache, + MutationCache, +} from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; +import { useAppErrorStore } from '@/store/appErrorStore'; +import { RequestGetError } from '@/common/errors/RequestGetError'; + type Props = { children: React.ReactNode; }; export const RQProvider = ({ children }: Props) => { - const [client] = useState( - new QueryClient({ - defaultOptions: { - // react-query 전역 설정 - queries: { - retry: false, - retryOnMount: true, - refetchOnReconnect: false, - refetchOnWindowFocus: false, - }, + const { updateAppError } = useAppErrorStore(); + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + throwOnError: true, + refetchOnWindowFocus: false, + }, + }, + queryCache: new QueryCache({ + onError: (error: Error) => { + console.log('QueryCache Error:', error); + if ( + error instanceof RequestGetError && + // errorBoundary로 처리해야하는 에러인 경우 updateAppError를 하지 못하도록 얼리리턴 + error.errorHandlingStrategy === 'fallback' + ) + return; + + updateAppError(error); }, }), - ); + mutationCache: new MutationCache({ + onError: (error: Error) => { + updateAppError(error); + }, + }), + }); return ( - + {children} { instance.interceptors.request.use( (config) => { const requestConfig = config; - - // Access Token 설정 const accessToken = AuthService.accessToken; if (accessToken) { requestConfig.headers.Authorization = `Bearer ${accessToken}`; } - return requestConfig; }, - (err: AxiosError): Promise => Promise.reject(err), + async (error: AxiosError) => { + const customError = await createError({ error }); + return Promise.reject(customError); + }, ); - // TODO, 응답 에러 처리 다듬기 instance.interceptors.response.use( (response) => response, - async (error) => { - if (isAxiosError(error) && error.response?.data) { - const { code } = error.response.data as APIErrorResponse; - - // console.log(code, error.response.data); + async (error: AxiosError) => { + if (error.response?.data) { + const { code } = error.response.data as ErrorInfo; - if (code === '202') { - // 액세스 토큰 만료 시 재요청 + if (code === ERROR_CODES.ACCESS_TOKEN_EXPIRED) { return AuthService.resetTokenAndRetryRequest(error); } - if (['201', '203', '204', '205'].includes(code)) { - // 세션 만료 시 로그아웃 처리 + if (isSessionExpiredCode(code)) { AuthService.expireSession(); - return Promise.reject(error); + return Promise.resolve({ sessionExpired: true }); } } - console.error(error); - return Promise.reject(error); + const customError = await createError({ error: error as AxiosError }); + return Promise.reject(customError); }, ); return instance; }; +type CreateError = { + error: AxiosError; +}; + +const createError = async ({ + error, + errorHandlingStrategy, +}: WithErrorHandlingStrategy): Promise< + RequestFailedError | RequestGetError +> => { + if (!error.response) { + throw error; + } + + const { status, config, data } = error.response; + const { code, message } = data as ErrorInfo; + + const errorParams = { + status, + requestBody: config.data, + endpoint: config.url || '', + method: config.method || '', + errorHandlingStrategy, + message, + errorCode: code, + }; + + return config.method?.toUpperCase() === 'GET' + ? new RequestGetError(errorParams) + : new RequestFailedError(errorParams); +}; + const apiClient = setInterceptor( axios.create({ baseURL: API_BASE_URL, diff --git a/services/one-app/src/app/error.tsx b/services/one-app/src/app/error.tsx new file mode 100644 index 00000000..2e0e6d9b --- /dev/null +++ b/services/one-app/src/app/error.tsx @@ -0,0 +1,22 @@ +'use client'; + +import { useEffect } from 'react'; + +export default function ErrorPage({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + console.error('error in ErrorPage', error); + }, []); + + return ( +
+

Something went wrong!

+ +
+ ); +} diff --git a/services/one-app/src/app/(site)/layout.tsx b/services/one-app/src/app/layout.tsx similarity index 51% rename from services/one-app/src/app/(site)/layout.tsx rename to services/one-app/src/app/layout.tsx index 0741d4ee..76d9f084 100644 --- a/services/one-app/src/app/(site)/layout.tsx +++ b/services/one-app/src/app/layout.tsx @@ -1,9 +1,14 @@ +import { Suspense } from 'react'; import type { Metadata } from 'next'; -import '../globals.css'; -import { RQProvider } from '../_components/RQProvider'; -import { MSWComponent } from '../_components/MSWComponent'; + +import './globals.css'; +import { RQProvider } from './_components/RQProvider'; +import { MSWComponent } from './_components/MSWComponent'; + import { cn } from '@/common/utils/cn'; import { Pretendard } from '@/common/assets/fonts/pretendard'; +import { PathTracker } from './_components/PathTracker'; +import { ErrorCatcher } from './_components/ErrorCatcher'; export const metadata: Metadata = { title: 'Create Next App', @@ -19,7 +24,14 @@ export default function RootLayout({ - {children} + + + {children} + + + + + ); diff --git a/services/one-app/src/app/(site)/not-found.tsx b/services/one-app/src/app/not-found.tsx similarity index 75% rename from services/one-app/src/app/(site)/not-found.tsx rename to services/one-app/src/app/not-found.tsx index 747b77ef..3b9be425 100644 --- a/services/one-app/src/app/(site)/not-found.tsx +++ b/services/one-app/src/app/not-found.tsx @@ -1,11 +1,11 @@ -import Link from 'next/link'; +// import Link from 'next/link'; import { NextPage } from 'next'; const NotFound: NextPage = () => { return (
이 페이지는 존재하지 않습니다. 다른 페이지를 구경해보세요.
- 이동 + {/* 이동 */}
); }; diff --git a/services/one-app/src/app/(site)/page.tsx b/services/one-app/src/app/page.tsx similarity index 67% rename from services/one-app/src/app/(site)/page.tsx rename to services/one-app/src/app/page.tsx index 7454a590..814cf8ac 100644 --- a/services/one-app/src/app/(site)/page.tsx +++ b/services/one-app/src/app/page.tsx @@ -1,6 +1,7 @@ import Link from 'next/link'; import PlusIcon from '@/common/assets/icons/plus'; -import LoggedIn from './_component/LoggedIn'; +import LoggedIn from './(site)/_component/LoggedIn'; +import UseQueryComponent from './(site)/_component/UseQueryComponent'; export default function Home() { return ( @@ -11,6 +12,7 @@ export default function Home() { - + + ); } diff --git a/services/one-app/src/common/assets/icons/info.tsx b/services/one-app/src/common/assets/icons/info.tsx new file mode 100644 index 00000000..fb1e8ad5 --- /dev/null +++ b/services/one-app/src/common/assets/icons/info.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; + +function InfoIcon() { + return ( + + + + ); +} + +export default InfoIcon; diff --git a/services/one-app/src/common/configure-axios.ts b/services/one-app/src/common/configure-axios.ts deleted file mode 100644 index c241835d..00000000 --- a/services/one-app/src/common/configure-axios.ts +++ /dev/null @@ -1,17 +0,0 @@ -import axios, { AxiosRequestConfig } from 'axios'; - -const config: AxiosRequestConfig = {}; -const baseURL = - process.env.NEXT_PUBLIC_API_MOCKING === 'enabled' - ? 'http://localhost:9090' - : process.env.NEXT_PUBLIC_BASE_URL; -config.baseURL = baseURL; - -const API_BASE = axios.create(config); -enum API_ROUTES { - AUTH = '/auth', - MEMBER = '/members', - COMMUNITY = '/community-posts', -} - -export { API_BASE, API_ROUTES }; diff --git a/services/one-app/src/common/constants/api.ts b/services/one-app/src/common/constants/api.ts index e2e6194a..cbf4c45b 100644 --- a/services/one-app/src/common/constants/api.ts +++ b/services/one-app/src/common/constants/api.ts @@ -8,10 +8,33 @@ export enum APIResponseCode { SUCCESS = '100', BAD_REQUEST = '101', INTERNAL_SERVER_ERROR = '102', + INVALID_DOMAIN = '103', + INVALID_ID_TOKEN = '200', + INVALID_ACCESS_TOKEN = '201', + EXPIRED_ACCESS_TOKEN = '202', + INVALID_REFRESH_TOKEN = '203', + EXPIRED_REFRESH_TOKEN = '204', + INVALID_PERMISSION_CODE = '205', } export const RESPONSE_MESSAGES: Record = { [APIResponseCode.SUCCESS]: 'SUCCESS', [APIResponseCode.BAD_REQUEST]: 'BAD REQUEST', [APIResponseCode.INTERNAL_SERVER_ERROR]: 'INTERNAL SERVER ERROR', -}; + [APIResponseCode.INVALID_DOMAIN]: '유효하지 않은 도메인입니다.', + [APIResponseCode.INVALID_ID_TOKEN]: '유효하지 않은 ID 토큰입니다.', + [APIResponseCode.INVALID_ACCESS_TOKEN]: '유효하지 않은 엑세스 토큰입니다.', + [APIResponseCode.EXPIRED_ACCESS_TOKEN]: + '유효기간이 만료된 엑세스 토큰입니다.', + [APIResponseCode.INVALID_REFRESH_TOKEN]: '유효하지 않은 리프레쉬 토큰입니다.', + [APIResponseCode.EXPIRED_REFRESH_TOKEN]: + '유효기간이 만료된 리프레쉬 토큰입니다.', + [APIResponseCode.INVALID_PERMISSION_CODE]: '유효하지 않은 권한 코드입니다.', +} as const; + +export type ErrorCode = `${APIResponseCode}`; + +export interface ErrorInfo { + code: ErrorCode; + message: string; +} diff --git a/services/one-app/src/common/errors/BaseError.tsx b/services/one-app/src/common/errors/BaseError.tsx new file mode 100644 index 00000000..a2e00430 --- /dev/null +++ b/services/one-app/src/common/errors/BaseError.tsx @@ -0,0 +1,52 @@ +import { SITE_URL } from '../constants/env'; +import { getCurrentPath } from '../utils/route'; + +type PathInfo = { + pathname: string; + params?: Record; + searchParams?: Record; +}; + +export class BaseError extends Error { + currentPath: PathInfo; + previousPath: PathInfo; + timestamp: string; + stack?: string; + + constructor(message: string) { + super(message); + this.name = this.constructor.name; + const { currentPath, previousPath } = getCurrentPath(); + this.currentPath = this.parsePathInfo(currentPath); + this.previousPath = this.parsePathInfo(previousPath); + this.timestamp = new Date().toISOString(); + } + + private parsePathInfo(path: string): PathInfo { + try { + const url = new URL(path, SITE_URL); + return { + pathname: url.pathname, + params: {}, + searchParams: Object.fromEntries(url.searchParams.entries()), + }; + } catch { + return { pathname: path }; + } + } + + toJSON() { + return { + name: this.name, + message: this.message, + timestamp: this.timestamp, + currentPath: this.currentPath, + previousPath: this.previousPath, + stack: this.stack, + }; + } + + toString(): string { + return JSON.stringify(this.toJSON(), null, 2); + } +} diff --git a/services/one-app/src/common/errors/RequestError.ts b/services/one-app/src/common/errors/RequestError.ts new file mode 100644 index 00000000..0578fa6e --- /dev/null +++ b/services/one-app/src/common/errors/RequestError.ts @@ -0,0 +1,45 @@ +import { BaseError } from './BaseError'; +import { type ErrorCode, RESPONSE_MESSAGES } from '@/common/constants/api'; + +export type RequestFailedErrorType = { + requestBody?: any; + status: number; + endpoint: string; + errorCode: ErrorCode; + message: string; + method: string; +}; + +export class RequestFailedError extends BaseError { + requestBody?: any; + status: number; + endpoint: string; + errorCode: ErrorCode; + errorMessage: string; + method: string; + + constructor({ + requestBody, + status, + endpoint, + errorCode, + message, + method, + }: RequestFailedErrorType) { + super(message); + this.name = this.constructor.name; + this.status = status; + this.method = method; + this.endpoint = endpoint; + this.errorCode = errorCode; + this.requestBody = requestBody; + this.errorMessage = + RESPONSE_MESSAGES[message as keyof typeof RESPONSE_MESSAGES] || message; + } +} + +export function isRequestFailedError( + error: unknown, +): error is RequestFailedError { + return error instanceof RequestFailedError; +} diff --git a/services/one-app/src/common/errors/RequestGetError.ts b/services/one-app/src/common/errors/RequestGetError.ts new file mode 100644 index 00000000..448a1123 --- /dev/null +++ b/services/one-app/src/common/errors/RequestGetError.ts @@ -0,0 +1,24 @@ +import { + RequestFailedError, + type RequestFailedErrorType, +} from './RequestError'; + +type ErrorHandlingStrategy = 'toast' | 'fallback'; + +// GET 에러 발생 시 토스트로 에러를 표현할 지 errorBounadry의 fallback으로 에러를 표현할 지 +export type WithErrorHandlingStrategy

= P & { + errorHandlingStrategy?: ErrorHandlingStrategy; +}; + +export class RequestGetError extends RequestFailedError { + errorHandlingStrategy; + + constructor({ + errorHandlingStrategy = 'toast', + ...rest + }: WithErrorHandlingStrategy) { + super(rest); + + this.errorHandlingStrategy = errorHandlingStrategy; + } +} diff --git a/services/one-app/src/common/hooks/useCurrentPath.ts b/services/one-app/src/common/hooks/useCurrentPath.ts new file mode 100644 index 00000000..b508380a --- /dev/null +++ b/services/one-app/src/common/hooks/useCurrentPath.ts @@ -0,0 +1,10 @@ +'use client'; + +import { usePathname, useSearchParams } from 'next/navigation'; + +export const useCurrentPath = () => { + const pathname = usePathname(); + const searchParams = useSearchParams(); + + return pathname + searchParams.toString(); +}; diff --git a/services/one-app/src/common/hooks/useDetectOnline.ts b/services/one-app/src/common/hooks/useDetectOnline.ts new file mode 100644 index 00000000..6044ea3b --- /dev/null +++ b/services/one-app/src/common/hooks/useDetectOnline.ts @@ -0,0 +1,43 @@ +import React, { useCallback, useState, useEffect } from 'react'; + +export const useDetectOnline = () => { + const [isOffline, setIsOffline] = useState(false); + const handleNetworkOnline = () => setIsOffline(false); + const handleNetworkOffline = () => setIsOffline(true); + + const addNetworkStateEventListener = () => { + window.addEventListener('online', handleNetworkOnline); + window.addEventListener('offline', handleNetworkOffline); + }; + + const removeNetworkStateEventListener = () => { + window.removeEventListener('online', handleNetworkOnline); + window.removeEventListener('offline', handleNetworkOffline); + }; + + useEffect(() => { + addNetworkStateEventListener(); + + return removeNetworkStateEventListener; + }, []); + + const runIfOnline = useCallback( + ({ + onNowOnline, + onNowOffline, + }: { + onNowOnline: () => void; + onNowOffline: () => void; + }) => { + if (window.navigator.onLine) { + onNowOnline(); + } else { + onNowOffline(); + window.addEventListener('online', onNowOnline, { once: true }); + } + }, + [], + ); + + return { isOffline, runIfOnline }; +}; diff --git a/services/one-app/src/common/service/AuthService.ts b/services/one-app/src/common/service/AuthService.ts index 3797bcda..aab4206b 100644 --- a/services/one-app/src/common/service/AuthService.ts +++ b/services/one-app/src/common/service/AuthService.ts @@ -34,7 +34,7 @@ class _AuthService { expireSession() { Cookies.remove(CookieKey.ACCESS_TOKEN); Cookies.remove(CookieKey.REFRESH_TOKEN); - window.location.replace('/login'); // TODO, 리다이렉트 경로 설정 + // window.location.replace('/login'); // TODO, 리다이렉트 경로 설정 } get isLoggedIn() { diff --git a/services/one-app/src/common/utils/error/captureError.ts b/services/one-app/src/common/utils/error/captureError.ts new file mode 100644 index 00000000..8814f54e --- /dev/null +++ b/services/one-app/src/common/utils/error/captureError.ts @@ -0,0 +1,40 @@ +import { sendLogToSentry } from './sendLogToSentry'; + +import { BaseError } from '@/common/errors/BaseError'; +import { RequestFailedError } from '@/common/errors/RequestError'; +import { IS_DEV_ENV } from '@/common/constants/env'; +import { RESPONSE_MESSAGES } from '@/common/constants/api'; + +export const captureError = async (error: Error) => { + // local 환경이면 얼리리턴 + if (IS_DEV_ENV) return; + + // instanceof 연산자는 객체의 프로토타입 체인을 따라 올라가면서 일치하는 생성자를 찾습니다. + // 따라서 (RequestFailedError가 BaseError를 상속하는 클래스여도) + // RequestFailedError의 인스턴스는 RequestFailedError의 instanceof 체크에서 먼저 true를 반환하게 됩니다. + if (error instanceof RequestFailedError) { + switch (error.errorMessage) { + case RESPONSE_MESSAGES[102]: + sendLogToSentry({ error, level: 'fatal' }); + break; + case RESPONSE_MESSAGES[103]: + case RESPONSE_MESSAGES[200]: + case RESPONSE_MESSAGES[201]: + case RESPONSE_MESSAGES[203]: + case RESPONSE_MESSAGES[204]: + case RESPONSE_MESSAGES[205]: + sendLogToSentry({ error, level: 'info' }); + break; + case RESPONSE_MESSAGES[101]: + sendLogToSentry({ error, level: 'warning' }); + break; + default: + sendLogToSentry({ error, level: 'error' }); + break; + } + } else if (error instanceof BaseError) { + sendLogToSentry({ error, level: 'error' }); + } else { + sendLogToSentry({ error, level: 'fatal' }); + } +}; diff --git a/services/one-app/src/common/utils/error/sendLogToSentry.ts b/services/one-app/src/common/utils/error/sendLogToSentry.ts new file mode 100644 index 00000000..74c82903 --- /dev/null +++ b/services/one-app/src/common/utils/error/sendLogToSentry.ts @@ -0,0 +1,71 @@ +import * as Sentry from '@sentry/react'; + +import { BaseError } from '@/common/errors/BaseError'; +import { RequestFailedError } from '@/common/errors/RequestError'; + +type SentryLevel = 'fatal' | 'error' | 'warning' | 'info' | 'debug' | 'log'; +type SendLogToSentry = { + level?: SentryLevel; + error: Error; +}; + +/** + * level은 아래와 같은 용도에 맞게 지정해줍니다. + * + * fatal: 앱이 종료될 수 있는 치명적인 오류 + * error: 특정 기능 실패로 앱 종료까지는 아닌 오류 + * warning: 잠재적으로 문제가 될 수 있는 오류. 현재는 심각하지 않은 오류 + * info: 시스템의 정상적인 동작을 나타냄. 중요한 이벤트나 상태 변화 기록용 + * debug: 디버깅 목적으로 사용됨 + * log: 일반적인 로그 메세지 + */ +export const sendLogToSentry = ({ + level = 'error', + error, +}: SendLogToSentry) => { + Sentry.withScope((scope) => { + scope.setLevel(level); + scope.setTag('environment', process.env.NODE_ENV); + + if (error instanceof BaseError) { + const { name, message, currentPath, previousPath } = error; + scope.setTags({ + url: window.location.href, + name, + message, + currentPath: JSON.stringify(currentPath), + previousPath: JSON.stringify(previousPath), + }); + + if (error instanceof RequestFailedError) { + const { + errorCode, + errorMessage, + endpoint, + status, + requestBody, + method, + } = error; + scope.setTags({ + errorCode, + errorMessage, + endpoint, + status, + requestBody: JSON.stringify(requestBody), + method, + }); + Sentry.captureMessage(`${errorCode}: ${errorMessage}`); + } else { + Sentry.captureMessage(`${name}: ${message}`); + } + } else { + const { name, message } = error; + scope.setTags({ + url: window.location.href, + name, + message, + }); + Sentry.captureMessage(`${name}: ${message}`); + } + }); +}; diff --git a/services/one-app/src/common/utils/route.ts b/services/one-app/src/common/utils/route.ts new file mode 100644 index 00000000..edc5ec98 --- /dev/null +++ b/services/one-app/src/common/utils/route.ts @@ -0,0 +1,8 @@ +import Cookies from 'js-cookie'; + +export const getCurrentPath = () => { + const currentPath = Cookies.get('currentPath') || ''; + const previousPath = Cookies.get('previousPath') || ''; + + return { previousPath, currentPath }; +}; diff --git a/services/one-app/src/mocks/handlers.ts b/services/one-app/src/mocks/handlers.ts index 527af3a2..daa4f6a7 100644 --- a/services/one-app/src/mocks/handlers.ts +++ b/services/one-app/src/mocks/handlers.ts @@ -1,7 +1,13 @@ -import { authHandlers, lostFoundHandlers, userHandlers } from './units'; +import { + authHandlers, + userHandlers, + lostFoundHandlers, + throwErrorHandlers, +} from './units'; export const handlers = [ ...authHandlers, ...userHandlers, ...lostFoundHandlers, + ...throwErrorHandlers, ]; diff --git a/services/one-app/src/mocks/units/index.ts b/services/one-app/src/mocks/units/index.ts index e9922d74..3287ed18 100644 --- a/services/one-app/src/mocks/units/index.ts +++ b/services/one-app/src/mocks/units/index.ts @@ -1,3 +1,4 @@ export { default as authHandlers } from './auth'; export { default as userHandlers } from './user'; export { default as lostFoundHandlers } from './lost-found'; +export { default as throwErrorHandlers } from './throw-error'; diff --git a/services/one-app/src/mocks/units/throw-error.ts b/services/one-app/src/mocks/units/throw-error.ts new file mode 100644 index 00000000..042b7896 --- /dev/null +++ b/services/one-app/src/mocks/units/throw-error.ts @@ -0,0 +1,39 @@ +import { HttpResponse, http } from 'msw'; +import { + ErrorInfo, + ErrorCode, + APIResponseCode, + RESPONSE_MESSAGES, +} from '@/common/constants/api'; +import { createSuccessResponse } from '../mock-utils'; + +const createErrorResponse = (errorInfo: ErrorInfo): HttpResponse => { + return HttpResponse.json( + { ...errorInfo, result: null }, + { status: 400 }, // 모든 에러를 400으로 처리하되, 실제 에러 코드는 응답 본문에 포함 + ); +}; + +const throwRandomError = () => { + const errorCodes = Object.values(APIResponseCode) as ErrorCode[]; + const randomErrorCode = + errorCodes[Math.floor(Math.random() * errorCodes.length)]; + + if (randomErrorCode === APIResponseCode.SUCCESS) + return createSuccessResponse({}); + + return createErrorResponse({ + code: randomErrorCode, + message: RESPONSE_MESSAGES[randomErrorCode], + }); +}; + +const exampleGetErrorHandler = http.get('/api/example-error', () => { + return throwRandomError(); +}); + +const examplePostErrorHandler = http.post('/api/example-error', () => { + return throwRandomError(); +}); + +export default [exampleGetErrorHandler, examplePostErrorHandler]; diff --git a/services/one-app/src/model/Error.ts b/services/one-app/src/model/Error.ts deleted file mode 100644 index 852007b5..00000000 --- a/services/one-app/src/model/Error.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { HttpStatusCode } from 'axios'; - -export type ErrorCode = - | '100' - | '101' - | '102' - | '103' - | '200' - | '201' - | '202' - | '203' - | '204' - | '205'; - -export type ErrorResponseMessages = - | 'SUCCESS' - | 'BAD_REQUEST' - | 'INTERNAL_SERVER_ERROR' - | '유효하지 않은 도메인입니다.' - | '유효하지 않은 ID 토큰입니다.' - | '유효기간이 만료된 엑세스 토큰입니다.' - | '유효하지 않은 리프레쉬 토큰입니다.' - | '유효기간이 만료된 리프레쉬 토큰입니다.' - | '유효하지 않은 권한 코드입니다.' - | '유효하지 않은 액세스 토큰입니다.'; - -export type ErrorCodeInfo = Partial>; - -export type APIErrorResponse = { - code: ErrorCode; - errors: string; - message: string; - status: HttpStatusCode; - statusText: string; -}; diff --git a/services/one-app/src/store/appErrorStore.ts b/services/one-app/src/store/appErrorStore.ts new file mode 100644 index 00000000..8f38247c --- /dev/null +++ b/services/one-app/src/store/appErrorStore.ts @@ -0,0 +1,14 @@ +import { create } from 'zustand'; + +type State = { + appError: Error | null; +}; + +type Action = { + updateAppError: (appError: State['appError']) => void; +}; + +export const useAppErrorStore = create((set) => ({ + appError: null, + updateAppError: (appError) => set(() => ({ appError })), +})); diff --git a/services/one-app/src/store/auth.ts b/services/one-app/src/store/authStore.ts similarity index 52% rename from services/one-app/src/store/auth.ts rename to services/one-app/src/store/authStore.ts index 4083bfc3..0c9268ee 100644 --- a/services/one-app/src/store/auth.ts +++ b/services/one-app/src/store/authStore.ts @@ -2,19 +2,24 @@ import { z } from 'zod'; import { create } from 'zustand'; import { LoginResponseSchema } from '@/app/(site)/login/_lib/requestLogin'; -export type TemporaryUserAuthData = Pick< +type TemporaryUserAuthData = Pick< z.infer['result'], 'accessToken' | 'refreshToken' >; -interface TemporaryAuthState { +type State = { auth: TemporaryUserAuthData | null; - setTempAuth: (authData: TemporaryUserAuthData) => void; +}; + +type Action = { + setTempAuth: (authData: State['auth']) => void; reset: () => void; -} +}; -export const useTemporaryAuthStore = create((set) => ({ +const useTemporaryAuthStore = create((set) => ({ auth: null, - setTempAuth: (authData: TemporaryUserAuthData) => set({ auth: authData }), + setTempAuth: (authData) => set({ auth: authData }), reset: () => set({ auth: null }), })); + +export { type TemporaryUserAuthData, useTemporaryAuthStore }; diff --git a/services/one-app/src/store/count.ts b/services/one-app/src/store/count.ts deleted file mode 100644 index adf9b9c5..00000000 --- a/services/one-app/src/store/count.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { create } from 'zustand'; - -interface CountState { - count: number; - inc(): void; -} - -export const useStore = create((set) => ({ - count: 1, - inc: () => set((state) => ({ count: state.count + 1 })), -})); diff --git a/yarn.lock b/yarn.lock index 72cddd3b..a78f8976 100644 --- a/yarn.lock +++ b/yarn.lock @@ -56,6 +56,7 @@ __metadata: dependencies: "@faker-js/faker": "npm:^9.0.3" "@mswjs/http-middleware": "npm:^0.10.2" + "@sentry/react": "npm:^8.38.0" "@tanstack/react-query": "npm:^5.59.16" "@tanstack/react-query-devtools": "npm:^5.59.16" "@testing-library/dom": "npm:^10.4.0" @@ -84,6 +85,7 @@ __metadata: postcss: "npm:^8" react: "npm:^18" react-dom: "npm:^18" + react-error-boundary: "npm:^4.0.13" tailwind-merge: "npm:^2.5.4" tailwindcss: "npm:^3.3.0" ts-jest: "npm:^29.1.1" @@ -6664,6 +6666,108 @@ __metadata: languageName: node linkType: hard +"@sentry-internal/browser-utils@npm:8.38.0": + version: 8.38.0 + resolution: "@sentry-internal/browser-utils@npm:8.38.0" + dependencies: + "@sentry/core": "npm:8.38.0" + "@sentry/types": "npm:8.38.0" + "@sentry/utils": "npm:8.38.0" + checksum: 10c0/f900a82d412007a323b654a3a78dd6f21713fd31dd032a277bb25f340fc4d552af6d1e3bdf711382df255fea2910cf1f9f160431a9f98dad1b00182435d9eee1 + languageName: node + linkType: hard + +"@sentry-internal/feedback@npm:8.38.0": + version: 8.38.0 + resolution: "@sentry-internal/feedback@npm:8.38.0" + dependencies: + "@sentry/core": "npm:8.38.0" + "@sentry/types": "npm:8.38.0" + "@sentry/utils": "npm:8.38.0" + checksum: 10c0/19acfc0e7afcf6c3fb2405d4311c0d51345d52bb36e46399c1aa2509e653ab81a316af75da17bf0792db38e47aa892a1eca072fca32dee064e9eb1825633f500 + languageName: node + linkType: hard + +"@sentry-internal/replay-canvas@npm:8.38.0": + version: 8.38.0 + resolution: "@sentry-internal/replay-canvas@npm:8.38.0" + dependencies: + "@sentry-internal/replay": "npm:8.38.0" + "@sentry/core": "npm:8.38.0" + "@sentry/types": "npm:8.38.0" + "@sentry/utils": "npm:8.38.0" + checksum: 10c0/0b2ecf7a1aed81ce4730cefb4622aaa103db7440264ec35a18a848a59285b5f30736588dde506c3c9f1523225cbadbef972fd3dd7290f0cee76a6b06e53cd600 + languageName: node + linkType: hard + +"@sentry-internal/replay@npm:8.38.0": + version: 8.38.0 + resolution: "@sentry-internal/replay@npm:8.38.0" + dependencies: + "@sentry-internal/browser-utils": "npm:8.38.0" + "@sentry/core": "npm:8.38.0" + "@sentry/types": "npm:8.38.0" + "@sentry/utils": "npm:8.38.0" + checksum: 10c0/742217269611c63275a67b8c7a8615fba57849828b11c02b57fd9998a64c0bcc39d9ec50a1d3726e0c8588dcf6fa4f1a5992a4845cb630a1f0f3eb724feca0e3 + languageName: node + linkType: hard + +"@sentry/browser@npm:8.38.0": + version: 8.38.0 + resolution: "@sentry/browser@npm:8.38.0" + dependencies: + "@sentry-internal/browser-utils": "npm:8.38.0" + "@sentry-internal/feedback": "npm:8.38.0" + "@sentry-internal/replay": "npm:8.38.0" + "@sentry-internal/replay-canvas": "npm:8.38.0" + "@sentry/core": "npm:8.38.0" + "@sentry/types": "npm:8.38.0" + "@sentry/utils": "npm:8.38.0" + checksum: 10c0/a01f747e53fa5840d6b73409948ec1137fe10372e7ee4903921700604626736e9ced9ff963178531082b56c6307e522080d39b9a09dce4973d7091c8407a7b38 + languageName: node + linkType: hard + +"@sentry/core@npm:8.38.0": + version: 8.38.0 + resolution: "@sentry/core@npm:8.38.0" + dependencies: + "@sentry/types": "npm:8.38.0" + "@sentry/utils": "npm:8.38.0" + checksum: 10c0/247b346f34ae4d9682daa1438a29193e66c9a0ec892b3ec5edc8fcabb96409edeaa7f9637e32eede457d45af7ed4b83a155b6daa5eab4b54790d5ad79e2268c8 + languageName: node + linkType: hard + +"@sentry/react@npm:^8.38.0": + version: 8.38.0 + resolution: "@sentry/react@npm:8.38.0" + dependencies: + "@sentry/browser": "npm:8.38.0" + "@sentry/core": "npm:8.38.0" + "@sentry/types": "npm:8.38.0" + "@sentry/utils": "npm:8.38.0" + hoist-non-react-statics: "npm:^3.3.2" + peerDependencies: + react: ^16.14.0 || 17.x || 18.x || 19.x + checksum: 10c0/ec8ddd693eda3f5b2fcc028e59f4621d2db41b5c6c738cc997ad3819f2cc90a56f379187fb40d4c92288def9774dae76eba7da3af704e1c9375f71dc2aff99aa + languageName: node + linkType: hard + +"@sentry/types@npm:8.38.0": + version: 8.38.0 + resolution: "@sentry/types@npm:8.38.0" + checksum: 10c0/615ee4d8732cefc74eb19330662eae297321a992f080efff1f15bf2957c72d211240b17ea8712bbbe88da5b29c1604efa4166ef51e745ebef58609290ef72bfe + languageName: node + linkType: hard + +"@sentry/utils@npm:8.38.0": + version: 8.38.0 + resolution: "@sentry/utils@npm:8.38.0" + dependencies: + "@sentry/types": "npm:8.38.0" + checksum: 10c0/01baeb95a355916502f1d8390f5398bb1a59dbda417e21a9170c4a4b1e2eb05029d2044fd82c6666338a1d497212570a36db92c1e3604a3fcd71bcf9d62451ca + languageName: node + linkType: hard + "@sinclair/typebox@npm:^0.24.1": version: 0.24.51 resolution: "@sinclair/typebox@npm:0.24.51" @@ -23541,6 +23645,17 @@ __metadata: languageName: node linkType: hard +"react-error-boundary@npm:^4.0.13": + version: 4.1.2 + resolution: "react-error-boundary@npm:4.1.2" + dependencies: + "@babel/runtime": "npm:^7.12.5" + peerDependencies: + react: ">=16.13.1" + checksum: 10c0/0737e5259bed40ce14eb0823b3c7b152171921f2179e604f48f3913490cdc594d6c22d43d7abb4ffb1512c832850228db07aa69d3b941db324953a5e393cb399 + languageName: node + linkType: hard + "react-error-overlay@npm:^6.0.11": version: 6.0.11 resolution: "react-error-overlay@npm:6.0.11"