Logo

파이썬의 asyncio를 통한 비동기 프로그래밍

파이썬에서도 자바스크립트처럼 비동기로 작동하는 코드를 짤 수 있을까요?

이번 글에서는 파이썬에 내장된 asyncio 모듈을 통해 비동기 프로그래밍을 어떻게 수행하는지에 대해서 알아보겠습니다.

동시 프로그래밍의 패러다임의 변화

전통적으로 동시 프로그래밍(concurrent programming)은 여러 개의 쓰레드(thread)를 활용하여 이루어졌는데요. 하지만 쓰레드를 이용해서 직접 코딩을 해보신 분이라면 겪어보셨겠지만, thread safe한 프로그램을 작성하는 것은 생각보다 쉬운 일이 아닙니다. 게다가 싱글 코어 프로세서에서 이러한 프로그램을 돌리면, 기대와 달리 동시 처리에 따른 성능 향상이 미미하거나 심지어 성능 저하되기도 하죠.

이러한 이유로 최근에는 하나의 쓰레드로 동시 처리를 하는 비동기 프로그래밍(asynchronous programming)이 더욱 주목받고 있는데요. 특히 대규모 애플리케이션에서 병렬 처리, 네트워크 통신, DB 연동 등을 효율적으로 수행하기 위해서는 비동기 프로그래밍이 필수적입니다.

파이썬에서 비동기 프로그래밍

웹 서버와 같은 애플리케이션을 개발하다보면 CPU 연산 대비 DB나 API와 연동 과정에서 발생하는 대기 시간이 훨씬 길다는 것을 알 수 있는데요. 비동기 프로그래밍은 네트워크 통신이나 파일 입출력에 발생하는 대기 시간을 낭비하지 않고, 그 시간에 CPU가 다른 처리를 할 수 있도록 하는데 이를 흔히 non-blocking하다고 합니다.

자바스크립트와 같이 애초에 비동기 방식으로 동작하도록 설계된 언어에서는 익숙한 개념이지만, 파이썬과 같이 기본적으로 동기 방식으로 동작하는 언어에서는 이 기념이 생소하게 느껴질 수도 있는데요. 하지만 파이썬 3.4에서 asyncio 모듈이 표준 라이브러리로 추가되고, 파이썬 3.5에서 async/await 키워드가 문법으로 채택이 되면서, 파이썬에서도 이제 별다른 외부 라이브러리 없이 비동기 프로그래밍이 가능해졌습니다.

핵심 문법

def 키워드로 선언하는 모든 함수는 파이썬에서 기본적으로 동기 방식으로 동작한다고 생각하시면 됩니다.

예를 들어, 다음과 같이 선언된 함수는 동기 함수입니다.

def do_sync():
    return 'sync'

기존 def 키워드 앞에 async 키워드까지 붙이면 이 함수는 비동기 처리되며, 이러한 비동기 함수를 파이썬에서는 코루틴(coroutine)이라고도 부릅니다.

async def do_async():
    return 'async'

이러한 비동기 함수는 일반 동기 함수가 호출하듯이 호출하면 코루틴 객체가 반환됩니다.

do_async() # <coroutine object do_async at 0x1038de710>

따라서 비동기 함수는 일반적으로 async로 선언된 다른 비동기 함수 내에서 await 키워드를 붙여서 호출해야 합니다.

async def main_async():
    await do_async()

자바스크립트에서 async로 선언된 비동기 함수를 호출할 때 await 키워드를 붙이지 않으면 Promise 객체를 반환하는 것과 같은 이치입니다. ([자바스크립트] 비동기 처리 3부 - async/await 참조)

async로 선언되지 않은 일반 동기 함수 내에서 비동기 함수를 호출하려면 asyncio 라이브러리의 이벤트 루프를 이용해야합니다.

loop = asyncio.get_event_loop()
loop.run_until_complete(main_async())
loop.close()

