home

여러 요청에 대응하는 Axios Interceptor

글 분류
main
키워드
axios
생성일
2024/04/07 09:58
최근 수정일
2024/04/16 23:32
작성중
글 작성 시 사용한 axios 버전 → 1.4.0

⚽️ 목표

단 한번만 토큰 재발급 요청을 보내는 Axios Interceptor를 제작한다.
여러 요청이 한 번에 여러 개의 요청이 거의 병렬적으로 수행될 때 단 한 번만 토큰 재발급 요청을 보내고 재발급받은 토큰으로 재요청하는 Axios Interceptor 제작.

다루지 않는 것

promise의 기본 작동 방식
axios의 기본 작동 방식
interceptor에 대한 기본 지식

TL;DR

promise와 모듈 스코프 변수를 사용하면 어렵지 않게 구현할 수 있다.
하나의 토큰 재발급 요청, 하나의 promise

1. as-is

sequenceDiagram
  브라우저 ->> 서버:요청1
  브라우저 ->> 서버:요청2
  브라우저 ->> 서버:요청3

  서버 -->> 브라우저: 요청1 실패 응답(토큰만료)
  브라우저 ->> 서버:토큰 재발급 요청(요청1)

  서버 -->> 브라우저: 요청2 실패 응답(토큰만료)
  브라우저 ->> 서버:토큰 재발급 요청(요청2)
  서버 -->> 브라우저: 요청3 실패 응답(토큰만료)
  브라우저 ->> 서버:토큰 재발급 요청(요청3)



  서버 -->> 브라우저 : 토큰 재발급(토큰명 A)
  Note over 서버,브라우저 : 토큰 A 발급됨
  브라우저 ->> 서버 :토큰A로 요청1 재요청
  서버 -->> 브라우저 : 토큰 재발급(토큰명 B)
	Note over 서버,브라우저 : 토큰 B 발급됨
  브라우저 ->> 서버 :토큰B로 요청2 재요청
  서버 -->> 브라우저 : 토큰 재발급(토큰명 C)
  Note over 서버,브라우저 : 토큰 C 발급됨
  브라우저 ->> 서버 :토큰C로 요청3 재요청
Mermaid
복사
페이지 이동 시, 이용자가 특정 상호 작용 시 한 번에 3개의 API가 요청되는 상황이 있다고 가정한다.
이전에 작성된 로직의 가장 큰 문제점은 한 번에 여러 개의 토큰이 만료된 요청이 가는 경우 요청 갯 수만큼 서로 다른 토큰이 발급되는 것이었다.

2. to-be

sequenceDiagram
  participant 인터셉터
  participant 브라우저
  participant 서버
autonumber
  브라우저 ->> 서버:요청1
  서버 -->> 브라우저: 요청1 실패 응답(토큰만료)
  브라우저 ->> 서버: 요청2
  브라우저 ->> 서버: 요청3
	브라우저 -->> 인터셉터 : 인터셉터가 실패 응답 catch
	
	rect rgb(236,236,254)
	인터셉터 ->> 서버  : 서버로 토큰 재발급 요청
	activate 서버
  end
  

  서버 -->> 브라우저: 요청2 실패 응답(토큰만료)
  서버 -->> 브라우저: 요청3 실패 응답(토큰만료)
	브라우저 -->> 인터셉터 : 인터셉터가 실패 응답 catch 후<br/>요청1(다이어그램상 6)의<br/>토큰 재발급 요청 완료까지 대기
	서버 -->> 인터셉터 :6번 요청에대한 응답 - 토큰발급
		deactivate 서버
	인터셉터 ->> 서버: 재발급된 토큰으로 요청1,2,3 재요청
	서버 ->> 브라우저: 요청 1,2,3에 대한 정상 응답
Mermaid
복사
쉬운 이해를 위해 인터셉터와 브라우저를 나눠서 표현했다.
해당 다이어그램을 간단하게 설명하자면 다음과 같다.
1.
요청 1,2,3이 한 번에 요청된다.(토큰이 만료된 상태에서 새로운 페이지로 이동하는 상황)
2.
요청 1이 제일 먼저 요청된다고 가정한다. 요청 1의 실패 응답이 도착할 경우 인터셉터가 실패 응답을 catch 하며 서버로 토큰 재발급 요청한다.
3.
요청 2,3도 추후에 요청되며 토큰이 만료된 상태이므로 해당 요청들은 서버에서 실패로 응답한다.
4.
이때 인터셉터는 요청 1의 실패로 인한 토큰 재발급 요청을 하고 있으므로 요청 2,3은 토큰 재발급이 완료될 때까지 대기한다.
5.
토큰이 재발급된 후 인터셉터를 이용해 요청 1,2,3을 재요청하고 브라우저는 해당 요청들에 대한 응답을 받는다.
“토큰이 만료된 상태에서 한 번에 여러 개의 요청이 서버로 전달될 경우 토큰 발급 로직은 한 번만 실행되고 발급된 토큰으로 전체 재요청하게 만드는 것”이 궁극적인 목표다.

