R Shiny 동적 inputId와 리바인딩 완전 정리: 입력값 섞임 없이 안전하게 구현하는 방법

R Shiny에서 동적 테이블, reactable, 입력 위젯을 다룰 때 발생하는 inputId 충돌과 리바인딩 꼬임 문제를 해결하는 방법을 단계별로 정리한 실전 가이드입니다.
데이터·통계
저자

Ikmyungterran

공개

2026년 3월 8일

Modified

2026년 3월 8일

0. 이 글이 다루는 문제

R Shiny 앱에서 검색 결과를 다시 불러올 때마다 표 안의 입력창도 함께 새로 그려지는 경우가 있습니다. 이때 가장 자주 생기는 문제가 두 가지입니다.

  • 이전 검색에서 입력한 값이 새 표에 섞여 나타나는 문제
  • observeEvent()가 엉뚱한 입력을 계속 감시하거나, 이벤트가 중복으로 반응하는 문제

이 글은 바로 이 문제를 다룹니다. 특히 reactable 같은 동적 테이블 안에 입력 위젯이 들어가 있을 때, 왜 inputId를 동적으로 만들어야 하는지, 왜 리바인딩 문제가 생기는지, 그리고 어떤 방식으로 해결하는 것이 가장 안전한지 차근차근 설명합니다.

핵심 결론은 간단합니다.

검색할 때마다 inputId를 새로 만들고, 그 inputId에 맞춰 관찰자도 다시 바인딩하면 상태 섞임 문제를 가장 안정적으로 해결할 수 있습니다.


1. 먼저 reactable이 무엇인지부터 이해하기

이 주제를 처음 접하는 독자라면, 먼저 reactable이 어떤 역할을 하는 패키지인지 이해하는 것이 좋습니다. reactable은 R에서 데이터 프레임을 인터랙티브한 HTML 테이블로 보여 주는 패키지입니다.

즉, 단순히 표를 출력하는 수준을 넘어 아래 기능을 자주 함께 사용합니다.

  • 정렬
  • 필터링
  • 페이지네이션
  • 셀 스타일링
  • 사용자 정의 셀 렌더링

Shiny에서 reactable이 많이 쓰이는 이유는, “서버에서 계산한 결과를 보기 좋은 표로 보여 주는 것”을 넘어서 “표 안에 버튼, 링크, 입력창 같은 UI 요소를 넣는 것”까지 가능하기 때문입니다.

예를 들어 가장 단순한 형태는 아래와 같습니다.

library(shiny)
library(reactable)

ui <- fluidPage(
  reactableOutput("tbl")
)

server <- function(input, output, session) {
  output$tbl <- renderReactable({
    reactable(
      iris[1:10, ],
      searchable = TRUE,
      striped = TRUE,
      highlight = TRUE
    )
  })
}

shinyApp(ui, server)

이 예제의 핵심은 다음과 같습니다.

  • reactableOutput("tbl")이 표가 들어갈 자리를 만든다
  • renderReactable()이 실제 표를 서버에서 생성한다
  • reactable() 안에서 열별 옵션을 세밀하게 제어할 수 있다

reactable을 처음 사용할 때는 “보기 좋은 표 라이브러리” 정도로 이해해도 괜찮습니다. 하지만 실무에서는 여기서 한 단계 더 나아가, 셀 자체를 편집 가능한 UI처럼 다루고 싶어지는 순간이 옵니다. 바로 그때 reactable.extras 계열 함수가 등장합니다.


2. reactable.extras::text_extra()는 무엇을 하는가

reactable.extras::text_extra()는 표 셀 안에 텍스트 입력 성격의 편집 요소를 넣고 싶을 때 쓰는 함수입니다. 즉, 표가 단순 조회 화면이 아니라 사용자가 값까지 수정하는 인터페이스가 되는 것입니다.

개념적으로 보면 아래와 같습니다.

도구 역할
reactable() 표 전체 구조를 만든다
colDef() 특정 열의 동작을 정의한다
text_extra() 셀 안에 입력 가능한 텍스트 편집 요소를 넣는다

예를 들어 점수 입력 셀이 있는 표를 만든다고 가정하면 구조는 아래처럼 잡힙니다.

library(reactable)
library(reactable.extras)

