FastAPI의 시대. 아직도 Flask 쓰시나요?

FastAPI의 시대. 아직도 Flask 쓰시나요?

Flask를 대체할 유력 후보

·

18 min read

FastAPI는 파이썬 웹 프레임워크의 새로운 트렌드로 자리잡고 있다. Flask를 사용하던 조직은 FastAPI로 옮겨가고 있고, 새 프로젝트들은 Flask를 후보에도 올리지 않는 경우도 생기고 있다. 파이썬 생태계에서 Flask의 숨은 꺼지고, FastAPI를 쓰는 것이 당연해지게 될 것이라고 단언한다. 어떤 Flask 튜토리얼의 마지막 글은 Why I'm Leaving Flask 이기까지 하다.

Untitled.png

전반적으로 '현대적인 프레임워크'라는 평가로, 2019년부터는 production에서 운영을 시작한 조직들이 많이 등장했다. Uber의 딥러닝 Toolbox인 Ludwig에서 사용된 사례, MS에서는 내부 ML 서비스들을 시작으로 Windows와 Office 제품들에 관련된 부분들도 옮겨볼 예정이라는 코멘트도 있었다.

국내에서는 매드업의 사례(Flask에서 FastAPI로 간 이유 - 매드업 블로그)가 대표적이다. 필자가 다니고 있는 AB180에서도 abit.ly 라는 숏링크 서비스의 백엔드 전체를 FastAPI로 개발했다. 숙련도가 높지 않음에도, 꽤 괜찮은 수준의 생산성과 사용 경험을 얻을 수 있었다. 해당 서비스를 개발하던 동료가 매일마다 ‘와 대박.. 이게 된다고?’ 라며 연호했던 기억이 난다.

필자는 Flask를 약 5년 가까이 써왔는데, 앞으로는 FastAPI를 애용하려고 하고 있다. 이 글에서는 FastAPI가 어떤 장점을 가지고 있는지, FastAPI를 쓰고 돌아보니 Flask에는 어떤 단점이 있었는지 등을 이야기한다.

일러두기

주요 요소들의 버전

이 글은 Python 3.9.0, Flask 2.0.3, FastAPI 0.75.0 버전을 기준으로 작성되었다. 문자열의 removeprefix 메소드를 호출하는 코드가 포함된 예제를 제외하고는 모두 Python 3.7+에서도 동작하는 것을 확인했다.

(깨알 파이썬 지식) PEP 616 - String methods to remove prefixes and suffixes

Python 3.9에서 문자열에 추가된 removeprefixremovesuffix 메소드는, 각각 인자로 전달된 문자열을 앞에서/뒤에서 제거한 결과를 반환한다.

>>> s = 'removeprefix and removesuffix'
>>> s.removeprefix('remove')
'prefix and removesuffix'
>>> s.removesuffix('suffix')
'removeprefix and remove'

lstrip, rstrip과 다른 점은 다음 예제를 통해 알 수 있다.

>>> s = 'removeprefix and removesuffix'
>>> s.lstrip('remove')
'prefix and removesuffix'
>>> s.lstrip('moreve')
'prefix and removesuffix'
>>> s.removeprefix('remove')
'prefix and removesuffix'
>>> s.removeprefix('moreve')
'removeprefix and removesuffix'

lstriprstrip기준이 되는 각 문자인자로 전달된 iterable 내에 포함되어 있는 경우 제거하는 반면, removeprefixremovesuffix는 각각 prefix와 suffix 전체가 인자로 전달된 것과 완전히 일치하는 경우에만 제거하는 것이다.

예제에 지면 아끼기

코드 예제가 길어지면 읽기 힘들기 때문에, 코드의 라인 수를 줄이기 위해 대부분의 예제에서 import 문을 아예 제거했다. 직접 실행해보고 싶다면 PyCharm의 context actions 기능을 통해 import문을 채워주기만 하면 된다.

context actions 기능 사용법

참조가 깨진 부분에 커서를 두고 Windows에서 Ctrl + Enter, Mac에서 Option + Enter를 누르면 된다.

image.png

Flask 2

이 글은 Flask 2번대 버전에서 새롭게 릴리즈된 기능을 사용한다. 바로 @get, @post 데코레이터를 사용하는 것이다.

Example (Flask)

app = Flask(__name__)


# old-style
@app.route("/posts", methods=["POST"])
def create_post():
    return {}


# new-style (Flask 2.0+)
@app.post("/posts")
def create_post():
    return {}

Ellipsis

이 글은 두 가지 목적에서 Ellipsis가 등장한다.

생략 표현

생략을 표현하기 위해 pass statement 대신 다음과 같이 ...(Ellipsis)를 사용한다.

def f():
    ...  # <--

Ellipsis는 원래 numpy에서 다차원 배열을 쉽게 다룰 수 있도록 만들어진 값인데, 의미 그대로 생략 을 표현하기에 자연스러우므로 pass 대신 자주 사용된다. pass 가 statement인 것에 비해 Ellipsis는 expression이므로 더 많은 범위에서 사용할 수 있기도 하다.

FastAPI와 Pydantic에서의 표현

