FastAPI Shallow Dive (3) — Dependency Injection은 왜 함수인가

Published:

FastAPI Shallow Dive 시리즈

  1. ASGI부터 시작
  2. Pydantic — 타입 힌트를 런타임 validator로
  3. Dependency Injection ← 현재
  4. async와 worker 개수

2편까지는 “타입 힌트가 런타임 계약이 된다”는 큰 전제가 깔렸다. 이번 편 주제인 Dependency Injection은 그 전제를 아키텍처 수준으로 끌어올리는 장치다.

Depends() 하나로 DB 세션, 인증 유저, 설정, rate limiter, 캐시 — 전부 타입으로 주입한다. 포인트는 이게 Spring/NestJS 스타일의 IoC 컨테이너가 아니라는 것. 그냥 Python 함수다. 왜 그렇게 설계됐는지부터 본다.

기본 모양

from fastapi import FastAPI, Depends

app = FastAPI()

def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

@app.get("/items/{item_id}")
async def read_item(item_id: int, db = Depends(get_db)):
    return db.query(Item).filter(Item.id == item_id).first()

get_db는 그냥 제너레이터 함수. Depends(get_db)는 “이 함수를 호출해서 결과를 주입해라”라는 마커. 특별한 기반 클래스도, 데코레이터도, 등록 단계도 없다.

요청이 오면 FastAPI가 이렇게 처리한다:

  1. 핸들러의 시그니처를 읽고 Depends(...)가 붙은 파라미터를 찾는다.
  2. 각 dependency를 순서 있는 그래프로 풀어 호출한다 (get_db() 실행).
  3. 결과를 해당 파라미터에 넘겨 핸들러 호출.
  4. 요청 종료 시 yield 이후 코드를 실행 (db.close()).

왜 함수인가 — Pythonic DI의 설계 판단

Java/TypeScript에서 오면 이게 어색할 수 있다. Spring은 @Autowired, NestJS는 constructor(private db: DbService). 둘 다 클래스 + 컨테이너 등록이 기본값.

FastAPI는 이걸 의도적으로 버렸다. 이유는 시그니처 세 줄로 요약된다:

  • Python은 타입 힌트만 있지 인터페이스가 제한적. Java의 interface, TS의 interface처럼 “계약 타입”을 컴파일러가 강제하진 않는다. IoC 컨테이너가 타입으로 해석기를 고르는 모델이 자연스럽지 않다.
  • 함수는 이미 1급 객체. “주입 가능한 단위”를 굳이 클래스로 감싸지 않아도 함수가 그 역할을 한다.
  • yield 한 줄로 setup/teardown. 클래스에 __enter__/__exit__ 두 개 쓸 일을 제너레이터가 자연스럽게 대체.

결과: 등록 단계가 없다. get_db를 어딘가에 register 하지 않아도 Depends(get_db) 하면 끝. 테스트에서 갈아끼울 때는 app.dependency_overrides[get_db] = fake_get_db 한 줄.

트레이드오프는 있다. 함수 시그니처에 dependency를 나열해야 해서, 많아지면 파라미터 목록이 길어진다. 이걸 줄이려고 “Depends 객체 하나로 묶기” 패턴을 쓰는데, 뒤에서 본다.

서브 의존성 — Dependency of Dependency

Dependency도 dependency를 가질 수 있다.

from fastapi import Depends, Header, HTTPException

def get_settings() -> Settings:
    return Settings()

def get_db(settings: Settings = Depends(get_settings)):
    db = create_engine(settings.database_url)
    try:
        yield db.connect()
    finally:
        db.dispose()

def get_current_user(
    db = Depends(get_db),
    token: str = Header(...),
) -> User:
    user = lookup(db, token)
    if not user:
        raise HTTPException(401)
    return user

@app.get("/me")
async def me(user: User = Depends(get_current_user)):
    return user

그래프는 이렇게 풀린다:

me
 └ get_current_user
     ├ get_db
     │   └ get_settings
     └ Header(token)

FastAPI가 위상 정렬해서 호출한다. get_settingsget_dbget_current_userme. 순서가 결정적이라 예측 가능.

요청 범위 캐시 — 같은 dependency는 한 번만

같은 요청 안에서 같은 dependency를 여러 번 선언해도 한 번만 실행된다.

@app.get("/orders")
async def list_orders(
    user: User = Depends(get_current_user),
    audit = Depends(audit_log),  # audit_log 내부에서도 get_current_user 씀
):
    ...

list_orders에서 한 번, audit_log 안에서 한 번 — 두 번 쓰였지만 DB 조회는 한 번만 일어난다. FastAPI가 요청 scope에서 결과를 캐시.

끄려면 Depends(fn, use_cache=False). 단, 캐시 키는 dependency 함수의 identity + 서브 의존성 결과라, 같은 함수라도 파라미터가 다르면 별개로 친다.

yield 기반 dependency — 리소스 수명 관리

이게 실전에서 제일 자주 쓰인다.

def get_db():
    db = SessionLocal()
    try:
        yield db
    except Exception:
        db.rollback()
        raise
    finally:
        db.close()

yield dbdependency 반환점. yield 이후 코드가 응답 후 정리 단계로 실행된다. Python의 with 문과 같은 패턴이지만, 핸들러가 끝날 때까지 열려있어야 하니 제너레이터로 풀어낸 꼴.