reactable(
  data.frame(item = c("A", "B"), score = c("", "")),
  columns = list(
    score = colDef(
      align = "center",
      cell = reactable.extras::text_extra(
        id = "text_A001",
        class = "table-text-number"
      )
    )
  )
)

여기서 중요한 점은 text_extra()가 단순한 장식이 아니라, Shiny 서버와 연결될 수 있는 입력 요소를 셀 안에 만든다는 것입니다. 즉, 이 셀은 나중에 서버에서 input[[...]] 형태로 추적될 수 있습니다.

바로 이 지점 때문에 inputId 설계가 중요해집니다.

  • 표가 한 번만 렌더링된다면 고정 ID도 큰 문제가 없습니다.
  • 하지만 검색 조건이 바뀔 때마다 표가 다시 생성된다면, 같은 ID를 계속 재사용하는 순간 상태 충돌 위험이 생깁니다.

즉, reactabletext_extra()를 이해하고 나면 왜 이 글의 핵심 주제가 “동적 inputId와 안전한 리바인딩”인지 자연스럽게 연결됩니다.

reactable 기반 편집형 테이블 예시 화면


3. 왜 이런 문제가 생기는가

Shiny에서 모든 입력 위젯은 inputId를 가집니다. 이 inputId는 단순한 문자열이 아니라, 서버가 해당 입력을 추적하는 고유 주소입니다.

예를 들어 표 셀 안에 점수 입력창이 있다고 가정하겠습니다.

textInput("text_A001", label = NULL)

이 입력창은 서버에서 input$text_A001로 읽힙니다. 여기까지만 보면 문제 없어 보입니다. 하지만 검색 결과를 다시 불러오면서 동일한 구조의 표를 새로 렌더링하면 상황이 달라집니다.

3.1 문제 시나리오

  1. 사용자가 첫 번째 검색을 실행한다.
  2. 표 안의 특정 셀 입력창이 text_A001이라는 inputId로 생성된다.
  3. 사용자가 이 셀에 값을 입력한다.
  4. 두 번째 검색을 실행한다.
  5. 표는 새 데이터로 다시 렌더링되지만, 셀의 inputId는 여전히 text_A001이다.

이때 Shiny는 이것을 “완전히 새로운 입력창”으로 보지 않고, 기존 입력 위젯이 다시 나타난 것으로 인식할 수 있습니다. 그러면 아래와 같은 현상이 발생합니다.

  • 이전 값이 남아 보인다
  • 이벤트 리스너가 이전 위젯 상태를 계속 붙잡고 있다
  • UI는 새로 그려졌는데 서버는 예전 입력과 연결되어 있다

즉, 문제의 본질은 검색 결과는 바뀌었는데 주소는 안 바뀐 것입니다.


4. 고정 inputId가 왜 위험한가

고정 inputId는 정적 화면에서는 전혀 문제가 없습니다. 하지만 동적으로 다시 그려지는 UI에서는 충돌 원인이 됩니다.

다음과 같이 이해하면 쉽습니다.

상황 화면은 바뀌는가 inputId는 바뀌는가 결과
정적 입력 폼 거의 안 바뀜 고정 문제 없음
검색 결과 테이블 재렌더링 계속 바뀜 고정 상태 섞임 위험
검색 세대별 새 ID 부여 계속 바뀜 함께 바뀜 안전

즉, UI가 재생성되는 구조라면 inputId도 재생성되어야 합니다.


5. 해결 전략의 큰 그림

이 문제를 해결하는 가장 실용적인 구조는 아래 4단계입니다.

  1. 현재 몇 번째 검색인지 기록하는 세대 카운터를 만든다.
  2. 기본 ID에 세대 번호를 붙여 동적 inputId를 만든다.
  3. 렌더링되는 셀 입력 위젯에 이 동적 ID를 적용한다.
  4. observeEvent()도 그 동적 ID를 기준으로 다시 등록한다.

즉, 아래처럼 바뀝니다.

text_A001

에서

text_A001__1
text_A001__2
text_A001__3

처럼 바뀌는 구조입니다.

Shiny 동적 inputId와 안전한 리바인딩 구조를 설명하는 도식


6. 먼저 이해해야 하는 Shiny 반응성 개념

이 문제를 제대로 해결하려면 reactive(), reactiveVal(), observeEvent(), isolate()의 역할을 명확히 구분해야 합니다.

6.1 reactive()reactiveVal()의 차이

