Table of contents
토큰 기반의 인증을 사용한다면, 클라이언트에서 만료된 토큰으로 요청을 보낼 때가 있을 것이다. 이 경우에 서버에서 재발급이 필요하다는 응답을 줄 것이고, 클라이언트에서는 토큰 재발급 요청을 수행해야 한다.
이 요구사항은 응답 객체에 따라 적절하게 분기처리하면 간단하게 구현이 가능하다.
import axios, { isAxiosError } from 'axios';
import { reissueToken } from '...';
const response = axios.get('/')
.catch((error) => {
if (!isAxiosError(error)) {
const statusCode = error.response?.status;
if (statusCode === ???) {
reissueToken();
}
}
});
그런데 토큰 재발급에 성공한 이후에 아무런 후속처리도 하지 않는다면, 기존 요청으로 인한 UI의 업데이트가 발생하지 않는다. 이런 상황을 의도한게 아니라면, 토큰 재발급에 성공한 이후에는 기존에 실패했던 요청을 다시 보내야 한다.
.catch((error) => {
if (...) {
return reissueToken().then(() => axios.get('/'));
}
})
그런데 모든 요청을 이런 패턴으로 작성하는건 현실적으로 불편한 점이 너무 많다. 이를 해결하기 위해 Axios의 Interceptors API를 사용할 수 있다.
Axios Interceptors API
문서에서 소개하는 내용은 매우 간단하다. axios의 라이프사이클중에 호출할 콜백을 정의할 수 있는데, 4가지의 위치를 선택할 수 있다.
요청을 보내기 전
요청 오류 전
성공 응답을 반환하기 전
실패 응답을 반환하기 전
콜백의 파라미터에는 요청 객체나 에러 객체가 전달되므로, 필요에 따라 해당 객체들을 활용할 수도 있다. 예를들어, 요청 객체를 가로채서 헤더에 토큰을 추가한다든가, 보관중인 토큰이 없다면 네트워크 요청을 보내지 않고 즉시 실패처리를 하는 등의 다양한 처리가 가능하다.
Interceptors API를 사용하여 토큰 만료 응답을 받았을 때, 토큰 재발급 요청을 수행하고 재발급에 성공한 경우 실패했던 요청을 다시 보내도록 기능을 추가해보았다.
import type { AxiosError } from 'axios';
import axios, { isAxiosError } from 'axios';
import { reissueToken } from '...';
axios.interceptors.response.use(
undefined,
(error: AxiosError | Error) => {
if (!isAxiosError(error) || !error.config) {
return Promise.reject(error);
}
const config = error.config;
const retry = () => axios.request(config);
// 토큰 재발급을 수행하기 위한 조건문은 정의하기 나름이다.
if (error.response?.status === ???) {
return reissueToken()
.then(() => retry());
}
return Promise.reject(error);
});
error.config
는 실패한 요청 객체인데, 이 객체를 요청함수의 파라미터로 전달하면 실패한 요청을 다시 보낼 수 있다. 위의 코드는 토큰이 HttpOnly쿠키로 전달되는 상황을 가정했기 때문에, 만약 Http 요청 헤더에 직접 토큰을 넣어야 한다면 config
객체를 세팅해주면 된다.
문제점1
그런데 위 코드는 Race condition을 고려하지 않았기 때문에 문제가 된다. 토큰이 만료된 상태에서 동시에 N개의 요청을 보내는 경우를 생각해보면, 이 경우엔 N개 전부 토큰 만료 응답을 받고 토큰 재발급 요청을 N번 보낼 것이다.
이 때 서버는 토큰을 N번 생성해야 하니까 불필요하게 리소스를 낭비하게 된다. 토큰은 한번 발급하면 강제로 만료시킬 수는 없지만, 서버에서 마지막에 발급된 토큰만 기억하여 유효한 토큰을 하나만 유지시키는 경우엔, 동시에 여러개의 재발급 요청을 보낼 때, 요청 순서대로 응답이 오는것이 100% 보장되는것은 아니기 때문에(1-2-3순서로 요청을 보내도, 1-3-2순서로 응답이 올 수 있으므로) 재발급 받은 토큰이 유효하다는걸 클라이언트 입장에선 확신할 수 없는 문제도 생긴다.
따라서, 클라이언트에서는 토큰 재발급 요청이 트리거되었을 때, 이미 토큰 재발급 요청을 수행중이라면 중복되는 요청들은 무시하도록 해야한다.
import type { AxiosError, InternalAxiosRequestConfig } from "axios";
import axios, { isAxiosError } from 'axios';
import { reissueToken } from '...';
let reissueTokenRequest: ReturnType<typeof reissueToken> | undefined;
const waitingQueue = new Set<InternalAxiosRequestConfig>();
axios.interceptors.response.use(
undefined,
(error) => {
...
if (토큰_재발급이_필요한_상황) {
if (reissueToken === undefined) {
reissueTokenRequest = reissueToken();
}
const config = error.config;
const retry = () => axios.request(config);
waitingQueue.add(config);
return reissueTokenRequest
.finally(() => {
waitingQueue.delete(config);
if (!waitingQueue.size) {
reissueTokenRequest = undefined;
}
})
.then(() => retry())
}
...
}
);
토큰 재발급 요청이 트리거되면, 요청 프로미스를 reissueTokenRequest
에 할당함과 동시에 큐에 요청 객체(error.config
)를 넣는다. 프로미스가 settle되면 큐에서 대기중인 요청 객체를 하나씩 제거하는데, 더 이상 요청 객체가 남아있지 않다면 reissueTokenRequest
를 undefined
로 다시 할당해준다. 프로미스 메서드의 순서는 finally → then으로, then메서드에서 기존에 실패했던 요청을 재시도한다. reissueTokenRequest
가 실패하면 finally만 실행되고, then은 실행되지 않는다.
간단하게 토큰 재발급 요청을 처리하는데 1초가 걸리는 로컬 서버를 만든 후, 만료된 토큰으로 동시에 4개의 요청을 보내봤다.
토큰 재발급 요청을 여러번 수행하는것을 막지 않았다면, 토큰 갱신 요청 로그도 4번 출력되었을 텐데, 1번만 출력됨을 알 수 있다.
문제점2
토큰 재발급 요청을 수행중일 때, 똑같은 토큰 재발급 요청을 막을 수는 있지만, 그게 아닌, 일반 요청을 막지는 않았다. 그래서 토큰 재발급 요청에 대한 응답이 오기 전 까지는, 갖고있던 만료된 토큰으로 얼마든지 요청을 보낼 수 있다. 이 때, 토큰 재발급이 끝난 후, 뒤늦게 보낸 요청에 대해 토큰이 만료되었다는 응답을 받게 된다면 다시 재발급을 요청할 것이고, 이전과 동일한 문제가 발생한다.
그래서 토큰 재발급 요청중에는, 동일한 토큰 재발급 요청을 막는것 뿐만 아니라, 만료된 토큰으로 시도하는 모든 요청을 막아야한다.
토큰 재발급 요청이 진행중일 때 클라이언트에서 발생하는 요청을 막으면 되는데, 이를 위해 요청 interceptors도 필요하다.
let reissueTokenRequest: ReturnType<typeof reissueToken> | undefined;
const waitingQueue = new Set<InternalAxiosRequestConfig>();
// 요청을 보내기 전에 수행되는 콜백
axios.interceptors.request.use((config) => {
if (reissueTokenRequest === undefined) return config;
return reissueTokenRequest.then(() => config);
});
axios.interceptors.response.use(...);
기존에는 토큰 재발급 요청이 수행되는 중에 새로운 요청을 계속 보낼 수 있었지만
인터셉터를 추가한 뒤에는, 토큰 재발급 중일 때 새로운 요청함수를 호출해도, 실제 네트워크 요청까지 진행되는게 아니라, 요청객체를 클라이언트에서 보관하고 있다가 토큰 재발급이 완료된 이후에 보내게 된다.
토큰 재발급을 여러번 수행하는 문제도 막고,
재발급이 완료되기 전 까지, 토큰이 만료되었기 때문에 어차피 실패할 요청도 막을 수 있게 되었다.
여러개의 axios 인스턴스 사용하기
인증이 필요없는 데이터들은 토큰 재발급 여부를 고려할 필요가 없다. 따라서 모든 요청을 동일한 axios인스턴스로 처리하면, 독립적으로 수행할 수 있는 요청들도 불필요하게 대기하는 상황이 발생할 수 있다.
axios.create
을 사용하면 새로운 axios인스턴스를 만들 수 있다. 각 인스턴스마다 별도의 인터셉터를 작성할 수 있고, 한 인스턴스의 인터셉터가 다른 인스턴스에 영향을 줄 수 없다.
const publicAxios = axios.create({});
const privateAxios = axios.create({});
privateAxios.interceptors.request.use(...);
privateAxios.interceptors.response.use(...);