Frame Work/FastAPI

[FastAPI] FastAPI MVC Pattern !

j4ko 2024. 8. 16. 21:45
728x90
반응형

 

개요 

근 7~8개월간 쭉 FastAPI를 사용해왔다.

 

주로 사이드 프로젝트나 회사에서 FastAPI로 개발 시 코드 베이스를 어떻게 구성하면 좋을까에 대한 고민들을 거쳤다. 익숙한 것이 MVC 이기 때문에 이를 기반으로 FastAPI를 다룰 때는 계속 MVC라는 밑그림 위에서 코드 베이스를 구성해 나갔는데 경험이 쌓이다보니 FastAPI로 MVC를 만들 때 기초적인 밑그림 정도는 이렇게 잡아놔야지라는 개인적인 스타일이 생겼다.

 

이 글에 작성된 내용과 방식이 옳든 틀리든 FastAPI로 개발하면서 만들었던 이 "개인적인 스타일"에 대한 정리와 필자 개인의 생각을 남기려 한다.

 

1.  FastAPI에서 MVC를?!

 

FastAPI에서 MVC를 사용하면서 가장 먼저 들었던 생각은 —
“이걸 정말 FastAPI에서 써야 할까?”였다.

 

FastAPI로 프로젝트를 개발한다는 건 곧 Python의 생산성을 최대한 누리고 싶다는 뜻이라 본다. 그러나 MVC, 즉 3-Tier 구조를 FastAPI 위에 억지로 올려놓는 순간부터 생산성은 오히려 떨어질 수도 있다.


그렇다면 굳이 Python으로 개발하는 이유가 뭘까? 라는 질문을 던지게 된다.

 

또 하나의 문제는, FastAPI에서 MVC 패턴을 적용한 사례나 참고 자료가 정말 드물다는 점이다. 결국 구글링으로 흩어진 정보들을 모으고, 부족한 부분은 스스로 채워야 한다.

 

그 과정에서 ‘왜 이렇게 해야 하지?’라는 의문이 자주 들지만, 그 답은 좀처럼 찾기 어렵다.

 

애초에 정립된 자료가 많지 않기 때문이다.

 

그럼에도 나는 FastAPI에 MVC를 적용해왔다.

 

이유는 단순히 두 가지였다.

 

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

경험상, 협업이든 개인 프로젝트든 한번 작성한 코드는 반드시 다시 보게 된다. “어차피 다시 볼 거라면” — 그때 최소한의 길잡이가 되어줄 구조가 필요하다.

 

MVC는 그 역할을 꽤 잘 해준다. 다시 코드를 열었을 때, ‘아, 이 부분은 여기 있었지’ 정도를 떠올리게 해주는 트리거 역할.
이게 유지보수의 출발점이다.

 

만약 이런 트리거 없이도 모든 걸 머릿속에서 즉시 떠올릴 수 있다면, 그건 정말 똑똑한 사람이다. (진심이다.)

 

 

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

두 번째 이유는 내 과거 경험에서 비롯됐다.

 

백엔드 개발을 처음 시작했을 무렵, 회사의 코드베이스는 Django를 깊이 커스터마이징한 형태였다. 거의 ‘사내 프레임워크’에 가까웠다.

 

문제는 그 구조에 맞춰야만 개발이 가능했다는 점이다. 그 때문에 미리 공부해둔 DRF나 Celery는 쓸 수 없었다. 시간이 지나고 깨달았다.

 

프레임워크만으로 개발하는 건 한계가 있다.

 

Framework가 제공하는 기능 안에만 머물면, 정작 ‘구조를 스스로 설계하는 힘’은 길러지지 않는다. 그래서 생각했다.

 

정립된 자료가 부족하다면, 그건 오히려 스스로 개념을 정립할 기회가 아닐까?

 

FastAPI에서 MVC를 적용해보는 일은 그 ‘부족함’을 스스로 채워볼 수 있는 좋은 실험이었다.

 

 

 

2.  FastAPI MVC Structure 

2.1  Controller

Controller는 사용자의 입력을 처리하고 Application의 흐름을 관리하며 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라고도 볼 수도 있다. FastAPI를 사용하기 이전까지의 필자는 Django를 주로 접했고 View를 만들 때는 Class Base View 가 익숙한 상황이었다. 또한 Class Base View가  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에서 노출시키지 않아야 할 때가 특히 그랬다. (Ex, 사용자의 패스워드)

 

그렇다 보니 요청과 응답을 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
반응형