iBetter Books
수정

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; }
  }
")))

플롯 아이디 chartoutput$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() 조합보다 현대적인 반응형 동작을 제공합니다.