토큰 재발급 중복 요청 문제

변찬우·2023년 2월 26일
0
post-thumbnail

이 문제는 학교에서 친구들과 진행한 동아리 관리 서비스 프론트를 개발하면서 발생하게 된다

프로젝트에 대한 간단한 설명

우선 이 문제를 이해하기 위해 프로젝트의 인가 방식에 대해서 설명을 하자면 refreshTokenaccessToken을 가지고 Authorization에 담아 서버로 보내면 되고 accessExprefreshExp값을 통해 토큰 만료 시간을 검사하면 되는 방식이다

하지만 문제는 페이지 렌더링 시에 2개의 요청을 동시에 보내면서 시작되었다.

문제의 시작

이 프로젝트에서는 axios를 사용해 api 통신을 하고 있었는데 axios의 interceptor에서 accessTokenAuthorization에 담거나, accessToken이 만료되었을 때 재발급을 해주는 작업을 하고 있었다.

API.interceptors.request.use(async (config) => {
  const tokenManager = new TokenManager()
  
  // ...

  // accessToken이 만료 되었는지 검증
  if (
    !tokenManager.validateToken(
      tokenManager.accessExp,
      tokenManager.accessToken
    )
  )
    // 토큰 재발급 요청을 하는 부분
    await tokenManager.tokenReissue()

  // accessToken을 Authorization에 담는 부분
  config.headers['Authorization'] = tokenManager.accessToken
    ? `Bearer ${tokenManager.accessToken}`
    : undefined

  // ...
  
  return config
})

그런데 페이지 하나가 렌더링 될 때 api 요청 2개를 동시에 보내다 보니 accessToken 값이 만료되었을 때 토큰 재발급 요청도 2번이 가게 되는 문제가 발생했다.

때문에 token 값은 꼬이게 되었고 그대로 로그아웃이 되어버리는 상황까지 와버렸다

1차 시도 (react-query)

우선 가장 간단한 방법으론 react-query 라는 라이브러리를 사용하는 방법이다.
react-query의 경우 중복 요청을 막는 기능이 내장되어 있어서 사용만 하면 이 문제를 쉽게 해결할 수 있다

참고 자료

하지만 이 프로젝트에서는 react-query 대신 내가 직접 개발한 useFetch라는 커스텀 훅을 사용하고 있기 때문에 이제 와서 react-query로 바꾸기에는 너무 늦은 것 같아 최후의 수단으로 남겨두기로 했다

2차 시도 (시간 저장)

axios의 interceptor에서 토큰 재발급을 요청을 시작하기 전에 시간을 기록해 두고 2번째 재발급 요청이 오면 전에 기록한 시간을 보고 재발급 요청을 한지 얼마 안 되었다는 걸 판단하고 재발급 요청을 취소하는 방법이다

// 재발급 요청 전에 시간을 기록하는 변수
let refreshTime = ''

API.interceptors.request.use(async (config) => {
  const tokenManager = new TokenManager()

  if (
    !tokenManager.validateToken(
      tokenManager.accessExp,
      tokenManager.accessToken
	)
  )
    // 토큰 재발급을 하기 전에 시간 기록
    refreshTime = new Date().toString()
  	// 토큰 재발급 전에 시간을 계산하여 1분 이내에 재발급 요청을 했으면 재발급 요청을 취소
    await tokenManager.tokenReissue(refreshTime)
 
  config.headers['Authorization'] = tokenManager.accessToken
    ? `Bearer ${tokenManager.accessToken}`
    : undefined

  return config
})

이론상 될 것 같았지만 사실 재발급 요청은 따로 가는 게 아닌 동시에 가는 거라서 refreshTime을 콘솔에 찍어보면 모두 빈 문자열을 출력하는 걸 볼 수 있다.

때문에 이 방법은 폐기하게 된다

3차 시도 (redux-toolkit thunk)

도저히 감이 오지 않아 마침 프로젝트에서 RTK를 사용하고 있어서 토큰 재발급 요청을 RTK로 옮기고 비동기 작업이 가능하도록 thunk를 사용해 구현을 했다

