[Next.js/JWT] Next.js 13 환경에서 권한/인증로직 구현하기
얼마전에 시작한 사이드 프로젝트의 환경을 Next.js 13 버전으로 적용하기로 했습니다.
프로젝트의 가장 기초는 로그인/회원가입 그리고 인증처리인데요. 제가 어떤 방법으로 Next.js 13 버전에서 인증로직을 작성했는지 여러분들께 공유하고자 합니다.
cookies-next
JWT를 사용할때, 쿠키에서 관리를 하는경우가 많은데요. 이를 위해 서버사이드 랜더링이 되는 next.js 에서는 cookies-next 라이브러리를 사용합니다.
처음 프로젝트를 세팅할 때 생각없이 react-cookie 라이브러리를 사용했었는데, 로그인 후 토큰을 쿠키에 저장하고 메인 페이지로 리다이렉트 하니 제대로 로직이 동작하지 않았습니다 ㅠㅠ
메인 페이지에서 console 을 찍어보니 쿠키가 undefined 였는데요.
같은 도메인이더라도 로그인 시 로그인 페이지에만 토큰이 쿠키에 저장되고, 메인 페이지에는 쿠키가 담기지 않았습니다. 그러니 저처럼 삽질하지 마시고 여러분들은 next.js 13 에서는 cookies-next 쓰시길!
사용법도 쉽답니다 ㅎㅎ
이제 얼른 본론으로 가봅시다!
권한처리 어떻게 해요?
기본적인 논리구조는 간단합니다.
로그인/회원가입 페이지에 로그인한 유저가 있다면 프로젝트의 메인인 List 페이지의 주소로 리다이렉트 합니다.
제 프로젝트에는 루트 주소인 " / " 페이지는 존재하지 않기 때문에 " / " 에 접근하는 유저는 로그인 페이지로 리다이렉트 됩니다.
그리고 권한이 없거나 로그인하지 않은 유저가 그 외의 페이지로 접근한다면, 서버에서 유저의 권한을 확인 후 에러를 반환하며 로그인 페이지로 리다이렉트 시킵니다.
위 로직을 따르기 위해서는 접근하는 유저의 로그인 여부를 우선 판별해야합니다.
access 토큰의 유효성을 확인하는 절차가 필요하겠죠?
access 토큰의 유효성 확인은 페이지에 접근할 때, 혹은 어떤 액션을 수행할 때 수시로 이루어져야합니다.
모든 페이지나 액션에 동일한 api 를 호출하는 코드를 작성하기에는 코드의 중복이 너무 많습니다. 이런 상황에서 일반적으로 사용되는 방식이 interceptor를 사용하는 것이죠!
requester.ts
저는 interceptor를 사용하는 requester와, 사용하지 않는 defaultRequester 두가지를 작성했습니다.
interceptor를 사용하는 requester에서는 api 호출 후 발생하는 에러를 확인합니다.
access 토큰이 만료되어 서버에서 권한 에러 "401"을 내려주면, refresh 토큰으로 access 토큰을 발급해주는 api 를 호출하고 응답으로 받아온 토큰들을 쿠키에 저장합니다. 그 후 이전에 실행했던 유저 액션인 originalRequest 를 반환합니다.
이 로직으로 유저가 로그인하지 않았거나, 권한이 없을때 최종적으로 에러가 반환됩니다.
물론 성공적으로 재발급 받는다면 문제가 되지 않겠죠!
import axios, { CreateAxiosDefaults, ParamsSerializerOptions } from 'axios';
import AuthApi from '@/apis/AuthApi';
import { stringify } from 'qs';
import { ACCESS_TOKEN_TITLE, REFRESH_TOKEN_TITLE } from '@/constants/common';
import { getCookie, setCookie, deleteCookie } from 'cookies-next';
let isRefreshing = false;
const TIME_OUT = 1000 * 120;
const UNAUTHORIZED = 401;
const STALE_REFRESH_TOKEN = 4108;
const accessToken = getCookie(ACCESS_TOKEN_TITLE);
const axiosDefault: CreateAxiosDefaults<any> = {
baseURL:
process.env.NEXT_PUBLIC_NODE_ENV === 'development'
? process.env.NEXT_PUBLIC_DAEHWA_URL_DEV
: process.env.NEXT_PUBLIC_DAEHWA_URL_PROD,
timeout: TIME_OUT,
withCredentials: true,
headers: {
'Content-Type': 'application/json',
},
paramsSerializer: {
serialize: stringify,
} as ParamsSerializerOptions,
};
const defaultRequester = axios.create(axiosDefault);
const requester = axios.create({
...axiosDefault,
headers: {
...axiosDefault.headers,
Authorization: accessToken,
},
});
requester.interceptors.response.use(
(response) => response,
(error) => {
const originalRequest = error.config;
if (
error.response &&
error.response?.status === UNAUTHORIZED &&
!originalRequest._retry &&
!isRefreshing
) {
const refreshToken = getCookie(REFRESH_TOKEN_TITLE) as string;
isRefreshing = true;
return AuthApi.refresh({ refreshToken })
.then((response) => {
originalRequest._retry = true;
const { result } = response.data;
const newAccessToken = result?.accessToken;
const newRefreshToken = result?.refreshToken;
setCookie(ACCESS_TOKEN_TITLE, newAccessToken);
setCookie(REFRESH_TOKEN_TITLE, newRefreshToken);
return axios({
...originalRequest,
headers: {
...axiosDefault.headers,
Authorization: newAccessToken,
},
});
})
.catch((error) => {
const { data } = error.response;
if (data?.status?.code === STALE_REFRESH_TOKEN) {
deleteCookie(REFRESH_TOKEN_TITLE);
deleteCookie(ACCESS_TOKEN_TITLE);
window.alert(data?.status?.message || '만료된 세션입니다.');
window.location.assign('/session-expired');
} else {
const { status, data } = error.response;
window.alert(
`${status || data?.status?.code} ${
data?.status?.message || '에러'
}`,
);
}
return Promise.reject(error);
})
.finally(() => {
isRefreshing = false;
});
}
return Promise.reject(error);
},
);
export { defaultRequester, requester };
그런데 위 로직은 api 호출시에만 토큰의 만료 여부를 확인할 수 있습니다.
그렇다면 api 호출이 없는 페이지의 클라이언트 화면에서는 어떻게 권한처리를 할 수 있을까요?
일단 서버 개발자에게 달려가서 토큰 유효성 검사를 해주는 api를 내놓으라고 윽박지릅시다!
일단 토큰 유효성 검사를 하는 api가 필요합니다!
저는 react-query 라이브러리로 해당 api를 호출하는 useAuthMe() 라는 커스텀 훅을 만들었습니다.
api 를 통한 access 토큰 유효성 검사에 실패하면 로그인 페이지로 보내는 간단한 로직입니다!
export const useAuthMe = () => {
const queryKey = ['auth-me'];
const queryFn = () => AuthApi.me();
return useQuery(queryKey, queryFn, {
staleTime: Infinity,
suspense: true,
useErrorBoundary: false,
enabled: false,
onSuccess: () => {},
onError: () => {
window.location.assign(SIGN_IN_PATH);
},
});
};
이제 이 커스텀 훅을 사용하는 컴포넌트를 작성해봅시다~
app/layout.tsx
next.js 이전 버전과 비교하여 13 버전에서 가장 큰 변화 중 하나로 app 디렉토리의 layout.tsx 파일이 있죠!
저는 페이지의 권한을 체크하는 AuthWrapper 라는 컴포넌트를 만들어서 children을 감쌌습니다.
이 AuthWrapper 컴포넌트에서 access 토큰의 유효성 검사와 추가적인 권한 확인을 할 것입니다.
export default function RootLayout({ children }: Props) {
return (
<html lang="ko">
<body>
<ErrorBoundary>
<QueryProvider>
<AuthWrapper>{children}</AuthWrapper>
</QueryProvider>
</ErrorBoundary>
</body>
</html>
);
}
AuthWrapper.tsx
AuthWrapper 컴포넌트에서는 주로 라우트 주소를 기준으로 권한에따라 리다이렉트 처리를 담당합니다..!
만약 useAuthMe() 훅의 유효성 확인 api에서 에러를 반환하거나 access 토큰을 확인할 수 없다면, 모든 토큰을 삭제합니다. 그리고 현재 유저가 머물러 있는 페이지에 따라 대응이 달라지는데요.
위 상황에서는 모든 토큰이 삭제 되었으니 로그인/회원가입 페이지가 아니라면, 기본적으로 로그인 페이지로 리다이렉트 됩니다.
반대로 로그인한 유저가 로그인/회원가입 등의 페이지에 있다면 List 페이지로 리다이렉트 되며,
위의 모든 조건을 통과하면 useQuery의 refetch() 함수로 한번 더 검사를 하고 로직은 종료됩니다.
이 컴포넌트로 페이지들을 감싸주면 useEffect에 의해 컴포넌트의 mount 시점에 권한 검사를 할 수 있습니다.
코드는 아래와 같습니다!
const PUBLIC_ROUTES = [SIGN_IN_PATH, SIGN_UP_PATH];
const ROOT = '/';
const removeAllCookies = () => {
...
};
export default function AuthWrapper({ children }: Props) {
const { error, refetch } = useAuthMe();
useEffect(() => {
(async () => {
const currentPath = window.location.pathname;
const accessToken = getCookie(ACCESS_TOKEN_TITLE);
if (error || !accessToken) {
removeAllCookies();
}
if (error || ROOT.includes(currentPath)) {
window.location.assign(SIGN_IN_PATH);
return;
}
if (PUBLIC_ROUTES.includes(currentPath)) {
if (!accessToken) {
return;
}
await refetch();
window.location.assign(LIST);
return;
}
await refetch();
return;
})();
}, []);
return error ? null : children;
}
마무리하며
이 프로젝트에서는 기본적이 로그인여부나 access 토큰의 여부에 따라 권한을 처리하는 로직만을 작성해보았는데요.
실무에서는 더 다양한 권한 처리 방법과 로직이 존재한답니다! 예를 들면 유저가 가진 권한에 따라 볼 수 있는 컴포넌트가 달라진다거나, 보이는 페이지 자체가 다를 수도 있겠지요 ㅎㅎ
이번 사이드 프로젝트는 어드민 권한은 필요하지 않을 것 같아 간단하게 작성할 수 있었어요. 고민이 되는 부분은 서버사이드 랜더링 시, 권한 검사를 화면이 랜더링 되기 전에 수행하고 싶은데, 아직 next.js 13버전에 미숙하다보니 시련을 겪는 중입니다 ㅋㅋ
좋은 팁을 알고 계신 분들은 언제든 공유 부탁드립니다~! ㅋ.ㅋ
'개발 이야기 > React & Next' 카테고리의 다른 글
[Typescript/React-Query] 이미지 api 호출 트러블슈팅 (1) | 2023.05.14 |
---|---|
[React/디자인 시스템] 알잘딱깔센 표 UI 인터페이스 구현하기 (1) | 2023.04.17 |
[React/Next.js] 특수한 조건의 라우팅 컴포넌트 제작(삽질)기 (8) | 2023.02.18 |
[React/Typescript] Modal/Dialog, 모달 창 만들기 (0) | 2021.11.28 |
[React/Typescript] 반응형 네비게이션 만들기 (0) | 2021.06.06 |