Skip to content

동시성 작업을 위한 파이썬의 비동기 라이브러리 Asyncio

도입

애플리케이션을 개발하고 나면 속도 문제를 경험하고는 한다. 예를 들어 회원가입을 할 때 이메일 인증을 거쳐야 한다고 가정하면 서버에서 사용자의 이메일로 인증 메일을 보내고 난 뒤에 사용자는 메일 전송이 완료되었으니 확인하라는 메시지를 전달 받게 만들 수 있다. 그러나 만약 대량의 사용자가 한 번에 회원가입을 요청하면 어떻게 될까?

실제로 회원가입을 할 때 이메일 인증을 받으면 분명히 메일을 보냈다고 했는데 아직 내 메일함에는 도착하지 않았던 상황을 경험한 적 있을 것이다. 이는 메시지 큐(Message Queue)라는 기능을 이용했기 때문에 발생하는 상황이다. 특정 작업을 큐(Queue)에 넣었다가 추후에 한 번에 처리하는 방식을 메시지 큐라 한다. 사용자 입장에서는 메일 전송 때까지 얼마나 걸릴 지 모르는 완료 표시를 기다려야 하기 때문에 긍정적인 사용자 경험(UX_User Experience)을 위해서도, 효율적인 작업 처리를 위해서도 필요한 기술이다.

이처럼 특정 작업을 요청했을 때 해당 작업이 종료될 때까지 기다리지 않고 다른 작업을 하다가 해당 작업이 끝나고 난 이후에 작업을 이어서 하는 걸 비동기적(Asynchronous) 작업이라 하고 반대로 요청된 작업이 종료될 때까지 기다렸다가 다른 작업을 수행하는 걸 동기적(Synchronous) 작업이라 한다.

파이썬에서는 이러한 비동기 작업을 위해 제공하는 라이브러리 Asyncio가 있다. 이전 부흥하고 있는 파이썬 웹 프레임워크 FastAPI의 장점 중 하나로 비동기 통신을 구현하기 쉽다는 점을 이야기 했었는데 그 이유는 바로 이 Asyncio 라이브러리 덕분이다.

Asyncio 라이브러리에 대해 자세히 살펴보기 이전에 헷갈리는 용어를 구별하고 갈 필요가 있다.

정보

FastAPI의 경우 정확하게는 Starlette 위에서 작동하는 프레임워크이기 때문에 파이썬 표준 라이브러리인 Asyncio 및 Trio와 완전히 상호운용되는 AnyIO를 바탕으로 하여 비동기 작업이 이루어진다.

따라서 정확하게 이야기하자면 Asyncio가 아닌 AnyIO 위에서 비동기 작업이 이루어지는 것이다.

동시성과 병렬성

보통 속도를 생각하고 애플리케이션을 개발할 때 동시성(Concurrency)병렬성(Paralleism)이라는 단어를 사용한다. 이때 두 용어는 다른 개념이다. 혼재해서 사용하는 경우가 존재하나 파이썬의 Asyncio 라이브러리를 사용하여 구현하는 비동기 프로그래밍은 병렬성이 아닌 동시성 작업을 위한 방법이다.

쉽게 동시성은 동시에 여러 작업이 처리되는 것처럼 보이는 것 을 의미하고 병렬성은 실제로 동시에 여러 작업이 처리되는 것 을 의미한다. 다시 말해 동시성은 논리적인 개념이고 병렬성은 물리적인 개념이다.

컴퓨터의 부품 중에서 중앙 처리 장치라 불리는 CPU(Central Processing Unit)는 정보를 입력 받고 이를 기억하며 프로그램의 명령어를 해석하여 연산하고 출력하는 역할을 담당한다. 사람으로 치면 뇌에 해당하는 것이다. 이때 CPU에는 코어(Core)라는 부품이 탑재되어 있는데 이 코어가 연산을 담당하기 때문에 기본적으로 CPU의 성능을 이야기할 때 코어의 성능, 수를 이야기한다.

동시성의 경우 단일 코어 내에서 멀티 스레드를 동작시키는 방법을 의미하고 병렬성은 멀티 코어 내에서 멀티 스레드를 동작시키는 방법을 의미한다.

주의

비동기 프로그래밍이 파이썬에서 주목 받는 이유를 알기 위해서는 스레드 및 프로세스 그리고 악명 높은 GIL(Global Interpreter Lock)에 관해 먼저 알아야 한다.

그러나 해당 개념을 살펴보는데 꽤 오랜 시간이 걸리기 때문에 이는 다음에 더 자세히 살펴보고자 한다. 멀티 스레드 방식이 파이썬에서도 불가능한 건 아니지만 GIL 때문에 생각하는 방향 대로 운용하기가 힘들고 오히려 비효율적인 작업 처리가 될 수 있어서 이에 대한 대안책 중 하나로 비동기 작업 방식을 사용하게 되었다고만 이해하면 된다.

예제

간단하게 Asyncio를 확인해보려 한다. 아래 코드 예시를 한 번 살펴보자.

import time
import asyncio


