FastAPI | JWT 인증 시스템 구현하기

Faithful Dev·2025년 6월 11일
0

셀파트너랩스

목록 보기
2/6

오늘은 JWT 기반 사용자 인증 시스템 구현하기.
이전에는 Python에 Django 썼어서, JWT 기반은 Java에서만 써봤더랬다.

오늘 구현한 기능들

JWT 인증 시스템

  • 회원가입/로그인: 사용자 계정 관리
  • JWT 토큰: 상태를 저장하지 않는 토큰 기반 인증
  • 비밀번호 해싱: bcrypt로 비밀번호 저장
  • 라우트: 인증이 필요한 API 엔드포인트

프로젝트 구조 변경

Before

todo_api/
├── main.py        # 모든 API가 한 파일에
├── database.py
├── models.py
└── schemas.py

After

todo_api/
├── main.py        # 앱 설정만
├── database.py
├── models.py      # User 모델 추가
├── schemas.py     # User, Token 스키마 추가
├── auth.py        # JWT 관련 로직
├── dependencies.py # 의존성 주입
└── routers/
    ├── auth.py    # 인증 관련 API
    └── todos.py   # 할일 관련 API

학습 내용

JWT의 동작 원리

전통적인 세션 방식 vs jWT

# 세션 방식 (서버에 상태 저장)
login → 서버가 세션 ID 생성 → 메모리/DB에 저장 → 쿠키로 전달

# JWT 방식 (상태를 저장하지 않음)
login → 서버가 JWT 토큰 생성 → 클라이언트가 저장 → 매 요청마다 헤더에 포함

JWT 토큰 구조

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyIiwiZXhwIjoxNjM5...
헤더.페이로드.서명

FastAPI 의존성 주입

Java Spring과 비교

# FastAPI
@app.get("/todos")
def get_todos(current_user = Depends(get_current_user)):
    return current_user.todos
// Java Spring
@GetMapping("/todos")
public List<Todo> getTodos(@AuthenticationPrincipal User user) {
    return user.getTodos();
}

의존성 체인

get_current_user → oauth2_scheme → token 검증 → DB 조회 → User 객체 반환

데이터베이스 관계 모델링

# User : Todo = 1 : N 관계
class User(Base):
    todos = relationship("Todo", back_populates="owner")

class Todo(Base):
    owner_id = Column(Integer, ForeignKey("users.id"))
    owner = relationship("User", back_populates="todos")

마주친 문제들과 해결 과정

순환 Import 문제

문제: main.py ↔︎ routers 간 순환 참조

어제 router 설정하기 전에 get_db 함수를 main.py 안에 뒀었는데,
라우터를 오늘 설정하면서 순환 참조 문제가 발생했다.

# main.py에서 routers import
from routers import auth, todos

# routers에서 main.py의 get_db import
from main import get_db  # ❌ 순환 import

해결: 공통 모듈로 분리

# database.py로 get_db 이동
def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

SQLAlchemy 관계 설정 에러

예러 메시지

Mapper 'Todo' has no property 'user'

원인: back_populates 불일치

# ❌ 잘못된 설정
class User(Base):
    todos = relationship("Todo", back_populates="user")  # "user"

class Todo(Base):
    user = relationship("User", back_populates="todos")  # 실제 속성명

해결: 의미적으로 명확한 관계명 사용

back_populates의 역할을 잘 몰라서 발생한 오류.
다른 클래스에서 해당 관계를 반영하는 속성의 이름을 정확하게 적어줘야 한다.

# ✅ 올바른 설정
class User(Base):
    todos = relationship("Todo", back_populates="owner")

class Todo(Base):
    owner = relationship("User", back_populates="todos")

JWT 토큰 생성 실패

문제: access_tokenNone으로 반환

찾아도 찾아도 안찾아져서 고생을 깨나했는데...
들여쓰기 문제였다..
지옥의 Python 다시 만났다, 들여쓰기 문제.

# ❌ 들여쓰기 문제
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
    if expires_delta:
        expire = datetime.utcnow() + expires_delta
    else:
        expire = datetime.utcnow() + timedelta(minutes=30)
        to_encode.update({"exp": expire})  # else 블록에만 있음!

해결: 들여쓰기

# ✅ 수정된 버전
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
    to_encode = data.copy()
    
    if expires_delta:
        expire = datetime.utcnow() + expires_delta
    else:
        expire = datetime.utcnow() + timedelta(minutes=30)
    
    to_encode.update({"exp": expire})  # 항상 실행
    return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)

Swagger UI 인증 경로 문제

문제: "Auth Error: Not Found"

# dependencies.py
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login")  # ❌ 잘못된 경로