함수 용도 특징
reactive() 계산된 값을 만들기 다른 반응형 값에 의존하는 읽기 중심 계산식
reactiveVal() 상태값 저장하기 프로그래머가 직접 읽고 쓰는 단일 상태 저장소

이 문제에서 필요한 것은 “계산식”보다 “현재 몇 번째 검색인지 저장하는 상태값”입니다. 그래서 reactiveVal()이 적합합니다.

6.2 observeEvent()isolate()의 차이

observeEvent()는 특정 이벤트가 일어났을 때만 실행됩니다. 여기서는 input$searchBtn 클릭이 대표적인 이벤트입니다.

isolate()는 반응형 값을 읽되, 그 값을 현재 코드의 새로운 반응성 의존 관계로 만들지 않도록 할 때 사용합니다.

예를 들어 검색 버튼을 눌렀을 때 카운터를 1 증가시키려면 아래처럼 작성합니다.

search_key <- reactiveVal(0)

observeEvent(input$searchBtn, {
  search_key(isolate(search_key()) + 1)
})

여기서 isolate(search_key())를 쓰는 이유는, search_key()를 읽는 행위가 이 observeEvent()를 다시 트리거하는 원인이 되지 않게 하려는 것입니다.


7. 1단계: 검색 세대 카운터 만들기

이제 실제 코드로 들어가겠습니다. 가장 먼저 할 일은 검색 세대를 추적하는 것입니다.

server <- function(input, output, session) {

  search_key <- reactiveVal(0)

  observeEvent(input$searchBtn, {
    search_key(isolate(search_key()) + 1)
  })

}

이 코드는 아래처럼 동작합니다.

  • 앱 시작 시 search_key()0
  • 첫 검색 후 1
  • 두 번째 검색 후 2
  • 세 번째 검색 후 3

즉, 검색이 일어날 때마다 세대 번호가 증가합니다.

7.1 왜 숫자 하나가 중요한가

이 숫자는 단순한 카운터가 아니라, 이후 동적 inputId를 만드는 기준이 됩니다. 다시 말해, 이 카운터가 있어야 “이번 검색에서 생성된 입력창들”을 한 덩어리로 구분할 수 있습니다.


8. 2단계: 동적 inputId 생성 함수 만들기

세대 카운터가 있으면 기본 ID에 이 값을 붙여 새 ID를 만들 수 있습니다.

make_id <- function(base) {
  paste0(base, "__", search_key())
}

예를 들면:

make_id("text_A001")

은 아래처럼 바뀝니다.

검색 세대 결과
1 text_A001__1
2 text_A001__2
3 text_A001__3

이제 같은 셀이라도 검색이 바뀌면 다른 주소를 갖게 됩니다.


9. 3단계: reactable 셀 입력에 동적 ID 적용하기

이제 실제 표 셀에 있는 입력 위젯에 이 함수를 적용합니다.

고정 ID를 쓰는 방식은 다음과 같습니다.

점수 = colDef(
  align = "center",
  width = 100,
  cell = reactable.extras::text_extra(
    id = "text_A001",
    class = "table-text-number"
  )
)

이것을 아래처럼 바꿉니다.

점수 = colDef(
  align = "center",
  width = 100,
  cell = reactable.extras::text_extra(
    id = make_id("text_A001"),
    class = "table-text-number"
  )
)

다른 열도 같은 방식으로 적용합니다.

전문기관검토 = colDef(
  align = "center",
  width = 100,
  cell = reactable.extras::text_extra(
    id = make_id("text_AK001"),
    class = "table-text-number"
  )
)

9.1 여기서 가장 중요한 실무 포인트

이 패턴을 도입했다면 관련 셀 전부에 빠짐없이 적용해야 합니다.

예를 들어 A 계열만 바꾸고 B, C 계열은 예전 고정 ID를 그대로 두면:

  • 어떤 셀은 정상 작동
  • 어떤 셀은 예전 값이 남음
  • 어떤 셀은 이벤트가 꼬임

같은 혼합 상태가 발생할 수 있습니다.

즉, 동적 ID는 부분 도입보다 일괄 도입이 훨씬 안전합니다.


10. 4단계: observeEvent()도 새 ID를 기준으로 다시 등록하기

입력 ID가 바뀌었는데, 서버가 여전히 고정 ID를 감시하면 문제가 해결되지 않습니다.

