개발자
류준열
토큰을 이용한 인증
JWT 토큰은 유저의 정보를 암호화 한 데이터이다. 서버에 유저의 정보를 저장하지 않고 클라이언트에서 넘겨주는 토큰으로 유저를 판단한다. 그렇기 때문에 JWT 토큰을 탈취하면 비회원이 회원인 척 할 수 있다.
그래서 보통 토큰을 자주 갱신한다. 그런데 유효기간을 너무 짧게두어 토큰이 자주 만료되면 유저가 자주 강제 로그아웃이 되기 때문에 사용자 경험이 좋지 않다. 그렇다고 유효기간을 길게 두기엔 불안하다.
그래서 access 토큰과 refresh 토큰 두개를 이용한다.
우리 서비스는 access 토큰의 유효기간은 하루, refresh 토큰의 유효기간은 1주일이다.
access 토큰이 만료되었을때 refresh 토큰을 검증받고 유효한 사용자이면 새로운 access 토큰을 발급받아 인증을 진행한다.

access 토큰이 만료되어 401 에러를 받은 후 refresh api를 통해 새로운 access 토큰을 발급받은 상황이다.
인증 갱신을 처리한 코드를 설명하기 전에 인증 만료 전 코드부터 설명한다.
axios interceptor를 이용했다.
인증 만료 전
매 요청시 토큰을 확인해서 헤더에 넣는다.

export const axios = Axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL,
});
const REFRESH_API_URL = "/v1/user/refresh"
axios.interceptors.request.use((config) => {
if (!config.headers) return config;
let token: string | null = null;
if (config.url === REFRESH_API_URL) {
token = localStorage.getItem("refresh");
} else {
token = localStorage.getItem("access");
}
if (token !== null) {
config.headers["Authorization"] = `Bearer ${token}`;
}
return config;
});
인증 만료 후
크게 보면 다음과 같다.
- access 토큰이 만료되면 refresh 토큰을 요청한다.
- refresh 토큰이 만료되면 로그아웃시킨다.
이를 구현하기 위해 axios에서 error를 interceptor했다.
access token 만료 플로우

위 다이어그램의 5번은 아래 조건문인데 각 조건을 보면 다음과 같다.
config.url === REFRESH_API_URL: refresh 요청에서 에러가 났다면 refresh 토큰이 만료되었거나 혹은 서버가 터진 것이다.status !== 401: 에러코드가 200, 300, 500, 404 등 인증만료 코드가 아닌 곳에서의 에러인데 이는 각 상황에 맞는 에러처리를 해야 한다.config.sent===true: 무한루프 방지 플래그
// 5번 if (config.url === REFRESH_API_URL || status !== 401 || config.sent === true) { // 6번 return Promise.reject(error); }
조건에 속하지 않으면 그대로 에러를 반환하고,(6번)
config.sent를 true로 만들어 무한루프를 방지한다. (7번)
axios.interceptors.response.use(
(res) => res,
async (error: AuthAxiosError) => {
const config = error.config;
const status = error.response?.status;
if (config && status) {
// 위 다이어그램의 5번, config.sent는 무한루프 방지용
if (config.url === REFRESH_API_URL || status !== 401 || config.sent === true) {
return Promise.reject(error); // 위 다이어그램의 6번
}
config.sent = true; // 위 다이어그램의 7번, 무한루프 방지
const refresh = localStorage.getItem("refresh"); // 8번
const newAccess = await getNewAccessToken(refresh!); // 9번
...
access token 재발급

9번의 getNewAccessToken()이 실행되어 10 ~ 15 까지의 일들이 진행된다.
const getNewAccessToken = async (refresh: string): Promise<string | void> => {
// 10, 11
try {
const {
data: { access },
} = await userApi.userRefreshCreate({ refresh });
// 12
localStorage.setItem("access", access);
return access;
} catch (e) {
...
}
};
axios.interceptors.response.use(
(res) => res,
async (error: AuthAxiosError) => {
...
const newAccess = await getNewAccessToken(refresh!);
if (newAccess) {
// 13
config.headers.Authorization = `Bearer ${newAccess}`;
}
...
// 14
return axios(config);
}
},
);
10번에서는 유저가 갖고 있는 refresh토큰을 userRefreshCreate()에 넣어 요청을 보낸다.
11번에서는 refresh 토큰이 유효할 경우 새로운 access 토큰을 발급해주고
12,13번에서 새로운 access 토큰을 저장하는 모습이다.
그리고 14번에서 원래 요청을 재전송하는 모습이다.
이렇게 했을때 위에서 보여주었던 네트워크 현상이 나타난다.

refresh 토큰 만료
refresh 토큰이 만료되는 상황에서는 위 조건문에 걸려 에러를 반환하고 아래 getNewAccessToken의 catch문에 의해 로그아웃이 진행된다. (18,19,20)
(17번은 토큰만료로 인한 강제로그아웃시 직전 url 저장)

const getNewAccessToken = async (refresh: string): Promise<string | void> => { try { const { data: { access }, } = await userApi.userRefreshCreate({ refresh }); localStorage.setItem("access", access); return access; } catch (e) { // 리프레시 토큰 못받으면 로그아웃 -> 로그인 할 때 재발급 localStorage.setItem("redirect_url", window.location.href); toast.info("세션이 만료되었습니다. 다시 로그인해주세요."); // 18 localStorage.removeItem("access"); // 19 localStorage.removeItem("refresh"); // 19 window.location.href = `/sign-in`; // 20 } };
전체 코드, 다이어그램

export const axios = Axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL,
});
axios.interceptors.request.use((config) => {
if (!config.headers) return config;
let token: string | null = null;
if (config.url === REFRESH_API_URL) {
// refresh 요청시에는 토큰 없이 요청
token = null;
} else {
token = localStorage.getItem("access");
}
if (token !== null) {
config.headers["Authorization"] = `Bearer ${token}`;
}
return config;
});
axios.interceptors.response.use(
(res) => res,
async (error: AuthAxiosError) => {
const config = error.config;
const status = error.response?.status;
if (config && status) {
if (config.url === REFRESH_API_URL || status !== 401 || config.sent === true) {
return Promise.reject(error);
}
config.sent = true;
const refresh = localStorage.getItem("refresh");
const newAccess = await getNewAccessToken(refresh!);
if (newAccess) {
config.headers.Authorization = `Bearer ${newAccess}`;
}
return axios(config);
}
},
);
const getNewAccessToken = async (refresh: string): Promise<string | void> => {
try {
const {
data: { access },
} = await userApi.userRefreshCreate({ refresh });
localStorage.setItem("access", access);
return access;
} catch (e) {
// 리프레시 토큰 못받으면 로그아웃 -> 로그인 할 때 재발급
localStorage.setItem("redirect_url", window.location.href);
toast.info("세션이 만료되었습니다. 다시 로그인해주세요.");
localStorage.removeItem("access");
localStorage.removeItem("refresh");
window.location.href = `/sign-in`;
}
};