const initialState: InitialState = {
  isLoading: false,
  refreshDate: '',
}

const reissueSlice = createSlice({
  name: 'reissue',
  initialState,
  reducers: {},
  extraReducers: (builder) => {
    builder.addCase(reissueToken.pending, (state) => {
      // 2차 시도 때 처럼 요청 전에 시간을 기록
      state.refreshDate = new Date().toString()
      // 요청을 시작하면 true로
      state.isLoading = true
    })
    builder.addCase(reissueToken.fulfilled, (state) => {
      // 요청이 끝나면 false로
      state.isLoading = false
    })
    builder.addCase(reissueToken.rejected, (state) => {
      const tokenManager = new TokenManager()
      // 요청에 실패하면 모든 토큰을 제거
      tokenManager.removeTokens()

      state.isLoading = false
      if (window.location.pathname !== '/')
        // 메인 페이지로 redirect
        window.location.href = '/'
    })
  },
})

위 코드를 설명하자면 RTK thunk가 시작되면 isLoading값을 true로 하고 시간도 기록을 해둔다. 그러다 요청이 끝나면 isLoading 값을 다시 false로 해준다.
만약 실패를 한다면 refreshToken이 만료된 것으로 인지하고 redirect를 시킨다.

다음은 thunk 부분이다

export const reissueToken = createAsyncThunk(
  'reissue/reissueToken',
  async (reissueStore: InitialState) => {
    const tokenManager = new TokenManager()

    if (
      // isLoading 값이 true면 재발급 요청을 하지 않고 thunk를 끝낸다
      reissueStore.isLoading ||
      // 요청 전에 기록했던 시간을 가지고 1분 이내에 요청을 했는지 검사
      tokenManager.calculatMinutes(reissueStore.refreshDate, 1) >= new Date()
    )
      return

    // 토큰 재발급 요청
    const { data } = await axios.patch<TokensType>(/* ... */)

    tokenManager.setTokens(data)
    return data
  }
)

위 코드를 설명하자면 isLoading 값이 true이거나 1분 이내에 요청을 한 기록이 있으면 thunk를 끝내고 아니면 재발급 요청을 날리는 코드이다.

이렇게 해주니 accessExp값이 변질되었을 때는 잘 돌아가지만 accessToken 값이 변질되었을 때는 돌아가지 않았다

이유는 간단했다. 이렇게 해서 토큰 재발급 중복 요청이라는 문제는 해결이 되었지만 토큰이 재발급 되기 전에 header의 유저 정보 요청이나 메인 콘텐츠 정보 요청을 보내버리기 때문에 accessToken은 만료된 상태로 날아가게 되는 것이다

아래 auth 요청이 토큰 재발급 요청이다

4차 시도 (RTK thunk + delay)

3차 시도에서 중복 요청은 해결을 했기 때문에 이제 마지막 일만 남았다.
토큰 재발급 요청이 끝나기 전에 유저 정보나 메인 콘텐츠 정보를 가져오는 걸 막는 일이다.

즉 토큰 재발급이 있을 때는 모든 api 요청을 멈추고 토큰 재발급이 끝날 때 api 요청을 다시 시작한다

때문에 thunk에서 isLoading 값이 true 일 때는 바로 return 하는 것이 아닌 false가 될 때까지 기다렸다가 반환하는 방식으로 하고 싶었다.
하지만 thunk에는 그런 게 없는 것 같아서 최후의 수단을 쓰기로 했다.

export const reissueToken = createAsyncThunk(
  'reissue/reissueToken',
  async (_, { getState, dispatch }) => {
    const tokenManager = new TokenManager()
    const { reissue } = getState() as RootState

    if (
      reissue.isLoading ||
      tokenManager.calculatMinutes(reissue.refreshDate, 1) >= new Date()
    ) {
      // isLoading 값이 false가 될 때까지 무한 로딩
      while ((getState() as RootState).reissue.isLoading) {
        // 0.1초간 멈추기
        await delay(100)
      }
      // 무한 로딩이 끝나면 thunk를 끝냄
      return
    }

    // 요청 시작 전에 isLoading을 true로 변경하고 시간을 기록
    dispatch(
      setRefreshTiming({
        isLoading: true,
        refreshDate: new Date().toString()
      })
    )
    // 토큰 재발급 요청
    const { data } = await axios.patch<TokensType>(/* ... */)

    tokenManager.setTokens(data)
    return data
  }
)