FastAPI와 Pydantic에서, 기본값을 명시하는 부분에 Ellipsis를 사용해 기본값 없음 을 표현한다.

Example (FastAPI)

app = FastAPI()


@app.get("/posts")
def create_comment(
    header_a: str = Header("foo"),
    header_b: str = Header(...)  # <--
):
    ...

Glossary

이 글은 Flask와 FastAPI에 대해 다룬다. HTTP, 웹 서버, Python, Flask에 대한 배경지식이 있어야 한다. Flask를 통해 DB를 사용하는 WAS(web application server)를 간단하게나마 개발해본 적 있다면 읽어볼만 할 것이다.

추가적으로, 몇 가지 프로그래밍적인 이론 지식이 준비되어 있으면 좋다. 이미 자연스럽게 사용하고 있으나, 용어가 익숙하지 않아 이해하기 어려운 것이 있을 수 있으므로 여기서 자주 등장하는 몇 가지 요소들만 간단하게 요약했다.

view function

Flask에서, HTTP request가 들어왔을 때 실행되는 함수를 의미한다. 가령 다음 예제의 get_postscreate_post 함수는 view function이다.

Example (Flask)

app = Flask(__name__)


@app.get("/posts")
def get_posts():  # <--
    return {}


@app.post("/posts")
def create_post():  # <--
    return {}

FastAPI에서는 조금 더 general한 용어로 router 라고 부른다.

DI

코드에서 필요한 의존성(dependency)을, 외부에서 주입하는 것을 의미한다. 어렵게 생각할 것 없다. "Dependency Injection is a 25-dollar term for a 5-cent concept" 라는 말도 있다. 코드에서 dependency를 찾고, 이것을 DI에 맞추어 변경하는 과정을 따라가 보자.

before

import os


class ApiClient:
    def __init__(self):
        self.api_key = os.getenv('API_KEY')  # <-- dependency
        self.timeout = os.getenv('TIMEOUT')  # <-- dependency


class Service:
    def __init__(self):
        self.api_client = ApiClient()  # <-- dependency


def main():
    service = Service()  # <-- dependency


if __name__ == '__main__':
    main()

after

import os


class ApiClient:
    def __init__(self, api_key: str, timeout: int):
        self.api_key = api_key  # <-- dependency is injected
        self.timeout = timeout  # <-- dependency is injected


class Service:
    def __init__(self, api_client: ApiClient):
        self.api_client = api_client  # <-- dependency is injected


def main(service: Service):  # <-- dependency is injected
    ...


if __name__ == '__main__':
    main(
        service=Service(
            api_client=ApiClient(
                api_key=os.getenv('API_KEY'),
                timeout=os.getenv('TIMEOUT'),
            ),
        ),
    )

DI와 관련된 강결합(tight coupling), 약결합(loose coupling), IoC와 같은 개념들과 장점에 대한 부분들은 분명히 어렵겠지만, DI 자체는 쉬운 개념이다. 물론 DI가 어떤 장점이 있는지 이해하는 것은 좋다. Dependency Injection Demystified 라는 글로 출발해 파이썬 애플리케이션 의존성 주입 - dependency injector 를 거쳐 Martin Fowler의 Inversion of Control Containers and the Dependency Injection pattern 까지다.

FastAPI는 어떤 점이 더 나은가?

DI 중심 설계

Flask의 global variable vs FastAPI의 DI

Flask에는 application context라는 것이 있다. API 요청마다 생성되는 thread같은 개념이고, 이 context에 따라 request 라는 전역변수에 담기는 값이 달라진다. 요청마다 생성되는 thread에 각자 할당해주는 객체인 것이다. 이를 Flask에서는 thread-local object라고 부른다.

Example (Flask)

app = Flask(__name__)

@app.post("/posts")
def create_post():
    payload = request.json  # <--

    title = payload["title"]
    content = payload["content"]

    ...

FastAPI에도 분명 request들을 관리하는 context 개념이 있겠지만, 개발자가 신경쓸 필요가 없다. contextual한 데이터들은 모두 DI되기 때문이다.

Example (FastAPI)

app = FastAPI()

class Post(BaseModel):
    title: str
    content: str

@app.post("/posts")
def create_post(post: Post):  # <--
    title = post.title
    content = post.content

    ...

Flask의 request, current_app과 같은 전역변수들은 일관적이지 않은 요소이기 때문에, 함수의 행동과 결과가 전역변수의 상태에 따라 달라지므로 예측 가능성이 떨어진다. 반면에 FastAPI와 같이, contextual한 데이터들이 DI되게 되면 pure function을 중심으로 코드베이스를 구성할 수 있게 된다. 덕분에 testability를 높이며, input을 가지고 output을 쉽게 예측할 수 있게 되는 것이다.

이는 깔끔한 모양의 Domain Service의 역할을 하므로, 다른 endpoint의 controller가 호출하기도 좋다. 가령 POST /posts라는 게시글 추가 API가 있는 상황에서, POST /posts_v2 라는 새로운 버전의 API를 개발한다고 하자. 만약 POST /posts_v2POST /posts API의 내용을 확장한 것이라면, 함수 호출로 로직을 쉽게 재사용할 수 있다.

