전생에 크롤링을 못하고 죽었나 싶을 정도로 또 크롤링을 돌리고 있다. 초기에는 특정 서비스에서 사진 / 동영상을 크롤링하는 코드를 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
와 섞어서 구현을 해보자는 생각을 했다.
자세한 얘기는 다음 포스팅으로 넘기기로 하고, 처음에는 감히 다음과 같은 코드로 요청과 요청 사이에 딜레이를 넣는 정-중한 크롤링을 시도했다.
async downloadSomething(mediaUrlList) { | |
mediaUrlList.forEach(async mediaUrl => { | |
const res = await axios.get(mediaUrl, { responseType: 'arraybuffer' }); | |
fs.writeFileSync(...); | |
await delay(2000); | |
}); | |
} |
그랬더니, 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을 하는 코드다.
async function test(urlList) { | |
const promiseList = urlList.map(url => async () => axios.get(url)); | |
return promiseList.reduce((p, fn) => p.then((value) => { | |
console.log('value', value); | |
return delay(2000); | |
}).then(fn), Promise.resolve()); | |
} | |
test(['http://ip.jsontest.com', 'http://headers.jsontest.com', 'http://time.jsontest.com']) | |
.then((value) => { | |
console.log('value last', value.data); | |
}); |
콘솔 출력으로는 value undefined, value ${ip_result}, value ${headers_result}, value ${time_result} (json을 전부 쓰는게 의미가 없어서 적당히 대체함) 가 2초 간격을 두고 순차적으로 출력되는 것을 확인할 수 있었다. 처음 출력되는
undefined
는 처음에 reduce
의 초깃값으로 넣어준 Promise.resolve()
객체의 그것이다.
하지만, 이 코드의 중대한 결함은 에러 핸들링이 빈약하다는 것. 실제로 위에서 링크건 포스트에서도 그다지 충실한 느낌의 catch
구문도 아니었거니와 실패한 Promise를 다시 실행할 수 있는 코드로서 사용하기도 힘든 느낌이 있다. 그래서 다음과 같은 함수를 프로토타입으로 대강 뽑아봤다.
사족: 실패횟수를 적당히 조절해 그 이상 넘어가면 재시도 버튼을 누르게 하는 것이 Best Practice 겠지만, 나는 내가 크롤링하려는 미국 대기업의 CDN을 믿는다 ^0^b
그래서 다음과 같이 재귀적으로 짜봤다. 꼬리재귀 최적화의 효과도 받을 수 있을 듯?
async function test(urlList) { | |
if (urlList.length === 0) { | |
return; | |
} | |
const errorUrlList = []; | |
for (let i = 0; i < urlList.length; i += 1) { | |
const url = urlList[i]; | |
try { | |
const res = Math.random() > 0.5 ? await axios.get(url) : await axios.get(1); | |
console.log(res.data); | |
} catch (e) { | |
errorUrlList.push(url); | |
} finally { | |
await delay(2000); // eslint-disable-line no-await-in-loop | |
} | |
} | |
} | |
test(errorUrlList); |
코드의
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로 감싸진다는 것도 실제로 느껴본 느낌이다.