R 지도 시각화 4

중앙선관위 투표 결과 데이터 크롤링하기

Dr.Kevin 8/2/2018

최근 3개의 포스팅을 통해 선거 결과 데이터와 행정경계구역 데이터를 전처리하고, ggplot2 패키지를 활용하여 단계구분도를 그려봤으며, leaflet 패키지를 활용하여 동적인 지도 시각화까지 다루어 봤습니다.

이번 포스팅은 시리즈를 완결짓는 마지막 편입니다. 이번 시리즈는 선거 결과 데이터를 엑셀 파일로 다운로드한 것으로 시작했습니다만, 아쉽게도 이렇게 전체 데이터를 일괄 다운로드할 수 있는 것은 2017년 선거까지인 것 같습니다. 올해 치뤄진 지방선거 결과를 얻기 위해 중앙선거관리위원회의 개표결과 페이지로 가보니 아래 그림처럼 선거 결과 데이터를 지방자치단체 단위로 조회하고 엑셀 파일로 다운로드할 수 있도록 변경되어 있었습니다.

그래서 부득이하게 크롤러를 만들어야 할 필요가 있게 되었습니다. 처음 시작을 선거 결과 데이터 전처리로 했는데, 선거 결과 데이터를 수집하는 방법으로 돌아오게 되었으니 이번 시리즈가 재미있게 마무리되는 것 같습니다.

이번 포스팅에서는 크롤링하는 방법을 자세하게 설명하지는 않겠습니다. 다만 코드를 따라하면서 흐름을 이해하시면 좋을 것 같습니다. 노파심에서 당부드리는 말씀은 제 코드는 제 스타일을 반영하는 것이므로 그냥 이렇게 하는 사람도 있구나 하고 이해해주시면 되겠습니다.

중앙선거관리위원회 개표결과 페이지로 돌아와서 화면 상단 메뉴에서 시도지사선거를 클릭하고 아래 시도란에 서울특별시, 구시군란에 종로구를 선택한 다음 검색을 클릭하면 아래 그림처럼 해당 지역의 선거 결과를 확인할 수 있습니다. 그리고 오른쪽에 PDF엑셀 버튼이 생기는 걸로 보아 이 지역의 결과만 따로 저장할 수 있는 것 같습니다.

확실히 데이터 수집에 어려움이 생긴 것이 분명합니다. 하지만 걱정 없습니다. 우리는 크롤러를 만들어서 손쉽게 데이터를 수집하면 되니까요.

지금까지 중앙선거관리위원회 웹페이지에서 이것 저것 클릭하면서 웹브라우저의 주소창을 유심히 살펴봐야 합니다. 처음 이미지의 주소창에서 보이는 URL은 http://info.nec.go.kr/main/showDocument.xhtml?electionId=0020180613&topMenuId=VC&secondMenuId=VCCP08인데요. 두 번째 이미지의 주소창에는 http://info.nec.go.kr/electioninfo/electionInfo_report.xhtml처럼 URL이 바뀌었습니다. URL에 상세 정보들이 포함되어 있지 않으므로 POST() 방식으로 HTTP Request를 해야될 것으로 보입니다.

저는 크롬 브라우저를 사용하는데요. 개발자도구에서 Network 창으로 가보면 맨 처음 행에 electionInfo_report.xhtml이 보일 것입니다. Method는 POST로 되어 있구요. 혹시 POST 방식으로 HTTP Request하는 방식에 대해서 잘 모르시는 분은 제가 정리해놓은 저의 블로그를 참조하시기 바랍니다.

특정 선거구의 개표 결과를 수집하는 방법

시도지사선거의 서울특별시 종로구 개표 결과를 수집하는 코드를 알려드리겠습니다.

