axios request interceptors 로 access token 재발급하기
사실 이 부분은 금방 끝날 줄 알았는데 아니었다... ㅠ
삽질도 많이 하고 에러도 많이 마주쳤던 부분이라 기록하고자 글을 남긴다.
1. refresh token과 access token
간단하게 말하자면 access token은 사용자가 요청을 보낼 때 사용하는 토큰인데, 보안 문제로 유효 기간이 짧다.
refresh token은 access token보다 유효 기간이 길어서, 만약 access token이 만료되었다면 refresh token을 이용해 access token을 재발급받을 수 있다.
2. 구현 방식에 대한 고민들
access token이 만료되면 재발급 요청을 어떻게 해야 하는가?
-
사용자가 만료된 access token을 가지고 요청을 보내면 그때 토큰을 재발급하는 방식
→ 해당 요청이 수행되지 않아 다시 요청을 보내야 하는 UX적인 단점 존재 -
토큰 만료 시점을 미리 계산하고, 만료 시점에 근접했을 때 토큰을 재발급받는 방식
→ 만료 시점에 근접했을 때 재발급 요청을 어떻게 해야 할지 감이 안 잡힘... -
요청을 보낸 후 만료 응답을 받으면 그때 재발급 요청
→ 이래도 401 에러는 뜨더라
결과적으로 채택한 방식
request를 가로채서 토큰의 유효 시간을 살핀 다음, 만료까지 1분 미만으로 남았다면 토큰을 재발급받은 후 해당 요청을 보내는 방식을 생각했다.
그런데 아무리 시도해 봐도 에러 해결이 안 되고, 아무리 찾아봐도 response를 가로채는 예시들만 나와서 일단은 response interceptor (응답 인터셉터)
로 구현했다. (이 예시는 구글링하면 많이 나와서 생략하겠다.)
하지만 응답 인터셉터를 사용해도 콘솔 창에 401 에러가 뜨는 게 보기 싫었고, (실행은 잘됨) request interceptor (요청 인터셉터)
로 구현하는 게 더 효율적이라고 판단하여 처음부터 끝까지 내가 구현해 보자고 마음 먹었다.
따라서 구현한 코드는 다음과 같다.
구현 과정
로직 설명
-
로그인 시 refresh token과 access token을
local storage
에 저장을 해 두었고,local storage
에 access token이 존재한다면, 이를 헤더에 담아 요청을 보낸다. -
jwt-decode
라이브러리를 활용하여 access token과 refresh token의 만료 시간을 알아내고, 이를 로컬 스토리지에 저장해 두었다. -
refresh token이 로컬 스토리지에 저장이 되어 있고 (즉, 로그인한 사용자고) access token의 만료 시점과 현재 시간의 차가 1분 미만이라면, access token 재발급을 요청한다.
-
우리는 refresh token을
application/x-www-form-urlencoded
타입으로 담아서 보내기 때문에URLSearchParams()
를 사용했다. -
그리고 재발급받은 access token과 만료 시점(expiredAt)을 로컬 스토리지에 저장해 두었다. (expiredAt을 갱신하지 않으면 또 에러가 생긴다!) 그리고 마지막 부분에는 재발급 받은 access token을 헤더에 기본으로 붙여 두었다.
이때 주의할 점! interceptor 안에 refresh token을 요청하는 코드를 작성할 때, instance를 호출하면 안 된다. 그러면 요청 → interceptor 실행 → interceptor 속 instance의 interceptor 실행 → ... 으로 무한 루프가 생겨 버린다. 그래서 나도 instance를 사용하지 않고 그냥 axios를 사용했다.
구현한 코드
axios.create()
으로 작성한 instance
의 request interceptor
코드다.
instance.interceptors.request.use(async config => {
const accessToken = localStorage.getItem('accessToken');
accessToken && (config.headers.Authorization = `Bearer ${accessToken}`);
const refreshToken = localStorage.getItem('refreshToken');
const expiredAt = localStorage.getItem('expiredAt');
const now = Date.now();
// if the token expires within a minute
if (refreshToken) {
if (expiredAt - now < 60000) {
const params = new URLSearchParams();
params.append('refresh', refreshToken);
const { data } = await axios.post('요청 주소', params, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
});
localStorage.setItem('accessToken', data.access);
localStorage.setItem('expiredAt', jwtDecode(data.access).exp * 1000);
config.headers['Authorization'] = `Bearer ${data.access}`;
}
}
return config;
});
마주쳤던 에러들
삽질 1. Date 객체 관련
access token을 디코딩해서 만료 시간(exp)을 알아내려고 했다. 그런데 이 값은 초 단위이고, JavaScript Date 객체를 사용해서 얻은 값은 밀리 초 단위이다. 따라서 exp 값에 1000을 곱한 후 Date 객체에 넣으면 대충 Sat Jan 08 2022 03:10:30 GMT+0900 (한국 표준시)
형태로 출력될 것이라고 생각했다.
그래서 시험 삼아 발급 시간(iat)과 만료 시간(exp) 각각에 1000을 곱한 후 Date 객체에 넣어 봤더니, 분명 다른 숫자임에도 불구하고 같은 시간대가 출력되었다. (원래는 30분의 시차가 있어야 함)
// 에러 코드
const decoded = jwtDecode(res.data.token.access);
const exp = decoded.exp;
const iat = decoded.iat;
console.log("발급 시간: ", Date(iat * 1000));
console.log("만료 시간: ", Date(exp * 1000));
// 출력 예시
발급 시간: Sat Jan 08 2022 03:00:13 GMT+0900 (한국 표준시)
만료 시간: Sat Jan 08 2022 03:00:13 GMT+0900 (한국 표준시)
거의 세 시간 동안 붙잡고 있었는데 @김현재 님이 도와주셔서 해결할 수 있었다...
Date ‘객체’라서 객체 참조에 관한 문제였다고 한다 🫠
const decoded = jwtDecode(res.data.token.access);
const iat = decoded.iat;
const exp = decoded.exp;
const now = Date.now()
console.log("발급 시간: ", new Date(iat * 1000));
console.log("만료 시간: ", new Date(exp * 1000));
console.log("현재 시간: ", new Date(now));
// 출력 예시
발급 시간: Sat Jan 08 2022 03:00:13 GMT+0900 (한국 표준시)
만료 시간: Sat Jan 08 2022 03:30:13 GMT+0900 (한국 표준시)
현재 시간: Sat Jan 08 2022 03:00:13 GMT+0900 (한국 표준시)
따라서 앞에 new 연산자를 꼭 붙여 줘야 제대로 나온다!
삽질 2: 무한 루프
axios interceptor를 잘못 작성해서 무한 루프에 빠져 버림...
// 에러 코드 && 로직 수정 전 코드
instance.interceptors.request.use((config) => {
const refreshToken = localStorage.getItem("refreshToken");
const expiredAt = parseInt(localStorage.getItem("expiredAt"));
const now = Date.now();
if (expiredAt - now < 60000) {
const params = new URLSearchParams();
params.append('refresh', refreshToken);
const { data } = await instance.post(
'요청 주소', params,
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
}
);
// 생략
return config;
});
정확하진 않지만 interceptor
안에서 또 instance
를 호출해 요청을 보내다 보니 무한 루프가 생긴 것 같다 ㅠㅠ 그래서 아래처럼 수정했더니 잘됐던 걸로 기억함 (아마도)
// 에러 코드 && 로직 수정 전 코드
instance.interceptors.request.use((config) => {
const refreshToken = localStorage.getItem("refreshToken");
const expiredAt = parseInt(localStorage.getItem("expiredAt"));
const now = Date.now();
if (expiredAt - now < 60000) {
const params = new URLSearchParams();
params.append('refresh', refreshToken);
const { data } = await axios.post(
'요청 주소', params,
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
}
);
// 생략
return config;
});
후기
하나 고치면 다른 데서 에러 생기고 🫠 계속해서 오류를 뱉어서 화가 나긴 했지만 그래도 수정하는 재미가 있었다! 이 과정과는 별개로 token과 관련된 모든 정보들을 로컬 스토리지에 저장하는 게 맞나 하는 생각이 든다 🤔 보안상의 이슈가 있을 것 같은데 더 좋은 방법을 찾아보고 리팩토링해 봐야겠다!