덕질을 하다보니

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

HTTP 요청은 역시 requests지!

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

import requests

for i in range(1, 59+1):
    res = requests.get(f'http://www28.atpages.jp/petitindex/SAO4_{i:02d}.txt')
    with open(f'{i}.txt', 'w') as f:
        f.write(res.text)

돌아는 가는데…

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

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

real    0m46.006s
user    0m0.578s
sys 0m0.138s

생각보다 너무 느렸습니다. 원인은 아무래도 하나씩 받고 있기 때문이겠죠.

두 번째는 인코딩입니다. 아무 생각 없이 다운받고 보니 문자가 모두 깨져 나왔습니다. 사실 이 파일들은 SHIFT_JIS로 되어있었습니다. 단순히 text를 쓰면 안 됐었고, bytes로 받아서 요리조리 요리를 해야 했습니다.

두 번째 문제는 (파일이 모두 SHIFT_JIS인지는 알 수 없으므로) chardet을 동원하면 됩니다. 하지만 첫 번째 문제는 쓰레딩 코드를 작성해야 합니다.

thinking face

고작 파일 59개 받는데 너무 산으로 가는 느낌입니다.

aiohttp

하지만 지금은 Python 3의 시대입니다. 골치 아픈 쓰레딩 코드를 대신해서 코루틴을 사용할 수 있죠. 백문이 불여일견, 코드를 만들어봤습니다.

import asyncio

import aiohttp


async def download(i: int):
    index = f'{i:02d}'
    async with aiohttp.ClientSession() as session:
        async with session.get(f'http://www28.atpages.jp/petitindex/SAO4_{index}.txt') as res:
            with open(f'{index}.txt', 'w') as f:
                f.write(await res.text())


tasks = [download(x) for x in range(1, 59+1)]
loop = asyncio.get_event_loop()
loop.run_until_complete(asyncio.wait(tasks))

뭔가 좀 더 복잡해진 것 같습니다.

그래서 빨라졌나?

real    0m43.636s
user    0m42.103s
sys 0m0.278s

thinking face

뭔가 기대했던 것보다 성과가 썩 좋지 않습니다.

문제 해결

원인은 두 가지인데, 첫째는 aiohttp의 통신에 필요한 DNS resolve가 느리기 때문입니다. 둘째는 aiohttp는 기본적으로 res.text()에 대하여 chardet을 적용합니다.

전자는 aiodns, 후자는 cchardet을 설치하면 속도가 빨라집니다. 실제 코드엔 단 한 줄의 수정도 가하지 않고 의존성 두 개만 설치하고 다시 실행해보겠습니다.

$ pip install aiodns cchardet
Collecting aiodns
  Using cached aiodns-1.1.1-py2.py3-none-any.whl
Collecting cchardet
Collecting pycares>=1.0.0 (from aiodns)
Installing collected packages: pycares, aiodns, cchardet
Successfully installed aiodns-1.1.1 cchardet-2.1.1 pycares-2.3.0
$ time python using_aiohttp.py

real    0m3.906s
user    0m0.558s
sys 0m0.124s

AWESOME! 10배가량 빨라졌습니다.

코드 설명

import asyncio

import aiohttp


async def download(i: int):
    index = f'{i:02d}'
    async with aiohttp.ClientSession() as session:
        async with session.get(f'http://www28.atpages.jp/petitindex/SAO4_{index}.txt') as res:
            with open(f'{index}.txt', 'w') as f:
                f.write(await res.text())


tasks = [download(x) for x in range(1, 59+1)]
loop = asyncio.get_event_loop()
loop.run_until_complete(asyncio.wait(tasks))

1, 3번째 라인은 의존성 import입니다. asyncio는 Python 3.3부터 내장된 모듈입니다. aiohttp는 별도로 설치해주셔야 합니다. ($ pip install aiohttp)

6번째 라인부터 생소하신 분이 있을 것 같습니다. async def 라는 것은 Python 3.5부터 도입된 코루틴 함수 생성 문법입니다. i: int 라는 것은 i의 타입이 int임을 기재한 부분입니다. 다만 이 기능은 타입을 강제하진 않고 단지 표기할 뿐입니다.

7번째 라인은 formatted string 기능을 사용했습니다.

8번째 라인에서 Clicent session을 시작합니다. 이와 비슷한 기능은 requests에도 있습니다만 보통 shortcut을 쓰기에 신경쓰지 않는 부분이죠.

9번째 라인에선 실제 Request를 GET 메소드로 날립니다.

10번째 라인에선 파일을 쓰기 가능으로 엽니다.

11번째 라인은 요청의 결괏값을 파일에 저장합니다.

14번째 라인은 실제로 다운 받아야할 01~59의 파일 다운로드 요청을 생성합니다. list comprehension을 사용해서 생소하신 분들은 아래와 동일한 동작을 한다고 보시면 쉽습니다.

tasks = []
for x in range(1, 59+1):
    tasks.append(download(x))

여기서 이미 download가 실행돼서 순차적으로 동작할 것 같지만, 코루틴은 await을 통해 불러들이기 전까진 실제 연산이 이뤄지지 않습니다.

15번째 라인은 event loop을 가져옵니다. 더 빠른 event loop 구현체를 사용할 수도 있겠지만 어디까지나 단순히 파일을 받을 뿐이니 기본 loop으로도 충분합니다.

16번째 라인은 실제로 목록으로 주어진 코루틴 task들을 동시에 시작합니다. 모든 코루틴이 실행되고 나면 스크립트가 종료됩니다.

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

만약 아까 코드를 requests를 그대로 쓰려고 했다면 threading 모듈을 가져다가 이리저리 요리를 해야 했을 것입니다. 그렇게 작성된 코드는 (코루틴에 비해) 이해하기 어렵습니다. 한 번에 한두 개만 요청한다면 requests로도 충분하겠지만 동시에 request를 해야 하는 상황이라면 requests보단 aiohttp를 쓰는 것이 코드 작성도, 실행 속도도 빠를 것입니다.