예를 들어 아래 코드는 더 이상 맞지 않습니다.

observeEvent(input$text_A001, {
  # 처리 로직
})

왜냐하면 실제 입력 ID는 text_A001__1, text_A001__2처럼 계속 바뀌기 때문입니다.

이 문제를 해결하려면 검색 세대가 바뀔 때마다, 이번 세대의 실제 ID를 감시하는 observeEvent()를 새로 등록해야 합니다.


11. 중복 코드를 줄이는 핵심: register_editor() 헬퍼 함수

동적 입력이 여러 개라면 일일이 관찰자를 작성하면 코드가 급격히 길어집니다. 이때 공용 등록 함수를 만들어 두면 구조가 정리됩니다.

register_editor <- function(id_base, df_fn, store_fn) {
  observeEvent(search_key(), {
    local_id <- make_id(id_base)

    observeEvent(input[[local_id]], {
      values <- input[[local_id]]
      req(values)

      if (!is.na(values$value[1]) &&
          values$value[1] != "" &&
          is.na(suppressWarnings(as.numeric(values$value[1])))) {
        showModal(modalDialog(
          title = "입력 오류",
          "점수 항목에는 숫자만 입력할 수 있습니다.",
          easyClose = TRUE,
          footer = NULL
        ))
        return()
      }

      df <- df_fn()
      data <- store_fn()

      data$rows <- c(data$rows, df$sheet_row[values$row[1]])
      data$numbers <- c(data$numbers, values$value[1])

      store_fn(data)

    }, ignoreInit = TRUE)
  }, ignoreInit = TRUE)
}

11.1 이 함수가 실제로 하는 일

이 함수는 두 단계의 관찰자를 중첩해서 만듭니다.

첫 번째 관찰자

observeEvent(search_key(), { ... })

이 부분은 검색 세대가 바뀔 때마다 실행됩니다. 즉, 새로운 검색 결과가 생겼다는 뜻입니다.

두 번째 관찰자

observeEvent(input[[local_id]], { ... })

이 부분은 이번 검색에서 새로 만들어진 실제 입력 ID만 감시합니다.

즉, 구조를 풀어 쓰면 아래와 같습니다.

  1. 검색 버튼을 눌렀다.
  2. search_key()가 1 증가했다.
  3. 이번 세대용 실제 ID를 계산했다.
  4. 그 실제 ID를 감시하는 관찰자를 등록했다.

이렇게 하면 새 테이블과 새 이벤트가 정확히 짝지어집니다.


12. register_editor() 호출은 어떻게 하는가

헬퍼 함수를 만들었으면 각 입력 그룹에 대해 등록만 해주면 됩니다.

register_editor("text_A001", equipment_df_A001, equipment_numbers_A)
register_editor("text_AK001", equipment_df_A001, equipment_numbers_A)

register_editor("text_B001", equipment_df_B001, equipment_numbers_B)
register_editor("text_BK001", equipment_df_B001, equipment_numbers_B)

이 구조의 장점은 다음과 같습니다.

  • 같은 패턴의 observeEvent()를 반복해서 작성하지 않아도 된다
  • 검증 로직과 저장 로직을 한 군데에서 통일할 수 있다
  • 나중에 에디터가 늘어나도 호출만 추가하면 된다

13. 숫자 입력 검증은 왜 이렇게 해야 하는가

원문에서도 강조한 부분이지만, 숫자 입력 검증은 단순해 보이면서도 실수가 잦습니다.

아래 검증 코드를 다시 보겠습니다.

if (!is.na(values$value[1]) &&
    values$value[1] != "" &&
    is.na(suppressWarnings(as.numeric(values$value[1])))) {
  showModal(modalDialog(
    title = "입력 오류",
    "점수 항목에는 숫자만 입력할 수 있습니다.",
    easyClose = TRUE,
    footer = NULL
  ))
  return()
}

이 검증이 좋은 이유는 중간 입력값을 괜히 막지 않기 때문입니다.

예를 들어 사용자가 0.1을 입력하는 과정은 실제로 이렇게 진행될 수 있습니다.

  1. 0
  2. 0.
  3. 0.1

이때 0.은 완성된 숫자는 아니지만, 사용자가 정상적으로 소수점을 입력하는 과정입니다. 이런 중간 상태까지 모두 오류로 처리하면 입력 UX가 매우 나빠집니다.