Example (FastAPI)

app = FastAPI()

class Post(BaseModel):
    title: str
    content: str

class PostV2(Post):
    imageUrl: str

@app.post("/posts")
def create_post(post: Post):
    title = post.title
    content = post.content

    ...

@app.post("/posts_v2")
def create_post_v2(post: PostV2):
    create_post(post=post)  # <--

FastAPI에서는 request와 관련된 모든 데이터를 DI받을 수 있다. FastAPI는 인자의 타입, 기본값에 따라 요청에서 어떤 데이터를 어떤 방식으로 inject해줄지 정한다. 몇 가지 상황(요청의 Content-Type 헤더 등)에 따라 조금씩 달라지기도 하지만, 기본적인 구조는 다음과 같다.

  1. type hint가 primitive type(int, str, ...)인 인자에는 동일한 이름의 path parameter나 query parameter를 전달한다.
  2. (1)에 더해 기본값이 fastapi.Header 객체인 인자가 있다면, 그 이름에 해당하는 header의 값을 전달한다.
  3. type hint가 Type[pydantic.BaseModel](BaseModel을 상속한 클래스)인 인자가 있다면, body를 전달한다.

이렇게 글로 보면 복잡한데, 코드로 표현된 것을 보면 초면이라도 꽤 자연스럽게 이해되는 것을 느낄 수 있다.

Example (FastAPI)

app = FastAPI()

class Comment(BaseModel):
    content: str

@app.post("/posts/{post_id}/comments")
def create_comment(
    post_id: int,
    q: str,
    comment: Comment,
    x: str = Header(...)
):
    ...

이는 어떤 request data를 사용할지 function signature 수준에서 모두 명시하게 만들어, Flask에서처럼 request 데이터 접근이 로직 어딘가에 숨겨져 찾기 어려운 문제를 겪지 않을 수 있다.

(여담) Header 는 왜 타입이 아니라 기본값으로 명시하게 했을까?

  1. Header라는 type hint가 있다면 그것은 조금 이상하다. FastAPI에서 type hint는 말 그대로 타입을, 기본값은 data source(어디서 가져올지)를 나타내는 것이 기본적인 컨셉이다. type hint를 모조리 primitive type과 Type[pydantic.BaseModel] 선에서 정리하는 것이 보기에도, 프레임워크를 구현하기에도 좋다.
  2. Python에서 기본값이 정의된 인자(default value parameter)는 기본값이 정의되지 않은 인자(non-default value parameter) 앞 순서에 위치할 수 없다. HTTP request에서 header는 부재료인 경우가 많으므로, 가장 나중 순서에 정의하게 만들어 사고 흐름을 자연스럽게 하기 위함이다.

Flask의 context manager vs FastAPI의 Depends

DB와 관련된 작업을 하려면 connector가 필요하다. 보통은 connector만 사용하지 않고, SQLAlchemy라는 라이브러리를 사용한다. DB 작업에 관한 많은 번거로운 일들을 대신 처리해주고, ORM 및 여러 편의 기능들을 지원하기 때문이다.

DB에 대한 작업들은 transaction 단위로 처리하는 것이 일반적이다. SQLAlchemy에서 추천하는 방식은 context manager를 사용해 with 문으로 transaction을 처리하는 것이다. session 이라는 개념이 사용된다.

Context Manager


ENGINE = create_engine(
    "mysql://127.0.0.1:3306/..."
)
make_session = sessionmaker(ENGINE)

@contextmanager
def session_scope():
    session = make_session()

    try:
        yield session
    except Exception:
        session.rollback()
        raise
    finally:
        session.close()

Example (Flask)

app = Flask(__name__)

@app.post("/posts")
def create_post():
    with session_scope() as s:  # <--
        s.add(...)  # <--
        s.commit()  # <--

        ...

with 블럭 시작과 함께 transaction도 시작하고, 블럭 종료와 함께 transaction도 종료된다.

이는 with문이라는 도구를 통해 직관적으로 transaction을 사용할 수 있다는 장점이 있다. 하지만 일반적으로 트랜잭션을 여러 번 생성하는 경우는 잘 없다. 요청 당 한 번의 트랜잭션이 사용되는 것이 일반적이라는 것이다.

그러므로 controller가 실행되기 전 transaction이 시작되고, controller와 함께 transaction도 종료되도록 만들면 조금 더 편할 것이다. 이렇게 탄생한 것이 flask-sqlalchemy이며, 실제로 많이 사용되고 있다. 이는 다음과 같은 일을 가능하게 만든다.

Example (Flask)

app = Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"] = "mysql://127.0.0.1:3306/..."
db = flask_sqlalchemy.SQLAlchemy(app)  # <--

@app.post("/posts")
def create_post():
    db.session.add(...)  # <--
    db.session.commit()  # <--

    ...

FastAPI에서도 이러한 context manager 컨셉을 이용해with 문을 열어 transaction을 사용할 수 있으나, 더 좋은 방법이 있다. 이러한 session을 dependency로 만드는 것이다. Depends 라는 클래스를 이용하면 쉽게 DI를 받도록 만들 수 있다. 참고로 다음 예제의 session_scope에 해당되는 코드는 앞에서의 것과 동일하다.