예외 처리 순서가 중요하다. FastAPI는 핸들러 실행 중 예외가 나면 dependency의 except 블록에도 예외를 재주입한다. 위 코드처럼 rollbackraise하면 트랜잭션이 자동으로 되돌려진다. SQLAlchemy 쓸 때 이 패턴이 사실상 표준.

클래스도 dependency가 될 수 있다

함수가 기본이지만, __init__ 시그니처를 DI 대상으로 쓸 수 있다.

class Pagination:
    def __init__(self, skip: int = 0, limit: int = 100):
        self.skip = skip
        self.limit = limit

@app.get("/items")
async def list_items(page: Pagination = Depends()):
    return items[page.skip : page.skip + page.limit]

Depends() 안에 아무것도 안 넣으면 타입 애너테이션을 보고 Pagination을 dependency로 해석. 이게 “파라미터 묶기” 패턴. skip, limit을 핸들러 시그니처에 늘어놓는 대신 하나의 객체로 받아 핸들러가 깔끔해진다.

실제로 팀에서 쓰던 패턴:

class CommonContext:
    def __init__(
        self,
        user: User = Depends(get_current_user),
        db = Depends(get_db),
        settings: Settings = Depends(get_settings),
    ):
        self.user = user
        self.db = db
        self.settings = settings

@app.post("/orders")
async def create_order(payload: OrderIn, ctx: CommonContext = Depends()):
    ...

핸들러 시그니처는 두 줄. 내부에서 ctx.user, ctx.db. “자주 같이 쓰이는 dependency 묶음”을 표현하는 방식으로 잘 맞는다.

보안 dependency — OAuth2, API Key

FastAPI의 security 모듈은 Depends로 감싼 보안 scheme를 제공한다.

from fastapi.security import OAuth2PasswordBearer

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")

def get_current_user(token: str = Depends(oauth2_scheme)) -> User:
    return decode_token(token)

@app.get("/me")
async def me(user: User = Depends(get_current_user)):
    return user

oauth2_schemecallable 객체다(__call__ 구현). FastAPI 입장에선 함수든 클래스 인스턴스든 callable이면 같다. 이게 보안 스펙을 OpenAPI에 자동 반영해주고, Swagger UI에 “Authorize” 버튼도 따라온다.

테스트 — override가 한 줄

DI의 제일 큰 실용적 이득이 여기서 나온다.

# tests/test_orders.py
from fastapi.testclient import TestClient
from app.main import app
from app.deps import get_db

def fake_get_db():
    yield InMemoryDB()

app.dependency_overrides[get_db] = fake_get_db

client = TestClient(app)

def test_list_orders():
    res = client.get("/orders")
    assert res.status_code == 200

app.dependency_overrides함수 → 함수 dict. 테스트에서 DB를 in-memory로, 결제 API를 mock으로, 외부 호출을 stub로 — 한 줄씩 바꾼다. 운영 코드에는 손을 대지 않는다.

Spring처럼 @MockBean이나 profile 전환을 준비하지 않아도 된다. 이 단순함이 FastAPI DI의 진짜 강점이라고 본다.

Spring / NestJS와의 비교

항목SpringNestJSFastAPI
주입 단위클래스 (@Component)클래스 (@Injectable())함수 또는 callable
등록컨테이너 자동 스캔모듈 providers 배열없음 — Depends(fn)으로 직접 참조
Scopesingleton / prototype / requestdefault / request / transientper-request + 같은 요청 내 캐시
설정값 주입@Value, @ConfigurationPropertiesConfigServiceDepends(get_settings) + Pydantic Settings
테스트 교체@MockBean, profileproviders 배열 오버라이드app.dependency_overrides[...]
Setup/Teardown@PostConstruct / @PreDestroy, try-with-resourceslifecycle hookgenerator yield 패턴

정리하면: FastAPI DI = Python 함수 + Depends 마커 + 요청 범위 캐시. 그 이상도 이하도 아니다. 복잡한 컨테이너를 깔 필요가 없어서 러닝 커브가 낮고, Spring을 몰라도 큰 서비스를 DI 잘 붙여 끌고 갈 수 있다.

정리

  • FastAPI DI는 함수 기반. 등록 단계 없음, Depends(fn) 한 줄이면 주입.
  • 서브 의존성이 DAG로 풀린다. 순서 결정적, 같은 dependency는 요청 범위에서 한 번만 실행.
  • yield 기반 dependency가 setup/teardown의 표준 패턴. SQLAlchemy 세션 수명 관리에 자연스럽게 맞음.
  • 클래스 dependency는 파라미터 묶기에 쓴다. “공통 컨텍스트” 패턴이 유용.
  • 보안도 Depends로. 테스트 override는 app.dependency_overrides 한 줄.
  • Spring/NestJS 대비 단순함이 설계 의도. 그게 Python 스타일에 맞춰 나온 결과.

다음 편은 4편, async와 worker 개수. def 핸들러와 async def 핸들러가 내부에서 어떻게 다르게 실행되는지, Uvicorn worker를 몇 개로 설정할지, 그리고 그 결정이 왜 GIL 시리즈의 프레임워크로 귀결되는지.