프로젝트에서 로그인/토큰 관리를 처음 붙일 때 가장 고민되는 부분은 토큰 관리 방식과 만료 처리 방식이다.
처음 API를 연동할 때는 환경 변수에 토큰을 하드코딩해두고 테스트했지만, 실제 서비스에서는 이렇게 할 수 없다.
그래서 이번 글에서는 Redux + Axios 인터셉터 기반으로 로그인과 토큰 관리를 깔끔하게 붙이는 과정을 정리해보려 한다.
코드는 내가 적용한 방식의 샘플 버전을 준비했다.
가장 먼저 필요한 패키지를 설치한다.
npm i @reduxjs/toolkit react-redux
리덕스 툴킷을 사용하면 보일러플레이트 코드가 줄어들고, 비동기 로직도 Thunk로 깔끔하게 처리할 수 있다.
토큰과 유저 정보를 브라우저 새로고침 후에도 유지하기 위해 로컬스토리지를 사용했다.
별도의 유틸 파일로 분리해두면 유지보수가 편하다.
다만 로컬스토리지는 간단하고 직관적이지만 단점도 있으므로, 서비스 특성에 맞게 저장 방식을 선택하는 것이 좋다.
// src/utils/tokenManager.js
export const TOKEN_KEY = 'auth_token';
export const USER_KEY = 'auth_user';
// 토큰 저장/조회/삭제
export const getToken = () => localStorage.getItem(TOKEN_KEY) || null;
export const setToken = (t) => localStorage.setItem(TOKEN_KEY, t);
export const clearToken = () => localStorage.removeItem(TOKEN_KEY);
// 유저 저장/조회/삭제
export const getUser = () => {
const raw = localStorage.getItem(USER_KEY);
return raw ? JSON.parse(raw) : null;
};
export const setUser = (u) => localStorage.setItem(USER_KEY, JSON.stringify(u));
export const clearUser = () => localStorage.removeItem(USER_KEY);
여기서 고려해야할 점은 JSON 직렬화이다.
토큰은 문자열 하나로 끝나지만, 유저 정보는 객체라 직렬화/역직렬화를 해줘야 한다.
기존에 환경 변수로 하드코딩했던 토큰을 동적으로 불러올 수 있도록 수정했다.
Axios 인스턴스를 생성하고, 요청 시 Authorization 헤더를 자동으로 붙이는 요청 인터셉터와 응답 시 토큰 만료 처리를 담당하는 응답 인터셉터를 설정한다.
// src/api/apiClient.js
import axios from 'axios';
import { getToken } from '../utils/tokenManager';
const BASE_URL = process.env.REACT_APP_BASE_URL;
const apiClient = axios.create({
baseURL: BASE_URL,
headers: { 'Content-Type': 'application/json' },
});
export const setupApiClient = (store) => {
// 요청 인터셉터
apiClient.interceptors.request.use((config) => {
const token = getToken();
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
// 응답 인터셉터
apiClient.interceptors.response.use(
(response) => {
if (response?.data?.result === false) {
const msg = response?.data?.err?.errorMessage;
if (
msg === 'Access Token required' ||
msg === 'Invalid token' ||
msg === 'jwt expired'
) {
store.dispatch({ type: 'auth/forceLogout', payload: msg });
}
return Promise.reject({
isApiError: true,
message: msg,
data: response.data,
});
}
return response;
},
(error) => {
const msg = error?.response?.data?.err?.errorMessage;
if (
msg === 'Access Token required' ||
msg === 'Invalid token' ||
msg === 'jwt expired'
) {
store.dispatch({ type: 'auth/forceLogout', payload: msg });
}
return Promise.reject(error);
}
);
};
export default apiClient;
여기서 가장 주의할 점은 apiClient 내부에서 store를 직접 import하지 않는 것이다.
apiClient 내부에서 직접 import 해버리면 순환 참조 문제가 생기기 때문에,
setupApiClient(store) 형태로 스토어를 주입해준다.
로그인 로직은 Thunk로 만들고, 상태 관리는 auth 슬라이스로 묶어준다.
// src/features/auth/authSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { loginUserApi } from '../../api/apiUser';
import {
setToken, clearToken, setUser, clearUser,
getToken, getUser
} from '../../utils/tokenManager';
export const loginUser = createAsyncThunk(
'auth/loginUser',
async ({ name, password }, { rejectWithValue }) => {
try {
const { user, token } = await loginUserApi(name, password);
setToken(token);
setUser(user);
return { user, token };
} catch (err) {
const msg = err?.response?.data?.err?.errorMessage || err.message || '로그인 실패';
return rejectWithValue(msg);
}
}
);
const initialState = {
user: getUser(),
token: getToken(),
status: 'idle',
error: null,
lastAuthError: null,
};
const authSlice = createSlice({
name: 'auth',
initialState,
reducers: {
logout(state) {
state.user = null;
state.token = null;
clearToken();
clearUser();
},
forceLogout(state, action) {
state.user = null;
state.token = null;
state.lastAuthError = action.payload || null;
clearToken();
clearUser();
},
},
extraReducers: (builder) => {
builder
.addCase(loginUser.pending, (state) => {
state.status = 'loading';
state.error = null;
state.lastAuthError = null;
})
.addCase(loginUser.fulfilled, (state, action) => {
state.status = 'succeeded';
state.user = action.payload.user;
state.token = action.payload.token;
})
.addCase(loginUser.rejected, (state, action) => {
state.status = 'failed';
state.error = action.payload || '로그인 실패';
});
},
});
export const { logout, forceLogout } = authSlice.actions;
export default authSlice.reducer;
여기서 lastAuthError는 인터셉터에서 강제 로그아웃 시에 메시지를 담아두는 용도로 사용했다.
나중에 UI 알림으로 쓸 수 있다.
이제 스토어를 만들고 바로 인터셉터에 스토어를 주입한다.
// src/store/index.js
import { configureStore } from '@reduxjs/toolkit';
import authReducer from '../features/auth/authSlice';
import { setupApiClient } from '../api/apiClient';
export const store = configureStore({
reducer: {
auth: authReducer,
},
});
setupApiClient(store);
export default store;
그리고 루트에서 Provider로 감싸준다.
// src/index.jsx
import { Provider } from 'react-redux';
import store from './store';
import App from './App';
<Provider store={store}>
<App />
</Provider>
로그인 페이지 컴포넌트에서는 Redux의 dispatch와 useSelector만 연결해주면 된다.
로그인에 성공하면 원하는 페이지로 리다이렉트, 실패하면 프로젝트 상황에 맞게 에러 처리를 하면 된다.
// src/components/Login/Login.jsx
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
import { loginUser } from '../../features/auth/authSlice';
const PROTECTED_ROUTE = '/example';
export default function Login() {
const [name, setName] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const navigate = useNavigate();
const dispatch = useDispatch();
const { status, error, token } = useSelector((s) => s.auth);
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
try {
// loginUser 는 createAsyncThunk
await dispatch(loginUser({ name, password })).unwrap();
navigate(PROTECTED_ROUTE);
} catch (err) {
// rejected 시 authSlice에서 넘겨준 메시지 사용
setError(err || '로그인에 실패했습니다.');
}
};
useEffect(() => {
if (token) {
navigate(PROTECTED_ROUTE);
}
}, [token]);
return (
<form onSubmit={handleSubmit}>
<button type="submit">로그인</button>
</form>
);
}
로그인을 해야만 접근할 수 있는 페이지에 비로그인 상태로 접근하면, 로그인 페이지로 돌려보내야 한다.
React Router v6 기준으로 <Outlet />과 <Navigate />를 활용하면 된다.
// src/routes/PrivateRoute.jsx
import { useSelector } from 'react-redux';
import { Navigate, Outlet } from 'react-router-dom';
export default function PrivateRoute() {
const token = useSelector((s) => s.auth.token);
return token ? <Outlet /> : <Navigate to="/login" replace />;
}
토큰이 만료 되었을 때는 인터셉터에서 forceLogout을 dispatch하고,
App 최상단에서 이를 감지해 알림과 함께 로그인 화면으로 보냈다.
// src/App.jsx
import { useEffect } from 'react';
import { useSelector } from 'react-redux';
import { useNavigate } from 'react-router-dom';
export default function App() {
const navigate = useNavigate();
const lastAuthError = useSelector((s) => s.auth.lastAuthError);
useEffect(() => {
if (lastAuthError) {
alert('세션이 만료되었거나 유효하지 않습니다. 다시 로그인해주세요.');
navigate('/login', { replace: true });
}
}, [lastAuthError, navigate]);
// ...
}
여기까지 Redux와 Axios 인터셉터 기반으로 JWT 토큰 관리 과정을 정리했다.
이 구조를 사용하면 불필요하게 컴포넌트마다 토큰을 다룰 필요가 없고, 인증 과정을 단일화할 수 있다.
어느 프로젝트에서든, 토큰 관리 부분에서 이 정도 틀을 잡아두면 꽤 안정적으로 동작할 수 있다고 생각한다.