Example (FastAPI)

app = FastAPI()

class Post(BaseModel):
    title: str
    content: str

@app.post("/posts")
def create_post(
    post: Post,
    session=Depends(session_scope),  # <--
):
    session.add(...)
    session.commit()

    ...

HTTP request가 들어와 함수 실행을 준비하는 과정에서, FastAPI는 Depends 에 명시된 callable을 호출한다. callable이 일반 함수라면 그 리턴을 DI해주고, context manager라면 함수 실행의 전과 후에 enter/exit을 처리하며 yield받은 것을 잘 DI해준다.

Flask의 view decorator vs FastAPI의 Depends

Flask에는 view decorator라는 것이 있다. view function에 붙여주는 decorator인 것이다. 이는 request 와 같은 전역 객체들이 유효한 상태로 실행된다. 덕분에 다음처럼 Authorization 헤더를 검증하는 로직을 decorator로 만들어 두고, view function에서 가져다 사용할 수 있게 된다.

Example (Flask)

app = Flask(__name__)

def auth_required(func):
    def wrapper(*args, **kwargs):
        authorization = request.headers["Authorization"]  # <-- request 객체가 유효한 상태
        token = authorization.removeprefix("JWT ")

        ...

        return func(*args, **kwargs)

    return wrapper

@app.get("/posts")
@auth_required  # <--
def get_posts():
    ...

이러한 decorator의 작업 결과를 controller가 사용해야 하는 일이 생길 수 있다. 예를 들어 JWT를 통해 얻어낸 user id와, 그 user id를 통해 DB에 쿼리한 user 객체가 있을 수 있다. Flask에서는 이런 상황에서 g 라는 전역 객체를 사용한다. request처럼 요청마다 만들어지는 객체다. 다음은 앞의 예제를 조금 수정한 것이다.

Example (Flask)

app = Flask(__name__)

@dataclass
class User:
        id: int

def auth_required(func):
    def wrapper(*args, **kwargs):
        authorization = request.headers["Authorization"]
        token = authorization.removeprefix("JWT ")

        user_id = jwt.decode(token, "SECRET", algorithms=["HS256"])["user_id"]

        g.user = User(id=user_id)  # <--

        return func(*args, **kwargs)

    return wrapper

@app.get("/posts")
@auth_required
def get_posts():
    user_id = g.user.id  # <--

FastAPI는 이런 것들도 DI 컨셉을 기반으로 만들 수 있다.

Example (FastAPI)

app = FastAPI()

class User(BaseModel):
    id: int

def auth(authorization: str = Header(...)):
    token = authorization.removeprefix("JWT ")

    user_id = jwt.decode(token, "SECRET", algorithms=["HS256"])["user_id"]

    return User(id=user_id)

@app.get("/posts")
def get_posts(user: User = Depends(auth)):  # <--
    user_id = user.id  # <--

FastAPI는 router 함수들에게 해주는 것과 같이, dependency 함수들에도 request 데이터를 DI해 준다. 덕분에 앞의 예제처럼 dependency에서도 header를 받아서 처리하는 것이 가능한 것이다.

Flask의 g 객체는 파이썬의 dynamic attribute 특성을 이용해 만들어진 것인데, 참조하고자 하는 속성의 존재 여부를 확신할 수 없다는 것이 가장 큰 단점이다. 데코레이팅 구문이 지워지거나, view decorator가 g 객체에 속성을 바인딩하지 않기라도 하면 g.user 구문에서 바로 AttributeError가 발생하는 것이다.

FastAPI의 Depends를 사용하게 되면, 먼저 type hint가 되어 자동완성이 되므로 심리적으로 안정적이다. 실제로 버그가 발생할 가능성도 비교적 적을 수밖에 없다. 어떤 함수가 router의 dependency에서 지워지면, 애초에 router의 인자에서 빠지게 되는 것이므로 warning을 받을 수 있다. 그리고 Dependency가 데이터를 제대로 리턴하는지는 mypy와 같은 type checker를 이용해 검증할 수 있다.

(깨알 파이썬 지식) dynamic attribute

파이썬의 모든 객체에는 속성을 마음대로 추가할 수 있다.

class Dummy:
    pass

obj = Dummy()
obj.abc = 123  # <--
obj.abcde = 12345  # <--

Flask의 g 객체도 이러한 특성이 반영된 것이다. thread-local로 동작하는, 텅 빈 객체인 것이다. 속성을 제한하고 싶다면, 클래스에 __slots__ 를 정의하면 된다. 그러면 __slots__에 정의되지 않은 이름의 속성을 설정하는 구문에서 에러가 발생한다. list-like 객체나 tuple-like 객체를 사용할 수 있다.

class Dummy:
    __slots__ = ['ab', 'cd']

obj = Dummy()
obj.ab = 180
obj.cd = 34
obj.ef = 56  # <-- Error

쓸만한 것들 중 가장 빠른 속도

벤치마크