# 필요 패키지를 불러옵니다. 
library(httr)
library(rvest)
library(dplyr)
library(stringr)
library(jsonlite)
# POST() 함수를 사용하여 시도지사선거의 서울특별시 종로구 개표 결과를 수집합니다.
resp <- 
  POST(
    url = 'http://info.nec.go.kr/electioninfo/electionInfo_report.xhtml',
    encode = 'form', 
    body = list(
      electionId = '0020180613',
      electionCode = '3',
      requestURI = '/WEB-INF/jsp/electioninfo/0020180613/vc/vccp08.jsp',
      topMenuId = 'VC',
      secondMenuId = 'VCCP08',
      menuId = 'VCCP08',
      statementId = 'VCCP08_#00',
      statementId = '0020180613.VCCP08_#00',
      hubo1 = '박원순',
      hubo2 = '김문수',
      hubo3 = '안철수',
      hubo4 = '김종민',
      hubo5 = '김진숙',
      hubo6 = '인지연',
      hubo7 = '신지예',
      hubo8 = '우인철',
      hubo9 = '최태현',
      hubo10 = '',
      hubo11 = '',
      hubo12 = '',
      hubo13 = '',
      hubo14 = '',
      hubo15 = '',
      hubo16 = '',
      hubo17 = '',
      hubo18 = '',
      hubo19 = '',
      hubo20 = '',
      jd1 = '더불어민주당',
      jd2 = '자유한국당',
      jd3 = '바른미래당',
      jd4 = '정의당',
      jd5 = '민중당',
      jd6 = '대한애국당',
      jd7 = '녹색당',
      jd8 = '우리미래',
      jd9 = '친박연대',
      jd10 = '',
      jd11 = '',
      jd12 = '',
      jd13 = '',
      jd14 = '',
      jd15 = '',
      jd16 = '',
      jd17 = '',
      jd18 = '',
      jd19 = '',
      jd20 = '',
      wiwName = '중구', 
      cityCode = '1100',
      townCode = '1102'
    )
  )

코드를 보면 body 인자에 list()의 요소로 입력해야 할 항목이 매우 많습니다만 electionId부터 statementId까지는 시도지사선거에 공통되는 항목으로 보이고, hubo1부터 jd20까지는 서울특별시장에 대해서만 공통된 항목으로 보이므로, 그 다음에 오는 cityCode, wiwNametownCode만 바꿔주면 서울특별시의 25개 자치구 단위로 개표 결과를 모두 수집할 수 있습니다.

물론 다른 시도지사 선거 개표 결과를 모으려면, 예를 들어 경기도지사 개표 결과를 모으려면 hubo1부터 jd20까지는 공통항목이니 각 후보자 이름과 정당 이름으로 바꿔주고 나머지 3개는 행정기관명과 행정기관코드를 넣어주어야 될 것입니다.

좀 더 진행을 하고 나서 설명을 마저 하도록 하겠습니다. 응답으로 받은 resp로부터 상태코드를 확인해보겠습니다. 200이 반환되면 서버가 정상으로 판단하고 응답했다는 것입니다.

# 응답 상태코드를 확인합니다. 
status_code(x = resp)
## [1] 200

응답 상태코드가 200으로 출력되었습니다. 그리고 나서 resp을 텍스트 형태로 출력하여 육안으로 확인해보겠습니다.

# 응답 결과를 출력하여 육안으로 확인합니다. 
content(x = resp, as = 'text')

전부 출력하려고 했으나 매우 길기 때문에 코드만 알려드리고 결과는 따로 보여드리지 않겠습니다. 궁금하신 분은 직접 해보시기 바랍니다.

결론적으로 말씀을 드리면, 개표 결과 데이터가 HTML의 table로 작성되어 있으므로 함수 3개만으로 편리하게 개표 결과 부분만 추출하여 데이터 프레임으로 저장할 수 있습니다.

# 서울특별시장 선거의 서울특별시 종로구 개표 결과를 추출합니다.
resultTbl <- 
  resp %>% 
  read_html() %>% 
  html_node(css = 'table') %>% 
  html_table(fill = TRUE)

위 코드에서 resp 객체를 read_html() 함수를 사용하여 읽고, html_node() 함수로 <table> 노드를 찾은 다음 html_table() 함수로 표 안의 데이터만 추출하여 resultTbl이라는 데이터 프레임으로 저장하였습니다.

지금까지의 작업으로 보아 지방선거 개표 결과를 수집하는 것은 다른 선거(대선이나 총선)에 비해 품이 더 많이 들 것 같습니다. 선거 종류도 여러 가지이고, 각 선거별로 후보자명과 정당명을 정리해서 입력해주어야 하니까요. 그래도 이렇게 하는 것이 완전 수작업으로 하는 것보다는 나을 것입니다.

