파이썬에서 의존성 주입의 심연 탐색
Lukas Schneider
DevOps Engineer · Leapcell

서론
현대 소프트웨어 개발의 세계에서 유지보수성, 테스트 용이성, 모듈성은 무엇보다 중요합니다. 파이썬은 동적인 특성과 풍부한 생태계를 통해 이러한 목표를 달성하기 위한 다양한 도구와 패턴을 제공합니다. 그중에서도 의존성 주입(DI)은 컴포넌트 간의 결합도를 낮추고 코드 품질을 향상시키는 강력한 기술로 두각을 나타냅니다. 하지만 모든 강력한 도구와 마찬가지로 DI도 오용될 수 있습니다. 신중한 고려 없이 의존성을 주입하면 우아한 해결책으로 시작된 것이 빠르게 "테스트 불가능한 의존성 지옥"으로 전락할 수 있습니다. 이 글은 파이썬에서 명시적인 Depends (또는 유사한 DI 구성)에 과도하게 의존하는 잠재적인 함정을 탐색하고, 더 중요하게는 개발자가 민첩성을 희생하거나 디버깅 악몽을 만들지 않고 DI의 이점을 활용하는 방법에 대한 지침을 제공하는 것을 목표로 합니다.
과도한 의존성 주입의 문제점
"피하는 방법"에 대해 자세히 알아보기 전에, 이 논의를 이해하는 데 중요한 몇 가지 핵심 용어를 명확히 해보겠습니다.
- 의존성 주입 (DI): 의존성 해결을 위해 제어의 역전을 구현하는 소프트웨어 디자인 패턴입니다. 컴포넌트가 자체 의존성을 생성하는 대신, 외부 엔티티(주입자)가 이를 제공합니다. 이는 느슨한 결합을 촉진합니다.
- 의존성: 다른 객체가 올바르게 작동하기 위해 필요한 객체 또는 서비스입니다. 예를 들어,
UserService는 데이터베이스와 상호 작용하기 위해UserRepository에 의존할 수 있습니다. - "의존성 지옥": 상호 연결된 의존성의 수와 복잡성으로 인해 시스템을 이해, 유지 관리, 테스트 및 배포하기가 어려운 상태입니다.
개발자가 "정확"하거나 "순수"한 DI 구현을 시도하면서 모든 것을 주입하기 시작할 때 문제가 발생합니다. name과 email 속성을 가질 수 있는 간단한 User 객체를 생각해 보세요. name이나 email을 의존성으로 주입해야 할까요? 아마 아닐 것입니다. 이것들은 본질적인 속성입니다. 모든 데이터 조각, 모든 도우미 함수, 모든 사소한 컴포넌트가 명시적으로 주입 가능한 의존성이 되면 다음과 같은 문제가 발생합니다.
- 상용구 과부하: 생성자 시그니처가
Depends지시어로 채워져 지나치게 길어집니다. 이는 코드를 읽고 쓰기 어렵게 만듭니다. - 테스트 설정 복잡성: 단위 테스트의 경우, 간단한 것조차도 이러한 모든 주입된 의존성을 설정하는 것이 험난한 작업이 될 수 있습니다. 모킹이 복잡해지고 테스트는 실제 로직보다 DI 구성을 더 많이 테스트하게 됩니다.
- 취약한 아키텍처: 깊이 중첩된 의존성의 작은 변경 사항이 전체 애플리케이션에 파급되어 수많은
Depends선언의 변경이 필요할 수 있습니다. - 가독성 저하: 핵심 비즈니스 로직이 의존성 선언의 잡음으로 가려져 클래스나 함수의 실제 목적을 파악하기 어렵게 만듭니다.
- 성능 오버헤드 (미미하지만 존재): 종종 무시할 수 있지만, 방대한 수의 의존성을 해결하면 약간의 성능 오버헤드가 발생할 수 있습니다.
Depends를 사용하는 간단한 FastAPI 예제를 살펴보겠습니다.
from fastapi import Depends, FastAPI, HTTPException, status from typing import Annotated # --- 문제가 되는 예제 --- class DatabaseConnection: def __init__(self, host: str, port: int): self.host = host self.port = port print(f"Database connected to {host}:{port}") class UserRepository: def __init__(self, db_conn: DatabaseConnection): self.db_conn = db_conn print("UserRepository initialized") def get_user_by_id(self, user_id: int): # 실제 DB 상호 작용이라고 가정 if user_id == 1: return {"id": 1, "name": "Alice"} return None class AuthService: def __init__(self, user_repo: UserRepository, secret_key: str): # secret_key 조차도 주입될 수 있음 self.user_repo = user_repo self.secret_key = secret_key print("AuthService initialized") def authenticate_user(self, user_id: int): user = self.user_repo.get_user_by_id(user_id) if user: return f"Authenticated: {user['name']}" raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") # 의존성 프로바이더 def get_db_connection() -> DatabaseConnection: return DatabaseConnection(host="localhost", port=5432) def get_user_repository( db_conn: Annotated[DatabaseConnection, Depends(get_db_connection)] ) -> UserRepository: return UserRepository(db_conn=db_conn) def get_secret_key() -> str: return "super-secret-key-123" app = FastAPI() @app.get("/users/{user_id}") async def read_user( user_id: int, auth_service: Annotated[AuthService, Depends(AuthService)] # 여기서 AuthService도 주입됩니다. 암묵적으로 이의 의존성을 사용합니다. ): # 여기서 복잡해집니다. AuthService 자체에 의존성이 필요합니다. # FastAPI가 유형이 일치하면 자동으로 해결하지만, 이는 경계를 넓히는 것입니다. # AuthService에 의존성이 10개 있었다면? return auth_service.authenticate_user(user_id)
위 예제에서 AuthService 자체는 주입 가능한 의존성이 됩니다. FastAPI가 UserRepository와 secret_key의 의존성을 지능적으로 해결하지만, AuthService가 훨씬 더 많은 의존성을 필요로 한다고 상상해 보세요. 각 의존성은 자체 체인을 가지고 있습니다. /users/{user_id} 엔드포인트의 시그니처는 AuthService의 모든 의존성을 명시적으로 정의하면 관리 불가능해지고, 암묵적 해결을 사용하더라도 의존성 그래프의 정신적 모델이 복잡해집니다. AuthService를 직접 테스트하려면 UserRepository와 secret_key를 제공해야 하며, 이는 자체적으로 DatabaseConnection이 필요합니다.
의존성 지옥을 피하는 전략
핵심은 실제 의존성, 즉 변동하거나 복잡한 것에 초점을 맞춰 DI를 분별력 있게 적용하는 것입니다. 모든 사소한 컴포넌트가 아닌, 그런 것에 초점을 맞추는 것입니다.
-
"의존성"과 "속성" 구분:
- 의존성: 외부 서비스, 복잡한 객체, 구성 가능한 리소스 또는 교체될 수 있는 컴포넌트 (예:
UserRepository,EmailService,Logger). DI의 주요 후보입니다. - 속성/값 객체: 간단한 데이터 유형, 구성 값 (서비스가 아닌 한) 또는 외부 상태에 의존하지 않는 자체 포함 객체 (예:
User객체,API_KEY문자열,PAGE_SIZE정수). 일반적으로 직접 인수로 전달되거나, 진정으로 전역적이고 불변적인 경우 전역 구성에 액세스하여야 합니다.
# -- 속성에 대한 개선된 접근 방식 -- class User: def __init__(self, user_id: int, name: str, email: str): self.user_id = user_id self.name = name self.email = email # user_id, name, email이 간단한 데이터인 경우 의존성으로 주입할 필요가 없습니다. # 애플리케이션이 이러한 값을 User 생성자에 *제공*합니다. def create_user_handler(user_id: int, name: str, email: str): user = User(user_id=user_id, name=name, email=email) # ... 로직 ... - 의존성: 외부 서비스, 복잡한 객체, 구성 가능한 리소스 또는 교체될 수 있는 컴포넌트 (예:
-
관련 설정에 대한 구성 객체 사용:
DB_HOST,DB_PORT,DB_USER,DB_PASSWORD를 개별적으로 주입하는 대신, 이를DatabaseConfig객체로 그룹화하고 해당 객체 하나를 주입합니다.from pydantic import BaseSettings # 또는 모든 구성 관리 라이브러리 class AppSettings(BaseSettings): database_host: str = "localhost" database_port: int = 5432 # ... 기타 설정 class Config: env_file = ".env" def get_app_settings() -> AppSettings: return AppSettings() class DatabaseConnection: def __init__(self, settings: AppSettings): # 구성 객체 주입 self.host = settings.database_host self.port = settings.database_port print(f"Database connected to {self.host}:{self.port}") # 이제 get_db_connection은 AppSettings에만 의존합니다. def get_db_connection(settings: Annotated[AppSettings, Depends(get_app_settings)]) -> DatabaseConnection: return DatabaseConnection(settings=settings)이렇게 하면 생성자 인수와
Depends호출 수가 크게 줄어듭니다. -
수명 주기 관리를 위한 컨텍스트 관리자 활용: 설정 및 해제가 필요한 리소스(데이터베이스 연결, 파일 핸들 등)의 경우, FastAPI의
Depends함수 내yield패턴이 훌륭합니다. 이는 리소스 관리를 캡슐화합니다.from contextlib import contextmanager class ManagedDatabaseConnection: def __init__(self, host: str): self.host = host print(f"Opening connection to {host}") def close(self): print(f"Closing connection to {self.host}") @contextmanager def create_managed_db_connection_context(): db = ManagedDatabaseConnection(host="my_db_server") try: yield db # 의존성 제공 finally: db.close() # 해제 로직 def get_managed_db_connection(): with create_managed_db_connection_context() as db_conn: yield db_conn # 이런 식으로 사용: db_conn: Annotated[ManagedDatabaseConnection, Depends(get_managed_db_connection)]이는 "Depends"를 줄이는 것보다는, 의존성 내부의 복잡성을 관리하여 호출 코드가 연결 세부 정보 또는 해제 로직을 알 필요가 없도록 하는 데 더 관련이 있습니다.
-
상속보다 조합 사용 (깊은 상속 및 평면 구조): 서비스가 너무 많은 의존성을 가진다면, 너무 많은 일을 하고 있는 것은 아닌지 고려해 보세요. 더 작고 집중된 서비스로 분할하세요. 각 작은 서비스는 더 적은 직접적인 의존성을 가질 것입니다. 이는 단일 책임 원칙의 적용입니다.
# -- 리팩터링된 접근 방식 (조합) -- class EmailService: def send_email(self, recipient: str, subject: str, body: str): print(f"Sending email to {recipient} with subject '{subject}'") class NotificationService: # 알림에 집중 (이메일, SMS 등 사용 가능) def __init__(self, email_service: EmailService): self.email_service = email_service def notify_user_registration(self, user_email: str): self.email_service.send_email(user_email, "Welcome!", "Thanks for registering!") class UserService: # 사용자 데이터 관리에 집중 def __init__(self, user_repo: UserRepository, notification_service: NotificationService): self.user_repo = user_repo self.notification_service = notification_service def register_user(self, name: str, email: str): # ... 저장소에 사용자 생성 ... self.notification_service.notify_user_registration(email) return {"message": "User registered and notified"} # 이제 UserService는 NotificationService에 의존하며, NotificationService는 *내부적으로* EmailService에 의존합니다. # 의존성 그래프는 여전히 존재하지만, 조합은 각 레벨에서 생성자 시그니처를 깔끔하게 유지합니다.이 접근 방식은
UserService의 직접적인 의존성(UserRepository, NotificationService)을 관리 가능하게 유지하는 반면,NotificationService는 자체 의존성(EmailService)을 처리합니다. -
테스트 용이성을 염두에 두기:
Depends를 추가하기 전에 스스로에게 물어보세요: "이 컴포넌트를 격리하여 어떻게 테스트할 것인가?" 새로운 의존성을 주입하는 것이 테스트 설정을 현저히 복잡하게 만든다면, 그것이 DI의 진정한 의미에서의 의존성인지, 아니면 단순히 스테이틱 메서드나 직접 임포트(실제로 상태가 없고 보편적으로 사용 가능한 경우)로 더 나을 수 있는 간단한 값 또는 도우미 함수인지 재평가하세요.
결론
의존성 주입은 강력하고 유지보수 가능하며 테스트 가능한 파이썬 애플리케이션을 구축하는 데 초석입니다. 그러나 Depends 또는 유사한 구성 요소의 무차별적인 사용은 관리 불가능한 "의존성 지옥"으로 이어질 수 있습니다. 진정한 의존성과 단순한 속성을 신중하게 구분하고, 구성 객체를 활용하며, 리소스 수명 주기를 위해 컨텍스트 관리자를 사용하고, 조합을 연습하며, 항상 테스트 용이성을 염두에 둠으로써 개발자는 상용구에 빠지거나 취약하고 이해하기 어려운 시스템을 만들지 않고 DI의 힘을 활용할 수 있습니다. 목표는 끝없는 명시적 주입 체인이 아니라 우아한 결합입니다.

