Profile picture

[Python] asyncio를 사용한 비동기 프로그래밍

JaehyoJJAng2024년 07월 01일

문법

def 키워드로 선언하는 모든 함수는 파이썬에서 기본적으로 동기 함수로 동작한다

아래와 같이 선언된 함수는 동기 함수로 작동한다

def do_sync() -> str:
    return 'sync'

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

async def do_async() -> str:
    return 'async'

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

print(type(do_async()))

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

async def main_async():
    await do_async()

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

import asyncio

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

파이썬 3.7 버전 이상부터는 다음과 같이 한 줄로 간단히 비동기 함수를 호출할 수 있다

asyncio.run(main_async())

실습 프로젝트

사용자 관리 애플리케이션을 데모로 실습 코드를 작성하면서 동기 처리하는 코드와 비동기 처리를 하는 코드를 비교해보도록 하자.

실습을 위한 가정은 아래와 같다

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

동기 프로그래밍

사용자 데이터 조회를 동기 방식으로 처리해주는 find_users_sync 함수를 작성. 의도적으로 1초의 지연을 주기 위해 time.sleep() 함수 사용

import time

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

애플리케이션에 들어온 3개의 요청을 동기 처리하는 process_sync 함수 작성

def process_sync() -> None:
    start = time.time() 
    find_users_sync(n=3)
    find_users_sync(n=2)
    find_users_sync(n=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.01879096031189

싱글 스레드 기반 웹 서버가 위와 같이 동작하면 실제 사용자는 꽤 많은 시간의 지연을 경험해야 할 것이다 ..


비동기 프로그래밍

위에서 동기 처리되도록 작성된 코드를 파이썬의 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: int) -> None:
    for i in range(1 , n + 1):
        print(f'{n}명 중 {i}번 째 사용자 조회 중')
        await asyncio.sleep(1.0)
    print(f'> 총 {n}명 사용자 조회 완료')

이제 파이썬의 asyncio 모듈을 사용해서 위에서 작성한 함수를 비동기로 실행해보자. 먼저 이벤트 루프가 3개의 함수 호출을 알아서 스케줄하여 비동기로 호출할 수 있도록 asyncio.gather 함수의 인자로 3개의 함수 반환값, 즉 코루틴 객체를 넘겨주도록 수정하자
그리고 이렇게 수정된 process_async 비동기 함수를 호출할 때도, 함수의 반환값인 코루틴 객체를 asyncio.run 함수에 넘겨주도록 하자

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

if __name__ == '__main__':
    process_sync()

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

실제 사용자 관점에서 3초가 걸리는 요청을 기다리지 않고, 1초가 걸리는 요청은 1초만에 응답이 오고 2초가 걸리는 요청은 2초 만에 응답이 올테니 매우 이상적이라고 할 수 있다

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

비동기 프로그래밍으로 크롤링 속도 향상 시키기

웹 크롤링을 할 때 비교적 느리게 작동하는 경험을 한 적이 있었을 거다.

동기 방식은 한 번에 한 작업만 처리하기 때문에, 네트워크 요청 대기 시간이 발생하면 어쩔 수 없이 전체 속도가 느려진다.

이 문제를 해결하기 위해서 위에서 배웠던 비동기 프로그래밍을 간단한 크롤링 작업에 적용해볼거다.


코드는 다음과 같이 작성되었다.

import asyncio
import aiohttp
from bs4 import BeautifulSoup

# 비동기적으로 웹 페이지를 가져오는 함수
async def fetch_page(session, url):
    async with session.get(url) as response:
        return await response.text()  # 페이지 내용을 리턴

# 페이지를 파싱하는 함수
def parse_page(html):
    soup = BeautifulSoup(html, 'html.parser')
    title = soup.title.string if soup.title else 'No title'
    return title

# 크롤링을 비동기적으로 처리하는 함수
async def crawl(urls):
    async with aiohttp.ClientSession() as session:
        tasks = [fetch_page(session, url) for url in urls]  # 각 URL에 대해 비동기 요청
        pages = await asyncio.gather(*tasks)  # 결과를 모두 모은 후 처리
        return [parse_page(page) for page in pages]  # 파싱 결과 리턴

# 실행을 위한 메인 함수
if __name__ == '__main__':
    urls = [
        'https://example.com',
        'https://example.org',
        'https://example.net'
    ]
    
    # 이벤트 루프를 실행하여 크롤링 시작
    result = asyncio.run(crawl(urls))
    
    # 결과 출력
    for url, title in zip(urls, result):
        print(f'URL: {url}, Title: {title}')

요약하면 웹 페이지의 텍스트를 추출하는 코드이고, 비동기를 기반으로 작성되었다.


parse_page()는 왜 비동기 처리를 하지 않았는가?

parse_page 함수를 비동기적으로 처리하지 않은 이유는,

비동기 작업은 주로 I/O 관련 작업, 즉 네트워크 요청, 파일 읽기/쓰기, 데이터베이스 쿼리와 같은 시간이 오래 걸리는 작업에 적합하기 때문이다.

반면, CPU 바운드 작업 (파싱, 데이터 처리 등) 은 일반적으로 비동기적으로 처리할 필요가 없다.


위 내용을 요약한다면

  • 비동기 작업의 이점
    • 비동기 프로그래밍은 네트워크 I/O, 파일 I/O와 같이 외부 자원을 기다리는 동안 다른 작업을 처리할 수 있게 해준다.
    • 하지만 parse_page 함수는 단순히 HTML을 파싱하는 CPU 연산이므로, 외부 자원을 기다리는 작업이 아니기 때문에 비동기 처리를 해주지 않았다.
  • 성능 측면
    • parse_page 함수는 빠르게 실행되는 작업이기 때문에, 비동기적으로 처리하는 것이 성능에 영향을 거의 주지 않는다고 봐도 무방하다.
    • 오히려, 비동기 처리를 할 경우, 그 만큼의 오버헤드가 발생해 성능이 더 떨어질 수도 있는 참사가 발생한다.
  • I/O vs CPU 바운드 작업
    • 비동기 처리는 I/O 바운드 작업에서 가장 큰 성능 향상을 기대할 수 있지만, CPU 바운드 작업은 다른 최적화 방법을 고려해야 한다.
    • 만약, parse_page 함수가 아주 큰 HTML을 처리하는 CPU 집약적인 작업이라면, 그 때는 오히려 비동기 처리를 하는게 이득일 수도 있다.

Loading script...