Ch 04. 반응형 디자인
데스크탑에서 멋지게 보이는 앱이 스마트폰에서는 글자가 겹치고 버튼이 너무 작아서 못 쓰는 경우가 있습니다. Shiny는 Bootstrap 그리드를 기반으로 하므로 기본적인 반응형 동작은 내장되어 있습니다. 여기에 몇 가지 기법을 더하면 모바일에서도 쓰기 편한 앱을 만들 수 있습니다.
Bootstrap 그리드의 반응형 원리
Bootstrap의 12열 그리드는 화면 너비에 따라 네 개의 중단점(breakpoint)을 정의합니다.
| 접두어 | 최소 너비 | 대상 장치 |
|---|---|---|
| (없음) | 0px 이상 | 모든 크기 |
sm |
576px 이상 | 작은 태블릿 |
md |
768px 이상 | 태블릿 |
lg |
992px 이상 | 일반 노트북 |
xl |
1200px 이상 | 큰 모니터 |
Shiny의 column() 함수는 HTML에서 col-md-N 클래스를 생성합니다. 768px 이상에서는 지정한 열 너비를 쓰고, 그 미만(모바일)에서는 자동으로 전체 너비(12열)로 전환됩니다.
모바일 대응 기본 패턴
sidebarLayout()은 모바일에서 사이드바가 위로, 메인 패널이 아래로 자동으로 쌓입니다. 별도 설정 없이도 기본적인 모바일 대응이 됩니다.
library(shiny)
ui <- fluidPage(
# 뷰포트 메타 태그 (모바일 스케일 고정)
tags$head(
tags$meta(name = "viewport",
content = "width=device-width, initial-scale=1")
),
titlePanel("반응형 예제"),
sidebarLayout(
sidebarPanel(
sliderInput("n", "데이터 수", 10, 500, 100),
selectInput("col", "색상",
choices = c("파랑", "빨강", "초록"))
),
mainPanel(
plotOutput("plot")
)
)
)
tags$meta(name = "viewport", ...) 는 모바일 브라우저가 페이지를 올바른 크기로 렌더링하게 합니다. 이 태그가 없으면 모바일에서 데스크탑 크기로 축소되어 보입니다.
column()으로 화면 크기별 레이아웃 제어
column()에 직접 HTML 클래스를 추가해 화면 크기마다 다른 열 너비를 지정할 수 있습니다.
ui <- fluidPage(
fluidRow(
# 모바일: 전체 너비(12), 태블릿 이상: 절반(6), 큰 화면: 4
div(class = "col-12 col-md-6 col-lg-4",
wellPanel(
h5("카드 1"),
textOutput("card1_text")
)
),
div(class = "col-12 col-md-6 col-lg-4",
wellPanel(
h5("카드 2"),
textOutput("card2_text")
)
),
div(class = "col-12 col-md-12 col-lg-4",
wellPanel(
h5("카드 3"),
textOutput("card3_text")
)
)
)
)
col-12는 모바일에서 한 줄 전체를 차지하게 합니다. col-md-6은 768px 이상에서 절반 너비, col-lg-4는 992px 이상에서 3분의 1 너비입니다.
CSS 미디어 쿼리 직접 작성
Bootstrap이 처리하지 못하는 세밀한 조정은 CSS 미디어 쿼리로 처리합니다.
ui <- fluidPage(
tags$head(
tags$style(HTML("
/* 기본 스타일 (모든 크기) */
.my-title {
font-size: 24px;
font-weight: bold;
color: #2c3e50;
}
/* 태블릿 이상 */
@media (min-width: 768px) {
.my-title {
font-size: 32px;
}
.sidebar-fixed {
position: sticky;
top: 20px;
}
}
/* 모바일에서 사이드바 숨기기 */
@media (max-width: 767px) {
.hide-on-mobile {
display: none !important;
}
/* 모바일에서 버튼을 전체 너비로 */
.btn {
width: 100%;
margin-bottom: 8px;
}
}
"))
),
div(class = "my-title", "반응형 앱 제목"),
sidebarLayout(
sidebarPanel(
class = "sidebar-fixed",
sliderInput("n", "수", 10, 100, 50)
),
mainPanel(
plotOutput("plot"),
div(class = "hide-on-mobile",
p("이 문구는 모바일에서는 보이지 않습니다."))
)
)
)
그래프 크기 반응형 처리
plotOutput()의 width를 "100%"로 지정하면 부모 컨테이너 너비에 맞게 자동 조정됩니다.
plotOutput("chart",
width = "100%",
height = "400px")
고정 높이가 부담스럽다면 CSS로 동적으로 처리할 수 있습니다.
tags$head(tags$style(HTML("
@media (max-width: 576px) {
#chart { height: 250px !important; }
}
@media (min-width: 577px) {
#chart { height: 400px !important; }
}
")))
플롯 아이디 chart는 output$chart에 대응하는 #chart CSS 선택자로 접근합니다.
모바일 친화적 위젯 선택
모바일에서는 작은 화면을 고려한 위젯을 선택하는 것이 중요합니다.
| 위젯 | 모바일 고려사항 |
|---|---|
sliderInput() |
터치 친화적이나 정밀 조작 어려움. 범위가 넓으면 numericInput() 병행 |
selectInput() |
모바일 기본 드롭다운 UI를 씀. 큰 목록은 selectizeInput()이 나음 |
dateInput() |
모바일에서 기본 날짜 선택기 UI 활성화 |
checkboxGroupInput() |
항목이 많으면 스크롤이 길어짐. selectInput(multiple=TRUE) 고려 |
actionButton() |
최소 44×44px 크기 권장 (손가락 터치 영역) |
버튼 크기는 Bootstrap 클래스로 조절합니다.
actionButton("run", "실행",
class = "btn-primary btn-lg", # 큰 버튼
style = "width: 100%;") # 전체 너비
bslib layout_sidebar로 반응형 개선
bslib의 layout_sidebar()는 Bootstrap 5의 반응형 동작을 더 잘 활용합니다.
library(shiny)
library(bslib)
ui <- page_sidebar(
title = "반응형 대시보드",
theme = bs_theme(version = 5, bootswatch = "flatly"),
sidebar = sidebar(
title = "설정",
width = 280,
sliderInput("n", "데이터 수", 10, 500, 100),
selectInput("type", "차트 종류",
choices = c("히스토그램", "박스플롯"))
),
plotOutput("main_chart", height = "400px"),
tableOutput("summary_tbl")
)
server <- function(input, output, session) {
output$main_chart <- renderPlot({
data <- rnorm(input$n)
if (input$type == "히스토그램") {
hist(data, col = "#0d6efd", main = "히스토그램")
} else {
boxplot(data, col = "#198754", main = "박스플롯")
}
})
output$summary_tbl <- renderTable({
data.frame(
통계량 = c("평균", "표준편차", "최솟값", "최댓값"),
값 = round(c(mean(rnorm(input$n)), sd(rnorm(input$n)),
min(rnorm(input$n)), max(rnorm(input$n))), 3)
)
})
}
shinyApp(ui, server)
page_sidebar()는 bslib의 최신 레이아웃 함수로, 모바일에서 사이드바가 접히는 토글 버튼이 자동으로 생성됩니다. fluidPage() + sidebarLayout() 조합보다 현대적인 반응형 동작을 제공합니다.