파이썬 3.7 이상에서는 다음과 같이 한 줄로 간단히 비동기 함수를 호출 할 수도 있습니다.

asyncio.run(main_async()) # 'async'

실습 프로젝트

지금부터 사용자 관리 애플리케이션을 흉내내는 실습 코드를 작성하면서 동기 처리하는 코드와 비동기 처리를 하는 코드를 한번 비교해보도록 하겠습니다.

억지스럽지만 시뮬레이션을 위해서 다음과 같은 가정을 해보겠습니다.

  • 애플리케이션은 사용자 데이터를 직접 보관하지 않고 외부 API를 호출해서 가져옵니다.
  • 외부 API는 1명의 사용자 데이터를 조회하는데 1초가 걸리고, 한 번에 여러 사용자의 데이터를 조회할 수 없습니다.
  • 각각 3명, 2명, 1명의 사용자 정보를 조회하는 요청 3개가 동시에 애플리케이션에 들어옵니다.

동기 프로그래밍

먼저 사용자 데이터 조회를 전통적인 동기 방식으로 처리해주는 find_users_sync 함수를 작성합니다. 의도적으로 1초의 지연을 주기 위해서 time.sleep() 함수를 사용하였습니다.

import time

def find_users_sync(n):
    for i in range(1, n + 1):
        print(f'{n}명 중 {i}번 째 사용자 조회 중 ...')
        time.sleep(1)
    print(f'> 총 {n}명 사용자 동기 조회 완료!')

그 다음, 애플리케이션에 들어온 3개의 요청을 동기 처리하는 process_sync 함수를 작성합니다.

def process_sync():
    start = time.time()
    find_users_sync(3)
    find_users_sync(2)
    find_users_sync(1)
    end = time.time()
    print(f'>>> 동기 처리 총 소요 시간: {end - start}')

if __name__ == '__main__':
    process_sync()

이 함수를 호출해보면 find_users_sync 함수가 총 6초 동안 3번 순차적으로 실행됨을 알 수 있습니다.

3명 중 1번 째 사용자 조회 중 ...
3명 중 2번 째 사용자 조회 중 ...
3명 중 3번 째 사용자 조회 중 ...
>3 명 사용자 동기 조회 완료!
2명 중 1번 째 사용자 조회 중 ...
2명 중 2번 째 사용자 조회 중 ...
>2 명 사용자 동기 조회 완료!
1명 중 1번 째 사용자 조회 중 ...
>1 명 사용자 동기 조회 완료!
>>> 동기 처리 총 소요 시간: 6.020448923110962

만약에 싱글 쓰레드의 웹 서버가 이러한 방식으로 동작한다면 실제 사용자는 얼마나 오랫동안 지연을 경험을 하게 될까요? 동기 처리에서는 첫 번째 함수의 실행이 끝나야 두 번째 함수가 실행되고, 마찬가지로 두 번째 함수가 끝나야 세 번째 함수가 실행됩니다. 즉, 첫 번째 요청이 처리되는데는 3초, 두 번째 요청은 5초(3 + 2), 세 번째 요청은 6초(3 + 2 + 1)가 걸릴 것입니다.

비동기 프로그래밍

위에서 동기 처리되도록 작성된 코드를 파이썬의 async/await 키워드를 사용해서 한 번 비동기 처리될 수 있도록 개선해보도록 하겠습니다. 기존의 함수 선언에 async 키워드를 붙여서 일반 동기 함수가 아닌 비동기 함수(coroutine)로 변경하였으며, time.sleep() 함수 대신에 asyncio.sleep() 함수를 사용하여 1초의 지연을 발생시켰습니다.

time.sleep() 함수는 기다리는 동안 CPU를 그냥 놀리는 반면에, asyncio.sleep() 함수는 CPU가 놀지 않고 다른 처리를 할 수 있도록 해줍니다. 여기서 주의할 점은 asyncio.sleep() 자체도 비동기 함수이기 때문에 호출할 때 반드시 await 키워드를 붙여야 한다는 것입니다.