서울특별시장 선거의 25개 자치구별로 모두 수집하려면 맨 처음 코드를 사용자 정의 함수로 만들고 마지막 3가지 항목(cityCode, wiwNametownCode)만 바꿔서 반복문을 돌리면 되겠죠? 그렇게 하려면 행정기관명과 코드를 가지고 있어야 합니다. 따라서 이를 수집하는 방법을 설명드리겠습니다.

행정기관명과 코드를 수집하는 방법

앞에서 요청 결과로 받은 resp 객체에는 개표 결과 HTML이 담겨 있습니다. 그리고 크롤링을 많이 해보면 경험적으로 알게 되는 것이지만 시도구시군처럼 선택 입력 창에는 관련 HTML Element들이 주르르 달려 있는 경우가 많습니다.

먼저 시도 항목을 찾아서 하위 노드들을 출력하면 다음과 같은 결과를 얻을 수 있습니다.

# '시도' 항목 입력에 관련된 HTML Element를 찾아 출력합니다. 
resp %>% 
  read_html() %>% 
  html_nodes(css = 'select#cityCode option')
## {xml_nodeset (18)}
##  [1] <option class="o_01" value="-1" selected>▽ 선 택</option>\n
##  [2] <option value="1100" selected>서울특별시</option>\n
##  [3] <option value="2600">부산광역시</option>\n
##  [4] <option value="2700">대구광역시</option>\n
##  [5] <option value="2800">인천광역시</option>\n
##  [6] <option value="2900">광주광역시</option>\n
##  [7] <option value="3000">대전광역시</option>\n
##  [8] <option value="3100">울산광역시</option>\n
##  [9] <option value="5100">세종특별자치시</option>\n
## [10] <option value="4100">경기도</option>\n
## [11] <option value="4200">강원도</option>\n
## [12] <option value="4300">충청북도</option>\n
## [13] <option value="4400">충청남도</option>\n
## [14] <option value="4500">전라북도</option>\n
## [15] <option value="4600">전라남도</option>\n
## [16] <option value="4700">경상북도</option>\n
## [17] <option value="4800">경상남도</option>\n
## [18] <option value="4900">제주특별자치도</option>

총 18개 항목이 출력되는데 맨 처음 행은 ‘선택’이니까 무시하고 나머지 17개 행에서 데이터를 추출하면 됩니다. 우리나라 광역시도가 총 17개이므로 맞겠죠?

데이터를 추출하는 방법은 아래와 같습니다. 먼저 value에 할당된 4자리 숫자만 추출해서 cityCd로 저장하고, >< 사이에 있는 텍스트만 추출해서 cityNm으로 저장하겠습니다.

# 광역시도 코드만 추출하여 저장합니다. 
cityCd <- 
  resp %>% 
  read_html() %>% 
  html_nodes(css = 'select#cityCode option') %>% 
  html_attr(name = 'value')

# 광역시도 이름만 추출하여 저장합니다. 
cityNm <- 
  resp %>% 
  read_html() %>% 
  html_nodes(css = 'select#cityCode option') %>% 
  html_text()

# 두 벡터를 cbind()하고 city라는 객체에 저장합니다. 
city <- cbind(cityCd, cityNm)

# 데이터 프레임으로 변환한 후 첫 행을 삭제합니다. 
city <- city %>% as.data.frame() %>% `[`(-1, )

# city 객체를 출력합니다. 
print(city)
##    cityCd         cityNm
## 2    1100     서울특별시
## 3    2600     부산광역시
## 4    2700     대구광역시
## 5    2800     인천광역시
## 6    2900     광주광역시
## 7    3000     대전광역시
## 8    3100     울산광역시
## 9    5100 세종특별자치시
## 10   4100         경기도
## 11   4200         강원도
## 12   4300       충청북도
## 13   4400       충청남도
## 14   4500       전라북도
## 15   4600       전라남도
## 16   4700       경상북도
## 17   4800       경상남도
## 18   4900 제주특별자치도

제대로 잘 정리되었죠? 그럼 지방자치단체명과 코드도 수집해보겠습니다. 위의 방법에서 조금만 바꿔주면 됩니다.

