Python FastAPI 입문(7/11)
Python/FastAPI

JWT 인증과 bcrypt 비밀번호 암호화

JWT가 무엇인지 이해하고, bcrypt로 비밀번호를 안전하게 저장한 뒤 로그인 API를 구현한다.

2026-04-10
11 min read
#Python#FastAPI#JWT#bcrypt#인증#보안

왜 인증이 필요한가

6편에서 만든 API는 누구나 호출할 수 있다. /users/1에 GET 요청을 보내면 인증 없이 그냥 응답이 온다.

실제 서비스라면 "이 사람이 로그인한 사람인가?" 를 확인해야 한다. 이를 인증(Authentication) 이라고 한다.

서버가 클라이언트를 인증하는 방법은 여러 가지가 있는데, 요즘 API에서 가장 많이 쓰는 방식이 JWT다.


JWT란?

JWT(JSON Web Token) 는 서버가 클라이언트에게 발급하는 서명된 토큰이다.

로그인 흐름을 살펴보면:

1. 클라이언트가 이메일/비밀번호로 로그인 요청
2. 서버가 검증 후 JWT 토큰을 발급
3. 클라이언트는 이후 요청마다 토큰을 헤더에 담아서 전송
4. 서버는 토큰을 검증해서 누구의 요청인지 확인

세션 방식과 달리 서버가 로그인 상태를 따로 저장하지 않아도 된다. 토큰 자체에 정보가 들어있기 때문이다.

JWT 구조

JWT는 .으로 구분된 3부분으로 이루어진다.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NSIsImV4cCI6MTcwMDAwMH0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
      Header                              Payload                                    Signature
부분역할예시
Header알고리즘 정보{ "alg": "HS256", "typ": "JWT" }
Payload실제 데이터{ "sub": "12345", "exp": 1700000000 }
Signature위변조 방지 서명Header + Payload를 SECRET_KEY로 서명

각 부분은 Base64URL로 인코딩되어 있다. Base64는 암호화가 아니라 인코딩이라서 Payload는 누구나 디코딩해서 볼 수 있다. 민감한 정보(비밀번호 등)는 넣으면 안 된다.

Signature는 서버만 아는 SECRET_KEY로 서명되어 있어서, 클라이언트가 Payload를 임의로 수정하면 서명이 맞지 않아 서버가 감지할 수 있다.


bcrypt란?

비밀번호를 DB에 그냥 평문으로 저장하면 DB가 털렸을 때 모든 사용자 비밀번호가 노출된다.

bcrypt는 비밀번호를 단방향 해시로 변환하는 알고리즘이다.

"password123"  →  "$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/lewFBcSPFJRQQ4Ke2"

특징:

  • 단방향 — 해시에서 원본 비밀번호를 복원할 수 없다
  • Salt 자동 포함 — 같은 비밀번호라도 매번 다른 해시가 나온다 (레인보우 테이블 공격 방어)
  • cost factor$2b$12$12가 연산 비용. 높을수록 느려지지만 브루트포스에 강해진다

SHA256 같은 일반 해시와 달리 bcrypt는 느리도록 설계되어 있어서 비밀번호 저장에 적합하다.


설치

pip install python-jose[cryptography] passlib[bcrypt]
  • python-jose — JWT 생성/검증
  • passlib — bcrypt 해싱 라이브러리

이번 편에서 변경하는 것

2편에서 소개한 core/ 폴더를 실제로 채우고, auth/ 도메인을 추가한다.

app/
├── main.py
├── core/                      ← 도메인 무관 공통 유틸
│   ├── config.py              ← 추가 (JWT 설정값)
│   ├── database.py            ← 기존 (DB 연결)
│   ├── hashing.py             ← 추가 (bcrypt)
│   └── jwt.py                 ← 추가 (JWT 생성/검증)
├── users/
│   ├── router.py              ← 수정 (/users/me 추가)
│   ├── service.py             ← 수정 (core/hashing 사용)
│   ├── repository.py
│   ├── models.py              ← 수정 (hashed_password 컬럼 추가)
│   └── schemas.py
└── auth/                      ← 추가 (인증 도메인)
    ├── router.py              ← 로그인 API
    ├── service.py             ← 로그인 비즈니스 로직
    ├── schemas.py             ← LoginRequest, TokenResponse
    └── dependencies.py        ← HTTPBearer 토큰 추출

