안내

본 게시물에 대해 다음의 정보가 변경되었음을 안내드립니다.

  1. 작성 당시보다 고버전의 Python을 사용하는 코드로 판올림 되었습니다.
  2. 기존 예제코드가 사용하던 atpages가 서비스를 종료한 관계로 비슷한 예시 생성을 위해 다른 endpoint를 사용하도록 변경했습니다.
  3. 파일 입출력 방식을 비동기로 변경하였습니다.
  4. aiohttp가 더 이상 aiodns를 자동 인식하지 않으므로 aiodns 관련 서술을 삭제합니다.

덕질을 하다보니

소드 아트 온라인 덕질을 열심히 하던 저는, 인터넷상에 소드 아트 온라인이 웹 연재되던 시절의 원고를 다운받을 수 있음을 알게 되었습니다. 모조리 다운받고 싶었는데, 파일이 59개나 되는 상태였죠. 나는 개발자니까 스크립트를 짜서 받는 게 좋을 거야! 라고 생각하게 되었습니다. http://www28.atpages.jp/petitindex/SAO4_{num}.txt 주소에 가면 파일을 받을 수 있었습니다.

미션

원래는 http://www28.atpages.jp/petitindex/SAO4_{num}.txt 에서 {num} 자리에 01부터 59 까지의 숫자를 대입해서 다운로드 하는 것이었습니다. 하지만 저 예시를 그대로 두면 예제 코드가 무용지물이 되어 게시물 자체가 아무 의미가 없어지기에, 게시물의 존속을 위해 http://www.randomtext.me/api/lorem/p-1/32를 128회 수집하는것으로 본 게시물의 미션을 변경합니다.

HTTP 요청은 역시 requests지!

가장 먼저 시도해본 것은 requests였습니다.

import requests

for i in range(128):
    res = requests.get(f'http://www.randomtext.me/api/lorem/p-1/32')
    with open(f'{i}.txt', 'w') as f:
        f.write(res.text)

참으로 친숙한 코드가 완성되었습니다.

돌아는 가는데…

물론 돌아는 갑니다. 하지만 문제점이 두 가지나 있었죠.

먼저 실행시간입니다. time 명령어로 계측한 실행 시간은 다음과 같습니다.

real    1m22.300s
user    0m0.805s
sys 0m0.202s

생각보다 너무 느렸습니다. 원인은 아무래도 파일을 하나씩 받고 있기 때문이겠죠. 이 문제를 해결하려면 쓰레딩등을 이용해서 동시에 다운로드 하도록 코드를 작성해야 합니다.

thinking face

하지만 쓰레딩을 사용하는 코드는 복잡도가 높습니다. 다른 방법이 없을까 고민하게 됩니다.

aiohttp

지금은 Python 3의 시대입니다. 골치 아픈 쓰레딩 코드를 대신해서 신문물인 코루틴을 사용할 수 있죠. 비동기 HTTP Server/Client툴인 aiohttp와 charset 감지용 라이브러리 cchardet, 파일 입출력을 위한 aiofiles를 사용해서 다운로더를 재작성 해보았습니다.

import asyncio

import aiohttp

import aiofiles


async def download(i: int):
    async with aiohttp.ClientSession() as session:
        async with session.get(f'http://www.randomtext.me/api/lorem/p-1/32') as resp:
            async with aiofiles.open(f'{i}.txt', 'w') as f:
                await f.write(await resp.text())


tasks = [download(x) for x in range(128)]
asyncio.run(asyncio.wait(tasks))

결과는…

real    0m11.378s
user    0m0.654s
sys 0m0.214s

AWESOME! 엄청 빨라졌습니다.

새 코드 설명

새 코드를 전체적으로 설명하기 위해 주석을 첨부하면 다음과 같습니다.

import asyncio  # Python 3.3부터 소개된 비동기 지원 모듈입니다.

import aiohttp  # asyncio를 사용하여 HTTP 요청을 하기 위해 사용합니다.

import aiofiles  # asyncio를 사용하여 파일 입출력을 하기 위해 사용합니다.

# async def는 해당 함수를 코루틴으로 만들어줍니다.
# i: int 라는 부분은 type annotation입니다. 본 소스의 동작에는 일체의 영향을 주지 않습니다.
async def download(i: int):
    async with aiohttp.ClientSession() as session:  # requests의 Session 클래스 같은 역할입니다.
        # 실제 요청을 비동기적으로 발생시킵니다.
        async with session.get(f'http://www.randomtext.me/api/lorem/p-1/32') as resp:
            # Python의 기본 open 함수는 비동기 입출력을 지원하지 않습니다. 그렇기에 외부 의존성을 씁니다.
            # 파일명 부분의 f'{i}.txt' 는 formatted string 구문입니다.
            async with aiofiles.open(f'{i}.txt', 'w') as f:
                await f.write(await resp.text())  # 결과를 text로 불러와서 파일에 저장합니다.

# 요청 2048개를 준비합니다.
# 만든 당시에는 아직 아무것도 일어나지 않고, 아랫줄의 asyncio.wait에 의해 동시에 실행됩니다.
tasks = [download(x) for x in range(128)]
asyncio.run(asyncio.wait(tasks))

이 코드는 단순히 요청을 빠르게 만들어주는 것이 아닙니다. 코드에 보이는 async with라던가 await같은 부분에서 일어나는 IO연산시 해당 코드 해석을 일시 중단하고 다른 작업을 진행하기 때문에 작업이 비동기적으로 이루어집니다.

그래서, 이걸 어떨 때 써요?

만약 아까 코드를 requests를 그대로 쓰려고 했다면 threading 모듈을 가져다가 이리저리 요리를 해야 했을 것입니다. 그리고 그렇게 작성된 코드는 코루틴에 비해 수정 및 재사용이 어렵습니다. 물론 소량의 요청만 필요한 간단한 크롤링이라면 requests로도 충분하다고 생각합니다. 하지만 동시에 여러 작업을 동시에 해야 하는 상황이라면 requests보단 aiohttp를 쓰는 것을 추천하고 싶습니다.