이 문제는 학교에서 친구들과 진행한 동아리 관리 서비스 프론트를 개발하면서 발생하게 된다
우선 이 문제를 이해하기 위해 프로젝트의 인가 방식에 대해서 설명을 하자면 refreshToken
과 accessToken
을 가지고 Authorization
에 담아 서버로 보내면 되고 accessExp
와 refreshExp
값을 통해 토큰 만료 시간을 검사하면 되는 방식이다
하지만 문제는 페이지 렌더링 시에 2개의 요청을 동시에 보내면서 시작되었다.
이 프로젝트에서는 axios를 사용해 api 통신을 하고 있었는데 axios의 interceptor에서 accessToken
을 Authorization
에 담거나, 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 값은 꼬이게 되었고 그대로 로그아웃이 되어버리는 상황까지 와버렸다
우선 가장 간단한 방법으론 react-query
라는 라이브러리를 사용하는 방법이다.
react-query
의 경우 중복 요청을 막는 기능이 내장되어 있어서 사용만 하면 이 문제를 쉽게 해결할 수 있다
하지만 이 프로젝트에서는 react-query 대신 내가 직접 개발한 useFetch
라는 커스텀 훅을 사용하고 있기 때문에 이제 와서 react-query
로 바꾸기에는 너무 늦은 것 같아 최후의 수단으로 남겨두기로 했다
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을 콘솔에 찍어보면 모두 빈 문자열을 출력하는 걸 볼 수 있다.
때문에 이 방법은 폐기하게 된다
도저히 감이 오지 않아 마침 프로젝트에서 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 요청이 토큰 재발급 요청이다
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 공식 문서를 둘러보다 두 번째 인자로 getState
나 dispatch
를 받을 수 있다는 걸 보고 바로 적용하였다.
이로써 전처럼 isLoading 값을 매개변수로 받지 않아도 되고 항상 최신 값을 가져올 수 있는 장점이 생겼다
중간에 토큰 재발급 요청을 하고 있는지 검증하는 부분에 while 문이 추가가 됐는데 이 while 문은 isLoading 값을 계속 조회하면서 false 가 될 때까지 기다렸다가 끝나면 thunk 를 끝내는 방식으로 만들었다. 안에 delay 넣은 이유는 while 문 때문에 성능에 문제가 생길 것 같아 0.1초씩 멈추게 만들어 줬다.
이렇게 했더니 드디어 재발급 요청 후에 나머지 api 요청을 하는 모습을 볼 수 있었다
이전 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도 다시 공부하게 되어서 좋은 경험이 됐던 것 같다