지난글에 이은 세번째 글입니다. 지난 글에서 파이썬에서 비동기를 구현하는 법에 대해 알아봤습니다. 이번 글에서는 어떻게 비동기를 활용할 수 있을지에 대해 알아봅시다.


비동기를 사용하면 네트워크 IO의 지연 때문에 낭비되는 시간을 줄일 수 있습니다. 온라인 사전사이트에서 단어들의 의미를 크롤링하는 코드를 작성한다고 가정해봅시다. 동기적인 방식을 사용한다면 아래와 같이 코드를 작성할 수 있습니다.

동기적 방식

# requests와 bs4 설치 필요
import requests
import time
from bs4 import BeautifulSoup


def get_text_from_url(url):
    print(f'Send request to ... {url}')
    res = requests.get(url, headers={'user-agent': 'Mozilla/5.0'})
    print(f'Get response from ... {url}')
    text = BeautifulSoup(res.text, 'html.parser').text
    return text


if __name__ == "__main__":
    start = time.time()

    base_url = 'https://www.macmillandictionary.com/us/dictionary/american/{keyword}'
    keywords = ['hi', 'apple', 'banana', 'call', 'feel',
                'hello', 'bye', 'like', 'love', 'environmental',
                'buzz', 'ambition', 'determine']

    urls = [base_url.format(keyword=keyword) for keyword in keywords]
    for url in urls:
        text = get_text_from_url(url)
        print(text[:100].strip())
    end = time.time()
    print(f'time taken: {end-start}')
>>
Send request to ... https://www.macmillandictionary.com/us/dictionary/american/hi
Get response from ... https://www.macmillandictionary.com/us/dictionary/american/hi
hi (interjection) American English definition and synonyms | Macmillan Dictionary
Send request to ... https://www.macmillandictionary.com/us/dictionary/american/apple
Get response from ... https://www.macmillandictionary.com/us/dictionary/american/apple
apple (noun) American English definition and synonyms | Macmillan Dictionary
Send request to ... https://www.macmillandictionary.com/us/dictionary/american/banana
Get response from ... https://www.macmillandictionary.com/us/dictionary/american/banana
...
Send request to ... https://www.macmillandictionary.com/us/dictionary/american/determine
Get response from ... https://www.macmillandictionary.com/us/dictionary/american/determine
determine (verb) American English definition and synonyms | Macmillan Dictionary
time taken: 12.112096309661865

약 12.11초가 걸렸습니다. 요청에 대한 응답을 받아야만 다음 요청을 할 수 있기 때문에 지연되는 시간이 발생합니다.

비동기적 방식 (Asyncio + requests)

이번에는 비동기적으로 코드를 작성해봅시다. requests는 비동기적으로 작성되지 않았기 때문에 loop.run_in_executor를 통해 쓰레드를 만드는 방식을 사용합니다. (이전 글 참고)

import requests
import time
import asyncio
from functools import partial
from bs4 import BeautifulSoup


async def get_text_from_url(url):  # 코루틴 정의
    print(f'Send request to ... {url}')
    loop = asyncio.get_event_loop()

    # loop.run_in_executor는 kwargs(keyword arguments)를 사용할 수 없기 때문에 functools.partial을 활용
    request = partial(requests.get, url, headers={
        'user-agent': 'Mozilla/5.0'})
    # ascyncio의 디폴트 쓰레드풀을 사용할 경우 첫번째 인자로 None
    # 직접 쓰레드풀을 만들 경우 concurrent.futures.threadpoolexecutor 사용
    res = await loop.run_in_executor(None, request)
    print(f'Get response from ... {url}')
    text = BeautifulSoup(res.text, 'html.parser').text
    print(text[:100].strip())


async def main():
    base_url = 'https://www.macmillandictionary.com/us/dictionary/american/{keyword}'
    keywords = ['hi', 'apple', 'banana', 'call', 'feel',
                'hello', 'bye', 'like', 'love', 'environmental',
                'buzz', 'ambition', 'determine']

    # 아직 실행된 것이 아니라, 실행할 것을 계획하는 단계
    futures = [asyncio.ensure_future(get_text_from_url(
        base_url.format(keyword=keyword))) for keyword in keywords]

    await asyncio.gather(*futures)

if __name__ == "__main__":

    start = time.time()
    loop = asyncio.get_event_loop()
    loop.run_until_complete(main())
    end = time.time()
    print(f'time taken: {end-start}')
>>
Send request to ... https://www.macmillandictionary.com/us/dictionary/american/hi
Send request to ... https://www.macmillandictionary.com/us/dictionary/american/apple
Send request to ... https://www.macmillandictionary.com/us/dictionary/american/banana
Send request to ... https://www.macmillandictionary.com/us/dictionary/american/call

...

Get response from ... https://www.macmillandictionary.com/us/dictionary/american/ambition
ambition (noun) American English definition and synonyms | Macmillan Dictionary
Get response from ... https://www.macmillandictionary.com/us/dictionary/american/apple
apple (noun) American English definition and synonyms | Macmillan Dictionary
Get response from ... https://www.macmillandictionary.com/us/dictionary/american/love
love (verb) American English definition and synonyms | Macmillan Dictionary
Get response from ... https://www.macmillandictionary.com/us/dictionary/american/determine
determine (verb) American English definition and synonyms | Macmillan Dictionary
...

time taken: 1.4359536170959473

