# 파이썬 비동기 프로그래밍

- Author: @baealex
- Published: 2020-08-23
- Updated: 2023-08-08
- Source: http://blex.me/@baealex/%ED%8C%8C%EC%9D%B4%EC%8D%AC%EC%9C%BC%EB%A1%9C-%EB%B9%84%EB%8F%99%EA%B8%B0-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D
- Tags: 프로그래밍, 파이썬, 비동기

---

파이썬의 sleep(1)은 왜 비효율적이라고 하는 걸까? 자바스크립트의 setTimeout(1000)과 파이썬의 sleep(1)은 근본적으로 어떤 차이를 가지고 있는 걸까? 이것이 단지 동기와 비동기라는 차이라면 비동기라는 녀석은 정확히 어떻게 동작하고 있는 걸까?

<br>

## 자바스크립트

---

자바스크립트는 기본적으로 싱글 스레드로 동작한다. 자바스크립트 엔진은 한 번에 하나의 테스크만 처리할 수 있다. 무한루프를 돌려보자. 자바스크립트는 루프 밖의 코드를 실행할 수 없는 상태에 도달한다. 자바스크립트는 기본적으로 동기 처리를 기본으로 한다. 동기 처리는 실행 순서가 보장된다는 장점이 있지만 현재 작업이 종료될 때 까지 다음 작업을 진행할 수 없다는 단점이 있다.

자바스크립트에서는 타이머, 네트워크 요청, 이벤트 핸들러 등의 비동기 처리 방식을 지원하는 함수를 제공한다. 비동기 처리는 현재 작업이 진행중이더라도 다음 작업을 처리할 수 있도록 한다. 즉, 비동기란 독립적인 두 작업이 서로의 시작 시간과 종료 시간에 영향을 주지 않는다고 생각할 수 있다.

#### 비동기 처리 로직

자바스크립트엔 콜 스택과 이벤트 루프, 테스크 큐라는 녀석들이 있다. 모든 함수는 호출시 콜 스택에 등록된다. 스택은 나중에 들어온 녀석이 먼저 나가는 방식으로 처리된다. 콜 스택은 대부분의 프로그래밍 언어에서 동일하게 동작한다. 비동기 함수는 이때 콜 스택에 등록되었다가 실행된 직후 사라진다. 그럼 콜 스택은 다음 작업을 지속적으로 처리해 나간다.

예를들어 `setTimeout`의 경우 `WebAPI`에 등록되어 타이머를 측정한다. 이후 타이머가 끝나면 테스크 큐에 프로그래머가 지정한 콜백 함수가 등록되며 이벤트 루프는 콜 스택에 처리할 작업이 없다는 것을 확인하면 테스크 큐에 있는 함수를 콜 스택에 등록하여 콜백 함수가 처리된다.

즉, 자신의 시스템이 sleep(1)으로 인해서 메인 로직에 영향을 받고 있다면 비효율을 논할 수 있으나 sleep 함수 자체가 비효율적이라고 표현하는 것은 잘못되었다.

<br>

## 파이썬

---

여하지간 파이썬 3.4에도 `asyncio`라는 비동기 표준 라이브러리가 추가되었고, 3.5에선 `async`와 `await`라는 예약어가 추가하는 등 파이썬에서도 비동기 프로그래밍을 적극적으로 지원하고 있다.

```python
def do_sync(): #일반 함수
    pass
```

```python
async def do_async(): #코루틴 함수
    pass
```

파이썬에서 비동기 함수는 코루틴 함수라고 불려진다. 코루틴 함수를 직접 호출하면 노드에서 프로미스를 호출하듯 코루틴 함수가 반환된다. 기본적으로 코루틴 함수는 다른 코루틴 함수에서 `await` 키워드를 붙여서 호출해야 한다.

```python
async def main_async():
    await do_async()
```

일반 함수에서 코루틴 함수를 호출하려면 `asyncio` 라이브러리 이벤트 루프를 이용해야 한다. 루프 시작을 비롯해 버전별로 다소 상이한 경우가 있으므로 잘 참고해서 사용하길 바란다.

#### 3.6 이하에서 비동기 루프 실행

```python
import asyncio

async def do_async():
    pass

async def main_async():
    await do_async()

if __name__ == '__main__':
    loop = asyncio.get_event_loop()
    loop.run_until_complete(main_async())
    loop.close()
```

#### 3.7 이상에서 비동기 루프 실행

```python
import asyncio

async def do_async():
    pass

async def main_async():
    await do_async()

if __name__ == '__main__':
	asyncio.run(main_async())
```

#### 비동기 파일 IO

