From 3dc86a09c9a5e99659de57cc93f25ce6261f2f79 Mon Sep 17 00:00:00 2001 From: gxxrxn Date: Tue, 28 May 2024 00:22:30 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20AuthRefreshIgnoredError=20class=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/types/customError/AuthRefreshIgnoredError.ts | 11 +++++++++++ src/types/customError/index.ts | 1 + 2 files changed, 12 insertions(+) create mode 100644 src/types/customError/AuthRefreshIgnoredError.ts create mode 100644 src/types/customError/index.ts diff --git a/src/types/customError/AuthRefreshIgnoredError.ts b/src/types/customError/AuthRefreshIgnoredError.ts new file mode 100644 index 00000000..b85f0478 --- /dev/null +++ b/src/types/customError/AuthRefreshIgnoredError.ts @@ -0,0 +1,11 @@ +/** + * accessToken을 갱신하는 요청이 진행 중인 경우, 갱신 요청은 무시되고 해당 에러가 발생합니다. + */ +class AuthRefreshIgnoredError extends Error { + constructor(message: string) { + super(message); + this.name = this.constructor.name; + } +} + +export default AuthRefreshIgnoredError; diff --git a/src/types/customError/index.ts b/src/types/customError/index.ts new file mode 100644 index 00000000..176fe8d4 --- /dev/null +++ b/src/types/customError/index.ts @@ -0,0 +1 @@ +export { default as AuthRefreshIgnoredError } from './AuthRefreshIgnoredError'; From f17eef5237e99f5ddf8a902e736f371284f257b6 Mon Sep 17 00:00:00 2001 From: gxxrxn Date: Tue, 28 May 2024 00:24:17 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20token=20refresh=20=EC=A4=91=EC=9D=BC?= =?UTF-8?q?=20=EB=95=8C=20query=20retry=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apis/core/axios.ts | 46 +++++++++++---------------- src/components/ReactQueryProvider.tsx | 10 +++++- 2 files changed, 27 insertions(+), 29 deletions(-) diff --git a/src/apis/core/axios.ts b/src/apis/core/axios.ts index 25951871..5f5a8b4a 100644 --- a/src/apis/core/axios.ts +++ b/src/apis/core/axios.ts @@ -1,12 +1,12 @@ import axios, { CreateAxiosDefaults, InternalAxiosRequestConfig } from 'axios'; +import { AuthRefreshIgnoredError } from '@/types/customError'; import { ACCESS_TOKEN_STORAGE_KEY, SERVICE_ERROR_MESSAGE } from '@/constants'; import { isAuthFailedError, isAuthRefreshError, isAxiosErrorWithCustomCode, } from '@/utils/helpers'; -import isClient from '@/utils/isClient'; import webStorage from '@/utils/storage'; const storage = webStorage(ACCESS_TOKEN_STORAGE_KEY); @@ -38,10 +38,6 @@ const requestHandler = (config: InternalAxiosRequestConfig) => { return config; }; -/** api 요청이 병렬적으로 이뤄질 때, - * 토큰 업데이트는 한번만 요청하기 위해 사용되는 flag 변수 */ -let isRefreshing = false; - const responseHandler = async (error: unknown) => { if (isAxiosErrorWithCustomCode(error)) { const { config: originRequest, response } = error; @@ -50,7 +46,7 @@ const responseHandler = async (error: unknown) => { console.warn(code, message); - if (originRequest && isAuthRefreshError(code) && !isRefreshing) { + if (originRequest && isAuthRefreshError(code)) { return silentRefresh(originRequest); } @@ -66,45 +62,39 @@ const responseHandler = async (error: unknown) => { const silentRefresh = async (originRequest: InternalAxiosRequestConfig) => { try { - isRefreshing = true; - const newToken = await updateToken(); storage.set(newToken); setAxiosAuthHeader(originRequest, newToken); - isRefreshing = false; - return await publicApi(originRequest); } catch (error) { removeToken(); - isRefreshing = false; - return Promise.reject(error); } }; -const updateToken = async () => { - try { - const { - data: { accessToken }, - } = await axios.post<{ accessToken: string }>('/service-api/auth/token'); +/** api 요청이 병렬적으로 이뤄질 때, + * 토큰 업데이트는 한번만 요청하기 위해 사용되는 flag 변수 */ +let isTokenRefreshing = false; - if (!accessToken) { - throw new Error('새로운 accessToken을 받아오지 못했어요.'); +const updateToken = () => + new Promise((resolve, reject) => { + if (isTokenRefreshing) { + reject(new AuthRefreshIgnoredError('Already trying to refresh token')); + return; } - return accessToken; - } catch (error) { - return Promise.reject(error); - } -}; + isTokenRefreshing = true; + + axios + .post<{ accessToken: string }>('/service-api/auth/token') + .then(({ data }) => resolve(data.accessToken)) + .catch(reason => reject(reason)) + .finally(() => (isTokenRefreshing = false)); + }); const removeToken = () => { storage.remove(); - - if (isClient()) { - window.location.reload(); - } }; const setAxiosAuthHeader = ( diff --git a/src/components/ReactQueryProvider.tsx b/src/components/ReactQueryProvider.tsx index fff2f458..1871e422 100644 --- a/src/components/ReactQueryProvider.tsx +++ b/src/components/ReactQueryProvider.tsx @@ -1,3 +1,4 @@ +import AuthRefreshIgnoredError from '@/types/customError/AuthRefreshIgnoredError'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { NextPage } from 'next/types'; @@ -14,11 +15,18 @@ const ReactQueryProvider: NextPage = ({ children }) => { defaultOptions: { queries: { refetchOnWindowFocus: false, - retry: false, + retry: (_count, error) => { + if (error instanceof AuthRefreshIgnoredError) { + return true; + } + + return false; + }, }, }, }) ); + return ( From f452232c97c6a421a1edaf8a304d4310f6d4a42c Mon Sep 17 00:00:00 2001 From: gxxrxn Date: Tue, 28 May 2024 01:25:49 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=EC=B5=9C=EC=83=81=EC=9C=84=20error?= =?UTF-8?q?=20boundary=20global-error=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/error.tsx | 29 ----------------------------- src/app/global-error.tsx | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 29 deletions(-) delete mode 100644 src/app/error.tsx create mode 100644 src/app/global-error.tsx diff --git a/src/app/error.tsx b/src/app/error.tsx deleted file mode 100644 index f2c65513..00000000 --- a/src/app/error.tsx +++ /dev/null @@ -1,29 +0,0 @@ -'use client'; - -import Button from '@/v1/base/Button'; -import Image from 'next/image'; -import { useRouter } from 'next/navigation'; - -export const ErrorPage = () => { - const router = useRouter(); - - return ( -
- loading -
- 다독이도 몰라요~ 왜 - 이래요~ -
- -
- ); -}; - -export default ErrorPage; diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx new file mode 100644 index 00000000..c11a3ef5 --- /dev/null +++ b/src/app/global-error.tsx @@ -0,0 +1,38 @@ +'use client'; + +import Button from '@/v1/base/Button'; +import Image from 'next/image'; +import { useRouter } from 'next/navigation'; + +export const ErrorPage = () => { + const router = useRouter(); + + return ( + + +
+ loading +
+ 다독이도 몰라요~ 왜 + 이래요~ +
+ +
+ + + ); +}; + +export default ErrorPage; From f86006033e48ed1e2d9c717a52d6252df2c5dfad Mon Sep 17 00:00:00 2001 From: gxxrxn Date: Tue, 28 May 2024 01:27:31 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20AuthFailedErrorBoundary=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/layout.tsx | 5 ++- src/components/AuthFailedErrorBoundary.tsx | 43 ++++++++++++++++++++++ src/components/ContextProvider.tsx | 6 +-- 3 files changed, 48 insertions(+), 6 deletions(-) create mode 100644 src/components/AuthFailedErrorBoundary.tsx diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 4c2ed7e6..8e19e35d 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,4 +1,5 @@ import ContextProvider from '@/components/ContextProvider'; +import AuthFailedErrorBoundary from '@/components/AuthFailedErrorBoundary'; import Layout from '@/v1/layout/Layout'; import { LineSeedKR } from '@/styles/font'; @@ -18,7 +19,9 @@ const RootLayout = ({ children }: { children: React.ReactNode }) => { {/* @todo Chakra 제거시 app-layout 프로퍼티 제거. */} - {children} + + {children} + diff --git a/src/components/AuthFailedErrorBoundary.tsx b/src/components/AuthFailedErrorBoundary.tsx new file mode 100644 index 00000000..3274a7c2 --- /dev/null +++ b/src/components/AuthFailedErrorBoundary.tsx @@ -0,0 +1,43 @@ +'use client'; + +import { useEffect } from 'react'; +import { QueryErrorResetBoundary } from '@tanstack/react-query'; +import { ErrorBoundary, FallbackProps } from 'react-error-boundary'; + +import useToast from '@/v1/base/Toast/useToast'; +import { isAuthFailedError, isAxiosErrorWithCustomCode } from '@/utils/helpers'; +import Loading from '@/v1/base/Loading'; + +const AuthFailedErrorBoundary = ({ + children, +}: { + children?: React.ReactNode; +}) => { + return ( + + {({ reset }) => ( + + {children} + + )} + + ); +}; + +export default AuthFailedErrorBoundary; + +const AuthFailedFallback = ({ error, resetErrorBoundary }: FallbackProps) => { + const { show: showToast } = useToast(); + + useEffect(() => { + if ( + isAxiosErrorWithCustomCode(error) && + isAuthFailedError(error.response.data.code) + ) { + showToast({ message: '다시 로그인 해주세요' }); + resetErrorBoundary(); + } + }, [error, resetErrorBoundary, showToast]); + + return ; +}; diff --git a/src/components/ContextProvider.tsx b/src/components/ContextProvider.tsx index 68edb417..0bef940a 100644 --- a/src/components/ContextProvider.tsx +++ b/src/components/ContextProvider.tsx @@ -1,10 +1,8 @@ 'use client'; import { ReactNode } from 'react'; -import { ErrorBoundary } from 'react-error-boundary'; import { RecoilRoot } from 'recoil'; -import ErrorPage from '@/app/error'; import ChakraThemeProvider from '@/components/ChakraThemeProvider'; import ReactQueryProvider from '@/components/ReactQueryProvider'; import ToastProvider from '@/v1/base/Toast/ToastProvider'; @@ -14,9 +12,7 @@ const ContextProvider = ({ children }: { children: ReactNode }) => { - - {children} - + {children}