즉, 실무에서는 아래 원칙이 중요합니다.

  • 숫자로 완전히 변환 불가능한 문자열만 막기
  • 사용자가 숫자를 입력하는 중간 과정은 허용하기

14. 저장 시에는 “마지막 값만 남기기”가 중요하다

동적 입력 셀에서는 사용자가 한 셀을 여러 번 수정할 수 있습니다. 이 경우 임시 저장소에는 같은 행의 값이 여러 번 쌓일 수 있습니다.

예를 들어:

sheet_row value
101 3
101 4
101 5

이런 식으로 누적되면, 실제 저장 시에는 마지막 값인 5만 남겨야 합니다.

예시는 다음과 같습니다.

library(dplyr)

final_values <- raw_values %>%
  group_by(sheet_row) %>%
  slice_tail(n = 1) %>%
  ungroup()

이 방식은 “입력 과정은 모두 허용하되, 최종 저장은 마지막 상태만 반영”하는 실무형 패턴입니다.


15. JavaScript 리바인딩은 언제 필요한가

동적 ID만으로도 대부분의 문제는 해결됩니다. 하지만 경우에 따라 UI 일부만 정밀하게 다시 바인딩해야 할 때가 있습니다.

Shiny는 페이지를 스캔하면서 입력 요소를 서버와 연결하는 bind 과정을 수행합니다. 동적 렌더링이 반복되면 새로 생긴 DOM 요소가 아직 바인딩되지 않았거나, 반대로 예전 바인딩이 남아 있을 수 있습니다.

이때 사용하는 것이 다음 함수들입니다.

  • Shiny.unbindAll(el)
  • Shiny.bindAll(el)

중요한 점은 문서 전체가 아니라 특정 영역(el)만 대상으로 해야 한다는 점입니다.


16. 가장 안정적인 JS 패턴: shiny:value 이벤트 훅

아래 방식은 특정 출력 영역이 갱신될 때만 그 부분을 다시 바인딩하는 패턴입니다.

(function() {
  if (!window.jQuery) return;

  var targets = new Set([
    "tableOutput_A001",
    "tableOutput_A002"
  ]);

  var timer = null;

  function rebindScoped(el) {
    if (!window.Shiny || !el) return;

    clearTimeout(timer);
    timer = setTimeout(function() {
      Shiny.unbindAll(el);
      Shiny.bindAll(el);
    }, 30);
  }

  $(document).on("shiny:value", function(ev) {
    var el = ev.target;
    var id = el && el.id;

    if (targets.has(id)) {
      rebindScoped(el);
    }
  });
})();

16.1 왜 이 코드가 안전한가

  • 전체 페이지를 건드리지 않는다
  • 특정 outputId만 다시 바인딩한다
  • setTimeout으로 디바운싱해 짧은 시간 안의 중복 호출을 줄인다

즉, 정밀하고 과한 부작용이 적습니다.

16.2 왜 Shiny.unbindAll() 전체 호출은 위험한가

아래처럼 대상 요소 없이 호출하면:

Shiny.unbindAll();
Shiny.bindAll();

사이드바 버튼, 검색 필터, 다른 입력창까지 모두 다시 바인딩 대상이 됩니다. 그 결과:

  • 전혀 상관없는 위젯이 잠깐 끊겼다가 다시 붙을 수 있고
  • 사용자가 입력하던 흐름이 흔들릴 수 있으며
  • 디버깅이 어려워집니다

따라서 리바인딩은 항상 스코프 한정이 핵심입니다.


17. htmlwidgets::onRender()sendCustomMessage()는 언제 쓰는가

17.1 onRender()는 초기 렌더 직후 한 번 개입할 때 유용하다

library(htmlwidgets)

output$tableInput_A001 <- renderReactable({
  reactable(...) %>%
    onRender("function(el, x){
      if (window.Shiny) {
        Shiny.unbindAll(el);
        Shiny.bindAll(el);
      }
    }")
})

이 방식은 위젯이 렌더링된 직후 해당 영역만 후처리하고 싶을 때 적합합니다.

17.2 sendCustomMessage()는 서버가 클라이언트에 직접 지시할 때 좋다

