Skip to content

인증 (JWT)

JWT의 본질

JWT (JSON Web Token)란?

JWT는 두 시스템 간에 정보를 안전하게 전송하기 위한 자체 포함(self-contained) 토큰입니다.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

이 문자열은 .으로 구분된 세 부분으로 구성됩니다:

  1. Header: {"alg": "HS256", "typ": "JWT"}
  2. Payload: {"sub": "1234567890", "name": "John Doe", "iat": 1516239022}
  3. Signature: HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)

핵심: 정보를 담고 있는 토큰

JWT의 가장 중요한 특징은 토큰 자체에 정보가 포함되어 있다는 점입니다:

import jwt
import base64
import json

# JWT 토큰 예시
token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxMjMsInVzZXJuYW1lIjoiam9obiIsInJvbGUiOiJhZG1pbiIsImV4cCI6MTcwNDIzOTAyMn0.signature"

# Payload 디코딩 (서명 검증 없이)
payload = token.split('.')[1]
# Base64 패딩 추가
payload += '=' * (4 - len(payload) % 4)
decoded = json.loads(base64.urlsafe_b64decode(payload))

print(decoded)
# {'user_id': 123, 'username': 'john', 'role': 'admin', 'exp': 1704239022}

세션 vs JWT: 근본적 차이

작동 방식 비교

세션 기반 인증

1. 로그인 → 서버가 세션 생성 (user_id: 123, username: john...)
2. 클라이언트에 세션 ID만 전달 (예: session_id=abc123)
3. 요청 시 세션 ID로 서버에서 정보 조회
4. 로그아웃 → 서버에서 세션 삭제 → 즉시 무효화

JWT 기반 인증

1. 로그인 → 서버가 사용자 정보를 토큰에 포함하여 서명
2. 클라이언트에 전체 토큰 전달 (사용자 정보 + 서명)
3. 요청 시 토큰 자체에서 정보 추출 (DB 조회 불필요)
4. 로그아웃 → 토큰은 만료까지 유효 (서버가 통제 불가)

핵심 차이점

특성 세션 JWT
저장 내용 단순 식별자 (키) 실제 데이터
정보 위치 서버 메모리/DB 토큰 자체
크기 작음 (~32바이트) 큼 (~200-500바이트)
서버 조회 매 요청마다 필요 불필요
무효화 즉시 가능 불가능 (만료 대기)
변조 가능성 키만으로는 의미 없음 서명으로 방지

시각적 이해

세션 쿠키: session_id=abc123def456
      서버에서 조회
    실제 데이터 획득

JWT: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxMjMsInVzZXJuYW1lIjoiam9obiJ9.signature
     ↓ (디코딩)
     {
       "user_id": 123,
       "username": "john",
       "role": "admin",
       "exp": 1704239022
     }

JWT의 한계: 강제 만료의 어려움

문제 상황

# 사용자가 로그아웃을 원함
@app.post("/logout")
async def logout(current_user: User = Depends(get_current_user)):
    # 세션이라면: session.delete() → 즉시 무효화
    # JWT라면: ??? → 토큰은 여전히 유효함!
    return {"message": "Logged out... but your token is still valid!"}

왜 강제 만료가 어려운가?

  1. Stateless 특성: 서버가 발급한 토큰을 추적하지 않음
  2. 자체 검증: 서명만 맞으면 유효한 토큰
  3. 분산 환경: 어느 서버든 토큰만으로 검증 가능

블랙리스트의 딜레마

# 블랙리스트 구현
token_blacklist = set()  # 또는 Redis

async def logout(token: str = Depends(oauth2_scheme)):
    token_blacklist.add(token)  # 결국 상태를 저장하게 됨!
    # Stateless의 장점을 포기하는 순간

FastAPI에서 JWT 구현

1. 최소 설정

pip install python-jose[cryptography] passlib[bcrypt]
from datetime import datetime, timedelta
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer

# 핵심 설정
SECRET_KEY = "your-secret-key-must-be-strong"  # 32+ 문자 권장
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30

app = FastAPI()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")

2. 토큰 생성과 검증

def create_access_token(data: dict):
    """사용자 정보를 포함한 JWT 생성"""
    to_encode = data.copy()
    expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)

    # 표준 클레임 추가
    to_encode.update({
        "exp": expire,  # 만료 시간
        "iat": datetime.utcnow(),  # 발급 시간
        "jti": str(uuid.uuid4())  # 토큰 ID (선택적)
    })

    # 토큰 생성 (정보 + 서명)
    encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
    return encoded_jwt

async def verify_token(token: str = Depends(oauth2_scheme)):
    """토큰 검증 및 정보 추출"""
    try:
        # 서명 검증 + 만료 확인
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        user_id: int = payload.get("user_id")
        if user_id is None:
            raise HTTPException(status_code=401, detail="Invalid token")
        return payload
    except JWTError:
        raise HTTPException(status_code=401, detail="Invalid token")

3. 실제 사용

from pydantic import BaseModel

class UserLogin(BaseModel):
    username: str
    password: str