우선 RTK 공식 문서를 둘러보다 두 번째 인자로 getStatedispatch를 받을 수 있다는 걸 보고 바로 적용하였다.
이로써 전처럼 isLoading 값을 매개변수로 받지 않아도 되고 항상 최신 값을 가져올 수 있는 장점이 생겼다

중간에 토큰 재발급 요청을 하고 있는지 검증하는 부분에 while 문이 추가가 됐는데 이 while 문은 isLoading 값을 계속 조회하면서 false 가 될 때까지 기다렸다가 끝나면 thunk 를 끝내는 방식으로 만들었다. 안에 delay 넣은 이유는 while 문 때문에 성능에 문제가 생길 것 같아 0.1초씩 멈추게 만들어 줬다.

이렇게 했더니 드디어 재발급 요청 후에 나머지 api 요청을 하는 모습을 볼 수 있었다

5차 시도 (Observer Pattern)

이전 4차 시도에서 while문을 활용해 해결을 했지만 문제는 만약 요청이 길어지면 무한으로 반복문을 돌아야 하고, 만약 요청에 성공을 해도 사용자는 0.1초 이하를 더 대기해야하는 문제가 있었다.

이 문제를 해결하기 위해 고민하던 도중 Observer 패턴이 떠올랐고 적용해 보기로 했다

class Observable {
  private observers: Observer[] = []

  setObserver(callback: CallbackType) {
    const observer = new Observer(callback)
    this.observers.push(observer)

    return observer
  }

  notifyAll() {
    this.observers.forEach((observer) => {
      observer.callback()
    })
  }

  removeAll() {
    this.observers = []
  }
}

class Observer {
  callback: CallbackType

  constructor(callback: CallbackType) {
    this.callback = callback
  }
}

const observable = new Observable()

export default observable

우선 현재 토큰 재발급 요청이 나가고 있는 상황이라고 가정을 해보자

첫 번째로 Observable의 setObserver 라는 메소드가 있는데 여기서 subscribe를 해준다. subscribe를 해주면 Observer라는 객체가 생기는데 Observer는 callback을 생성자로 받는다.

그 다음 토큰 재발급 요청이 끝나면 notifyAll 메서드가 실행이 되는데 notifyAll에서는 모든 observer의 callback 함수를 실행시키게 된다

마지막으로 observable을 싱글톤으로 사용하기 위해 Observable을 생성한 인스턴스를 반환한다

아래 코드는 observable을 사용한 코드이다

if (
	reissue.isLoading ||
	tokenManager.calculateMinutes(reissue.refreshDate, 1) >= new Date()
) {
	await new Promise((resolve) => {
      observable.setObserver(resolve)
    })
	return
}

while문을 돌던 코드를 지우고 Promise를 생성해 observable에서 notifyAll 메서드가 실행되기 만을 기다린다

// 토큰 재발급 요청
const { data } = await axios.patch<TokensType>(/* ... */)
observable.notifyAll()
observable.removeAll()

마지막으로 토큰 재발급 요청이 끝나면 notifyAll을 실행하고 나머지 observer는 이제 필요가 없으므로 removeAll을 통해 전부 지워버린다

느낀점

처음 보는 문제와 검색을 해도 나오지 않는 문제라 많이 겁을 먹었지만 직접 스스로 해결을 하니 굉장히 뿌듯했고 이 문제를 해결해 나가는 과정이 굉장히 재미있었던 것 같다. 이를 통해 observer도 다시 공부하게 되어서 좋은 경험이 됐던 것 같다

profile
잘 먹고 잘 살고 싶다 하핳

0개의 댓글