5  공간 역학 데이터 시각화

Author

연세대 산업보건 연구소

Published

November 19, 2025

6 공간 역학 데이터 시각화

🎯 학습 목표

이 챕터를 마치면 다음을 할 수 있습니다:

  • sf 패키지로 공간 데이터를 R에서 처리
  • 역학 데이터와 지리 데이터 결합하여 코로플레스 맵 제작
  • ggplot2tmap으로 전문적인 지도 시각화
  • 공간 패턴을 해석하고 클러스터 탐지
  • 지역별 질병 부담 비교 및 표준화
📦 필요한 패키지
install.packages(c("sf", "rnaturalearth", "rnaturalearthdata", "tmap"))

6.1 4.1 공간 데이터의 기초

6.1.1 4.1.1 Simple Features (sf)란?

sf (Simple Features for R)는 공간 데이터를 다루는 현대적인 R 패키지입니다.

주요 특징: - tidyverse와 완벽 호환 (dplyr, ggplot2 등) - GDAL/GEOS 기반으로 빠르고 안정적 - 데이터프레임처럼 다룰 수 있는 직관적 구조

좌표계 (CRS, Coordinate Reference System): - WGS84 (EPSG:4326): 경도/위도 좌표계 (GPS 표준) - Korea 2000 (EPSG:5179): 한국 평면직각좌표계 - Web Mercator (EPSG:3857): 웹 지도용

6.1.2 4.1.2 한국 지도 데이터 가져오기

library(tidyverse)
library(sf)
library(rnaturalearth)

# 세계 지도에서 한국 추출
korea <- ne_countries(scale = "medium", country = "South Korea", returnclass = "sf")

