React에서 JWT Access/Refresh Token 인증 시스템 구현하기
발행일: 2025년 12월 2일 오후 05:05
JWT Access/Refresh Token 인증 시스템을 React와 Redux Toolkit으로 구현하는 방법을 상세한 코드 예시와 함께 알아봅니다.
들어가며
웹 애플리케이션에서 사용자 인증은 가장 중요한 기능 중 하나입니다. 이번 글에서는 **JWT(JSON Web Token)**를 활용한 Access/Refresh Token 인증 시스템을 React와 Redux Toolkit으로 구현하는 방법을 상세히 알아보겠습니다.
JWT란?
JWT(JSON Web Token)는 당사자 간 정보를 JSON 객체로 안전하게 전송하기 위한 표준입니다. 토큰은 세 부분으로 구성됩니다:
- Header: 토큰 타입과 해싱 알고리즘 정보
- Payload: 사용자 정보와 클레임(claims)
- Signature: 토큰의 무결성을 검증하는 서명
1eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9. // Header 2eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4ifQ. // Payload 3SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c // Signature
Access Token vs Refresh Token
Access Token
| 항목 | 설명 |
|---|---|
| 용도 | API 요청 시 사용자 인증 |
| 유효기간 | 짧음 (15분 ~ 1시간) |
| 저장 위치 | 메모리 또는 localStorage |
Refresh Token
| 항목 | 설명 |
|---|---|
| 용도 | Access Token 재발급 |
| 유효기간 | 김 (7일 ~ 30일) |
| 저장 위치 | HttpOnly Cookie (권장) |
왜 두 개의 토큰을 사용할까요?
Access Token만 사용하면 탈취 시 피해가 큽니다. 짧은 유효기간의 Access Token과 긴 유효기간의 Refresh Token을 조합하면 보안성과 사용자 경험을 모두 잡을 수 있습니다.
구현하기
1. 타입 정의
1// types/auth.ts 2 3interface User { 4 id: string; 5 email: string; 6 name: string; 7} 8 9interface AuthState { 10 user: User | null; 11 accessToken: string | null; 12 refreshToken: string | null; 13 isAuthenticated: boolean; 14 isLoading: boolean; 15} 16 17interface LoginCredentials { 18 email: string; 19 password: string; 20} 21 22interface TokenResponse { 23 accessToken: string; 24 refreshToken: string; 25 user: User; 26}
2. Auth Slice 구현 (Redux Toolkit)
1// store/authSlice.ts 2 3import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; 4 5const initialState: AuthState = { 6 user: null, 7 accessToken: null, 8 refreshToken: null, 9 isAuthenticated: false, 10 isLoading: false, 11}; 12 13// 로그인 Thunk 14export const login = createAsyncThunk( 15 'auth/login', 16 async (credentials: LoginCredentials, { rejectWithValue }) => { 17 try { 18 const response = await fetch('/api/auth/login', { 19 method: 'POST', 20 headers: { 'Content-Type': 'application/json' }, 21 body: JSON.stringify(credentials), 22 }); 23 24 if (!response.ok) { 25 throw new Error('로그인 실패'); 26 } 27 28 const data: TokenResponse = await response.json(); 29 30 // 토큰 저장 31 localStorage.setItem('accessToken', data.accessToken); 32 localStorage.setItem('refreshToken', data.refreshToken); 33 34 return data; 35 } catch (error) { 36 return rejectWithValue(error.message); 37 } 38 } 39); 40 41// 토큰 갱신 Thunk 42export const refreshAccessToken = createAsyncThunk( 43 'auth/refresh', 44 async (_, { rejectWithValue }) => { 45 try { 46 const refreshToken = localStorage.getItem('refreshToken'); 47 48 if (!refreshToken) { 49 throw new Error('Refresh Token 없음'); 50 } 51 52 const response = await fetch('/api/auth/refresh', { 53 method: 'POST', 54 headers: { 'Content-Type': 'application/json' }, 55 body: JSON.stringify({ refreshToken }), 56 }); 57 58 if (!response.ok) { 59 throw new Error('토큰 갱신 실패'); 60 } 61 62 const data = await response.json(); 63 localStorage.setItem('accessToken', data.accessToken); 64 65 return data; 66 } catch (error) { 67 localStorage.removeItem('accessToken'); 68 localStorage.removeItem('refreshToken'); 69 return rejectWithValue(error.message); 70 } 71 } 72); 73 74const authSlice = createSlice({ 75 name: 'auth', 76 initialState, 77 reducers: { 78 logout: (state) => { 79 state.user = null; 80 state.accessToken = null; 81 state.refreshToken = null; 82 state.isAuthenticated = false; 83 localStorage.removeItem('accessToken'); 84 localStorage.removeItem('refreshToken'); 85 }, 86 restoreSession: (state) => { 87 const accessToken = localStorage.getItem('accessToken'); 88 const refreshToken = localStorage.getItem('refreshToken'); 89 90 if (accessToken && refreshToken) { 91 state.accessToken = accessToken; 92 state.refreshToken = refreshToken; 93 state.isAuthenticated = true; 94 } 95 }, 96 }, 97 extraReducers: (builder) => { 98 builder 99 .addCase(login.pending, (state) => { 100 state.isLoading = true; 101 }) 102 .addCase(login.fulfilled, (state, action) => { 103 state.isLoading = false; 104 state.user = action.payload.user; 105 state.accessToken = action.payload.accessToken; 106 state.refreshToken = action.payload.refreshToken; 107 state.isAuthenticated = true; 108 }) 109 .addCase(login.rejected, (state) => { 110 state.isLoading = false; 111 }) 112 .addCase(refreshAccessToken.fulfilled, (state, action) => { 113 state.accessToken = action.payload.accessToken; 114 }) 115 .addCase(refreshAccessToken.rejected, (state) => { 116 state.user = null; 117 state.accessToken = null; 118 state.refreshToken = null; 119 state.isAuthenticated = false; 120 }); 121 }, 122}); 123 124export const { logout, restoreSession } = authSlice.actions; 125export default authSlice.reducer;
3. Axios Interceptor로 토큰 자동 갱신
가장 중요한 부분입니다. 401 에러 발생 시 자동으로 토큰을 갱신하고 원래 요청을 재시도합니다.
1// lib/axiosInstance.ts 2 3import axios from 'axios'; 4import { store } from '@/store'; 5import { refreshAccessToken, logout } from '@/store/authSlice'; 6 7const axiosInstance = axios.create({ 8 baseURL: '/api', 9}); 10 11// Request Interceptor - 모든 요청에 Access Token 추가 12axiosInstance.interceptors.request.use( 13 (config) => { 14 const token = localStorage.getItem('accessToken'); 15 if (token) { 16 config.headers.Authorization = `Bearer ${token}`; 17 } 18 return config; 19 }, 20 (error) => Promise.reject(error) 21); 22 23// Response Interceptor - 401 에러 시 토큰 갱신 24axiosInstance.interceptors.response.use( 25 (response) => response, 26 async (error) => { 27 const originalRequest = error.config; 28 29 // 401 에러이고, 재시도하지 않은 요청인 경우 30 if (error.response?.status === 401 && !originalRequest._retry) { 31 originalRequest._retry = true; 32 33 try { 34 const result = await store.dispatch(refreshAccessToken()); 35 36 if (refreshAccessToken.fulfilled.match(result)) { 37 // 새 토큰으로 원래 요청 재시도 38 const newToken = result.payload.accessToken; 39 originalRequest.headers.Authorization = `Bearer ${newToken}`; 40 return axiosInstance(originalRequest); 41 } 42 } catch (refreshError) { 43 store.dispatch(logout()); 44 window.location.href = '/login'; 45 } 46 } 47 48 return Promise.reject(error); 49 } 50); 51 52export default axiosInstance;
4. 커스텀 훅으로 인증 상태 관리
1// hooks/useAuth.ts 2 3import { useSelector, useDispatch } from 'react-redux'; 4import { RootState } from '@/store'; 5import { login, logout } from '@/store/authSlice'; 6 7export const useAuth = () => { 8 const dispatch = useDispatch(); 9 const { user, isAuthenticated, isLoading } = useSelector( 10 (state: RootState) => state.auth 11 ); 12 13 const handleLogin = async (email: string, password: string) => { 14 return dispatch(login({ email, password })); 15 }; 16 17 const handleLogout = () => { 18 dispatch(logout()); 19 }; 20 21 return { 22 user, 23 isAuthenticated, 24 isLoading, 25 login: handleLogin, 26 logout: handleLogout, 27 }; 28};
인증 플로우 다이어그램
1┌──────────┐ 로그인 요청 ┌──────────┐ 2│ Client │ ─────────────────────▶ │ Server │ 3└──────────┘ └──────────┘ 4 │ │ 5 │ Access Token + Refresh Token │ 6 │ ◀──────────────────────────────── │ 7 │ │ 8 │ API 요청 + Access Token │ 9 │ ─────────────────────────────────▶│ 10 │ │ 11 │ 401 (Token 만료) │ 12 │ ◀──────────────────────────────── │ 13 │ │ 14 │ Refresh Token으로 갱신 요청 │ 15 │ ─────────────────────────────────▶│ 16 │ │ 17 │ 새 Access Token │ 18 │ ◀──────────────────────────────── │ 19 │ │ 20 │ 원래 요청 재시도 │ 21 │ ─────────────────────────────────▶│
보안 고려사항
1. XSS 방어
1// 사용자 입력값은 항상 sanitize 2const sanitizedInput = DOMPurify.sanitize(userInput);
2. Refresh Token은 HttpOnly Cookie에 저장
1// 서버에서 쿠키 설정 (Node.js 예시) 2res.cookie('refreshToken', token, { 3 httpOnly: true, // JavaScript 접근 불가 4 secure: true, // HTTPS에서만 전송 5 sameSite: 'strict', 6 maxAge: 7 * 24 * 60 * 60 * 1000 // 7일 7});
3. 토큰 만료 시간 권장값
| 토큰 | 권장 시간 | 이유 |
|---|---|---|
| Access Token | 15분 ~ 1시간 | 짧을수록 탈취 피해 최소화 |
| Refresh Token | 7일 ~ 30일 | UX와 보안의 균형 |
마치며
JWT 기반 Access/Refresh Token 인증 시스템은 stateless하고 확장성이 좋습니다. 핵심은:
- Access Token은 짧게, Refresh Token은 길게
- Axios Interceptor로 토큰 자동 갱신
- HttpOnly Cookie로 Refresh Token 보호
실제 프로덕션에서는 토큰 블랙리스트, Rate Limiting, HTTPS 적용 등 추가 보안 조치도 고려해야 합니다.
이 글이 도움이 되셨다면 좋아요를 눌러주세요!
태그
#React#TypeScript#JWT#Authentication#Redux Toolkit
2개의 댓글
26회 조회