13 . 데이터 저널리즘
오마이뉴스 제20대 대통령 선거, 사전투표 역사를 새로 쓰다 - 마의 30%대 넘긴 사전투표율 데이터 저널리즘 기사에 포함된 시각화 객체를 작성해보자. 제20대 대통령선거에서 사전투표율이 30%를 넘긴 사실과 이를 한눈에 사전투표율 변화를 제19대 대통령선거와 비교하여 2일에 걸쳐 실시된 사전투표를 그래프 우측에 사전투표율 차이를 꼼꼼히 작성한 것이 이채로운 기사다.
이 기사를 작성하기 위해 공공데이터를 크롤링하여 정제하는 과정을 거치고 이를 다양한 시각화 방법론 중에서 독자에게 객관적인 사실을 읽기 편하고 정보를 제대로 제공할 수 있는 형태로 전달하도록 ggplot
을 사용해서 제작했다.
13.1 크롤링
먼저 중앙선거관리위원회 선거통계시스템 웹사이트에서 제19대 대통령선거 사전투표율 데이터를 시도별로 크롤링한다. 오마이뉴스 기자가 제19대와 제20대를 비교하여 5년간의 사전투표율 변화를 통해 유권자의 표심과 사회적 변화를 담아내고자 두 시점 2017년과 2022년을 각각 가져온다.
13.1.1 제19대 대통령 선거
중앙선관위 선거통계시스템에서 시도별로 사전투표율 데이터를 조회하도록 했기 때문에 각 시도별로 반복이 필요하기 때문에 함수로 작성해서 특정 시도를 기준으로 테스트 작업을 수행한다.
# 0. 패키지 ----------------
library(tidyverse)
library(rvest)
library(httr)
library(lubridate)
## 크롤링 함수
get_early_voting_data <- function(sido_code = "1100", date_code = "1") {
cat("\n------------------------------\n", sido_code, ":", date_code, "\n")
nec_url <- glue::glue("http://info.nec.go.kr/electioninfo/electionInfo_report.xhtml?",
"electionId=0000000000",
"&requestURI=%2FWEB-INF%2Fjsp%2Felectioninfo%2F0000000000%2Fvc%2Fvcap01.jsp",
"&topMenuId=VC",
"&secondMenuId=VCAP01",
"&menuId=VCAP01",
"&statementId=VCAP01_%234",
"&oldElectionType=1",
"&electionType=1",
"&electionName=20170509",
"&cityCode={sido_code}",
"&dateCode={date_code}")
Sys.setlocale("LC_ALL", "C")
nec_html <- read_html(nec_url)
nec_raw <- nec_html %>%
html_element(css = '#table01') %>%
html_table(fill = TRUE)
Sys.setlocale("LC_ALL", "Korean")
nec_colnames <- nec_raw %>%
janitor::clean_names(ascii = FALSE) %>%
slice(1) %>%
unlist() %>%
as.character() %>%
dput()
nec_tbl <- nec_raw %>%
janitor::clean_names(ascii = FALSE) %>%
slice(2:n()) %>%
set_names(nec_colnames) %>%
pivot_longer(`7시`:`18시`, names_to = "시간", values_to = "값") %>%
mutate(구분 = ifelse(str_detect(값, "%"), "투표율", "투표자수")) %>%
pivot_wider(names_from = 구분, values_from = 값)
nec_tbl
}
# get_early_voting_data("4900", "2") # 함수테스트
13.1.2 데이터 긁어오기
앞서 작성한 함수를 시도 및 구시군별 크롤링하여 데이터프레임으로 저장한다.
특이한 점은 map2
함수형 프로그래밍 기법을 적용하여 코드를 for
문 없이
간결하게 작성했다.
presid_sigungu_code_2017 <- read_csv("data/presid_sigungu_code_2017.csv")
early_voting_code <- presid_sigungu_code_2017 %>%
group_by(시도코드, 시도명) %>%
summarise(n = n()) %>%
ungroup() %>%
select(-n)
early_voting_2017_raw <- early_voting_code %>%
mutate(사전투표일 = rep(list(c(1, 2)), n())) %>%
unnest(사전투표일) %>%
mutate(data = map2(시도코드, 사전투표일, get_early_voting_data))
13.1.3 데이터 정제
제19대 대통령 선거 구시구별 사전투표일 누적투표수를 시각화 가능한 형태로 저장시킨다.
early_voting_2017_tbl <- early_voting_2017_raw %>%
unnest(data, names_repair = "unique") %>%
janitor::clean_names(ascii = FALSE) %>%
set_names(c("시도코드", "시도명_2", "사전투표일", "구시군명", "선거인수",
"시간", "투표자수", "투표율", "시도명_9")) %>% ## 변수명 R4.2 버그
rename(시도명 = 시도명_2) %>%
mutate(구시군명 = ifelse(is.na(시도명_9), 구시군명, 시도명_9)) %>%
select(-시도코드, -시도명_9)
early_voting_2017_order <- early_voting_2017_tbl %>%
select(시도명, 구시군명, 사전투표일, 시간, 선거인수, 투표자수) %>%
filter(구시군명 != "합계") %>%
pivot_wider(names_from = 사전투표일, values_from = 투표자수) %>%
mutate(across(선거인수:`2`, parse_number )) %>%
group_by(시도명, 구시군명) %>%
mutate(누적투표수 = max(`1`) + `2`) %>%
ungroup() %>%
select(-`2`) %>%
pivot_longer(`1`:누적투표수, names_to = "사전투표일", values_to = "누적투표수") %>%
mutate(사전투표일 = ifelse(사전투표일 == "누적투표수", "2일", "1일")) %>%
mutate(시간순서 = glue::glue("{사전투표일}-{시간}")) %>%
arrange(시도명, 구시군명, 시간순서)
early_voting_2017 <- early_voting_2017_order %>%
mutate(사전투표율 = 누적투표수 / 선거인수) %>%
mutate(선거구분 = "제19대") %>%
relocate(선거구분, .before = 시도명)
early_voting_2017 %>%
write_rds("data/early_voting_2017.rds")
13.2 제20대 대통령 선거
제20대 대통령선거 사전투표율 데이터도 제19대 대통령선거와 동일한 방식으로 크롤링한다.
13.2.1 크롤링 함수
제19대와 달리 제20대는 데이터 제공방식에 차이가 있어 크롤링함수를 이에 맞춰 작성하여 시도별로 크롤링할 수 있는 함수를 제작한다.
get_early_voting_data_latest <- function(sido_code = "1100", date_code = "1", time_code = "07") {
cat("\n------------------------------\n", sido_code, ":", date_code, ":", time_code, "\n")
nec_url <- glue::glue("http://info.nec.go.kr/electioninfo/electionInfo_report.xhtml?",
"electionId=0020220309",
"&requestURI=%2FWEB-INF%2Fjsp%2Felectioninfo%2F0020220309%2Fvc%2Fvcap01.jsp",
"&topMenuId=VC",
"&secondMenuId=VCAP01",
"&menuId=VCAP01",
"&statementId=VCAP01_%232",
"&cityCode={sido_code}",
"&dateCode={date_code}",
"&timeCode={time_code}")
Sys.setlocale("LC_ALL", "C")
nec_html <- read_html(nec_url)
nec_raw <- nec_html %>%
html_element(css = '#table01') %>%
html_table(fill = TRUE)
Sys.setlocale("LC_ALL", "Korean")
nec_tbl <- nec_raw %>%
set_names(c("구시군명", "선거인수", "사전투표자수", "사전투표율"))
nec_tbl
}
# get_early_voting_data_latest("4900", "2", "17")
13.2.2 데이터 긁어오기
크로링함수가 완성되었다면 시도코드와 사전투표일, 사전투표시간을 달리하여 함수형프로그래밍 기법을 적용하여 간결한 크롤링함수를 작성한다.
early_voting_2022_raw <- early_voting_code %>%
mutate(사전투표일 = rep(list(c(1, 2)), n())) %>%
unnest(사전투표일) %>%
mutate(사전투표시간 = rep(list( c(7:18)), 34 )) %>%
unnest(사전투표시간) %>%
mutate(사전투표시간 = as.character(사전투표시간) %>% str_pad(., 2, "left", pad = "0")) %>%
mutate(data = pmap(list(시도코드, 사전투표일, 사전투표시간), get_early_voting_data_latest))
# early_voting_2022_raw
13.2.3 데이터 정제
제20대 대통령 선거 구시구별 사전투표일 누적투표수를 시각화 가능한 형태로 저장시킨다.
early_voting_2022_tbl <- early_voting_2022_raw %>%
mutate(data = map(data, ~.x %>% mutate_all(., as.character ) )) %>%
unnest(data, names_repair = "unique") %>%
mutate(across(선거인수:사전투표율, parse_number))
early_voting_2022_order <- early_voting_2022_tbl %>%
filter(구시군명 != "합계") %>%
select(시도명, 구시군명, 사전투표일, 시간=사전투표시간, 선거인수, 투표자수=사전투표자수) %>%
pivot_wider(names_from=사전투표일, values_from = 투표자수) %>%
group_by(시도명, 구시군명) %>%
mutate(누적투표수 = max(`1`) + `2`) %>%
ungroup() %>%
select(-`2`) %>%
pivot_longer(`1`:누적투표수, names_to = "사전투표일", values_to = "누적투표수") %>%
mutate(사전투표일 = ifelse(사전투표일 == "누적투표수", "2일", "1일")) %>%
mutate(시간순서 = glue::glue("{사전투표일}-{시간}")) %>%
arrange(시도명, 구시군명, 시간순서)
early_voting_2022 <- early_voting_2022_order %>%
mutate(사전투표율 = 누적투표수 / 선거인수) %>%
mutate(선거구분 = "제20대") %>%
relocate(선거구분, .before = 시도명) %>%
filter(구시군명 != "합계") %>%
mutate(시간 = as.numeric(시간)) %>%
mutate(시간 = glue::glue('{str_pad(시간, 2, "left", "0")}시'))
early_voting_2022 %>%
write_rds("data/early_voting_2022.rds")
13.4 데이터프레임
시간대별로 제19대와 제20대 대통령선거를 비교하여 시각화하기 위해 ggplot
에 입력값인
데이터프레임에 필요한 변수작업을 사전 준비하여 시각화에 집중할 수 있도록 작업내용을
구분해둔다.
early_voting_timeline <- early_voting %>%
group_by(선거구분, 시간순서) %>%
summarise(선거인수 = sum(선거인수),
사전투표수 = sum(누적투표수)) %>%
ungroup() %>%
mutate(사전투표율 = 사전투표수 / 선거인수) %>%
## 시간순서 맞춤
separate(시간순서, into = c("일", "시간"), sep = "-") %>%
mutate(across(일:시간, parse_number)) %>%
mutate(시간 = str_pad(시간, 2, "left", "0")) %>%
mutate(시간순서 = glue::glue("{일}일-{시간}시")) %>%
select(선거구분, 시간순서, 사전투표율) %>%
## ggalt::geom_dumbbell
pivot_wider(names_from = 선거구분, values_from = 사전투표율) %>%
mutate(구분 = ifelse(str_detect(시간순서, "1일"), "첫째날", "둘째날"))
13.5 시각화
뉴스기사에 바로 사용할 수 있는 고품질 ggplot
객체를 작성하여 png
파일로
작성하여 로컬 파일로 내보낸다. 기본 개념은 시간에 대한 순서를 정렬하여 맞추고
두선거를 아령그래프를 주된 시각화기법을 두고 작성한 후 독자가 큰 흐름을 읽을 수
있게 만들고 마지막으로 우측에 20대 대선과 19대 대선 시간대별 투표율 차이를
계산하여 한번에 필요한 모든 정보를 독자가 얻을 수 있도록 제작한다.
library(ggalt)
early_voting_timeline_diff_g <- early_voting_timeline %>%
mutate(차이 = 제20대 - 제19대) %>%
ggplot(aes(y = 시간순서, x = 제19대, xend = 제20대)) +
geom_dumbbell(aes(color = 구분), size=3, colour_x = "red", colour_xend = "blue",
dot_guide=TRUE, dot_guide_size=0.25) +
theme_classic(15, base_family = "NanumBarunPen") +
theme(legend.position = "top",
legend.title=element_text(size=15),
legend.text=element_text(size=13),
strip.text.x = element_text(size = rel(1.3), colour = "black", family = "NanumMyeongjo", face="bold"),
axis.text.y = element_text(size = rel(1.5), colour = "gray35", family = "NanumBarunpen", face="bold"),
axis.text.x = element_text(size = rel(1.3), colour = "black", family = "NanumBarunpen", face="bold"),
strip.background=element_rect(fill="gray95"),
plot.title=element_text(size=25, face="bold", family = "NanumBarunpen"),
plot.subtitle=element_text(face="bold", size=13, colour="grey10", family = "NanumBarunpen"),
plot.tag.position = c(0.91, 0.93)) +
labs(x="",
y="",
title = "제19, 20대 대통령선거 사전투표율 시간대별 비교",
caption = "데이터 출처: 중앙선거관리위원회 선거통계시스템",
tag = "투표율 차이") +
scale_x_continuous(labels = scales::percent, limits = c(0, 0.45)) +
scale_color_manual(values = c(
"제19대" = "red",
"제20대" = "blue",
"첫째날" = "gray50",
"둘째날" = "black")) +
geom_text_repel(aes(x = 제19대, label = glue::glue("{scales::percent(제19대, accuracy = 0.1)}")), hjust = 1.5) +
geom_text_repel(aes(x = 제20대, label = glue::glue("{scales::percent(제20대, accuracy = 0.1)}")), hjust = -0.5) +
guides(colour = guide_legend(nrow = 1)) +
geom_rect(aes(xmin=.4, xmax=.45, ymin=-Inf, ymax=Inf), fill="gray90") +
geom_text(aes(label=scales::percent(차이, accuracy = 0.1), y=시간순서, x=.425), fontface="bold", size=5, family="NanumBarunPen")
early_voting_timeline_diff_g
ragg::agg_png(glue::glue("assets/images/early_voting_timeline_diff_g.png"), width = 297, height = 210, units = "mm", res = 600)
early_voting_timeline_diff_g
dev.off()