트러블슈팅과 고민

코루틴을 이용한 크롤링 속도 개선

정재익 2025. 12. 25. 19:50

2025년 2월의 내용을 노션에서 가져왔습니다.

 

1. PlayWright - 5분 30초

  • 베스트셀러 목록을 가져올 때 매번 크롤링을 실행하지 않고 DB에 저장된 데이터 반환 Book테이블에 순위 컬럼을 만들어 베스트셀러 구분 매시간 10분 마다 스케줄러를 이용한 자동 크롤링 크롤링 소요 시간 5분 30초
  • 동적 페이지를 크롤링 하기 위해 Playwright 라이브러리 활용
  • 교보문고 실시간 베스트셀러 100권을 크롤링
  • 목록에서 상세페이지로 들어가는 링크 100개를 크롤링
  • 그다음 상세 페이지 링크 100개로 들어가 데이터 크롤링
  • Book테이블에 새로운 랭킹 컬럼을 하나 만들어 베스트셀러 순위를 입력함
  • 베스트셀러가 아닌 책은 랭킹 컬럼에 null값 베스트셀러만 랭킹순위를 가지고 있음
  • 베스트셀러인 책과 아닌 책은 랭킹 순위를 가지고 있느냐로 구분
  • 랭킹 순위를 가진 책은 db에 저장할 때 동일한 책이라도 걸러지지않고 랭킹컬럼만 업데이트
  • 교보문고의 실시간 베스트셀러는 매시간 정각에 업데이트 되기 때문에 매시간 10분 마다 스케줄러를 이용하여 자동으로 크롤링 되게 설정
  • 매시간 10분마다 랭킹순위 초기화하고 새로 순위를 매기어 순위 중복을 방지
  • 순위 컬럼을 유니크로 하는 방식으로 할 시에 만약 겹치는 순위를 가진 책이 들어올 때 이전에 그 순위를 가진 책이 베스트셀러에서 빠진건지 순위가 변경된건지 확인해야 하는 등 로직이 복잡해짐
  • 베스트셀러는 db에서 추천순위로 구분하여 메인페이지에 반환 즉 베스트셀러를 호출할 때 매번 크롤링을 실행하는게 아니라 db에서 1위부터 정렬하여 가져옴
  • 베스트셀러는 매시간 10분마다 자동으로 DB에 업데이트되고 호출하는 입장에서는 db에 있는 베스트셀러만 가져오면 됨
  • 현재 크롤링 시간은 5분 30초 소요 코루틴을 활용하여 대폭 시간을 줄이려고 함

