FastAPI의 의존성 주입 마스터하기
Wenhao Wang
Dev Intern · Leapcell

소개
견고하고 확장 가능한 웹 API를 구축하려면 종종 복잡한 애플리케이션 로직 관리, 데이터베이스 상호 작용, 다양한 서비스 통합이 필요합니다. 애플리케이션의 크기와 복잡성이 커짐에 따라 코드베이스를 깔끔하고 테스트 가능하며 유지보수 가능하게 유지하는 것이 중대한 과제가 됩니다. 바로 이때 의존성 주입(DI)이라는 개념이 진가를 발휘합니다.
Python 웹 프레임워크의 세계에서 FastAPI는 성능, 사용 편의성, 자동 문서화 측면에서 뛰어난 강력한 프레임워크로 부상했습니다. FastAPI의 우아한 디자인과 확장성의 초석은 정교한 의존성 주입 시스템입니다. 이 시스템을 통해 개발자는 라우트 함수가 필요로 하는 '것'(데이터베이스 세션, 특정 구성 또는 인증된 사용자 등)을 선언할 수 있으며, FastAPI가 이를 제공하는 작업을 처리합니다. 이를 통해 코드를 더 모듈화하고, 테스트하기 쉽게 만들며, 이해하기 쉽게 만듭니다.
이 글에서는 FastAPI의 의존성 주입 시스템을 기본부터 고급 응용 프로그램까지 깊이 파고듭니다. DI가 일반적인 작업을 단순화하고, 테스트 용이성을 향상하며, 백엔드 서비스에 대한 더 나은 아키텍처 패턴을 촉진하는 방법을 살펴보겠습니다.
의존성 주입 이해하기
FastAPI의 특정 사항을 자세히 알아보기 전에 의존성 주입이 무엇이며 왜 중요한지에 대한 기본적인 이해를 확립해 보겠습니다.
핵심 개념
본질적으로 의존성 주입은 클래스나 함수가 자체적으로 생성하는 대신 외부 소스에서 의존성을 받을 수 있도록 하는 디자인 패턴입니다. 함수가 예를 들어 데이터베이스 연결에 대한 자체 설정을 수행하는 대신, 해당 연결은 호출될 때 함수에 주입됩니다.
간단한 비유를 들어보겠습니다. 집을 짓고 있다고 상상해 보세요. 직접 모든 벽돌, 목재 및 도구를 가져오는 대신, 해당 재료와 도구가 건설 현장으로 배달됩니다. 필요한 것을 선언하기만 하면 바로 사용할 수 있게 도착합니다. 이 "배달 서비스"는 의존성 주입기와 유사합니다.
주요 용어:
- 의존성: 다른 객체가 기능하기 위해 필요한 객체입니다. 예를 들어,
UserService
는DatabaseConnection
에 의존할 수 있습니다. - 의존자: 의존성을 요구하는 객체입니다. 위 예시에서
UserService
가 의존자입니다. - 주입기: 의존자를 위해 의존성을 제공하는 메커니즘입니다. FastAPI에서 프레임워크 자체가 주입기 역할을 합니다.
의존성 주입을 수용해야 하는 이유
DI의 이점은 상당합니다.
- 느슨한 결합: 구성 요소는 다른 구성 요소의 내부 구현 세부 사항에 대한 의존성이 줄어듭니다. 데이터베이스 연결 방식을 변경하면 데이터베이스를 사용하는 모든 함수가 아닌 의존성 공급자만 업데이트하면 됩니다.
- 향상된 테스트 용이성: 의존성이 주입되면 테스트 중에 실제 의존성을 모의(mock) 또는 가짜 버전으로 쉽게 교체할 수 있습니다. 실제 데이터베이스에 연결하지 않고도
UserService
를 테스트할 수 있어 테스트가 더 빠르고 안정적입니다. - 코드 재사용성: 인증, 데이터베이스 세션, 로깅과 같은 일반적인 기능은 재사용 가능한 의존성 함수로 캡슐화될 수 있습니다.
- 개선된 유지보수성: 변경 사항이 더 국지화됩니다. 리소스 관리 방식을 수정해야 하는 경우 한 곳, 즉 의존성 함수에서 수정합니다.
- 더 나은 확장성: 애플리케이션이 성장함에 따라 DI 시스템에서 명시적으로 선언하고 관리되는 경우 리소스와 상태 관리가 더 쉬워집니다.
FastAPI의 의존성 주입 시스템
FastAPI의 DI 시스템은 Python 타입 힌트를 기반으로 구축됩니다. 경로 작업 함수나 다른 의존성에서 타입 힌트가 있는 매개변수를 선언하면 FastAPI가 지능적으로 해당 의존성을 확인하고 제공합니다.
매개변수 전달의 기본
가장 간단한 형태의 의존성은 경로 작업 함수에 직접 전달되는 매개변수입니다.
from fastapi import FastAPI app = FastAPI() @app.get("/items/{item_id}") async def read_item(item_id: int): return {"item_id": item_id}
여기서 item_id
는 FastAPI가 경로에서 추출하는 의존성입니다. 이는 FastAPI가 값을 "주입"하는 방식을 보여주는 기본적인 예입니다.
Depends
DI의 핵심
FastAPI DI의 진정한 힘은 Depends
유틸리티에 있습니다. Depends
는 "호출 가능한 것"(함수, 클래스 또는 클래스 메서드)을 받아 FastAPI에 이를 실행하고 해당 반환 값을 가져온 다음 해당 값을 의존성으로 주입하도록 지시합니다.
사용자를 가져오는 것을 시뮬레이션하는 간단한 의존성 함수를 정의해 보겠습니다.
from fastapi import FastAPI, Depends, HTTPException, status app = FastAPI() # 사용자 "가짜" 데이터베이스 fake_users_db = { "john_doe": {"username": "john_doe", "email": "john@example.com"}, "jane_smith": {"username": "jane_smith", "email": "jane@example.com"}, } def get_current_user_name() -> str: # 실제 앱에서는 인증 토큰에서 가져올 것입니다. # 지금은 사용자를 하드코딩하겠습니다. return "john_doe" @app.get("/users/me") async def read_current_user(username: str = Depends(get_current_user_name)): return fake_users_db[username]
이 예시에서는:
get_current_user_name
이 우리의 의존성 함수입니다. 단순히 문자열을 반환합니다.read_current_user
에서username: str
매개변수를 선언합니다.username = Depends(get_current_user_name)
를 설정하여 FastAPI가 먼저get_current_user_name
을 호출한 다음 해당 반환 값을 가져와read_current_user
의username
매개변수에 할당하도록 지시합니다.
의존성에 다른 의존성이 있는 경우
의존성 함수 자체에도 의존성이 있을 수 있습니다! 이렇게 하면 의존성 체인이 생성되어 더 작고 테스트 가능한 단위로 복잡한 논리를 구성할 수 있습니다.
API 키에 대한 Query
매개변수가 있다고 가정하고 사용자 유효성 검사를 포함하도록 사용자 예제를 개선해 보겠습니다.
from typing import Annotated from fastapi import Depends, FastAPI, HTTPException, status, Query app = FastAPI() fake_users_db = { "john_doe": {"username": "john_doe", "email": "john@example.com"}, "jane_smith": {"username": "jane_smith", "email": "jane@example.com"}, } API_KEY = "supersecretapikey" def verify_api_key(api_key: str = Query(..., min_length=10)) -> bool: if api_key != API_KEY: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API Key" ) return True def get_current_active_user( username: str = Depends(get_current_user_name), is_valid_key: bool = Depends(verify_api_key) # 이 의존성은 자체 의존성(Query)을 가지고 있습니다. ): if not is_valid_key: # verify_api_key가 HTTPException을 발생시키면 이 부분은 도달하지 않습니다. pass user = fake_users_db.get(username) if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="User not found" ) return user @app.get("/users/me_with_key") async def read_current_user_with_key(current_user: dict = Depends(get_current_active_user)): return current_user
여기서 get_current_active_user
는 get_current_user_name
및 verify_api_key
에 의존합니다. /users/me_with_key
가 호출되면 FastAPI는 다음을 수행합니다.
verify_api_key
호출 (쿼리에서api_key
를 가져옵니다).get_current_user_name
호출.- 이전 두 호출의 결과를 전달하여
get_current_active_user
호출. - 마지막으로
get_current_active_user
의 결과를 전달하여read_current_user_with_key
호출.
체인의 어떤 의존성이든 HTTPException
을 발생시키면 FastAPI는 즉시 처리를 중지하고 오류 응답을 반환하여 오류 처리를 간단하게 만듭니다.
클래스를 의존성으로 사용하기
클래스를 의존성으로 사용할 수도 있습니다. FastAPI는 해당 클래스의 인스턴스를 생성하여 주입합니다. 이는 데이터베이스 세션과 같은 특정 우려 사항과 관련된 상태 또는 논리를 캡슐화하는 데 특히 유용합니다.
from fastapi import Depends, FastAPI, Request from typing import Annotated, Generator app = FastAPI() class DatabaseSession: def __init__(self): print("Opening database session...") self.session = "mock_db_session_object" # 데이터베이스 세션 시뮬레이션 def get_data(self, query: str): print(f"Executing query: {query} with session: {self.session}") return {"data": f"result for {query}"} def close(self): print("Closing database session...") # DatabaseSession을 생성하여 yield하는 의존성 함수 # 이것은 정리(예: 데이터베이스 연결)가 필요한 리소스에 이상적입니다. def get_db_session() -> Generator[DatabaseSession, None, None]: db = DatabaseSession() try: yield db finally: db.close() @app.get("/items/{item_id}") async def read_item_with_db( item_id: str, db: Annotated[DatabaseSession, Depends(get_db_session)] ): # 여기서 'db' 객체는 DatabaseSession의 인스턴스입니다. # FastAPI는 yield를 중심으로 'with' 컨텍스트 관리자 동작을 처리합니다. data = db.get_data(f"SELECT * FROM items WHERE id='{item_id}'") return {"item_id": item_id, "data": data}
이 설정에서:
DatabaseSession
은 "데이터베이스 세션"을 관리하는 클래스입니다.get_db_session
은 제너레이터 함수입니다. FastAPI는 제너레이터 의존성을 인식하고 컨텍스트 관리자로 취급합니다.yield
이전의 코드는 의존성이 획득될 때 실행되고,yield
이후의 코드는 요청이 완료될 때(또는 예외가 발생하는 경우) 실행되어 리소스가 올바르게 정리되도록 합니다. 이 패턴은 데이터베이스 연결, 파일 핸들 등에 중요합니다.
테스트를 위한 의존성 재정의
FastAPI DI의 가장 강력한 기능 중 하나는 의존성을 쉽게 재정의할 수 있다는 것입니다. 이는 단위 및 통합 테스트 작성에 있어 게임 체인저입니다. 애플리케이션 코드를 변경하지 않고도 실제 데이터베이스 연결을 모의 연결로 바꾸거나 외부 API 호출을 하드코딩된 응답으로 바꿀 수 있습니다.
get_db_session
예제를 확장해 보겠습니다.
from fastapi.testclient import TestClient import pytest # 테스트와 함께 자주 사용됨 # ... (이전 앱 및 get_db_session 정의) ... # 테스트를 위한 "가짜" 데이터베이스 세션 class MockDatabaseSession: def get_data(self, query: str): print(f"Mocking query: {query}") # 일관되고 테스트 가능한 응답 반환 return {"data": f"mock_result for {query}"} def close(self): pass # 모의에는 실제 정리할 리소스가 없음 # 테스트를 위해 특정 의존성을 재정의합니다. def override_get_db_session(): # 이 의존성 함수는 이제 "가짜" 세션을 yield합니다. yield MockDatabaseSession() # 테스트 클라이언트 생성 client = TestClient(app) def test_read_item_with_mock_db(): app.dependency_overrides[get_db_session] = override_get_db_session response = client.get("/items/test_item") app.dependency_overrides = {} # 테스트 후 재정의 비우기 assert response.status_code == 200 assert response.json() == {"item_id": "test_item", "data": {"data": "mock_result for SELECT * FROM items WHERE id='test_item'"}} print("Test passed: Mock DB session used successfully.") # 이를 실행하려면 코드를 Python 파일(예: main.py 및 test_main.py)로 저장한 다음 # pytest를 사용하거나 테스트 함수를 스크립트에서 직접 호출하면 됩니다. # (참고: 실제 pytest의 경우 앱 초기화 및 정리를 더 공식적으로 통합해야 합니다.)
app.dependency_overrides[original_dependency_function]
에 새 의존성 호출 가능 객체를 할당하여 FastAPI는 original_dependency_function
이 요청될 때 재정의를 사용합니다. 테스트 후 app.dependency_overrides = {}
를 설정하는 것은 후속 테스트에 대한 부작용을 방지하는 데 중요합니다.
고급 사용법 컨텍스트 및 Request
객체
때로는 의존성이 현재 요청 객체 자체(예: 헤더, 쿼리 매개변수 또는 요청 본문을 읽기 위해)에 액세스해야 할 수 있습니다. Request
를 의존성 함수에서 타입 힌트가 있는 매개변수로 선언할 수 있습니다.
from fastapi import FastAPI, Request, Depends, Header from typing import Annotated app = FastAPI() def get_user_agent(request: Request) -> str: # Request 객체에서 직접 속성 액세스 return request.headers.get("user-agent", "Unknown") def get_platform_from_user_agent(user_agent: Annotated[str, Depends(get_user_agent)]): if "Mobile" in user_agent: return "Mobile" elif "Desktop" in user_agent: return "Desktop" return "Other" @app.get("/client_info") async def get_client_info( user_agent: Annotated[str, Depends(get_user_agent)], platform: Annotated[str, Depends(get_platform_from_user_agent)] ): return {"user_agent": user_agent, "platform": platform}
여기서 get_user_agent
는 Request
객체를 받습니다. 그런 다음 get_platform_from_user_agent
는 get_user_agent
의 결과에 의존합니다. 이는 전체 요청 컨텍스트를 활용하여 매우 구체적인 의존성을 구성할 수 있음을 보여줍니다.
전역 의존성
때로는 모든 요청 또는 특정 라우터의 모든 요청에 대해 특정 의존성이 실행되도록 하고 싶을 수 있습니다. FastAPI는 FastAPI()
앱 수준 또는 APIRouter()
수준에서 의존성을 추가할 수 있도록 합니다.
from fastapi import FastAPI, Depends, status, HTTPException from typing import Annotated app = FastAPI() def verify_token(token: Annotated[str, Header()]): if token != "my-secret-token": raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized") return token # 이 앱의 모든 경로 작업에 적용되는 전역 의존성을 추가합니다. app = FastAPI(dependencies=[Depends(verify_token)]) @app.get("/protected_data") async def read_protected_data(): return {"message": "You accessed protected data!"} @app.get("/public_data") async def read_public_data(): # 전역 의존성 때문에 이 엔드포인트도 토큰이 필요합니다. return {"message": "This is public data, but still protected by global token."}
이제 /protected_data
또는 /public_data
로의 모든 요청은 먼저 verify_token
을 통과합니다. 토큰이 유효하지 않으면 HTTPException
이 발생하고 경로 작업 함수는 실행되지 않습니다. 이는 인증, 로깅 또는 속도 제한과 같은 교차 관심사에 이상적입니다.
결론
FastAPI의 의존성 주입 시스템은 단순한 편리한 기능 그 이상입니다. 리소스 프로비저닝 및 일반 로직을 재사용 가능한 의존성 함수로 추상화함으로써 FastAPI 애플리케이션을 쉽게 확장 가능하고 유지보수 가능하게 만들어 더욱 깨끗하고 모듈화되며 본질적으로 테스트 가능한 코드베이스를 촉진하는 근본적인 아키텍처 패턴입니다. 이 시스템을 마스터하는 것은 진지한 웹 개발 프로젝트를 위해 FastAPI의 모든 잠재력을 발휘하는 열쇠입니다.