Python/FastAPI 멀티모듈 프로젝트 구성기 (Dependency Injection, Layered Architecture, ...)

Python/FastAPI 멀티모듈 프로젝트 구성기 (Dependency Injection, Layered Architecture, ...)
GitHub - byunjuneseok/fastapi-multi-module-project: 📦 Fastapi Multimodule project boilerplates. Build more flexible, testable and maintainable api service.
📦 Fastapi Multimodule project boilerplates. Build more flexible, testable and maintainable api service. - GitHub - byunjuneseok/fastapi-multi-module-project: 📦 Fastapi Multimodule project boilerpla...
빠른 요약

커머스 프로젝트를 런칭하면서 정리된 코드는 위에 샘플로 작성해두었다.

지금까지의 파이썬 개발은...

높은 커플링 (Red Edges), 낮은 응집도

많은 초기 단계 스타트업에서 python을 주 언어로 사용할 때, django를 사용한다. 하지만, django와 django-restframework 는 복잡한 로직을 다루는데 한계점이 분명하다. restframework는 Database의 모델부터 User-side의 어플리케이션까지 강결합으로 구현되어 제공되는 프레임워크이다. 간단한 CRUD 정도만 필요한 어플리케이션을 만들어내는 것 정도야 쉬운 일일 수 있겠지만, 복잡해질 수록 그 강결합을 다루기 위한 엄청난 비용을 지불할 수 밖에 없다. 본인도 그 상황에 허우적대며 생산성을 낭비하는 환경을 경험해 본 적이 있다. (뭐, 물론 restframework 위에서도 잘 해쳐나갈 방법이 있지만!)

낮은 커플링 (Green edge) , 높은 응집도
Loose coupling - Wikipedia

결합을 느슨하게 설계(Loose Coupling)하여야 훨씬 더 확장하기 쉬운 개발 환경을 만들 수 있을 거다. 각 컴포넌트 간의 결합을 최소화하고 각자의 관심사를 잘 분리하여 갖고 있다면, 확장하는 속도는 훨씬 빨라질 것.

DI (Dependency Injection)

Dependency Injector — Dependency injection framework for Python — Dependency Injector 4.41.0 documentation
Dependency Injector is a dependency injection framework for Python. It helps to maintain you application structure. It was designed to be unified, developer-friendly tool that helps to implement dependency injection design pattern in formal, pretty, Pythonic way. Dependency Injector provides impleme…

위에서 이야기한 낮은 커플링, 높은 응집도를 달성하기 위해 도입한 방법론. "Dependency Injection".

스프링 환경에서 개발하다보면 이해하기 더 쉬운 측면이 있겠지만..

"토비의 스프링"이라는 책을 쓴 이일민씨는 저서에서 아래와 같이 기술하였다.

  • 클래스 모델이나 코드에는 런타임 시점의 의존관계가 드러나지 않는다. 그러기 위해서는 인터페이스만 의존하고 있어야 한다.
  • 런타임 시점의 의존관계는 컨테이너나 팩토리 같은 제3의 존재가 결정한다.
  • 의존관계는 사용할 오브젝트에 대한 레퍼런스를 외부에서 제공(주입)해줌으로써 만들어진다.

- 이일민, 토비의 스프링 3.1, 에이콘(2012), p114

이제부터 사용할 파이썬의 라이브러리는 dependency injector. 이제부터 이 라이브러리가 각자 컴포넌트들의 의존성을 조립하는 역할을 하게 된다. 컴포넌트의 생성자에서 다른 컴포넌트를 만드는 행위를 더 이상 하지 않게 된다. 즉, 이 라이브러리가 위에서 이야기하는 제3의 존재가 되는 것이고, 이 라이브러리를 통해서 훨씬 더 명확하게 어플리케이션 전체의 의존성을 더 명시적으로 (Explicit!) 관리할 수 있게 된다.

from dependency_injector.containers import DeclarativeContainer, WiringConfiguration
from dependency_injector.providers import Configuration, Container

from domains.container import DomainsContainer
from infra.container import InfraContainer
from systems.container import SystemContainer


class MainContainer(DeclarativeContainer):
    # Configurations
    config = Configuration(yaml_files=["config.yml"])
    wiring_config = WiringConfiguration(packages=["applications"])

    # Infra
    infra = Container(InfraContainer, config=config.infra)

    # Systems
    systems = Container(SystemContainer, config=config.systems)

    # Domains
    domains = Container(
        DomainsContainer,
        config=config.domains,
        user_repository=infra.rdb.users.user_repository,
        notification_push_api=systems.notification_push_api,
    )

