Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[#596] accessToken이 만료된 직후 다른 페이지 접근할 때 에러 페이지를 보여주지 않고 새로고침 #600

Merged
merged 4 commits into from
May 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 18 additions & 28 deletions src/apis/core/axios.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -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;
Expand All @@ -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);
}

Expand All @@ -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<string>((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 = (
Expand Down
29 changes: 0 additions & 29 deletions src/app/error.tsx

This file was deleted.

38 changes: 38 additions & 0 deletions src/app/global-error.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<html>
<body>
<div className="absolute left-0 top-0 flex h-full w-full flex-col items-center justify-center gap-[2rem]">
<Image
src="/images/loading.gif"
width={230}
height={160}
alt="loading"
/>
<div className="font-heading">
<span className="font-bold text-main-900">다독이</span>도 몰라요~ 왜
이래요~
</div>
<Button
size="large"
colorScheme="main"
fill={false}
onClick={() => router.replace('/')}
>
처음으로 돌아가기
</Button>
</div>
</body>
</html>
);
};

export default ErrorPage;
5 changes: 4 additions & 1 deletion src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -18,7 +19,9 @@ const RootLayout = ({ children }: { children: React.ReactNode }) => {
{/* @todo Chakra 제거시 app-layout 프로퍼티 제거. */}
<body className={`${LineSeedKR.variable} app-layout font-lineseed`}>
<Layout>
<ContextProvider>{children}</ContextProvider>
<ContextProvider>
<AuthFailedErrorBoundary>{children}</AuthFailedErrorBoundary>
</ContextProvider>
</Layout>
</body>
</html>
Expand Down
43 changes: 43 additions & 0 deletions src/components/AuthFailedErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary onReset={reset} FallbackComponent={AuthFailedFallback}>
{children}
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
);
};

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 <Loading fullpage />;
};
6 changes: 1 addition & 5 deletions src/components/ContextProvider.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -14,9 +12,7 @@ const ContextProvider = ({ children }: { children: ReactNode }) => {
<RecoilRoot>
<ReactQueryProvider>
<ChakraThemeProvider>
<ErrorBoundary fallbackRender={ErrorPage}>
<ToastProvider>{children}</ToastProvider>
</ErrorBoundary>
<ToastProvider>{children}</ToastProvider>
</ChakraThemeProvider>
</ReactQueryProvider>
</RecoilRoot>
Expand Down
10 changes: 9 additions & 1 deletion src/components/ReactQueryProvider.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -14,11 +15,18 @@ const ReactQueryProvider: NextPage<PropTypes> = ({ children }) => {
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: false,
retry: (_count, error) => {
if (error instanceof AuthRefreshIgnoredError) {
return true;
}

return false;
},
},
},
})
);

return (
<QueryClientProvider client={queryClient}>
<ReactQueryDevtools initialIsOpen={false} position="bottom-right" />
Expand Down
11 changes: 11 additions & 0 deletions src/types/customError/AuthRefreshIgnoredError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* accessToken을 갱신하는 요청이 진행 중인 경우, 갱신 요청은 무시되고 해당 에러가 발생합니다.
*/
class AuthRefreshIgnoredError extends Error {
constructor(message: string) {
super(message);
this.name = this.constructor.name;
}
}

export default AuthRefreshIgnoredError;
1 change: 1 addition & 0 deletions src/types/customError/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as AuthRefreshIgnoredError } from './AuthRefreshIgnoredError';
Loading