import asyncio

async def find_users_async(n):
    for i in range(1, n + 1):
        print(f'{n}명 중 {i}번 째 사용자 조회 중 ...')
        await asyncio.sleep(1)
    print(f'> 총 {n}명 사용자 비동기 조회 완료!')

자 이제, 파이썬의 asyncio 모듈을 사용해서 위에서 작성한 함수를 비동기로 실행해볼까요?

asyncio.gather() 함수는 비동기 함수들을 병렬로 실행하고 그 결과를 모아주는 역할을 하는데요. 인자로 넘어온 비동기 함수들을 동시에 실행하고 각 비동기 함수가 반환하는 결과들을 모아서 리스트로 반환합니다.

따라서 이벤트 루프가 3개의 함수 호출을 알아서 스케줄하여 비동기로 호출할 수 있도록 asyncio.gather() 함수의 인자로 3개의 함수 반환값, 즉 코루틴 객체를 넘겨주도록 수정합니다. 그리고 이렇게 수정된 process_async 비동기 함수를 호출할 때도, 함수의 반환값인 코루틴 객체를, asyncio.run() 함수에 넘겨줍니다.

async def process_async():
    start = time.time()
    await asyncio.gather(
        find_user_async(3),
        find_user_async(2),
        find_user_async(1),
    )
    end = time.time()
    print(f'>>> 비동기 처리 총 소요 시간: {end - start}')

if __name__ == '__main__':
    asyncio.run(process_async())

비동기 처리되도록 재작성된 코드를 실행해보면 호출 순서와 무방하게 실행 시간이 짧은 수록 먼저 처리되는 것을 알 수 있습니다. 게다가 총 소요 시간도 6초에서 3초로 100% 단축되었음을 알 수 있습니다!

실제 사용자 관점에서 생각해보면 3초가 걸리는 요청을 기다리지 않고, 1초가 걸리는 요청은 1초 만에 응답이 오고, 2초가 걸리는 요청은 2초 만에 응답이 올테니 매우 이상적이지 않을 수 없습니다.

1명 중 1번 째 사용자 조회 중 ...
2명 중 1번 째 사용자 조회 중 ...
3명 중 1번 째 사용자 조회 중 ...
>1 명 사용자 비동기 조회 완료!
2명 중 2번 째 사용자 조회 중 ...
3명 중 2번 째 사용자 조회 중 ...
>2 명 사용자 비동기 조회 완료!
3명 중 3번 째 사용자 조회 중 ...
>3 명 사용자 비동기 조회 완료!
>>> 비동기 처리 총 소요 시간: 3.0041661262512207

기본적으로 비동기 처리는 정확히 실행 순서가 보장되지 않기 때문에, 여러분 PC에서 실행했을 때는 저와 약간 실행 순서가 다를 수도 있습니다. 비록 동일한 실행 순서를 보장받지 못하더라도, 여기서 중요한 점은 CPU를 놀리지 않고 불필요한 지연없이 3개의 요청이 실행되어야 한다는 것입니다.

전체 코드

본 포스팅에서 제가 작성한 전체 코드는 아래에서 직접 확인하고 실행해보실 수 있습니다.

https://dales.link/p90

마치면서

이상으로 간단한 실습을 통해서 파이썬의 asyncio 모듈을 이용해서 비동기 프로그래밍을 하는 방법과 동기 프로그래밍 대비 장점에 대해서 살펴보았습니다.

파이썬으로 비동기 프로그래밍을 하는 것이 과거 그 어느 때 보다 쉬워진 만큼 앞으로 파이썬에서도 비동기로 처리되는 코드를 점점 더 많이 보게 될 것 같습니다. 관련해서 기본 개념을 잡으시는데 본 글이 도움이 되셨으면 좋겠습니다.