dev-writeup-2024 / march

개발 1일 1글 스터디
2 stars 0 forks source link

[03-12] FastAPI mechanism 이해 #30

Open steammando opened 3 months ago

steammando commented 3 months ago

안녕하세요? 현생에 치여 늦참하게 된 스팀만두입니다. 아직 바쁜 일은 안끝났지만 급하게 발등의 불을 꺼야해서 공부를 시작해봅니다.


오늘 살펴볼 내용은 FastAPI의 동작 구조입니다. fastAPI는 다들 아시겠지만 비동기 동작이 가능한 파이썬 프레임워크입니다.

pydantic 등 때문에 파이썬 프레임워크 중에서는 성능이 상당히 좋은 편입니다. 실제로 message publish나 DB I/O의 경우엔 flask, django에 비해 2배 이상의 퍼포먼스를 내는 테스트 자료도 있습니다. 그리고 자동으로 swagger 생성을 해주기 때문에 개발 편의성이 높습니다.

개인적으로 가볍기도 하고 비동기 지원때문에 골치아픈 것도 있지만 개발적인 이점이 많아 FastAPI를 채택해 개발하고 있습니다.

FastAPI 구조

fastAPI의 전체적인 구조는 다음과 같습니다. [ASGI Framework] FastAPi - starlette - [ASGI Server] uvicorn - uvloop - libuv

여기서 ASGI는 Asyncronous Server Gateway Interface의 약자입니다.

요청의 콜백이 동기적인 형태를 가진 WSGI와 달리 ASGI는 프레임워크와 서버 사이의 비동기 인터페이스를 제공하기 때문입니다. django 3.0에서도 ASGI를 차용하고 있습니다.

FastAPI는 ASGI 인터페이스가 구현된 starlette을 추상화하여 구현한 프레임워크입니다. Starlette은 내부적으로 uvicorn을 사용합니다.

이 uvicorn은 cython으로 구현된 uvloop을 사용하여 이벤트 루프를 구현하고 httptools를 사용합니다.

FastAPI 동작분석

위에서 말했듯 FastAPI는 uvicorn(middleware layer) -> asyncio(router) -> FastAPI-starlette(function) 흐름으로 동작합니다.

from fastapi import FastAPI
from fastapi.routing import APIRouter
from fastapi.middleware.cors import CORSMiddleware
import uvicorn

router = APIRouter(prefix="/api")

@router.get("/hello")
async def root():
    return {"message": "Hello World"}

app = FastAPI()
app.include_router(router)

if __name__ == "__main__":
    uvicorn.run(app, host="localhost", port=8080)

이러한 main 코드가 있다고 가정했을 때 /hello가 실행되면

#uvicorn
#uvicorn/main.py
def run(...):
    ...
    server = Server(config=config)
    ... # 동작에 필요한 Config를 확인하고
        server.run() #서버가 시작 됩니다. 
    ... # Config가 틀리면 종료됩니다.

# uvicorn/server.py
def run(self, sockets: Optional[List[socket.socket]] = None) -> None:
  self.config.setup_event_loop()
    # asyncio로 server의 method를 실행합니다.
  return asyncio.run(self.serve(sockets=sockets)) 

이 다음은 asyncio로 넘어갑니다.

# asyncio/runners.py
def run(main, *, debug=None):
        """Execute the coroutine and return the result."""
    ...
        # 독립된 event loop를 가지고 코루틴을 처리하는 runner
    # thread에서 이미 존재하는 event loop가 있다면 동작하지 않습니다.
    with Runner(debug=debug) as runner:
       return runner.run(main)
        # 그 후 코루틴을 검사하고 awaitable task로 변경합니다.

# asyncio/base_events.py
def run_until_complete(self, future):
    """Run until the Future is done.
  If the argument is a coroutine, it is wrapped in a Task."""
    # loop status 확인, future 객체 확인, call back 등록 후,
    ... 
    try:
        # 동작
        self.run_forever()

다시 uvicorn으로 돌아와 request, response 처리를 합니다.

# uvicorn/protocols/http/h11_impl.py
class RequestResponseCycle:
    async def run_asgi(self, app: "ASGI3Application") -> None:
              try:
            # app: ProxyHeadersMiddleware(fastapi)에 
            # self.scope, self.receive, self.send 전달
            # self.scope: http 관련 정보
            # self.receive: ASGIReceiveEvent 반환 method
            # self.send: ASGISendEvent 처리 method
      result = await app(
          self.scope, self.receive, self.send
      )

# uvicorn/middleware/proxy_headers.py

# FastAPI 를 wrapping하고 있는 ProxyHeadersMiddleware 에서 
# FastAPI 에 scope, receive, send 전달
class ProxyHeadersMiddleware:
    ...
    async def __call__(
      self, scope: "Scope", receive: "ASGIReceiveCallable", send: "ASGISendCallable"
  ) -> None:
        # request 관련 작업
        ...
        return await self.app(scope, receive, send)

코드를 보다보면 starlette이 정말 많이 사용됩니다. 기본클래스도 starlette을 상속받고, request, response, middleware 모두 starlette 베이스입니다.

이렇게 만들어진 미들웨어는 serverErrorMiddleware -> Custom Middleware -> ExceptionMiddleware로 request 처리를 진행합니다.

커스텀 미들웨어가 없다면 exceptionMiddleware -> ServerErrorMiddleware -> RequestResponseCycle 순으로 로직이 진행됩니다.

FastAPI에서의 비동기, 동기

FastAPI에서는 비동기와 동기를 모두 사용할 수 있는데 이는 def 앞에 async가 붙냐 아니냐로 구분합니다. def의 경우 fastapi가 가지고 있는 thread인 external thread pool에서 direct로 실행하여 서버가 blocking되지 않지만, async def는 external thread pool에서 실행하지 않아 blocking됩니다.

근데 꼭 비동기가 좋다는건 아니고, 비동기, 동기 중 상황에 맞춰 사용하면 됩니다. 예를 들어 I/O 작업의 경우 데이터베이스 write가 크다면 다른 task도 동시에 이뤄질 수 있도록 비동기를 하는 것이 좋겠지요. 하지만 여기서 write하는 데이터가 무결성을 보장해야한다면 동기잡으로 돌려야겠죠.

I/O bound가 발생하지 않는 경우, async/await를 지원하는 라이브러리를 사용하는 경우 async def를 사용하는 것이 좋고 지원하지 않는다면 def를 사용해도 무방합니다.


아직 정리 못한 부분이 많지만 밤이 깊었으므로 적당히 글을 끝냅니다. FastAPI 좋긴한데 Pydantic이나 DB consistency 등 처리해야할거나 sqlalchemy 버그 등등때문에 좀 귀찮은거 같기도 합니다. 그래도 뭐 쓰다보니 정들긴함..