문제
문제는 importlib이라는 python 기본 라이브러리가 문제였다. 해당 라이브러리를 사용할 때 간헐적으로 최대 1.8초나 지연되는 현상이 발생했다.
기존 라우팅 방식
해당 서버는 웹 프레임워크를 사용하지 않았기에, 챗봇별로 로직을 실행할 때 importlib
이라는 라이브러리를 이용해서 라우팅을 실시했다. importlib
은 동적으로 import를 할 수 있게 도와주는 라이브러리이다.
예를 들어 body에 chatbotId
가 Foo
이면 FooFacade
클래스를 import 해서 쓰거나, Bar
이면 BarFacade
클래스를 import해서 쓰거나 하는 식으로 진행했다.
그런데 importlib
이 느리다니… 이해가 되지 않았다. importlib
은 결국 __import__
함수의 래퍼이다. __import__
함수는 import 구문을 만나면 실행되는 기본적인 함수이다. 이게 느리다면, 파이썬을 사용해도 되는 것일까?
Import의 작동 방식에 대해서 알아보자
해당 정보는 김지연 님의 블로그를 참고해서 작성했다.
1. sys.module에 모듈이 존재하는지 찾아보기
sys.module에는 이때까지 사용했던 module들이 딕셔너리 형태로 저장되어 있다. import시 해당 모듈이 이전에 import 된 것이라면 빠르게 가져올 수 있다.
2. sys.path에 저장된 파일 목록들 하나하나 찾아보기
이 작업이 좀 오래 걸린다. 파일 리스트을 하나하나 탐색하면서 모듈을 가져오기 때문에 시간이 오래걸린다. 아마 필자의 생각으로는 File I/O 작업이라서 오래 걸리는 것 같다.
그럼 동적으로 import 하는 것은…?
만약 FooFacade
클래스를 처음 동적으로 import 한다면, 생각보다 시간이 오래 걸릴 수 있겠다는 생각이 들었다. 실제로도 처음 실행할 때와, 조금 유휴시간이 지난 후 실행하면 importlib
동작 시간이 오래 걸리는 것을 확인할 수 있었다.
심지어 AWS 람다를 이용하고 있어서, 일정 유휴시간이 지나면 컨테이너가 내려가버린다. 그렇다면 새롭게 컨테이너가 생성될 때마다, importlib
에서 시간을 많이 잡아먹었다.
해결방법
이제 문제점을 찾았으니 해결을 해보자. 결국 라우팅의 문제였으니, 이 라우팅을 다른 방식으로 하면 되지 않을까? 그래서 유명한 파이썬 웹 프레임워크인 FastAPI의 깃허브 소스를 뜯어보았다. FastAPI는 어떻게 라우팅을 사용하고 있을까?
from fastapi import APIRouter, FastAPI
app = FastAPI()
internal_router = APIRouter()
users_router = APIRouter()
@users_router.get("/users/")
def read_users():
return [{"name": "Rick"}, {"name": "Morty"}]
internal_router.include_router(users_router)
app.include_router(internal_router)
이런 식으로 APIRouter
객체를 하나 생성하고, APIRouter 객체의 get
(post
, put
, …) 함수를 라우팅 하고자 하는 함수에 데코레이터로 붙여준다. 그리고 app
객체에 해당 라우터를 전달한다.
이후 http 요청이 들어오면 app
객체로 전달되고, app
객체는 라우팅 정보를 확인해서 해당 함수를 실행한다.
자 어떻게 이것이 가능할까.
필자의 생각은 아래와 같았다.
APIRouter
객체는 멤버 함수로 데코레이터로 사용가능한 함수를 가지고 있다.(ex.get
,post
,put
,patch
,delete
)- 데코레이터 함수는 자기가 붙은 함수를 객체 형태로 사용할 수 있다.
(ex.user_router
의 get 함수는read_users
함수를 객체형태로 사용가능) - 그렇다면 get을 호출하면,
read_users
같은 함수를users_router
에 딕셔너리 형태로 저장하면 되겠네?
(ex.{"users": read_user}
와 같은 형태로) - 맞는 것 같은데… 한번 확인해 볼까?
APIRouter
의get
함수를 보면self.api_route(...)
를 호출하고 해당 결괏값을 바로 반환한다. 그렇다면api_route
함수를 보자.
## 너무 길어서 간략하게 축소한 버전이다.
def api_route(
self,
path: str,
**kwargs,
) -> Callable[[DecoratedCallable], DecoratedCallable]:
def decorator(func: DecoratedCallable) -> DecoratedCallable:
self.add_api_route(
path,
func,
**kwargs
)
return func
return decorator
해당 함수를 보게 되면, user_router.get
이 read_users
에 데코레이터로 붙게 되는 순간 func
파라미터에 read_users가
들어오게 된다. 이후 self.add_api_route
를 호출하는데, 이때 아래와 같은 코드가 실행된다.
def add_api_route(
self,
path: str,
endpoint: Callable[..., Any],
**kwargs,
) -> None:
"""
생략
"""
route = route_class(
self.prefix + path,
endpoint=endpoint,
"""
생략
"""
)
self.routes.append(route)
path는 “/user/”, endpoint는 read_users
함수이다. 이 두 가지를 통해 route
객체를 하나 만들고 이를 user_routes의
routes
리스트에 추가한다.
필자가 생각한 3번 과정은 아니고, 리스트 탐색으로 라우팅을 하는 것이지만, 어찌 됐든 비슷하다고 생각했다.
그리고 path param을 생각하면 딕셔너리의 key, value 탐색보다 리스트 탐색이 더 낫다고 생각이 든다.(path param이 들어가면 어쨌든 n만큼 순회해야 하니까!)
필자의 생각이 어느 정도 맞다는 걸 인지했으니 신나게 Router 객체를 제작해서 만들었다.
결과
무려 라우팅시 1.8초나 걸리던 것이 0.001초 미만에 해결되는 모습을 보였다. 해당 코드를 만들고 코드리뷰 때 “어떻게 이런 생각을 했냐"라고 하셔서 되게 기분이 좋았다.