비동기 방식으로 프로그래밍을 하려면 이미 비동기로 만들어진 함수를 잘 활용 해야 한다. 비동기 파일 입출력은 `aiofiles`라는 라이브러리를 이용하여 처리할 수 있다.

```bash
pip install aiofiles
```

```python
import asyncio
import aiofiles

async def main():
    async with aiofiles.open('test.txt', 'w') as f:
        await f.write('Hello World!')

if __name__ == '__main__':
    loop = asyncio.get_event_loop()
    loop.run_until_complete(main())
    loop.close()
```

#### 비동기 네트워크 IO

비동기 네트워크는 `aiohttp`라는 라이브러리를 사용하여 처리할 수 있다.

```bash
pip install aiohttp
```

```python
import asyncio
import aiohttp

async def main():
    async with aiohttp.ClientSession() as session:
        async with session.get('https://baejino.com') as res:
            print(await res.text())

if __name__ == '__main__':
    loop = asyncio.get_event_loop()
    loop.run_until_complete(main())
    loop.close()
```

`requests`라이브러리의 문법에 익숙한 사용자라면 위 코드가 다소 불편하게 다가올 수 있는데, `requests_async`라는 라이브러리를 사용하면 기존 `requests` 라이브러리와 동일한 문법을 사용할 수 있다. 단지 앞에 `await` 키워드만 붙여주면 된다.

```bash
pip install requests_async
```

```python
import asyncio
import requests_async as requests

async def main():
    res = await requests.get('https://baejino.com')
    print(res.text)

if __name__ == '__main__':
    loop = asyncio.get_event_loop()
    loop.run_until_complete(main())
    loop.close()
```

#### 동시성 프로그래밍

비동기의 유일한 장점은 불필요한 대기시간을 없애는 것인데 위와같이 단일 함수만 호출되는 경우 동기식으로 작성한 것과 다를바가 없다. 아래 코드는 대표적으로 잘못 작성한 코드이다.

```python
import asyncio
import requests_async as requests

async def req(i):
    res = await requests.get('https://baejino.com')
    if res.status_code == 200:
        print(str(i) + '번째 요청 : 정상적인 응답입니다.')
        return
    print(str(i) + '번째 요청 : 정상적인 응답이 아닙니다.')

async def main():
    for i in range(10):
        await req(i)

if __name__ == '__main__':
    loop = asyncio.get_event_loop()
    loop.run_until_complete(main())
    loop.close()
```

```python
0번째 요청 : 정상적인 응답입니다.
1번째 요청 : 정상적인 응답입니다.
2번째 요청 : 정상적인 응답입니다.
3번째 요청 : 정상적인 응답입니다.
4번째 요청 : 정상적인 응답입니다.
5번째 요청 : 정상적인 응답입니다.
6번째 요청 : 정상적인 응답입니다.
7번째 요청 : 정상적인 응답입니다.
8번째 요청 : 정상적인 응답입니다.
9번째 요청 : 정상적인 응답입니다.
```

이러면 그저 불필요하게 키보드를 두들기고 코드의 양만 늘어났을 뿐이다. 동시 다발적으로 실행을 시키려면 `asyncio.wait` 함수를 사용하거나 `asyncio.create_task`를 활용해야 한다.

###### asyncio.wait

아주 간단한다. 코루틴 함수를 배열로 감싸서 `wait` 함수를 `await`을 해준뒤 실행하면, 코루틴 배열이 모두 종료된 후 다음 코드로 이어진다. 타임아웃을 걸어준 이유는 해당 코루틴 배열중에 응답이 오지 않거나 락에 걸리는 상황에 대비한 것이다.

```python
import asyncio
import requests_async as requests

async def req(i):
    res = await requests.get('https://baejino.com')
    if res.status_code == 200:
        print(str(i) + '번째 요청 : 정상적인 응답입니다.')
        return
    print(str(i) + '번째 요청 : 정상적인 응답이 아닙니다.')

async def main():
    coroutines = []
    for i in range(10):
        coroutines.append(req(i))
    await asyncio.wait(coroutines, timeout=60)

    # await asyncio.wait([req(i) for i in range(10)], timeout=60) # 한줄로 표현

if __name__ == '__main__':
    loop = asyncio.get_event_loop()
    loop.run_until_complete(main())
    loop.close()
```

```python
7번째 요청 : 정상적인 응답입니다.
3번째 요청 : 정상적인 응답입니다.
4번째 요청 : 정상적인 응답입니다.
6번째 요청 : 정상적인 응답입니다.
8번째 요청 : 정상적인 응답입니다.
0번째 요청 : 정상적인 응답입니다.
5번째 요청 : 정상적인 응답입니다.
2번째 요청 : 정상적인 응답입니다.
1번째 요청 : 정상적인 응답입니다.
9번째 요청 : 정상적인 응답입니다.
```

