이 글 목차

FastAPI Dependency Injection 패턴
모든 라우트 핸들러에서 `Depends(get_current_user)`를 반복하면 보일러플레이트가 생기고 불일치가 발생하기 쉬워요
개인 프로젝트에서 인증을 정리하다가 라우터마다 같은 Depends(get_current_user)를 반복하고 있는 걸 발견했어요. 어떤 라우터는 user.sub를 문자열 그대로 넘기고, 어떤 라우터는 UUID로 변환하고
있었어요. 라우터 경계에서는 아무도 불평하지 않았어요. 이 불일치는 다운스트림
데이터베이스 쿼리가 타입 미스매치로 실패할 때까지 드러나지 않았어요.
해결책은 검증을 더 추가하는 게 아니었어요. dependency를 재사용 가능한 type
alias로 중앙화해서 모든 라우터가 같은 걸 같은 방식으로 받게 하는 거였어요.
FastAPI의 Annotated 타입 패턴이 이걸 타입 안전하게 해 줘요. 다만 조용히
무너지는 실패 방식이 하나 있어요. 도입하기 전에 알고 가는 게 좋아요.
이게 왜 중요한가
모든 라우트 핸들러에서 Depends(get_current_user)를 반복하면 두 가지
문제가 생겨요. 첫째, 보일러플레이트예요. 새 엔드포인트마다 같은 세 줄이
필요해요. 둘째, 더 위험한 건 불일치가 생기기 쉽다는 거예요. 중앙화된
dependency 타입이 없으면 인증 프로바이더를 바꿀 때 모든 라우터 파일을
수정해야 해요.
개발자가 여럿 붙고 코드베이스가 커지면 “다른 라우터에서 이 패턴을 복사해”가 타입 미스매치와 미묘한 버그가 쌓이는 경로가 돼요.
해결책: Annotated Type Alias
Python의 Annotated 타입(3.9+)을 사용하면 Depends() 메타데이터를 type
alias에 직접 포함할 수 있어요. 한 번 정의하고 어디서나 사용하면 돼요.
from typing import Annotated
from fastapi import Depends
# Define reusable type alias (once, in deps.py)
CurrentUser = Annotated[
KeycloakTokenClaims,
Depends(get_current_user)
]
# Use in any router (clean, no Depends() boilerplate)
@router.get("/items")
async def list_items(
current_user: CurrentUser, # bare alias — no Optional[...] wrapper
session: AsyncSession = Depends(get_db_session),
) -> list[Item]:
user_id = UUID(current_user.sub) # Convert at boundary
... CurrentUser alias 하나에 타입 정보(IDE 자동완성용)와 dependency
메타데이터(FastAPI 인젝터용)가 같이 들어 있어요. current_user: CurrentUser를
쓰는 라우터는 Depends(get_current_user)를 import하거나 반복하지 않아도
Keycloak 토큰을 자동으로 받아요.
조용히 injection을 건너뛰는 함정
비슷해 보이는 세 가지 형태인데, 실제로 injection이 되는 건 두 개뿐이에요:
# BAD: the Union wrapper hides the Depends metadata — injection never runs
async def endpoint(current_user: Optional[CurrentUser] = None):
... # current_user is None; get_current_user was never called
# GOOD: bare alias — FastAPI reads Depends from the Annotated metadata
async def endpoint(current_user: CurrentUser):
...
# GOOD: genuinely optional — Optional goes INSIDE the alias
OptionalUser = Annotated[
Optional[KeycloakTokenClaims],
Depends(get_optional_user),
]
async def endpoint(current_user: OptionalUser):
... # get_optional_user runs and may return None 첫 번째 형태가 위험한 건 실패 방식 때문이에요. FastAPI는 에러도 안 내고 경고도
안 찍어요. Optional[CurrentUser]는 Union[Annotated[...], None]인데 FastAPI는 Annotated가 맨 바깥에 있을 때만 그 메타데이터를 들여다봐요. 그래서 Depends를
아예 못 봐요. 대신 그 파라미터를 평범한 request 데이터로 취급해요. 버전에 따라
optional query 파라미터가 되기도 하고 request body 필드가 되기도 하는데, 평범한 GET은 둘 다 채워주지 않으니 None 기본값이 이겨요. 그래서
코드는 전혀 다른 곳에서 터져요. 보통 서비스 메서드 깊은 곳에서 AttributeError: 'NoneType' has no attribute 'sub'가 나요. 실제 실수가 있는
라우터와는 한참 떨어진 지점이죠.
정정
그때 남긴 제 메모에는 원인이 ”= None을 추가하면 Annotated injection이
깨진다”라고 적혀 있었어요. 이 글을 쓰면서 다시 테스트해 봤는데 그렇게 동작하지
않았어요. 기본값만 붙인 current_user: CurrentUser = None은 제가 시도한 모든
FastAPI 버전(0.95.0, 0.100.0, 0.110.0, 0.115.0, 0.128.0, 0.141.1)에서 여전히
dependency를 호출했고 그 기본값은 그냥 쓰이지 않았어요. 함정이 아니라 죽은
코드였던 거예요.
조용한 우회를 재현할 수 있었던 유일한 형태는 Union 래퍼였어요. 제가 디버깅한
증상은 진짜였지만 적어둔 원인은 틀렸던 거죠. 두 동작 모두 FastAPI 문서에
명시된 걸 찾지는 못했으니, 규격이 아니라 관찰로 받아들여 주세요. 다만 위
버전들에서는 전부 동일했어요.
발견하기 어려운 이유
FastAPI 문서가 두 패턴 모두 보여줘요. 공식 문서가 레거시 param = Depends(func) 패턴과 새로운 Annotated 패턴을 모두 다루고 있어요. 배울 때
어느 쪽을 골라야 할지 헷갈려요. Annotated는 Python 3.9부터 typing에
들어왔고(그 이전 버전은 typing_extensions가 필요해요), FastAPI가 메타데이터를
읽기 시작한 건 0.95.0부터예요.
“선택적”으로 만들려는 본능이 함정을 불러요. 파라미터를 선택적으로
만들려면 Python에서는 보통 Optional[...]을 씌우죠. 그런데 Annotated alias에
그걸 씌우는 순간 Depends가 사라져요. Optional은 alias 바깥이 아니라 안쪽에
들어가야 해요.
타입 미스매치가 늦게 나타나요. 어떤 라우터는 current_user.sub를 문자열로
사용하고 다른 건 UUID로 사용했어요. 버그는 라우터 경계에서 변환이 일어나야
하는 곳이 아니라, 다운스트림 쿼리가 타입 에러로 실패할 때 나타났어요.
조합 가능성
기본 패턴을 갖추면 변형을 만드는 건 간단해요:
# Base: required authenticated user
CurrentUser = Annotated[
KeycloakTokenClaims,
Depends(get_current_user)
]
# Variant: admin-only
AdminUser = Annotated[
KeycloakTokenClaims,
Depends(get_admin_user)
]
# Variant: optional auth — Optional stays INSIDE
OptionalUser = Annotated[
Optional[KeycloakTokenClaims],
Depends(get_optional_user)
]
# Each router uses the appropriate type
@router.get("/admin/users")
async def list_users(admin: AdminUser): ...
@router.get("/items")
async def list_items(user: CurrentUser): ... 실전 가이드
Annotated type alias를 사용하면 좋은 경우:
- 여러 라우터가 같은 dependency를 공유하는 경우 (인증, DB 세션)
- IDE 자동완성이 구체 타입까지 잡히길 원하는 경우
- dependency에서 일관된 타입 변환이 필요한 경우 (예:
str에서UUID로) - 변형을 만들 계획인 경우 (
AdminUser,OptionalUser)
인라인 Depends()를 그대로 둬도 되는 상황도 있어요:
- 일회성 dependency: dependency를 엔드포인트 하나에서만 쓴다면 인라인
Depends(func)가 type alias를 만드는 것보다 더 단순하고 명시적이에요. - 선택적 dependency인데 중첩이 번거로울 때:
Annotated도 선택적 dependency를 처리해요. 다만Optional이 alias 안쪽에 있어야 하고, dependency 함수가 익명 호출자에게None을 돌려줘야 해요. 익명 허용 엔드포인트 하나만 있다면 전통적인user: Optional[Type] = Depends(func)형태가 더 눈에 잘 들어와요. 이 경우엔Depends가 기본값 자리에서 오기 때문에Union이 아무것도 가리지 않아요. - FastAPI가 아닌 프레임워크:
Annotated[..., Depends()]패턴은 FastAPI 전용이에요. Flask, Django 등 다른 프레임워크는 DI 메커니즘이 아예 달라요.
기억할 규칙은 하나예요. Annotated를 항상 맨 바깥에 두세요. 공유 dependency는 deps.py에 Annotated type alias로 정의하고, 사용자 ID는 라우터 경계에서
변환하면 돼요. 선택적으로 만들어야 한다면 Optional을 alias 안쪽으로 옮기고요.