
팀 프로젝트 중 로그인 구현을 JWT를 활용하여 AccessToken은 로컬스토리지, RefreshToken은 httpOnly 쿠키에 담아 헤더로 보내기로 결정했다.
이러한 로그인 인증/인가 과정 중 (1) 헤더에 토큰을 담아보내기나, (2) 토큰 만료 시 재발급하는 과정을 API 요청 전, 후 처리로 구현해야하는 상황이였다.
Axios의 Interceptor를 사용하면 간편하게 구현할 수 있지만, 프로젝트 설계 당시 내부 사정으로 Fetch API를 사용하고 있어 직접 구현하게 되었다.
첫 번째 해결 방법으로 API 요청에 공통된 전,후 처리를 할 로직을 Fetch API로 래핑하여 구현했다. 요청 실행 전 토큰을 헤더에 포함하는 전처리를 수행하고, 요청 후 response 값에 따라 If / Else 조건문으로 후처리를 해줬다.
Interceptor의 전처리 역할과 함께 axios.create 처럼 공통 헤더에 토큰을 포함시키는 역할을 아래의 코드와 같이 baseOption으로 묶어 구현했다.
//authorityApi.js
const authorityApi = async (method, endpoint, { body }) => {
const accessToken = window.localStorage.getItem("ACT");
//요청 전처리 수행
const baseOption = {
method: method,
headers: {
"Content-Type": "application/json",
Authorization: `${accessToken}`,
},
credentials: "include",
};
//...
}
baseOption에 토큰을 담아 전처리 작업 후 Async / Await 기반 요청에 돌아오는 response 값에 따라 후처리를 해줬다.
response에 대한 응답값은 엑세스 토큰이 정상적일 경우 200 코드를, 엑세스 토큰이 만료되었을 경우 401 코드를 반환한다. 이때 엑세스 토큰이 만료된 401 코드를 받으면 엑세스 토큰 재발급 요청을 보낸 후 새로운 토큰을 다시 첨부하여 기존 요청을 재시도하는 과정을 아래 코드처럼 재귀 호출로 구현했다.
//authorityApi.js
const authorityApi = async (method, endpoint, { body }) => {
//... 요청 전처리 생략
try {
const url = `/api${endpoint}`;
const response = await fetch(url, {
...baseOption,
});
//엑세스 토큰 만료 시 재발급
if (response.status === 401) {
const refreshUrl = "/reissue";
console.log("토큰 만료!");
const refreshResponse = await fetch(refreshUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
});
//엑세스 토큰 재발급 성공
if (refreshResponse.status === 200) {
const newAccessToken = refreshResponse.headers.get("Authorization");
window.localStorage.setItem("ACT", newAccessToken);
console.log("토큰 재발급 성공");
//원래 작업 실행
return authorityApi(method, endpoint);
} else {
//엑세스 토큰 재발급 실패
const refreshErrData = refreshResponse;
console.log("토큰 재발급 실패");
logoutApi();
throw new Error(refreshErrData.message);
}
}
//...
}
//...
};
이렇게 interceptor의 기능을 대신할 수 있게 fetch로 래핑한 로직을 구현하였다. 조금 찝찝한 기분이 들었지만 프로젝트가 작동하는데 문제 없었기에 일단 수정 없이 프로젝트를 마무리했다.
여유가 생기고 다시 코드를 보니 이 찝찝함이 당연했다.
위의 코드들에 생략된 에러 처리 코드들까지.. 구현한 내가 봐도 가독성이 안좋고, 어영부영 시간에 쫓겨 작성한 느낌이 든다. 그래서 더 개선된 로직을 구현해보기로 했다.
위의 코드는 하나의 로직 안에서 모든 역할을 수행하는데, 좀 더 Interceptor 스럽게 작동하도록 모듈로 나누어줬다.
크게 아래 3가지로 구분해주었고, 추가적으로 토큰 재발급 로직도 분리했다.
2-1. 요청 전처리 : requestFetch
2-2. 요청 후처리 : responseFetch
( + 토큰 재발급 : handleRefreshToken )
2-3. 최종 요청 관리 및 실행 : authorityApi
const requestFetch = (method, { body }) => {
const accessToken = window.localStorage.getItem("ACT");
const baseOption = {
method: method,
headers: {
"Content-Type": "application/json",
Authorization: `${accessToken}`,
},
credentials: "include",
};
if (body) {
baseOption.body = JSON.stringify(body);
}
return baseOption;
};
Axios interceptor 중 instance.interceptors.request.use 를 대신한다. interceptor에서 config 를 사용하여 요청 구성 객체를 설정하는데 이를 baseOption 으로 대체하여 쿠키와 토큰을 담아 반환해줬다.
토큰 검증이 필요한 요청에 최대한 범용적으로 사용 가능하도록 하기 위해 body 는 조건문으로 묶어 값이 존재할 경우만 포함해줬다.
const responseFetch = async (response, method, endpoint, { body }) => {
if (response.status === 200) {
console.log("토큰 유효함. 권한 확인 완료");
const data = await response.json();
return data;
} else if (response.status === 401) {
try {
await handleRefreshToken();
return authorityApi(method, endpoint, { body });
} catch (error) {
throw error;
}
}
} else if (response.status === 400) {
//양식 오류
throw new Error(response.message);
} else {
//엑세스 토큰 검증 오류
console.log("토큰 검증 실패");
logoutApi();
}
};
Axios interceptor 중 instance.interceptors.response.use 를 대신한다.
전처리를 포함한 요청을 통해 받은 response 코드 값에 따라 후처리를 해줬다. 토큰 검증 오류에 관해서는 로그아웃 처리를 해주어 만약의 상황을 예방했다. 토큰 만료일 경우 401 코드를 반환하는데, 이때 토큰 재발급 로직을 실행한 후 원래 요청을 재시도하는 재귀 호출을 한다.
const handleRefreshToken = async () => {
const refreshUrl = "/reissue";
console.log("토큰 만료!");
const refreshResponse = await fetch(refreshUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
});
if (refreshResponse.status === 200) {
const newAccessToken = refreshResponse.headers.get("Authorization");
window.localStorage.setItem("ACT", newAccessToken);
console.log("토큰 재발급 성공");
return newAccessToken;
} else {
//엑세스 토큰 재발급 실패
const refreshErrData = refreshResponse;
console.log("토큰 재발급 실패");
logoutApi();
throw new Error(refreshErrData.message);
}
};
후처리 로직 responseFetch 중 401 코드를 반환 시 실행되는 토큰 재발급 로직이다. 기존 fetch로 래핑한 코드에서는 하나의 로직에서 오류 처리를 하여 오류 디버깅 및 관리가 어려웠던 경험이 있었다. 그래서 재발급 로직을 분리해서 오류 디버깅을 효율적으로 관리하고 가독성 및 재사용성을 높였다.
const authorityApi = async (method, endpoint, { body }) => {
const baseOption = requestFetch(method, { body });
const url = `/api${endpoint}`;
try {
const response = await fetch(url, baseOption);
return await responseFetch(response, method, endpoint, { body });
} catch (error) {
console.error("Fetch Error:", error);
throw error;
}
};
실질적으로 호출되는 함수로써, 위에서 설정한 requestFetch로 요청 옵션 설정 후 API 요청을 수행하고 responseFetch를 통해 응답을 처리한다. axios.create와 유사하게 전달받은 endpoint를 기반으로 기본 API URL을 구성한다.
여기까지 재사용성과 가독성을 높이도록 리팩토링을 해봤다.
확실히 이전의 코드에 비해서는 깔끔하고 관리하기도 쉬워졌다.
그런데 아직 해결해보고 싶은 문제가 더 있다.
기존 코드 구현 당시 메인페이지에는 토큰 검증이 필요한 요청이 여러 개 있었는데, 토큰 만료 상태에서 메인 페이지 접속 시 토큰 검증 요청이 동시에 이뤄지면서 토큰 재발급 요청이 중복되어 오류가 생겼었다.
당시에는 테스트를 앞두고 있어 급하게 문제를 해결하기 위해 토큰 검증 로직을 분리하고, 한쪽에는 재발급 로직을 제외시켰다. 그러고서 useEffect에 토큰 값을 의존성으로 두어 다른 요청에서 토큰이 재발급될 시 쿼리 키를 무효화 하는 방법을 아래처럼 사용했다.
//예시
const queryClient = useQueryClient();
const [token, setToken] = useRecoilState(accessTokenState);
const hasToken = localStorage.getItem("ACT");
useEffect(() => {
setToken(localStorage.getItem("ACT"));
}, [hasToken, setToken]);
useEffect(() => {
queryClient.invalidateQueries({ queryKey: ["chatToken"] });
}, [token, queryClient]);
문제는 해결되었지만, 토큰 검증 로직과 같이 동일한 기능을 하는 로직이 중복되어 사용되고 불필요한 리렌더링이 생기고 있다.
이번 리팩토링을 하면서 동시 요청을 처리하는 글을 볼 수 있었고, 이를 적용해보기로 했다.
권한 확인이 필요한 여러 요청이 동시에 401코드를 반환하면, 각각의 요청이 토큰 재발급을 시도해 불필요한 요청으로 서버 과부하가 발생하거나 재발급 과정이 꼬여 오류가 생긴다.
토큰 검증 로직을 모듈로 통일하고, 토큰 재발급 과정이 중복되는 문제를 해결하기 위해 아래 두 가지를 사용했다.
let isRefreshing = false;
let failedQueue = [];
let isRefreshing = false 가 플래그 역할을 하여 토큰 재발급이 시작되면 isRefreshing을 true로 설정하여 다른 요청이 토큰 재발급 시도를 하지 못하게 막는다.
//요청 후처리
const responseFetch = async (response, method, endpoint, { body }) => {
//...
} else if (response.status === 401) {
if (!isRefreshing) {
isRefreshing = true;
try {
await handleRefreshToken();
isRefreshing = false;
return authorityApi(method, endpoint, { body });
} catch (error) {
isRefreshing = false;
throw error;
}
} //...
};
let failedQueue = [ ] 는 요청 재시도를 위한 대기열로 isRefreshing = true 상태인 토큰 재발급 진행 중일 때 발생한 요청들을 임시로 저장한다. 이를 콜백 함수로 저장해두었다가 재발급 성공 시 순차적으로 실행한다.
//요청 후처리
const responseFetch = async (response, method, endpoint, { body }) => {
//...
} else if (response.status === 401) {
if (!isRefreshing) {
//...
} else {
return new Promise((resolve, reject) => {
failedQueue.push(async () => {
const retryResponse = await authorityApi(method, endpoint, { body });
resolve(retryResponse);
});
});
}
} //...
};
//토큰 재발급
const handleRefreshToken = async () => {
//... 재발급 요청
if (refreshResponse.status === 200) {
const newAccessToken = refreshResponse.headers.get("Authorization");
window.localStorage.setItem("ACT", newAccessToken);
console.log("토큰 재발급 성공");
failedQueue.forEach((callback) => callback());
failedQueue = [];
return newAccessToken;
}//...
};
# 1.
A 요청이 401 응답을 받아 handleRefreshToken을 호출.
isRefreshing = true로 설정.
토큰 재발급을 시도.
# 2.
B 요청도 401 응답을 받음.
이때 isRefreshing이 true이므로, handleRefreshToken을 재호출하지 않고 각 요청의 콜백을 failedQueue에 추가.
# 3.
토큰 발급 성공.
새로운 토큰이 localStorage에 저장됨.
failedQueue의 모든 콜백을 실행하여 B 요청을 재시도.
failedQueue 초기화.
이렇게 동시 요청을 처리함으로써 나눠놨던 로직을 하나로 통일하여 중복된 코드를 없앨 수 있었고, 그에 따라 의존성을 가지고 리렌더링 되던 컴포넌트도 줄일 수 있었다. 처음 코드를 짤 때 조금만 더 생각해보고 짰더라면 많은 문제들을 한 번에 해결할 수 있었지 않았을까 하는 아쉬움과 동시에 이제라도 근본적인 문제를 해결해서 뿌듯하다.