async def from_hi_to_bye():
    await print(f'{time.ctime()} Hi!')
    asyncio.sleep(1.0)
    await print(f'{time.ctime()} Bye!')


asyncio.run(from_hi_to_bye())

위 코드를 실행하면 아래와 같은 결괏값을 볼 수 있다.

$ python 1-example.py
Sun Dec 19 18:54:55 2021 Hi!
Sun Dec 19 18:54:56 2021 Bye!

asyncio 모듈에서 제공하는 run() 함수를 통해 from_hi_to_bye() 함수를 실행하는 예제이다. 이때 from_hi_to_bye() 함수는 async def 함수로 이를 코루틴(Coroutine)이라 부른다. async 키워드를 붙여서 비동기 처리를 할 수 있게 만든 함수 내부에서 await키워드를 사용할 수 있는 건 동일한 코루틴 객체, 다시 말해 비동기 함수여야 한다.

예를 들어 await print()와 같이 print() 함수에 await 키워드를 사용하고 프로그램을 실행하면 object NoneType can't be used in 'await' expression이라는 TypeError 오류가 발생한다. print() 함수는 NoneType을 반환(return)하는 일반 함수이기 때문이다.

그렇다면 정말 일반적인 함수와 async 키워드를 붙인 함수가 차이가 존재할까?

def is_normal_def():
    pass

async def is_coroutine():
    pass


print('Type')
print('def: ', type(is_normal_def))
print('async def: ', type(is_coroutine))

print('\n')

print('Call Function')
print('def: ', is_normal_def())
print('async def: ', is_coroutine())

위 코드를 실행하면 아래와 같은 결괏값을 볼 수 있다.

$ python 2-example.py
Type
def:  <class 'function'>
async def:  <class 'function'>

Call Function
def:  None
async def:  <coroutine object is_coroutine at 0x7fecb8bc1ec0>

일반 함수인 is_normal_def()의 경우 타입은 function이며 아무 것도 반환하지 않기 때문에 None을 출력한 것을 알 수 있다. 반대로 async 키워드를 붙인 함수 is_coroutine()의 경우 똑같이 타입이 function이지만 coroutine object를 반환한 것을 볼 수 있다.

코루틴

코루틴(Coroutine)이 무엇인지 정확하게 알 필요가 있다. 코루틴은 루틴이라는 용어 앞에 코(Co)가 붙은 것처럼 각 루틴이 대등한 관계로 서로를 순차적으로 호출하는 함수를 의미한다.

일반적인 함수는 메인 루틴(Main Routine)서브 루틴(Sub Routine)으로 구성되어 메인 루틴이 서브 루틴을 호출하면 서브 루틴의 작업이 완료되고 나서 다시 메인 루틴으로 돌아온다. 이때 서브 루틴에서 사용되는 정보는 서브 루틴 내에서만 존재하기 때문에 해당 서브 루틴이 종료되면 사라진다.

예를 들어 아래와 같은 기본 함수를 한 번 살펴보자. 이때 메인 루틴은 print() 함수이고 서브 루틴인 num_sum()를 호출하는 형태다.

def num_sum(num):
    total = 0
    while True:
        total += num
        return total


for num in range(1, 11):
    print(num_sum(num=num))

해당 num_sum() 함수를 for 반복문을 통해 출력해보면 아래와 같다. return에 의해 값을 반환하기 때문에 값이 누적되지 않고 그대로 출력된다.

$ python 3-example.py

1
2
3
4
5
6
7
8
9
10

코루틴은 이와 달리 특정 시점에서 메인 루틴이 다른 루틴을 실행하게 된다. 이는 이전 메인 루틴과 서브 루틴처럼 종속적인 관계가 아닌 대등한 관계로 동작하는 걸 의미한다.

아래와 같이 yield를 사용하여 코루틴 객체로 만든 coroutine_num_sum() 함수를 살펴보자.

def coroutine_num_sum():
    total = 0
    while True:
        temp = (yield total)
        total += temp

coroutine = coroutine_num_sum()
next(coroutine)

for num in range(1, 11):
    print(coroutine.send(num))

해당 coroutine_num_sum() 함수를 for 반복문을 통해 출력해보면 아래와 같다. total 변수의 값이 누적되어 출력되는 것을 확인할 수 있다.

$ python 4-example.py

1
3
6
10
15
21
28
36
45
55

결과적으로 일반적인 함수의 경우 서브 루틴의 종료 이후 메인 루틴이 재실행되기 때문에 total 변수의 값이 완전히 반환된 이후에 print() 함수가 실행되었지만 코루틴의 경우 코루틴이 종료되지 않고 유지되기 때문에 total 변수의 값이 누적되어 print() 함수가 실행되었다.

그리고 await 키워드를 통해 코루틴 객체에만 접근할 수 있는 이유는 이러한 루틴의 유지 때문이다. 영어 단어의 그 의미처럼 해당 코루틴 객체가 끝날 때까지 기다리라는 의미로 await 키워드가 붙기 때문이다.

정보

yield 키워드를 활용하여 만든 함수를 제너레이터(Generator) 객체라고 한다. 제너레이터와 코루틴의 차이점은 무엇인지에 관해서는 추후에 살펴보고자 한다.