async/await을 기반한 비동기 프로그래밍 지원, 퍼포먼스가 가장 좋다고 잘 알려진 ASGI인 Uvicorn을 사용하고 있어 준수한 성능을 보인다. 링크는 Java, Go, Node.js, Python에서 잘 사용되는 프레임워크들과의 Multiple queries(DB query를 여러 번 수행하는 워크로드) 상황에서의 벤치마크 점수 비교다.

Flask, Django가 1,000점대의 점수를 갖는 것에 비해, FastAPI는 Go의 echo, gin, martini나 node.js의 express, koa 등보다 높은 14,000점대의 점수를 기록했다. 이러한 벤치마크 점수는 쿼리 횟수별 rps(response per second), latency, overhead 정보를 기반으로 계산된다. 필요하다면 세부적인 데이터를 더 살펴보도록 하자.

물론 FastAPI 벤치마크의 경우 PostgreSQL을 대상으로 ORM을 사용하지 않은(raw SQL을 사용한) 테스트 결과밖에 없어 현실적인 워크로드와 벗어나 있을 수는 있다. MySQL + SQLAlchemy와 같은 기반 환경에서는 상상했던 것만큼 그렇게 좋은 성능을 내지 못할 수도 있다 (그러나 큰 차이는 없을 것이라고 생각한다).

무엇이 이러한 차이를 만드는가?

FastAPI가 여타 다른 프레임워크에 비해 이만큼 높은 퍼포먼스를 뽑아낼 수 있는 이유는, 비동기 프로그래밍에 기반한 동시성 제어 모델 덕분이다. 예를 들어 220ms의 총 처리 시간을 가진 API에서, DB query가 200ms를 소요한다고 하자. Flask 환경에서는 이 200ms를 thread가 온전히 대기한다. 그러나 FastAPI 환경에서는, DB query 부분에 await 처리가 되어 있다면, thread가 그 대기 시간동안 다른 일을 처리하기 위해 움직일 수 있으므로 자원을 효율적으로 사용할 수 있는 것이다.

(여담) 프레임워크 수준의 latency 최적화는 큰 차이를 주지 못한다

파이썬에는 japronto라는 프레임워크가 있다. 첨부되어 있는 rps 측정 통계를 보면 알 수 있듯, 말도 안 되는 수준이다.

image.png

여기서 중요하게 생각해야 하는 부분은, 이것이 자체적인 벤치마크 결과라는 점이다. 값에 거짓을 섞지는 않았겠지만, 극적인 차이를 연출했을 가능성이 높다.

  1. japronto를 테스트할 때는 심혈을 기울이고, 다른 환경에서는 적극적으로 튜닝을 시도하지 않았을 가능성
  2. 현실적이지 않은 상황을 테스트했을 가능성 (*정말 중요한 부분)

조금 파헤쳐본 결과, Go 환경 테스트에는 멀티코어를 활용하는 GOMAXPROCS 를 설정하지 않았고, 높은 퍼포먼스를 가진 fasthttp 라이브러리를 서버 구현에 사용하지 않았다. 이 둘을 잘 조율하면 japronto와 큰 차이가 나지 않는다는 점을 발견했다. 다른 환경의 벤치마크 과정까지는 알아보지 않았으나, (1)의 가설이 일부 맞다고 볼 수 있다.

그리고 벤치마크는 "Hello world" 를 리턴하는 HTTP 서버를 통해 테스트했다고 한다. 그러나 현실 세계의 API는 복잡한 도메인을 처리하기 위해 DB query를 수행하며, 이것이 처리 시간에 큰 비중을 차지한다. 때문에 이러한 DB query 상황을 연출해 테스트했다면 이 정도의 극적인 차이가 나지 않을 수 있다.

HTTP message나 JSON string을 아무리 빠르게 parse/build한다고 하더라도, 결국 DB query가 발생하는 상황에서는 latency에 큰 차이를 만들어내지 못한다. 예를 들어 HTTP Message와 JSON을 처리하는 것에 Flask는 20ms, japronto는 1ms를 소요한다고 하자. 이는 20배 차이지만, DB query를 하는 부분이 들어가 200ms씩 더 점유하게 됐다면 각각 220ms, 201ms로 비교적 큰 차이라고 보기 어렵다.

프레임워크 수준의 latency 최적화는 결국 유의미한 수준의 차이를 만들지 않으며, 트래픽을 감당하는 것에 실질적으로 중요한 것은 동시성 제어 모델이라는 점을 인지해야 한다.

주: 비동기적으로 Python 다루기

속도보다 더 중요한 것

현재 섹션의 제목은 <쓸만한 것들 중 가장 빠른 속도> 다. 사실 속도만큼이나 중요한 것이, 프레임워크가 정말로 ‘쓸만하냐’는 것이다. 파이썬은 언어 차원에서 비동기 프로그래밍을 잘 지원하고 있으므로, 고성능의 프레임워크를 만드는 것 자체는 크게 어렵지 않다. 그러므로 쓰기 좋은 프레임워크를 찾는 것이 매우 중요하다. 결국 이러한 프레임워크는 개발자들에 의해 사용되는 도구에 불과하기 때문이다. 웹 프레임워크를 평가할 수 있는 지표를 대충 다음과 같이 정리할 수 있을 것 같다.

  • 충분한 기능을 빌트인하고 있거나, 부가적인 기능이 필요할 때 믿을만 한 라이브러리가 있는지
    • WebSocket, Streaming Response, CORS와 같은 HTTP 기능이나 Middleware, 테스팅 모듈 등
  • 이들이 제대로 공급되고 있는지
    • 얼마나 개발자에게 친숙한 인터페이스로 제공되고 있는지
    • 얼마나 버그 없이 stable한지
    • 문서를 통해 가이드를 잘 주고 있는지
  • 생산성이 괜찮은지
    • IDE와 잘 어울려 동작하는지 등
  • 러닝커브를 줄이기 위한 튜토리얼이 제공되고 있다면 가산점!
  • maintain이 잘 되고 있는지
  • production ready인지
  • 배포 난이도 등, ops 관점에서 편하게 건드릴 수 있는지