###### asyncio.create_task

다음은 `create_task`를 이용하여 동시적으로 코드를 실행하는 방법이다. **테스크란** 코루틴 함수를 실행할 이벤트 목록에 추가한 후 반환된 값이다. 이미 코루틴 함수는 프로그래머의 의지와 상관없이(?)  실행 될 준비가 된 상태이며, 해당 코루틴이 정상적으로 실행된 이후 다음 작업을 진행하려면 태스크를 `await`을 해주면 된다.

3.7이하에서는 `create_task`가 존재하지 않으므로 `ensure_future`를 사용해야 한다.

```python
import asyncio
import requests_async as requests

async def req(i):
    res = await requests.get('https://baejino.com')
    if res.status_code == 200:
        print(str(i) + '번째 요청 : 정상적인 응답입니다.')
        return res
    print(str(i) + '번째 요청 : 정상적인 응답이 아닙니다.')
    return res

async def main():
    tasks = [i for i in range(10)] # 테스크를 담을 
    for i in range(10):
        # tasks[i] = asyncio.create_task(req(i)) # 3.7 이상이면 이 코드로
        tasks[i] = asyncio.ensure_future(req(i))
    
    for i in range(10):
        await tasks[i]

if __name__ == '__main__':
    loop = asyncio.get_event_loop()
    loop.run_until_complete(main())
    loop.close()
```

```python
0번째 요청 : 정상적인 응답입니다.
4번째 요청 : 정상적인 응답입니다.
8번째 요청 : 정상적인 응답입니다.
6번째 요청 : 정상적인 응답입니다.
3번째 요청 : 정상적인 응답입니다.
2번째 요청 : 정상적인 응답입니다.
9번째 요청 : 정상적인 응답입니다.
7번째 요청 : 정상적인 응답입니다.
1번째 요청 : 정상적인 응답입니다.
5번째 요청 : 정상적인 응답입니다.
```

#### 함수를 코루틴으로

아무래도 파이썬의 대부분의 라이브러리는 동기 함수로 만들어져 있으므로 비동기를 적극적으로 활용하기엔 어려움이 있다. 예를들어 `Django` 같은 ~~동기가 지배한~~ 프레임워크에서는 활용하기가 무척이나 어려운데, 아래와 같이 동기 함수를 코루틴으로 변환시키면 비동기를 적극적으로 활용할 수 있다.

```python
import asyncio

from functools import wraps, partial

def asynchronously(func):
    @wraps(func)
    async def coro(*args, loop=None, executor=None, **kwargs):
        if loop is None:
            loop = asyncio.get_event_loop()
        partial_func = partial(func, *args, **kwargs)
        return await loop.run_in_executor(executor, partial_func)
    return coro
```

사용법은 정말 간단하다. 동기함수를 만들고 저 함수를 데코레이터로 감싸면 된다.

```python
import asyncio
import requests

from functools import wraps, partial

def asynchronously(func):
    @wraps(func)
    async def coro(*args, loop=None, executor=None, **kwargs):
        if loop is None:
            loop = asyncio.get_event_loop()
        pfunc = partial(func, *args, **kwargs)
        return await loop.run_in_executor(executor, pfunc)
    return coro

@asynchronously
def req(i):
    res = requests.get('https://baejino.com')
    if res.status_code == 200:
        print(str(i) + '번째 요청 : 정상적인 응답입니다.')
        return
    print(str(i) + '번째 요청 : 정상적인 응답이 아닙니다.')
    
async def main():
    await asyncio.wait([req(i) for i in range(10)])

if __name__ == '__main__':
    asyncio.run(main())
```

```python
9번째 요청 : 정상적인 응답입니다.
0번째 요청 : 정상적인 응답입니다.
7번째 요청 : 정상적인 응답입니다.
6번째 요청 : 정상적인 응답입니다.
3번째 요청 : 정상적인 응답입니다.
2번째 요청 : 정상적인 응답입니다.
4번째 요청 : 정상적인 응답입니다.
1번째 요청 : 정상적인 응답입니다.
8번째 요청 : 정상적인 응답입니다.
5번째 요청 : 정상적인 응답입니다.
```

## 비동기 웹 프레임워크

비동기 로직에 대한 의문이 왜 여기까지 왔는지 모르겠지만 결과적으로 이걸 학습하는 목적은 현재 이 `BLEX`도 그렇고 적어도 파이썬으로 웹 개발을 한다면 최대한 비동기 프로그래밍을 활용하고자 한다.

#### Vibora