# 지방자치단체 코드만 추출하여 저장합니다. 
townCd <- 
  resp %>% 
  read_html() %>% 
  html_nodes(css = 'select#townCode option') %>% 
  html_attr(name = 'value')

# 지방자치단체 이름만 추출하여 저장합니다. 
townNm <- 
  resp %>% 
  read_html() %>% 
  html_nodes(css = 'select#townCode option') %>% 
  html_text()

# 두 벡터를 cbind()하고 town이라는 객체에 저장합니다. 
town <- cbind(townCd, townNm)

# 데이터 프레임으로 변환한 후 첫 행을 삭제합니다. 
town <- town %>% as.data.frame() %>% `[`(-1, )

# town 객체를 출력합니다. 
print(town)
##    townCd   townNm
## 2    1101   종로구
## 3    1102     중구
## 4    1103   용산구
## 5    1104   성동구
## 6    1105   광진구
## 7    1106 동대문구
## 8    1107   중랑구
## 9    1108   성북구
## 10   1109   강북구
## 11   1110   도봉구
## 12   1111   노원구
## 13   1112   은평구
## 14   1113 서대문구
## 15   1114   마포구
## 16   1115   양천구
## 17   1116   강서구
## 18   1117   구로구
## 19   1118   금천구
## 20   1119 영등포구
## 21   1120   동작구
## 22   1121   관악구
## 23   1122   서초구
## 24   1123   강남구
## 25   1124   송파구
## 26   1125   강동구

이렇게 하고 보니 citytown 간 연결고리가 없어서 두 객체를 병합할 수가 없습니다. 이 문제를 해결하는 방법은 각 객체의 코드값에서 앞 2자리를 추출하여 새로운 컬럼을 만든 다음 그 컬럼을 기준으로 병합하면 됩니다. 결국 17개 광역시도별로 한 번씩 resp를 받은 다음, 지방자치단체 코드만 추출하여 rbind() 하게 되면 전국 250개 지방자치단체의 코드를 수집할 수 있게 되는 셈이죠.

사실 이렇게 하는 것은 꽤나 귀찮은 방법입니다. 따라서 굳이 중앙선거관리위원회에서 행정표준코드를 수집하려고 할 게 아니라 다른 사이트에서도 더욱 쉽게 수집할 수 있을 것이므로 다양한 방법을 찾아보시기 바랍니다.

저는 일단 서울특별시장 선거 결과만 수집하는 코드를 소개하고 마무리 하도록 하겠습니다. 방금 생성한 town을 활용하면 25개 자치구별로 개표 결과를 다 모을 수 있습니다.

먼저 처음 소개해드린 코드를 사용자 정의 함수로 생성하겠습니다.