2. Playwright에 코루틴 적용 - 40초

  • 현재 문제는 코루틴을 적용하지않은 playwright는 아주 잘 작동함 그러나 코루틴을 적용하면 코루틴에러는 안뜨고 playwright 오류가 뜸 아마 비동기로 코루틴들이 동시에 가까운 속도로 작업을할때 playwright 객체에 대한 에러인거같음 이부분을 해결해야 함
  • playwright가 나온지 얼마안됐고 공식적으로 코틀린을 지원하지않아 (자바를 지원하기 때문에 코틀린으로 사용가능할 뿐) 코루틴을 접목할때 오류가 있는듯 함.
  • 아마 playwright 자체 비동기 기능을 가지고 있기때문에 이런일이 생긴다고 생각 (자체 비동기 기능은 코틀린에서는 사용불가) playwright의 객체인 page, brouser, browsercontext의 수명주기가 코루틴이 전환될때 강제로 close 되는 듯
  • 따로 코드짜서 웹 페이지 2개를 동시에 여는 간단한 테스트를 한 결과 page객체가 강제 종료되는 현상 발견 코루틴마다 새로운 page객체를 부여했는데도 불구하고 page 오류
  • 스택오버플로우 사이트에도 이러한 오류때문에 마이크로소프트에게 메일보냈다는 글이 좀 있었음
  • 그런데 내가 몰라서 Playwright오류라고 생각했을 가능성 큼 만약 코루틴만 호출했다고 생각했는데 디스패처에서 다른 스레드를 호출했다면 stack area가 달라져서 playwright객체가 오류를 일으킬 가능성이 있음
  • 그래서 스레드나 코루틴에 꼬리표를 달고 만약 하나 이상의 스레드가 작동되는지 확인할 생각임
  • 크롤링을 playwright에서 Jsoup + OkHttp를 활용한것으로 변경했지만 Jsoup + OkHttp 차단됨
  • 결국 playwright와 selenium중에서 써야함
  • 위에 적은거 보면 playwright를 코루틴에 접목할 때 오류가 있다고 했는데 그건 내가 잘 몰라서 한 말이었음 playwright는 스레드안정성이 없고 싱글스레드 위에서 동작하는데 그걸 Dispatcher.IO로 돌렸으니 당연히 playwright 객체를 찾을 수 없었던 것
  • 스레드 4개를 배정하고 각각 playwright 객체를 부여하고 Dispatcher.IO로 돌려서 완료
  • 다만 의문점이 있음 코루틴을 활용하는것이 메인이다보니 싱글스레드로 두고 코루틴만으로 속도를 줄이려 한 적이 있음 그런데 그땐 코루틴 쓸때랑 코루틴 안쓸때가 속도가 비슷함 vm옵션으로 페이지마다 다른코루틴이 적용되는 건 확인했음 코루틴이 동시성이라고 하지만 동시작용처럼 보이는 것일뿐 싱글 스레드 안에서 왔다갔다 자리 이동하는 것
  • 크롤링을 크게 3부분으로 나누면 1. 웹사이트에 접속한다 2. 웹사이트가 로딩될때까지 기다린다 3. 데이터를 파싱한다로 나눌 수 있음 이중에서 가장 큰 시간을 차지하는건 2번 웹사이트를 로딩하는 것을 기다리는 것.. 이라고 생각했었음
  • 웹 사이트를 로딩하는 것을 기다리는동안 코루틴을 스위칭해서 다른 웹 사이트들에 접속하고 첫번째 웹사이트의 로딩이 끝났을때 파싱하는 것으로 100개의 사이트의 로딩 기다리는 시간을 최소화하려고 playwright의 웹사이트 접속 코드, 로딩 기다리는 코드로 나누었으나 웹 사이트 접속코드가 로딩 기다리는 시간과 비슷해서 크게 효과를 못 봄 웹 사이트 접속 옵션을 가장 시간을 빨리당기는 옵션을 썼으나 그래도 playwright옵션의 한계인지 웹 사이트 접속이 2.7초면 웹 사이트 로딩을 기다리는 시간은 0.1초 정도로 나왔고 로딩을 기다리는 동안 코루틴을 전환하려했으나 코루틴을 쓰지 않았을때와 속도가 비슷했고 웹 사이트 접속하는 것은 거기에서 더 나눌수 없는 하나의 코드라 거기서 더 쪼개서 코루틴을 넣을 수 없었음 웹 페이지마다 다른 코루틴이 활동하는건 확인했으나 웹 사이트 접속자체가 가장 큰 시간 비중을 가지고 있어서 마치 동기식처럼 작용했음 Dispatcher.IO를 쓰지못해서 그런건지도 아니면 내가 잘 활용을 못한건지 모르겠음
  • 그래서 사실상 크게 시간을 줄인건 코루틴이 아니라 멀티스레드 부분이었음 코루틴이 주제인데 멀티스레드로 시간을 줄여서 찜찜한 기분임 다만 멀티스레드 환경에서는 코루틴이 시간을 줄이는데 크게 기여함 스레드를 4개만 써서 예상시간은 약 1분 20초였는데 스레드가 많다보니 Dispatcher.IO를 적용했을 때 코루틴이 알아서 여기저기 끼어들어갔는지 예상 시간이 2배 줄어서 40초가 나왔음 vm옵션을 적용했는데도 멀티스레드 환경에서는 콘솔에 코루틴배정이 안보여서 불편했음

 

3. Jsoup 크롤러 - 37초

동기식 코드로만 작성된 기본 크롤러를 스케줄러로 실행하면 스케줄러 스레드를 통해 동기식으로 처리됨

  • PlayWright, Selenium은 브라우저를 통해 접근하는 방식으로 무겁고 느림
  • PlayWright는 싱글스레드 기반에서 작동하여 코루틴 Dispatcher같은 핵심 기능을 쓰지 못함
  • 싱글스레드에서만 코루틴 적용시 적용하지 않았을때와 시간 감소 차이가 거의 없음
  • 내가 PlayWright 크롤링 소요시간을 8배 감소시켰던건 스레드를 명시적으로 부여하고 각 스레드마다 PlatWright 인스턴스를 부여하여 코루틴의 Dispatcher.IO를 적용한 것 소요시간은 빨라졌지만 스레드 레벨에서 코드를 조작하기 때문에 자원 부담이 큼 그리고 코루틴을 이용한 자원과 시간 감소라는 임무의 목표에 맞지 않음
  • Jsoup이용해서 yes24 베스트셀러 크롤링 완료 시간 37초

 

4. 싱글 스레드에서 코루틴 사용한 크롤링 - 35초

