[JWT 가이드] 개념부터 React 실무 구현까지 - Session 비교, 보안, TypeScript 예제 총정리

2025. 7. 21. 15:04·Frontend Development
728x90

프론트엔드 개발에서 사용자 인증은 거의 모든 프로젝트의 핵심 요구사항입니다. 전통적인 세션 기반 인증에서 토큰 기반 인증으로의 전환이 가속화되면서, JWT(JSON Web Token)는 현대 웹 애플리케이션의 표준 인증 방식으로 자리잡았습니다. 이 글에서는 JWT의 개념부터 실제 구현까지 체계적으로 정리하겠습니다.

목차

  1. JWT란 무엇인가?
  2. JWT의 구조와 동작 원리
  3. JWT vs Session 인증 방식 비교
  4. JWT의 장점과 단점
  5. 실제 구현 예시
  6. 보안 모범 사례
  7. 결론 및 실무 가이드

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: '로그아웃되었지만 토큰은 만료까지 유효' });
});

해결 방안:

  1. Access/Refresh Token 패턴

    const generateTokens = (payload) => ({
    accessToken: jwt.sign(payload, ACCESS_SECRET, { expiresIn: '15m' }),
    refreshToken: jwt.sign(payload, REFRESH_SECRET, { expiresIn: '7d' })
    });
  2. 블랙리스트 관리

    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의 장점을 최대한 활용하면서도 단점을 보완하는 방향으로 설계하시기 바랍니다.

728x90
저작자표시 비영리 변경금지 (새창열림)

'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
'Frontend Development' 카테고리의 다른 글
  • React Hooks 규칙: useState를 조건문에서 사용하면 안 되는 이유
  • Git branch 전략
  • 무한 스크롤 vs 페이지네이션, 내가 내린 선택과 후회
  • JavaScript의 this 바인딩: 상황별 동작 원리
Kun Woo Kim
Kun Woo Kim
안녕하세요, 김건우입니다! 웹과 앱 개발에 열정적인 전문가로, React, TypeScript, Next.js, Node.js, Express, Flutter 등을 활용한 프로젝트를 다룹니다. 제 블로그에서는 개발 여정, 기술 분석, 실용적 코딩 팁을 공유합니다. 창의적인 솔루션을 실제로 적용하는 과정의 통찰도 나눌 예정이니, 궁금한 점이나 상담은 언제든 환영합니다.
  • Kun Woo Kim
    WhiteMouseDev
    김건우
  • 깃허브
    포트폴리오
    velog
  • 전체
    오늘
    어제
  • 공지사항

    • [인사말] 이제 티스토리에서도 만나요! WhiteMouse⋯
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
    • 분류 전체보기 (114) N
      • Frontend Development (49) N
      • Backend Development (25)
      • Algorithm (33)
        • 백준 (11)
        • 프로그래머스 (17)
        • 알고리즘 (5)
      • Infra (1)
      • 자료구조 (3)
  • 링크

    • Github
    • Portfolio
    • Velog
  • 인기 글

  • 태그

    frontend development
    tailwindcss
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
Kun Woo Kim
[JWT 가이드] 개념부터 React 실무 구현까지 - Session 비교, 보안, TypeScript 예제 총정리
상단으로

티스토리툴바