3. 변수 및 함수 설명

let authPromise: Promise<AxiosResponse> | null = null const reissueToken = () => { const refreshToken = localStorage.getItem('refreshToken') return axios.post('https://토큰-재발급-API주소', { refresh_token: refreshToken, }) } const resetAuthPromise = () => { authPromise = null } const getAuthToken = () => { if (!authPromise) { authPromise = reissueToken() authPromise.then(resetAuthPromise, resetAuthPromise) } return authPromise }
TypeScript
복사

authPromise

reissueToken 함수의 리턴값인 axiosResponse promise를 담는 모듈 변수
이미 토큰 재발급 로직이 실행 중인가? 를 확인하는 변수

reissueToken

토큰 재발급받는 함수로 axiosResponse promise를 반환한다.

resetAuthPromise

authPromise 변수를 초기화하는 함수

getAuthToken

제일 중요한 함수
authPromise가 truthy일 경우 - authPromise가 truthy라는 뜻은 이미 토큰 재발급 요청이 수행되고 있다는 뜻이므로 모듈 변수로 존재하는 authPromise를 반환해 준다.
authPromise가 falsy일 경우 → authPromise가 falsy라는 뜻은 토큰 재발급 요청이 수행되고 있지 않다는 뜻이므로 reissueToken 함수를 실행하고 토큰 재발급 성공, 실패와 상관없이 authPromise를 초기화해준 후 authPromise를 반환한다.(.then이니 promise가 resolve 된 후에 실행되는 건 설명하지 않아도 되겠찌…!??)

interceptors.response.use

authorizedInstance.interceptors.response.use( (successRes) => successRes, async (error) => { if (error.response.status === 400 && error.config && !error.config.__isRetryRequest) { return getAuthToken().then((response) => { const newAccessToken = response.data.data.access_token localStorage.setItem('accessToken', newAccessToken) localStorage.setItem('refreshToken', response.data.data.refresh_token) error.config.headers.Authorization = `Bearer ${newAccessToken}` error.config.__isRetryRequest = true return axios(error.config) }) } return Promise.reject(error) } )
TypeScript
복사
해당 코드에서는 응답 코드가 400일 경우 토큰 만료라고 가정한다.
__isRetryRequest는 플래그 역할을 하는 프로퍼티로 직접 axios에 존재하는 프로퍼티가 아니라 직접 추가한 프로퍼티다. 해당 프로퍼티로 재발급된 토큰으로 재요청 시 또 실패할 경우 다시 재요청하는 무한루프를 방지하기 위한 safety lock이다.
응답이 “네가 했던 요청의 토큰은 만료됐으며 재발급된 토큰으로 재요청에 또 실패해서 다시 요청한 게 아닌 경우”일 경우 토큰 재발급 요청을 실행한다.

4. 다이어그램을 활용한 설명

phase 1. 요청1의 authPromise 점유

graph TD
  rq1(요청1)
  rq2(요청2)
  rq3(요청3)

  rq1(요청1) ==> f1(실패)
  rq2(요청2) -.-> f2(응답 대기)
	rq3(요청3) -.-> f3(응답 대기)
	f1 ==> func(getAuthToken)
	func ==null에서 axios promise로 변경됨==> authPromise
Mermaid
복사
// 요청 1의 getAuthToken에서는 authPromise가 null인 상태이므로 getAuthToken을 실행한다. const getAuthToken = () => { if (!authPromise) { authPromise = reissuanceTokens() authPromise.then(resetAuthTokenRequest, resetAuthTokenRequest) } return authTokenRequest }
TypeScript
복사
위에 미리 설명했듯이 요청 1,2,3중 요청 1이 제일 먼저 실행된다는 가정이다. 요청 2,3도 요청 1과 큰 시간차이 없이 실행됐지만 요청1에 대한 응답을 제일 먼저 받은 상황이다.
초기상태에 authPromise는 null이므로 reissueToken 함수를 호출하여 promise객체를 authPromise에 담는다.
이때 authPromise에는 토큰 재발급 요청에 대한 axios promise가 존재한다.