newSigleThreadContext를 사용하여 하나의 스레드로 Context로 만들어서 그 안에서 코루틴이 생성되게 했습니다. 사실상 동기식으로 작동하는 것을 볼 수 있었습니다. 몇 번 돌리진 못했지만 네트워크 상황에 따라 오히려 더 느릴 때도 있고 엇비슷합니다.

스레드를 반복문으로 운영하는 기본 크롤러와 달리 이 경우 코루틴 레벨에서 반복문에 투입되고 싱글스레드이기때문에 코루틴 간 context switch 비용을 최소화 시킨 이유로 성능의 향상을 조금 기대해 보았지만 싱글 스레드상의 효용이 너무 적고 매우 작은 비용이지만 코루틴들을 만들고 죽이는 비용도 있기 때문에 결국 엇비슷한 결과가 나왔습니다

 

5. 스레드 풀로 스레드를 5개 지정한 크롤링 - 8초

5개의 스레드가 병렬식으로 작동되어 크롤링을 합니다 각 스레드에 사전에 역할을 지정해주어 스레드간 context switch 비용을 최소화 시켰습니다.

스레드가 5개면 정확히 5배 빨라지는거라고 생각할 수도 있지만 동시성 이슈를 방지하기 위해 모든 스레드의 작업이 끝나는 것을 기다리게 했고 스레드 관련 자원 배분이나 기타 로직들이 추가되었기 때문에 5배 보다는 조금 느린 결과가 나왔습니다

 

6. 스레드 풀을 디스패처로 정의한 크롤링 - 8초

5개의 스레드로 최대 효율을 만드려고 스레드 풀을 디스페처로 지정하여 코루틴을 사용할 수 있게 하였습니다. 코루틴이 스레드 사이를 왕복하면서 조금 빨라지지 않을까 생각했으나 기존의 8초라는 시간이 매우 짧은 시간이고 이번에는 코루틴의 운용을 부드럽게 하기 위해 미리 스레드에 자원분배를 해주지 않아 스레드간, 코루틴간 context switch 비용이 3번의 경우보다 크게 생겼습니다 결론적으로 비슷한 결과가 나왔습니다.

 

7. 코루틴 DisPatchers.IO로 크롤링하되 스레드 개수를 제한 - 7초 (스레드 5개, 코루틴 사용)

코루틴의 DIsPatchers.IO는 IO작업에 내부적으로 최적화된 디스패처입니다. 따로 스레드 풀을 만들기 보단 최적화 된 디스패처를 사용하여 스레드 수를 제한하는게 더 좋은 결과가 나올 것이라 생각했습니다.

DisPatchers.IO는 자동으로 스레드의 개수를 조절하는데 작업량이 적으면 1~2개를 쓸 수도 있고 많으면 5개를 유지할 수도 있습니다. 유동적인 작업에서는 DisPatchers.IO가 좋습니다

이 경우 베스트셀러의 개수가 100개로 정해져있고 베스트셀러가 1시간마다 바뀌는데 그때마다 작업량이 달라지긴 하지만 큰 차이는 없습니다. 한마디로 작업량이 정해져있다고 보면되고 이 경우 DisPatchers.IO는 항상 5개의 스레드를 쓰게 됩니다.

작업량이 유동적이면 더 좋았겠지만 어차피 스레드 풀로 정의해도 5개의 스레드로 고정되니까 더 효율적인 DisPatchers.IO가 더 좋다고 생각합니다.

결과는 1초 정도 줄었습니다만 기존의 8초가 매우 짧은 시간이었던 것만큼 선택지 중에선 가장 정답이라고 생각합니다.

 

8. 코루틴의 DisPatchers.IO로 크롤링 - 4초 (스레드 64개, 코루틴 사용)

코루틴의 DIsPatchers.IO로 따로 제한을 두지 않고 크롤링 했습니다. 기본적으로 DisPatchers.IO는 64개의 스레드를 지원합니다. 베스트셀러가 100개다 보니 DisPatchers.IO는 가장 빠른작업을 위해서 64개의 스레드를 모두 썼습니다

시간은 4초로 크게 줄었지만 절대값으로 보면 겨우 3초가 줄었다는 것과 투자된 자원이 지나치게 많다는 점을 생각하면 부적절하다고 생각합니다.

작업량이 엄청나게 많아 시간이 오래걸릴때는 고려할만 하다고 생각합니다만 그때도 스레드의 제한을 상황에 따라 유동적으로 거는 것이 좋다고 생각합니다.

 

9. 베스트셀러 변경 없을 때 (접속하여 해시값 만들고 기존 해시값과 판단) : 0.9초