리프레쉬가 왜 일어나는지가 엄청 중요하다. 엑세스 토큰은 정확히 어떤 역할을 하는지 간략하게 표현해보면, 엑세스 토큰 같은 경우 서버에서 정말 나 자신이다! 라고 증명을 할 때 쓰는 일련의 짧은 비밀번호라고 말을 할 수 있다. 이 토큰은 만료 시간을 따로 설정을 해주는 식으로 처리한다.
- 엑세스 토큰은 남들이 그 토큰의 value 값, 로컬 스토리지에 저장된 그 값만 알면 남인 척하고 쓸 수가 있다. -> 토큰이 탈취 당하면 누구나 이 사람인 척하고 쓸 수 있다. 다른 유저인데도 불구하고 가능하기 때문에 탈취가 되서는 안 된다.
- 그렇기 때문에 엑세스 토큰 같은 경우는 비교적 짧게 설정을 한다. 이렇게 악용되지 않게 하기 위해서 보통 쿠키 방식 등을 선호하긴 한다.
- 지금 여기서의 로그인 방식은 엑세스 토큰이랑 리프레쉬 토큰 방식이므로 엑세스 토큰 같은 경우는 짧아야 한다.
- 훔칠 수 있기 때문에. -> 짧은 만료 시간으로, 해커가 토큰을 훔치더라도 사용 시간(만료 시간)이 다 되면 제한되게 할 수 있기 때문이다.
- 만약 5분으로 설정하면, 5분이 지나면 사용자는 자동 로그아웃 될 것이다. 근데 다른 웹사이트들이 로그아웃이 바로 되는 경우는 발경하지 못했을 것이다.
- 리프레쉬 토큰 같은 경우는 엑세스 토큰을 재발급 하는데 쓴다.
- 엑세스 토큰이 만료되었을 때 리프레쉬 토큰을 서버로 넘기면 받는 응답값이 새로 엑세스 토큰과 리프레쉬 토큰을 내려준다.
- 이를 통해서 새로운 엑세스 토큰을 재발급하는 형식으로 사이트에 적용할 수 있을 것이다.
🍎 리프레쉬 토큰을 돌리는 로직 생성
- 인터셉터라는 것을 쓸 것이다.
- axios의 config 라는 타입이 있다. 이에 대한 인터페이스를 만들어준다.
- retry같은 경우는 요청 재시도 여부를 나타내는 플래그.
- 이 retry를 안 달아주면 401 에러가 떴을 경우 재발급을 해줘야 하는데, 리프레쉬 토큰을 한번 요청을 한다고 했을 때 retry를 안 걸어주면 401이 계속 반복될 것이다.
- 이것을 방지하기 위해서 요청의 재시도 여부를 나타내는 플래그를 세우고 true, false에 따라 결정해줄 수 있다.
interface CustomInternalAxiosRequestConfig extends InternalAxiosRequestConfig {
retry?:boolean;
}
- 전역 변수로 refresh 요청의 promise를 저장해서 중복 요청을 방지하는 방식도 쓸 수 있다.
- 바로 로그아웃 처리를 하려면 로컬 스토리지에 모든 것들을 다 빼줘야 한다.
- 리프레쉬 요청을 잘 가고 있는 건 확인할 수 있을 텐데 전역으로 변수를 처리해준다. 그럼 아마 정상적으로 만료 시간에 맞춰서 작동될 것이다.
// 전역 변수로 refresh 요청의 Promise를 저장해서 중복 요청을 방지한다.
let refreshPromise: Promise<string> | null = null;
- 뒤에 변화하는 것이 없으면 자동으로 const가 된다. 그 이전에 만약에 저장을 했으면 const로 바뀔 것이다. 이를 let으로 바꿔준 것!! -> 값이 정상적으로 나옴.
- 사용자들이 새로고침하면 페이지를 이동하든 로그인된 상태로 계속 보일 수 있는 것이다.
import axios, { InternalAxiosRequestConfig } from "axios";
import { LOCAL_STORAGE_KEY } from "../constants/key";
import { useLocalStorage } from "../hooks/useLocalStorage";
interface CustomInternalAxiosRequestConfig extends InternalAxiosRequestConfig {
_retry?:boolean; // 요청 재시도 여부를 나타내는 플래그
}
// 전역 변수로 refresh 요청의 Promise를 저장해서 중복 요청을 방지한다.
let refreshPromise: Promise<string> | null = null;
export const axiosInstance = axios.create({
baseURL: import.meta.env.VITE_SERVER_API_URL,
})
// 요청 인터셉터: 모든 요청 전에 Authorization 헤더에 추가한다.
axiosInstance.interceptors.request.use((config) => {
const { getItem } = useLocalStorage(LOCAL_STORAGE_KEY.accessToken);
const accessToken = getItem(); // localStorage에서 accessToken을 가져온다.
// accessToken이 존재하면 Authorization 헤더에 Bearer 토큰 형식으로 추가한다.
if(accessToken) {
config.headers = config.headers || {};
config.headers.Authorization = `Bearer ${accessToken}`;
}
// 수정된 요청 설정을 반환한다.
return config;
},
// 요청 인터셉터가 실패하면, 에러 뿜음.
(error) => Promise.reject(error),
);
// 응답 인터셉터: 401 에러가 발생 -> refresh 토큰 갱신을 처리한다.
axiosInstance.interceptors.response.use(
(response) => response, // 정상 응답 그대로 반환
async (error) => {
const originalRequest: CustomInternalAxiosRequestConfig = error.config;
// 401 에러면서, 아직 재시도 하지 않은 요청 경우 처리
if (error.response &&
error.response.staus === 401 &&
!originalRequest._retry
) {
// refresh 엔드포인트 401에러가 발생한 경우 (Unauthorized), 중복 재시도 방지를 위해 로그아웃 처리.
if (originalRequest.url === '/v1/auth/refresh') {
const { removeItem: removeAccessToken } = useLocalStorage(LOCAL_STORAGE_KEY.accessToken);
const { removeItem: removeRefreshToken } = useLocalStorage(LOCAL_STORAGE_KEY.refreshToken);
removeAccessToken();
removeRefreshToken();
window.location.href = '/login'; // 로그인 페이지로 리다이렉트
return Promise.reject(error);
}
// 재시도 플래그 설정
originalRequest._retry = true;
// 이미 리프레쉬 요청이 진행중이면, 그 Promise를 재사용한다.
if (!refreshPromise) {
refreshPromise = ( async() => {
const { getItem: getRefreshToken } = useLocalStorage(LOCAL_STORAGE_KEY.refreshToken);
const refreshToken = getRefreshToken(); // localStorage에서 refreshToken을 가져온다.
// 스웨거 참고
const {data} = await axiosInstance.post('/v1/auth/refresh', {
refresh: refreshToken,
});
// 새 토큰이 반환
const { setItem:setAccessToken } = useLocalStorage(LOCAL_STORAGE_KEY.accessToken);
const { setItem:setRefreshToken } = useLocalStorage(LOCAL_STORAGE_KEY.refreshToken);
setAccessToken(data.data.accessToken);
setRefreshToken(data.data.refreshToken);
// 새 AccessToken을 반환하여 다른 요청들이 이것을 사용할 수 있게 함.
return data.data.accessToken;
}) ()
.catch((error) => {
const { removeItem: removeAccessToken } = useLocalStorage(LOCAL_STORAGE_KEY.accessToken);
const { removeItem: removeRefreshToken } = useLocalStorage(LOCAL_STORAGE_KEY.refreshToken);
removeAccessToken();
removeRefreshToken();
})
.finally(() => {
refreshPromise = null;
});
}
//진행 중인 refreshPromise가 해결될 때까지 기다림.
return refreshPromise.then((accessToken) => {
// 원본 요청의 Authorization 헤더를 갱신된 토큰으로 업뎃.
originalRequest.headers['Authorization'] = `Bearer ${accessToken}`;
// 업데이트 된 원본 요청을 재시도한다.
return axiosInstance.request(originalRequest);
});
}
// 401 에러가 아닌 경우에 그대로 오류를 반환
return Promise.reject(error);
}
)
- 추가적으로, 에러 처리같은 경우 특정 상황에서는 서버에서 커스텀한 에러를 던져주는 경우가 있다. 이거에 대한 케이스는 따로 처리를 해줘야 하긴 하는데 여긴 커스텀된 것이 없기 때문에 401 에러 관련된 것만 처리해줬다.
error.response.errorCode === "AUTH_001"
- 이 과정이 여기서 한 모든 내용이라고 보면 된다. 여기서 AccessToken은 3600 즉, 60분 유효하도록 했는데 3초로 아주 짧게 설정을 해서 테스트를 했다.
- 그래서 3초가 지나면 새로운 엑세스 토큰을 요청하고 정상적으로 작동하면 로컬 스토리지에 저장하고 경로를 따라 내 정보로 다시 요청을 해서 받아오는 처리를 해준 것이다.
'UMC 8th Web 워크북' 카테고리의 다른 글
⛏️Tanstack-Query 활용한 상태 관리 (2) | 2025.05.06 |
---|---|
🎙️Social Login (0) | 2025.05.02 |
🗺️ProtectedRoute (UMC 5주차 강의 참고.) (0) | 2025.05.01 |
🍎 회원가입 관리 (0) | 2025.04.13 |
🍎 로그인 구현해보기 (umc 4주차) (0) | 2025.04.13 |