1. 서론: 왜 토큰 인증이 문제였을까?
최근 프로젝트에서 JWT 토큰을 이용한 인증 시스템을 구현하는 과정에서 예상치 못한 문제를 마주하게 되었습니다.
로그인 후 localStorage에 accessToken과 refreshToken이 저장되는 것은 확인되었지만,
이후 API 요청을 보낼 때 accessToken이 null로 나오는 이상한 현상이 발생했습니다.
이 글에서는 API 인증 과정에서 발생할 수 있는 토큰 문제를 어떤 방식으로 해결했는지 상세히 기록해두고자 합니다.
JWT 인증을 구현하는 과정에서 accessToken과 refreshToken을 헷갈리지 않도록 설정하는 방법을 중심으로 설명하겠습니다.
서버에는 잘 토큰 두개가 정상적으로 저장되는가?
서버는 명세서대로 정확히 전달되었습니다.
즉, 사용자가 로그인을 성공하면 서버에서 accessToken과 refreshToken을 정상적으로 반환합니다.
로컬스토로지를 활용하여 저장하는 방식
• localStorage.setItem('accessToken', accessToken);을 통해 localStorage에 저장하는 코드가 정상적으로 실행됨.
문제 상황: 로그인 후 accessToken이 저장되지 않는 현상
🔹 증상
1. 사용자가 로그인하면 서버에서 accessToken과 refreshToken을 정상적으로 반환함.
2. localStorage.setItem('accessToken', accessToken);을 통해 localStorage에 저장하는 코드가 정상적으로 실행됨.
3. 하지만 API 요청을 보낼 때 accessToken 값이 null로 나오는 현상이 발생.
4. refreshToken은 정상적으로 저장되고 API 요청 시 포함됨.
🔹 API 요청 보냄: /budget
🔑 ACCESS-AUTH-KEY: null
🔄 REFRESH-AUTH-KEY: eyJhbGciOiJIUzI1NiJ9...
✅ refreshToken은 정상적으로 들어가지만, accessToken은 null이어서 인증 요청이 실패!
3. 원인 분석: 왜 accessToken이 null이 되는가?
🔹 원인 1: localStorage.setItem()이 정상적으로 실행되지 않았을 가능성
우선 LoginPage.jsx (로그인 화면 컴포넌트) 에서 accessToken이 localStorage에 정상적으로 저장되는지 확인해야 했습니다.
로그인 시 작동되는 로직은 일차적으로 여기서 검토하게끔 코드를 짰다.
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
console.log('📌 localStorage 저장 확인 - accessToken:', localStorage.getItem('accessToken'));
console.log('📌 localStorage 저장 확인 - refreshToken:', localStorage.getItem('refreshToken'));
🚀 결과:
console.log()를 실행해보니 accessToken이 정상적으로 localStorage에 저장되는 것을 확인!
➡️ 즉, accessToken이 저장되지 않은 게 아니라, 이후 API 요청에서 불러오지 못하는 것이 문제였다!
해당 API 요청은 apiClient.jsx( axios 통신 기본설정 컴포넌트)를 활용하게끔 코드를 짰었다.
즉, 한마디로 말하면 apiClient.Jsx에서 최신 accessToken을 가져오지 못함 (accessToken 최신화 불가)
API 요청을 보낼 때 accessToken을 가져오는 코드를 확인해보니, 최초 실행 시점에서 localStorage 값을 가져오고 이후 업데이트되지 않는 문제가 있었습니다.
🔹 일반적인 axios 활용 방식
• axios.create()에서는 baseURL과 Content-Type 같은 변경되지 않는 기본 설정만 적용.
• accessToken은 매 요청마다 업데이트되므로, interceptors.request에서 추가하는 것이 가장 안전함.
그러나 전 일반적인 방식이 아니라 axios 통신 객체 생성 시점에 모든 헤더에 같이 보내는 부분도 추가했습니다. (문제발생 포인트)
그 포인트가 아래 두번째 원인을 야기했습니다.
🔹 원인 2: apiClient.jsx에서 최신 accessToken을 가져오지 못함
// ❌ 예전 코드 (문제 발생)
const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_URL,
headers: {
'Content-Type': 'application/json',
'ACCESS-AUTH-KEY': `BEARER ${localStorage.getItem('accessToken')}`, // ❌ 최초 실행 시점의 토큰만 사용됨
'REFRESH-AUTH-KEY': `BEARER ${localStorage.getItem('refreshToken')}`,
},
});
if -> axios.create() 시점에서 localStorage 값이 null이거나 undefined인 경우
• axios.create()가 처음 실행될 때, localStorage.getItem('accessToken')이 **비어있는 상태(null)**라면,
• 이후 로그인 후 토큰이 localStorage에 저장되더라도, axios.create()에서는 이전 값(null)을 계속 사용함.
로그인을 하면 새로운 토큰값을 서버로 받아와 그걸 써야하는데 axios.create() 시점에서 로컬에 값이 없으니 이전값 null을 쓰니까 당연히 401 unauthorized 에러가 지속적으로 떴던겁니다!
💡 해결 방법: axios.create()에서는 헤더를 빈 값으로 두고, interceptors.request에서 동적으로 추가
즉. axios.create()를 새로 실행하는 대신, 매 요청마다 accessToken을 업데이트하도록 interceptors를 활용
const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_URL,
headers: {
'Content-Type': 'application/json', // ✅ 기본 헤더만 설정
},
});
// ✅ 요청마다 최신 토큰을 가져오도록 설정
apiClient.interceptors.request.use(
(config) => {
const accessToken = localStorage.getItem('accessToken');
if (accessToken) {
config.headers['ACCESS-AUTH-KEY'] = `BEARER ${accessToken}`;
}
return config;
},
(error) => Promise.reject(error)
);
개선된 apiClient.jsx의 종합적인 흐름으로는
1. 환경변수 활용 -> vite 환경이며, 백엔드 서버는 배포된 도메인 서버이기에 baseURL설치를 환경변수파일 주입.
• import.meta.env.VITE_API_URL을 사용하여 서버 도메인 URL을 동적으로 설정한 점은 Vite 환경에서 좋은 방식입니다.
참고로 env 파일은 보안사항이 들어있을수 있어 gitignore이 되어있습니다.
따라서, 배포된 web 환경에 따라 env내부의 key : value값을 주입시켜야 배포 환경끼리의통신이 원할합니다.
2. 요청 인터셉터: ( 토큰을 로컬스토리지에서 가져와 요청 전에 헤더에 추가합니다)
-> 백엔드 개발자의 요청에 따라 헤더에 BEARER ${accesstoken,refreshtoken}을 함께 보냅니다.
apiClient에 이 부분입니다.
-> 기본적으로는 json 형태의 content type이지만, 파일을 보내야할 상황도 존재하여 form-data일떄의 경우도 고려해 이 부분 추가합니다.
if (accessToken) {
config.headers['ACCESS-AUTH-KEY'] = `BEARER ${accessToken}`;
}
if (refreshToken) {
config.headers['REFRESH-AUTH-KEY'] = `BEARER ${refreshToken}`;
}
if (config.data instanceof FormData) {
config.headers['Content-Type'] = 'multipart/form-data';
}
• 요청이 발생할 때마다 localStorage에서 accessToken과 refreshToken을 가져와 헤더에 추가해주고 있습니다.
• 만약 요청 데이터가 FormData 인스턴스라면, Content-Type을 'multipart/form-data'로 재설정하는 로직도 있어, 파일 업로드 등에서 유용할 것입니다.
• 디버깅을 위한 콘솔 로그가 포함되어 있어 요청 시 어떤 토큰이 사용되고 있는지 쉽게 확인할 수 있습니다.
3. 응답 인터셉터: (401 에러 캣칭)
• 서버로부터 401(인증 실패) 응답을 받으면, localStorage의 토큰들을 제거하고 /login 페이지로 리다이렉트하여 보안상 안전하게 로그아웃 처리를 해주고 있습니다.
• 즉각적인 로그아웃 처리는 보안 측면에서는 좋으나, 만약 토큰 갱신(refresh token) 로직을 구현할 계획이라면 이 부분을 확장할 수 있을 것 같다는 생각은 듭니다) -> 사용자 경험 증진의 가능성

'FrontEnd Develop > Project : Wallet Guardians' 카테고리의 다른 글
본격 연동 #6. 비동기 데이터 로딩 시 UI 깜빡임 방지 (await & setTimeout 활용)으로 사용자 경험 증대 (0) | 2025.02.11 |
---|---|
본격 연동 #5. 비동기 처리의 이해로 로그인 후 리다이렉션 처리 (0) | 2025.02.10 |
본격 연동 #4 로그인 후 기존 컨텍스트(데이터)가 있으면 메인 페이지로 이동하는 방법 (React + Context API + Axios) (0) | 2025.02.10 |
본격 연동 #3. 프론트에서의 라우팅을 활용한 보안, 인증 처리 (0) | 2025.02.09 |
본격 연동 #2. 보안 강화된 인증 및 접근 제어 설계 과정 (0) | 2025.02.08 |