class User(BaseModel):
    id: int
    username: str
    role: str = "user"

@app.post("/token")
async def login(user_data: UserLogin):
    """로그인 - JWT 발급"""
    # 실제로는 DB에서 확인
    if user_data.username != "test" or user_data.password != "password":
        raise HTTPException(status_code=401, detail="Invalid credentials")

    # JWT에 포함할 정보 (민감정보 제외!)
    token_data = {
        "user_id": 123,
        "username": user_data.username,
        "role": "admin"
    }

    access_token = create_access_token(token_data)
    return {"access_token": access_token, "token_type": "bearer"}

@app.get("/protected")
async def protected_route(token_data: dict = Depends(verify_token)):
    """보호된 엔드포인트"""
    return {
        "message": f"Hello {token_data['username']}!",
        "your_data": token_data
    }

실무에서의 JWT

1. 짧은 만료 + Refresh Token

# Access Token: 15분, Refresh Token: 7일
ACCESS_TOKEN_EXPIRE_MINUTES = 15
REFRESH_TOKEN_EXPIRE_DAYS = 7

@app.post("/token")
async def login_with_refresh(user_data: UserLogin):
    # ... 인증 로직 ...

    access_token = create_access_token(
        data={"user_id": user.id, "username": user.username},
        expires_delta=timedelta(minutes=15)
    )

    refresh_token = create_access_token(
        data={"user_id": user.id, "type": "refresh"},
        expires_delta=timedelta(days=7)
    )

    return {
        "access_token": access_token,
        "refresh_token": refresh_token,
        "token_type": "bearer"
    }

2. 클라이언트 구현

class TokenManager {
    constructor() {
        this.accessToken = null;
        this.refreshToken = null;
    }

    async login(username, password) {
        const response = await fetch('/token', {
            method: 'POST',
            headers: {'Content-Type': 'application/json'},
            body: JSON.stringify({username, password})
        });

        const data = await response.json();
        this.accessToken = data.access_token;
        this.refreshToken = data.refresh_token;

        // 토큰 자동 갱신 설정 (만료 1분 전)
        this.scheduleRefresh();
    }

    async apiCall(url) {
        const response = await fetch(url, {
            headers: {
                'Authorization': `Bearer ${this.accessToken}`
            }
        });

        if (response.status === 401) {
            await this.refreshAccessToken();
            // 재시도
            return fetch(url, {
                headers: {
                    'Authorization': `Bearer ${this.accessToken}`
                }
            });
        }

        return response;
    }
}

3. 보안 강화

# 1. 강력한 비밀키
import secrets
SECRET_KEY = secrets.token_urlsafe(32)

# 2. 환경별 설정
from pydantic import BaseSettings

class Settings(BaseSettings):
    jwt_secret_key: str
    jwt_algorithm: str = "HS256"
    access_token_expire_minutes: int = 30

    class Config:
        env_file = ".env"

# 3. 추가 검증
async def get_current_user(token: str = Depends(oauth2_scheme)):
    payload = verify_token(token)

    # 추가 검증 (예: 사용자 활성 상태)
    user = await get_user_from_db(payload["user_id"])
    if not user or not user.is_active:
        raise HTTPException(status_code=401)

    return user

JWT 사용 결정 가이드

JWT를 선택해야 할 때

모바일 앱 API - 쿠키 사용 불가 - 토큰을 안전하게 저장 가능 (Keychain, Keystore)

마이크로서비스 - 서비스 간 세션 공유 불필요 - 각 서비스가 독립적으로 검증

서버리스/람다 - 상태 저장 불가능한 환경 - 요청별 독립 실행

공개 API - 제3자 개발자 대상 - 간단한 인증 체계 필요

세션을 선택해야 할 때

웹 애플리케이션 - 즉시 로그아웃 필요 - 복잡한 권한 관리

관리자 패널 - 세밀한 접근 제어 - 실시간 권한 변경

금융/의료 서비스 - 강제 세션 종료 필수 - 감사 추적 중요

하이브리드 접근

# 웹: 세션, 모바일: JWT
@app.post("/auth/web/login")
async def web_login(request: Request, credentials: UserLogin):
    # 세션 기반 인증
    request.session["user_id"] = user.id
    return {"message": "Logged in"}

@app.post("/auth/mobile/login")
async def mobile_login(credentials: UserLogin):
    # JWT 기반 인증
    token = create_access_token({"user_id": user.id})
    return {"access_token": token}

결론

JWT는 정보를 담은 자체 검증 토큰입니다. 서버가 상태를 관리하지 않아도 되는 장점이 있지만, 한번 발급하면 강제로 무효화할 수 없다는 치명적인 단점이 있습니다.

핵심 질문: "사용자가 로그아웃했을 때 즉시 접근을 막아야 하는가?" - Yes → 세션 사용 - No → JWT 고려

두 방식은 상호 배타적이지 않습니다. 요구사항에 따라 적절히 선택하거나 혼용하는 것이 현명한 접근입니다.

Comments