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

[토이프로젝트] KBO 경기 일정 수집하기(1) - 데이터 크롤링과 파싱(With 스프링부트 + Playwright)

by 개발하는 곰돌이 2025. 11. 25.

목차

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

    계기

    평소에 야구를 좋아하다 보니 경기 일정이나 결과를 데이터로 뭔가 해보면 어떨까 하는 생각이 들었습니다. 당장 데이터로 뭘 할지는 생각해보지 않았지만 그래도 일단 데이터를 수집해두면 뭐라도 하지 않을까 싶어 내친김에 시도해봤습니다.

     

    처음에는 개발자 센터 같은곳에서 제공하는 API가 있나 찾아봤는데 아무래도 KBO는 개발자 센터나 오픈 API를 제공하지 않는 모양이더라구요. 그래서 KBO 공식 사이트를 크롤링하여 데이터를 수집해봐겠다는 생각을 하게 됐습니다.

    robots.txt 정책 확인 하기

    특정 웹사이트를 크롤링하려면 먼저 해당 사이트의 robots.txt 파일을 확인해서 크롤링을 허용하거나 허용하지 않는 경로를 확인해야 한다고 합니다. 물론 robots.txt 파일은 어디까지나 권고의 개념이라 강제성이 없긴 하지만 그래도 괜히 긁어 부스럼을 만들기보단 지켜주는게 좋을 듯 합니다.

     

    https://www.koreabaseball.com/robots.txt와 같이 브라우저에서 크롤링하려는 웹사이트의 루트 경로 뒤에 /robots.txt를 붙인 주소에 접속하면 내용을 확인할 수 있습니다.

    확인해보니 KBO는 /Common/**, /Help/**, /Member/**, /ws/**라는 경로에 대한 크롤링은 허용하지 않지만, 그 외의 경로는 딱히 건드리지 않습니다. 경기 일정은 어디에도 속하지 않으니 문제 없이 크롤링할 수 있겠네요.

    개발 환경

    아래 개발 환경 및 사용 기술 스택을 사용했습니다.

    • Windows 11
    • IntelliJ IDEA Ultimate 2025.2.4
    • Spring Boot 3.4.11
    • Kotlin 2.2.20(JDK 17)
    • Playwright

    KBO 공식 웹사이트의 경기일정/결과 페이지는 동적 페이지이기 때문에 웹사이트를 크롤링하려면 브라우저를 조작할 수 있는 라이브러리를 사용해야 합니다.

    일반적으로 동적 페이지를 크롤링할 때는 Selenium과 Playwright를 사용하는데, 저는 Playwright를 선택했습니다.

    Playwright를 선택한 이유?

    Selenium에 비해 성능이나 안정성 면에서 더 좋다는 생각이 들어서 Playwright를 선택했습니다.

     

    동적 페이지 크롤링에는 전통적으로 Selenium이 사용되지만 몇가지 단점이 있습니다.

    • 브라우저를 조작할 때 별도로 실행되는 웹 드라이버에 HTTP 요청을 전달하여 간접적으로 조작합니다. 즉 코드와 브라우저 사이에 웹 드라이버라는 중간 응용 계층이 존재하기 때문에 속도가 상대적으로 느립니다.
    • 위의 단점과 연관된 단점으로, Selenium을 사용하려면 PC에 설치된 브라우저를 기반으로 작동하기 때문에 브라우저의 버전에 맞는 웹 드라이버를 추가로 설치해야 합니다. 최근에는 WebDriverManager를 통해 자동으로 드라이버를 설치할 수 있지만, 협업을 한다면 팀원들과 브라우저의 버전을 맞추지 않으면 결과가 달라질 수 있다는 잠재적 문제가 있습니다.
    • 웹페이지의 DOM을 조작할 때 해당 DOM이 화면 상에 존재하지 않으면 즉시 예외를 발생시킵니다. 이를 방지하기 위해 개발자가 직접 대기 시간을 설정해야 합니다.
    • 웹 드라이버를 컨트롤하는 객체가 AutoCloseable 구현체가 아닙니다. 따라서 자바의 try-with-resources 구문이나 코틀린의 use와 같은 강력한 기능을 사용할 수 없고, 크롤링이 끝난 이후에 finally를 통해 별도로 웹 드라이버를 종료해야 합니다.

    이 중에서 두번째와 네번째 단점이 가장 크게 와닿았습니다. 라이브러리를 사용할 뿐인데 별도의 드라이버가 필요하고 같은 버전의 라이브러리를 사용하더라도 브라우저의 버전에 따라 결과가 달라질 수 있다는 점, finally를 통한 안전한 리소스 종료를 신경쓰지 않으면 런타임 에러나 메모리 누수가 발생할 수 있다는 점은 꽤나 큰 문제라고 여겨졌거든요.

     

    Playwright는 라이브러리 버전별로 지정된 버전의 브라우저를 자동으로 설치하여 사용합니다. 이 브라우저는 PC에 설치된 기존 브라우저와 격리된 별개의 브라우저고, 라이브러리 버전에 따라 브라우저 버전이 지정되어 있기 때문에 환경에 따라 결과가 달라질 우려가 없습니다.

     

    또한, Playwright에서 브라우저를 직접 조작하기 위해 생성하는 객체는 모두 AutoCloseable 구현체입니다. try-with-resources 구문이나 use를 통해 자동으로 안전하게 사용이 끝난 리소스들을 종료할 수 있는 것이죠.

     

    그 외에도 Playwright는 브라우저를 직접 조작하기 때문에 속도가 빠르고 DOM이 등장할 때까지 자동으로 대기하는 기능이 탑재되어 있어 따로 대기 코드를 작성하지 않아도 되는 장점도 있다보니, 새로 프로젝트를 시작할 때는 굳이 Selenium을 사용할 필요가 없다고 생각했습니다.

    경기일정/결과 페이지 구조를 파악하자!

    화면 구조

    경기일정과 결과를 데이터화하려면 먼저 해당 정보를 제공하는 페이지가 어떻게 구성되어 있는지 파악해야 합니다.

    단일 웹페이지에서 연도와 월, 시즌 종류를 선택하면 일정표만 다시 로드하는 구조입니다. 한 시즌의 전체 일정을 얻으려면 먼저 연도를 선택하고 월을 선택하는 드롭다운 메뉴에서 1월부터 12월까지 각각 선택해서 데이터를 수집하고, 이 작업을 시범경기, 정규시즌, 포스트시즌 드롭다운 메뉴 각각에 대해 수행하면 되겠네요.

    HTML

    브라우저의 개발자 도구를 열어 페이지의 HTML을 확인해봅시다.

    우선 가장 먼저 셀렉트박스 부분입니다. id가 각각 ddlYear, ddlMonth, ddlSeries인 셀렉트 박스에서 어떤 옵션을 선택해야 하는지 알 수 있네요.

    각 경기 일정은 #tblScheduleList라는 테이블에 차곡차곡 쌓여있습니다. 이걸 기반으로 데이터를 파싱하면 되겠네요!

    본격적으로 데이터를 수집해보자!

    이제 본격적으로 Playwright를 사용하여 데이터를 수집해봅시다.

    일단 브라우저부터 실행하자

    웹사이트를 크롤링하려면 우선 브라우저부터 실행해야 합니다. Playwright는 드라이버 객체를 통해 브라우저를 실행하고, 이 브라우저에서 웹사이트에 접속합니다. 여기서 말하는 드라이버는 Selenium의 웹 드라이버와 달리, 브라우저와 직접적으로 소켓 통신을 하는 커넥션 객체입니다.

    Playwright.create().use { playwright ->
        playwright.chromium().launch().use { browser ->
            actualLogic()      // 실제 로직
        }
    }

    Playwright.create()를 통해 드라이버 객체를 생성합니다. 그리고 드라이버 객체를 통해 브라우저를 실행합니다. 저는 chromium().launch()를 통해 크로미움을 실행했지만, 파이어폭스나 웹킷도 사용할 수 있습니다.

     

    이렇게 실행하는 브라우저는 기본적으로 헤드리스 모드로 실행되어 백그라운드에서 실행되는 것과 비슷한 방식으로 작동합니다.

     

    드라이버 객체와 브라우저 모두 use를 사용하여 사용이 끝나면 자동으로 종료되게 합니다.

    경기 일정을 가져오자

    이제 실행한 브라우저에서 KBO 경기 일정 페이지에 접속하여 경기 일정을 가져와봅시다. 이 코드들은 모두 actualLogic()의 위치에 들어가는 코드입니다.

    browser.newPage().use { page ->
        page.navigate("https://www.koreabaseball.com/Schedule/Schedule.aspx")
        page.locator("#ddlYear").selectOption("$season")
        page.locator("#ddlMonth").selectOption("$month".padStart(2, '0'))
        page.locator("#ddlSeries").selectOption("0,9,6")
    
        val scheduleTableRows = locator("#tblScheduleList > tbody").locator("tr")
            .all()
    
        parseGameSchedule(scheduleTableRows, season, seriesType)
    }
    1. 먼저 KBO 경기 일정 페이지(https://www.koreabaseball.com/Schedule/Schedule.aspx)에 접속합니다.
    2. 특정 시즌의 경기 일정을 가져오기 위해 선택자가 #ddlYear인 DOM에서 해당 시즌을 선택합니다.
    3. 특정 월의 경기 일정을 가져오기 위해 선택자가 #ddlMonth인 DOM에서 해당 월을 선택합니다. KBO 공식 페이지의 월 선택은 두자리 값이기 때문에 1월부터 9월은 앞에 0을 채워서 01~09로 만들어줍니다.
    4. 어떤 시리즈의 경기 스케줄을 가져올 것인지 선택하기 위해 선택자가 #ddlSeries인 DOM에서 0,9,6을 선택합니다. 이 값은 KBO 내부에서 지정된 셀렉트 박스의 값이기 때문에 시범 경기나 포스트 시즌 스케줄을 크롤링하려면 그에 맞는 값을 입력하면 됩니다.

    이렇게 어느 시점의 어느 시리즈의 경기 일정을 조회했다면, #tblScheduleListtbody에 있는 모든 자식 tr을 리스트로 가져와서 경기 데이터를 파싱합니다.

     

    이 코드에는 URL이나 선택자들이 모두 하드코딩되어 있지만 스프링부트 프로퍼티나 enum class를 사용하는게 더 좋긴 합니다.

    경기 정보를 파싱하자!

    경기 일정은 크롤링했지만 이 정보는 그냥 HTML 덩어리입니다. 따라서, DB에 저장하기에 적합한 형태로 가공해봅시다.

    경기 정보 엔티티의 구조부터 정의하자

    각 경기 정보를 DB에 저장하려면, 먼저 경기 정보라는 엔티티의 구조를 정의해야 합니다. 경기 정보 엔티티의 구조를 다음과 같이 코틀린 의사 코드로 정의했습니다.

    class GameInfo(
        val gameId: Long? = null,       // DB 식별자(PK)
        val gameKey: String,        // 실제로 경기 정보를 구분할 UK
        val seriesType: SeriesType,     // 시범경기, 정규시즌, 포스트시즌
        val date: LocalDate,
        var time: LocalTime?,   // 더블헤더 경기는 시간이 미정이므로 nullable
        val awayTeam: Team,
        val homeTeam: Team,
        
        // 경기가 시작하지 않았거나 취소되면 점수가 없으므로 null
        var awayScore: Int? = null,
        var homeScore: Int? = null,
    
        var relay: String?,
        var stadium: String,
        var gameStatus: GameStatus,     // 예정됨, 진행중, 종료, 취소
        var cancellationReason: CancellationReason?     // gameStatus가 CANCELLED일 때만 존재
    )

    gameKey는 사람이 알아보기 쉽게 {yyyyMMdd}-{원정팀}-{홈팀}의 형태로 구성한 고유 식별자입니다. 다만 이렇게만 하면 더블헤더 경기를 식별할 수 없기 때문에 {yyyyMMdd}-{원정팀}-{홈팀}-{일련번호}의 형태로 확장했습니다.

     

    시리즈의 종류, 팀, 경기 상태, 취소 사유와 같이 변경이 거의 없다고 볼 수 있는 프로퍼티들은 모두 Enum으로 정의했습니다.

     

    이 엔티티 구조로 저장된 데이터는 아래 JSON 예시처럼 저장되겠죠.

    {
      "gameId": 84,
      "gameKey": "20250329-KT-LOTTE-1",
      "seriesType": "REGULAR_SEASON",
      "date": "2025-03-29",
      "time": "17:00:00",
      "awayTeam": "KT",
      "homeTeam": "LOTTE",
      "awayScore": 1,
      "homeScore": 3,
      "relay": "KN-T,MS-T",
      "stadium": "사직",
      "gameStatus": "FINISHED",
      "cancellationReason": null
    }
    {
      "gameId": 37,
      "gameKey": "20250316-KT-LOTTE-1",
      "seriesType": "PRESEASON",
      "date": "2025-03-16",
      "time": "13:00:00",
      "awayTeam": "KT",
      "homeTeam": "LOTTE",
      "awayScore": null,
      "homeScore": null,
      "relay": "TVING",
      "stadium": "사직",
      "gameStatus": "CANCELLED",
      "cancellationReason": "RAIN"
    }

    크롤링 결과를 파싱하기 전에 고려할 점!

    DB에 저장할 엔티티 구조까지 정의했으니 이제 본격적으로 크롤링 결과를 파싱해봅시다.

     

    파싱하기 전에 몇가지 고려할 점이 있습니다.

    • 해당 연, 월에 경기 일정이 전혀 없으면 "데이터가 없습니다."라는 문구가 담긴 단일 행만 나타납니다. 이 케이스는 파싱에서 제외해야 합니다.

    • 포스트시즌은 특이하게 이동일까지 경기 일정 페이지에 등록되어 있습니다. 이동일은 경기가 예정되어 있었다가 취소된 경우가 아니기 때문에 역시 파싱에서 제외해야 합니다.

    • 하루에 여러 경기가 예정된 경우에는 경기 정보가 날짜별로 그룹핑되어 있습니다. HTML 구조상으로 보면, 해당 날짜의 첫 행만 날짜 정보를 갖고 있으며, 해당 날짜의 나머지 경기는 날짜 정보가 없습니다. 사실상 포스트시즌이나 정규시즌 잔여 경기를 제외한 모든 경우에 해당하는 만큼 주의깊게 처리해야 합니다.

    • gameKey{yyyyMMdd}-{원정팀}-{홈팀}-{일련번호} 형태로 정의했으니, 더블헤더 경기는 어떤 방식으로 일련번호를 매길건지도 고민해봐야 합니다.

    어떻게 처리할까?

    정상 경우와 경기 데이터가 없는 경우, 이동일의 경우의 HTML을 한번 보겠습니다.

    정상 경우
    데이터가 없는 경우
    이동일인 경우

    경기 정보가 있는 정상 경우에는 .play인 요소가 있지만, 경기 데이터가 없는 경우와 이동일인 경우에는 .play가 없는 것을 볼 수 있습니다. 정상 경우는 아직 진행 전인 경기와 취소 경기가 모두 포함되기 때문에 .playtd가 있는지 여부로 판단할 수 있겠네요.

    for (row in locators) {
        // 경기 정보가 없으면 스킵
        val playCell = row.locator("td.play")
        if (playCell.count() == 0) {
            continue
        }
    
        ...
    }

    여기서는 좀 더 확실하게 td 태그 중에서 클래스가 play인 요소들의 개수가 0이면 다음 행으로 넘어가도록 구현했습니다.


    한 날짜에 여러 경기가 포함된 경우의 HTML을 보겠습니다.

    여기서는 해당 날짜의 첫 행에만 .day가 존재하고, .day가 없는 행들은 해당 날짜에 속한다는 것을 알 수 있습니다.

     

    이렇다보니 반복문 내부에서 날짜를 선언해버리면, .day가 없는 행들은 어느 날짜의 경기인지 알 수가 없어집니다. 그래서 반복문 바깥에 날짜 변수를 선언해두고, .day가 있으면 날짜 변수를 갱신해서 사용하면 되겠다고 생각했습니다.

    var currentDate = LocalDate.MIN
    
    for (row in locators) {
        ...
    
        val dayCell = row.locator("td.day")
        if (dayCell.count() > 0) {
            val (month, day) = dayCell.innerText().take(5).trim().split('.').map { it.toInt() }
            currentDate = LocalDate.of(season, month, day)
        }
    
        ...
    }

    마찬가지로 좀 더 확실하게 td 태그 중에서 day 클래스를 가진 요소가 있는지를 분기 조건으로 삼았습니다. 날짜 텍스트가

    MM.dd(E)포맷이기 때문에 앞에서 5글자까지 잘라서 날짜를 파싱했습니다.


    더블헤더 경기는 같은 날짜에 원정팀과 홈팀이 모두 같은 경기가 2번 진행되기 때문에, gameKey{yyyyMMdd}-{원정팀}-{홈팀}까지 동일하고 일련번호만 다르게 부여됩니다. 수집한 데이터 중에 같은 {yyyyMMdd}-{원정팀}-{홈팀}가 나타난 횟수를 기록하고, 이를 기반으로 일련 번호를 부여할 수 있겠네요.

    val gameCountMap = mutableMapOf<String, Int>()
    
    for (row in locators) {
        ...
    
        val gameKey = "${currentDate.format(yyyyMMdd)}-$awayTeam-$homeTeam"
        val count = gameCountMap[gameKey] ?: 1
        gameCountMap[gameKey] = count + 1
    
        ...
        
        val gameInfo = GameInfo(
            gameKey = "$gameKey-$count",
            ...
        )
        
        ...
    }

    반복문 바깥에 같은 {yyyyMMdd}-{원정팀}-{홈팀}가 등장한 횟수를 세기 위한 MutableMap을 선언하고, 이를 기반으로 일련번호를 count에 저장합니다. 이를 기반으로 완전한 형태의 gameKey를 부여했습니다.

    데이터 파싱 전체 코드

    위의 고려사항을 반영한 데이터 파싱 코드입니다.

    fun parseGameSchedule(locators: List<Locator>, season: Int, seriesType: SeriesType): List<GameInfo> {
        val yyyyMMdd = DateTimeFormatter.ofPattern("yyyyMMdd")
        val gameCountMap = mutableMapOf<String, Int>()
        val gameInfoList = mutableListOf<GameInfo>()
    
        var currentDate = LocalDate.MIN  // 날짜 지정용 변수
    
        for (row in locators) {
            // 경기 정보가 없으면 스킵
            val playCell = row.locator("td.play")
            if (playCell.count() == 0) {
                continue
            }
            // 날짜 정보가 있으면 currentDate 갱신
            val dayCell = row.locator("td.day")
            if (dayCell.count() > 0) {
                val (month, day) = dayCell.innerText().take(5).trim().split('.').map { it.toInt() }
                currentDate = LocalDate.of(season, month, day)
            }
    
            val time = LocalTime.parse(row.locator("td.time").innerText().trim())
    
            val (awayTeam, homeTeam) = playCell.locator("> span").allInnerTexts().map { Team.findByTeamName(it) }
    
            val gameKey = "${currentDate.format(yyyyMMdd)}-$awayTeam-$homeTeam"
            val count = gameCountMap[gameKey] ?: 1
            gameCountMap[gameKey] = count + 1
    
            val scores = playCell.locator("em > span").allInnerTexts().mapNotNull { it.toIntOrNull() }
            val awayScore = scores.getOrNull(0)
            val homeScore = scores.getOrNull(1)
    
            val remainCells = row.locator("td:not([class])").all()
    
            val cancellationReason = CancellationReason.fromString(remainCells.last().innerText().trim())
            val gameStatus = when {
                cancellationReason != null -> GameStatus.CANCELLED
                scores.isNotEmpty() -> GameStatus.FINISHED
                else -> GameStatus.SCHEDULED
            }
    
            // 클래스가 없는 나머지 컬럼들
            val remainCells = row.locator("td:not([class])").all()
    
            // 비고란이 '-'인 경우는 정상 종료된 경기이므로 취소사유가 null이 됨
            val cancellationReason = CancellationReason.fromString(remainCells.last().innerText().trim())
    
            // 여러가지 조건에 따라 경기 상태 지정
            val gameStatus = when {
                scoreSpans.size > 1 -> GameStatus.FINISHED
                cancellationReason != null -> GameStatus.CANCELLED
                else -> GameStatus.SCHEDULED
            }
    
            val gameInfo = GameInfo(
                gameKey = "$gameKey-$count",
                seriesType = seriesType,
                date = currentDate,
                time = time,
                awayTeam = awayTeam,
                homeTeam = homeTeam,
                awayScore = awayScore,
                homeScore = homeScore,
                relay = remainCells[1].innerHTML().replace("<br>", ",").trim(),
                stadium = remainCells[3].innerText().trim(),
                gameStatus = gameStatus,
                cancellationReason = cancellationReason
            )
    
            gameInfoList.add(gameInfo)
        }
    
        return gameInfoList
    }

    경기 진행 날짜, 시작 시간, 팀 및 점수에 대한 컬럼은 클래스가 있는 만큼 클래스를 사용해서 값을 가져왔습니다.

     

    경기를 진행하는 팀은 팀 명을 추출해서 Enum으로 변환했습니다.

     

    경기 점수는 .play 태그 아래의 em > span 태그에 각각 기록되는데, 아직 진행되지 않은 경기나 취소된 경기에서는 기록되지 않습니다. 따라서 mapNotNulltoIntOrNull을 결합하여 점수 리스트를 추출하고, getOrNull을 사용하여 경기 점수가 없으면 점수 프로퍼티의 값에 null이 저장되도록 했습니다.

     

    TV 중계사, 구장, 취소 사유는 클래스가 없는 단순 컬럼인 만큼, 클래스가 없는 td 태그만 선택하여 추출했습니다. 중계사의 경우엔 그다지 중요한 정보가 아니라고 생각하여 단순 문자열로 저장했습니다.

     

    경기 상태는 취소 사유, 점수의 존재 여부 등을 기반으로 지정했습니다.

     

    이 모든 정보를 기반으로 GameInfo 객체를 생성하여 리스트에 담아 반환합니다.

    결과

    2025년의 전체 일정을 DB에 저장해봤습니다. 전체적으로 만족스럽게 저장된 것을 확인할 수 있었습니다.

    추가로 고려해 볼만한 점

    • KBO의 경기 일정 페이지에서는 진행중인 경기의 점수 정보가 0 vs 0에서 갱신되지 않는 것을 발견했습니다. 따라서 진행중인 경기의 정보를 파악하기 위한 다른 방법을 찾아야할 듯 합니다.
    • 서스펜디드 게임의 경우, 중단된 상태를 어떻게 판별할 것인지도 고려해 볼만합니다. 다만, 서스펜디드 게임의 사례가 굉장히 드물기 때문에 이미 완료된 사례만 존재하고 직접 관찰하기가 힘들다는 점이 걸림돌입니다.

    깃허브 링크

     

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

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

    github.com

     

    댓글