실제 엔드포인트: /auth/login
Swagger 요청에 사용한 경로: /login

해결:

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login")  # ✅ 올바른 경로

인증 시스템 비교: Java JWT vs Django vs FastAPI

이전에 Java JWT와 Django 인증 시스템을 사용해본 작은 경험으로 비교해보기.

설정 복잡도

Java Spring Security + JWT

// SecurityConfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    
    @Bean
    public JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint() {
        return new JwtAuthenticationEntryPoint();
    }
    
    @Bean
    public JwtRequestFilter jwtRequestFilter() {
        return new JwtRequestFilter();
    }
    
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
    
    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
        httpSecurity.csrf().disable()
            .authorizeRequests()
            .antMatchers("/auth/**").permitAll()
            .anyRequest().authenticated()
            .and()
            .exceptionHandling().authenticationEntryPoint(jwtAuthenticationEntryPoint)
            .and()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
            
        httpSecurity.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class);
    }
}

// JwtUtil.java (별도 유틸리티 클래스 필요)
@Component
public class JwtUtil {
    private String secret = "secret";
    
    public String generateToken(UserDetails userDetails) {
        Map<String, Object> claims = new HashMap<>();
        return createToken(claims, userDetails.getUsername());
    }
    // ... 더 많은 보일러플레이트 코드
}

Django REST Framework

# settings.py
INSTALLED_APPS = [
    'rest_framework',
    'rest_framework.authtoken',  # 또는 rest_framework_simplejwt
]

REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': [
        'rest_framework_simplejwt.authentication.JWTAuthentication',
    ],
    'DEFAULT_PERMISSION_CLASSES': [
        'rest_framework.permissions.IsAuthenticated',
    ],
}

# urls.py
from rest_framework_simplejwt.views import TokenObtainPairView

urlpatterns = [
    path('api/token/', TokenObtainPairView.as_view()),
]

# views.py
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated

@api_view(['GET'])
@permission_classes([IsAuthenticated])
def protected_view(request):
    return Response({'user': request.user.username})

FastAPI

# auth.py (핵심 로직만)
SECRET_KEY = "secret"
ALGORITHM = "HS256"

def create_access_token(data: dict):
    to_encode = data.copy()
    expire = datetime.utcnow() + timedelta(minutes=30)
    to_encode.update({"exp": expire})
    return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)

# dependencies.py
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login")

async def get_current_user(token: str = Depends(oauth2_scheme)):
    # 간단한 의존성 주입
    return verify_and_get_user(token)

# main.py
@app.get("/protected")
def protected_route(user = Depends(get_current_user)):
    return {"user": user.username}

코드 가독성 및 유지보수성

측면Java SpringDjangoFastAPI
설정 파일Config 클래스가 복잡함settings.py 중앙 집중최소한의 설정
보일러플레이트매우 많음적당매우 적음
의존성 주입어노테이션 기반전역 설정함수 기반, 직관적
타입 안정성컴파일 타임런타임런타임 + 타입 힌트

인증 플로우

Java Spring Security

클라이언트 → SecurityFilterChain → JwtRequestFilter → UserDetailsService → Controller
  • 장점: 매우 안전하고 검증된 패턴
  • 단점: 설정이 복잡하고 러닝 커브 높음

Django

클라이언트 → Middleware → Authentication Class → Permission Class → View
  • 장점: 배터리 포함, ORM과 완벽 통합
  • 단점: 무겁고, API 전용으로는 오버엔지니어링

FastAPI 플로우

클라이언트 → OAuth2Scheme → Dependencies → Route Function
  • 장점: 간단하고 직관적, 성능 우수
  • 단점: 굳이 꼽자면,, 상대적으로 새로운 생태계라는 점이려나

실제 개발 경험 비교

개발 속도

FastAPI > Django > Java Spring

Java Spring Security

  • ✅ 엔터프라이즈급 보안 기능
  • ✅ 풍부한 생태계와 문서
  • ❌ 설정 지옥, 높은 러닝 커브
  • ❌ 보일러플레이트 코드 과다

Django + DRF

  • ✅ 관리자 패널, ORM 등 풍부한 기능
  • ✅ 검증된 보안 패턴
  • ✅ 풍부한 써드파티 패키지
  • ❌ 무거운 프레임워크
  • ❌ API 전용 서비스에는 과도한 기능

FastAPI

  • ✅ 빠른 개발 속도
  • ✅ 자동 문서화
  • ✅ 현대적인 Python 기능 활용
  • ✅ 높은 성능
  • ❌ 상대적으로 적은 레퍼런스
  • ❌ 일부 고급 기능은 직접 구현 필요
profile
Turning Vision into Reality.

0개의 댓글