# 구조 확인
glimpse(korea)
#> Rows: 1
#> Columns: 169
#> $ featurecla <chr> "Admin-0 country"
#> $ scalerank  <int> 1
#> $ labelrank  <int> 2
#> $ sovereignt <chr> "South Korea"
#> $ sov_a3     <chr> "KOR"
#> $ adm0_dif   <int> 0
#> $ level      <int> 2
#> $ type       <chr> "Sovereign country"
#> $ tlc        <chr> "1"
#> $ admin      <chr> "South Korea"
#> $ adm0_a3    <chr> "KOR"
#> $ geou_dif   <int> 0
#> $ geounit    <chr> "South Korea"
#> $ gu_a3      <chr> "KOR"
#> $ su_dif     <int> 0
#> $ subunit    <chr> "South Korea"
#> $ su_a3      <chr> "KOR"
#> $ brk_diff   <int> 0
#> $ name       <chr> "South Korea"
#> $ name_long  <chr> "Republic of Korea"
#> $ brk_a3     <chr> "KOR"
#> $ brk_name   <chr> "Republic of Korea"
#> $ brk_group  <chr> NA
#> $ abbrev     <chr> "S.K."
#> $ postal     <chr> "KR"
#> $ formal_en  <chr> "Republic of Korea"
#> $ formal_fr  <chr> NA
#> $ name_ciawf <chr> "Korea, South"
#> $ note_adm0  <chr> NA
#> $ note_brk   <chr> NA
#> $ name_sort  <chr> "Korea, Rep."
#> $ name_alt   <chr> NA
#> $ mapcolor7  <int> 4
#> $ mapcolor8  <int> 1
#> $ mapcolor9  <int> 1
#> $ mapcolor13 <int> 5
#> $ pop_est    <dbl> 51709098
#> $ pop_rank   <int> 16
#> $ pop_year   <int> 2019
#> $ gdp_md     <int> 1646739
#> $ gdp_year   <int> 2019
#> $ economy    <chr> "4. Emerging region: MIKT"
#> $ income_grp <chr> "1. High income: OECD"
#> $ fips_10    <chr> "KS"
#> $ iso_a2     <chr> "KR"
#> $ iso_a2_eh  <chr> "KR"
#> $ iso_a3     <chr> "KOR"
#> $ iso_a3_eh  <chr> "KOR"
#> $ iso_n3     <chr> "410"
#> $ iso_n3_eh  <chr> "410"
#> $ un_a3      <chr> "410"
#> $ wb_a2      <chr> "KR"
#> $ wb_a3      <chr> "KOR"
#> $ woe_id     <int> 23424868
#> $ woe_id_eh  <int> 23424868
#> $ woe_note   <chr> "Exact WOE match as country"
#> $ adm0_iso   <chr> "KOR"
#> $ adm0_diff  <chr> NA
#> $ adm0_tlc   <chr> "KOR"
#> $ adm0_a3_us <chr> "KOR"
#> $ adm0_a3_fr <chr> "KOR"
#> $ adm0_a3_ru <chr> "KOR"
#> $ adm0_a3_es <chr> "KOR"
#> $ adm0_a3_cn <chr> "KOR"
#> $ adm0_a3_tw <chr> "KOR"
#> $ adm0_a3_in <chr> "KOR"
#> $ adm0_a3_np <chr> "KOR"
#> $ adm0_a3_pk <chr> "KOR"
#> $ adm0_a3_de <chr> "KOR"
#> $ adm0_a3_gb <chr> "KOR"
#> $ adm0_a3_br <chr> "KOR"
#> $ adm0_a3_il <chr> "KOR"
#> $ adm0_a3_ps <chr> "KOR"
#> $ adm0_a3_sa <chr> "KOR"
#> $ adm0_a3_eg <chr> "KOR"
#> $ adm0_a3_ma <chr> "KOR"
#> $ adm0_a3_pt <chr> "KOR"
#> $ adm0_a3_ar <chr> "KOR"
#> $ adm0_a3_jp <chr> "KOR"
#> $ adm0_a3_ko <chr> "KOR"
#> $ adm0_a3_vn <chr> "KOR"
#> $ adm0_a3_tr <chr> "KOR"
#> $ adm0_a3_id <chr> "KOR"
#> $ adm0_a3_pl <chr> "KOR"
#> $ adm0_a3_gr <chr> "KOR"
#> $ adm0_a3_it <chr> "KOR"
#> $ adm0_a3_nl <chr> "KOR"
#> $ adm0_a3_se <chr> "KOR"
#> $ adm0_a3_bd <chr> "KOR"
#> $ adm0_a3_ua <chr> "KOR"
#> $ adm0_a3_un <int> -99
#> $ adm0_a3_wb <int> -99
#> $ continent  <chr> "Asia"
#> $ region_un  <chr> "Asia"
#> $ subregion  <chr> "Eastern Asia"
#> $ region_wb  <chr> "East Asia & Pacific"
#> $ name_len   <int> 11
#> $ long_len   <int> 17
#> $ abbrev_len <int> 4
#> $ tiny       <int> -99
#> $ homepart   <int> 1
#> $ min_zoom   <dbl> 0
#> $ min_label  <dbl> 2.5
#> $ max_label  <dbl> 7
#> $ label_x    <dbl> 128.1295
#> $ label_y    <dbl> 36.38492
#> $ ne_id      <dbl> 1159320985
#> $ wikidataid <chr> "Q884"
#> $ name_ar    <chr> "كوريا الجنوبية"
#> $ name_bn    <chr> "দক্ষিণ কোরিয়া"
#> $ name_de    <chr> "Südkorea"
#> $ name_en    <chr> "South Korea"
#> $ name_es    <chr> "Corea del Sur"
#> $ name_fa    <chr> "کره جنوبی"
#> $ name_fr    <chr> "Corée du Sud"
#> $ name_el    <chr> "Νότια Κορέα"
#> $ name_he    <chr> "קוריאה הדרומית"
#> $ name_hi    <chr> "दक्षिण कोरिया"
#> $ name_hu    <chr> "Dél-Korea"
#> $ name_id    <chr> "Korea Selatan"
#> $ name_it    <chr> "Corea del Sud"
#> $ name_ja    <chr> "大韓民国"
#> $ name_ko    <chr> "대한민국"
#> $ name_nl    <chr> "Zuid-Korea"
#> $ name_pl    <chr> "Korea Południowa"
#> $ name_pt    <chr> "Coreia do Sul"
#> $ name_ru    <chr> "Республика Корея"
#> $ name_sv    <chr> "Sydkorea"
#> $ name_tr    <chr> "Güney Kore"
#> $ name_uk    <chr> "Південна Корея"
#> $ name_ur    <chr> "جنوبی کوریا"
#> $ name_vi    <chr> "Hàn Quốc"
#> $ name_zh    <chr> "大韩民国"
#> $ name_zht   <chr> "大韓民國"
#> $ fclass_iso <chr> "Admin-0 country"
#> $ tlc_diff   <chr> NA
#> $ fclass_tlc <chr> "Admin-0 country"
#> $ fclass_us  <chr> NA
#> $ fclass_fr  <chr> NA
#> $ fclass_ru  <chr> NA
#> $ fclass_es  <chr> NA
#> $ fclass_cn  <chr> NA
#> $ fclass_tw  <chr> NA
#> $ fclass_in  <chr> NA
#> $ fclass_np  <chr> NA
#> $ fclass_pk  <chr> NA
#> $ fclass_de  <chr> NA
#> $ fclass_gb  <chr> NA
#> $ fclass_br  <chr> NA
#> $ fclass_il  <chr> NA
#> $ fclass_ps  <chr> NA
#> $ fclass_sa  <chr> NA
#> $ fclass_eg  <chr> NA
#> $ fclass_ma  <chr> NA
#> $ fclass_pt  <chr> NA
#> $ fclass_ar  <chr> NA
#> $ fclass_jp  <chr> NA
#> $ fclass_ko  <chr> NA
#> $ fclass_vn  <chr> NA
#> $ fclass_tr  <chr> NA
#> $ fclass_id  <chr> NA
#> $ fclass_pl  <chr> NA
#> $ fclass_gr  <chr> NA
#> $ fclass_it  <chr> NA
#> $ fclass_nl  <chr> NA
#> $ fclass_se  <chr> NA
#> $ fclass_bd  <chr> NA
#> $ fclass_ua  <chr> NA
#> $ geometry   <MULTIPOLYGON [°]> MULTIPOLYGON (((126.6339 37...
# 기본 플롯
ggplot(data = korea) +
  geom_sf(fill = "lightblue", color = "black") +
  theme_minimal() +
  labs(title = "대한민국")