조직이나 엔지니어들의 성격에 따라 더 중요하게 생각하는 부분이 있겠지만, 내 기준으로는 이 만큼이다. 웹 프레임워크는 굉장히 많지만, 이 기준에 모두 부합하는 프레임워크는 많지 않다. 이만큼 완전한 프레임워크를 준비하는 것은 큰 비용이 들어가기 때문이다.

FastAPI는 이런 부분들이 잘 준비되어 있다고 생각했다. 이 뒤로도 FastAPI의 장점을 구구절절 설명할 것이기 때문에 특별한 언급은 넘어가도록 하자.

validation과 문서화

FastAPI에서, router 함수들의 signature는 다음 두 용도로 사용된다.

  1. validation
  2. 문서화

그리고 type hint에 pydantic model을 사용할 수 있게 만들어, 복잡한 데이터 모델과 validation을 충분히 수행할 수 있다. 이러한 특징은 FastAPI를 굉장히 특별한 프레임워크로 만들어 준다.

validation

함수에 정의되어 있는 매개변수와 타입 정보는, 매 request마다 들어오는 데이터들을 validation하는 데에 사용된다. 여기에는 pydantic의 컨셉이 녹아들어 있다. validation에 걸리는 경우 message도 programmability와 human readability를 함께 챙기는 방향으로 잘 정리해 준다.

Example (FastAPI)

app = FastAPI()

class Post(BaseModel):
    title: constr(max_length=10)  # <--
    content: str

@app.post("/posts")
def create_post(post: Post):  # <--
    title = post.title
    content = post.content

    return post.dict()

다음은 해당 FastAPI 앱이 localhost:5000 에서 실행되고 있다고 가정한 curl 결과다. response json은 임의로 prettify했다.

$ curl --request POST \
  --url http://localhost:5000/posts \
  --header 'Content-Type: application/json' \
  --data '{
    "title": 123,
    "content": "hello"
}'
{
    "title": "123",
    "content": "hello"
}
$
$ curl --request POST \
  --url http://localhost:5000/posts \
  --header 'Content-Type: application/json' \
  --data '{
    "title": "hellohellohello",
    "content": "hello"
}'
{
    "detail": [
        {
            "loc": [
                "body",
                "title"
            ],
            "msg": "ensure this value has at most 10 characters",
            "type": "value_error.any_str.max_length",
            "ctx": {
                "limit_value": 10
            }
        }
    ]
}

Flask의 경우에는 validation 기능이 내장되어 있지 않으므로, 별도로 만들어진 extension 라이브러리를 사용해야만 한다.

Flask-WTF는 사용 사례가 많으나 form data를 위한 validation 라이브러리로 SSR(server side rendering) 상황이 아니라면 그렇게 어울리지 않는다. type hint가 비약적으로 발전하던 시기 이전에 만들어졌기 때문에, 인터페이스도 조금 ‘옛날 것’ 느낌이 들기도 한다.

Flask-Validator는 인터페이스가 그렇게 친숙하지 않으며 star 수도 많지 않고, 마지막 커밋이 2년을 넘으므로 조금 불안한 부분이 있다.

Flask-Pydantic은 전반적으로 FastAPI의 스타일이 반영된 라이브러리다. pydantic을 사용하며, view function에 DI도 해 주고, 에러 처리도 신경썼다. FastAPI가 취향에 맞는다면, Flask에선 이 라이브러리를 사용했을 때 만족스러울 것이다. 필자도 나중에 Flask 환경에서 개발할 상황이 생긴다면 이 라이브러리를 사용할 것 같다.

Flask-Pydantic은 2020년 중반부터 인기를 얻기 시작했다. 그 전부터 Flask를 사용해왔던 사람들은 불편함을 감수하고 기존 라이브러리들을 사용하거나, 직접 만들어서 사용하곤 했다. 필자의 경우에도 과거에 Flask를 쓰면서, validation은 해야겠는데 마땅한 라이브러리가 없어 직접 만든 적이 있었다. 다음처럼 데코레이터의 인자로 pydantic model을 받고, g 객체에 바인딩해주는 형태였다.

JoMingyu/Flask-Large-Application-Example 내용을 각색

class PostJson(BaseModel):
        title: str
        content: str


class SampleAPI(Resource):
    @validate(json=PostJson)  # <--
    def post(self):
        payload: PostJson = g.json  # <--

        ...

문서화

