프로젝트에 바로 쓰는 JWT 로그인/토큰 관리 (Redux + Axios)

nagosu·2025년 9월 30일
0
post-thumbnail

서론

프로젝트에서 로그인/토큰 관리를 처음 붙일 때 가장 고민되는 부분은 토큰 관리 방식과 만료 처리 방식이다.

처음 API를 연동할 때는 환경 변수에 토큰을 하드코딩해두고 테스트했지만, 실제 서비스에서는 이렇게 할 수 없다.

그래서 이번 글에서는 Redux + Axios 인터셉터 기반으로 로그인과 토큰 관리를 깔끔하게 붙이는 과정을 정리해보려 한다.

코드는 내가 적용한 방식의 샘플 버전을 준비했다.


적용 과정

1. 패키지 설치

가장 먼저 필요한 패키지를 설치한다.

npm i @reduxjs/toolkit react-redux

리덕스 툴킷을 사용하면 보일러플레이트 코드가 줄어들고, 비동기 로직도 Thunk로 깔끔하게 처리할 수 있다.


2. 토큰/유저 유틸

토큰과 유저 정보를 브라우저 새로고침 후에도 유지하기 위해 로컬스토리지를 사용했다.

별도의 유틸 파일로 분리해두면 유지보수가 편하다.

다만 로컬스토리지는 간단하고 직관적이지만 단점도 있으므로, 서비스 특성에 맞게 저장 방식을 선택하는 것이 좋다.

// 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 직렬화이다.

토큰은 문자열 하나로 끝나지만, 유저 정보는 객체라 직렬화/역직렬화를 해줘야 한다.


3. Axios 클라이언트/인터셉터 설정

기존에 환경 변수로 하드코딩했던 토큰을 동적으로 불러올 수 있도록 수정했다.

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) 형태로 스토어를 주입해준다.


4. 로그인 Thunk + 슬라이스

로그인 로직은 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 알림으로 쓸 수 있다.


5. 스토어/인터셉터 세팅

이제 스토어를 만들고 바로 인터셉터에 스토어를 주입한다.

// 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>

6. 로그인 페이지 컴포넌트 설정

로그인 페이지 컴포넌트에서는 Redux의 dispatchuseSelector만 연결해주면 된다.

로그인에 성공하면 원하는 페이지로 리다이렉트, 실패하면 프로젝트 상황에 맞게 에러 처리를 하면 된다.

// 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>
  );
}

7. 보호 라우트 설정

로그인을 해야만 접근할 수 있는 페이지에 비로그인 상태로 접근하면, 로그인 페이지로 돌려보내야 한다.

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 />;
}

8. 토큰 만료 처리

토큰이 만료 되었을 때는 인터셉터에서 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 토큰 관리 과정을 정리했다.

이 구조를 사용하면 불필요하게 컴포넌트마다 토큰을 다룰 필요가 없고, 인증 과정을 단일화할 수 있다.

어느 프로젝트에서든, 토큰 관리 부분에서 이 정도 틀을 잡아두면 꽤 안정적으로 동작할 수 있다고 생각한다.

profile
프론트엔드 개발자..일걸요?

0개의 댓글