Figure 6.1: 한국 기본 지도

6.2 4.2 시뮬레이션 데이터로 코로플레스 맵 제작

실습을 위해 시도별 가상 질병 데이터를 만들겠습니다.

6.2.1 4.2.1 시뮬레이션 데이터 생성

# 한국 17개 시도 데이터 생성
set.seed(123)
regional_disease <- tibble(
  region = c("서울", "부산", "대구", "인천", "광주", "대전", "울산", "세종",
             "경기", "강원", "충북", "충남", "전북", "전남", "경북", "경남", "제주"),
  population = c(9700000, 3400000, 2400000, 3000000, 1500000, 1500000, 1100000, 360000,
                 13500000, 1500000, 1600000, 2100000, 1800000, 1800000, 2600000, 3300000, 670000),
  cases = c(980, 340, 245, 305, 155, 152, 112, 37,
            1360, 153, 163, 214, 183, 185, 265, 335, 68),
  deaths = c(98, 34, 25, 31, 16, 15, 11, 4,
             136, 15, 16, 21, 18, 19, 27, 34, 7)
) %>%
  mutate(
    incidence_rate = (cases / population) * 100000,  # 인구 10만명당
    case_fatality = (deaths / cases) * 100  # 치명률 (%)
  )

# 데이터 확인
head(regional_disease)
#> # A tibble: 6 × 6
#>   region population cases deaths incidence_rate case_fatality
#>   <chr>       <dbl> <dbl>  <dbl>          <dbl>         <dbl>
#> 1 서울      9700000   980     98           10.1         10   
#> 2 부산      3400000   340     34           10           10   
#> 3 대구      2400000   245     25           10.2         10.2 
#> 4 인천      3000000   305     31           10.2         10.2 
#> 5 광주      1500000   155     16           10.3         10.3 
#> 6 대전      1500000   152     15           10.1          9.87

6.2.2 4.2.2 간단한 점 지도 (Point Map)

# 시도별 대략적인 좌표 (중심점)
region_coords <- tibble(
  region = c("서울", "부산", "대구", "인천", "광주", "대전", "울산", "세종",
             "경기", "강원", "충북", "충남", "전북", "전남", "경북", "경남", "제주"),
  lon = c(126.98, 129.08, 128.60, 126.71, 126.85, 127.38, 129.31, 127.29,
          127.18, 128.21, 127.70, 126.80, 127.15, 126.99, 128.89, 128.25, 126.53),
  lat = c(37.57, 35.18, 35.87, 37.46, 35.16, 36.35, 35.54, 36.48,
          37.41, 37.82, 36.64, 36.52, 35.72, 34.86, 36.25, 35.46, 33.49)
)