파이썬에서 함수의 signature는 inspect.signuatre와 같은 것으로 매우 쉽게 뽑아낼 수 있다. 때문에 FastAPI는 router 함수의 signutare를 API 문서화에도 이용한다. FastAPI 어플리케이션은 현재의 라우팅 정보들을 수집해, 자체적으로 API 문서를 serving한다. 예로 다음과 같은 FastAPI 어플리케이션이 있다고 하자.

Example (FastAPI)

app = FastAPI()

class Post(BaseModel):
    title: constr(max_length=10)
    content: str

@app.post("/posts")
def create_post(post: Post):
    title = post.title
    content = post.content

    return post.dict()

이는 다음처럼 API 문서를 /docs 라는 URL에 Swagger 형태로 서빙한다.

image.png

ReDoc으로 visualize된 결과도 /redoc 에서 제공해준다.

image.png

title이나 description같은 부분은 직접 설정 가능하며, pydantic.constr, pydantic.conint와 같은 타입을 이용해 만든 구체적인 validation rule들도 문서에 반영되어 굉장히 편하다.

Swagger, ReDoc과 같은 visualizer를 사용하려면 당연한 것이긴 하지만, FastAPI는 OpenAPI specification을 만들어내기 때문에 다른 visualizer를 사용하기에도 좋다. 보통은 이렇게 API 문서가 코드와 결합되지 않고 따로따로 관리되는 경우가 많다. 따라서 직접 문서화를 해줘야 하고, 이 과정에서 문서화를 하지 않고 넘어가거나, 실수가 발생해 문서와 실제 API 간 mismatch가 발생해 불편함이 생기곤 한다. FastAPI를 사용하면, 이런 문제를 쉽게 해결할 수 있게 된다. 개인적으로 API와 API 문서는 강결합되어야 한다는 입장이라, 이 기능은 정말 만족스럽다.

풍부한 빌트인 기능 지원

FastAPI는 Starlette을 기반으로 하고 있다. 그러므로 Starlette에 있는 기능을 빌트인 단위에서 모두 사용할 수 있어 이득이 크다. Flask에 있는 기능은 모두 FastAPI에 있다고 보면 되고, 추가적으로 다음과 같은 기능들을 사용할 수 있다.

  • WebSocket 지원
  • GraphQL 지원
  • 백그라운드 task
  • startup/shutdown 이벤트 handler
  • CORS 지원
  • GZip 압축 지원
  • Streaming response 지원

이들을 당장 쓸 일이 없어 특별한 감흥이 없을 수 있으나, 이러한 빌트인 지원은 이 기능들이 필요해졌을 때 매우 소중하게 작용한다. 예를 들어 Flask의 경우 CORS, GZip, websocket 기능을 지원하지 않는다. 각각 Flask-CORS, Flask-gzip이나 Flask-Compress, Flask-SocketIO를 사용해야 한다. 이러한 라이브러리를 찾는 과정도 쉽지 않고, 제대로 된 라이브러리가 없거나, maintain이 제대로 되지 않거나 하면 좀 힘들어진다.

이러한 기능들이 프레임워크 단위에서 지원된다는 것은, maintain의 범위에 들어있어 시간이 지나도 안정성을 보장받을 수 있다는 장점이 있으니 아무래도 좋게 바라볼 수밖에 없다.

디테일한 문서

FastAPI의 문서는 개인적으로 파이썬 웹 프레임워크들 중 가장 잘 만들어져있다고 생각한다. 단순히 디자인이 예쁜 것을 넘어서, 지원되는 기능들이 모두 양질의 내용으로 문서화되어 있으며 튜토리얼도 제공하고, 배포에 대한 가이드HTTPS에 대한 부분처럼 Ops에 대한 것들도 신경쓰는 것을 볼 수 있다. 그 과정에서 배경지식이 필요한 부분이 있다면 Python Types Intro와 같이 직접 설명해주기도 하고, 공부할 수 있는 웹사이트의 링크를 제공해주기도 한다.

마케팅적인 측면도 조금 있겠지만, 뉴스레터를 통해 소식을 공유하거나, 본인들 로드맵에 대한 이야기도 적극적으로 내세우는 것을 보았을 때 이 부분도 좋게 바라볼 수밖에 없다.

Flask는 왜 이렇게 하지 못했는가

비동기 프로그래밍은 Python 3.5에서, Type Hint는 Python 3.6에서 정립되어 지금까지 발전하고 있다. Flask는 그 전에 만들어졌기 때문에, FastAPI에서 좋은 기능으로 보이는 것들을 구현하고 있지 않다. 또한 Python 3.5의 EOL이 한참 지난 뒤 릴리즈된 Flask 2.0에서도 큼지막한 기능을 추가하지 않은 것을 보았을 때, Flask는 마이크로 프레임워크의 정신을 계속 이어가고 싶은 것으로 보인다. Flask 생태계는 extension 문화가 많이 발전해 있기 때문에, Flask 입장에서도 빌트인 기능을 적극적으로 추가하는 것은 괜한 모험이 될 수 있는 것이다.

필자의 생각

Flask는 이제 그만 써야겠다

