본문으로 바로가기
728x90
반응형

목차

     

    이 글의 DI는 의존성 주입(Dependecny injection)을 의미한다.

    개요 

    FastAPI는 Django와 Flask랑 확연이 차이가 있다는 지점을 느낀 부분이 DI이다. FastAPI의 DI를 사용할 수도  있으며 dependency-injector라는 라이브러리를 이용해서 DI를 사용할 수도 있다.
     
    이 글은 FastAPI의 dependency-injector라는 라이브러리를 적용하기 위해 고민했던 흔적을 남긴다.
     
     

    1. 부트스트래핑과 DI

    dependency-injector를 적용하기 위해 먼저 고민했던 것은 DI의 시작점을 어떻게 가져갈 것인지에 대한 부분이었다. django에서는 settings.py라는 것을 통해 간접적으로 드러나는데 FastAPI로 넘어오면서 프로젝트 구성에 대한 명시적인 부분이 많이 사라진 상태였다.
     
    이에 관련하여 "파이썬으로 살펴보는 아키텍처 패턴"이라는 책에서 힌트를 얻었다. 이 책에서는 "부트 스트랩 스크립트"를 이용하라는 것이다. "부트스트랩 스크립트"는 애플리케이션(FastAPI)이 시작될 때  다음과 같은 일을 하는 스크립트라고 설명된다.

    1. 디폴트 의존성을 선언하지만 원하는 경우 이를 오버라이드 할 수 있어야 한다.
    2. 앱을 시작하는데 필요한 초기화를 수행한다.
    3. 모든 의존성을 핸들러에 주입한다.
    4. 앱의 핵심 객체인 메시지 버스를 반환한다.

    책에서는 이를 "bootstrap.py"라는 파일에 위에 해당하는 개념의 코드들이 작성되어 있다. 그런데 bootstrap이라는 단어가 "HTML/CSS/JS"를 만들 때 사용하는 라이브러리가 더 대중적이라 잘 와닿지 않았기에 config라는 패키지 밑에 setup이라는 module을 만들었다. 구조는 다음과 같다.

    ├── src
    │   ├── app.py
    │   ├── application
    │   │   ├── __init__.py
    │   │   ├── apis
    │   └── config
    │       ├── __init__.py
    │       └── setup.py

    그리고 FastAPI를 실행하는 코드가 작성되어 있는 app.py에 다음과 같은 형태의 코드를 작성했다.

    from __future__ import annotations
    
    from fastapi import FastAPI
    
    from src.config import setup
    
    class FastApi:
        def __init__(self, app):
            self.app = app
    
        def __call__(self, *args, **kwargs):
            setup.router(self.app)
            setup.container()
    
            return self.app
    
    
    server = FastApi(app=FastAPI())

    책에서는 bootstrap.py에 bootstrap이라는 함수가 들어있고 이 함수를 통해 Application이 실행되는데 필요한 모든 동작을 정의했지만 이런 형태는 처음 사용하다 보니 단계별로 변경할 수 있기에 함수를 분리했다.
     
     

    2.  dependency-injector 사용하기

    앞에서 bootstrap 스크립트가 하는 일에 대한 목록을 나열했을 때 내가 주목한 부분은 2번이다.
     
    "앱을 시작하는데 필요한 초기화를 수행한다"라는 문장을 "instance를 생성할 컨테이너들을 정의하는 곳"이라는 하나의 해석을 하게 만들었다. 그래서 container라는 개념으로 다룰 수 있는 dependency-injector라는 라이브러리를 사용하기로 했다.
     
    DI를 통해 사용할 클래스들을 Container라는 개념으로 사용하는 게 개념별로 잘 정립된 컴포넌트를 만들 수 있다는 것이 큰 장점이라고 생각된다. 예를 들어 Repository에 대한 Container는 다음과 같은 형식이다.

    from dependency_injector import containers
    
    class RepositoryConatiner(containers.DeclarativeContainer): ...

     
     

    3. Container에 Session 담기

    제일 처음 Container에 무엇을 넣을 것인지에 대해 생각해 보다가 "DataBase Session을 넣자"라는 생각이었다. 아래 코드는 SqlAlchemy를 통해 session을 생성하는 Class와 관련 함수이다.

    class SqlAlchemySessionFactory(AbstractSessionFactory):
    
        @classmethod
        def get_session(cls,drivername: str,username: str,password: str,database: str,host: str,port: int) -> Session:
            url = URL.create(
                drivername=drivername,
                username=username,
                password=password,
                host=host,
                port=port,
                database=database
            )
            engine = create_engine(url, echo=True)
    
            _Session = sessionmaker(bind=engine, autocommit=False, autoflush=True)
            session = _Session()
            return session
    
    db_session = SqlAlchemySessionFactory.get_session(
        drivername=DATABASE['DRIVER'],
        username=DATABASE['USERNAME'],
        password=DATABASE['PASSWORD'],
        database=DATABASE['NAME'],
        host=DATABASE['HOST'],
        port=DATABASE['PORT']
    )

    그리고 이를 Container에 binding 할 수 있게 providers.Dependency()를 만들자.

    from dependency_injector import providers, containers
    
    class DataBaseContainer(containers.DeclarativeContainer):
        session = providers.Dependency()

    이제 setup.py의 container 함수에서 이를 가져다 사용할 수 있다.

    def container():
        database_container = DataBaseContainer(session=db_session)
        database_container.wire(modules=[__name__])

    위 코드가 실제 사용되는 시점은 FastApi 클래스가 실행되는 시점이다. 바꿔 말하면 uvicorn으로 FastAPI를 실행할 때 container 함수에 작성한 DataBase가 DB Session을 초기화하는 셈이 된다.
     

    4. Container에 Repository 넣기

    앞서 database_container를 생성할 때 modules를 __name__으로 한정했다. 이는 의도한 것이며 session을 사용해야 되는 대상을 container 함수 안에서 전달할 수 있도록 구성하기 위한 고민 중 하나였다. 이 고민의 실제 예시는 Repository에서 table의 데이터가 접근할 때 DB session을 필요로 한다는 점에서 출발한 것이다.
     
    Repository의 코드는 다음과 같이 생겼다.

    from __future__ import annotations
    
    from abc import ABCMeta
    
    class AbstractRepositry(metaclass=ABCMeta):
    
        def __init__(self, session):
            self._session = session
    
    class MemberRepoitory(AbstractRepositry):
    
        def get_member(self, member_id):
            sql = "SELECT * FROM member WHERE member_id=:member_id;"
            self._session.execute(sql, params={"member_id": member_id})
            return self._session.fetchone()

    SQLAlchemy는 ORM의 특화되었기에 Model을 통해서 데이터를 가져와도 되지만 session을 굳이 사용했던 이유는 어떤 SQL을 날리는지 확인하기 위함이다.  그리고 이에 대한 Container는 다음과 같이 작성할 수 있다.

    class RepositoryConatiner(containers.DeclarativeContainer):
        session = providers.Dependency()
    
        member_repository: MemberRepoitory = providers.Factory(
            MemberRepoitory, session=session
        )

    DI Container에 등록할 Repository가 늘어나면 session을 계속 전달하는 코드가 중복으로 발생하지만 이는 나중에 다시 고민해 봐야겠다. 이제 setup.py의 container는 다음과 같이 작성할 수 있다.

    def container():
        database_container = DataBaseContainer(session=db_session)
        database_container.wire(modules=[__name__])
    	
        # 추가된 부분
        repository_container = RepositoryConatiner(session=database_container.session)
        repository_container.wire(
            packages=[
                'src.application.apis'
            ])

    wire를 호출하는 부분에서 packages, modules를 설정할 수 있으며 위에 설정된 packages의 경로는 예시로 넣은 것이다. 핵심은 이러한 옵션을 통해 DI를 적용할 위치를 선택할 수 있다는 점이다.
     

    5. DI 사용하기

    DI를 통해 사용할 container를 설정해 두었으니 이제 사용할 차례이다. 다음 예시로 작성된 코드이다.

    from dependency_injector.wiring import Provide
    from src.config.ioc import RepositoryConatiner
    
    class MemberSevice:
        member_repository = Provide[RepositoryConatiner.member_repository]
    
        @classmethod
        def get_member(cls, member_id: int):
            return cls.member_repository.get_member(member_id)
    
    
    @member_router.get(path="/member")
    async def member_retreieve():
        member_serivce = MemberSevice.get_member(member_id=1)
        print(member_serivce)
        return JSONResponse(content={})

    설정해 둔 Container로부터 instance를 얻어다 사용할 수 있다.
     
     

    마치며

    사실 Python에서 DI는 심각하게 고민하지 않아도 되는 주제이다. 동적 타이핑 언어가 DI를 써야된다면 그건 무슨 효과를 기대하기 위함일까? Python의 장점은 가벼운 문법과 빠른 개발 속도인데..

     

    하지만 DI를 적용한다는 건 단순히 객체들의 관계를 표현하는 것보다 코드의 흐름이다. 더 나아가서는 복잡한 컴포넌트의 흐름을 나타내는 데서 그 가치가 있다고 생각한다. 복잡한 객체 관계의  흐름을 알아볼 수 있어야 유지보수가 편하고 특정 부분에 경계를 그어 테스트하기 용이하게 만든다.

     

    DI를 적용하기 위한 방법보다 DI를 사용함으로써 얻는 이점을 통해 어떻게 사용하니까 좋다더라라는 경험을 가지는게 좋을 듯 싶다.

    728x90
    반응형