# 데이터 결합
map_data <- regional_disease %>%
  left_join(region_coords, by = "region")

# 점 지도
ggplot() +
  geom_sf(data = korea, fill = "grey95", color = "black") +
  geom_point(data = map_data,
             aes(x = lon, y = lat, size = incidence_rate, color = incidence_rate),
             alpha = 0.7) +
  scale_size_continuous(name = "발생률\n(인구 10만명당)", range = c(3, 15)) +
  scale_color_viridis_c(name = "발생률\n(인구 10만명당)", option = "plasma") +
  theme_minimal() +
  labs(title = "시도별 질병 발생률 분포",
       subtitle = "원의 크기와 색상이 발생률에 비례") +
  theme(
    legend.position = "right",
    plot.title = element_text(face = "bold", size = 14)
  )
Figure 6.2: 시도별 발생률 (원 크기 비례)

6.2.3 4.2.3 막대 그래프로 지역 비교

# 발생률 순위
regional_disease %>%
  arrange(desc(incidence_rate)) %>%
  mutate(region = fct_reorder(region, incidence_rate)) %>%
  ggplot(aes(x = region, y = incidence_rate, fill = incidence_rate)) +
  geom_col(alpha = 0.8) +
  geom_text(aes(label = round(incidence_rate, 1)),
            hjust = -0.1, size = 3) +
  scale_fill_viridis_c(option = "plasma") +
  coord_flip() +
  labs(
    title = "시도별 질병 발생률 (인구 10만명당)",
    x = NULL,
    y = "발생률 (per 100,000)",
    fill = "발생률"
  ) +
  theme_minimal() +
  theme(
    legend.position = "none",
    plot.title = element_text(face = "bold", size = 14)
  )
Figure 6.3: 시도별 발생률 순위

6.3 4.3 치명률 (Case Fatality Rate) 분석

ggplot(regional_disease, aes(x = incidence_rate, y = case_fatality)) +
  geom_point(aes(size = population, color = region), alpha = 0.7) +
  geom_smooth(method = "lm", se = TRUE, color = "red", linetype = "dashed") +
  geom_text(aes(label = region), vjust = -0.8, size = 3, check_overlap = TRUE) +
  scale_size_continuous(name = "인구", labels = scales::comma) +
  labs(
    title = "발생률과 치명률의 관계",
    x = "발생률 (인구 10만명당)",
    y = "치명률 (%)",
    color = "시도"
  ) +
  theme_minimal() +
  theme(
    plot.title = element_text(face = "bold", size = 14),
    legend.position = "bottom"
  )
Figure 6.4: 발생률 vs 치명률 산점도

6.4 4.4 인구 표준화: 공정한 비교를 위해

문제: 인구가 많은 지역이 발생 건수가 많을 수 있음

해결: 표준화 발생비 (SIR, Standardized Incidence Ratio) 사용

# 전국 평균 발생률
national_rate <- sum(regional_disease$cases) / sum(regional_disease$population) * 100000

# SIR 계산
regional_disease_sir <- regional_disease %>%
  mutate(
    expected_cases = (population / 100000) * national_rate,
    SIR = (cases / expected_cases) * 100,
    SIR_category = case_when(
      SIR < 80 ~ "매우 낮음",
      SIR < 95 ~ "낮음",
      SIR < 105 ~ "평균",
      SIR < 120 ~ "높음",
      TRUE ~ "매우 높음"
    )
  )

# SIR 결과
regional_disease_sir %>%
  select(region, cases, expected_cases, SIR, SIR_category) %>%
  arrange(desc(SIR))
