Frame Work/FastAPI

[FastAPI] 실행은 Factory Pattern을 적용하자.

j4ko 2023. 9. 15. 23:30
728x90
반응형

목차

     

    개요

    최근에 FastAPI를 통해 이것저것 시도해보고 있는 중이다. 무언가를 새롭게 배우고 있는 중이라 확실히 알아가는 재미를 느끼고 있다. 

     

    문제는 개발하려는 개념을 쪼개서 Project Directory에 녹이려 하다 보니 FastAPI Document에서 제시하는 내용을 가지고는 부족함을 느끼고 있다.

     

    그래서인지 항상 Google에 이것저것 검색하게 되면서 "삽질"의 시간이 늘어남을 체감 중이다.

     

    이번 글에 기록할 내용은 FastAPI를 실행시키기 위한 진입점인 main.py 혹은 경우에 따라서는 app.py 로도 사용하는 파일에 대해 고찰한 내용이다.

     

     

    FastAPI, main.py?

    FastAPI Document는 FastAPI를 구동시키기 위해 main.py라는 파일에  다음과 같은 코드를 사용하라고 가이드해 준다.

    from typing import Union
    
    from fastapi import FastAPI
    
    app = FastAPI()
    
    
    @app.get("/")
    def read_root():
        return {"Hello": "World"}
    ...

    FastAPI Document에 나와있는 방법이기에 FastAPI를 실행시키기 위한 단순하고 확실한 방법이다. 그런데 위와 같은 방법을 계속 사용하다 보니 무언가 부족함이 느껴졌다.

     

     

    "부족함"이란?

    앞서 언급한 FastAPI를 실행시키기 위한 코드는  "Application 시작 시`무엇`을 Setup 하는가?"라는 의도를 확실히 드러내는데 부족하다고 느껴졌다.

     

    아키텍처의 개념을 정확히 Project Source에 나타내진 않더라도 자연스레 개발을 할 때 Project Source에 특정 개념에 해당하는 Directory를 생성하고 그에 걸맞은 코드를 넣게 된다. 예를 들어 API Endpoint만을 나타내는 Directory나 Business Logic만을 다루는 module과 같은 것을 Package화 시킨다는 점에서 그러하다.

     

    즉 앞서 언급한 FastAPI를 실행시키기 위한 코드는 main.py에다가 이러한 의도를 다 때려 박아서 FastAPI를 실행시킨다는 점이다. 예를 들어 FastAPI의 APIRouter가 늘어난다면 다음과 같이 main.py의 코드가 늘어나게 될 것이다.

    # Bad Example: 계속 늘어나는 APIRouter
    
    from __future__ import annotations
    from fastapi import FastAPI, APIRouter
    
    app = FastAPI()
    app.include_router(APIRouter())
    app.include_router(APIRouter())
    ...

    예시를 단순히 APIRouter로만 작성했는데  여러 설정들을 고려한다면 main.py는 "Application 시작 시 `무엇`을 Setup 하는가?"라는 것을 표현하는 곳이어야 한다라는 게 필자의 생각이다.

     

     

    그래서 어떻게 표현할 것인가?

    이러한 특정한 Setup을 명시하기 위해 main.py의 내용은 적절히 분리되어야 한다.

     

    그렇다면 main.py의 "코드"를 어떻게 만들어야 할까?

    사실 무엇이 맞고 무엇이 틀리고는 없는 문제이다.

     

    이에 대해서 필자는 Application Factory라는 개념에 class를 적용하는 방법을 선택했다.

     

     

    Application Factory Pattern으로  실행하도록 구성하자.

    간혹 Flask의 예제들을 보면 다음과 같이 Application의 Instance를 얻어서 실행하도록 만드는 Application Factory라는 방법을 이용한다. 이를 그대로 FastAPI에 적용하면 다음과 같이 만들 수 있다.

    from fastapi import FastAPI
    
    def create_app():
        app = FastAPI()
        return app
        
    app = create_app()

    그러나 필자의 스타일상 "def"보다는 "class"를 통해 위와 같은 동작을 하도록 구성했다.

    # main.py
    
    from __future__ import annotations
    
    from fastapi import FastAPI
    
    
    class Application:
    
        def __init__(self, app: FastAPI):
            self.app = app
    
        def __call__(self):
            return self.app
    
    application = Application(app=FastAPI())

    이제 위와 같이 구성했을 때 얻을 수 있는 부분들을 알아보자.

     

    순환 참조를 회피할 수 있다.

    순환참조란?
    - A가 B를 참조하고 B가 다시 A를 참조하는 경우

    FastAPI에서 가이드하는 대로 app을 만들어 사용하게 되면 app 객체를 전역적으로 접근하는 경우가 발생한다. 아래는 예시를 위해 구성한 구조이다.

    .
    ├── README.md
    ├── poetry.lock
    ├── pyproject.toml
    ├── src
    │   └── fastapi_lab
    │       ├── __init__.py
    │       ├── apis
    │       │   ├── __init__.py
    │       │   └── member.py
    │       ├── config
    │       │   ├── __init__.py
    │       │   └── setup.py
    │       └── main.py
    └── tests
        └── __init__.py

    fastapi_lab의 main.py는 다음과 같이 생겼다.

    from __future__ import annotations
    
    from fastapi import FastAPI
    from src.fastapi_lab.apis.member import member_router
    from src.fastapi_lab.config import setup
    
    db = setup.inmemory_db()
    
    applicaiton = FastAPI()
    
    applicaiton.include_router(member_router)

    member_router가 정의된 member.py는 다음과 같이 생겼다.

    from src.fastapi_lab.main import db
    
    from fastapi import APIRouter
    from fastapi.responses import JSONResponse
    
    member_router = APIRouter(tags=['Member'], prefix="/v1/member")
    
    @member_router.get(path="")
    def get_member():
        member = db.get_member()
    
        return JSONResponse(content={})

    이와 같이 구성하고 Application을 실행하면 다음과 같은 Error 일어난다.

    ImportError: cannot import name 'db' from partially initialized module'src.fastapi_lab.main' (most likely due to a circular import)
    (/Users/jako/private/opt-repo/fastapi_lab/src/fastapi_lab/main.py)

     

    위 상황은 member의 정보를 얻는 api에서 db에서 member를 얻기 위해 main.py에서 생성된 db 객체에 접근하는 경우이다. 보시다시피 순환참조가 발생한다. 이를 Application Factory로 구성할 경우 문제를 회피할 수 있게 된다.


    테스트하기 좋다.

    테스트 코드를 작성할 때 외부 애플리케이션이나 인프라에 의존해야 하는 경우가 종종 생긴다. 그런데 전역적으로 application을 만들어 사용하도록 하게 되면 외부와 커넥션을 일으킬 때 많이 불편하다. 예시는 다음과 같다.

    # main.py
    from __future__ import annotations
    
    from fastapi import FastAPI
    from src.fastapi_lab.config import setup
    
    db = setup.db()
    
    applicaiton = FastAPI()
    
    
    # test.py
    from fastapi.testclient import TestClient
    from src.fastapi_labs.main import application
    
    client = TestClient(application())

    test.py에서 정의된 test client는 main.py에 정의된 application이 외부 객체를 설정하는 부분이 없기 때문에 유연하지 못하다. Application Factory를 이용한 경우 다음과 같은 방식으로 해결할 수 있게 된다.

    # main.py
    
    from __future__ import annotations
    
    from fastapi import FastAPI
    
    
    class Application:
    
        def __init__(self, app: FastAPI, db=None):
            self.app = app
            self._db = db
    
        def __call__(self):
            return self.app
    
    application = Application(app=FastAPI())
    
    
    # test.py
    from fastapi.testclient import TestClient
    from src.fastapi_labs.main import application
    
    
    inmemory = dict()
    
    client = TestClient(application(db=inmemory))

    물론 코드를 구성하는 방식과 스타일에 따라 다르다. 그러나 위와 같은 코드를 통해 드러내고자 함은 테스트 코드를 실행 시 유연하게 Application을 생성하여 테스트할 수 있다는 점일 것이다.

     

    src layout으로 구성해 보기

    이제 class를 통해 FastAPI Application을 실행할 수 있으니 Project 예시를 작성해 보자

    ╰─$ tree -L 3
    .
    ├── README.md
    ├── poetry.lock
    ├── pyproject.toml
    ├── src
    │   └── fastapi_lab
    │       ├── __init__.py
    │       ├── __pycache__
    │       └── main.py
    └── tests
        └── __init__.py

    Project는 poetry를 이용해 src layout 구조로 잡았다. 위와 같은 구조에서 uvicorn으로 FastAPI를 실행시키기 위한 명령은 다음과 같다.

    poetry run uvicorn src.fastapi_lab.main:application

    위 Project 구조에서 Project 환경 설정에 관련된 개념을 추가한다면 다음과 같을 것이다.

    ╰─$ tree -L 4
    .
    ├── README.md
    ├── poetry.lock
    ├── pyproject.toml
    ├── src
    │   └── fastapi_lab
    │       ├── __init__.py
    │       ├── config # 추가된 부분
    │       │   ├── __init__.py
    │       │   └── setup.py
    │       └── main.py
    └── tests
        └── __init__.py

    위와 같은 구조의 setup에서 다음과 같은 함수를 작성할 수 있을 것이다.

    # config.py
    from fastpi import FastAPI
    
    # enviorment
    THIRD_PART_API_KEY = os.environ.get("THIRD_PART_API_KEY", None) 
    
    # bootstrapping
    def routers(app:FastApi): ... # Apis  
    def di(): ... # DataBase, Cache, Etc.. 
    def exception_hander(app:FastAPI): ... 
    def middleware(app:FastAPI): ...

    이를 main.py에서 가져다 사용하자.

    # main.py
    from __future__ import annotations
    
    from src.fastapi_lab.config import setup
    from fastapi import FastAPI
    
    class Application:
    
        def __init__(self, app: FastAPI):
            self.app = app
    
        def __call__(self):
        	setup.router(self.app)
        	setup.di()
            setup.exception_handler(self.app)
            setup.middleware(self.app)
        
            return self.app
    
    application = Application(app=FastAPI())

     

    마치며

    지금까지 기재한 내용은 지극히도 필자 개인의 스타일 문제이다. django를 사용하다 보니 django의 project setup 과정에서 얻은 아이디어를 적용하려다 보니 위와 같은 느낌의 코드를 사용하게 되었다. 위와 같이 구성하는 게 외부 의존성을 관리하고 적절한 Mock 객체들을 설정하는데 유용하다고 봤던 것 같다.

     

    그렇다고 해서 "이렇게 해야 합니다" 혹은 "이렇게 하는 걸 권장드립니다"도 아니며 "이런 관점에서 작성해 봤습니다"라는 정도의 글이기 때문에 main.py에 FastAPI를 실행시키기 위한 코드를 어떤 식으로 작성할지는 상황에 맞게 잘 작성하도록 하자.

     


    728x90
    반응형