프론트엔드 개발에서 사용자 인증은 거의 모든 프로젝트의 핵심 요구사항입니다. 전통적인 세션 기반 인증에서 토큰 기반 인증으로의 전환이 가속화되면서, JWT(JSON Web Token)는 현대 웹 애플리케이션의 표준 인증 방식으로 자리잡았습니다. 이 글에서는 JWT의 개념부터 실제 구현까지 체계적으로 정리하겠습니다.
목차
JWT란 무엇인가?
JWT(JSON Web Token)는 당사자 간에 정보를 JSON 객체로 안전하게 전송하기 위한 컴팩트하고 자체 포함된(self-contained) 방식을 정의하는 개방형 표준(RFC 7519)입니다.
JWT가 필요한 이유
현대 웹 개발에서 JWT가 주목받는 이유는 다음과 같습니다:
- 분산 시스템 지원: 마이크로서비스 아키텍처에서 서비스 간 인증 정보 공유
- 모바일 친화적: 네이티브 앱에서 쉬운 토큰 관리
- 확장성: 서버 상태 관리 없이도 사용자 인증 처리
- 표준화: 업계 표준으로 다양한 플랫폼 지원
JWT의 구조와 동작 원리
JWT 토큰 구조
JWT는 점(.)으로 구분된 세 부분으로 구성됩니다:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
이를 분해하면:
- Header:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
- Payload:
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
- Signature:
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
1. Header (헤더)
토큰의 타입과 해싱 알고리즘을 지정합니다.
{
"alg": "HS256",
"typ": "JWT"
}
2. Payload (페이로드)
실제 전송할 데이터(클레임)를 포함합니다.
{
"sub": "1234567890",
"name": "John Doe",
"role": "admin",
"iat": 1516239022,
"exp": 1516242622
}
표준 클레임 (Registered Claims):
iss
(issuer): 토큰 발급자sub
(subject): 토큰 제목aud
(audience): 토큰 대상자exp
(expiration): 만료 시간iat
(issued at): 발급 시간jti
(JWT ID): JWT 고유 식별자
3. Signature (서명)
헤더와 페이로드의 무결성을 검증하는 서명입니다.
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret
)
JWT 동작 흐름
1. 클라이언트가 로그인 요청 (username, password)
↓
2. 서버가 사용자 인증 후 JWT 토큰 생성
↓
3. 서버가 JWT 토큰을 클라이언트에 전송
↓
4. 클라이언트가 토큰을 저장 (localStorage, 쿠키 등)
↓
5. 이후 API 요청 시 토큰을 Authorization 헤더에 포함
↓
6. 서버가 토큰을 검증하고 사용자 정보 추출
↓
7. 검증 성공 시 요청 처리, 실패 시 401 에러
JWT vs Session 인증 방식 비교
Session 기반 인증 방식
전통적인 세션 방식에서는 서버가 사용자 상태를 관리합니다.
동작 과정:
1. 사용자 로그인 → 서버에서 세션 생성
2. 세션 ID를 쿠키로 클라이언트에 전송
3. 클라이언트가 요청 시 세션 ID 포함
4. 서버가 세션 스토어에서 사용자 정보 조회
5. 인증 확인 후 요청 처리
구현 예시:
// 세션 기반 로그인
app.post('/login', (req, res) => {
// 사용자 인증 후
req.session.userId = user.id;
req.session.role = user.role;
res.json({ message: '로그인 성공' });
});
// 매 요청마다 세션 조회
app.get('/profile', (req, res) => {
const userId = req.session.userId; // 세션 스토어에서 조회
// 추가 DB 조회 필요
const user = await User.findById(userId);
res.json(user);
});
JWT 기반 인증 방식
JWT 방식에서는 클라이언트가 토큰을 보관하고 서버는 상태를 저장하지 않습니다.
동작 과정:
1. 사용자 로그인 → 서버에서 JWT 토큰 생성
2. JWT 토큰을 클라이언트에 전송
3. 클라이언트가 요청 시 JWT 토큰 포함
4. 서버가 토큰 서명을 검증하고 정보 추출
5. 추가 조회 없이 바로 요청 처리
구현 예시:
// JWT 기반 로그인
app.post('/login', (req, res) => {
const token = jwt.sign(
{ userId: user.id, role: user.role },
secret,
{ expiresIn: '1h' }
);
res.json({ token });
});
// 토큰에서 직접 정보 추출
app.get('/profile', authenticateToken, (req, res) => {
const userId = req.user.userId; // JWT에서 직접 추출
// DB 조회 없이 바로 사용 가능 (선택사항)
res.json({ userId, role: req.user.role });
});
상세 비교 분석
특성 | Session 방식 | JWT 방식 |
---|---|---|
상태 관리 | Stateful (서버에 상태 저장) | Stateless (상태 저장 안 함) |
저장 위치 | 서버 (메모리/DB/Redis) | 클라이언트 (브라우저) |
확장성 | 수직 확장 위주 | 수평 확장 유리 |
서버 부하 | 세션 저장소 I/O 부하 | CPU 부하 (서명 검증) |
네트워크 비용 | 낮음 (세션 ID만 전송) | 높음 (토큰 전체 전송) |
보안 제어 | 서버에서 완전 제어 | 토큰 만료까지 유효 |
로그아웃 | 즉시 무효화 가능 | 복잡함 (토큰 블랙리스트 필요) |
다중 서비스 | 세션 공유 복잡 | 토큰 공유 용이 |
모바일 지원 | 쿠키 의존적 | 토큰 기반으로 친화적 |
사용 시나리오별 권장사항
Session 방식 선택 기준
// 1. 높은 보안이 요구되는 서비스
app.post('/admin/force-logout', (req, res) => {
const { userId } = req.body;
sessionStore.destroy(userId); // 즉시 강제 로그아웃
res.json({ message: '사용자 로그아웃 완료' });
});
// 2. 전통적인 서버 렌더링 웹사이트
app.get('/dashboard', sessionAuth, (req, res) => {
const user = req.session.user;
res.render('dashboard', { user });
});
적합한 프로젝트:
- 금융 서비스, 관리자 시스템
- 모놀리식 아키텍처
- 서버 사이드 렌더링 위주
JWT 방식 선택 기준
// 1. SPA (Single Page Application)
const useAuth = () => {
const [user, setUser] = useState(null);
useEffect(() => {
const token = localStorage.getItem('token');
if (token) {
const payload = parseJWT(token);
setUser(payload);
}
}, []);
return { user };
};
// 2. 마이크로서비스 아키텍처
// 각 서비스가 동일한 토큰으로 사용자 인증 가능
const validateTokenMiddleware = (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1];
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
};
적합한 프로젝트:
- React, Vue, Angular 기반 SPA
- 모바일 애플리케이션
- 마이크로서비스 아키텍처
- RESTful API 서비스
JWT의 장점과 단점
주요 장점
1. Stateless 인증의 확장성
// 여러 서버 인스턴스가 있어도 상태 공유 불필요
const server1 = express();
const server2 = express();
// 두 서버 모두 동일한 JWT 검증 로직만 있으면 됨
const jwtMiddleware = (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1];
req.user = jwt.verify(token, SECRET_KEY);
next();
};
server1.use(jwtMiddleware);
server2.use(jwtMiddleware);
2. 자체 포함성 (Self-contained)
{
"userId": 123,
"username": "johndoe",
"role": "admin",
"permissions": ["read", "write", "delete"],
"department": "engineering",
"exp": 1642680000
}
토큰 자체에 필요한 정보가 포함되어 있어 추가 데이터베이스 조회 없이도 권한 검사가 가능합니다.
3. 크로스 도메인 지원
// 다른 도메인의 API 서버도 동일한 토큰으로 인증 가능
const apiCall = async () => {
const token = localStorage.getItem('token');
// 메인 서비스
await fetch('https://api.example.com/users', {
headers: { Authorization: `Bearer ${token}` }
});
// 결제 서비스
await fetch('https://payment.example.com/process', {
headers: { Authorization: `Bearer ${token}` }
});
};
주요 단점과 해결 방안
1. 토큰 무효화의 어려움
문제점:
// 사용자가 로그아웃해도 토큰이 여전히 유효
app.post('/logout', (req, res) => {
// JWT는 서버에서 강제 무효화할 방법이 없음
res.json({ message: '로그아웃되었지만 토큰은 만료까지 유효' });
});
해결 방안:
Access/Refresh Token 패턴
const generateTokens = (payload) => ({ accessToken: jwt.sign(payload, ACCESS_SECRET, { expiresIn: '15m' }), refreshToken: jwt.sign(payload, REFRESH_SECRET, { expiresIn: '7d' }) });
블랙리스트 관리
const blacklistedTokens = new Set();
app.post('/logout', (req, res) => {
const token = req.headers.authorization.split(' ')[1];
blacklistedTokens.add(token);
res.json({ message: '로그아웃 완료' });
});
const jwtMiddleware = (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1];
if (blacklistedTokens.has(token)) {
return res.status(401).json({ message: '무효한 토큰' });
}
// 토큰 검증...
};
#### 2. 토큰 크기 문제
**문제점:** JWT는 사용자 정보를 포함하므로 세션 ID(16-32 bytes)보다 훨씬 큽니다(수백 bytes).
**해결 방안:**
```javascript
// 최소한의 정보만 포함
const minimalPayload = {
userId: user.id,
role: user.role,
exp: Math.floor(Date.now() / 1000) + (60 * 60) // 1시간
};
// 상세 정보는 필요시 별도 조회
app.get('/profile', authenticateToken, async (req, res) => {
const userId = req.user.userId;
const userDetails = await User.findById(userId);
res.json(userDetails);
});
3. 보안 취약점
XSS 공격 위험:
// 위험: localStorage에 토큰 저장
localStorage.setItem('token', token);
// 더 안전: httpOnly 쿠키 사용
res.cookie('token', token, {
httpOnly: true,
secure: true,
sameSite: 'strict'
});
실제 구현 예시
1. 프론트엔드 구현 (React + TypeScript)
완전한 인증 시스템 Hook
// hooks/useAuth.ts
import { useState, useEffect, useCallback, useContext, createContext } from 'react';
interface User {
id: number;
username: string;
role: string;
}
interface AuthContextType {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (username: string, password: string) => Promise<void>;
logout: () => void;
refreshToken: () => Promise<boolean>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
// JWT 파싱 유틸리티
const parseJWT = (token: string): any => {
try {
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const jsonPayload = decodeURIComponent(
atob(base64)
.split('')
.map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
.join('')
);
return JSON.parse(jsonPayload);
} catch (error) {
console.error('JWT 파싱 실패:', error);
return null;
}
};
// 토큰 유효성 검사
const isTokenValid = (token: string): boolean => {
const payload = parseJWT(token);
if (!payload) return false;
const currentTime = Date.now() / 1000;
return payload.exp > currentTime;
};
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
// 토큰 갱신
const refreshToken = useCallback(async (): Promise<boolean> => {
try {
const refreshToken = localStorage.getItem('refreshToken');
if (!refreshToken) return false;
const response = await fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
});
if (!response.ok) throw new Error('토큰 갱신 실패');
const { accessToken, refreshToken: newRefreshToken } = await response.json();
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', newRefreshToken);
const payload = parseJWT(accessToken);
setUser({
id: payload.userId,
username: payload.username,
role: payload.role,
});
return true;
} catch (error) {
console.error('토큰 갱신 실패:', error);
logout();
return false;
}
}, []);
// 로그인
const login = useCallback(async (username: string, password: string) => {
setIsLoading(true);
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (!response.ok) {
throw new Error('로그인 실패');
}
const { accessToken, refreshToken } = await response.json();
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
const payload = parseJWT(accessToken);
setUser({
id: payload.userId,
username: payload.username,
role: payload.role,
});
} catch (error) {
console.error('로그인 에러:', error);
throw error;
} finally {
setIsLoading(false);
}
}, []);
// 로그아웃
const logout = useCallback(() => {
setUser(null);
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
}, []);
// 초기화 및 자동 토큰 검증
useEffect(() => {
const initializeAuth = async () => {
const token = localStorage.getItem('accessToken');
if (!token) {
setIsLoading(false);
return;
}
if (isTokenValid(token)) {
const payload = parseJWT(token);
setUser({
id: payload.userId,
username: payload.username,
role: payload.role,
});
} else {
// 토큰이 만료된 경우 갱신 시도
const refreshed = await refreshToken();
if (!refreshed) {
logout();
}
}
setIsLoading(false);
};
initializeAuth();
}, [refreshToken, logout]);
const value: AuthContextType = {
user,
isAuthenticated: !!user,
isLoading,
login,
logout,
refreshToken,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
// Hook 사용
export const useAuth = (): AuthContextType => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
API 클라이언트 설정 (Axios)
// api/client.ts
import axios, { AxiosRequestConfig, AxiosResponse } from 'axios';
// Axios 인스턴스 생성
const apiClient = axios.create({
baseURL: process.env.REACT_APP_API_URL || '/api',
timeout: 10000,
});
// 요청 인터셉터: 토큰 자동 첨부
axios.interceptors.request.use(
(config: AxiosRequestConfig) => {
const token = localStorage.getItem('accessToken');
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// 응답 인터셉터: 토큰 만료 시 자동 갱신
axios.interceptors.response.use(
(response: AxiosResponse) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
const refreshToken = localStorage.getItem('refreshToken');
if (!refreshToken) {
throw new Error('No refresh token');
}
const response = await axios.post('/api/auth/refresh', {
refreshToken,
});
const { accessToken } = response.data;
localStorage.setItem('accessToken', accessToken);
// 원래 요청 재시도
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
}
return axios(originalRequest);
} catch (refreshError) {
// 갱신 실패 시 로그아웃 처리
localStorage.clear();
window.location.href = '/login';
}
}
return Promise.reject(error);
}
);
export default apiClient;
보호된 라우트 컴포넌트
// components/ProtectedRoute.tsx
import React from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
import LoadingSpinner from './LoadingSpinner';
interface ProtectedRouteProps {
children: React.ReactNode;
requiredRole?: string;
}
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
children,
requiredRole
}) => {
const { isAuthenticated, isLoading, user } = useAuth();
const location = useLocation();
if (isLoading) {
return <LoadingSpinner />;
}
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
if (requiredRole && user?.role !== requiredRole) {
return <Navigate to="/unauthorized" replace />;
}
return <>{children}</>;
};
export default ProtectedRoute;
2. 백엔드 구현 (Node.js + Express + TypeScript)
JWT 유틸리티 클래스
// utils/JWTManager.ts
import jwt from 'jsonwebtoken';
import { promisify } from 'util';
interface TokenPayload {
userId: number;
username: string;
role: string;
}
interface TokenPair {
accessToken: string;
refreshToken: string;
}
class JWTManager {
private readonly accessTokenSecret: string;
private readonly refreshTokenSecret: string;
private readonly accessTokenExpiry: string;
private readonly refreshTokenExpiry: string;
constructor() {
this.accessTokenSecret = process.env.ACCESS_TOKEN_SECRET!;
this.refreshTokenSecret = process.env.REFRESH_TOKEN_SECRET!;
this.accessTokenExpiry = process.env.ACCESS_TOKEN_EXPIRY || '15m';
this.refreshTokenExpiry = process.env.REFRESH_TOKEN_EXPIRY || '7d';
if (!this.accessTokenSecret || !this.refreshTokenSecret) {
throw new Error('JWT secrets must be provided in environment variables');
}
}
// 토큰 쌍 생성
generateTokenPair(payload: TokenPayload): TokenPair {
const accessToken = jwt.sign(payload, this.accessTokenSecret, {
expiresIn: this.accessTokenExpiry,
issuer: 'your-app-name',
audience: 'your-app-users',
});
const refreshToken = jwt.sign(
{ userId: payload.userId },
this.refreshTokenSecret,
{
expiresIn: this.refreshTokenExpiry,
issuer: 'your-app-name',
audience: 'your-app-users',
}
);
return { accessToken, refreshToken };
}
// Access Token 검증
async verifyAccessToken(token: string): Promise<TokenPayload> {
try {
const decoded = await promisify(jwt.verify)(token, this.accessTokenSecret) as any;
return {
userId: decoded.userId,
username: decoded.username,
role: decoded.role,
};
} catch (error) {
throw new Error('유효하지 않은 액세스 토큰');
}
}
// Refresh Token 검증
async verifyRefreshToken(token: string): Promise<{ userId: number }> {
try {
const decoded = await promisify(jwt.verify)(token, this.refreshTokenSecret) as any;
return { userId: decoded.userId };
} catch (error) {
throw new Error('유효하지 않은 리프레시 토큰');
}
}
// 토큰에서 페이로드 추출 (검증 없이)
decodeToken(token: string): any {
try {
return jwt.decode(token);
} catch (error) {
return null;
}
}
}
export default new JWTManager();
인증 미들웨어
// middleware/auth.ts
import { Request, Response, NextFunction } from 'express';
import JWTManager from '../utils/JWTManager';
// 확장된 Request 타입
export interface AuthenticatedRequest extends Request {
user?: {
userId: number;
username: string;
role: string;
};
}
// 기본 인증 미들웨어
export const authenticateToken = async (
req: AuthenticatedRequest,
res: Response,
next: NextFunction
): Promise<void> => {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
res.status(401).json({
error: 'UNAUTHORIZED',
message: '인증 토큰이 필요합니다.'
});
return;
}
const token = authHeader.substring(7);
const user = await JWTManager.verifyAccessToken(token);
req.user = user;
next();
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
res.status(401).json({
error: 'TOKEN_EXPIRED',
message: '토큰이 만료되었습니다.'
});
return;
}
if (error instanceof jwt.JsonWebTokenError) {
res.status(401).json({
error: 'INVALID_TOKEN',
message: '유효하지 않은 토큰입니다.'
});
return;
}
res.status(500).json({
error: 'INTERNAL_ERROR',
message: '서버 오류가 발생했습니다.'
});
}
};
// 역할 기반 인증 미들웨어
export const requireRole = (requiredRole: string) => {
return (req: AuthenticatedRequest, res: Response, next: NextFunction): void => {
if (!req.user) {
res.status(401).json({ message: '인증이 필요합니다.' });
return;
}
if (req.user.role !== requiredRole) {
res.status(403).json({
message: `${requiredRole} 권한이 필요합니다.`
});
return;
}
next();
};
};
// 여러 역할 허용 미들웨어
export const requireAnyRole = (allowedRoles: string[]) => {
return (req: AuthenticatedRequest, res: Response, next: NextFunction): void => {
if (!req.user) {
res.status(401).json({ message: '인증이 필요합니다.' });
return;
}
if (!allowedRoles.includes(req.user.role)) {
res.status(403).json({
message: `다음 권한 중 하나가 필요합니다: ${allowedRoles.join(', ')}`
});
return;
}
next();
};
};
인증 컨트롤러
// controllers/authController.ts
import { Request, Response } from 'express';
import bcrypt from 'bcrypt';
import JWTManager from '../utils/JWTManager';
import { AuthenticatedRequest } from '../middleware/auth';
import User from '../models/User'; // 가정된 User 모델
class AuthController {
// 로그인
async login(req: Request, res: Response): Promise<void> {
try {
const { username, password } = req.body;
if (!username || !password) {
res.status(400).json({
error: 'MISSING_CREDENTIALS',
message: '사용자명과 비밀번호가 필요합니다.'
});
return;
}
// 사용자 조회
const user = await User.findOne({ username });
if (!user) {
res.status(401).json({
error: 'INVALID_CREDENTIALS',
message: '잘못된 사용자명 또는 비밀번호입니다.'
});
return;
}
// 비밀번호 검증
const isValidPassword = await bcrypt.compare(password, user.password);
if (!isValidPassword) {
res.status(401).json({
error: 'INVALID_CREDENTIALS',
message: '잘못된 사용자명 또는 비밀번호입니다.'
});
return;
}
// JWT 토큰 생성
const tokenPair = JWTManager.generateTokenPair({
userId: user.id,
username: user.username,
role: user.role,
});
// Refresh Token을 DB에 저장 (선택사항)
await User.updateOne(
{ _id: user.id },
{ refreshToken: tokenPair.refreshToken }
);
res.json({
message: '로그인 성공',
user: {
id: user.id,
username: user.username,
role: user.role,
},
...tokenPair,
});
} catch (error) {
console.error('로그인 에러:', error);
res.status(500).json({
error: 'INTERNAL_ERROR',
message: '서버 오류가 발생했습니다.'
});
}
}
// 토큰 갱신
async refreshToken(req: Request, res: Response): Promise<void> {
try {
const { refreshToken } = req.body;
if (!refreshToken) {
res.status(400).json({
error: 'MISSING_REFRESH_TOKEN',
message: '리프레시 토큰이 필요합니다.'
});
return;
}
// 리프레시 토큰 검증
const { userId } = await JWTManager.verifyRefreshToken(refreshToken);
// 사용자 조회 및 리프레시 토큰 확인
const user = await User.findById(userId);
if (!user || user.refreshToken !== refreshToken) {
res.status(401).json({
error: 'INVALID_REFRESH_TOKEN',
message: '유효하지 않은 리프레시 토큰입니다.'
});
return;
}
// 새로운 토큰 쌍 생성
const newTokenPair = JWTManager.generateTokenPair({
userId: user.id,
username: user.username,
role: user.role,
});
// 새로운 리프레시 토큰 저장
await User.updateOne(
{ _id: user.id },
{ refreshToken: newTokenPair.refreshToken }
);
res.json({
message: '토큰 갱신 성공',
...newTokenPair,
});
} catch (error) {
console.error('토큰 갱신 에러:', error);
res.status(401).json({
error: 'TOKEN_REFRESH_FAILED',
message: '토큰 갱신에 실패했습니다.'
});
}
}
// 로그아웃
async logout(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
if (!req.user) {
res.status(401).json({ message: '인증이 필요합니다.' });
return;
}
// DB에서 리프레시 토큰 제거
await User.updateOne(
{ _id: req.user.userId },
{ $unset: { refreshToken: 1 } }
);
res.json({ message: '로그아웃 성공' });
} catch (error) {
console.error('로그아웃 에러:', error);
res.status(500).json({
error: 'INTERNAL_ERROR',
message: '서버 오류가 발생했습니다.'
});
}
}
// 현재 사용자 정보 조회
async getProfile(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
if (!req.user) {
res.status(401).json({ message: '인증이 필요합니다.' });
return;
}
const user = await User.findById(req.user.userId).select('-password -refreshToken');
res.json({
user: {
id: user.id,
username: user.username,
email: user.email,
role: user.role,
createdAt: user.createdAt,
},
});
} catch (error) {
console.error('프로필 조회 에러:', error);
res.status(500).json({
error: 'INTERNAL_ERROR',
message: '서버 오류가 발생했습니다.'
});
}
}
}
export default new AuthController();
라우터 설정
// routes/auth.ts
import { Router } from 'express';
import AuthController from '../controllers/authController';
import { authenticateToken } from '../middleware/auth';
import { validateLogin } from '../middleware/validation';
const router = Router();
// 공개 라우트
router.post('/login', validateLogin, AuthController.login);
router.post('/refresh', AuthController.refreshToken);
// 보호된 라우트
router.post('/logout', authenticateToken, AuthController.logout);
router.get('/profile', authenticateToken, AuthController.getProfile);
export default router;
보안 모범 사례
1. 토큰 저장 방식 선택
httpOnly 쿠키 (권장)
// 백엔드: 안전한 쿠키 설정
const setSecureTokenCookie = (res: Response, token: string, name: string) => {
res.cookie(name, token, {
httpOnly: true, // JavaScript 접근 차단 (XSS 방지)
secure: process.env.NODE_ENV === 'production', // HTTPS에서만 전송
sameSite: 'strict', // CSRF 공격 방지
maxAge: 15 * 60 * 1000, // 15분
path: '/', // 쿠키 경로
});
};
// 로그인 시 쿠키 설정
app.post('/api/auth/login', async (req, res) => {
// 인증 로직...
const { accessToken, refreshToken } = generateTokens(user);
setSecureTokenCookie(res, accessToken, 'accessToken');
setSecureTokenCookie(res, refreshToken, 'refreshToken');
res.json({ message: '로그인 성공', user });
});
// 프론트엔드: 쿠키에서 토큰 읽기
const getTokenFromCookie = (name: string): string | null => {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) {
return parts.pop()?.split(';').shift() || null;
}
return null;
};
// API 요청 시 쿠키 자동 포함
const apiRequest = async (url: string, options: RequestInit = {}) => {
return fetch(url, {
...options,
credentials: 'include', // 쿠키 포함
headers: {
'Content-Type': 'application/json',
...options.headers,
},
});
};
localStorage 사용 시 주의사항
// XSS 공격 방지를 위한 추가 보안 조치
const SecureTokenManager = {
// 토큰 저장 전 검증
setToken: (token: string) => {
if (!token || typeof token !== 'string') {
throw new Error('유효하지 않은 토큰');
}
// 토큰 형식 검증
const parts = token.split('.');
if (parts.length !== 3) {
throw new Error('JWT 형식이 올바르지 않습니다');
}
localStorage.setItem('accessToken', token);
},
// 토큰 조회 시 검증
getToken: (): string | null => {
const token = localStorage.getItem('accessToken');
if (!token) return null;
// 기본적인 형식 검증
const parts = token.split('.');
if (parts.length !== 3) {
localStorage.removeItem('accessToken');
return null;
}
return token;
},
// 토큰 제거
removeToken: () => {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
},
};
2. CSRF 공격 방지
// CSRF 토큰 생성 및 검증
import crypto from 'crypto';
class CSRFProtection {
private static tokens = new Map<string, number>();
static generateToken(sessionId: string): string {
const token = crypto.randomBytes(32).toString('hex');
this.tokens.set(token, Date.now());
return token;
}
static validateToken(token: string): boolean {
const timestamp = this.tokens.get(token);
if (!timestamp) return false;
// 토큰 유효 시간 (5분)
const VALID_DURATION = 5 * 60 * 1000;
if (Date.now() - timestamp > VALID_DURATION) {
this.tokens.delete(token);
return false;
}
return true;
}
static middleware = (req: Request, res: Response, next: NextFunction) => {
if (req.method === 'GET') {
next();
return;
}
const csrfToken = req.headers['x-csrf-token'] as string;
if (!csrfToken || !CSRFProtection.validateToken(csrfToken)) {
res.status(403).json({ message: 'CSRF 토큰이 유효하지 않습니다.' });
return;
}
next();
};
}
3. 토큰 생명주기 관리
// 토큰 갱신 전략
class TokenManager {
private static readonly REFRESH_THRESHOLD = 5 * 60 * 1000; // 5분
// 토큰 만료 시간까지 남은 시간 계산
static getTimeUntilExpiry(token: string): number {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
const expiryTime = payload.exp * 1000;
return expiryTime - Date.now();
} catch {
return 0;
}
}
// 자동 갱신이 필요한지 확인
static shouldRefresh(token: string): boolean {
const timeUntilExpiry = this.getTimeUntilExpiry(token);
return timeUntilExpiry > 0 && timeUntilExpiry < this.REFRESH_THRESHOLD;
}
// 백그라운드 토큰 갱신
static startAutoRefresh(refreshCallback: () => Promise<void>) {
const interval = setInterval(async () => {
const token = localStorage.getItem('accessToken');
if (!token) {
clearInterval(interval);
return;
}
if (this.shouldRefresh(token)) {
try {
await refreshCallback();
} catch (error) {
console.error('자동 토큰 갱신 실패:', error);
clearInterval(interval);
}
}
}, 60 * 1000); // 1분마다 체크
return interval;
}
}
4. 환경별 보안 설정
// 환경별 설정
const getSecurityConfig = () => {
const isProduction = process.env.NODE_ENV === 'production';
return {
// JWT 설정
jwt: {
accessTokenExpiry: isProduction ? '15m' : '1h',
refreshTokenExpiry: isProduction ? '7d' : '30d',
algorithm: 'HS256',
issuer: process.env.JWT_ISSUER || 'your-app',
audience: process.env.JWT_AUDIENCE || 'your-app-users',
},
// 쿠키 설정
cookie: {
secure: isProduction,
sameSite: isProduction ? 'strict' : 'lax',
domain: isProduction ? process.env.COOKIE_DOMAIN : undefined,
},
// CORS 설정
cors: {
origin: isProduction
? process.env.ALLOWED_ORIGINS?.split(',')
: ['http://localhost:3000'],
credentials: true,
},
// Rate Limiting
rateLimit: {
windowMs: 15 * 60 * 1000, // 15분
max: isProduction ? 100 : 1000, // 프로덕션에서 더 엄격하게
},
};
};
결론 및 실무 가이드
JWT는 현대 웹 애플리케이션의 핵심 인증 방식으로 자리잡았습니다. 하지만 올바른 이해와 구현이 전제되어야 안전하고 효율적인 시스템을 구축할 수 있습니다.
핵심 요약
1. JWT의 핵심 개념
- Self-contained: 토큰 자체에 필요한 정보 포함
- Stateless: 서버에서 상태 관리 불필요
- Portable: 다양한 플랫폼과 서비스에서 공유 가능
2. 언제 JWT를 선택해야 하는가?
- ✅ SPA (Single Page Application) 개발
- ✅ 마이크로서비스 아키텍처
- ✅ 모바일 애플리케이션
- ✅ API 기반 서비스
- ❌ 전통적인 서버 렌더링 웹사이트
- ❌ 매우 높은 보안이 요구되는 금융 서비스
3. 보안 모범 사례
- 토큰 저장: httpOnly 쿠키 사용 권장
- HTTPS 필수: 토큰 전송 시 반드시 사용
- 최소 권한 원칙: 토큰에 필요한 최소한의 정보만 포함
- 정기적 갱신: Access/Refresh Token 패턴 활용
실무 적용 로드맵
1단계: 기본 구현 (소규모 프로젝트)
// 단순한 JWT 구현
const token = jwt.sign({ userId: user.id }, secret, { expiresIn: '1h' });
2단계: 갱신 메커니즘 추가 (중간 규모)
// Access/Refresh Token 패턴
const tokens = {
accessToken: jwt.sign(payload, accessSecret, { expiresIn: '15m' }),
refreshToken: jwt.sign({ userId }, refreshSecret, { expiresIn: '7d' })
};
3단계: 고급 보안 기능 (대규모 서비스)
- 토큰 블랙리스트 관리
- CSRF 보호
- Rate limiting
- 로그 및 모니터링
흔한 실수와 해결책
실수 | 문제점 | 해결책 |
---|---|---|
민감 정보 포함 | 보안 위험 | 최소한의 정보만 포함 |
긴 만료 시간 | 토큰 탈취 위험 | 15분 이하 권장 |
localStorage 사용 | XSS 취약 | httpOnly 쿠키 사용 |
갱신 로직 부재 | UX 저하 | 자동 갱신 구현 |
에러 처리 부족 | 디버깅 어려움 | 상세한 에러 분류 |
마무리
JWT는 올바르게 구현하면 확장 가능하고 안전한 인증 시스템의 기반이 됩니다. 하지만 프로젝트의 특성과 요구사항을 면밀히 분석하여 적절한 구현 방식을 선택하는 것이 중요합니다.
특히 보안은 구현 단계에서부터 고려되어야 하며, 운영 중에도 지속적인 모니터링과 개선이 필요합니다. JWT의 장점을 최대한 활용하면서도 단점을 보완하는 방향으로 설계하시기 바랍니다.
'Frontend Development' 카테고리의 다른 글
React Hooks 규칙: useState를 조건문에서 사용하면 안 되는 이유 (2) | 2025.07.18 |
---|---|
Git branch 전략 (0) | 2025.07.14 |
무한 스크롤 vs 페이지네이션, 내가 내린 선택과 후회 (0) | 2025.07.07 |
JavaScript의 this 바인딩: 상황별 동작 원리 (1) | 2025.07.07 |
웹 성능 최적화의 핵심: preconnect, preload, prefetch 가이드 (1) | 2025.07.04 |