Layered architecture, 전체의 프로젝트 구도가 이렇게 하나의 컨테이너로 표현될 수 있다.

물론 꼭 위처럼 컨테이너를 구성할 필요는 없다. 아래처럼 해도 된다.

예를들자면, 이렇게.. 

프로젝트가 커져서 의존관계를 표현하는 메인컨테이너가 커질 수 있어서 처음부터 레이어들을 Container 화 시켜두었다.

Architecture

프로젝트의 레이어는 위와 같이 구성해두었다.

예를 들어, FastAPI는 하나의 어플리케이션 클래스일뿐, 도메인 영역과 시스템 영역, 그리고 인프라 영역에는 전혀 관계없이 구현되어야 한다. 즉, FastAPI의 라이브러리들이 서비스 및 그 하위 레이어에 임포트될 일은 없다는 뜻이다.

from typing import TYPE_CHECKING
from uuid import uuid4

from .user import User
from .user_creator import UserCreator
from .user_reader import UserReader

if TYPE_CHECKING:
    from systems.notification_push_api.client import NotificationPushAPI


class UserService:
    def __init__(
        self,
        user_creator: UserCreator,
        user_reader: UserReader,
        notification_push_api: "NotificationPushAPI",
    ):
        self.user_creator = user_creator
        self.user_reader = user_reader
        self.notification_push_api = notification_push_api

    async def create_user(self, user: User) -> User:
        return await self.user_creator.create_user(user)

    async def get_user(self, user_id: int) -> User | None:
        return await self.user_reader.get_user(user_id)

    async def get_users(self, user_ids: list[int]) -> list[User]:
        return await self.user_reader.get_users(user_ids)

    async def send_user_created_notification(self, user: User) -> None:
        await self.notification_push_api.enqueue_push(message_id=uuid4.__str__(), payload=user.model_dump())

컴포넌트가 각자 알아야 하나는건 DI 에 의하여 의존된 컴포넌트들의 "인터페이스" 자체라는 것을 이해하여야 한다.

Testing

특히 django 의 경우는 어떤 로직을 테스트할 때, test database를 테스트 런타임에 구성하여 인프라부터 테스트하는 경우를 많이 경험했을 것이다. 하지만, 이는 어떤 환경에서나 항상 테스트를 성공시키기 힘들 수도 있고, 생산성도 느릴 것이고, 테스트를 위해 너무나 많은 정보를 알아야 한다는 것이다.

예를 들어, User Service를 테스팅하는 목적은 유저 서비스만의 도메인 지식이 잘 구현되었나를 테스트하려는 것일 텐데, 데이터베이스부터 알아야된다면 곤란하다.

테스트하고자 하는 시스템인 UserService가 의존하는 UserCreator, UserReader, NotificationPushAPI 컴포넌트의 동작을 알 필요는 전혀 없고, 필요한 수준만큼의 모킹이 된다면, 테스트는 매우 빠르고 쉬울 것이다.

from unittest.mock import Mock

import pytest

from domains.users.user import User
from domains.users.user_creator import UserCreator
from domains.users.user_service import UserService


class TestUserService:
    sut: UserService

    @pytest.mark.asyncio
    async def test_create_user(self, container):
        # arrange
        user_creator = Mock(spec=UserCreator)
        user_creator.create_user.return_value = (
            expected := User(
                id=1,
                username="tester-name",
                email="test-email@email.com",
                first_name="test-first-name",
                last_name="test-last-name",
            )
        )
        
        # act
        with container.domains().users().user_creator.override(user_creator):
            user_service = container.domains().users().user_service()
            result = await user_service.create_user(
                user=User(
                    username="tester-name",
                    email="test-email@email.com",
                    first_name="test-first-name",
                    last_name="test-last-name",
                )
            )
        
        # assert
        assert expected.id == result.id
        assert expected.username == result.username
        assert expected.email == result.email
        assert expected.first_name == result.first_name
        assert expected.last_name == result.last_name

Conclusion

  • 개발 관점을 프레임워크에 의존하지 않고 격리시키자.
  • 높은 응집도, 낮은 결합을 달성하기 위해 노력하자.
  • 그렇다면, 확장의 유연함, 쉬운 테스팅, 높은 유지보수성을 갖춘 개발환경을 구성할 수 있다.