Flask에서 async API 만들기?
일반적으로 Flask는 wsgi와 연동되도록 synchronous하게 동작하는 것을 기대하고 구현되었기 때문에 async 함수 없이 구현하는 것이 일반적인 패턴이다.
그러나 경우에 따라 필요에 의해 Flask 서버에 비동기 non-blocking I/O 기반 기능을 추가해야 할 경우가 발생하게 된다. 애초에 async 함수로 구현되는 FastAPI에서는 그다지 할 필요가 없는 고민이긴 하다.
어찌되었든, Flask에서 비동기를 사용하고 싶다면 어떤 방법으로 문제를 해결할 수 있을까?
가장 간단한 방법으로는 `asyncio`를 사용해서 비동기적인 I/O 기능을 할 수 있도록 만들고, 이를 Flask 코드와 연결시키면 된다.
눈치 챈 사람들도 있겠지만, 파이썬에서 함수를 수정하지 않고도 유연하게 함수에 특정 동작을 추가하거나 작동 방식을 바꿀 수 있는 가장 간단한 방법으로는 `decorator`를 사용하는 것이다.
decorator란?
데코레이터는 함수를 수정하지 않은 상태에서 추가 기능을 구현할 때 사용한다.
데코레이터를 사용하면 타겟이 된 함수 자체를 매개변수로 삼아서 데코레이터 함수로 전달한다. 데코레이터 함수는 미리 정의된 코드로 매개변수로 주어진 함수를 wrapping한다.
이러한 기능이 가능한 이유는 파이썬 언어가 함수를 객체로 고려할 수 있는 First-Order Language의 특성을 가짐과 동시에, 파이썬에서 Closure(클로져: 함수를 다른 함수 내부에 선언하는 기능) 기능을 제공하기 때문이다.
우선 First-Order Language의 예제를 보도록 하자.
# First-Order Language이므로 함수를 객체나 변수로 사용할 수 있다.
def test():
print('hi')
a = test
a()
위의 코드를 실행하면 `hi`라는 메세지가 출력된다. 함수를 변수처럼 사용할 수 있기 때문에 변수 a가 함수 test를 가리키도록 만든 뒤 a를 호출하였기 때문에 `test()`와 같은 결과를 얻게 되는 것이다.
마찬가지로 아래 코드는 클로져에 대한 예제이다.
def outerFunction():
msg = 'hello from inner'
def innerFunction():
print(msg)
print('hello from outer')
innerFunction()
outerFunction()
위의 코드를 실행시키면 `hello from outer`가 출력되고 `hello from inner` 메시지가 출력된다. 여기에서 innerFunction이 outerFunction 내의 변수에 접근 가능하도록 해주는 것이 바로 클로져 함수의 특징이다.
이제 간단한 데코레이터 예제 코드를 소개하도록 하겠다.
def decorator1(func):
def wrapper():
print('decorator1')
func()
return wrapper
def decorator2(func):
def wrapper():
print('decorator2')
func()
return wrapper
@decorator1
@decorator2
def hello():
print('hello')
hello()
위의 예제를 실행하면 `decorator1`이 먼저 출력되고, `decorator2`가 출력된 후에 `hello`가 출력된다. 그 이유는 hello 함수를 decorator2가 먼저 wrapping하고, 그 함수를 decorator1 함수가 wrapping하기 때문이다.
위의 코드에서 각 decorator 함수 내에는 wrapper라는 이름의 함수가 있는데, 이 함수는 각 decorator 함수의 매개변수로 주어진 `func`라는 객체에 접근해서 적절한 시점에 이를 호출한다. 또한, 파이썬 인터프리터에 의해 타겟 함수는 자동으로 데코레이터 함수에게 매개변수로서 주어지게 된다.
즉, 파이썬 데코레이터 패턴은 First-Order Language의 특징과 Closure 함수의 특징을 모두 활용한 패턴이라고 할 수 있다.
asyncio를 활용한 비동기 데코레이터 만들기
이제 본 글의 주 목적인 "Flask에서 non-blocking I/O" 기능을 사용할 수 있도록 해주는 데코레이터 함수를 구현하도록 하자.
from functools import wraps
from flask import Flask
import asyncio
def async_action(f):
@wraps(f)
def wrapped(*args, **kwargs):
return asyncio.run(f(*args, **kwargs))
return wrapped
app = Flask(__name__)
@app.route('/')
@async_action
async def index():
await asyncio.sleep(2)
return 'Hello world !'
app.run()
위의 코드에서 async_action 함수는 wrapped 라는 함수를 포함하고 있으며, wrapped 함수에는 `@wraps(f)`라는 형태로 데코레이터가 추가되어 있는 것을 볼 수가 있을 것이다. 이는 `functools.wraps`를 활용한 것인데, `functools.wraps`는 커스텀 데코레이터를 구현할 때 자주 사용되는 기능으로 wrapping되는 타겟 함수의 매개변수들이나 keyword args 등까지 모두 전해줄 수 있도록 도와주기에 조금 더 고급 데코레이터 함수를 구현하는데 사용된다.
여기에서는 `asyncio.run` 함수를 통해서 비동기 coroutine을 생성하고, 이 coroutine 위에서 주어진 타겟 함수가 비동기적으로 실행될 수 있도록 도와준다.
위의 `async_action` 데코레이터를 적절히 활용하면 Flask 어플리케이션에 비동기 I/O 기능을 추가함으로서 성능 튜닝을 할 수 있을 것이다.
그러나 알다시피, coroutine은 결국 실제 스레드가 아닌 pseudo-thread이기 때문에 완전한 동시성을 보장하지는 않으며, GIL(Global Interpreter Lock)의 영향을 받을 수 밖에 없다.
따라서, 이러한 기능을 적용할 때에는 늘 충분한 테스트를 통해서 어느 정도의 성능 개선이 이루어지고 어떤 병목이 발생할 수 있는지 등을 충분히 확인해야 한다.
'Python > Flask' 카테고리의 다른 글
Dropzone.js를 사용해 대용량 파일 업로드 API 성능 개선 (0) | 2023.10.09 |
---|---|
Flask - "The browser (or proxy) sent a request that this server could not understand." (0) | 2023.10.09 |