일반적인 함수와 제너레이터의 가장 큰 차이는 메모리의 효율성이 있다. 일반적인 함수의 경우 return 키워드를 사용할 때 모든 결괏값을 메모리에 올려야 하는데 제너레이터의 경우 yield 키워드를 통해서 결괏값을 하나씩 메모리에 올려놓기 때문이다.

이러한 제너레이터의 특성 때문에 제너레이터를 게으른 반복자(Lazy Iterator)라고도 부르기도 한다.

비교

이제 n 명의 사용자를 한 명씩 조회하는 함수를 동기적 처리하는 방법과 비동기적 처리하는 방법으로 나누어서 생각해보자.

동기적 처리

아래 예시를 보면 main() 함수에서 세 개의 동기적 처리를 수행한다.

import time


def find_user_sync(n):
    for i in range(1, n + 1):
        print(f'{n}명 중 {i}번 째 사용자 조회 중 ...')
        time.sleep(1)


def main():
    start = time.time()

    find_user_sync(5)
    find_user_sync(7)
    find_user_sync(3)

    end = time.time()

    print(f'> {end - start} 소요')


main()

time.sleep() 함수에 의해 1초씩 지연이 되기 때문에 결과적으로 아래와 같이 총 15초가 소요된 것을 확인할 수 있다.

$ python 5-example.py

5명 중 1번 째 사용자 조회 중 ...
5명 중 2번 째 사용자 조회 중 ...
5명 중 3번 째 사용자 조회 중 ...
5명 중 4번 째 사용자 조회 중 ...
5명 중 5번 째 사용자 조회 중 ...
7명 중 1번 째 사용자 조회 중 ...
7명 중 2번 째 사용자 조회 중 ...
7명 중 3번 째 사용자 조회 중 ...
7명 중 4번 째 사용자 조회 중 ...
7명 중 5번 째 사용자 조회 중 ...
7명 중 6번 째 사용자 조회 중 ...
7명 중 7번 째 사용자 조회 중 ...
3명 중 1번 째 사용자 조회 중 ...
3명 중 2번 째 사용자 조회 중 ...
3명 중 3번 째 사용자 조회 중 ...
> 15.031453132629395 소요

비동기적 처리

아래 예시를 보면 async def 키워드를 활용한 main() 함수에서 세 개의 비동기적 처리를 수행한다.

import time
import asyncio


async def find_user_async(n):
    for i in range(1, n + 1):
        print(f'{n}명 중 {i}번 째 사용자 조회 중 ...')
        await asyncio.sleep(1)


async def main():
    start = time.time()

    await asyncio.wait([
        find_user_async(5),
        find_user_async(7),
        find_user_async(3),
    ])

    end = time.time()

    print(f'> {end - start} 소요')


asyncio.run(main())

asycio.sleep() 함수에 의해 1초씩 지연이 되지만 해당 함수는 time.sleep() 함수와 달리 CPU가 지연되는 시간 동안 놀지 않고 다른 처리를 할 수 있게 해준다.

또한 asyncio.wait() 함수를 통해 비동기 호출을 스케줄했기 때문에 처리 순서가 실행 순서에 따라 처리되지 않고 실행 시간이 짧은 것 먼저 처리된 것을 확인할 수 있다.

아래와 같이 총 시간도 절반 정도로 줄어 7초가 소요되었다.

$ python 6-example.py

7명 중 1번 째 사용자 조회 중 ...
3명 중 1번 째 사용자 조회 중 ...
5명 중 1번 째 사용자 조회 중 ...
7명 중 2번 째 사용자 조회 중 ...
3명 중 2번 째 사용자 조회 중 ...
5명 중 2번 째 사용자 조회 중 ...
7명 중 3번 째 사용자 조회 중 ...
3명 중 3번 째 사용자 조회 중 ...
5명 중 3번 째 사용자 조회 중 ...
7명 중 4번 째 사용자 조회 중 ...
5명 중 4번 째 사용자 조회 중 ...
7명 중 5번 째 사용자 조회 중 ...
5명 중 5번 째 사용자 조회 중 ...
7명 중 6번 째 사용자 조회 중 ...
7명 중 7번 째 사용자 조회 중 ...
> 7.003783226013184 소요

결론

결론적으로 비동기 처리는 실행 순서를 보장 받지 못하지만 빠르게 처리해야 하는 작업을 우선적으로 처리해주면서 CPU가 하나의 작업이 수행될 때까지 기다리지 않고 다른 작업을 처리할 수 있게 해준다.

순서가 그리 중요하지 않은 경우, 예를 들어 첫 번째로 실행한 사용자가 그대로 첫 번째로 그 결괏값을 받아야 하는 경우가 아닌 이상 비동기 처리는 동기적인 처리보다 훨씬 효율적이며 더욱이 await 키워드를 사용하여 작업이 마무리 될 때까지 기다릴 수도 있기 때문에 잘 활용하면 효율적인 방식을 통해 처리 순서도 보장할 수 있다.

레퍼런스


작성자: 이태현

작성일: 2021-12-19