오늘은 JWT 기반 사용자 인증 시스템 구현하기.
이전에는 Python에 Django 썼어서, JWT 기반은 Java에서만 써봤더랬다.
todo_api/
├── main.py # 모든 API가 한 파일에
├── database.py
├── models.py
└── schemas.py
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
# 세션 방식 (서버에 상태 저장)
login → 서버가 세션 ID 생성 → 메모리/DB에 저장 → 쿠키로 전달
# JWT 방식 (상태를 저장하지 않음)
login → 서버가 JWT 토큰 생성 → 클라이언트가 저장 → 매 요청마다 헤더에 포함
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyIiwiZXhwIjoxNjM5...
헤더.페이로드.서명
# 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")
문제: 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()
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")
문제: access_token
이 None
으로 반환
찾아도 찾아도 안찾아져서 고생을 깨나했는데...
들여쓰기 문제였다..
지옥의 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)
문제: "Auth Error: Not Found"
# dependencies.py
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login") # ❌ 잘못된 경로
실제 엔드포인트: /auth/login
Swagger 요청에 사용한 경로: /login
해결:
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login") # ✅ 올바른 경로
이전에 Java JWT와 Django 인증 시스템을 사용해본 작은 경험으로 비교해보기.
// 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());
}
// ... 더 많은 보일러플레이트 코드
}
# 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})
# 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 Spring | Django | FastAPI |
---|---|---|---|
설정 파일 | Config 클래스가 복잡함 | settings.py 중앙 집중 | 최소한의 설정 |
보일러플레이트 | 매우 많음 | 적당 | 매우 적음 |
의존성 주입 | 어노테이션 기반 | 전역 설정 | 함수 기반, 직관적 |
타입 안정성 | 컴파일 타임 | 런타임 | 런타임 + 타입 힌트 |
클라이언트 → SecurityFilterChain → JwtRequestFilter → UserDetailsService → Controller
클라이언트 → Middleware → Authentication Class → Permission Class → View
클라이언트 → OAuth2Scheme → Dependencies → Route Function
FastAPI > Django > Java Spring