실시간 데이터 대시보드
앞 챕터의 대시보드는 사용자가 직접 필터를 조작해야 화면이 바뀌었습니다. 이번 챕터에서는 아무것도 클릭하지 않아도 앱이 스스로 데이터를 읽어 화면을 갱신하는 방법을 배웁니다. Shiny가 제공하는 세 가지 도구, reactivePoll(), reactiveFileReader(), invalidateLater()를 순서대로 살펴봅니다.
왜 자동 갱신이 필요한가
주식 시세, 서버 모니터링, IoT 센서 데이터처럼 외부에서 계속 바뀌는 정보를 다룰 때 사용자가 새로고침을 누르는 방식은 현실적이지 않습니다. Shiny는 반응형 프로그래밍의 타이머 기능으로 이 문제를 깔끔하게 해결합니다.
invalidateLater() — 가장 단순한 타이머
invalidateLater(ms)는 지정한 밀리초 후에 현재 반응식을 강제로 무효화합니다. 무효화되면 Shiny가 다시 실행하므로, 루프처럼 동작합니다.
# 새 파일: realtime-dashboard/app.R
library(shiny)
library(bslib)
library(tidyverse)
library(plotly)
# ── 서버 시작 시 전역 상태 초기화 ───────────────────────────
history_data <- data.frame(
time = Sys.time(),
value = 50
)
# ── UI ─────────────────────────────────────────────────────
ui <- page_fluid(
theme = bs_theme(bootswatch = "cyborg"),
h2("실시간 센서 모니터링"),
layout_columns(
col_widths = c(4, 8),
card(
card_header("현재 상태"),
value_box(
title = "현재 온도 (°C)",
value = textOutput("current_value"),
theme = "primary"
),
value_box(
title = "마지막 갱신",
value = textOutput("last_update"),
theme = "secondary"
),
sliderInput(
"interval",
"갱신 주기 (초)",
min = 1, max = 10, value = 2
)
),
card(
card_header("최근 60초 추이"),
plotlyOutput("live_chart", height = "400px")
)
)
)
# ── Server ─────────────────────────────────────────────────
server <- function(input, output, session) {
# invalidateLater로 주기적 갱신
live_data <- reactive({
invalidateLater(input$interval * 1000, session)
# 실제 환경에서는 DB나 API를 호출합니다
new_row <- data.frame(
time = Sys.time(),
value = 50 + rnorm(1, sd = 5)
)
# 전역 변수에 누적 (세션 공유 주의 — 단일 사용자 데모용)
history_data <<- bind_rows(history_data, new_row) |>
filter(time >= Sys.time() - 60)
history_data
})
output$current_value <- renderText({
df <- live_data()
round(tail(df$value, 1), 1)
})
output$last_update <- renderText({
live_data()
format(Sys.time(), "%H:%M:%S")
})
output$live_chart <- renderPlotly({
df <- live_data()
p <- df |>
ggplot(aes(x = time, y = value)) +
geom_line(color = "#00bc8c", linewidth = 1) +
geom_point(data = tail(df, 1), color = "white", size = 3) +
labs(x = NULL, y = "온도 (°C)") +
theme_minimal() +
theme(
plot.background = element_rect(fill = "#303030", color = NA),
panel.background = element_rect(fill = "#303030", color = NA),
text = element_text(color = "white"),
axis.text = element_text(color = "grey80")
)
ggplotly(p) |>
layout(
paper_bgcolor = "#303030",
plot_bgcolor = "#303030"
)
})
}
shinyApp(ui, server)
invalidateLater는 세션이 살아있는 동안 계속 동작합니다. input$interval로 갱신 주기를 사용자가 실시간으로 조절할 수 있는 것도 포인트입니다.
reactivePoll() — 데이터베이스 폴링
reactivePoll()은 두 함수를 받습니다. 첫 번째는 가벼운 "변경 감지" 함수, 두 번째는 실제 "데이터 읽기" 함수입니다. 변경이 감지될 때만 두 번째 함수를 실행해 불필요한 연산을 줄입니다.
# 수정: realtime-dashboard/app.R (reactivePoll 예시 — server 함수 내부)
server <- function(input, output, session) {
# reactivePoll: 5초마다 행 개수를 확인하고, 바뀌면 전체 데이터를 읽음
db_data <- reactivePoll(
intervalMillis = 5000,
session = session,
# 가벼운 체크 함수 — 행 개수만 확인
checkFunc = function() {
# 실제 환경에서는 DB 쿼리: SELECT COUNT(*) FROM sensor_log
Sys.time() # 예시: 항상 바뀌는 값으로 대체
},
# 무거운 읽기 함수 — 변화가 있을 때만 실행
valueFunc = function() {
# 실제 환경에서는 DB 쿼리 또는 API 호출
data.frame(
time = seq(Sys.time() - 300, Sys.time(), by = 10),
value = 50 + cumsum(rnorm(31, sd = 1))
)
}
)
output$live_chart <- renderPlotly({
df <- db_data()
# ... 차트 코드
})
}
reactiveFileReader() — 파일 모니터링
CSV나 로그 파일이 외부 프로세스에 의해 갱신되는 경우에 씁니다. 파일 수정 시각을 주기적으로 확인하고, 바뀌면 다시 읽습니다.
# 수정: realtime-dashboard/app.R (reactiveFileReader 예시)
server <- function(input, output, session) {
# 2초마다 파일 수정 시간 체크, 바뀌면 read_csv 재실행
sensor_csv <- reactiveFileReader(
intervalMillis = 2000,
session = session,
filePath = "data/sensor_log.csv",
readFunc = read.csv
)
output$live_chart <- renderPlotly({
df <- sensor_csv()
# ... 차트 코드
})
}
세 가지 도구 비교
| 도구 | 언제 쓰나 | 특징 |
|---|---|---|
invalidateLater() |
단순 타이머가 필요할 때 | 매번 실행, 변경 감지 없음 |
reactivePoll() |
DB, API 폴링 | 체크 함수로 불필요한 조회 최소화 |
reactiveFileReader() |
파일 모니터링 | 파일 수정 시각 기준 감지 |
실시간 대시보드를 만들 때 자원이 충분하면 invalidateLater로 시작하고, 쿼리 비용이 걱정되면 reactivePoll로 교체하는 전략이 실용적입니다.
주의 사항
전역 변수(<<-)로 데이터를 누적하는 방식은 단일 사용자 데모에서는 동작하지만, 여러 사용자가 접속하면 데이터가 섞입니다. 다중 사용자 환경에서는 reactiveValues를 세션 함수 안에 선언해 사용자별로 상태를 분리합니다.
server <- function(input, output, session) {
# 세션별 분리된 상태
rv <- reactiveValues(history = data.frame(time = Sys.time(), value = 50))
...
}