FastAPI는 성능과 생산성이 높고, 버그 발생 가능성은 낮다. 새 프로젝트에서 Flask를 선택할 이유는 전혀 없다고 생각한다. 과거에 Java를 사용하던 조직이 Kotlin으로 이동했듯, Flask를 사용하던 조직이 FastAPI로 이동하는 흐름이 앞으로 있을 것이라고 생각한다. 그 이유는 성능 때문일 수도, 자동 문서화와 같은 강력한 편의 기능 때문일 수도 있다.

내가 학생이라면 어떻게 할 것인가

기존 Flask 프로젝트 몇 개는 FastAPI로 옮겨보고, 새 프로젝트는 Python 3.10, SQLAlchemy 2.0+ 환경에서 FastAPI를 사용해 진행할 것 같다. Flask, FastAPI를 어느 정도 알면서, Flask 어플리케이션을 FastAPI로 옮겨본 경험이 있는 정도가 된다면 파이썬 계열에서는 꽤 경쟁력 있는 개발자가 될 것이라고 본다. 물론 백엔드는 언어, 프레임워크 말고도 알아야 할 것이 많지만.

Flask와 FastAPI 어플리케이션을 벤치마크해 보고, 어느 정도 워크로드에서 얼마나 비용 최적화가 되는지도 알아보려고 할 것 같다. DB query의 비중이 높지 않은 어플리케이션은 생각보다 지표 변화가 극적이지 않다거나, 하는 것을 실험해보는 과정을 거쳐보면 재밌을 듯.

예전에 Sanic이라는 게 있었는데..

파이썬에서 비동기 프로그래밍은 생각보다 오래 존재해왔던 기능이다. 빌트인에서 비동기 프로그래밍이 제대로 지원되기 시작한 Python 3.7이 2018년 6월에 릴리즈된 것으로 알 수 있다. 그러나 비동기 웹 프레임워크가 생태계의 중심이 되는 것은 또 다른 이야기다. 당연히 프레임워크가 개발되는 기간도 있겠지만, DB Query가 동기적으로 실행된다면 결국 가장 시간이 많이 들어가는 부분에서 Blocking이 발생하는 것이기 때문이다.

이것은 비동기 트랜잭션을 지원하는 DB 라이브러리가 있어야 해결할 수 있다. aiomysql, psycopg2같은 라이브러리를 예로 들 수 있다. 이들은 Python 3.7 이전부터 개발되어왔기 때문에 사실 특별한 걸림돌은 아니다(Python 3.3에서도 비동기 프로그래밍은 할 수 있었기 때문이다. yield from 문법이 존재하고 asyncio 라이브러리가 있던 시기였으므로).

문제는 ORM 라이브러리를 사용할 때 발생한다. ORM 라이브러리가 비동기 DB 라이브러리를 connector로서 지원하지 않으면 사실상 ORM을 사용할 수 없는 것이다. SQLAlchemy의 경우 databases와 같은 어댑터를 끼우고, 조금 더 low-level의 인터페이스를 쓰는 것으로 우회할 수는 있으나, SQLAlchemy라는 핵심적인 라이브러리를 완전히 사용하지 못 한다는 것은 큰 문제다. 이는 2021년 3월에 SQLAlchemy 1.4가 만들어지며 aiomysql, aiopg, psycopg, aiosqlite를 지원하게 되며 해결되었다.

주: [FastAPI] 9. Persistence Layer 구간을 비동기 처리 하는 방법

파이썬 비동기 웹 프레임워크의 시초 격인 Sanic은 불행하게도 그 전에 소문이 나 버렸다. 실질적으로 제대로 된 비동기 웹 어플리케이션을 개발할 수 없었던 시기였다. 그나마 peewee-async라는 또 다른 ORM 라이브러리를 사용해볼 수 있었으나, 둘이 썩 잘 어울린다는 느낌은 아니었다. 예를 들어, Sanic 테스트 클라이언트에서 request를 보낸 뒤에 event loop를 닫아버려 peewee에서 에러가 난다거나 하는 일이 있었다. 애초에 peewee 자체가 썩 인기 있는 ORM 라이브러리도 아니었고 말이다. 그래서 비동기 트랜잭션은 포기하는 방향으로 개발하는 케이스도 꽤 있었다. 그런데 다들 Sanic을 성능 때문에 쓰려고 했던지라 모순되는 일이다.

다음의 관심도 변화 차트를 보면 알 수 있는데, Sanic은 잠깐 반짝였을 뿐이고, Flask의 인기를 실질적으로 뺏은 것은 FastAPI라고 볼 수 있다.

image.png

FastAPI는 파이썬 비동기 프로그래밍에 관심이 많아지던 시기에 이목을 끌기도 했고, 얼마 안 지나 SQLAlchemy 1.4가 릴리즈되었으며, 애초에 성능 말고도 많은 장점들이 있으므로 계속해서 관심을 얻는 것으로 보인다.

필자는 FastAPI를 모두에게 강력 추천하고 싶다. Flask를 배우고 있는 학교 후배들에게는 앞으로 FastAPI를 써 보라고 했다. FastAPI가 파이썬 웹 프로그래밍 세상에 많은 기여를 해주길 기대한다.