UMC 8th Web 워크북

💊리프레쉬 토큰

minnote29 2025. 5. 1. 17:56
리프레쉬가 왜 일어나는지가 엄청 중요하다. 엑세스 토큰은 정확히 어떤 역할을 하는지 간략하게 표현해보면, 엑세스 토큰 같은 경우 서버에서 정말 나 자신이다! 라고 증명을 할 때 쓰는 일련의 짧은 비밀번호라고 말을 할 수 있다. 이 토큰은 만료 시간을 따로 설정을 해주는 식으로 처리한다. 

 

  • 엑세스 토큰은 남들이 그 토큰의 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초가 지나면 새로운 엑세스 토큰을 요청하고 정상적으로 작동하면 로컬 스토리지에 저장하고 경로를 따라 내 정보로 다시 요청을 해서 받아오는 처리를 해준 것이다.