본문 바로가기
  • 개발하는 곰돌이
Development/토이 프로젝트

[토이프로젝트] KBO 경기 일정 수집하기(3) - 멀티 스레드 기반의 경기 정보 병렬 수집

by 개발하는 곰돌이 2025. 12. 9.

목차

    /pages/KBO-경기-일정-수집하기-시리즈

    병렬 프로세스 생각한 계기

    KBO 공식 사이트 경기 일정 페이지의 특성 상 한 번에 1개의 시리즈의 한달치 경기 일정만 조회할 수 있습니다. 즉, 1년 전체 일정을 수집하려면 시범경기, 정규시즌, 포스트시즌 각각 1월부터 12월까지 총 36번의 요청이 필요한 것이죠.

     

    이 요청을 동기 방식으로 처리한다면 이전 요청이 끝나야 다음 요청이 실행되기 때문에 36번의 요청을 모두 처리하기 까지 시간이 꽤 많이 걸립니다. 그렇다보니 비동기 요청으로 요청 시간을 줄이면 어떨까 하는 생각이 든거죠.

     

    물론 시범경기는 보통 3월 한달만 진행되고 포스트시즌은 정규시즌이 끝난 후 약 한달 정도만 진행되는 만큼 엄격히 따지면 요청 횟수를 줄일 수 있긴 합니다. 그래도 우선은 코드 관리의 용이성을 위해 각 시리즈별로 1월부터 12월까지 요청을 보내도록 구현했습니다.

    기존 동기 요청 코드

    처음에는 다음과 같이 단순 반복을 통해 데이터를 수집했습니다.

    fun collectAndSaveSeasonGameInfo(
        season: Int,
        seriesType: SeriesType? = null
    ): CollectDataResponse {
        // seriesType이 null이면 전체 시리즈 수집
        val seriesTypes = seriesType?.let { listOf(it) } ?: SeriesType.entries
        val seasonGameInfo = Playwright.create().use { playwright ->
            playwright.chromium().launch().use { browser ->
                seriesTypes.flatMap { type ->
                    (1..12).flatMap { month -> 
                        browser.scrapeGameInfo(season, month, type)
                    }
                }
            }
        }
    
        return gameInfoDataService.saveOrUpdateGameInfo(seasonGameInfo)
    }

    시범경기에 대해 1월부터 12월까지 수집 → 정규시즌에 대해 1월부터 12월까지 수집 → 포스트시즌에 대해 1월부터 12월까지 수집 순서로 한달의 경기 일정 수집이 끝나야 그 다음달의 경기 일정을 수집합니다.

     

    그 결과 한 시즌의 전체 경기 일정을 수집하는 데에 약 1분 13초가 걸렸습니다.

    병렬 프로세스로 개선하자!

    1분대면 그래도 기다릴법한 시간이긴 하지만 길다고 느낄 수도 있는 시간인 만큼 병렬 프로세스로 개선해보겠습니다.

     

    코틀린에는 코루틴이라는 강력한 비동기 처리 도구가 있기 때문에 코루틴을 사용했습니다.

    비동기 요청 전략을 세우자

    병렬 프로세스를 통해 요청을 보내게 되면 서버에 동시다발적인 요청을 보내기 때문에 서버의 부하가 심해집니다. 하지만 한 시즌의 전체 일정을 수집하여 갱신하는 로직은 하루에 많아봐야 10번 내외로 호출할 것이라는 전제를 두고 진행하고 있었던 만큼, 서버에 큰 부담이 되진 않을 것이라고 판단했습니다.

    물론 그렇다고 하더라도 서버가 1초에 36번의 요청을 받는건 달가운 일이 아닙니다. 자칫 잘못하면 서버에 대한 공격으로 간주되어 차단당할 수도 있겠죠.

     

    뿐만 아니라 Playwright를 통한 크롤링은 눈에 보이지 않는 실제 브라우저를 실행하여 진행되기 때문에 클라이언트가 되는 PC의 리소스를 상당히 사용합니다. 이 경우라면 36개의 브라우저 창이 동시에 실행되어 웹페이지에 접속하는 셈이라 클라이언트 입장에서도 상당한 부담이 됩니다. 그래서 두가지 방법을 혼용하여 요청을 분산시키면 어떨까 하는 생각이 들었습니다.

    • 1월부터 12월까지의 경기 일정 수집은 비동기 요청을 보내되, 각 시리즈별 경기 일정 수집은 동기 방식으로 진행합니다. 총 36번의 요청이 발생하는 것은 맞지만 12번의 비동기 요청 → 완료 후 12번의 비동기 요청 → 완료 후 12번의 비동기 요청 순으로 서버에 요청을 보내게 되어 한 번에 최대 12번의 요청만 보내게 됩니다.
    • 각 비동기 요청을 보내기 이전에 무작위 지연시간을 발생시킵니다. 예를 들어 12번의 비동기 요청을 발생시킬 때 각 요청마다 0.1 ~ 0.5초의 무작위 지연시간을 넣어서 서버가 1초에 받을 요청의 수를 줄이는 것이죠.

    Playwright의 비동기 문제

    일반적으로 Playwright는 스레드 안전성을 가진다고 알려져있습니다. 하지만 이 말은 반만 맞는 말입니다.

     

    코틀린이나 자바 같은 JVM에서 사용되는 Playwright는 스레드 안전성을 가지지 않습니다. 즉 Playwright 객체, Browser 객체, BrowserContext 객체, Page 객체 등의 메소드는 모두 객체가 생성된 스레드와 같은 스레드에서 호출되어야 합니다.

    만약 이를 무시하고 여러 개의 스레드가 Playwright 라이버리의 객체 하나를 공유해서 사용하려고 한다면 위 사진과 같이 요청 컨텍스트를 찾을 수 없다는 PlaywrightException이 발생합니다. 브라우저 객체를 생성한 애플리케이션의 메인 스레드와 브라우저 객체를 사용하려는 스레드가 서로 다르기 때문이죠.

    Playwright 공식 문서에서는 이에 대한 대안으로 여러 개의 스레드를 띄우고 각 스레드별로 Playwright 객체를 생성하는 것은 문제가 없다고 합니다.

     

    또한 Playwright는 어디까지나 자바 라이브러리다보니 모든 함수가 블로킹 함수로 작성되어 있습니다. 그렇기 때문에 코루틴을 사용하더라도 Dispatchers.IO 속성을 통해 멀티 스레드 환경을 만들고, 각 요청을 별개의 스레드에서 실행해야 진정한 병렬 처리가 가능합니다.

     

    다시 말해 코틀린 코루틴 환경에서 Playwright를 사용한 비동기 크롤링을 하려면 다른 언어 환경과는 달리 단일 스레드에서의 비동기 크롤링은 불가능하고, 멀티 스레드 환경을 만들어서 스레드마다 브라우저를 실행해야 한다는 것이죠.

    실제 구현

    suspend fun collectAndSaveSeasonGameInfo(
        season: Int,
        seriesType: SeriesType? = null
    ): CollectDataResponse {
        // seriesType이 null이면 전체 시리즈 수집
        val seriesTypes = seriesType?.let { listOf(it) } ?: SeriesType.entries
        // 1월부터 12월까지 해당 시즌/시리즈의 경기 일정을 병렬 수집 후 취합
        val seasonGameInfo = coroutineScope {
            seriesTypes.flatMap { type ->
                (1..12).map { month -> async(Dispatchers.IO) {
                    // KBO 서버 부하 방지를 위한 랜덤 딜레이(0.1 ~ 0.5초)
                    delay(Random.nextLong(100, 501).milliseconds)
                    Playwright.create().use { playwright ->
                        playwright.chromium().launch().use { browser ->
                            browser.scrapeGameInfo(season, month, type)
                        }
                    }
                } }.awaitAll().flatten()
            }
        }
    
        return withContext(Dispatchers.IO) { gameInfoDataService.saveOrUpdateGameInfo(seasonGameInfo) }
    }

    먼저 코루틴을 사용해야 하니 이 함수 자체를 suspend 함수로 바꿔줍니다.

     

    경기 일정을 수집하는 부분은 구조를 수정해야 합니다. 기존에 동기 프로세스에서는 브라우저 실행 → 시리즈별 순회 → 월별로 순회하여 경기 일정 수집 순서였지만, 앞서 이야기했듯이 현재 환경에서 병렬 프로세스로 경기 일정을 수집하려면 각 요청마다 새로운 브라우저를 실행해야 하죠. 즉 시리즈별 순회 → 월별 순회 → 브라우저를 실행하여 경기 일정 수집 순서로 구조가 바뀌게 됩니다.

     

    멀티 스레드 환경에서 병렬 처리를 하기 위해 async의 속성으로 Dispatchers.IO를 설정하고, 그 내부에서 0.1 ~ 0.5초의 무작위 지연시간을 가진 다음에 브라우저를 실행하여 경기 일정을 수집하는 요청을 보냅니다.

     

    이렇게 한 시리즈의 1월부터 12월까지 경기 일정 수집 요청을 모두 보냈다면 awaitAll()을 통해 경기 일정이 모두 수집되는걸 기다리고, flatten()을 통해 경기 정보 리스트들을 하나로 합쳐서 한 시리즈의 1년치 경기 정보 리스트를 만들어줍니다.

     

    seriesTypes에 포함된 모든 시리즈의 1년치 경기 정보 리스트들은 다시 flatMap을 통해 합쳐져, seasonGameInfo라는 전체 1년치 경기 정보가 담긴 하나의 리스트가 완성됩니다.

     

    마지막으로 완성된 리스트를 사용하여 2편에서 다룬 Upsert 로직을 실행하여 DB에 반영합니다. 여기서 Upsert 로직도 입출력 스레드에서 실행하도록 격리했는데, 현재 환경이 스프링 MVC여서 당장의 문제는 없지만 코루틴의 컨텍스트 분리 원칙에 따라 메인 스레드를 보호하기 위해 적용했습니다.

    결과

    동기 방식의 약 1분 13초에서 약 20초로 줄어들어 약 3.7배의 성능 향상이 이뤄진 것을 확인할 수 있었습니다.

    깃허브 링크

     

    GitHub - Colabear754/kbo-scraper: KBO 경기 일정을 크롤링하는 프로젝트

    KBO 경기 일정을 크롤링하는 프로젝트. Contribute to Colabear754/kbo-scraper development by creating an account on GitHub.

    github.com

    댓글