# 지방자치단체별 개표 결과를 수집하는 사용자 정의 함수를 생성합니다. 
# 이번 코드에서는 wiwName과 townCode만 바꿔서 입력할 수 있도록 설정하겠습니다. 
getVoteResult <- function(cityCd = '1100', townCd, townNm) {
  
  # HTTP Request 
  resp <- 
    POST(
      url = 'http://info.nec.go.kr/electioninfo/electionInfo_report.xhtml',
      encode = 'form', 
      body = list(
        electionId = '0020180613',
        electionCode = '3',
        requestURI = '/WEB-INF/jsp/electioninfo/0020180613/vc/vccp08.jsp',
        topMenuId = 'VC',
        secondMenuId = 'VCCP08',
        menuId = 'VCCP08',
        statementId = 'VCCP08_#00',
        statementId = '0020180613.VCCP08_#00',
        hubo1 = '박원순',
        hubo2 = '김문수',
        hubo3 = '안철수',
        hubo4 = '김종민',
        hubo5 = '김진숙',
        hubo6 = '인지연',
        hubo7 = '신지예',
        hubo8 = '우인철',
        hubo9 = '최태현',
        hubo10 = '',
        hubo11 = '',
        hubo12 = '',
        hubo13 = '',
        hubo14 = '',
        hubo15 = '',
        hubo16 = '',
        hubo17 = '',
        hubo18 = '',
        hubo19 = '',
        hubo20 = '',
        jd1 = '더불어민주당',
        jd2 = '자유한국당',
        jd3 = '바른미래당',
        jd4 = '정의당',
        jd5 = '민중당',
        jd6 = '대한애국당',
        jd7 = '녹색당',
        jd8 = '우리미래',
        jd9 = '친박연대',
        jd10 = '',
        jd11 = '',
        jd12 = '',
        jd13 = '',
        jd14 = '',
        jd15 = '',
        jd16 = '',
        jd17 = '',
        jd18 = '',
        jd19 = '',
        jd20 = '',
        cityCode = cityCd,
        townCode = townCd,
        wiwName = townNm
      )
    )
  
  # 개표 결과 테이블만 추출하여 저장합니다. 
  resultTbl <- 
    resp %>% 
    read_html() %>% 
    html_node(css = 'table') %>% 
    html_table(fill = TRUE)
    
  # 개표 결과 테이블은 읍면동명으로 시작합니다. 
  # 지방자치단체별 구분을 위하 시군구명을 추가하겠습니다. 
  # 시군구명에는 townNm을 할당합니다. 
  resultTbl <- cbind(시군구명 = townNm, resultTbl)
    
  # 첫 행에 있는 후보자명만 추출합니다. (이상한 부분 제거!)
  # 후보자명으로 컬럼명을 대체하고 첫 행은 삭제합니다. 
  candidates <- resultTbl[1, c(6, 9:17)]
  colnames(x = resultTbl)[6:15] <- candidates
  resultTbl <- resultTbl[-1, ]
  
  # 숫자 중 콤마(,)와 마이너스(-) 부호를 제거하고 숫자형 벡터로 변환합니다. 
  resultTbl[, 4:17] <- 
    resultTbl[, 4:17] %>% 
    sapply(FUN = function(x) 
      x %>% str_remove_all(pattern = '[,-]') %>% as.numeric())
  
  # 개표 결과를 반환합니다. 
  return(resultTbl)
}

사용자 정의 함수를 만들었으니 이제 반복문을 만들어 실행하는 것으로 마무리하겠습니다. 웹페이지에 부하를 조금이나마 줄이기 위해 1초 단위로 브레이크를 설정하겠습니다.

# 최종 결과를 저장할 빈 객체를 생성합니다. 
result <- data.frame()

# 전체 행수를 저장합니다. 
n <- nrow(x = town)

# 반복문으로 서울특별시 전체 개표 결과를 하나로 수집합니다. 
for (i in 1:n) {
  
  # 소요시간 측정을 위해 시작 시각을 저장합니다. 
  startTime <- Sys.time()
  
  # 사용자 정의 함수에 포함될 값을 정합니다. 
  townCode <- town[i, 1]
  townName <- town[i, 2]
  
  # 진행과정을 출력합니다. 
  cat('[', i, '/', n, '] 현재', townName, '작업 중! ')
  
  # 사용자 정의 함수를 실행하여 df에 저장합니다. 
  df <- getVoteResult(townCd = townCode, townNm = townName)
  
  # df를 최종 결과 객체에 rbind() 합니다. 
  result <- rbind(result, df)
  
  # 소요시간 측정을 위해 종료 시작을 저장합니다. 
  endTime <- Sys.time()
  
  # 소요시간을 출력합니다. 
  (endTime - startTime) %>% print()
  
  # 1초간 쉽니다. 
  Sys.sleep(time = 1)
}
## [ 1 / 25 ] 현재 종로구 작업 중! Time difference of 0.2566891 secs
## [ 2 / 25 ] 현재 중구 작업 중! Time difference of 0.2337711 secs
## [ 3 / 25 ] 현재 용산구 작업 중! Time difference of 0.3334179 secs
## [ 4 / 25 ] 현재 성동구 작업 중! Time difference of 0.364773 secs
## [ 5 / 25 ] 현재 광진구 작업 중! Time difference of 0.3438001 secs
## [ 6 / 25 ] 현재 동대문구 작업 중! Time difference of 0.304065 secs
## [ 7 / 25 ] 현재 중랑구 작업 중! Time difference of 0.2974181 secs
## [ 8 / 25 ] 현재 성북구 작업 중! Time difference of 0.3235881 secs
## [ 9 / 25 ] 현재 강북구 작업 중! Time difference of 0.3382139 secs
## [ 10 / 25 ] 현재 도봉구 작업 중! Time difference of 2.659602 secs
## [ 11 / 25 ] 현재 노원구 작업 중! Time difference of 0.476274 secs
## [ 12 / 25 ] 현재 은평구 작업 중! Time difference of 0.3766479 secs
## [ 13 / 25 ] 현재 서대문구 작업 중! Time difference of 0.2485478 secs
## [ 14 / 25 ] 현재 마포구 작업 중! Time difference of 0.2432232 secs
## [ 15 / 25 ] 현재 양천구 작업 중! Time difference of 0.3864088 secs
## [ 16 / 25 ] 현재 강서구 작업 중! Time difference of 0.2490759 secs
## [ 17 / 25 ] 현재 구로구 작업 중! Time difference of 0.314379 secs
## [ 18 / 25 ] 현재 금천구 작업 중! Time difference of 0.232585 secs
## [ 19 / 25 ] 현재 영등포구 작업 중! Time difference of 0.25858 secs
## [ 20 / 25 ] 현재 동작구 작업 중! Time difference of 2.689714 secs
## [ 21 / 25 ] 현재 관악구 작업 중! Time difference of 0.7264609 secs
## [ 22 / 25 ] 현재 서초구 작업 중! Time difference of 0.2911541 secs
## [ 23 / 25 ] 현재 강남구 작업 중! Time difference of 0.424108 secs
## [ 24 / 25 ] 현재 송파구 작업 중! Time difference of 0.4162149 secs
## [ 25 / 25 ] 현재 강동구 작업 중! Time difference of 0.2497768 secs

