iBetter Books
수정

공공데이터 탐색 대시보드

공공 데이터를 활용해 지역과 기간을 고르면 차트와 테이블이 동시에 바뀌는 대시보드를 완성합니다. 이 챕터는 앞서 배운 selectInput, reactive(), renderPlotly, renderDT를 하나의 앱으로 엮는 실전 연습입니다.

완성 앱 미리보기

사이드바에서 지역과 연도를 선택하면 오른쪽 메인 패널의 막대 차트와 데이터 테이블이 즉시 갱신됩니다. 데이터는 행정안전부 공개 인구 통계 CSV를 가상으로 모사한 샘플을 사용합니다.

[ 사이드바 ]           [ 메인 패널 ]
  지역 선택            ┌──────────────────┐
  ○ 서울               │  plotly 막대 차트  │
  ○ 부산               └──────────────────┘
  ○ 대구               ┌──────────────────┐
                       │  DT 데이터 테이블  │
  연도 범위             └──────────────────┘
  2018 ~ 2023

프로젝트 구조

앱을 하나의 app.R로 작성합니다. 실무에서는 ui.R / server.R로 분리하지만, 이 챕터는 전체 흐름을 한눈에 보는 것이 목적이므로 단일 파일로 구성합니다.

public-dashboard/
└── app.R

패키지 설치

install.packages(c("shiny", "bslib", "tidyverse", "plotly", "DT"))

샘플 데이터 생성

실제 CSV 파일 대신 앱 내부에서 재현 가능한 샘플 데이터를 만듭니다.

# 새 파일: public-dashboard/app.R
library(shiny)
library(bslib)
library(tidyverse)
library(plotly)
library(DT)

# ── 샘플 데이터 ────────────────────────────────────────────
set.seed(42)

regions <- c("서울", "부산", "대구", "인천", "광주", "대전", "울산")
years   <- 2018:2023

pop_data <- expand.grid(region = regions, year = years) |>
  mutate(
    population = as.integer(runif(n(), min = 200000, max = 10000000)),
    birth_rate  = round(runif(n(), min = 3.5, max = 9.0), 2),
    death_rate  = round(runif(n(), min = 4.0, max = 8.0), 2)
  )

# ── UI ─────────────────────────────────────────────────────
ui <- page_sidebar(
  title = "공공데이터 탐색 대시보드",
  theme = bs_theme(bootswatch = "flatly"),

  sidebar = sidebar(
    selectInput(
      inputId  = "region",
      label    = "지역 선택",
      choices  = regions,
      selected = "서울",
      multiple = TRUE
    ),
    sliderInput(
      inputId = "year_range",
      label   = "연도 범위",
      min     = 2018,
      max     = 2023,
      value   = c(2018, 2023),
      sep     = ""
    ),
    selectInput(
      inputId  = "metric",
      label    = "지표 선택",
      choices  = c(
        "인구수"    = "population",
        "출생률"    = "birth_rate",
        "사망률"    = "death_rate"
      ),
      selected = "population"
    )
  ),

  layout_columns(
    col_widths = 12,
    card(
      card_header("지역별 추이"),
      plotlyOutput("trend_chart", height = "350px")
    ),
    card(
      card_header("원본 데이터"),
      DTOutput("data_table")
    )
  )
)

# ── Server ─────────────────────────────────────────────────
server <- function(input, output, session) {

  # 필터링된 데이터 — 두 출력이 공유
  filtered <- reactive({
    pop_data |>
      filter(
        region %in% input$region,
        year   >= input$year_range[1],
        year   <= input$year_range[2]
      )
  })

  # plotly 차트
  output$trend_chart <- renderPlotly({
    req(nrow(filtered()) > 0)

    p <- filtered() |>
      ggplot(aes(
        x    = year,
        y    = .data[[input$metric]],
        fill = region,
        text = paste0(
          "지역: ", region, "\n",
          "연도: ", year, "\n",
          "값: ", format(.data[[input$metric]], big.mark = ",")
        )
      )) +
      geom_col(position = "dodge") +
      scale_x_continuous(breaks = 2018:2023) +
      labs(
        x    = "연도",
        y    = switch(input$metric,
          population = "인구수",
          birth_rate = "출생률",
          death_rate = "사망률"
        ),
        fill = "지역"
      ) +
      theme_minimal()

    ggplotly(p, tooltip = "text")
  })

  # DT 테이블
  output$data_table <- renderDT({
    filtered() |>
      arrange(region, year) |>
      rename(
        지역 = region,
        연도 = year,
        인구수 = population,
        출생률 = birth_rate,
        사망률 = death_rate
      ) |>
      datatable(
        options = list(
          pageLength  = 10,
          lengthMenu  = c(5, 10, 20),
          searching   = TRUE,
          scrollX     = TRUE
        ),
        rownames = FALSE
      ) |>
      formatCurrency("인구수", currency = "", interval = 3, mark = ",", digits = 0)
  })
}

shinyApp(ui, server)

핵심 패턴 정리

reactive()로 데이터 공유하기

filtered()를 한 번만 정의했지만 renderPlotlyrenderDT 두 곳에서 동시에 씁니다. 필터 조건이 바뀌면 두 출력이 함께 갱신됩니다.

filtered <- reactive({
  pop_data |>
    filter(region %in% input$region, ...)
})

output$chart <- renderPlotly({ filtered() |> ... })
output$table <- renderDT({    filtered() |> ... })

reactive() 없이 input$region을 양쪽에서 직접 쓰면, 필터 로직이 중복되고 미묘한 타이밍 차이가 생길 수 있습니다.

req()로 빈 선택 방어하기

selectInput에서 multiple = TRUE를 쓰면 아무것도 선택하지 않을 수 있습니다. 이때 req(nrow(filtered()) > 0)이 실행을 멈춰 빈 차트 오류를 막습니다.

.data[[input$metric]] 패턴

ggplot2에서 변수 이름을 문자열로 전달할 때 .data[[...]]를 씁니다. aes_string()은 구식 방법이므로 이 패턴을 기억해두세요.

앱 실행

RStudio에서 app.R을 열고 "Run App" 버튼을 클릭합니다. 터미널에서 실행하려면 아래 명령어를 씁니다.

shiny::runApp("public-dashboard")

지역을 여러 개 선택하면 차트에 색상별 막대가 나란히 표시되고, 테이블은 선택한 지역과 기간으로 자동 필터링됩니다. 이 구조가 앞으로 만들 모든 대시보드의 뼈대입니다.

Ch 01. 공공데이터 탐색 대시보드 — 실전 R과 Shiny | iBetter Books