phase 2. 요청 2,3의 처리

graph TD

	  rq1(요청1) -.처리중(토큰 재발급 받는 중)...- authPromise
	  rq2(요청2) --> f2(실패) --> getAuthToken
	  rq3(요청3) --> f3(실패) --> getAuthToken
Mermaid
복사
let authPromise: Promise<AxiosResponse> | null = null // 요청1이 이미 점유했으므로 axios response promise를 가지고있다. // 요청 1의 getAuthToken에서는 authPromise가 null인 상태이므로 getAuthToken 실행 시 reissueToken을 실행하지 않고 모듈 변수인 autPromise를 반환한다. const getAuthToken = () => { if (!authPromise) { authPromise = reissueToken() authPromise.then(resetAuthTokenRequest, resetAuthTokenRequest) } return authTokenRequest }
TypeScript
복사
요청 1로 인해 authPromise는 토큰을 재발급해오는 axiosResponse promise를 가지고 있다.
요청 2, 요청 3이 실패 응답을 받아 getAuthToken을 실행한다. 이때 authPromise는 null이 아니므로 authPromise를 반환한다.
graph TD
	authPromise(토큰 재발급 promise)

	then1(getAuthToken.then - 요청1) -.대기중.->	authPromise
	then2(getAuthToken.then - 요청2) -.대기중.->	authPromise
	then3(getAuthToken.then - 요청3) -.대기중.->	authPromise
Mermaid
복사
phase 2에서는 요청 1,2,3, 모두 토큰 재발급 promise가 resolve 될 때까지 대기 중인 상황이다.
하나의 promise(토큰 재발급)가 then으로 resolve 될 때까지 대기 중

phase 3. 토큰 재발급 후

authorizedInstance.interceptors.response.use( (successRes) => successRes, async (error) => { if (error.response.status === 400 && error.config && !error.config.__isRetryRequest) { // 확인해야할 부분 return getAuthToken().then((response) => { const newAccessToken = response.data.data.access_token localStorage.setItem('accessToken', newAccessToken) localStorage.setItem('refreshToken', response.data.data.refresh_token) error.config.headers.Authorization = `Bearer ${newAccessToken}` error.config.__isRetryRequest = true return axios(error.config) }) } return Promise.reject(error) } )
TypeScript
복사
graph TD
	authPromise(토큰 재발급 promise)
  authPromise -..-> resolve([resolve 됨 - 토큰 재발급 받아옴])
	resolve -..->	then1(요청1의 getAuthToken.then 실행)
	resolve -..->	then2(요청2의 getAuthToken.then 실행)
	resolve -..->	then3(요청3의 getAuthToken.then 실행)
Mermaid
복사
요청 1에서 만들어낸 토큰 재발급 Promise가 resolve 될 경우, 즉 토큰이 재발급 됐을 때 실행되는 로직이다.
토큰 재발급 요청에 대한 응답의 토큰들을 이용하여 요청 1,2,3이 재요청된다.

결론

전체 코드

import axios, { AxiosResponse } from 'axios' const unAuthorizedInstance = axios.create({}) const authorizedInstance = axios.create({}) let authPromise: Promise<AxiosResponse> | null = null const reissueToken = () => { const refreshToken = localStorage.getItem('refreshToken') return axios.post('https://토큰-재발급-API주소', { refresh_token: refreshToken, }) } const resetAuthPromise = () => { authPromise = null } const getAuthToken = () => { if (!authPromise) { authPromise = reissueToken() authPromise.then(resetAuthPromise, resetAuthPromise) } return authPromise } authorizedInstance.interceptors.request.use( // ...생략... ) authorizedInstance.interceptors.response.use( (successRes) => successRes, async (error) => { if (error.response.status === 400 && error.config && !error.config.__isRetryRequest) { return getAuthToken().then((response) => { const newAccessToken = response.data.data.access_token localStorage.setItem('accessToken', newAccessToken) localStorage.setItem('refreshToken', response.data.data.refresh_token) error.config.headers.Authorization = `Bearer ${newAccessToken}` error.config.__isRetryRequest = true return axios(error.config) }) } return Promise.reject(error) } ) export { authorizedInstance, unAuthorizedInstance }
TypeScript
복사

참조