참고로 위 링크 정독 후 구현 시작-!
토큰 기반 인증 시스템에서는 클라이언트가 서버로 요청을 보낼 때마다 토큰을 함께 전달함
서버는 해당 토큰을 검증하여 유효한 사용자인지 확인하고, 요청에 대한 권한 및 접근 제어를 수행
Axios 인스턴스를 사용하여 HTTP 요청을 보낼 때, 인터셉터를 활용하여 토큰 검증 로직을 구현할 수 있음
⇒ axios 인터셉터가 모든 API 호출 전에 실행되므로
각각의 API 호출에서 별도로 토큰 유효성 검사 및 재발급 로직을 구현할 필요 없이
중앙에서 관리할 수 있음
: Axios에서 제공하는 기능으로, HTTP 요청과 응답을 가로채서 원하는 처리를 할 수 있게 해주는 기능
인터셉터를 사용하면 모든 요청 전후에 특정 로직을 실행할 수 있음
ex) 요청 전에 헤더를 설정하거나 응답을 가공하는 등의 로직
현재 구현할 인터셉터는 모든 요청 전에 실행되어 해당 요청의 헤더에 유효한 토큰 값을 추가하거나, 만료된 토큰인 경우 재발급 등의 작업을 수행
: Axios 라이브러리에서 제공하는 독립적인 HTTP 클라이언트
지금 상황에서는 인터셉터를 활용하여 토큰 검증하는 설정을 적용하기 위해 사용
일반적으로 Axios를 사용하여 HTTP 요청을 보낼 때 기본 설정을 정의하고, 이를 바탕으로 요청을 처리하지만! 때로는 다른 기본 설정이 필요한 상황이 발생할 수 있음.
ex) 특정 헤더 값을 요구하거나 타임아웃 시간을 더 길게 설정
동일한 설정을 가진 여러 요청들에 대해 반복적으로 코드를 작성하지 않아 유지보수 용이
Axios 인스턴스는 axios.create() 메서드를 사용하여 생성됨
이 메서드를 호출하면 새로운 Axios 인스턴스가 반환되며,
해당 인스턴스에 대해 원하는 설정 값을 지정할 수 있음
아래 로직으로 구현하고 있던 중…
- [프론트엔드] 사용자가 로그인 정보를 제공합니다.
- [백엔드] 서버는 제공된 로그인 정보를 검증하고, 유효한 경우 액세스 토큰(Access Token)과 리프레시 토큰(Refresh Token), 액세스 토큰의 만료 시간을 발급합니다. 이때 생성된 리프레시 토큰은 데이터베이스에 (사용자 ID, 리프레시 토큰) 형태로 저장됩니다.
- [프론트엔드] 서버로부터 반환된 액세스 토큰을 API 호출마다 요청의 헤더에 포함하여 전송합니다.
- [백엔드] API 호출 시 서버는 요청의 헤더에서 액세스 토큰을 확인하고, 유효성 및 만료 여부를 확인한 후 해당 API 동작을 수행합니다.
- [프론트엔드] 클라이언트는 액세스 토큰의 만료 시간을 체크하고, 만료 기간이 지나거나 30초 미만으로 남았다면 백엔드에 리프레시 토큰과 함께 재발급 요청(Reissue Request)을 보냅니다.
- [백엔드] 재발급 요청이 들어온 경우, 서버는 데이터베이스에서 해당 사용자의 리프레시 토큰을 확인한 후 유효성 검사를 수행합니다. 유효한 경우 새로운 액세스 토큰(Access Token)과 새로운 액세스 토큰의 만료 시간을 반환합니다.
- [프론트엔드] 클라이언트는 재발급 결과로 받은 새로운 액세스 토큰과 해당하는 만료 기간을 저장하여 다음 API 호출에 사용합니다.
클라이언트에서의 Access Token 만료 시간 체크는 몇 가지 이점과 치명적인 단점있다는 사실을 알게되었습니다…
이점
1) 빠른 응답 및 성능 개선
서버로 요청을 보내기 전에 클라이언트 측에서 Access Token의 만료 여부를 확인하면, 서버로부터 오는 추가적인 오류 응답 대기 시간을 줄일 수 있음.
만료된 Access Token으로 요청을 보내지 않으므로 서버는 불필요한 작업(Access Token 유효성 검증 등)을 수행하지 않아도 됨
2) 사용자 경험 개선
클라이언트 측에서 Access Token의 만료 여부를 실시간으로 확인하여, 만료된 경우에는 사용자에게 로그인 또는 재인증 요청과 같은 추가 조치를 취할 수 있는 안내를 제공할 수 있음
단점
1) 보안 위험
클라이언트가 직접 Access Token의 만료 시간을 체크하는 경우, 해당 정보가 클라이언트 쪽에 노출될 수 있으며 해커가 해당 정보를 탈취하거나 조작할 가능성이 생깁니다.
2) 코드 복잡성 및 관리 어려움
클라이언트 코드에 추가적인 로직과 처리 과정을 구현해야 하므로 코드 복잡성과 유지보수 어려움 등의 문제가 발생할 수 있습니다.
⇒ 일반적으로 중요한 보안 요소들은 서버 쪽에서 처리하는 것이 안전함
그리하여 아래 로직으로 구현하고자 함!
- [프론트엔드] 사용자가 로그인 정보를 제공합니다.
- [백엔드] 서버는 제공된 로그인 정보를 검증하고, 유효한 경우 액세스 토큰(Access Token)과 리프레시 토큰(Refresh Token), 액세스 토큰의 만료 시간을 발급합니다. 이때 생성된 리프레시 토큰은 데이터베이스에 (사용자 ID, 리프레시 토큰) 형태로 저장됩니다.
- [프론트엔드] 서버로부터 반환된 액세스 토큰을 API 호출마다 요청의 헤더에 포함하여 전송합니다.
- [백엔드] API 호출 시 서버는 요청의 헤더에서 액세스 토큰을 확인하고, 유효성 및 만료 여부를 확인한 후 해당 API 동작을 수행합니다. 이때, 액세스 토큰의 만료 시간을 체크하고, 만료 기간이 지났으면 오류를 반환합니다.
- [프론트엔드] 오류를 반환받았으면 백엔드에 리프레시 토큰, 사용자의 ID 및 이메일과 함께 재발급 요청(Reissue Request)을 보냅니다.
- [백엔드] 재발급 요청이 들어온 경우, 서버는 데이터베이스에서 해당 사용자의 리프레시 토큰을 확인한 후 유효성 검사를 수행합니다. 유효한 경우 새로운 액세스 토큰(Access Token)과 새로운 액세스 토큰의 만료 시간을 반환합니다. 유효하지 않을 경우 오류를 반환합니다.
- [프론트엔드] 클라이언트는 재발급 결과로 받은 새로운 액세스 토큰을 저장하여 다음 API 호출에 사용합니다. 오류를 반환받았으면 로그아웃 시키고 사용자가 새로 로그인하게 만듭니다.
클라이언트가 직접 액세스 토큰의 만료 시간을 체크하는 것이 아니라,
서버에서 체크하여 만료된 경우 오류 코드를 반환하고
클라이언트에서 해당 오류 코드에 따른 조치를 취하기로 했다.
위 이미지는 오류 코드와 메세지를 어떻게 줄 것인지 백엔드 팀원이 준 자료이다.
fetchApi.interceptors.request.use
이 부분은 모든 HTTP 요청 전에 실행되는 인터셉터이다.
이 곳에서 액세스 토큰이 있는지 확인하고, 있다면 해당 토큰 값을 요청의 헤더에 추가한다.
fetchApi.interceptors.response.use
이 부분은 서버로부터의 응답 후 실행되는 인터셉터이다.
만약 서버로부터 에러 코드가 반환된다면, 그 에러 코드에 따라 적절한 처리를 한다.
/src/utils/tokenUtils.ts
import axios, {
AxiosInstance,
AxiosRequestConfig,
AxiosError,
AxiosRequestHeaders,
} from "axios";
import {
getAccessToken,
getAsyncData,
logoutUser,
refreshToken,
} from "../utils/common";
import reactotron from "reactotron-react-native";
import { CommonType } from "../types/CommonType";
// AxiosRequestConfig 인터페이스를 확장하여 새로운 인터페이스를 정의
interface AdaptAxiosRequestConfig extends AxiosRequestConfig {
headers: AxiosRequestHeaders; // 헤더에 대한 타입 명시적으로 지정
}
// Axios 인스턴스 생성
export const fetchApi: AxiosInstance = axios.create();
// 요청 전에 실행되는 인터셉터
fetchApi.interceptors.request.use(
// 요청을 보내기 전에 실행되는 비동기 콜백 함수, 매개변수 config는 현재 요청의 설정 객체
async (config): Promise<AdaptAxiosRequestConfig> => {
// 액세스 토큰을 요청 헤더에 추가
// 로그인 데이터 가져오기
const getAccessTokenForApi = await getAsyncData<string>("accessToken");
if (getAccessTokenForApi) {
// 헤더에 토큰 추가
config.headers = config.headers || {};
config.headers.Authorization = `Bearer ${getAccessTokenForApi}`;
}
return config;
},
(error: AxiosError<CommonType.Terror>) => {
return Promise.reject(error);
},
);
fetchApi.interceptors.response.use(
response => response, // 응답이 성공적일 경우 그대로 반환
async (error: AxiosError<CommonType.Terror>) => {
const originalRequest = error.config as AdaptAxiosRequestConfig;
if (error.response) {
const errorCode = error.response.data.code;
const errorMessage = error.response.data.message;
let newAccessToken;
switch (errorCode) {
case "T-001":
reactotron.log!(errorMessage); // 올바른 토큰 아님
break;
case "T-002":
reactotron.log!("엑세스 토큰 기간 만료", errorMessage); // Access 토큰 기간 만료
await refreshToken(); // Refresh Token으로 새 AccessToken을 받아옴
newAccessToken = await getAccessToken();
reactotron.log!("뉴 엑세스", newAccessToken);
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; // 새 AccessToken으로 업데이트
return fetchApi(originalRequest); // 업데이트된 AccessToken으로 다시 원래의 request 재요청
case "T-003":
reactotron.log!("리프레시 토큰 기간 만료", errorMessage); // Refresh 토큰 기간 만료
logoutUser(); // 사용자를 로그아웃 시킴
break;
case "T-004":
reactotron.log!(errorMessage); // 토큰 없음
break;
case "T-005":
reactotron.log!(errorMessage); // 토큰에 담긴 유저와 토큰 보낸 유저 다름
break;
}
}
return Promise.reject(error);
},
);
export default fetchApi;
refreshToken 함수
이 함수는 액세스 토큰이 만료되었을 때 새로운 액세스 토큰을 받아오기 위해 사용된다.
이때, 헤더에는 accessToken을, 바디에는 사용자의 id와 email을 함께 보낸다.
이유는 암호화 된 토큰 안에 토큰 생성 시 요청했던 user의 id와 email이 담겨있어서
재발급 요청이 왔을 때 요청을 보낸 user가 함께 보낸 토큰의 주인이 맞는지 확인하기 위해서이다.
이렇게 하면 보안을 더 강화할 수 있다!
logoutUser 함수
이 함수는 액세스 토큰과 리프레시 토큰 모두 만료되었을 때 사용자를 로그아웃 시켜
다시 재로그인하여 새로운 액세스 토큰과 리프레시 토큰을 받아오게 하기 위함이다.
asyncStorage에 저장된 accessToken, refreshToken은 무조건 삭제한다.
id는 로그인 절차 및 페이지 라우팅에 따라 다르게 적용할 수 있는데 본 프로젝트에서는 id 또한 삭제한다.
/src/utils/common.ts
// Refresh Token으로 새 AccessToken 받아오기
export const refreshToken = async () => {
try {
// AsyncStorage에서 refreshToken 가져오기
const getRefreshToken = await getAsyncData<string>("refreshToken");
const getId = await getAsyncData<string>("id");
const getEmail = await getAsyncData<string>("email");
if (!getRefreshToken) {
reactotron.log!("Refresh token이 없습니다.");
}
reactotron.log!("Refresh token으로 발급받는 중..", getRefreshToken);
const sendBE = {
id: getId,
email: getEmail,
};
// 서버에 요청 보내서 새 AccessToken 받아옴
const response: AxiosResponse<CommonType.TrefreshToken> = await axios.post(
`${Config.API_URL}/member/token/refresh`,
sendBE,
{
headers: {
Authorization: `Bearer ${getRefreshToken}`,
},
},
);
reactotron.log!("refreshToken으로 accessToken 발급", response.data);
// 새 AccessToken 저장
setAsyncData("accessToken", response.data.accessToken);
const getAccessToken = await getAsyncData<string>("accessToken");
reactotron.log!("새 accessToken 저장 성공!", getAccessToken);
return getAccessToken;
} catch (error) {
reactotron.log!("리프레시 토큰 기간 만료", error); // Refresh 토큰 기간 만료
logoutUser(); // 사용자를 로그아웃 시킴
return Promise.reject(error);
}
};
// 현재 저장된 AccessToken 가져오기
export const getAccessToken = async (): Promise<string | null> => {
return getAsyncData<string>("accessToken");
};
// 사용자 로그아웃 처리
export const logoutUser = async () => {
try {
// 인증 정보 삭제
await removeAsyncData("accessToken");
await removeAsyncData("refreshToken");
await removeAsyncData("id");
queryClient.invalidateQueries(["isLoggedIn"]);
} catch (error) {
reactotron.log!(error);
}
};
asyncStorage 참고 링크
지금 해결하고자 하는 문제와 직접적인 연관은 없지만,
asyncStorage를 사용하여 토큰과 사용자 정보 일부를 저장하고 있기에 참고하면 좋을 것 같다.
아래 로그처럼 내 정보를 불러오는 api 통신 시 401에러가 뜨고
백엔드에서 반환하는 에러코드에 따라 Access Token 기간이 만료되어
Refresh Token으러 새로운 Access Token을 발급받고 저장하는데 성공한다.
교체된 Access Token으로 원래 요청인 내 정보를 불러오는 api 통신을 재시도한 결과가
200 성공으로 뜬다!

클라이언트와 서버 간 상호작용 중 발생할 수 있는 여러 문제 중 하나인
액세스 토큰 만료 문제를 해결하는 과정을 통해
Axios Interceptor의 중요성과 그 활용 방법, 그리고 토큰 기반 인증 시스템에서 발생할 수 있는 문제를
백엔드 팀원과 소통하며 그 해결책에 대해 알아보는 시간을 가질 수 있었다.