대용량 데이터 처리 전략
100만 행짜리 CSV 파일을 read.csv()로 읽으면 Shiny 앱이 요청마다 수십 초씩 걸립니다. 이 챕터에서는 실무에서 검증된 네 가지 전략으로 앱을 빠르게 만드는 방법을 다룹니다.
전략 1: global.R에서 데이터 한 번만 로드
Shiny는 앱 시작 시 global.R을 딱 한 번 실행합니다. 무거운 데이터 로드를 여기에 넣으면 사용자가 접속할 때마다 읽는 부담이 사라집니다.
modular-app/
├── global.R ← 앱 시작 시 1회 실행
├── app.R ← UI·Server 정의
└── data/
└── large_dataset.csv
# 새 파일: modular-app/global.R
library(tidyverse)
library(arrow)
message("데이터 로딩 중...")
# 앱 전체에서 공유되는 전역 변수
APP_DATA <- read_csv("data/large_dataset.csv", show_col_types = FALSE)
# 자주 쓰는 집계 결과도 미리 계산
SUMMARY_BY_REGION <- APP_DATA |>
group_by(region, year) |>
summarise(
total = sum(value, na.rm = TRUE),
average = mean(value, na.rm = TRUE),
.groups = "drop"
)
message("데이터 로딩 완료: ", nrow(APP_DATA), "행")
server.R에서는 APP_DATA를 그냥 씁니다. read_csv()를 매번 호출하지 않아도 됩니다.
전략 2: Arrow와 Parquet 파일
CSV는 사람이 읽기에는 좋지만 컴퓨터가 읽기에는 느립니다. Parquet는 컬럼 기반 바이너리 형식으로, 같은 데이터를 10배 빠르게 읽습니다.
install.packages("arrow")
# 새 파일: modular-app/global.R (Arrow 버전)
library(arrow)
library(dplyr)
# CSV를 Parquet로 변환 (최초 1회만)
if (!file.exists("data/large_dataset.parquet")) {
df <- read.csv("data/large_dataset.csv")
write_parquet(df, "data/large_dataset.parquet")
}
# Parquet 읽기 — CSV보다 훨씬 빠름
APP_DATA <- read_parquet("data/large_dataset.parquet")
더 나아가 open_dataset()으로 파일을 메모리에 올리지 않고 필요한 컬럼·행만 읽을 수 있습니다.
# 디스크에서 직접 쿼리 (메모리 절약)
ds <- open_dataset("data/large_dataset.parquet")
server <- function(input, output, session) {
filtered <- reactive({
ds |>
filter(region == input$region) |>
collect() # 이 시점에 실제로 읽음
})
}
전략 3: 사전 집계로 쿼리 크기 줄이기
원본 데이터를 매번 필터링하는 대신, 자주 쓰는 집계 결과를 미리 만들어 저장합니다.
# 수정: modular-app/global.R
library(tidyverse)
# 원본 데이터 (100만 행)
RAW_DATA <- read_parquet("data/raw.parquet")
# 사전 집계 (1만 행) — 대시보드 차트용
DAILY_SUMMARY <- RAW_DATA |>
group_by(date, region) |>
summarise(
count = n(),
revenue = sum(revenue, na.rm = TRUE),
.groups = "drop"
)
# 월별 집계 (1천 행) — KPI 카드용
MONTHLY_KPI <- DAILY_SUMMARY |>
mutate(month = floor_date(date, "month")) |>
group_by(month, region) |>
summarise(
total_count = sum(count),
total_revenue = sum(revenue),
.groups = "drop"
)
대시보드가 DAILY_SUMMARY를 쓰면 100만 행 대신 1만 행을 필터링하므로 속도가 수십 배 빨라집니다.
전략 4: reactiveValues 최소화
reactiveValues를 과도하게 쓰면 예상치 못한 시점에 재실행이 일어납니다. 읽기 전용 데이터는 반응형으로 감싸지 않고 일반 변수로 씁니다.
# 나쁜 패턴 — APP_DATA는 바뀌지 않는데 반응형으로 감쌈
server <- function(input, output, session) {
rv <- reactiveValues(data = APP_DATA) # 불필요
output$chart <- renderPlotly({
rv$data |> filter(...)
})
}
# 좋은 패턴 — global.R의 APP_DATA를 직접 참조
server <- function(input, output, session) {
filtered <- reactive({
APP_DATA |> filter(region %in% input$region)
})
output$chart <- renderPlotly({
filtered() |> ...
})
}
reactiveValues는 앱 상태가 실제로 변할 때(파일 업로드, 버튼 클릭으로 데이터 추가)만 씁니다.
성능 측정
어느 부분이 느린지 모르면 개선도 어렵습니다. profvis 패키지로 병목을 찾습니다.
install.packages("profvis")
library(profvis)
# 느린 코드 블록을 profvis로 감싸기
profvis({
df <- read.csv("data/large_dataset.csv")
result <- df |>
group_by(region) |>
summarise(total = sum(value))
})
RStudio에서 실행하면 각 함수가 얼마나 시간을 썼는지 시각적으로 보여줍니다. 빨간 막대가 긴 함수가 최적화 대상입니다.
전략별 효과 요약
| 전략 | 효과 | 복잡도 |
|---|---|---|
global.R 로드 |
사용자 요청당 IO 제거 | 낮음 |
| Parquet 파일 | 읽기 속도 5~10배 향상 | 낮음 |
| Arrow 디스크 쿼리 | 메모리 사용량 대폭 감소 | 중간 |
| 사전 집계 | 필터링 데이터 크기 감소 | 중간 |
reactiveValues 최소화 |
불필요한 재실행 방지 | 낮음 |
대부분의 앱은 global.R + Parquet + 사전 집계만으로도 충분한 성능이 나옵니다. Arrow 디스크 쿼리는 데이터가 수억 행 이상일 때 고려합니다.