📚 1. 사전 지식
저장소 종료 : 로컬 Storage, Session Storage, Cookie
각 저장소별 특징
localStorage
: local에 도메인 별로 지속되는 storage
- 시간 제한 X, 브라우저가 꺼져도 죽지 않음
- 값을 지우려면 직접 지워야 함
- 용량 제한만 존재
sessionStorage
: 세션(프로세스, 탭, 브라우저) 종료될 때까지 지속되는 storage
- 세션 종료 시 지워짐
- 여기서 세션은 탭 단위
cookie
: 서버와 클라이언트측 양쪽에서 cookie 데이터를 사용하는 api가 존재
- 서버쪽 사용이 필수적이고 잦다면 localStorage가 아닌 cookie 사용
- e.g. '광고 7일동안 보지 않기'. localStorage는 '기간' 기능이 없기 때문
- 문자열만 저장 가능
- 용량 제한, 시간 제한, 갯수 제한 존재
각 저장소별 보안
localStorage
- XSS(Cross-Site Scripting, Javascript를 통해 정보 탈취) 공격에 취약
Cookie
- HttpOnly 속성을 통해 XSS공격 막을 수 있음
제일 안전한 방법
: Refresh Token은 http only, secure Cookie에 저장 & Access Token은 로컬 변수에 저장
=> CSRF 공격 : Cookie에 Access Token이 없기에 인증 불가 상태. http only secure 쿠키 특성상 refresh token 자체를 털 방법이 없음
=> XSS 공격 : Access Token은 로컬 변수에 저장되어 있기에 탈취 불가
=> Access Token을 만료시간을 짧게 가져감으로써 그나마 취약한 XSS를 좀 더 방어할 수 있음
Next.js의 렌더링 과정
토큰을 설정하기에 앞서서, Next.js의 렌더링 과정을 살펴보아야 한다. Next.js는 기본적으로 모든 페이지를 미리 렌더링(pre-render)한다. 이는 Next.js가 각 페이지의 HTML을 미리 생성하여 해당 페이지에 필요한 최소한의 Javascript 코드와 연결된다. 그 후 브라우저에 의해 페이지가 로드되면 hydration에 의해 Javascript 코드가 실행되어 페이지와 유저가 상호작용할 수 있게 된다.
Next.js의 pre-render에는 생성 시점에 따라 SSG와 SSR로 나뉜다. 여기서 나는, 프로젝트에서 각 사용자의 Access Token을 담아서 서버에 데이터를 요청하여 서버 측에서 pre-render되는 것을 원하는 것이기에 매 요청마다 HTML을 생성하는 방식인 SSR을 활용할 것이다.
🤩 2. 실제로 구현하기
1. 설계하기
1. _App.tsx에서 우선 페이지를 체크한다. 페이지가 로그인 이전 페이지(path = /auth 혹은 /begin)가 아니라면 cookie의 토큰을 검사한다. 1.1 토큰이 없다면 로그인 이전 페이지로 redirect해준다. 1.2 토큰이 있다면, 해당 토큰(Refresh Token)으로 서버에 Access Token을 요청한다.2. 1.2에서 받은 Access Token으로 모든 페이지에서 요청하는 데이터(e.g. 사용자 정보)를 서버에 요청한다.3. 1.2에서 받은 Access Token과 2에서 받은 사용자 정보 데이터를 Redux Store에 저장한다.
내 설계는 위와같다. 위 과정을 Server측에서 실행하기 위해 _App.getInitialProps를 활용할 것이다.
2. _App.getInitialProps
// src/pages/_app.tsx
const fetchAccessToken = async (refreshToken: string) => {
try {
const response = await axios.post(`${CONFIG.API_BASE_URL}/members/token`, {
refreshToken,
});
return response.data.accessToken;
} catch (error: any) {
throw new Error(`Fetching access token failed: ${error.message}`);
}
};
const fetchUserData = async (accessToken: string) => {
try {
axios.defaults.headers.common['Authorization'] = accessToken;
axios.defaults.baseURL = CONFIG.API_BASE_URL;
const resUserData = await axios('/members/me');
return resUserData.data;
} catch (error: any) {
throw new Error(`Fetching user data failed: ${error.message}`);
}
};
App.getInitialProps = async ({ Component, ctx }: AppContext) => {
let pageProps = {};
if (Component.getInitialProps) {
pageProps = await Component.getInitialProps(ctx);
}
if (ctx.pathname.includes('/auth') || ctx.pathname.includes('/begin')) {
return { pageProps };
}
const refreshToken = getCookie('refreshToken', ctx);
if (!refreshToken) {
if (!ctx.res) return;
ctx.res.setHeader('Location', '/auth/login');
ctx.res.statusCode = 302;
ctx.res.end();
return { pageProps };
}
try {
const accessToken = await fetchAccessToken(refreshToken + '');
setCookie('accessToken', accessToken, ctx);
const userData = await fetchUserData(accessToken);
return { pageProps, userData, accessToken };
} catch (error) {
if (!ctx.res) return;
ctx.res.setHeader('Location', '/auth/login');
ctx.res.statusCode = 302;
ctx.res.end();
return { pageProps };
}
};
ctx(context)는 cookie, pathName, query 등 페이지에 관한 정보들이 담겨있다.
cookie에 있는 Refresh Token을 이용하여 Access Token을 요청한 뒤, 이 토큰을 각 페이지에 넘겨줌으로써 로컬에서 활용한다.
이 때, axios.default.headers.common['Authorization']에 accessToken을 설정함으로써 Server Side에서 사용하는 Axios에 Access Token을 설정하였다. 이는 Server Side에서 사용하는 Axios에만 적용이 되며, Client Side에서 사용하는 Axios에는 적용이 되지 않는다. 그래서 Access Token을 각 페이지에 props로 넘겨줄 필요가 있다.
3. _App
// src/pages/_app.tsx
export default function App({
Component,
pageProps,
userData,
accessToken,
}: AppExtendedProps) {
...
return (
<Component {...pageProps} userInfo={userData} accessToken={accessToken}/>
);
4. 각 페이지에서 SSR 구현
이제, AccessToken을 각 페이지에서 받아 활용할 수 있다. 각 페이지에서 Server Side에서 데이터를 받아오고 싶다면, 다음과 같이 getServerSideProps를 활용하면 된다. 이 때, _App.getInitialprops에서 axios.default.headers.common에 AccessToken을 설정해주었으므로 따로 토큰을 담아주지 않아도 된다.
export const getServerSideProps: GetServerSideProps = async () => {
const resAuction = await axios('/auction');
const resPastAuction = await axios('/auction/period-over');
return {
props: {
auctionList: resAuction.data,
pastAuctionList: resPastAuction.data,
},
};
};
5. 각 페이지에서 CSR 구현
각 페이지의 Client측에서 데이터를 받아오기 위해서는 Redux에 설정한 Access Token을 꺼내다 쓰면 된다.
// src/pages/home/index.tsx
export default function Home(){
...
const accessToken = useAppSelector((state) => state.token.accessToken);
...
}
여기서 드는 고민 1 : Access Token을 한 번에 instance에서 설정할 수는 없을까?
이렇게 하면 매번 accessToken을 axios의 header에 설정해야한다.
Redux store에 설정된 Access Token을 Axios.interceptors.request에서 사용할 수 있을까?
Axios.interceptors.request의 생성 시점은 언제일까?
instance.interceptors.request.use()내부에서 useAppSelector를 사용해보았다.
instance.interceptors.request.use(
(config: any) => {
const accessToken = useAppSelector((state: any) => state);
console.log(accessToken);
...
return config;
},
(error) => {
Promise.reject(error);
},
);
위와 같은 에러가 발생한다. 맞다. instance내부는 React 컴포넌트 함수 내부가 아니기 때문에 훅을 사용하지 못한다.
그래서, <SetAccessToken />이라는 React 컴포넌트를 넣어, 페이지를 생성할 때 이 컴포넌트도 생성하여 axios의 default header를 설정하도록 하였다.
// src/componenents/SetAccessToken.tsx
import instance from '@apis/_axios/instance';
import { useAppSelector } from '@features/hooks';
export default function SetAccessToken() {
console.log(instance);
const accessToken = useAppSelector((state) => state.token.accessToken);
instance.defaults.headers.common['Authorization'] = accessToken;
return <div></div>;
}
여기서 드는 고민 2 : _App.getInitialProps가 최선일까?
공식문서를 포함해 많은 글에서 getInitialProps 사용을 권하지 않고 있다. 그럼에도 getInitialProps를 사용한 것은 처음 서비스에 진입할 때 무조건 Refresh Token 로직을 돌아 로그인 정보를 받아와야 했기 때문이다. 서비스의 어떤 페이지에서 새로고침했을때도 항상 로그인이 유지되어야 했다.
getInitialProps를 사용함으로써 다음과 같은 문제점이 있었다. 자동 정적 최적화가 불가능하다. 모든 페이지가 SSR로 구현이 되는 것이다.
build를 해보았을 때 Server Side에서 만들어진 html파일을 찾아볼 수가 없다.
🤔 2. 느낀 점 / 배운 점 / 추가로 공부할 것
Refresh Token과 Access Token을 각각 cookie와 local 변수에 저장하였다. 이를 Next.js의 Server Side에서 데이터를 받아오기 위해서는 어떻게 구현을 해야하는가에 대해 많은 고민을 하였다. 위의 방식이 정말 최선의 방식이라고 생각하지 않는다. 아직 getInitialProps으로 구현하면 정적 최적화가 불가능하다는 단점을 극복하지 못했다. 이 방식은도 추후에 고민을 더해서 더 나은 방식으로 구현해보도록 해야겠다.
Reference