본문으로 바로가기

[FastAPI] FastAPI MVC Pattern !

category Frame Work/FastAPI 2024. 8. 16. 21:45
728x90
반응형

 

목차

     

    개요 

    근 7~8개월간 계속 FastAPI를 사용했었다.

     

    주로 사이드 프로젝트나 회사에서 FastAPI로 개발 시 코드 베이스를 어떻게 구성하면 좋을까에 대한 고민들을 거쳤다. 익숙한 것이 MVC 이기 때문에 이를 기반으로 FastAPI를 다룰 때는 계속 MVC라는 밑그림 위에서 코드 베이스를 구성해 나갔다.

     

    그렇게 시간이 지나다 보니 FastAPI로 MVC를 만들 때 기초적인 밑그림 정도는 이렇게 잡아놔야지라는 개인적인 스타일이 생겼다. 이 글에 작성된 내용과 방식이 옳든 틀리든 FastAPI로 개발하면서 만들었던 이 "스타일"에 대한 정리와 필자 개인의 생각을 남기려 한다.

     

    1.  FastAPI에서 MVC를?!

    일단 MVC라는 것을 짚어보자. MVC는 Model, View, Controller라는 역할로 나눠 개발하는 것을 뜻한다.  그러나 이 글에서 설명할 MVC는 "Controller - Service - Repository"로 쪼개는 구조를 뜻한다.

     

    그렇다. 엄밀히 말하자면 MVC라기보다 "Presentation - Business Logic - Data Access" Tier로 나눠진 3 Tier 구조가 더 맞는 표현이다. 

    (그러나 MVC라고 표현하는 이유는 MVC가 더 친숙하기 때문이다.)

     

    각설하고 FastAPI에서 MVC, 즉  3-Tier를 사용하는 것 자체는 고민해 볼 만한 문제다.

     

    FastAPI로 프로젝트를 개발한다는 건 Python을 개발언어로 채택했다는 것이고 Python으로 개발한다는 것은 Python의 편리한 문법으로 인한 생산성이기 때문이다. 따라서 FastAPI로 MVC 패턴을 적용하여 복잡한 방식으로 다뤄서 생산성을 저하시킨다면 Python을 개발언어로 채택한 이유에 대해 의문을 가져볼 만한다.

     

    또한 FastAPI에서 MVC 패턴을 적용하는 것에 대해 참고할만한 자료가 부족하다 보니 열심히 구글링 하면서 부족한 부분은 스스로 메꿔나가야 한다. 이 과정에서 참고하는 정보들은 얼핏 봤을 때 그렇게 해야 하는 이유에 대해 호기심을 품게 되며 이 호기심은 좀처럼 풀리지 않는다. 왜냐면 앞서 말했듯 정립된 자료가 부족하기 때문이다. 

     

     

    앞선 두 가지 이유에도 불구하고 필자는 FastAPI에 MVC를 적용시켜 왔는데 다음 두 가지 이유 때문이다.

     

    1. 어차피 코드베이스는 유지보수를 해야 한다.

      경험에 의한 바, 협업이든 나 혼자 작업이던 한번 작성한 코드는 어차피 나중에 다시 보게 되었다. 한번 쓰고 버릴 코드라면 굳이 FastAPI라는 FrameWork를 선택하면서 개발하지 않았다. 미래의 나를 위해서든 옆사람을 위해서든 코드를 다시 봤을 때 "어디에 무엇을 놓았었지" 정도는 상기시킬 수 있게 만들려면 트리거가 필요한데 이 트리거가 MVC를 기반으로 만들어놓은 코드 베이스다. 만약 이러한 트리거 없이도 그 정도는 할 수 있다고 한다면 당신은 똑똑한 사람이다.  

     

    2. 정립된 자료가 부족하다면 이는 도전해 볼 만한 주제이다.

      두 번째 이유는 과거 경험에서 기인한다. 백엔드 개발을 처음 시작할 무렵 당시 회사 코드 베이스는 사수분이 Django 내부 코드를 튜닝해 놔서 사내 라이브러리처럼 쓰던 방식이었다. 개발 작업을 하려면 사수분이 짜놓은 사용 방식에 따라서 개발을 해야 했기에 미리 공부해 놨던 DRF나 Celery 같은 것들이 무용지물이었다.

      그런 작업 환경에서 시간이 지나고 보니 framework(DRF, Celery)만 이용해서 개발하는 것은 도움이 되지 않는다는 것을 알게되었다. FrameWork가 제공하는 여러 모듈과 기능에 갇혀 기능과 구조를 스스로 짜는 연습을 해보지 않는다면 발전이 더디구나를 느꼈다. 따라서 정립된 자료가 부족하다면 부족한 부분을 채우기 위한 개념이 스스로에게 존재하는지 검증해 볼 만한 좋은 기회라고 생각한다.

     

    2.  FastAPI MVC Structure 

    2.1  Controller

    Controller는 API가 노출되는 영역이다.

     

    2.1.1 FastAPI는 Function Based View가 기본이다.

    3-Tier에서 Controller는 HTTP 요청과 응답을 수행하는 역할이다. MVC에서는 이 역할을 "View"라는 형태로 다루며  FastAPI는 다음과 같은 형태로 이를 사용할 수 있다.

    # https://fastapi.tiangolo.com/ko/tutorial/first-steps/
    from fastapi import FastAPI
    
    app = FastAPI()
    
    @app.get("/")
    async def root():
        return {"message": "Hello World"}

    위 형태를 Django의 개념에 대입해 본다면 function Base View라고도 볼 수도 있다. 필자는 Django를 주로 접했었고 View를 만들 때는 Class Base View 가 익숙한 상황이었다. 또한 Class Base View가 EndPoint 단위로 GET, POST, DELETE, PUT과 같은 HTTP 메서드를 한눈에 파악할 수 있어서 좋았는데 FastAPI에서 Class Base View가 지원되는지 찾아봤는데 이는 지원되지 않았다.

     

    하지만 FastAPI-Utilities라는 사이트에서 FastAPI에서도 Class Base View로 사용할 수 있게 만들어 놓은 라이브러리를 찾을 수 있었는데 굳이 사용하진 않았다. Class Base View 하나 때문에 해당 라이브러리를 설치하여 사용하는 것은 너무 과장된 방식이라 여겼다. 그러나 FastAPI-Utilites를 충분히 활용한다면 FastAPI에 CBV를 도입해 보는 건 좋은 시도이지 않을까 싶다. 혹은 직접 구현하는 방식인데 배보다 배꼽이 더 클 수 있어 개인적으로는 비추천하며 맘 편하게 FastAPI가 제공하는 기능을 충분히 활용하자.

     

    2.1.2 FastAPI Controller는 명시적으로 Swagger를 다루기에 편리하다.

    FastAPI에서 Controller 계층을 다루면서 가장 유용하다 생각했던 부분은 Swagger와 연동되어 코드 단에서 명시적으로 Swagger를 다룰 수 있게 해 준다는 점이었다.

     

    위에 첨부한 이미지는 FastAPI의 EndPoint에 Parameter로 넘겨서 설정할 수 있는 Swagger 관련 옵션들이다. Pydantic과 연계하여 각 옵션들을 상황에 맞게 사용하면 되지만 언급하고 싶은 유용한 트릭은 하나의 Http Status 코드에 여러 응답 예제들을 제공할 수 있는 트릭이다.

    @api_router.post(
        path="",
        response_model=s200.PetDto,
        responses={
            400: {
                "content": {"application/json": {"examples": {
                    "RESPONSE_EXAMPLE01": {"value": {"key":"value"}},
                    "RESPONSE_EXAMPLE02": {"value": {"key":"value"}},
                }}}
            }
        }
    )
    def register(): ...

    API를 개발하다 보면 하나의 status_code에 여러 응답 예제들을 던지는 경우가 발생하는데 이를 Swagger로 표현하려면 위와 같이 사용하면 된다.

     

    2.1.3 DTO는 Pydantic으로 다루자.

    이 내용은 FastAPI 문서에도 나와있기에 문서대로 따라가면 된다.

     

    그러나 Pydantic으로 DTO를 다루면서 풀기 어려웠던 점은 ORM모델과 Pydantic을 같이 사용하는 경우였다. 하나의 ORM 모델을 그대로 DTO와 매핑해서 사용하는 방법은 현실적으로 제약이 생긴다. 예를 들어 ORM 모델에서 특정 데이터를 Response에서 노출시키지 않아야 할 때가 특히 그랬다.

     

    그렇다 보니 요청과 응답을 Pydantic Model로 다루게 되면 거대한 중복 정의가 된 코드가 생기기 마련인데 이 부분은 해결할 수 있는 뾰족한 방법은 찾지 못했다.

     

    2.1.4 Service 계층을 호출할 땐 Depands를 사용하자.

    Controller는 Http 요청에 대한 행위를 수행하기 위해 Business Logic이 구현된 Service를 불러다 사용한다. 그러나 Service를 호출하는 방식 자체를 FastAPI가 제공하는 DI 개념인 Depands()를 사용하면 instance를 편리하게 사용할 수 있다.

    from fastapi import Depends
    
    @member_router.put()
    def member_agreement_update(
            service: MemberService = Depends(MemberService)
    ):
        service.update()
        return JSONResponse(status_code=200, content={})

    계속 위와 같은 방식으로 Service 계층을 호출해 사용했지만 선호하지 않는 방식이기도 하다. Controller 함수가 다뤄야 하는 Parameter의 일관성을 꺠뜨리기 때문이다. 개인적으로 Controller 함수는 Http Request Data 부분만 Paratmer로 받는 것이 깔끔해 보인다고 생각하는데 위 예시코드는 그러지 않는다.  

     

    그래서 깔끔하다고 생각하는 부분은 포기하고  FastAPI Controller에서 다루는 Parameter는 다음과 같이 딱 3가지 정도로만 제한했다.

    def register(
            context: RequestContext = Depends(RequestContext()),
            request: RequestDTO = Depends(RequestDTO.as_form),
            service: Service = Depends(SomeService)
    ):

    1. context

      Http Request을 받아들이기 이전 Controller 단위로 검증해야 될 부분 구현하는 부분이며

    2. request

      Http Request Data만을 표현한다. RequestDTO는 Pydantic Model이지만 form 데이터 방식을 구현할 때는 위와 같이 사용한다.

    3. service

      Service는 앞서 설명했던 Service 계층을 호출하는 로직이다.

     

     

    2.2 Service

    Service 계층은 비즈니스 로직을 구현하는 부분이며 고민했던 부분 중 한 가지 핵심적인 부분만 다루려고 한다.

     

    2.2.1  트랜잭션 관리를 어떻게?

    FastAPI에서 SQLAlchemy를 사용하는 경우 다음과 같이 DB에 Transaction을 날리는 예제가 존재한다.

    import contextlib
    
    from sqlalchemy import create_engine
    from sqlalchemy.ext.declarative import declarative_base
    from sqlalchemy.orm import sessionmaker
    
    SQLALCHEMY_DATABASE_URL = "sqlite:///./myapi.db"
    
    engine = create_engine(
        SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
    )
    SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
    
    Base = declarative_base()
    
    
    @contextlib.contextmanager
    def get_db():
        db = SessionLocal()
        try:
            yield db
        finally:
            db.close()
    
    
    @router.get("/")
    def register(db: Session = Depends(get_db)):
        return db.query(OrmModel).order_by().all()

     

    계층구조나 MVC 패턴을 적용하지 않는다면 위 방식은 간결하고 편리한 방식이다.

    def controller(db = Depands(get_db()):
         service(db)
    
    def service(db):
    	repository(db)
    
    def repository(db):
    	db.execute()
        ...

     

    그러나 이를 MVC 패턴으로 가져오게 되면 Controller에서 얻은 DB Context를 Repository 계층까지 전달해야 하는 문제가 생긴다. Controller에서 얻은 DB Context 굳이 Repository까지 내려서 다루는 게 의미가 있을까? 아마 없을 것이다.

     

    이를 해결하는 아이디어 중 하나는 SpringBoot에서 사용하는 Transactional 데코레이터를 구현하는 것이었다.

    @Transactional
    def service():
    	repository()

    그러나 위와 같이 구현하는 경우 실행이 Transaction을 잘 일으키는지 관찰해야 한다. 아무래도 SQLALchemy의 Session을 다루기에 생기는 이슈이기에 더 신경 써줘야 하는 부분인데 필자의 경우에는 SQLAlchemy의 scoped_session을 사용해서 구성했다. 

     

    이러한 이슈를 다룬 블로그도 더러 존재하고 이미 필자 블로그 내에서 구현 방식에 대한 참고할만한 글도 따로 기록했다.

    https://jakpentest.tistory.com/entry/FastAPI-Request-%EB%8B%A8%EC%9C%84%EC%9D%98-Transaction-%EC%9E%A1%EA%B8%B0

     

    [FastAPI] Request 단위의 Transaction 잡기

    HTML 삽입 미리보기할 수 없는 소스 2023.10.06 - [Frame Work/FastAPI] - [FastAPI] DI는 bootstrapping을 생각해보기 개요 FastAPI에서 DI를 적용하기 위해, FastAPI의 Depends 함수가 아닌 dependency-injector 라이브러리를

    jakpentest.tistory.com

     

     

    2.3 Repository

    Repository는 데이터 접근 계층이다.

     

    2.3.1 Repository에서 DB Session 얻기

    Repository는 DataBase에 저장된 데이터를  읽고 불러들이기에 DB Session을 얻을 수 있어야 했다. 이를 풀어내는 필자의 방식은 추상화된 Repository를 정의하고 DB Session을 생성하는 SingleTon 객체를 만드는 것이다. 

    class AbstractRDBRepository:
        session : Session = SQLAlchemySessionFactory.get_session()

    SQLAlchemySessionFactory가 DB와 Connection을 맺는 시점은 FastAPI Application이 실행되기 전이다. 이는 블로그 내에 정리된 글이 존재하기에 해당 링크를 첨부하겠다.

    https://jakpentest.tistory.com/entry/FastAPI-DI%EB%8A%94-bootstrapping%EC%9D%84-%EC%83%9D%EA%B0%81%ED%95%B4%EB%B3%B4%EA%B8%B0

     

    [FastAPI] DI는 bootstrapping을 생각해보기

    HTML 삽입 미리보기할 수 없는 소스 이 글의 DI는 의존성 주입(Dependecny injection)을 의미한다. 개요 FastAPI는 Django와 Flask랑 확연이 차이가 있다는 지점을 느낀 부분이 DI이다. FastAPI의 DI를 사용할 수도

    jakpentest.tistory.com

     

    2.3.2 추상화된 Repository 사용하기?

    앞선 예시에서 정의했던 AbstractRDBRepository를  조금 더 발전시켜 보자면 Generic을 사용할 수도 있다.

    class AbstractRDBRepository(Generic[T]):
        session: Session = SQLAlchemySessionFactory.get_session()
        
        def save(self) -> T: ...
    
        def get(self, id) -> T: ...

    AbstractRDBRepository를 상속받는 구현 클래스의 예시는 다음과 같다.

    class MemberRepository(AbstractRDBRepository[MemberEntity]):
    
        def save(self) -> MemberEntity: ...
    
        def get(self, id) -> MemberEntity: ...

     

    현재 시점에서 아직 고민하고 있는 문제는 Repository를 어떻게 바라볼 것인지에 대한 문제다. "파이썬을 살펴보는 아키텍처 패턴"이라는 책에서는 Repository를 구성할 때 CRUD 연산을 수행하는 Method 4가지 만을 정의하라고 권고한다. 그러나 개발을 하다 보면 이는 잘 지켜지지 않는데 데이터 접근 패턴이 다양해질수록 추가적으로 구현하는 Method가 다양해지기 때문이다.

    def find_by_id(): ....
    
    def find_by_xxxx_with_xxx(): ...
    
    def update_by_xxx_and_xxx(): ...
    
    def count_by_()...

    아무래도 스스로가 SpringBoot의 Repository에 정의하는 Method와 혼동하여 보기 때문일 듯싶은데 이 부분은 아직 명확한 해답은 없다.

     

    3. Entity

    FastAPI에 SQLAlchemy를 사용하면서 MVC에 Entity를 표현하는 건 개인적으로 난해한 부분이었다. 

     

    SQLAlchemy Model을 그대로 Entity를 사용하자니 순수 Python 객체가 아니기 때문에 미심쩍은 여지가 남고 그렇다고 SQLAlchemy Model을 그대로 사용하자니 Entity가 SQLALchemy에 너무 강하게 결합된 형태였다.

     

    DDD나 기타 다른 매체에서 접한 Entity는 순수 객체로 표현해 특정 기술에 의존하지 않아야하는 게 원칙이었다. 일각에서는 "한번 구축한 시스템에 특정 기술을 뗴어놓는 상황은 자주 발생하는게 아니다"라는 주장도 있었다. 이런 관점에서 보자면 SQLALchemy Model을 그대로 Entity 만드는 건 일리가 있지만 Entity를 표현하는 코드가 너무 SQLAlchemy 집약적인 코드로 만들어진다는 점에서 읽기 어려운 코드가 된다고 생각했다.

     

    즉 MVC Pattern에서는 다소 벗아나지만 MVC 안에서 사용될 개념인 Entity에 신경썼던건 "POPO 표현되어야 하면서 너무 기술 집약적이지 않은 스타일"을 담은 Entity 코드를 담아내야한다는 점이었다.

     

    이런저런 구현방식과 구글링을 참고한 끝에 결정한 방식은 SQLAlchemy의 Imperative Mapping이라는 방식을 활용하면서 어느 정도 목적을 이룰 수 있었다. Imperative Mapping 방식은 다음 링크를 참고하자.

    https://jakpentest.tistory.com/entry/SQLAlchemy-Imperative-Mapping\

     

     

    4. 마치며

    이 글을 작성할지 말지 나름 고민의 시간을 가졌다.

     

    글에 작성된 내용들은 필자가 겪은 몇 개의 사이드 프로젝트와 Java 개발자들과 협업할 때 사용했던 사례에 기인해 FastAPI로 MVC Pattern을 사용할 때 신경 썼던 여러 요소들이기 때문이다.

     

    따라서 이 글은 필자의 생각 정리용 기록일 뿐이다. 이 글을 읽고 도움이 되었다면 기쁘지만 만약 FastAPI에 MVC 도입을 고민하고 있다면 동료와 함께 고민해 보는 걸 추천하는 바이다.

    728x90
    반응형