hashing.pyjwt.py는 인증 외 다른 도메인에서도 쓸 수 있는 순수 유틸리티이므로 core/에 둔다. auth/service.py가 이 유틸을 이용해 실제 인증 로직을 처리한다.


core/config.py — JWT 설정값

# app/core/config.py
SECRET_KEY = "your-secret-key-change-this-in-production"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30

SECRET_KEY는 토큰 서명에 쓰이는 핵심 값이다. 실제 서비스에서는 환경변수로 관리하고, 절대 코드에 하드코딩하면 안 된다.


core/hashing.py — bcrypt 비밀번호 처리

# app/core/hashing.py
from passlib.context import CryptContext

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")

def hash_password(plain: str) -> str:
    return pwd_context.hash(plain)

def verify_password(plain: str, hashed: str) -> bool:
    return pwd_context.verify(plain, hashed)
  • hash_password("1234") → DB에 저장할 해시 문자열 반환
  • verify_password("1234", hashed) → 입력값과 해시가 일치하는지 검증

같은 "1234"를 두 번 해시해도 결과가 다르지만, verify는 내부적으로 Salt를 추출해 비교하므로 정상적으로 동작한다.


core/jwt.py — JWT 생성/검증

# app/core/jwt.py
from datetime import datetime, timedelta
from jose import jwt, JWTError
from fastapi import HTTPException
from app.core.config import SECRET_KEY, ALGORITHM, ACCESS_TOKEN_EXPIRE_MINUTES

def create_access_token(user_id: int) -> str:
    expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    payload = {
        "sub": str(user_id),  # subject — 보통 유저 ID
        "exp": expire,        # expiration — 만료 시각
    }
    return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)

def decode_token(token: str) -> int:
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        return int(payload["sub"])
    except JWTError:
        raise HTTPException(status_code=401, detail="유효하지 않은 토큰")

core/jwt.py는 JWT 자체의 생성/검증만 담당한다. "누가 로그인했는가"와 같은 비즈니스 로직은 auth/service.py에서 처리한다.


auth/schemas.py — 요청/응답 스키마

# app/auth/schemas.py
from pydantic import BaseModel

class LoginRequest(BaseModel):
    email: str
    password: str

class TokenResponse(BaseModel):
    access_token: str
    token_type: str = "bearer"

로그인 관련 스키마는 auth 도메인에 속하므로 auth/schemas.py에 둔다.


auth/service.py — 로그인 비즈니스 로직

# app/auth/service.py
from sqlalchemy.orm import Session
from app.users.models import User
from app.core.hashing import verify_password
from app.core.jwt import create_access_token

class AuthService:
    def login(self, db: Session, email: str, password: str) -> str:
        """이메일/비밀번호를 검증하고 액세스 토큰을 반환한다."""
        user = db.query(User).filter(User.email == email).first()
        if not user or not verify_password(password, user.hashed_password):
            return None
        return create_access_token(user.id)

def get_auth_service() -> AuthService:
    return AuthService()

AuthServicecore/hashingcore/jwt를 조합해 인증 흐름을 처리한다. DB 접근은 직접 하되, 유저 도메인의 모델(User)만 참조한다.


auth/dependencies.py — 토큰 추출 의존성

# app/auth/dependencies.py
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from fastapi import Depends
from app.core.jwt import decode_token

security = HTTPBearer()

def get_current_user_id(
    credentials: HTTPAuthorizationCredentials = Depends(security)
) -> int:
    return decode_token(credentials.credentials)

HTTPBearer는 요청 헤더에서 Authorization: Bearer <token> 형식의 토큰을 자동으로 추출해준다.


