인증 (JWT)
JWT의 본질
JWT (JSON Web Token)란?
JWT는 두 시스템 간에 정보를 안전하게 전송하기 위한 자체 포함(self-contained) 토큰입니다.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
이 문자열은 .으로 구분된 세 부분으로 구성됩니다:
- Header:
{"alg": "HS256", "typ": "JWT"} - Payload:
{"sub": "1234567890", "name": "John Doe", "iat": 1516239022} - 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!"}
왜 강제 만료가 어려운가?
- Stateless 특성: 서버가 발급한 토큰을 추적하지 않음
- 자체 검증: 서명만 맞으면 유효한 토큰
- 분산 환경: 어느 서버든 토큰만으로 검증 가능
블랙리스트의 딜레마
# 블랙리스트 구현
token_blacklist = set() # 또는 Redis
async def logout(token: str = Depends(oauth2_scheme)):
token_blacklist.add(token) # 결국 상태를 저장하게 됨!
# Stateless의 장점을 포기하는 순간
FastAPI에서 JWT 구현
1. 최소 설정
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 고려
두 방식은 상호 배타적이지 않습니다. 요구사항에 따라 적절히 선택하거나 혼용하는 것이 현명한 접근입니다.