Axios의 Interceptor
axios의 interceptor는 요청의 응답을 가로챈 후 그 응답을 미리 처리할 수 있는 기능을 지원합니다.
그렇기에 인가가 필요한 요청에서 401(Unauthorized) 에러가 발생하면 응답(에러)을 먼저 가로채서 토큰 Refresh를 진행하고, refresh된 토큰을 에러가 발생했던 요청의 헤더에 다시 넣어서 요청을 retry 할 수 있습니다.
이러한 interceptor은 모든 요청과 응답에 대해 공통적인 작업을 수행할 수 있어 중복되는 코드를 없애며, 효율적인 인가 처리를 도와줍니다.
다음은 interceptor의 base 코드입니다.
export const instance = axios.create({
baseURL: NEXT_PUBLIC_BASE_URL,
});
instance.interceptors.request.use(
(config) => {
// do something
(error) => Promise.reject(error),
);
instance.interceptors.response.use(
(response) => response,
async (error) => {
const { config, response } = error;
const originalRequest = config;
// do something
return Promise.reject(error);
},
);
Interceptor의 response
instance.interceptors.response.use(
(response) => response,
async (error) => {
const { config, response } = error;
const originalRequest = config;
if (error.response.status === 401) {
try {
// 토큰 Refresh 및 새 토큰 저장
// 재요청
return instance(originalConfig);
} catch (_error) {
return Promise.reject(_error);
}
}
return Promise.reject(error);
}
);
위의 코드는 응답을 가로챈 후 그 응답을 기반으로 수행되는 작업입니다.
위의 코드에서는 client의 요청이 성공적으로 이루어졌다면 그대로 응답을 반환하지만, 401 에러가 발생했다면 토큰 Refresh 요청을 진행한 후 새로운 토큰을 발급 받고, 401 에러가 발생했던 요청을 새로운 토큰과 함께 재요청시킵니다.
이런 식으로 interceptor를 구축하게 된다면, client에서 401 에러가 발생하더라도 즉시 토큰 Refresh 진행 후 재요청을 진행하기 때문에 사용자는 에러 발생과 상관없이 즉각적인 응답을 화면에서 볼 수 있고 이에 따라서 원활한 서비스 이용이 가능해집니다.
Multiple Requests(다수 요청) 문제
하지만 위의 interceptor에서는 multiple requests(다수 요청)가 일어났을 때 문제가 발생할 수 있습니다.
앞에서 말한 interceptor의 내용처럼, 엑세스 토큰이 만료된 후 요청이 발생하면 401 에러가 발생하여 interceptor에 의해 토큰 Refresh가 진행되겠지만, 엑세스 토큰이 만료된 후 2개의 요청이 동시에 발생되면 어떻게 될까요?
특정 상황을 예시로 들어 보겠습니다.
아래의 이미지와 같이 client는 엑세스 토큰이 요구되는 2개 이상의 API 요청을 동시에 서버 측에 보냈고, 이 과정에서 발생한 401 에러 대응을 위해 interceptor는 각 요청에 대한 토큰 Refresh 작업을 진행해야 하는 상황입니다.
요청에서 401 에러가 발생했다면 interceptor는 곧바로 토큰 Refresh 작업을 진행할텐데요. 그런데 현재 상황에서는 2개 이상의 API가 동시에 요청을 보냈고, 401 에러 또한 모두 동시에 일어났기에 각 요청의 토큰 Refresh 요청이 동시에 발생하게 됩니다.
여러 토큰 Refresh 요청이 동시에 발생하게 되면, 각각의 요청들은 순서가 정해지지 않았기 때문에 무작위 순서로 요청을 진행하고 응답을 받게 됩니다. 따라서 무작위에 따라 어떤 요청이 먼저 토큰 Refresh 요청을 진행하게 됐다면 해당 요청은 곧바로 새로운 토큰을 저장하게 되지만, 이후 진행되는 다른 요청은 앞서 먼저 진행된 요청이 토큰 Refresh를 하는 사이에 이미 만료된 리프레시 토큰을 가지고 뒤늦게 토큰 Refresh 요청을 진행하게 될 확률이 높습니다.
한 client에서 일어나는 요청들이 헤더에 각각 다른 토큰을 담고 있다면 어떤 요청은 성공적으로 진행이 되겠지만, 어떤 요청은 인가에 실패하여 에러가 발생하게 됩니다.
저도 프로젝트를 진행하면서 이와 같은 상황을 마주했었는데요. 이러한 문제는 어떻게 해결해야 할까요?
문제 해결하기
이렇게 다수 요청이 발생했을 때 에러가 발생하는 이유는 각각의 요청이 질서 없이 무분별하게 요청되기 때문입니다. 그렇기 때문에 발생되는 401 에러 요청의 횟수와 상관없이 토큰 Refresh를 1번만 진행하고 각각의 요청의 순서를 정한 뒤, 토큰 Refresh를 통해 받은 새로운 토큰으로 기존의 에러가 발생했던 요청을 재요청하며 요청의 에러를 해결해야 합니다.
다음은 제어 플래그 패턴을 통해 수정한 interceptor response 코드입니다.
let authTokenRequest: Promise<any> | null = null;
instance.interceptors.response.use(
(response) => response,
async (error) => {
const { config, response } = error;
const originalRequest = config;
if (response.status === 401 && !originalRequest.__isRetryRequest) {
if (!authTokenRequest) {
originalRequest.__isRetryRequest = true;
authTokenRequest = refreshAuthToken();
}
try {
const newAuthTokenResponse = await authTokenRequest;
tokenService.setUser(newAuthTokenResponse);
return instance(originalRequest);
} catch (refreshError) {
tokenService.removeUser();
window.location.replace("/login");
return Promise.reject(refreshError);
} finally {
authTokenRequest = null;
}
}
return Promise.reject(error);
},
);
async function refreshAuthToken() {
try {
return await RefreshAPI();
} catch (error) {
throw new Error("Failed to refresh auth token");
}
}
const RefreshAPI = async () => {
const response = await axios({
// 토큰 리프레시 API 작성
});
return response.data;
};
다음은 수정된 코드의 핵심 부분입니다.
let authTokenRequest: Promise<any> | null = null;
if (response.status === 401 && !originalRequest.__isRetryRequest) {
if (!authTokenRequest) {
originalRequest.__isRetryRequest = true;
authTokenRequest = refreshAuthToken();
}
try {
const newAuthTokenResponse = await authTokenRequest;
tokenService.setUser(newAuthTokenResponse);
return instance(originalRequest);
} catch (refreshError) {
tokenService.removeUser();
window.location.replace("/login")
return Promise.reject(refreshError);
} finally {
authTokenRequest = null;
}
}
위의 코드에서는 error가 발생하면, if문을 통해서 응답 상태가 401 (Unauthorized)이고, 해당 요청이 이미 재시도된 요청이 아닌 상태인지를 확인합니다.
그 후, if (!authTokenRequest) 조건을 통해 현재 진행 중인 토큰 Refresh 요청이 있는 지를 검사합니다.
만약 authTokenRequest가 null이라면, originalRequest.__isRetryRequest를 통해 현재의 요청이 재시도 중이라는 플래그를 설정합니다. 이는 이후에 동일한 요청이 다시 이 블록을 통과하지 않도록 방지하기 위함입니다.
또, authTokenRequest가 null이면, 이는 아직 다른 토큰 Refresh 요청이 진행되지 않았음을 의미합니다. 이 경우, 새로운 토큰 Refresh 요청을 시작할 수 있습니다. 그렇기 때문에 refreshAuthToken 함수를 호출하여 토큰 Refresh를 진행하고, authTokenRequest 변수에 이 Promise를 담아 await을 통해 authTokenRequest에 저장된 Promise가 완료될 때까지 기다리게 합니다.
그렇게 토큰 Refresh가 끝났다면, 갱신된 새로운 토큰 정보가 newAuthTokenResponse에 담기게 되고, 토큰을 로컬 스토리지에 저장한 뒤, 에러가 발생했던 originalRequest의 헤더에 새로운 엑세스 토큰을 담고 요청을 다시 시도합니다.
최종적으로 401 에러가 발생한 요청의 횟수와 상관없이 1번의 토큰 Refresh만 진행하게 되고, 로컬에 저장되는 토큰의 꼬임 없이, 기존 요청에서 발생한 401 에러를 해결 후 성공적으로 요청이 진행됩니다.
도움이 된 글
https://axios-http.com/kr/docs/interceptors
https://github.com/axios/axios/issues/450
https://gusrb3164.github.io/web/2022/08/07/refresh-with-axios-for-client/
'Web Frontend > Library' 카테고리의 다른 글
조금 늦은 Tailwind CSS 사용 후기 (2) | 2024.04.08 |
---|---|
React-hook-form에서 Cannot read properties of undefined 해결하기 (0) | 2023.06.01 |
React-Query로 Optimistic UI 구현하기 (0) | 2023.01.08 |