클래스를 의존성으로
클래스를 의존성으로 (Classes as Dependencies)
의존성 주입(Dependency Injection) 시스템을 더 깊이 파고들기 전에, 지금까지의 예제를 한 단계 업그레이드해 볼게요. 지금까지 의존성은 전부 함수로 선언했는데, 사실 클래스도 의존성이 될 수 있어요.
출처: 공식문서
직전 예제의 dict
먼저 직전 장에서 본 예제를 떠올려요. 의존성(이 맥락에선 "dependable"이라고도 해요)이 dict를 돌려주던 구조였죠.
from typing import Annotated
from fastapi import Depends, FastAPI
app = FastAPI()
async def common_parameters(q: str | None = None, skip: int = 0, limit: int = 100):
return {"q": q, "skip": skip, "limit": limit}
@app.get("/items/")
async def read_items(commons: Annotated[dict, Depends(common_parameters)]):
return commons
@app.get("/users/")
async def read_users(commons: Annotated[dict, Depends(common_parameters)]):
return commons
그런데 그러면 _경로 동작 함수_의 commons 파라미터가 dict로 들어와요. 에디터는 dict에 대해 자동 완성 같은 지원을 거의 해 주지 못해요. 키와 값 타입을 알 수 없으니까요. 분명 더 나은 방법이 있어요.
무엇이 의존성이 되는가
지금까지 의존성은 전부 함수로 선언했어요. 그런데 함수가 의존성이 될 수 있는 유일한 방법은 아니에요(가장 흔하긴 하지만요).
핵심 조건은 딱 하나예요. 의존성은 "호출 가능한(callable)" 것이어야 해요. Python에서 "callable"이란 함수처럼 **호출(실행)**할 수 있는 모든 것을 말해요.
예를 들어 어떤 객체 something이 함수가 아니어도, 이렇게 호출할 수 있다면 callable이에요.
something()
또는 이렇게:
something(some_argument, some_keyword_argument="foo")
클래스도 callable이라서 의존성이 돼요
Python 클래스의 인스턴스를 만들 때 쓰는 문법을 떠올려 보세요. 클래스 이름 뒤에 괄호를 붙여 호출하죠?
class Cat:
def __init__(self, name: str):
self.name = name
fluffy = Cat(name="Mr Fluffy")
여기서 fluffy는 Cat 클래스의 인스턴스예요. 그리고 이 인스턴스를 만들기 위해 우리는 Cat을 "호출"했어요. 즉 Python 클래스도 callable이라는 뜻이에요.
그렇다면 FastAPI에서 클래스를 의존성으로 쓸 수 있겠죠. FastAPI가 실제로 확인하는 건 "이게 callable(함수든 클래스든 그 외든)이고, 파라미터가 뭔지"예요. callable을 의존성으로 넘기면, FastAPI는 그 callable의 파라미터를 분석해서 _경로 동작 함수의 파라미터와 같은 방식으로 처리해요. 하위 의존성까지 포함해서요.
이 규칙은 파라미터가 아예 없는 callable에도 똑같이 적용돼요. 파라미터가 없는 _경로 동작 함수와 같은 원리죠.
그럼 위의 common_parameters라는 함수를 CommonQueryParams라는 클래스로 바꿔 볼게요.
from typing import Annotated
from fastapi import Depends, FastAPI
app = FastAPI()
fake_items_db = [{"item_name": "Foo"}, {"item_name": "Bar"}, {"item_name": "Baz"}]
class CommonQueryParams:
def __init__(self, q: str | None = None, skip: int = 0, limit: int = 100):
self.q = q
self.skip = skip
self.limit = limit
@app.get("/items/")
async def read_items(commons: Annotated[CommonQueryParams, Depends(CommonQueryParams)]):
response = {}
if commons.q:
response.update({"q": commons.q})
items = fake_items_db[commons.skip : commons.skip + commons.limit]
response.update({"items": items})
return response
여기서 주목할 건 인스턴스를 만드는 __init__ 메서드예요. 이 __init__의 파라미터가 사실 이전 common_parameters 함수의 파라미터와 똑같아요. 바로 이 파라미터들을 FastAPI가 "해석"해서 의존성을 해결해요.
둘 다 결과적으로 이렇게 동작해요.
- 선택적인
q쿼리 파라미터 (str) - 기본값
0을 가진skip쿼리 파라미터 (int) - 기본값
100을 가진limit쿼리 파라미터 (int)
데이터 변환, 검증, OpenAPI 스키마 문서화까지 전부 동일하게 일어나요. 함수든 클래스든 결과가 같다는 게 핵심이에요.
사용하기
이제 이 클래스를 의존성으로 선언하면 돼요. FastAPI가 CommonQueryParams 클래스를 호출해서 그 인스턴스를 만들고, 그 인스턴스를 commons 파라미터로 함수에 넘겨줘요. 그리고 그 인스턴스의 __init__ 파라미터(q, skip, limit)는 쿼리 파라미터로 처리되죠.
@app.get("/items/")
async def read_items(commons: Annotated[CommonQueryParams, Depends(CommonQueryParams)]):
response = {}
if commons.q:
response.update({"q": commons.q})
items = fake_items_db[commons.skip : commons.skip + commons.limit]
response.update({"items": items})
return response
commons.q, commons.skip처럼 속성에 접근할 수 있어서, dict를 쓸 때보다 에디터의 지원을 훨씬 잘 받아요.
타입 애너테이션 vs Depends
위 코드에서 CommonQueryParams가 두 번 쓰이는 걸 눈치챘나요?
commons: Annotated[CommonQueryParams, Depends(CommonQueryParams)]
마지막에 있는 Depends(CommonQueryParams) 안의 CommonQueryParams가 FastAPI가 실제로 의존성으로 사용하는 값이에요. 여기서 파라미터를 추출하고, 여기를 호출해요.
반면에 첫 번째, 즉 Annotated[CommonQueryParams, ...]의 CommonQueryParams는 FastAPI에게 특별한 의미가 없어요. 데이터 변환이나 검증에 쓰이지 않아요(그건 Depends(CommonQueryParams)가 담당하니까요). 그래서 사실 이렇게 단순하게 써도 돼요.
commons: Annotated[Any, Depends(CommonQueryParams)]
물론 commons: CommonQueryParams = Depends(CommonQueryParams) 같은 비-Annotated 형태도 동작하고, 심지어 Depends()를 파라미터 없이 쓰는 단축 표기도 가능해요.
commons: Annotated[CommonQueryParams, Depends()]
FastAPI가 타입 애너테이션(CommonQueryParams)을 보고 의존성을 알아서 해결해요. 다만 이 단축 표기법이 오히려 헷갈린다면 무시해도 괜찮아요. 쓸 필요가 없는 편의 기능일 뿐이에요. FastAPI가 코드 반복을 줄여 주는 걸 좋아해서 제공하는 숏컷이거든요.
더 알아보기 (Learn more)
- 공식문서: Classes as Dependencies
- 의존성 첫걸음: Dependencies - First Steps
- 하위 의존성: Sub-dependencies