위 글에서 이어진다.
이전 게시글을 통해 앱-웹간 통신으로 토큰을 받아오고 이벤트를 발생시키는 것까지는 완료했다.
하지만 이제부터 시작이다. 받아오기전에는 어떻게 하고, 받은 토큰을 어떻게 관리할 것인가?
토큰을 받아오기 전에는?
토큰은 앱의 onLoad를 통해 주입되지만, 이 이벤트가 동기적으로 작동하지는 않는다.
따라서 웹 화면은 로드되었으나, 토큰이 없는 시점이 분명하게 존재한다.
이때 api호출이 되면 무조건 401에러가 발생하므로 이러한 일이 일어나지 않도록 방지해주어야 했다.
간단하면서도 명확한 방법이 있었다.
최상단 App.tsx에서 access나 refresh 토큰이 없는경우 Loading컴포넌트를 보여주는 것이다.
function App() {
const { setAccess, setRefresh } = useSetToken();
const { access, refresh } = useToken();
const tokenHandler = (event: MessageEvent) => {
const { accessToken, refreshToken } = JSON.parse(event.data);
setAccess(accessToken);
setRefresh(refreshToken);
};
useEffect(() => {
document.addEventListener('message', tokenHandler as EventListener);
window.addEventListener('message', tokenHandler);
}, []);
if (!access || !refresh) return <Loading />;
...
}
받은 토큰 관리하기
이제 이 토큰을 어떻게 관리해야하는지가 고민이다.
크게 두 가지 고민이 생긴다.
- 웹 사용중에 access token이 만료되는 경우 access token 갱신을 어떻게 해줄 것인가?
- 웹에서만 access token 토큰을 갱신한다 vs 앱에서도 access token을 갱신한다.
- 웹 사용중에 refresh token이 만료되면 어떻게 처리해줄 것인가?
- 어떤 방법을 사용하여 토큰을 관리할 것인가?
- 쿠키
- localStorage
- 전역상태
이 두가지 고민에 대해서 우리 팀은 아래와 같은 결론을 냈다.
- 웹 사용 중에 access token이 만료되는 경우 access token 갱신을 어떻게 해줄 것인가?
- 앱에서 JWT토큰을 웹으로 넣어주면, 웹에서는 내부적으로 토큰을 갱신하고, 관리한다.
- 굳이 앱과 웹의 토큰 수명 주기를 맞춰줄 필요성을 느끼지 못했기 때문이다.
- 웹 사용중에 refresh token이 만료되면 어떻게 처리해줄 것인가?
- 웹 사용중에 refresh token이 만료되는 경우 앱에서 로그아웃 처리하고, 재로그인 시킨다.
- 이건 웹 내부에서 다시 재로그인을 시킬 수는 없기때문에 이 방법밖에 없다고 판단했다.
- 어떤 방법을 사용하여 토큰을 관리할 것인가?
- 쿠키 : 로그인 자체를 웹환경에서 하지 않기때문에 Set-Cookie를 통해 httpOnly쿠키를 받아올 수 없다. 즉, 쿠키로 관리하는 장점이 없다.
- localStorage: 매번 웹뷰가 열릴때 토큰을 주입받으므로 localStorage를 사용할 이유가 없다.
따라서 전역상태로 토큰들을 관리하기로 했다. 전역상태 라이브러리는 번들 사이즈가 작은 Zustand를 택했다.
또한 토큰 에러 핸들링을 직접 api마다 해주면 매우 반복되는 작업이 많아지고 힘들어지기에 자동 갱신이 되도록 axios interceptor를 활용해줄 것이다.
더불어 우리는 모노레포로 Turborepo를 사용하고 있다. 여러 apps에서 사용할 수 있어야한다.
여러 레포에서 사용할 수 있는 자동 토큰 관리 시스템을 만들어줄 것이다.
구현하기
package/hooks 만들기
공통 훅들을 여기에 모아줄 것이다.
axios와 zustand를 설치해주었다.
//package.json
{
"name": "@repo/hooks",
"version": "1.0.0",
"description": "common hooks",
"main": "./index.ts",
"types": "./index.ts",
"private": true,
"keywords": [],
"author": "",
"license": "ISC",
"scripts": {
"lint": "npx prettier --write src & eslint src"
},
"devDependencies": {
"@repo/eslint-config": "*", //lint공통 설정
"@repo/typescript-config": "*", //ts공통설정
"@types/axios": "^0.14.0",
"typescript": "^5.3.3"
},
"dependencies": {
"axios": "^1.6.5",
"zustand": "^4.4.7"
}
}
전역상태 만들기
AuthStore를 만들어주었다.
import { create } from 'zustand';
interface AuthState {
access: string;
refresh: string;
setAccess: (value: string) => void;
setRefresh: (value: string) => void;
}
const useAuthStore = create<AuthState>((set) => ({
access: '',
refresh: '',
setAccess: (value: string) => set((state) => ({ ...state, access: value })),
setRefresh: (value: string) => set((state) => ({ ...state, refresh: value })),
}));
export default useAuthStore;
토큰을 쓰는 곳과 set하는 곳이 다를 수 있을거라 생각해서... 두개의 훅으로 분리시켜주었다.
// useToken.ts
import useAuthStore from '../stores/useAuthStore';
export default function useToken() {
const access = useAuthStore((state) => state.access);
const refresh = useAuthStore((state) => state.refresh);
return { access, refresh };
}
// useSetToken.ts
import useAuthStore from '../stores/useAuthStore';
export default function useSetToken() {
const setAccess = useAuthStore((state) => state.setAccess);
const setRefresh = useAuthStore((state) => state.setRefresh);
return { setAccess, setRefresh };
}
axios interceptor 를 활용한 useAuthAxiosInstance
access token이 만료되면, refresh한후에 재요청을 보내는 axios인스턴스를 만들어서 내보내는 훅을 만들어주었다.
함수가 아닌 훅을 택한 이유는 여기서 전역상태 토큰을 알아서 관리해줄 것이기 때문이다.
baseURL과 onError 함수를 받아 각 프로젝트별로 다른 URL를 base로 사용할 수 있게 했고, refresh가 만료된 경우에도 각각 다른 Action을 일으킬 수 있게 만들었다.
export default function useAuthAxiosInstance(baseURL: string, onError: () => void) {
const { access, refresh } = useToken();
const authAxios = axios.create({
baseURL,
headers: {
Authorization: `Bearer ${access}`,
},
});
...
}
baseURL과 access token을 알아서 넣어서 요청하는 인스턴스를 만들었다.
이제 401인경우 refresh를 통해 토큰을 갱신시켜주자.
authAxios.interceptors.response.use(
(res) => res, //응답이 okay면 그냥 결과를 내보낸다.
async (error) => {
const {
config,
response: { status },
} = error;
if (status !== 401) return Promise.reject(error); //401이 아니면 Promise를 reject시킨다.
try { //아니라면(401이면) 토큰을 재발급하여 재요청보낸다.
const { accessToken: newAccessToken, refreshToken: newRefreshToken } = await getNewToken(baseURL, refresh); //새로운 토큰을 발급받는다.
setAccess(newAccessToken);
setRefresh(newRefreshToken);
config.headers.Authorization = `Bearer ${newAccessToken}`; //기존 요청의 헤더를 재설정한다.
const response = await axios.get(config.url, config); //기존 요청을 한번 더 보낸다.
return await Promise.resolve(response); //결과를 내보낸다.
} catch (err) {
onError(); //재발급에서 오류가 나면 주입받은 onError 함수를 수행한다.
return Promise.reject(err);
}
},
);
위 로직을 그림으로 그리면 아래와 같다.
이러면 사용자는 만료가 된줄도 모르고 그냥 정상적인 요청처럼 응답을 받게된다.
더불어 개발자는 여기서 만든 authAxios를 통해 요청하면 토큰에 대해서 전혀 신경쓰지 않아도 된다.
전체코드
import axios from 'axios';
import useToken from './useToken';
import useSetToken from './useSetToken';
const getNewToken = async (baseURL: string, refresh: string) => {
const { data } = await axios.get(`${baseURL}/token`, { headers: { Authorization: `Bearer ${refresh}` } });
return data as { accessToken: string; refreshToken: string };
};
export default function useAuthAxiosInstance(baseURL: string, onError: () => void) {
const { access, refresh } = useToken();
const { setAccess, setRefresh } = useSetToken();
const authAxios = axios.create({
baseURL,
headers: {
Authorization: `Bearer ${access}`,
},
});
authAxios.interceptors.response.use(
(res) => res,
async (error) => {
const {
config,
response: { status },
} = error;
if (status !== 401) return Promise.reject(error);
try {
const { accessToken: newAccessToken, refreshToken: newRefreshToken } = await getNewToken(baseURL, refresh);
setAccess(newAccessToken);
setRefresh(newRefreshToken);
config.headers.Authorization = `Bearer ${newAccessToken}`;
const response = await axios.get(config.url, config);
return await Promise.resolve(response);
} catch (err) {
onError();
return Promise.reject(err);
}
},
);
return authAxios;
}
사용하기
최초 토큰 초기화
토큰이 app에서 넘어왔을때, 딱 한번 set해줄 필요가 있다.
function App() {
const { setAccess, setRefresh } = useSetToken();
const { access, refresh } = useToken();
const tokenHandler = (event: MessageEvent) => {
const { accessToken, refreshToken } = JSON.parse(event.data);
setAccess(accessToken);
setRefresh(refreshToken);
};
useEffect(() => {
document.addEventListener('message', tokenHandler as EventListener);
window.addEventListener('message', tokenHandler);
}, []);
if (!access || !refresh) return <Loading />;
...
}
useAuthAxios
레포에서 useAuthAxiosInstance를 import해서 useAuthAxios로 한번 래핑해주자.
import { useAuthAxiosInstance } from '@repo/hooks';
export default function useAuthAxios() {
const onError = () => {
window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'LOGOUT' })); //refresh가 만료되면 로그아웃 처리
};
return useAuthAxiosInstance(import.meta.env.VITE_API_URL, onError);
}
이후에 사용해주면 토큰 신경쓸 필요없이 api요청이 가능해진다.
import { useQuery } from '@tanstack/react-query';
import REACT_QUERY_KEYS from '@/constants/REACT_QUERY_KEYS';
import useAuthAxios from '@/hooks/useAuthAxios';
export default function useSomeQuery() {
const authAxios = useAuthAxios(); //요 훅만 사용하면 알아서 토큰이 담긴다!
const getMyPosts = async () => {
const result = await authAxios.get('/apiUrl');
return result.data;
};
return useQuery({ queryKey: [REACT_QUERY_KEYS.MY_POSTS], queryFn: useSomeQuery });
}
결론적으로 위의 이미지 처럼 작동하게 되고, 각 프로젝트에서는 token에 대해 신경쓸 필요없이 api요청을 할 수 있게 된다.