session$sendCustomMessage("rebindOne", list(id = "tableInput_A001"))
Shiny.addCustomMessageHandler("rebindOne", function(data) {
  var el = document.getElementById(data.id);
  if (el && window.Shiny) {
    Shiny.unbindAll(el);
    Shiny.bindAll(el);
  }
});

이 방식은 서버가 특정 시점에 “지금 이 부분만 다시 바인딩해라”라고 명시적으로 지시할 수 있다는 점에서 제어력이 높습니다.


18. Shiny.setInputValue() 방식은 대안이 될 수 있는가

가능합니다. 그리고 경우에 따라 상당히 우아합니다.

핵심 아이디어는 이렇습니다.

  • Shiny 기본 입력 위젯을 쓰지 않는다
  • 셀 안에 순수 HTML <input>을 직접 렌더링한다
  • JavaScript 이벤트로 값을 감지한다
  • Shiny.setInputValue()로 서버에 전송한다
  • 서버는 단 하나의 고정 입력 input$cell_edited만 감시한다

예시는 다음과 같습니다.

점수 = colDef(
  align = "center",
  width = 100,
  html = TRUE,
  cell = function(value, index) {
    htmltools::tags$input(
      type = "text",
      class = "custom-cell-input",
      `data-row` = index,
      `data-col` = "A001",
      value = value,
      onchange = "Shiny.setInputValue('cell_edited', { row: this.dataset.row, col: this.dataset.col, value: this.value }, { priority: 'event' });"
    )
  }
)

서버는 이렇게 단순해집니다.

observeEvent(input$cell_edited, {
  values <- input$cell_edited
  req(values)

  row_index <- as.numeric(values$row)
  col_id <- values$col
  new_value <- values$value

  # 검증 및 저장 로직
})

18.1 이 방식의 장점

  • 서버 관찰자가 하나로 줄어든다
  • 동적 ID 관리 부담이 줄어든다
  • 바인딩 문제를 다른 방식으로 우회할 수 있다

18.2 이 방식의 단점

  • JavaScript 의존성이 커진다
  • 디버깅 지점이 R과 JS로 나뉜다
  • 팀이 R 중심이라면 유지보수 난도가 올라간다

즉, 기술적으로는 매우 좋은 대안이지만, R 중심 Shiny 프로젝트에서 항상 첫 선택이 되는 것은 아닙니다.


19. 어떤 방식을 선택해야 하는가

아래처럼 정리할 수 있습니다.

기준 동적 inputId 방식 Shiny.setInputValue() 방식
R 중심 개발 매우 적합 다소 부담
JS 활용도 거의 없음 높음
서버 코드 단순성 중간 높음
유지보수성 R 팀에 유리 JS 숙련도 필요
Shiny-native 느낌 강함 약간 우회적

실무적으로는 보통 아래처럼 판단하면 됩니다.

  • R 중심 프로젝트라면: 동적 inputId + 재바인딩 방식
  • JS 연동에 익숙한 팀이라면: Shiny.setInputValue()도 적극 검토 가능

20. 멀티세션 환경에서는 안전한가

대부분의 경우 안전합니다.

Shiny는 사용자 브라우저 탭마다 별도 세션을 만들기 때문에:

  • 한 사용자의 search_key는 다른 사용자 세션과 섞이지 않고
  • input, output, observeEvent도 세션 단위로 분리됩니다

즉, 동적 ID 자체는 멀티세션 문제를 만들지 않습니다.

20.1 진짜 조심해야 하는 것은 공유 자원이다

문제는 세션이 아니라 아래 같은 공유 자원입니다.

  • 전역 변수
  • 공용 파일
  • 여러 사용자가 동시에 수정하는 데이터베이스 레코드

예를 들어 저장 버튼을 누를 때 모두 같은 테이블을 수정한다면, 마지막 저장만 남는 문제가 생길 수 있습니다. 이런 경우에는 아래 같은 방식을 고려해야 합니다.

  • updated_at 타임스탬프 비교
  • 버전 번호 기반 검증
  • 낙관적 잠금 패턴

즉, 동적 inputId는 세션 충돌 문제라기보다 UI 입력 상태 충돌 문제를 해결하는 도구입니다.


21. 단계별 적용 체크리스트

