인스타그램 인기게시물 지도 제공 서비스 당신이 모르는 그곳, 미소를 만들기 위해 인스타그램 데이터를 수집하며 겪었던 문제와 팀원들과 함께 해결한 내용을 쭉 정리해보자.
인스타그램 인기 게시물 지도 제공 서비스인 미소를 만들기 위해서는 인스타그램의 정보가 필수적으로 필요했다.
인기 게시물을 Hashtag 개수를 기준으로 판단하기 위한 특정 가게의 Hashtag 개수 및 인스타그램에서 특정 가게 검색 시 노출되는 9개 인기 게시물의 이미지 URL과 포스트 URL이 필요했다.
처음엔 'instagram API를 사용하면 되겠지~'라고 막연하게 생각했다.
서울만 해도 약 4만 개의 가게가 있고 거기에 인기 게시물 9개의 이미지, 포스트 URL까지, 시간당 200번 요청은 터무니없이 작게 느껴졌다. 그래서 스크래핑(크롤링)을 어쩔 수 없이 선택했다.
우리 프로젝트에서 했던 건 스크래핑이지만 이 글에서 용어가 중요한 건 아니니까.. 크롤링이라고 칭하겠다.
CSR 크롤링
크롤링을 시작하자마자 문제가 있었는데 인스타그램을 크롤링하면 HTML 코드가 크롤링 되어 오지 않았다. 실제로 인스타그램을 크롤링해보면 아래와 같이 json만 가득 찬 코드가 리턴됐다.
<script type="text/javascript">window._sharedData = {"config":{"csrf_token":"eNDwTVXNsrLT6dLkrANfR7ppFwYC8v6L","viewer":null,"viewerId":null},"country_code":"KR","language_code":"ko","locale":"ko_KR","entry_data":{"TagPage":[{" ...
길이도 엄청 길다. 몇 번 검색해보니 그 이유를 알 수 있었다. 인스타그램은 React로 만들었고 SPA의 경우엔 서버는 데이터만 브라우저에 전송하고 브라우저가 랜더링해주는 방식이다.
서버에 데이터를 요청하는 HTTP Request를 사용하는 Jsoup은 그래서 랜더링된 HTML 코드를 긁어올 수 없었다.
WebDriver를 사용하는 Selenium 라이브러리로 랜더링된 HTML 코드를 긁어올 수도 있었지만 selenium-vs-jsoup -stack overflow글을 보면 selenium은 항상 element가 유효한지 확인하는 과정이 있기때문에 느리다. 우리는 꽤 많이 반복하며 크롤링해야 했기 때문에 조금이라도 더 빠른 Jsoup으로 해결하고 싶었다. 그래서 페어와 받아온 데이터를 뚫어져라 쳐다보다 수 많은 데이터들 중 원하는 데이터들이 있는 패턴을 찾아낼 수 있었다.
결국 정규식으로 데이터를 추출한 후 JsonParser 객체로 json 데이터를 String으로 변환하는 방식으로 해결했다.
@Getter
enum RegexPattern {
HASH_TAG_COUNT(Pattern.compile("(\"edge_hashtag_to_media\":\\{\"count\"):([0-9]+)")),
HASHTAG_POPULAR_POSTS_INFO(Pattern.compile("(\"edge_hashtag_to_top_posts\":)(.*)(,\"edge_hashtag_to_content_advisory\")"));
private Pattern pattern;
RegexPattern(Pattern pattern) {
this.pattern = pattern;
}
public String extract(String body) {
...
}
}
429 Too Many Request
카카오 API로 수집한 서울시 가게들에 대해 크롤링으로 인스타그램 데이터를 수집하기 위한 테스트 도중 429 에러가 많이 반환됐다. 크롤링으로 너무 많은 요청을 보내니 생기는 에러였다.
429 에러를 피해가기 위해 처음 시도했던 방식은 proxy이다. 열심히 검색해보니 요청 횟수를 판단하는 기준이 ip 같았고 proxy로 ip를 속이면서 크롤링하면 429를 피해갈 수 있을 거라고 생각했다.
하나의 프록시로는 현재 있는 약 4만개의 가게 정보를 429를 피하면서 크롤링할 수 없기에 여러개의 프록시를 모으기 위한 방법을 고민했다. 그러다 프록시를 크롤링해오면 되지 않나?? 라는 생각이 들었고 free-proxy-list라는 적당한 사이트를 발견했다.
크롤링하고 가공해서 모아온 프록시를 검증하는 것이 다음 문제였다. 긁어는 왔지만 작동이 되는 프록시인지 보장할 수 없었기 때문이다. 프록시는 작동되지 않아도 디폴트 ip로 네트워크 연결을 해서 크롤링해오기 때문에 검증을 마친 프록시를 사용해야만 429를 피할 수 있을 것 같았다. HttpURLConnection 객체의 usingProxy메서드가 유일한 검증 수단으로 보였고 https 프록시들은 하나씩 연결해보면서 usingProxy 메서드로 검증했다. disconnect을 해주지 않으면 connection이 부족하다는 에러와 함께 연결되지 않으므로 주의해야 한다.
private static boolean isOnline(Proxy proxy) {
proxy.setHostAndPort();
HttpURLConnection httpURLConnection = null;
try {
httpURLConnection = (HttpURLConnection) new URL("https://www.instagram.com/").openConnection();
httpURLConnection.setConnectTimeout(5000);
httpURLConnection.setReadTimeout(5000);
httpURLConnection.connect();
boolean isOnline = httpURLConnection.usingProxy();
httpURLConnection.disconnect();
return isOnline;
...
}
그렇게 모아둔 프록시로 크롤링을 해오는데 언제 프록시를 바꿔주느냐가 고민이었다. 여러 번 실험을 해보니 분당 40~45번 요청 사이에서 항상 429 에러가 반환됐고 크롤링 횟수 40번을 기준으로 프록시를 바꿔주니 429가 한 번도 터지지 않았다. 하지만 다른 여러 가지 새로운 문제들이 발생했다. 분명 프록시를 사용해서 연결되는 것 까지 확인했는데 read time out이 계속해서 발생했다. 이 문제는 네이버 D2에서 사용할 수 있는 포트가 없으면 read time out이 발생할 수 있다는 내용을 보고 프록시를 설정할 때 set했던 https.proxyHost를 clear해주니 확연히 time out이 줄어들었다.
public void setHostAndPort() {
System.setProperty("https.proxyHost", this.host);
System.setProperty("https.proxyPort", this.port);
}
public void clearProperty() {
System.clearProperty("https.proxyHost");
System.clearProperty("https.proxyPort");
}
나머지는 Jsoup에 설정해뒀던 time out 시간을 늘려주니 아예 발생하지 않았다. Read time out 문제를 해결하니 또 다른 여러 가지 문제들이 발생했다.
- javax.net.ssl.SSLException: SSL peer shut down incorrectly
- java.net.SocketException: Connection reset
- java.io.IOException: Underlying input stream returned zero bytes
등등 너무 많은 종류의 에러가 발생했다. 아무래도 무료 proxy를 사용하니 발생하는 문제가 많은 것처럼 느껴졌다. 그래서 다음으로 고려한 건 Thread.sleep으로 크롤링 자체를 천천히 하는 것이다. 안그래도 많은 데이터에 Thread.sleep을 하려니 429가 발생 안 하면서 최대한 짧은 시간 sleep 시키고 싶어서 몇 시간 실험을 거쳐 최적의 millisecond를 찾아냈다.
하지만 여전히 약 4만 개를 크롤링해온다고 할 때 너무너무 오랜 시간이 걸린다. 이 문제는 아직 진행 중이다. 다음 방법으로는 멀티 쓰레드로 크롤링을 최대한 빨리 돌리는 동시에 프록시를 적용해서 429는 피하고, 이력 관리 테이블을 만들어서 프록시로 인해 발생한 에러들을 기록하고 다시 돌리는 식의 방법을 생각 중이다. 프록시를 적용 안하고 멀티쓰레드로 돌렸을 때 429개수보다 프록시로 인한 에러 개수가 훨씬 더 적기 때문에 가져올 수 있는 데이터를 100% 가져오면서 시간을 단축할 수 있을 것 같다. 직접 해보면 또 다른 문제가 발생하겠지만..
404 NOT FOUND
카카오 API로 수집한 서울시 가게들에 대해 크롤링으로 인스타그램 데이터를 수집하기 위한 테스트 도중 404 에러가 많이 반환됐다. 해시태그가 한 개도 등록돼있지 않은 가게를 검색했을 때 반환되는 에러였다. 어쩔 수 없는 경우였기에 Optional.empty로 값을 반환해주는 처리를 했었다.
그런데 예상치 못한 404 에러를 반환하는 경우가 한 가지 더있었다. 바로 공백이 포함된 가게 이름이다. 인스타그램에서 해시태그는 공백을 포함해서 등록할 수 없었고 카카오 API에서 받아온 가게 이름 중에 공백을 포함한 가게 이름은 무수히 많았다.
이를 알아차리고 유틸 클래스를 만들어서 공백을 제거해주는 작업을 해줬다.
public static String parsePlaceName(String placeName) {
String parsedPlaceName = placeName.replaceAll(" ", "");
...
}
데이터 정확성
카카오 API로 수집한 가게를 기준으로 인스타그램에 요청을 보내다 보니 데이터 정확성이 떨어지는 문제가 발생했다. 예를 들면 '한강'이라는 음식점이 있을 때 한강으로 인스타그램 데이터를 수집하면 해시태그 개수가 4백만 개가 나왔다. 하지만 그 음식점을 태그한 게 아니라 한강 자체를 해시태그한 게시물이 대다수기 때문에 이런 데이터들을 거르는 작업이 필요했다.
해시태그들이 해당 가게를 태그한 것인지 확인하는 작업은 코딩만으로는 거를 방법이 없었다. 이런 잘못된 데이터들은 대부분 해시태그 개수를 비정상적으로 많은 특징이 있었기에 해시태그 개수 기준 내림차순 정렬된 가게들을 보여주고 해시태그 개수가 해당 가게를 태그한 게 맞는지 직접 팀원들이 확인하고 지역명을 넣어 이름을 바꾸거나 아예 black list로 처리하는 기능을 admin 페이지에 구축해서 관리하고 있다.
이렇게 관리한다고 해도 100% 정확한 데이터를 만들 순 없다. 그래서 사용자 신고 기능을 도입해서 제보를 받으며 더 정확한 데이터를 만들려고 노력할 계획이다.
롤백과 Spring Batch
데이터 수집을 싱글 쓰레드로 한 번에 처리하는 구조에서 한 번은 몇 시간에 걸쳐 진행 중이던 데이터 수집 작업이 Transaction time out 이 나면서 종료한 일이 있었다. 재실행해도 실패한 로직부터가 아니라 처음부터 실행 해야 했다. 몇시간을 날린 기분이었다.
이러한 문제를 두고 팀원들과 해결할 방법을 고민했다. Transaction time out 자체야 메서드 분리 등을 통해 쉽게 해결할 수 있었지만 이러한 예상치 못한 문제가 또 발생하지 않는다는 보장이 없었다. 그래서 작업 실패에 대처할 수 있는 방법을 고민했다.
이력 관리 테이블을 만들고 처리하는 로직을 짜서 해결하는 방법과 Spring Batch를 도입하는 방법을 고민했는데 Spring Batch를 통해 해결하게 되었다.
하려던 방법이 Spring Batch에서 이미 제공하는 기능들이기에 다시 만들 필요가 없다고 생각했다. 이력 관리를 통해 실패한 작업부터 시작할 수 있고 로깅도 지원한다.
또 이러한 로직이 프로덕션 코드 중간중간 섞이지 않고 따로 모듈로 분리할 수 있다는 점도 매력적이었다.
마지막으로 서비스에서 지금은 서울시 가게들 정보만 제공하지만 전국으로 확장할 계획이기에 chunk 단위로 데이터를 읽어오는 등 대용량 데이터 처리에 최적화된 Batch가 적합하다고 생각했기에 프로젝트에 Spring Batch를 적용하였고 나름 성공적이었다.
'우아한테크코스 > 프로젝트' 카테고리의 다른 글
Multi Module (0) | 2020.09.21 |
---|---|
Logback, Error 로그 Slack 알림 받기 (0) | 2020.08.25 |
Logback 이해하기 (2) | 2020.07.29 |