Polite Crawling with await & async

bill-williams-1806

전생에 크롤링을 못하고 죽었나 싶을 정도로 또 크롤링을 돌리고 있다. 초기에는 특정 서비스에서 사진 / 동영상을 크롤링하는 코드를 Node.js CLI 로 짰다. 이 코드는 아주 잘 작동했지만, 한 가지 치명적인 단점이 있었다. 그건 바로 sync-request를 쓰는 것도 모자라, C++ binding을 사용해 Node의 모든 이벤트 루핑을 막아버리는 sleep이라는 패키지까지 가져다 썼다는 것.

sleep을 사용한 이유는 너무 빠르게 요청을 보내버리면 IP Block을 당할 수 있기 때문이었고, 이를 해결하기 위해 각 요청이 끝난 후에 적당한 간격을 두고 다시 다음 요청을 수행하는 방식으로 진행한 것이다. 그리고, 어차피 이렇게 block해가면서 할 꺼면 굳이 비동기식으로 GET Req를 날리지 않아도 되겠거니~ 싶어서 sync-request를 사용했던 것도 있었다.

그러다가 이전에 다운 받았던 미디어를 다시 받고 싶지 않아졌고, 이 블로그가 돌아가는 서버에 크롤러와 WebDAV 서버를 돌리니 웹 트래픽이 조금 아쉬워지기 시작했다. (이 서버는 싸구려 월 2.5달러 VPS 라서 컨텐츠 딜리버리를 하기에는 월 트래픽이 부족하다.)  요즘 Electron (구 Atom Shell)을 만지고 있는데, 여기에 Vue와 섞어서 구현을 해보자는 생각을 했다.

자세한 얘기는 다음 포스팅으로 넘기기로 하고, 처음에는 감히 다음과 같은 코드로 요청과 요청 사이에 딜레이를 넣는 정-중한 크롤링을 시도했다.


그랬더니, 100개의 요청이 와장창 시작되고, 각각의 요청이 끝난 뒤에 2초를 기다려주는 귀족적인 모습을 볼 수 있었다. Array.prototype.forEach가 딜레이 없이 와장창 실행되어버린 것이다.  결국 내가 원하는 그 정-중함은 위의 코드로 얻을 수 없었다.

사족: Production 환경에서는 2초보다는 더 짧은 간격을 주지만, 개발단계에서는 실제로 간격이 들어가고 있는지 확인하기 위해 적당히 더 긴 간격으로 설정한 것


예전에 이런 콜백들을 다루는 라이브러리, async라는 것이 생각나서 async.serial 함수를 사용하여 처리해보려고 했지만, 이 녀석은 Promise를 다루는 라이브러리라기 보다는 callback 스타일의 라이브러리라 다른 대안을 찾아보기로 생각했다. (찾다 찾다 안 나오면 써야지.. 후..)

처음 찾은 대안은 이 포스팅이다. Q library를 쓴다는 것이 약간 걸렸지만, 내가 원하던 구현이 담겨있었다. 저 포스팅과 이 스택오버플로우 답변을 참고했다. 특히 이 답변에서 내가 잘못 알고 있었던 Promise에 대한 이해도 어느정도 풀 수 있었는데, Promise는 실행될 수 있는 그 무언가가 아니라는 뜻. 나는 JAVA의 Thread와 비슷한 무엇이라고 생각하고 있었는데,  굳이 그것에 빗대어 설명을 다시 하자면, 만들어지자마자 실행되는 Thread가 곧 Promise였던 것이다!


위 코드를 참고하여 작성해본 코드다. JSON을 던져주는 적당한 서비스를 찾아서 그 여러개의 urlList에 2초간격을 두고 순차적으로 Api Call을 하는 코드다.


콘솔 출력으로는 value undefined, value ${ip_result}, value ${headers_result}, value ${time_result} (json을 전부 쓰는게 의미가 없어서 적당히 대체함)  가 2초 간격을 두고 순차적으로 출력되는 것을 확인할 수 있었다. 처음 출력되는 undefined는 처음에 reduce의 초깃값으로 넣어준 Promise.resolve() 객체의 그것이다.


하지만, 이 코드의 중대한 결함은 에러 핸들링이 빈약하다는 것. 실제로 위에서 링크건 포스트에서도 그다지 충실한 느낌의 catch 구문도 아니었거니와 실패한 Promise를 다시 실행할 수 있는 코드로서 사용하기도 힘든 느낌이 있다. 그래서 다음과 같은 함수를 프로토타입으로 대강 뽑아봤다.

사족: 실패횟수를 적당히 조절해 그 이상 넘어가면 재시도 버튼을 누르게 하는 것이 Best Practice 겠지만, 나는 내가 크롤링하려는 미국 대기업의 CDN을 믿는다 ^0^b

그래서 다음과 같이 재귀적으로 짜봤다. 꼬리재귀 최적화의 효과도 받을 수 있을 듯?


코드의 eslint-disable-line 이라는 eslint DSL을 사용하지 않으니, 에러를 띄워주더라. eslint 사이트에 들어가서 문서를 뒤져보니 Promise의 장점을 살리지 못하는 sequential한 코드기 때문에 구리다는 문구가 들어가있었다. 그래! 내가 원하던 게 바로 이 구림이야!

사실, 밑의 코드보다 위에서 reduce를 사용하는 함수를 짜면서 더 많이 공부가 됐던 것 같다. 특히, urlList.map(async url => () => axios.get(url))urlList.map(url => async () => axios.get(url))의 실행결과가 다르다는 것도 조금 흥미로웠고, async 함수의 return값이 암묵적으로 Promise.resolve로 감싸진다는 것도 실제로 느껴본 느낌이다.

Leave a Reply