아래 체크리스트대로 적용하면 구조를 놓치지 않을 수 있습니다.

  • search_key <- reactiveVal(0)를 추가했다
  • 검색 버튼에서 search_key(isolate(search_key()) + 1)를 호출했다
  • make_id() 함수를 만들었다
  • 모든 동적 셀 입력 위젯에 make_id("기본ID")를 적용했다
  • 기존 고정 ID 기반 observeEvent(input$text_xxx, ...)를 제거했다
  • register_editor() 같은 헬퍼 함수로 관찰자 등록을 공통화했다
  • 숫자 검증 로직에서 중간 입력값을 과도하게 차단하지 않도록 했다
  • 최종 저장 시 셀별 마지막 값만 반영하도록 했다
  • JS 리바인딩이 필요하다면 Shiny.unbindAll(el)처럼 스코프를 제한했다

22. 최소 동작 예제로 다시 정리하기

아래 코드는 핵심 아이디어만 압축한 미니 버전입니다.

library(shiny)

ui <- fluidPage(
  actionButton("searchBtn", "검색"),
  verbatimTextOutput("current_id")
)

server <- function(input, output, session) {
  search_key <- reactiveVal(0)

  observeEvent(input$searchBtn, {
    search_key(isolate(search_key()) + 1)
  })

  make_id <- function(base) {
    paste0(base, "__", search_key())
  }

  observeEvent(search_key(), {
    local_id <- make_id("text_A001")
    message("이번 검색의 inputId: ", local_id)
  }, ignoreInit = TRUE)

  output$current_id <- renderPrint({
    make_id("text_A001")
  })
}

shinyApp(ui, server)

이 예제를 먼저 이해한 뒤, reactable 셀 입력과 register_editor() 구조를 덧붙이면 전체 구조를 훨씬 쉽게 받아들일 수 있습니다.


23. 자주 묻는 질문

Q1. 동적 inputId를 쓰면 관찰자가 계속 쌓여 메모리 문제가 생기지 않나

일반적인 Shiny 사용 패턴에서는 큰 문제가 되는 경우가 드뭅니다. 다만 검색 횟수가 매우 많고 구조가 복잡하다면 설계를 더 정교하게 다듬을 필요는 있습니다. 대부분은 안정성 측면에서 얻는 이점이 훨씬 큽니다.

Q2. 꼭 JavaScript 리바인딩까지 해야 하나

아닙니다. 먼저 동적 inputId와 동적 관찰자 등록만으로 해결되는지 확인하는 것이 좋습니다. JS는 정말 필요할 때만 추가하는 편이 유지보수에 유리합니다.

Q3. observeEvent() 안에서 search_key()를 그냥 읽으면 안 되나

가능은 하지만, 의도치 않은 반응성 연결이 생길 수 있습니다. 여기서는 isolate()를 써서 “현재 값만 읽는다”는 의도를 분명히 하는 편이 안전합니다.

Q4. Shiny.setInputValue() 방식이 더 최신인가

최신이라기보다 다른 축의 해결책입니다. JS를 잘 다루는 팀에게는 더 깔끔할 수 있지만, R 중심 프로젝트에서는 동적 inputId 방식이 여전히 매우 현실적이고 좋은 해법입니다.


24. 마무리

Shiny에서 동적 테이블 입력이 꼬이는 이유는 복잡한 라이브러리 때문이 아니라, 결국 입력 위젯의 주소를 재사용했기 때문입니다. 이 원인만 정확히 이해하면 해결책도 비교적 명확해집니다.

가장 실무적인 해법은 검색 세대를 추적하고, 그 세대에 맞춰 동적 inputId를 만들고, 그 입력을 감시하는 관찰자도 새로 등록하는 구조입니다. 여기에 필요한 경우에만 범위를 한정한 리바인딩이나 Shiny.setInputValue()를 조합하면 됩니다.

핵심은 기술을 많이 쓰는 것이 아니라, 어느 시점에 어떤 입력이 생성되었고 서버가 지금 무엇을 감시해야 하는지 명확하게 분리하는 것입니다. 이 원칙만 지키면 복잡한 동적 UI도 훨씬 안정적으로 운영할 수 있습니다.

공식 문서 및 참고 링크

동적 inputId 문제는 구현 세부사항이 많아서, 실제 앱을 만들 때는 Shiny와 표 패키지의 공식 문서를 같이 보는 편이 안정적입니다. 특히 입력값 전달 방식과 커스텀 셀 렌더링 규칙은 문서 원문이 가장 정확합니다.

함께 읽으면 좋은 글