이번에는 약 1.43초가 걸렸습니다. 비동기적으로 작성한 코드가 동기적으로 작성한 코드에 비해 약 8배 이상의 성능개선이 있습니다. 결과를 보면 요청에 대한 응답을 기다리지 않고 바로 다른 요청을 하는 것을 확인할 수 있습니다. 또한, "hi > apple > banana" 순으로 요청했으나 응답은 "ambition > apple > love" 순으로 요청순서와 일치하지 않는 것을 확인할 수 있습니다. 이는 요청순서가 아니라 응답 순서로 코드를 처리했기 때문입니다.

비동기적 방식 (asyncio + aiohttp)

하지만, requests모듈은 코루틴으로 만들어진 모듈이 아니기 때문에 위의 코드는 내부적으로 쓰레드를 만들어 동작합니다. 따라서, 요청의 수가 많아질수록 컨텍스트 스위칭의 비용이 발생합니다. (이전글 참고)

비동기 HTTP통신 라이브러리인 aiohttp를 이용하면 코루틴을 이용한 비동기 방식을 이용할 수 있습니다.

import time
import asyncio
# aiohttp 설치 필요
import aiohttp
from bs4 import BeautifulSoup


async def get_text_from_url(url):  # 코루틴 정의
    print(f'Send request to ... {url}')

    async with aiohttp.ClientSession() as sess:
        async with sess.get(url, headers={'user-agent': 'Mozilla/5.0'}) as res:
            text = await res.text()

    print(f'Get response from ... {url}')
    text = BeautifulSoup(text, 'html.parser').text
    print(text[:100].strip())


async def main():
    base_url = 'https://www.macmillandictionary.com/us/dictionary/american/{keyword}'
    keywords = ['hi', 'apple', 'banana', 'call', 'feel',
                'hello', 'bye', 'like', 'love', 'environmental',
                'buzz', 'ambition', 'determine']

    # 아직 실행된 것이 아니라, 실행할 것을 계획하는 단계
    futures = [asyncio.ensure_future(get_text_from_url(
        base_url.format(keyword=keyword))) for keyword in keywords]

    await asyncio.gather(*futures)

if __name__ == "__main__":

    start = time.time()
    loop = asyncio.get_event_loop()
    loop.run_until_complete(main())
    end = time.time()
    print(f'time taken: {end-start}')
>>
Send request to ... https://www.macmillandictionary.com/us/dictionary/american/hi
Send request to ... https://www.macmillandictionary.com/us/dictionary/american/apple
Send request to ... https://www.macmillandictionary.com/us/dictionary/american/banana
Send request to ... https://www.macmillandictionary.com/us/dictionary/american/call
...

Get response from ... https://www.macmillandictionary.com/us/dictionary/american/hello
hello (interjection) American English definition and synonyms | Macmillan Dictionary
Get response from ... https://www.macmillandictionary.com/us/dictionary/american/environmental
environmental (adjective) American English definition and synonyms | Macmillan Dictionary
Get response from ... https://www.macmillandictionary.com/us/dictionary/american/apple
apple (noun) American English definition and synonyms | Macmillan Dictionary
...

time taken: 1.4322197437286377

물론 위의 코드에서는 요청 수가 많지 않아 성능의 차이를 확인하기는 어렵습니다.

위와같이 하나의 웹사이트에서 크롤링할 때 비동기적인 방식을 사용하면 짧은 시간에 너무나도 많은 요청으로 인해 웹사이트에 나쁜 영향을 끼칠 수 있습니다. 아이피를 차단당할 수도 있으니 주의합시다. 위의 코드는 단지 하나의 예시일 뿐입니다. 만약 여러 웹사이트에서 크롤링한다면 비동기적인 방식을 고려해볼 수 있습니다.

Awesome Asyncio

asyncio를 제대로 사용하기 위해서는 사용하는 모듈들이 모두 코루틴으로 작성되어 있어야 합니다. 아래의 페이지에서는 asyncio기반 모듈들을 소개하고 있습니다.

Awesome Asyncio

비동기 기반 웹프레임워크

파이썬에는 훌륭한 웹프레임워크인 DjangoFlask가 존재합니다. 하지만, 비동기 방식으로 동작하는 웹프레임워크들도 활발히 개발중입니다. 대표적인 예가 sanicvibora입니다.

sanic카카오를 비롯한 많은 곳에서 이미 생산단계에서 이용중인 웹프레임워크이며, vibora는 아직 생산단계에서 사용하기에는 무리가 따르지만, cythonasyncio를 이용해 엄청난 퍼포먼스로 많은 관심을 받고있는 웹프레임워크입니다.

아래는 vibora에서 소개하고 있는 벤치마크입니다. sanicvibora 모두 DjangoFlask에 비해 좋은 성능을 보여줍니다. (물론 Instagram은 Django를 사용하지만 전 세계 유저들이 무리 없이 이용하고 있습니다.)

출처: https://github.com/vibora-io/benchmarks

두 프레임워크 모두 올 12월에 버전업이 예정되어있습니다. 파이썬에서 비동기 생태계가 좀 더 발전하길 바라는 마음에서 소개해봤습니다.


이상으로 파이썬과 비동기 프로그래밍 시리즈의 연재를 마칩니다. 프로젝트에 비동기를 도입하는 것이 어떨지 고민해보는 것도 좋을 거 같습니다. 읽어주셔서 감사합니다.