#> # A tibble: 17 × 5
#>    region cases expected_cases   SIR SIR_category
#>    <chr>  <dbl>          <dbl> <dbl> <chr>       
#>  1 광주     155          152.  102.  평균        
#>  2 세종      37           36.5 101.  평균        
#>  3 전남     185          182.  101.  평균        
#>  4 대구     245          243.  101.  평균        
#>  5 강원     153          152.  101.  평균        
#>  6 경북     265          263.  101.  평균        
#>  7 충남     214          213.  101.  평균        
#>  8 충북     163          162.  101.  평균        
#>  9 울산     112          111.  100.  평균        
#> 10 전북     183          182.  100.  평균        
#> 11 인천     305          304.  100.  평균        
#> 12 경남     335          334.  100.  평균        
#> 13 제주      68           67.9 100.  평균        
#> 14 대전     152          152.  100.  평균        
#> 15 서울     980          983.   99.7 평균        
#> 16 경기    1360         1368.   99.4 평균        
#> 17 부산     340          345.   98.7 평균
# SIR 점 지도
map_data_sir <- regional_disease_sir %>%
  left_join(region_coords, by = "region")

ggplot() +
  geom_sf(data = korea, fill = "grey95", color = "black") +
  geom_point(data = map_data_sir,
             aes(x = lon, y = lat, size = abs(SIR - 100), color = SIR),
             alpha = 0.7) +
  scale_size_continuous(name = "전국 평균과의\n차이", range = c(3, 15)) +
  scale_color_gradient2(
    name = "SIR",
    low = "blue", mid = "white", high = "red",
    midpoint = 100,
    breaks = c(80, 90, 100, 110, 120)
  ) +
  theme_minimal() +
  labs(
    title = "표준화 발생비 (SIR)",
    subtitle = "100 = 전국 평균, >100 = 평균보다 높음, <100 = 평균보다 낮음"
  ) +
  theme(
    legend.position = "right",
    plot.title = element_text(face = "bold", size = 14)
  )
Figure 6.5: 표준화 발생비 (SIR) - 전국 평균 대비

6.5 4.5 공간 데이터 분석 워크플로우

6.5.1 4.5.1 실전 분석 단계

1단계: 데이터 수집 - 질병 발생 데이터 (보건소, 질병관리청) - 인구 데이터 (통계청) - 공간 경계 파일 (Shapefile, GeoJSON)

2단계: 데이터 전처리

# 표준화
- 인구 10만명당 발생률
- 연령 표준화 (직접/간접 표준화)
- SIR 계산

3단계: 공간 결합

# sf 객체와 데이터 결합
combined_data <- left_join(spatial_data, disease_data, by = "region_id")

4단계: 시각화 - 코로플레스 맵: geom_sf() + scale_fill_* - 점 지도: geom_point() with coordinates - 복합 레이어: 지도 + 점 + 라벨

5단계: 해석 - 공간 클러스터 탐지 - 핫스팟 분석 - 시계열 공간 변화

6.6 4.6 유용한 팁과 트러블슈팅

6.6.1 4.6.1 좌표계 변환

# WGS84 → UTM-K (EPSG:5179) 변환
korea_utm <- st_transform(korea, crs = 5179)

# 좌표계 확인
st_crs(korea_utm)

6.6.2 4.6.2 공간 데이터 소스

무료 공간 데이터: - 대한민국 행정구역: 국가공간정보포털 - 세계 지도: rnaturalearth 패키지 - OpenStreetMap: osmdata 패키지 - 통계청: SGIS

6.6.3 4.6.3 ggplot2 vs tmap 선택 가이드

기준 ggplot2 + geom_sf() tmap
학습 곡선 tidyverse 사용자에게 쉬움 전용 문법 필요
커스터마이징 매우 유연 템플릿 기반
인터랙티브 plotly로 변환 가능 내장 지원 (tmap_mode("view"))
출판 품질 완벽 제어 빠른 프로토타입
권장 용도 논문, 세밀한 조정 탐색적 분석, 대시보드

6.7 요약

sf 패키지로 공간 데이터를 데이터프레임처럼 다룸 ✅ 발생률 계산: 인구 10만명당 표준화 필수 ✅ SIR (표준화 발생비): 공정한 지역 간 비교 ✅ 시각화 전략: 점 지도 vs 코로플레스 맵 ✅ 데이터 소스: 국가공간정보포털, rnaturalearth

📚 더 배우기

고급 공간 분석: - spdep 패키지: 공간 자기상관, Moran’s I - spatstat 패키지: 점 패턴 분석 - ggspatial 패키지: 북쪽 화살표, 축척 추가

인터랙티브 지도: - leaflet 패키지: 웹 인터랙티브 지도 - mapview 패키지: 빠른 탐색용 지도