auth/router.py — 로그인 API

# app/auth/router.py
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.auth.schemas import LoginRequest, TokenResponse
from app.auth.service import AuthService, get_auth_service

router = APIRouter(prefix="/auth", tags=["auth"])

@router.post("/login", response_model=TokenResponse)
def login(
    body: LoginRequest,
    db: Session = Depends(get_db),
    service: AuthService = Depends(get_auth_service),
):
    token = service.login(db, body.email, body.password)
    if not token:
        raise HTTPException(status_code=401, detail="이메일 또는 비밀번호가 틀렸습니다")
    return TokenResponse(access_token=token)

로그인 실패 시 "이메일이 없다", "비밀번호가 틀렸다"를 구분하면 계정 존재 여부가 노출된다. 항상 같은 메시지를 반환하는 게 보안상 올바르다.


users/models.py 수정 — hashed_password 컬럼 추가

# app/users/models.py (기존 파일 수정)
from sqlalchemy import Column, Integer, String
from app.core.database import Base

class User(Base):
    __tablename__ = "users"

    id = Column(Integer, primary_key=True)
    name = Column(String, nullable=False)
    email = Column(String, unique=True, nullable=False)
    hashed_password = Column(String, nullable=False)  # 추가

users/service.py 수정 — 회원가입 시 비밀번호 해싱

# app/users/service.py (기존 파일 수정)
from app.users.repository import UserRepository
from app.users.schemas import UserCreate, UserResponse
from app.core.hashing import hash_password  # 추가

class UserService:
    def __init__(self, repo: UserRepository):
        self.repo = repo

    def create_user(self, data: UserCreate) -> UserResponse:
        if self.repo.find_by_email(data.email):
            raise ValueError("이미 존재하는 이메일입니다")
        hashed = hash_password(data.password)       # 추가
        return self.repo.save(data, hashed_password=hashed)

    def get_user(self, user_id: int) -> UserResponse:
        user = self.repo.find_by_id(user_id)
        if not user:
            raise ValueError(f"유저 {user_id}를 찾을 수 없습니다")
        return user

users/service.py는 유저 도메인의 비즈니스 로직만 담당한다. core/hashing은 가져다 쓰지만, 로그인 처리(authenticate)는 auth/service.py의 역할이다.


users/router.py 수정 — 인증된 엔드포인트 추가

# app/users/router.py (기존 파일 수정)
from fastapi import APIRouter, Depends
from app.auth.dependencies import get_current_user_id

router = APIRouter(prefix="/users", tags=["users"])

@router.get("/me")
def get_my_info(user_id: int = Depends(get_current_user_id)):
    return {"user_id": user_id, "message": "내 정보입니다"}

요청 흐름:

클라이언트                        서버
   │                               │
   │  GET /users/me                │
   │  Authorization: Bearer eyJ... ────►  HTTPBearer가 토큰 추출
   │                               │           ↓
   │                               │     core/jwt.decode_token() 검증
   │                               │           ↓
   │◄──────────────────────────────│     user_id를 라우터에 주입
   │  { "user_id": 1 }             │

main.py — 라우터 등록

# app/main.py
from fastapi import FastAPI
from app.users.router import router as user_router
from app.auth.router import router as auth_router

app = FastAPI()

app.include_router(user_router)
app.include_router(auth_router)

전체 흐름 정리

회원가입
  └─ POST /users
       └─ users/service.py → core/hashing.hash_password() → DB 저장

로그인
  └─ POST /auth/login
       └─ auth/service.py → core/hashing.verify_password() 검증
                          → core/jwt.create_access_token() → 토큰 발급

인증된 API 호출
  └─ GET /users/me
  └─ Authorization: Bearer <토큰>
       └─ auth/dependencies.py → core/jwt.decode_token() → user_id 추출
       └─ 라우터 실행

core/는 JWT와 bcrypt라는 순수 유틸리티를 제공하고, auth/는 그것을 조합해 인증 비즈니스 로직을 처리한다.