위 코드는 총 25번 반복문을 실행하는 동안 각 실행마다 진행과정 및 소요시간을 출력하게 함으로써 얼마나 진행되고 있는지 알기 쉽게 한 것입니다. 이제 정말 마지막으로 최종 결과 데이터를 구조를 파악하고 끝내겠습니다.

# 최종 결과 객체의 구조를 파악합니다. 
str(object = result)
## 'data.frame':    1369 obs. of  17 variables:
##  $ 시군구명          : chr  "종로구" "종로구" "종로구" "종로구" ...
##  $ 읍면동명          : chr  "합계" "거소투표" "관외사전투표" "청운효자동" ...
##  $ 구분              : chr  "" "" "" "계" ...
##  $ 선거인수          : num  134963 145 8516 9719 1618 ...
##  $ 투표수            : num  81195 138 8506 6057 1618 ...
##  $ 더불어민주당박원순: num  41148 62 4950 3025 900 ...
##  $ 자유한국당김문수  : num  18777 28 1419 1420 315 ...
##  $ 바른미래당안철수  : num  15946 36 1533 1167 293 ...
##  $ 정의당김종민      : num  1469 2 191 116 40 ...
##  $ 민중당김진숙      : num  358 2 38 21 7 14 19 6 13 1 ...
##  $ 대한애국당인지연  : num  223 0 17 25 3 22 10 2 8 3 ...
##  $ 녹색당신지예      : num  1896 2 218 188 44 ...
##  $ 우리미래우인철    : num  215 0 38 16 1 15 8 0 8 2 ...
##  $ 친박연대최태현    : num  59 1 8 2 1 1 3 2 1 2 ...
##  $ 계                : num  80091 133 8412 5980 1604 ...
##  $ 무효투표수        : num  1104 5 94 77 14 ...
##  $ 기권수            : num  53768 7 10 3662 0 ...

result 객체는 총 1369행, 17열로 된 데이터 프레임입니다. 이로써 중앙선거관리위원회의 2018년도 서울특별시장 선거 개표 결과 데이터를 모두 수집하였습니다.

사실 이 코드를 좀 더 정교하게 만들려면 사용자 정의 함수 부분을 대대적으로 뜯어고쳐야 하겠지만 그건 각자의 몫으로 남겨놓겠습니다. 저는 이 정도만으로도 충분하다고 판단하고 있습니다. 필요한 부분은 제공해드린 코드를 응용하여 해결하실 수 있을 것입니다. 하지만 만약 도움이 필요하시면 댓글로 질문을 남겨주세요. 감사합니다!

Written on August 2, 2018