`Vibora`는 파이썬의 비동기 웹 프레임워크이다. 파이썬 웹 프레임워크 중에 *가장* 빠르다고 알려졌으며(`Flask`대비 5-6배 초당 응답량 많음) `Flask` 스타일을 지향한다.

![](https://static.blex.me/images/content/2020/8/22/11_YM3Utm9rhddPgFlvFsZf.png)

공식 홈페이지에선 3.6이상을 사용하라고 권장하나 Python 3.7에선 Syntax에러가 발생하여 사용이 불가능하다. 해당 프레임워크를 사용해보고 싶은 경우 3.6 버전을 설치해야 하므로 [pyenv](https://blex.me/@baealex/pyenv)를 활용하도록 하자.

```python
from vibora import Vibora, Request
from vibora.responses import JsonResponse

app = Vibora()

@app.route('/')
async def home(request: Request):
    return JsonResponse({'hello': 'world'})

if __name__ == '__main__':
    app.run(debug=True, host='0.0.0.0', port=8000)
```

`Flask`와 문법이 전체적으로 동일하나 모든 라우트 함수 앞에 `async`라는 키워드가 추가되었다. 여담으로 `vivora`의 경우 문서의 상태가 썩 좋지 못하고, 자잘한 오류가 정말 많은데다 가장 큰 문제는 이 자잘한 오류가 이슈에도 등재되어 있으며 전혀 고쳐지지 않고 있다. 조금은 느리더라도 이미 검증되어 널리 사용되는 `Sanic`을 사용하는 것을 추천하고 싶다.

#### Async ORM

해당 라이브러리의 이름은 [orm](https://github.com/encode/orm)으로 지어져 있으나. 이는 실제 널리 사용되는 이름이므로 혼동이 생길 수 있어 `Async ORM`으로 부르도록 하겠다. 필자는 간단하게 테스트를 위해서 사용하므로 `sqlite`를 사용한다.

```python
pip install orm~=0.1
pip install aiosqlite
```

라이브러리 개발자가 말하길 개발중인 상태이므로 위와같이 설치하는 것을 권장한단다.

```python
import databases
import orm
import sqlalchemy
import asyncio

database = databases.Database("sqlite:///db.sqlite")
metadata = sqlalchemy.MetaData()

class Note(orm.Model):
    __tablename__ = "notes"
    __database__ = database
    __metadata__ = metadata

    id = orm.Integer(primary_key=True)
    text = orm.String(max_length=100)
    completed = orm.Boolean(default=False)

# Create Database
engine = sqlalchemy.create_engine(str(database.url))
metadata.create_all(engine)

async def create():
    await Note.objects.create(text="Buy the groceries.", completed=False)
    await Note.objects.create(text="Call Mum.", completed=True)
    await Note.objects.create(text="Send invoices.", completed=True)

async def get():
    notes = await Note.objects.all()
    return notes
```

@gif[https://static.blex.me/images/content/2020/8/22/11_7xniyEpNTXbdvUdjeRGW.mp4]

~~개쩐다.~~ 장고의 ORM 문법과 거의 동일하다. 장고의 내부 함수를 많이 사용하지 않았다면 마이그레이션도 간단할 것 같다. 여하지간 요걸 `Vibora`랑 섞으면 이런 느낌이 된다.

```python
import databases
import orm
import sqlalchemy
import asyncio
import re

from vibora import Vibora, Request
from vibora.responses import Response, JsonResponse

database = databases.Database("sqlite:///db.sqlite")
metadata = sqlalchemy.MetaData()

class Note(orm.Model):
    __tablename__ = "notes"
    __database__ = database
    __metadata__ = metadata

    id = orm.Integer(primary_key=True)
    text = orm.String(max_length=100)
    completed = orm.Boolean(default=False)

app = Vibora()

@app.route('/create_database')
async def note_create(request: Request):
    engine = sqlalchemy.create_engine(str(database.url))
    metadata.create_all(engine)

@app.route('/create_notes')
async def create():
    await Note.objects.create(text="Buy the groceries.", completed=False)
    await Note.objects.create(text="Call Mum.", completed=True)
    await Note.objects.create(text="Send invoices.", completed=True)
    return JsonResponse({'state': 'done'})

@app.route('/notes')
async def note(request: Request):
    notes = await Note.objects.all()
    return JsonResponse(list(map(lambda x: {x.id: x.text}, notes)))

if __name__ == '__main__':
    app.run(debug=True, host='0.0.0.0', port=8000)
```

상당히 Node 스럽네...

```bash
curl http://localhost:8000/create_database
curl http://localhost:8000/create_notes
curl http://localhost:8000/notes
```

```js
[{"1":"Buy the groceries."},{"2":"Call Mum."},{"3":"Send invoices."}]
```
