안녕하세요! JWT 인증 시리즈의 세 번째 글입니다. [1편](https://mori29.tistory.com/23)에서는 JWT의 기본 개념과 구조에 대해 알아보았고, [2편](https://mori29.tistory.com/24)에서는 Node.js와 Express를 사용한 서버 측 JWT 인증 구현을 살펴보았습니다. 이번 글에서는 React와 Next.js를 사용하여 클라이언트 측에서 JWT를 효과적으로 관리하고 활용하는 방법에 대해 알아보겠습니다.
목차
- 프론트엔드 인증의 중요성
- JWT 저장 전략 비교
- React에서 JWT 인증 구현하기
- Context API를 활용한 인증 상태 관리
- Axios 인터셉터를 활용한 토큰 자동 갱신
- Next.js에서의 JWT 인증 구현
- 인증된 라우트 보호하기
- JWT 인증 디버깅 및 문제 해결
- 모범 사례 및 팁
- 마무리
프론트엔드 인증의 중요성
프론트엔드에서의 인증 관리는 사용자 경험과 애플리케이션 보안에 직접적인 영향을 미칩니다. 잘 구현된 인증 시스템은 다음과 같은 이점을 제공합니다:
- 원활한 사용자 경험: 사용자가 로그인 상태를 유지하면서 애플리케이션을 자유롭게 탐색할 수 있습니다.
- 보안 강화: 권한이 없는 사용자가 보호된 리소스에 접근하는 것을 방지합니다.
- 성능 최적화: 불필요한 API 호출을 줄이고 효율적인 토큰 관리를 통해 성능을 향상시킵니다.
- 오프라인 지원: 토큰을 적절히 저장하여 일시적인 네트워크 중단 시에도 기능을 유지할 수 있습니다.
JWT 저장 전략 비교
프론트엔드에서 JWT를 저장하는 여러 방법이 있으며, 각각 장단점이 있습니다. 가장 일반적인 세 가지 방법을 비교해 보겠습니다:
1. 로컬 스토리지 (localStorage)
// 저장
const saveToken = (token: string) => {
localStorage.setItem('accessToken', token);
};
// 가져오기
const getToken = (): string | null => {
return localStorage.getItem('accessToken');
};
// 삭제 (로그아웃)
const removeToken = () => {
localStorage.removeItem('accessToken');
};
장점:
- 구현이 간단하고 직관적입니다.
- 브라우저를 닫았다 열어도 토큰이 유지됩니다.
- JavaScript에서 쉽게 접근할 수 있습니다.
단점:
- XSS(Cross-Site Scripting) 공격에 취약합니다. 악성 스크립트가 실행되면 토큰에 접근할 수 있습니다.
- 토큰 크기가 클 경우 localStorage의 용량 제한에 도달할 수 있습니다(대략 5MB).
2. 쿠키 (Cookies)
// 저장 (클라이언트 측)
const saveTokenToCookie = (token: string) => {
document.cookie = `accessToken=${token}; path=/; max-age=${60 * 60 * 24 * 7}`; // 7일
};
// 가져오기
const getTokenFromCookie = (): string | null => {
const cookies = document.cookie.split(';');
const tokenCookie = cookies.find(cookie => cookie.trim().startsWith('accessToken='));
return tokenCookie ? tokenCookie.split('=')[1] : null;
};
// 삭제
const removeTokenFromCookie = () => {
document.cookie = 'accessToken=; path=/; max-age=0';
};
장점:
- HttpOnly 플래그를 설정하면 JavaScript에서 쿠키에 접근할 수 없어 XSS 공격에 대한 보안이 향상됩니다.
- Secure 플래그를 설정하면 HTTPS 연결에서만 쿠키가 전송됩니다.
- SameSite 속성을 설정하여 CSRF 공격을 방지할 수 있습니다.
단점:
- HttpOnly 쿠키는 클라이언트 측 JavaScript에서 직접 접근할 수 없습니다(백엔드에서 설정해야 함).
- 기본적으로 모든 요청에 쿠키가 포함되므로 CSRF 공격에 취약할 수 있습니다.
- 도메인 간 요청 시 추가 설정이 필요합니다(CORS with credentials).
3. 메모리 저장
// JavaScript 변수에 저장 (클로저 활용)
const createTokenManager = () => {
let accessToken: string | null = null;
return {
setToken: (token: string) => {
accessToken = token;
},
getToken: () => accessToken,
clearToken: () => {
accessToken = null;
}
};
};
export const tokenManager = createTokenManager();
장점:
- 메모리에만 저장되므로 XSS 공격이 성공하더라도 토큰에 접근하기 어렵습니다.
- 브라우저 저장소에 접근하는 오버헤드가 없어 성능이 약간 향상됩니다.
- 토큰을 직접 관리할 수 있어 유연성이 높습니다.
단점:
- 페이지를 새로고침하면 토큰이 사라집니다.
- 여러 탭이나 창에서 공유되지 않습니다.
- SPA(Single Page Application)에서만 효과적으로 작동합니다.
4. 하이브리드 접근법
현대적인 웹 애플리케이션에서는 보안과 사용자 경험의 균형을 위해 하이브리드 접근법이 권장됩니다:
- 액세스 토큰: 짧은 수명을 가진 액세스 토큰은 메모리에 저장합니다.
- 리프레시 토큰: 긴 수명을 가진 리프레시 토큰은 HttpOnly 쿠키에 저장합니다.
// 액세스 토큰을 위한 메모리 저장소
let inMemoryToken: string | null = null;
// 액세스 토큰 관리
const tokenService = {
setAccessToken: (token: string) => {
inMemoryToken = token;
},
getAccessToken: () => inMemoryToken,
clearAccessToken: () => {
inMemoryToken = null;
}
};
// 리프레시 토큰은 서버에서 HttpOnly 쿠키로 설정됨
// 로그인 API 호출 예시
const login = async (credentials: { email: string; password: string }) => {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(credentials),
credentials: 'include' // 쿠키를 포함하기 위해 필요
});
if (response.ok) {
const data = await response.json();
tokenService.setAccessToken(data.accessToken);
return true;
}
return false;
};
이러한 하이브리드 접근법을 통해 XSS 및 CSRF 공격으로부터 더 나은 보호를 제공하면서도 사용자 경험을 유지할 수 있습니다.
React에서 JWT 인증 구현하기
이제 React 애플리케이션에서 JWT 인증을 구현하는 방법을 알아보겠습니다. 먼저, 필요한 의존성을 설치합니다:
npm install axios jwt-decode
# 또는
yarn add axios jwt-decode
1. 기본 프로젝트 구조
JWT 인증을 위한 기본 프로젝트 구조는 다음과 같습니다:
src/
├── api/
│ ├── axios.ts # Axios 인스턴스 및 인터셉터
│ └── authService.ts # 인증 관련 API 호출
├── context/
│ └── AuthContext.tsx # 인증 상태 관리
├── hooks/
│ └── useAuth.ts # 인증 훅
├── pages/
│ ├── Login.tsx # 로그인 페이지
│ ├── Register.tsx # 회원가입 페이지
│ └── Dashboard.tsx # 인증된 사용자만 접근 가능한 페이지
├── components/
│ ├── PrivateRoute.tsx # 인증된 라우트 보호
│ └── Navbar.tsx # 네비게이션 바
└── App.tsx # 메인 애플리케이션
2. 인증 서비스 구현
먼저 인증 관련 API 호출을 처리하는 서비스를 구현합니다:
// src/api/authService.ts
import axios from './axios';
import jwt_decode from 'jwt-decode';
export interface LoginCredentials {
email: string;
password: string;
}
export interface RegisterData {
username: string;
email: string;
password: string;
}
export interface AuthResponse {
accessToken: string;
user: {
id: number;
username: string;
email: string;
role: string;
};
}
export interface DecodedToken {
id: number;
username: string;
role: string;
exp: number;
}
// 로그인 API
export const login = async (credentials: LoginCredentials): Promise<AuthResponse> => {
const response = await axios.post<AuthResponse>('/auth/login', credentials);
return response.data;
};
// 회원가입 API
export const register = async (data: RegisterData): Promise<{ message: string }> => {
const response = await axios.post<{ message: string }>('/auth/register', data);
return response.data;
};
// 로그아웃 API
export const logout = async (): Promise<void> => {
await axios.post('/auth/logout');
localStorage.removeItem('accessToken');
};
// 토큰이 유효한지 확인
export const isTokenValid = (token: string): boolean => {
try {
const decoded = jwt_decode<DecodedToken>(token);
const currentTime = Date.now() / 1000;
return decoded.exp > currentTime;
} catch (error) {
return false;
}
};
// 토큰에서 사용자 정보 추출
export const getUserFromToken = (token: string): DecodedToken['user'] | null => {
try {
const decoded = jwt_decode<DecodedToken>(token);
return {
id: decoded.id,
username: decoded.username,
role: decoded.role
};
} catch (error) {
return null;
}
};
// 현재 사용자 정보 가져오기
export const getCurrentUser = async () => {
const response = await axios.get('/auth/me');
return response.data;
};
3. Axios 인스턴스 설정
API 요청을 처리하기 위한 Axios 인스턴스를 설정합니다:
// src/api/axios.ts
import axios from 'axios';
const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:3000/api';
// Axios 인스턴스 생성
const instance = axios.create({
baseURL: API_URL,
timeout: 10000,
headers: {
'Content-Type': 'application/json'
}
});
// 요청 인터셉터
instance.interceptors.request.use(
(config) => {
const token = localStorage.getItem('accessToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
export default instance;
Context API를 활용한 인증 상태 관리
React의 Context API를 사용하여 인증 상태를 전역적으로 관리하는 방법을 알아보겠습니다:
// src/context/AuthContext.tsx
import React, { createContext, useReducer, useEffect } from 'react';
import jwt_decode from 'jwt-decode';
import { getCurrentUser, isTokenValid } from '../api/authService';
// 사용자 타입 정의
export interface User {
id: number;
username: string;
email?: string;
role: string;
}
// 인증 상태 타입 정의
interface AuthState {
isAuthenticated: boolean;
user: User | null;
loading: boolean;
error: string | null;
}
// 액션 타입 정의
type AuthAction =
| { type: 'LOGIN_SUCCESS'; payload: { user: User; token: string } }
| { type: 'LOGOUT' }
| { type: 'AUTH_ERROR'; payload: string }
| { type: 'CLEAR_ERROR' }
| { type: 'SET_LOADING' }
| { type: 'USER_LOADED'; payload: User };
// 초기 상태
const initialState: AuthState = {
isAuthenticated: false,
user: null,
loading: true,
error: null
};
// Context 생성
export const AuthContext = createContext<{
state: AuthState;
dispatch: React.Dispatch<AuthAction>;
login: (token: string, user: User) => void;
logout: () => void;
}>({
state: initialState,
dispatch: () => null,
login: () => null,
logout: () => null
});
// 리듀서 함수
const authReducer = (state: AuthState, action: AuthAction): AuthState => {
switch (action.type) {
case 'LOGIN_SUCCESS':
localStorage.setItem('accessToken', action.payload.token);
return {
...state,
isAuthenticated: true,
user: action.payload.user,
loading: false,
error: null
};
case 'LOGOUT':
localStorage.removeItem('accessToken');
return {
...state,
isAuthenticated: false,
user: null,
loading: false,
error: null
};
case 'AUTH_ERROR':
localStorage.removeItem('accessToken');
return {
...state,
isAuthenticated: false,
user: null,
loading: false,
error: action.payload
};
case 'CLEAR_ERROR':
return {
...state,
error: null
};
case 'SET_LOADING':
return {
...state,
loading: true
};
case 'USER_LOADED':
return {
...state,
isAuthenticated: true,
user: action.payload,
loading: false
};
default:
return state;
}
};
// Provider 컴포넌트
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [state, dispatch] = useReducer(authReducer, initialState);
// 로그인 함수
const login = (token: string, user: User) => {
dispatch({
type: 'LOGIN_SUCCESS',
payload: { token, user }
});
};
// 로그아웃 함수
const logout = () => {
dispatch({ type: 'LOGOUT' });
};
// 초기 인증 상태 확인
useEffect(() => {
const loadUser = async () => {
const token = localStorage.getItem('accessToken');
if (!token || !isTokenValid(token)) {
dispatch({ type: 'LOGOUT' });
return;
}
try {
const user = await getCurrentUser();
dispatch({ type: 'USER_LOADED', payload: user });
} catch (error) {
dispatch({ type: 'AUTH_ERROR', payload: 'Authentication failed' });
}
};
loadUser();
}, []);
return (
<AuthContext.Provider value={{ state, dispatch, login, logout }}>
{children}
</AuthContext.Provider>
);
};
1. 커스텀 인증 훅 생성
위에서 만든 Context를 더 쉽게 사용할 수 있도록 커스텀 훅을 만듭니다:
// src/hooks/useAuth.ts
import { useContext } from 'react';
import { AuthContext } from '../context/AuthContext';
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
2. 로그인 컴포넌트 구현
이제 로그인 컴포넌트를 구현해 보겠습니다:
// src/pages/Login.tsx
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { login as apiLogin } from '../api/authService';
import { useAuth } from '../hooks/useAuth';
const Login: React.FC = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const { login } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const response = await apiLogin({ email, password });
login(response.accessToken, response.user);
navigate('/dashboard');
} catch (err: any) {
setError(err.response?.data?.message || 'An error occurred during login');
} finally {
setLoading(false);
}
};
return (
<div className="login-container">
<h2>Login</h2>
{error && <div className="error">{error}</div>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="email">Email</label>
<input
type="email"
id="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="form-group">
<label htmlFor="password">Password</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<button type="submit" disabled={loading}>
{loading ? 'Logging in...' : 'Login'}
</button>
</form>
</div>
);
};
export default Login;
3. 인증된 라우트 보호하기
인증된 사용자만 접근할 수 있는 라우트를 보호하기 위한 컴포넌트를 구현합니다:
// src/components/PrivateRoute.tsx
import React from 'react';
import { Navigate, Outlet } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
const PrivateRoute: React.FC = () => {
const { state } = useAuth();
const { isAuthenticated, loading } = state;
if (loading) {
return <div>Loading...</div>;
}
return isAuthenticated ? <Outlet /> : <Navigate to="/login" />;
};
export default PrivateRoute;
그리고 App 컴포넌트에서 라우트를 설정합니다:
// src/App.tsx
import React from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import { AuthProvider } from './context/AuthContext';
import PrivateRoute from './components/PrivateRoute';
import Navbar from './components/Navbar';
import Login from './pages/Login';
import Register from './pages/Register';
import Dashboard from './pages/Dashboard';
import Home from './pages/Home';
const App: React.FC = () => {
return (
<AuthProvider>
<Router>
<Navbar />
<div className="container">
<Routes>
<Route path="/" element={<Home />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
{/* 보호된 라우트 */}
<Route element={<PrivateRoute />}>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/profile" element={<Profile />} />
</Route>
</Routes>
</div>
</Router>
</AuthProvider>
);
};
export default App;
Axios 인터셉터를 활용한 토큰 자동 갱신
만료된 액세스 토큰을 자동으로 갱신하기 위해 Axios 인터셉터를 구현해 보겠습니다:
// src/api/axios.ts (업데이트)
import axios, { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios';
import jwt_decode from 'jwt-decode';
const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:3000/api';
// 액세스 토큰 메모리 저장소
let accessToken: string | null = null;
// 토큰 새로고침 중인지 확인하는 플래그
let isRefreshing = false;
// 보류 중인 요청 저장소
let failedQueue: any[] = [];
// 보류 중인 요청 처리
const processQueue = (error: any, token: string | null = null) => {
failedQueue.forEach(prom => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token);
}
});
failedQueue = [];
};
// Axios 인스턴스 생성
const instance = axios.create({
baseURL: API_URL,
timeout: 10000,
headers: {
'Content-Type': 'application/json'
}
});
// 토큰이 만료되었는지 확인
const isTokenExpired = (token: string): boolean => {
try {
const decoded = jwt_decode<{ exp: number }>(token);
const currentTime = Date.now() / 1000;
return decoded.exp < currentTime;
} catch (error) {
return true;
}
};
// 요청 인터셉터
instance.interceptors.request.use(
(config) => {
// 메모리에서 액세스 토큰을 가져옴
const token = accessToken || localStorage.getItem('accessToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
// 메모리에 토큰 저장
if (!accessToken) {
accessToken = token;
}
}
return config;
},
(error) => Promise.reject(error)
);
// 응답 인터셉터
instance.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// 401 에러이고 재시도하지 않은 경우
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
// 이미 새로고침 중이라면 큐에 요청 추가
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
})
.then(token => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return axios(originalRequest);
})
.catch(err => Promise.reject(err));
}
originalRequest._retry = true;
isRefreshing = true;
try {
// 리프레시 토큰으로 새 액세스 토큰 요청
const response = await axios.post(
`${API_URL}/auth/refresh-token`,
{},
{ withCredentials: true } // 쿠키 전송을 위해 필요
);
const newAccessToken = response.data.accessToken;
// 메모리와 로컬 스토리지에 새 토큰 저장
accessToken = newAccessToken;
localStorage.setItem('accessToken', newAccessToken);
// 보류 중인 요청 처리
processQueue(null, newAccessToken);
// 원래 요청 재시도
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
return axios(originalRequest);
} catch (refreshError) {
// 리프레시 실패 시 로그아웃 처리
processQueue(refreshError, null);
localStorage.removeItem('accessToken');
accessToken = null;
// 로그인 페이지로 리다이렉트
window.location.href = '/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
}
);
export default instance;
이 구현은 다음과 같은 특징을 가지고 있습니다:
- 메모리 기반 토큰 저장: 액세스 토큰은 메모리에 저장되어 XSS 공격에 대한 보안이 향상됩니다.
- 자동 토큰 갱신: 401 에러 발생 시 리프레시 토큰을 사용하여 액세스 토큰을 자동으로 갱신합니다.
- 요청 큐: 토큰을 갱신하는 동안 발생하는 추가 요청을 큐에 저장하여 토큰 갱신 후 자동으로 재시도합니다.
- 로그아웃 처리: 리프레시 토큰이 만료되었거나 유효하지 않은 경우 자동으로 로그아웃 처리합니다.
Next.js에서의 JWT 인증 구현
Next.js는 서버 사이드 렌더링(SSR)을 제공하는 React 프레임워크로, JWT 인증을 구현할 때 몇 가지 추가적인 고려 사항이 있습니다. 다음은 Next.js에서 JWT 인증을 구현하는 방법입니다:
1. API 라우트를 사용한 인증 처리
Next.js의 API 라우트를 사용하여 인증 관련 엔드포인트를 구현할 수 있습니다:
// pages/api/auth/login.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import cookie from 'cookie';
import { login as loginService } from '../../../services/auth';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'POST') {
return res.status(405).json({ message: 'Method not allowed' });
}
try {
const { email, password } = req.body;
// 백엔드 인증 서비스 호출
const { accessToken, refreshToken, user } = await loginService(email, password);
// 리프레시 토큰을 HttpOnly 쿠키로 설정
res.setHeader('Set-Cookie', cookie.serialize('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV !== 'development',
sameSite: 'strict',
maxAge: 60 * 60 * 24 * 7, // 7일
path: '/'
}));
// 액세스 토큰과 사용자 정보 반환
return res.status(200).json({ accessToken, user });
} catch (error: any) {
return res.status(401).json({ message: error.message || 'Authentication failed' });
}
}
2. 커스텀 Axios 인스턴스 설정
Next.js에서 사용할 수 있는 API 클라이언트를 구성합니다:
// lib/axios.ts
import axios from 'axios';
const baseURL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api';
const api = axios.create({
baseURL,
headers: {
'Content-Type': 'application/json'
},
withCredentials: true // 쿠키를 포함하기 위해 필요
});
// 클라이언트 사이드에서만 실행되는 인터셉터
if (typeof window !== 'undefined') {
// 액세스 토큰 저장소 (메모리만 사용)
let accessToken: string | null = null;
// Request 인터셉터
api.interceptors.request.use(
(config) => {
// 메모리에서 토큰 가져오기
const token = accessToken;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Response 인터셉터 (토큰 갱신 로직)
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// 401 에러이고 재시도하지 않은 경우
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
// 리프레시 토큰으로 새 액세스 토큰 요청
const response = await axios.post('/api/auth/refresh', {}, { withCredentials: true });
const newAccessToken = response.data.accessToken;
// 메모리에 새 토큰 저장
accessToken = newAccessToken;
// 원래 요청 헤더 업데이트
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
// 원래 요청 재시도
return axios(originalRequest);
} catch (refreshError) {
// 리프레시 실패 시 로그아웃
accessToken = null;
// 로그인 페이지로 리다이렉트
window.location.href = '/login';
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
// 액세스 토큰 설정 함수 (로그인 성공 후 호출)
export const setAccessToken = (token: string) => {
accessToken = token;
};
// 액세스 토큰 제거 함수 (로그아웃 시 호출)
export const clearAccessToken = () => {
accessToken = null;
};
}
export default api;
3. 인증 컨텍스트 구현
Next.js에서 사용할 인증 컨텍스트를 구현합니다:
// contexts/auth.tsx
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
import { useRouter } from 'next/router';
import api, { setAccessToken, clearAccessToken } from '../lib/axios';
import jwt_decode from 'jwt-decode';
// 사용자 타입
interface User {
id: number;
username: string;
email: string;
role: string;
}
// 인증 컨텍스트 타입
interface AuthContextType {
user: User | null;
loading: boolean;
error: string | null;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
isAuthenticated: boolean;
}
// 기본값 생성
const AuthContext = createContext<AuthContextType>({
user: null,
loading: true,
error: null,
login: async () => {},
logout: async () => {},
isAuthenticated: false
});
// 컨텍스트 훅
export const useAuth = () => useContext(AuthContext);
// 공급자 컴포넌트
export const AuthProvider = ({ children }: { children: ReactNode }) => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
// 로그인 함수
const login = async (email: string, password: string) => {
try {
setLoading(true);
setError(null);
const response = await api.post('/api/auth/login', { email, password });
const { accessToken, user } = response.data;
// 메모리에 액세스 토큰 저장
setAccessToken(accessToken);
// 사용자 정보 설정
setUser(user);
// 리디렉션
router.push('/dashboard');
} catch (err: any) {
setError(err.response?.data?.message || 'Login failed');
throw err;
} finally {
setLoading(false);
}
};
// 로그아웃 함수
const logout = async () => {
try {
await api.post('/api/auth/logout');
} catch (err) {
console.error('Logout error:', err);
} finally {
// 메모리에서 토큰 제거
clearAccessToken();
// 사용자 상태 초기화
setUser(null);
// 로그인 페이지로 리디렉션
router.push('/login');
}
};
// 현재 사용자 정보 로드
useEffect(() => {
const loadUser = async () => {
try {
const response = await api.get('/api/auth/me');
setUser(response.data);
} catch (err) {
setUser(null);
} finally {
setLoading(false);
}
};
loadUser();
}, []);
return (
<AuthContext.Provider
value={{
user,
loading,
error,
login,
logout,
isAuthenticated: !!user
}}
>
{children}
</AuthContext.Provider>
);
};
4. 인증 API 엔드포인트 구현
필요한 인증 API 엔드포인트를 더 구현해 보겠습니다:
// pages/api/auth/refresh.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import cookie from 'cookie';
import { refreshToken } from '../../../services/auth';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'POST') {
return res.status(405).json({ message: 'Method not allowed' });
}
// 쿠키에서 리프레시 토큰 추출
const refreshTokenCookie = req.cookies.refreshToken;
if (!refreshTokenCookie) {
return res.status(401).json({ message: 'Refresh token not found' });
}
try {
// 리프레시 토큰으로 새 액세스 토큰 발급
const { accessToken, newRefreshToken } = await refreshToken(refreshTokenCookie);
// 새 리프레시 토큰을 쿠키에 설정
if (newRefreshToken) {
res.setHeader('Set-Cookie', cookie.serialize('refreshToken', newRefreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV !== 'development',
sameSite: 'strict',
maxAge: 60 * 60 * 24 * 7, // 7일
path: '/'
}));
}
// 새 액세스 토큰 반환
return res.status(200).json({ accessToken });
} catch (error: any) {
// 리프레시 토큰 만료 또는 무효화
res.setHeader('Set-Cookie', cookie.serialize('refreshToken', '', {
httpOnly: true,
secure: process.env.NODE_ENV !== 'development',
sameSite: 'strict',
maxAge: 0,
path: '/'
}));
return res.status(401).json({ message: error.message || 'Token refresh failed' });
}
}
// pages/api/auth/logout.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import cookie from 'cookie';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'POST') {
return res.status(405).json({ message: 'Method not allowed' });
}
// 리프레시 토큰 쿠키 제거
res.setHeader('Set-Cookie', cookie.serialize('refreshToken', '', {
httpOnly: true,
secure: process.env.NODE_ENV !== 'development',
sameSite: 'strict',
maxAge: 0,
path: '/'
}));
return res.status(200).json({ message: 'Logged out successfully' });
}
// pages/api/auth/me.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import { verifyToken } from '../../../lib/jwt';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'GET') {
return res.status(405).json({ message: 'Method not allowed' });
}
// 헤더에서 액세스 토큰 추출
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ message: 'Access token not found' });
}
const token = authHeader.split(' ')[1];
try {
// 토큰 검증 및 사용자 정보 추출
const user = verifyToken(token);
// 필요한 경우 추가 사용자 정보 조회
// const userDetails = await getUserDetails(user.id);
return res.status(200).json(user);
} catch (error: any) {
return res.status(401).json({ message: error.message || 'Invalid token' });
}
}
5. JWT 유틸리티 함수 구현
JWT 토큰을 처리하기 위한 유틸리티 함수를 구현합니다:
// lib/jwt.ts
import jwt from 'jsonwebtoken';
// 환경 변수에서 시크릿 키 로드
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
// 사용자 타입
interface User {
id: number;
username: string;
email: string;
role: string;
}
// 토큰 생성
export const generateToken = (user: User, expiresIn = '1h') => {
return jwt.sign(
{
id: user.id,
username: user.username,
email: user.email,
role: user.role
},
JWT_SECRET,
{ expiresIn }
);
};
// 토큰 검증
export const verifyToken = (token: string): User => {
try {
const decoded = jwt.verify(token, JWT_SECRET) as User;
return decoded;
} catch (error) {
throw new Error('Invalid token');
}
};
// 토큰 디코딩 (검증 없이)
export const decodeToken = (token: string): User | null => {
try {
return jwt.decode(token) as User;
} catch (error) {
return null;
}
};
6. 서버 사이드 인증 적용
Next.js의 getServerSideProps를 사용하여 서버 사이드에서 인증을 적용합니다:
// pages/dashboard.tsx
import { GetServerSideProps } from 'next';
import { parseCookies } from 'nookies';
import { verifyToken } from '../lib/jwt';
import { useAuth } from '../contexts/auth';
export const getServerSideProps: GetServerSideProps = async (context) => {
try {
// 쿠키에서 리프레시 토큰 가져오기
const cookies = parseCookies(context);
const refreshToken = cookies.refreshToken;
if (!refreshToken) {
// 인증되지 않은 사용자를 로그인 페이지로 리디렉션
return {
redirect: {
destination: '/login',
permanent: false
}
};
}
// 필요한 경우 여기서 서버 측 인증 로직 추가
// 예: 리프레시 토큰으로 사용자 정보 확인
return {
props: {} // 컴포넌트에 전달할 props
};
} catch (error) {
// 인증 실패 시 로그인 페이지로 리디렉션
return {
redirect: {
destination: '/login',
permanent: false
}
};
}
};
const Dashboard = () => {
const { user, logout } = useAuth();
return (
<div>
<h1>Dashboard</h1>
{user && (
<div>
<p>Welcome, {user.username}!</p>
<p>Email: {user.email}</p>
<p>Role: {user.role}</p>
<button onClick={logout}>Logout</button>
</div>
)}
</div>
);
};
export default Dashboard;
7. 미들웨어를 활용한 인증(Next.js 12 이상)
Next.js 12부터는 미들웨어를 사용하여 전역적으로 인증을 처리할 수 있습니다:
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { verifyToken } from './lib/jwt';
// 인증이 필요한 경로 패턴
const protectedPaths = ['/dashboard', '/profile', '/admin'];
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 보호된 경로인지 확인
const isProtectedPath = protectedPaths.some((path) =>
pathname.startsWith(path)
);
if (!isProtectedPath) {
return NextResponse.next();
}
// 쿠키에서 리프레시 토큰 확인
const refreshToken = request.cookies.get('refreshToken')?.value;
// 리프레시 토큰이 없으면 로그인 페이지로 리디렉션
if (!refreshToken) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('redirectTo', pathname);
return NextResponse.redirect(loginUrl);
}
// 리프레시 토큰이 있으면 요청 계속 진행
return NextResponse.next();
}
// 미들웨어가 실행될 경로 지정
export const config = {
matcher: [
/*
* '/api/:path*' 경로를 제외한 모든 경로에 매칭
* (API 경로는 자체적으로 인증 처리)
*/
'/((?!api|_next/static|favicon.ico).*)'
]
};
인증된 라우트 보호하기
Next.js에서 인증된 라우트를 보호하는 여러 가지 방법이 있습니다:
1. HOC(Higher Order Component)를 사용한 방법
// components/withAuth.tsx
import { useEffect } from 'react';
import { useRouter } from 'next/router';
import { useAuth } from '../contexts/auth';
export const withAuth = (Component: React.ComponentType) => {
const AuthenticatedComponent = (props: any) => {
const { isAuthenticated, loading } = useAuth();
const router = useRouter();
useEffect(() => {
if (!loading && !isAuthenticated) {
router.replace(`/login?redirectTo=${router.pathname}`);
}
}, [isAuthenticated, loading, router]);
// 로딩 중이거나 인증되지 않은 경우 로딩 표시
if (loading || !isAuthenticated) {
return <div>Loading...</div>;
}
// 인증된 경우 원래 컴포넌트 렌더링
return <Component {...props} />;
};
return AuthenticatedComponent;
};
// 사용 예:
// pages/profile.tsx
import { withAuth } from '../components/withAuth';
const Profile = () => {
return <div>Protected Profile Page</div>;
};
export default withAuth(Profile);
2. 커스텀 훅을 사용한 방법
// hooks/useRequireAuth.ts
import { useEffect } from 'react';
import { useRouter } from 'next/router';
import { useAuth } from '../contexts/auth';
export const useRequireAuth = (redirectTo = '/login') => {
const { user, loading } = useAuth();
const router = useRouter();
useEffect(() => {
if (!loading && !user) {
router.push({
pathname: redirectTo,
query: { redirectTo: router.pathname }
});
}
}, [user, loading, redirectTo, router]);
return { user, loading };
};
// 사용 예:
// pages/settings.tsx
import { useRequireAuth } from '../hooks/useRequireAuth';
const Settings = () => {
const { user, loading } = useRequireAuth();
if (loading || !user) {
return <div>Loading...</div>;
}
return (
<div>
<h1>Settings</h1>
<p>Your account: {user.email}</p>
{/* 설정 내용 */}
</div>
);
};
export default Settings;
3. 역할 기반 접근 제어(RBAC)
특정 역할을 가진 사용자만 접근할 수 있는 컴포넌트를 만들 수 있습니다:
// components/RoleBasedAccess.tsx
import React from 'react';
import { useAuth } from '../contexts/auth';
interface RoleBasedAccessProps {
allowedRoles: string[];
children: React.ReactNode;
fallback?: React.ReactNode;
}
export const RoleBasedAccess: React.FC<RoleBasedAccessProps> = ({
allowedRoles,
children,
fallback = null
}) => {
const { user } = useAuth();
if (!user) {
return <>{fallback}</>;
}
const hasPermission = allowedRoles.includes(user.role);
return <>{hasPermission ? children : fallback}</>;
};
// 사용 예:
// pages/admin-dashboard.tsx
import { RoleBasedAccess } from '../components/RoleBasedAccess';
import { withAuth } from '../components/withAuth';
const AdminDashboard = () => {
return (
<RoleBasedAccess
allowedRoles={['admin']}
fallback={<div>Access denied. Admin privileges required.</div>}
>
<div>
<h1>Admin Dashboard</h1>
{/* 관리자 기능 */}
</div>
</RoleBasedAccess>
);
};
export default withAuth(AdminDashboard);
JWT 인증 디버깅 및 문제 해결
JWT 인증을 구현하다 보면 다양한 문제가 발생할 수 있습니다. 여기에서는 일반적인 문제와 해결 방법을 살펴보겠습니다:
1. 토큰 만료 문제
// 토큰 만료 시간 확인 함수
const isTokenExpired = (token: string): boolean => {
try {
const decoded = jwt_decode<{ exp: number }>(token);
const currentTime = Date.now() / 1000;
// 디버깅을 위해 콘솔에 출력
console.log('Token expires at:', new Date(decoded.exp * 1000).toLocaleString());
console.log('Current time:', new Date(currentTime * 1000).toLocaleString());
console.log('Time remaining:', Math.round(decoded.exp - currentTime), 'seconds');
return decoded.exp < currentTime;
} catch (error) {
console.error('Token decode error:', error);
return true;
}
};
2. CORS 문제
API와 프론트엔드가 다른 도메인에 있는 경우 CORS 설정이 필요합니다:
// pages/api/auth/[...auth].ts에 CORS 헤더 추가
import type { NextApiRequest, NextApiResponse } from 'next';
import Cors from 'cors';
// CORS 미들웨어 초기화
const cors = Cors({
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
credentials: true,
origin: (origin, callback) => {
const allowedOrigins = [
'http://localhost:3000',
'https://your-production-domain.com'
];
if (!origin || allowedOrigins.indexOf(origin) !== -1) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
}
});
// 미들웨어 실행 헬퍼
const runMiddleware = (req: NextApiRequest, res: NextApiResponse, fn: Function) => {
return new Promise((resolve, reject) => {
fn(req, res, (result: any) => {
if (result instanceof Error) {
return reject(result);
}
return resolve(result);
});
});
};
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
// CORS 미들웨어 실행
await runMiddleware(req, res, cors);
// 이제 API 로직 계속 진행
// ...
}
3. 토큰 디버깅
JWT 토큰 내용을 디버깅하는 함수를 만들면 유용합니다:
// 토큰 디버깅 유틸리티
export const debugToken = (token: string | null) => {
if (!token) {
console.error('No token provided');
return null;
}
try {
const decoded = jwt_decode(token);
console.log('Decoded token:', decoded);
// 만료 시간 확인
if ('exp' in decoded) {
const exp = (decoded as any).exp;
const currentTime = Date.now() / 1000;
console.log('Token expires in:', Math.round(exp - currentTime), 'seconds');
}
return decoded;
} catch (error) {
console.error('Token decode error:', error);
return null;
}
};
// 사용 예:
const accessToken = localStorage.getItem('accessToken');
debugToken(accessToken);
4. 네트워크 요청 로깅
API 요청 문제를 디버깅하기 위한 로깅 인터셉터:
// lib/axios.ts에 로깅 인터셉터 추가
api.interceptors.request.use(
(config) => {
console.log(`[REQUEST] ${config.method?.toUpperCase()} ${config.url}`, {
headers: config.headers,
data: config.data
});
return config;
},
(error) => {
console.error('[REQUEST ERROR]', error);
return Promise.reject(error);
}
);
api.interceptors.response.use(
(response) => {
console.log(`[RESPONSE] ${response.status} ${response.config.url}`, {
data: response.data
});
return response;
},
(error) => {
console.error('[RESPONSE ERROR]', {
status: error.response?.status,
url: error.config?.url,
data: error.response?.data
});
return Promise.reject(error);
}
);
5. 개발 도구 확장 프로그램
JWT 디버깅을 위한 유용한 브라우저 확장 프로그램:
- JWT Debugger (Chrome)
- JWT Inspector (Chrome)
모범 사례 및 팁
Next.js 및 React 환경에서 JWT 인증을 구현할 때 고려해야 할 몇 가지 모범 사례와 팁입니다:
1. 환경 변수 관리
민감한 정보를 안전하게 관리하기 위해 환경 변수를 사용합니다:
// .env.local (로컬 개발용)
JWT_SECRET=your_super_secret_key
REFRESH_TOKEN_SECRET=another_super_secret_key
NEXT_PUBLIC_API_URL=http://localhost:3000/api
// .env.production (프로덕션용)
JWT_SECRET=production_secret_key
REFRESH_TOKEN_SECRET=production_refresh_secret
NEXT_PUBLIC_API_URL=https://your-api.com/api
Next.js에서 환경 변수 사용:
// 서버 사이드에서만 접근 가능한 환경 변수
const jwtSecret = process.env.JWT_SECRET;
// 클라이언트와 서버 모두에서 접근 가능한 환경 변수
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
2. 토큰 갱신 전략 최적화
사용자 경험을 향상시키기 위해 토큰 갱신 전략을 최적화합니다:
// hooks/useTokenRefresh.ts
import { useEffect, useRef } from 'react';
import { useAuth } from '../contexts/auth';
import api from '../lib/axios';
import jwt_decode from 'jwt-decode';
export const useTokenRefresh = () => {
const { logout } = useAuth();
const refreshTimeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
// 이전 타이머 제거
if (refreshTimeoutRef.current) {
clearTimeout(refreshTimeoutRef.current);
}
const refreshAccessToken = async () => {
try {
const response = await api.post('/api/auth/refresh');
const { accessToken } = response.data;
// 메모리에 새 토큰 저장
// 여기서는 사용 중인 토큰 관리 방식에 따라 구현
localStorage.setItem('accessToken', accessToken);
// 새 토큰으로 타이머 설정
setRefreshTimer(accessToken);
} catch (error) {
console.error('Token refresh failed:', error);
logout();
}
};
const setRefreshTimer = (token: string) => {
try {
// 토큰에서 만료 시간 추출
const decoded = jwt_decode<{ exp: number }>(token);
const expiresIn = decoded.exp * 1000 - Date.now();
// 만료 시간이 유효하지 않으면 로그아웃
if (expiresIn < 0) {
logout();
return;
}
// 토큰 만료 1분 전에 리프레시 시도
const timeoutDelay = Math.max(0, expiresIn - 60000);
refreshTimeoutRef.current = setTimeout(refreshAccessToken, timeoutDelay);
console.log(`Token refresh scheduled in ${Math.round(timeoutDelay / 1000)} seconds`);
} catch (error) {
console.error('Failed to schedule token refresh:', error);
}
};
// 현재 토큰으로 타이머 설정
const accessToken = localStorage.getItem('accessToken');
if (accessToken) {
setRefreshTimer(accessToken);
}
// 컴포넌트 언마운트 시 타이머 정리
return () => {
if (refreshTimeoutRef.current) {
clearTimeout(refreshTimeoutRef.current);
}
};
}, [logout]);
};
// 사용 예:
// _app.tsx에서 이 훅 사용
function MyApp({ Component, pageProps }: AppProps) {
// AuthProvider 내부에서 사용하거나 여기서 직접 사용
useTokenRefresh();
return (
<AuthProvider>
<Component {...pageProps} />
</AuthProvider>
);
}
3. 사용자 활동 기반 토큰 갱신
사용자가 활동 중일 때만 토큰을 갱신하도록 최적화합니다:
// hooks/useActivityBasedRefresh.ts
import { useEffect, useRef } from 'react';
import { useAuth } from '../contexts/auth';
import api from '../lib/axios';
export const useActivityBasedRefresh = () => {
const { logout } = useAuth();
const lastActivityRef = useRef<number>(Date.now());
const activityTimeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
// 사용자 활동 이벤트 핸들러
const handleUserActivity = () => {
lastActivityRef.current = Date.now();
};
// 사용자 활동 이벤트 리스너 등록
window.addEventListener('mousemove', handleUserActivity);
window.addEventListener('keydown', handleUserActivity);
window.addEventListener('click', handleUserActivity);
window.addEventListener('scroll', handleUserActivity);
// 30초마다 사용자 활동 확인
const checkActivity = async () => {
const now = Date.now();
const inactiveTime = now - lastActivityRef.current;
// 10분 이상 비활성 상태면 토큰 갱신 건너뛰기
if (inactiveTime < 10 * 60 * 1000) {
try {
await api.post('/api/auth/refresh');
console.log('Token refreshed based on activity');
} catch (error) {
console.error('Token refresh failed:', error);
logout();
}
} else {
console.log('Skipping token refresh due to inactivity');
}
};
// 30분마다 활동 체크 및 토큰 갱신
activityTimeoutRef.current = setInterval(checkActivity, 30 * 60 * 1000);
// 컴포넌트 언마운트 시 정리
return () => {
window.removeEventListener('mousemove', handleUserActivity);
window.removeEventListener('keydown', handleUserActivity);
window.removeEventListener('click', handleUserActivity);
window.removeEventListener('scroll', handleUserActivity);
if (activityTimeoutRef.current) {
clearInterval(activityTimeoutRef.current);
}
};
}, [logout]);
};
4. 토큰 보안 강화
JWT 토큰 보안을 강화하기 위한 몇 가지 팁:
// lib/jwt.ts
import jwt from 'jsonwebtoken';
import { v4 as uuidv4 } from 'uuid';
// 환경 변수에서 시크릿 키 로드
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
// 토큰 생성 (보안 강화)
export const generateEnhancedToken = (user, expiresIn = '1h') => {
// 고유 JWT ID 추가
const jwtid = uuidv4();
// 발급 시간과 IP 주소 기록 (요청 객체 필요)
const issuedAt = Math.floor(Date.now() / 1000);
return jwt.sign(
{
id: user.id,
username: user.username,
role: user.role,
// JWT 클레임 활용
jti: jwtid, // JWT ID (고유 식별자)
iat: issuedAt, // Issued At (발급 시간)
},
JWT_SECRET,
{
expiresIn,
audience: 'your-app-name', // 대상 애플리케이션
issuer: 'your-api-domain', // 발급자
}
);
};
// 토큰 검증 (보안 강화)
export const verifyEnhancedToken = (token) => {
try {
// 추가 검증 옵션 포함
const decoded = jwt.verify(token, JWT_SECRET, {
audience: 'your-app-name',
issuer: 'your-api-domain',
});
// 필요한 경우 추가 검증 로직
// 예: 블랙리스트 확인, 토큰 버전 확인 등
return decoded;
} catch (error) {
throw new Error(`Token verification failed: ${error.message}`);
}
};
5. 다중 디바이스 로그인 관리
사용자가 여러 디바이스에서 로그인한 경우를 관리합니다:
// pages/api/auth/devices.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import { verifyToken } from '../../../lib/jwt';
import { getUserSessions, revokeSession } from '../../../services/authService';
// 현재 로그인된 모든 디바이스 조회
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
// 헤더에서 액세스 토큰 추출
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ message: 'Access token not found' });
}
const token = authHeader.split(' ')[1];
try {
// 토큰 검증 및 사용자 정보 추출
const user = verifyToken(token);
if (req.method === 'GET') {
// 사용자의 모든 활성 세션 조회
const sessions = await getUserSessions(user.id);
return res.status(200).json({ sessions });
} else if (req.method === 'DELETE') {
const { sessionId } = req.body;
if (!sessionId) {
return res.status(400).json({ message: 'Session ID is required' });
}
// 특정 세션 종료
await revokeSession(user.id, sessionId);
return res.status(200).json({ message: 'Session revoked successfully' });
} else {
return res.status(405).json({ message: 'Method not allowed' });
}
} catch (error: any) {
return res.status(401).json({ message: error.message || 'Invalid token' });
}
}
// 프론트엔드 컴포넌트 예시
// components/DeviceManager.tsx
import { useState, useEffect } from 'react';
import api from '../lib/axios';
export const DeviceManager = () => {
const [sessions, setSessions] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchSessions = async () => {
try {
setLoading(true);
const response = await api.get('/api/auth/devices');
setSessions(response.data.sessions);
} catch (err) {
setError('Failed to fetch sessions');
console.error(err);
} finally {
setLoading(false);
}
};
fetchSessions();
}, []);
const handleRevoke = async (sessionId) => {
try {
await api.delete('/api/auth/devices', { data: { sessionId } });
// 세션 목록에서 제거
setSessions(sessions.filter(session => session.id !== sessionId));
} catch (err) {
setError('Failed to revoke session');
console.error(err);
}
};
if (loading) return <div>Loading sessions...</div>;
if (error) return <div>Error: {error}</div>;
return (
<div>
<h2>Your Active Sessions</h2>
<ul>
{sessions.map(session => (
<li key={session.id}>
<div>Device: {session.deviceName}</div>
<div>Last active: {new Date(session.lastActive).toLocaleString()}</div>
<div>IP: {session.ipAddress}</div>
<button
onClick={() => handleRevoke(session.id)}
disabled={session.isCurrent} // 현재 세션은 취소 불가
>
{session.isCurrent ? 'Current Session' : 'Logout Device'}
</button>
</li>
))}
</ul>
</div>
);
};
6. 점진적 JWT 보안 향상
JWT 인증의 보안을 점진적으로 향상시키는 방법:
- Fingerprinting: 사용자 디바이스 정보를 기반으로 추가 검증
// services/fingerprintService.ts
import FingerprintJS from '@fingerprintjs/fingerprintjs';
// 디바이스 지문 생성
export const generateDeviceFingerprint = async () => {
const fp = await FingerprintJS.load();
const result = await fp.get();
return result.visitorId;
};
// 로그인 시 디바이스 지문 추가
export const enhanceTokenWithFingerprint = async (token) => {
const fingerprint = await generateDeviceFingerprint();
return `${token}.${fingerprint}`;
};
// 토큰 검증 시 지문 확인
export const verifyTokenWithFingerprint = async (enhancedToken) => {
const [token, savedFingerprint] = enhancedToken.split('.');
const currentFingerprint = await generateDeviceFingerprint();
if (savedFingerprint !== currentFingerprint) {
throw new Error('Device fingerprint mismatch');
}
return token; // 원래 토큰 반환
};
- 토큰 교체(Rotation): 일정 기간마다 리프레시 토큰도 교체
// pages/api/auth/refresh.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import cookie from 'cookie';
import { refreshToken, shouldRotateRefreshToken } from '../../../services/auth';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
// 쿠키에서 리프레시 토큰 추출
const refreshTokenCookie = req.cookies.refreshToken;
if (!refreshTokenCookie) {
return res.status(401).json({ message: 'Refresh token not found' });
}
try {
// 리프레시 토큰 교체 필요 여부 확인
const needsRotation = shouldRotateRefreshToken(refreshTokenCookie);
// 리프레시 토큰으로 새 액세스 토큰 발급
const { accessToken, newRefreshToken } = await refreshToken(
refreshTokenCookie,
needsRotation // true면 새 리프레시 토큰도 발급
);
// 새 리프레시 토큰이 발급된 경우 쿠키 업데이트
if (newRefreshToken) {
res.setHeader('Set-Cookie', cookie.serialize('refreshToken', newRefreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV !== 'development',
sameSite: 'strict',
maxAge: 60 * 60 * 24 * 30, // 30일
path: '/'
}));
}
// 새 액세스 토큰 반환
return res.status(200).json({
accessToken,
rotated: !!newRefreshToken
});
} catch (error: any) {
// 오류 처리
return res.status(401).json({ message: error.message || 'Token refresh failed' });
}
}
7. 최적화된 상태 관리 통합
JWT 인증을 React Query 또는 SWR과 같은 상태 관리 라이브러리와 통합하여 효율성을 높입니다:
// hooks/useAuth.ts (React Query 사용)
import { useQuery, useMutation, useQueryClient } from 'react-query';
import api from '../lib/axios';
import { useRouter } from 'next/router';
export const useAuth = () => {
const queryClient = useQueryClient();
const router = useRouter();
// 현재 사용자 정보 가져오기
const { data: user, isLoading, error } = useQuery(
'currentUser',
async () => {
try {
const response = await api.get('/api/auth/me');
return response.data;
} catch (err) {
// 401 오류는 정상적인 비로그인 상태로 처리
if (err.response?.status === 401) {
return null;
}
throw err;
}
},
{
staleTime: 5 * 60 * 1000, // 5분
cacheTime: 10 * 60 * 1000, // 10분
retry: false
}
);
// 로그인 뮤테이션
const loginMutation = useMutation(
async (credentials: { email: string; password: string }) => {
const response = await api.post('/api/auth/login', credentials);
return response.data;
},
{
onSuccess: (data) => {
// 액세스 토큰 저장
localStorage.setItem('accessToken', data.accessToken);
// 사용자 정보 캐시 업데이트
queryClient.setQueryData('currentUser', data.user);
// 리디렉션
if (router.query.redirectTo) {
router.push(router.query.redirectTo as string);
} else {
router.push('/dashboard');
}
}
}
);
// 로그아웃 뮤테이션
const logoutMutation = useMutation(
async () => {
return await api.post('/api/auth/logout');
},
{
onSuccess: () => {
// 액세스 토큰 제거
localStorage.removeItem('accessToken');
// 사용자 정보 캐시 초기화
queryClient.setQueryData('currentUser', null);
// 모든 쿼리 무효화
queryClient.invalidateQueries();
// 로그인 페이지로 리디렉션
router.push('/login');
}
}
);
return {
user,
isLoading,
error,
isAuthenticated: !!user,
login: loginMutation.mutate,
logout: logoutMutation.mutate,
loginLoading: loginMutation.isLoading,
logoutLoading: logoutMutation.isLoading
};
};
마무리
이 시리즈에서는 JWT 인증의 기본 개념부터 실제 구현까지 모든 과정을 자세히 살펴보았습니다. 클라이언트 측에서 JWT를 효과적으로 관리하는 다양한 전략과 보안 모범 사례를 통해 안전하고 사용자 친화적인 인증 시스템을 구축하는 방법을 알아보았습니다.
주요 내용 요약
- JWT의 기본 개념과 구조: JWT의 구성 요소(Header, Payload, Signature)와 작동 원리를 이해했습니다.
- 서버 측 JWT 구현: Node.js와 Express를 사용하여 JWT 인증 시스템을 구현하는 방법을 알아보았습니다.
- 클라이언트 측 JWT 관리: 다양한 JWT 저장 전략(localStorage, 쿠키, 메모리)과 각각의 장단점을 비교했습니다.
- React에서의 JWT 인증: Context API를 활용한 인증 상태 관리와 보호된 라우트 구현 방법을 살펴보았습니다.
- Next.js에서의 JWT 구현: Next.js의 서버 사이드 렌더링(SSR) 환경에서 JWT 인증을 효과적으로 구현하는 방법을 알아보았습니다.
- 토큰 자동 갱신: Axios 인터셉터를 활용한 토큰 자동 갱신 메커니즘을 구현했습니다.
- 모범 사례 및 보안 고려사항: JWT 인증 시스템을 구축할 때 고려해야 할 다양한 보안 모범 사례와 최적화 전략을 살펴보았습니다.
인증 시스템 확장하기
이 시리즈에서 배운 지식을 바탕으로 다음과 같은 방향으로 인증 시스템을 확장할 수 있습니다:
- 다중 인증(MFA/2FA): SMS, 이메일, 인증 앱 등을 사용한 2단계 인증을 추가하여 보안을 강화합니다.
- 소셜 로그인 통합: Google, Facebook, GitHub 등의 소셜 로그인을 JWT 인증 시스템과 통합합니다.
- 권한 기반 접근 제어(RBAC): 사용자 역할과 권한에 따른 세분화된 접근 제어 시스템을 구현합니다.
- 활동 로깅 및 감사: 사용자 활동을 로깅하고 의심스러운 활동을 감지하는 시스템을 구축합니다.
- 암호 관리 개선: 비밀번호 강도 요구 사항, 정기적인 비밀번호 변경 알림, 이전 비밀번호 재사용 방지 등의 기능을 추가합니다.
AI를 활용한 JWT 인증 개선
최신 AI 기술을 활용하여 JWT 인증 시스템을 더욱 강화할 수 있는 몇 가지 방법을 소개합니다:
- 이상 징후 감지: 머신러닝 모델을 사용하여 사용자의 로그인 패턴, 위치, 디바이스 정보 등을 분석하고 의심스러운 활동을 감지합니다.
- 적응형 인증: 사용자의 행동과 컨텍스트에 따라 인증 강도를 동적으로 조절합니다. 위험도가 낮은 상황에서는 간소화된 인증을, 위험도가 높은 상황에서는 강화된 인증을 요구할 수 있습니다.
- 개인화된 보안 추천: AI를 활용하여 각 사용자에게 맞춤형 보안 권장 사항을 제공합니다. 사용자의 행동 패턴과 보안 위험에 기반하여 더 강력한 비밀번호 사용, 2FA 활성화 등을 권장할 수 있습니다.
- 프로액티브 위협 대응: 알려진 보안 위협 및 취약점 데이터베이스를 AI로 분석하여 선제적으로 보안 대책을 마련합니다. 새로운 취약점이 발견되면 자동으로 관련 토큰을 무효화하고 사용자에게 알림을 보낼 수 있습니다.
최종 정리
JWT는 현대 웹 애플리케이션에서 인증을 구현하는 강력한 도구입니다. 올바르게 구현하면 확장성이 뛰어나고 사용자 친화적인 인증 시스템을 구축할 수 있습니다. 그러나 JWT는 만능 해결책이 아니며, 프로젝트의 요구 사항과 보안 고려 사항에 따라 적절한 인증 방식을 선택해야 합니다.
이 시리즈가 여러분의 JWT 인증 시스템 구현에 도움이 되었기를 바랍니다. 보안은 끊임없이 발전하는 분야이므로, 항상 최신 모범 사례와 보안 권장 사항을 따르는 것이 중요합니다.
다음 JWT 관련 주제로 다룰 수 있는 내용들은 다음과 같습니다:
- JWT와 소셜 로그인 통합하기
- 마이크로서비스 아키텍처에서의 JWT 활용
- JWT와 GraphQL API 보안
- 모바일 앱에서의 JWT 인증 구현
피드백과 질문을 댓글로 남겨주시면 감사하겠습니다. 다음 시리즈에서 더 유익한 내용으로 찾아뵙겠습니다!
참고 자료
'Study > Authentication' 카테고리의 다른 글
| JWT 인증 시리즈 4편: JWT 보안 강화 및 모범 사례 (1) | 2025.04.27 |
|---|---|
| JWT 인증 시리즈 2편: Node.js와 Express를 활용한 JWT 인증 구현 (0) | 2025.04.27 |
| JWT 인증 시리즈 1편: JWT 토큰 원리 (0) | 2025.04.27 |