
로그인 상태를 유지하기 위해 API 매 요청마다 항상 토큰을 헤더에 실어보내면서 사용자 인증을 진행해야 한다. 그에 따라 기존에 작성했던 로직은 다음과 같다.
내가 채택한 로그인 인증 방식
import { API } from './api';
import useAuthStore from '@store/authStore';
import { toast } from 'react-toastify';
const makeAuthorizedRequest = async (url, method = 'get', config) => {
try {
let response;
switch (method) {
case 'get':
response = await API.get(url);
break;
case 'post':
response = await API.post(url, config);
break;
case 'put':
response = await API.put(url, config);
break;
case 'delete':
response = await API.delete(url, { data: config });
break;
case 'patch':
response = await API.patch(url);
break;
default:
throw new Error('Invalid HTTP method');
}
return response;
} catch (error) {
if (error.response && error.response.status === 403) {
try {
const refreshResponse = await API.get('/api/v1/member/refresh');
setAccessToken(refreshResponse.data.access_token);
const accessToken = useAuthStore.getState().accessToken;
API.defaults.headers.common['Authorization'] = `Bearer ${accessToken}`;
return await makeAuthorizedRequest(url, method, config);
} catch (error) {
toast.error('로그인이 필요한 서비스입니다!');
setTimeout(() => {
window.location.href = 'http://127.0.0.1:3000/login';
}, 3000);
}
} else {
if (error.response) {
const errorMessage = error.response.data?.errorMessages?.errorMessage;
if (errorMessage) {
toast.error(errorMessage);
} else {
toast.error('알 수 없는 오류가 발생했습니다.');
}
} else {
toast.error('네트워크 오류가 발생했습니다.');
}
return error.response.data.errorMessages.errorMessage;
}
// throw error;
}
};
const setAccessToken = (token) => {
useAuthStore.getState().setAccessToken(token);
};
export { makeAuthorizedRequest };
makeAuthorizedRequest 파일의 기능은 다음과 같다.
이 함수를 만들 당시에는 axios interceptor라는 것을 들어보기만 했지, 제대로 알지 못했다. 하지만 최근에 회사 실무에서 interceptor를 접하면서 이 프로젝트에도 interceptor로 코드를 수정하기로 결정했다.
Promise 기반의 HTTP 요청을 처리하는 클라이언트 단에서 사용하는 라이브러리인 axios의 기능 중 하나인 interceptor는 API 요청이나 응답을 가로채고, 추가 작업을 수행할 수 있도록 도와주는 기능이다. 요청을 보내기 전이나 응답을 받은 뒤에, 필요한 로직을 추가하여 코드 중복을 줄이고, 전역적으로 설정을 관리하므로 유지보수 측면에서 매우 용이하다.
아래 코드는 기존의 코드를 interceptor를 이용하여 수정한 코드이다.
import axios from 'axios';
import { toast } from 'react-toastify';
import useAuthStore from '@store/authStore';
let isToastVisible = false; // 전역 플래그 변수
// 토스트 메시지 표시 함수
const showToast = (message) => {
if (!isToastVisible) {
isToastVisible = true;
toast.error(message);
setTimeout(() => {
isToastVisible = false;
}, 3000);
}
};
export const API = axios.create({
baseURL: 'http://127.0.0.1:8080',
timeout: 30000,
withCredentials: true
});
// 요청 인터셉터
API.interceptors.request.use(
(config) => {
const accessToken = useAuthStore.getState().accessToken;
if (accessToken) {
config.headers['Authorization'] = `Bearer ${accessToken}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// 응답 인터셉터
API.interceptors.response.use(
(response) => {
return response;
},
async (error) => {
const originalRequest = error.config;
// 재전송 방지 플래그
if (error.response && error.response.status === 403) {
if (!originalRequest._retry) {
originalRequest._retry = true; // 재전송 플래그 설정
try {
const refreshResponse = await axios.get('http://127.0.0.1:8080/api/v1/member/refresh');
if (refreshResponse.status === 200) {
setAccessToken(refreshResponse.data.access_token);
API.defaults.headers.common['Authorization'] = `Bearer ${refreshResponse.data.access_token}`;
return API(originalRequest); // 원래 요청 재전송
}
} catch (refreshError) {
showToast('로그인이 필요한 서비스입니다!');
setTimeout(() => {
window.location.href = 'http://127.0.0.1:3000/login';
}, 3000);
}
}
}
// 일반 에러 처리
if (error.response) {
const errorMessage = error.response.data?.errorMessages?.errorMessage;
if (errorMessage) {
showToast(errorMessage);
} else {
showToast('알 수 없는 오류가 발생했습니다.');
}
} else if (error.request) {
// 요청은 했지만 응답이 없는 경우
showToast('서버에서 응답이 없습니다. 네트워크를 확인하세요.');
} else {
// 기타 에러
showToast(`오류 발생: ${error.message}`);
}
return Promise.reject(error);
}
);
// Access Token 설정 함수
const setAccessToken = (token) => {
useAuthStore.getState().setAccessToken(token);
};
API.interceptors.request.use(...)이 부분은 Axios의 요청 인터셉터를 설정하는 코드이다. API는 Axios 인스턴스를 의미하며, interceptors.request는 요청이 서버로 전송되기 전에 가로채는 기능을 제공한다.
use 메서드는 두 개의 콜백 함수를 인자로 받는다. 첫 번째는 요청을 처리하는 함수, 두 번째는 요청 에러를 처리하는 함수다.
첫 번째 콜백 함수 (config) => { ... }이 함수는 요청이 서버로 전송되기 전에 호출된다. config 객체는 요청에 대한 모든 설정을 포함하고 있다.
이 함수 안에서 전역 상태로 저장되어있는 accessToken을 가져온다.
만약 토큰이 있다면, 요청 헤더에 Authorization 필드를 추가한다. Bearer는 토큰 인증 방식에서 일반적으로 사용하는 접두사로, accessToken과 함께 사용된다.
이렇게 하면, 서버는 요청을 받을 때 이 토큰을 확인하여 사용자를 인증할 수 있다. 마지막으로 수정된 config를 반환하며 axios가 서버로 요청을 보낼 때 수정된 config가 적용되어 요청을 진행한다.
두 번째 콜백 함수 (error) => { ... }이 함수는 요청 중 발생한 에러를 처리한다. 요청이 실패하면 이 함수가 호출된다.
return Promise.reject(error); 코드는 에러를 다시 발생시켜, 호출한 곳에서 이 에러를 처리할 수 있도록 합니다.
API.interceptors.response.use(...)이 부분은 Axios의 응답 인터셉터를 설정하는 코드다. 서버로부터 응답을 받은 후 이를 가로채는 기능을 제공한다.
use 메서드는 두 개의 콜백 함수를 인자로 받는다. 첫 번째는 정상적인 응답을 처리하는 함수, 두 번째는 에러를 처리하는 함수다.
첫 번째 콜백 함수 (response) => { ... }이 함수는 서버로부터의 응답이 성공적일 때 호출되며 응답을 그대로 반환한다.
이 부분은 추가적인 처리가 필요 없을 때 간단하게 응답을 반환하는 역할을 한다.
두 번째 콜백 함수 (error) => { ... }이 함수는 요청 중 에러가 발생했을 때 호출된다. 에러 객체를 인자로 받아서 처리한다.
const originalRequest = error.config; 에러가 발생한 원래 요청의 설정을 가져온다.이 정보를 사용하여 요청을 재전송할 수 있다.
if (error.response && error.response.status === 403) { ... }const refreshResponse = await axios.get('http://127.0.0.1:8080/api/v1/member/refresh');
특히, 토큰을 갱신하는 API 요청 부분에서 기존에 사용하던 axios 인스턴스(내 코드에서는 API 인스턴스)를 사용해서 요청하게 되면 요청 인터셉터에서 갱신되지 않은 토큰을 헤더에 실어 요청을 보내기 때문에 계속 403에러가 반환될 수 있다. (이 부분에서 에러 무한루프에 걸리면서 애를 좀 먹었다...ㅎ) 따라서 나같은 경우는 인터셉터가 적용된 인스턴스가 아니라, 새로 axios 인스턴스를 이용하여 요청을 진행했다.


영상을 통해 로그인이 되어 있지 않으면 로그인 페이지로 이동시키고, 로그인 완료 후에 프로필 조회 요청이 제대로 되고 있는 것을 확인할 수 있다.