플러터 바텀시트 setState가 반응하지 않을 때

플러터(Flutter)로 미려하고 반응성이 뛰어난 애플리케이션을 개발하는 과정은 분명 즐거운 경험입니다. 특히 화면 하단에서 부드럽게 나타나 사용자에게 추가적인 옵션이나 정보를 제공하는 showModalBottomSheet 위젯은 사용자 경험(UX)을 극적으로 향상시키는 강력한 도구 중 하나입니다. 필터링 옵션, 이미지 선택, 간단한 확인 메시지 등 그 활용도는 무궁무진합니다. 하지만 이 강력한 도구를 사용해 본 개발자라면 거의 예외 없이 통과의례처럼 좌절의 순간을 맞이하게 됩니다. 바로 바텀 시트 내부의 UI가 그 어떤 상호작용에도 꿈쩍하지 않는 현상입니다.

분명히 바텀 시트의 내용을 구성하는 위젯을 StatefulWidget으로 만들었고, 버튼을 탭할 때마다 상태 변수를 변경한 뒤 `setState`를 호출하는, 플러터 상태 관리의 가장 기본적인 공식을 충실히 따랐습니다. 하지만 기대와는 달리 아이콘은 바뀌지 않고, 텍스트는 그대로이며, 목록의 선택 상태도 갱신되지 않습니다. 개발자는 자신의 머리를 쥐어뜯으며 의심의 늪에 빠지기 시작합니다. "내가 뭘 잘못했지? 오타가 있나? 혹시 플러터 프레임워크 자체의 버그는 아닐까?" 특히 Provider, Riverpod, BLoC과 같은 복잡한 상태 관리 라이브러리를 사용하고 있다면, 문제의 원인은 더욱 찾기 힘든 미궁 속으로 사라지는 것처럼 느껴집니다.

만약 당신이 이와 같은 답답함과 씨름하며 수많은 Stack Overflow 페이지를 전전했다면, 이제 그 고통스러운 여정을 끝낼 시간입니다. 이 글은 단순한 해결책 코드 몇 줄을 던져주는 것을 넘어, `showModalBottomSheet`에서 `setState`가 왜 무력해지는지에 대한 근본적인 원인을 플러터의 심장부인 BuildContext와 렌더링 메커니즘 수준에서 해부할 것입니다. 그리고 이 문제를 해결하기 위한 가장 실용적이고 강력한 두 가지 접근법, 즉 StatefulBuilder를 활용한 빠르고 국소적인 상태 관리Provider와 같은 상태 관리 솔루션을 이용한 구조적이고 확장 가능한 상태 연동 방법을, 당장 당신의 프로젝트에 적용할 수 있을 만큼 상세하고 친절한 코드 예제와 함께 제시할 것입니다. 이 글을 끝까지 읽고 나면, 당신은 더 이상 바텀 시트의 변덕에 휘둘리지 않고 그 동작을 완벽하게 제어할 수 있는 자신감을 얻게 될 것입니다.

이 글에서 다룰 내용:
  • setState가 바텀 시트에서 실패하는 진짜 이유: BuildContextNavigator, Overlay의 삼각관계 심층 분석
  • 가장 빠르고 간단한 해결책: StatefulBuilder의 작동 원리와 실전 사용법, 그리고 주의할 점
  • 구조적이고 확장 가능한 해결책: Provider를 이용해 바텀 시트와 앱 전체의 상태를 연동하는 아키텍처
  • StatefulBuilder vs. Provider: 어떤 상황에서 어떤 무기를 선택해야 하는가에 대한 명확한 가이드라인
  • 보너스: 순수 플러터 기능으로 구현하는 또 다른 대안, ValueNotifierValueListenableBuilder

문제의 근원: 위젯 트리와 분리된 BuildContext의 비밀

모든 문제 해결의 시작은 정확한 원인 규명입니다. showModalBottomSheet 내부에서 상태 갱신이 실패하는 현상의 주범은 바로 플러터의 모든 것을 관장하는 핵심 개념, BuildContext입니다. 많은 개발자들이 `context`를 단순히 위젯을 빌드할 때 필요한 '마법의 지팡이' 정도로 생각하지만, 그 실체는 훨씬 더 구체적이고 중요합니다. BuildContext는 거대하고 복잡한 위젯 트리 내에서 **현재 위젯의 정확한 위치와 신원을 증명하는 '고유 주소'**와 같습니다.

우리가 `Theme.of(context)`로 앱의 테마 색상에 접근하거나, `Navigator.of(context).pop()`으로 현재 화면을 닫거나, `Provider.of(context)`로 상위 위젯이 제공하는 데이터에 접근하는 모든 작업은 이 '주소'를 통해 이루어집니다. `context`는 위젯 트리 상에서 자신의 부모, 조상 위젯들을 찾아 올라가는 이정표 역할을 수행합니다. 그리고 우리가 `StatefulWidget`에서 `setState`를 호출할 때, 플러터 프레임워크는 바로 이 `context`를 사용하여 "이 주소에 해당하는 위젯과 그 자식들을 다시 그려주세요(rebuild)!"라는 명령을 내리게 됩니다.

그렇다면 이 평범한 메커니즘이 왜 showModalBottomSheet와 만나기만 하면 문제를 일으키는 걸까요? 바로 showModalBottomSheet가 위젯을 화면에 표시하는 방식의 특수성 때문입니다.

새로운 세계의 창조: Navigator와 Overlay

우리가 `showModalBottomSheet` 함수를 호출하는 순간, 플러터는 단순히 현재 페이지(예: `Scaffold`)의 자식으로 바텀 시트 위젯을 추가하는 것이 아닙니다. 대신, 훨씬 더 복잡하고 독립적인 프로세스를 거칩니다.

  1. `Navigator` 호출: `showModalBottomSheet`는 내부적으로 `Navigator.of(context).push(...)`와 유사한 동작을 수행합니다. 즉, 새로운 '경로(Route)'를 네비게이션 스택에 쌓는 행위입니다. 우리가 `Navigator.push`로 새 페이지를 띄우는 것과 본질적으로 같습니다.
  2. `Overlay` 생성: 이 새로운 라우트는 현재의 위젯 트리 위에 완전히 독립적인 시각적 레이어인 '오버레이(Overlay)'에 그려집니다. 오버레이는 앱의 다른 UI 요소들 위에 자유롭게 위젯을 띄울 수 있게 해주는 특수한 스택(Stack)과 같습니다. 플로팅 액션 버튼, 다이얼로그, 툴팁, 그리고 바로 이 바텀 시트가 모두 오버레이를 통해 구현됩니다.
  3. 독립적인 위젯 트리 구축: 생성된 오버레이 위에, 우리가 `builder` 속성을 통해 전달한 위젯들이 완전히 새로운 위젯 트리를 구성하며 그려집니다.

이 과정의 핵심은 바텀 시트가 기존 페이지와는 다른, 완전히 독립된 자신만의 `BuildContext`를 가진다는 점입니다. 이를 현실 세계에 비유하면 이해가 쉽습니다.

본가와 파티룸 비유

  • 기존 페이지 (여러분의 `StatefulWidget`이 있는 화면): 이것은 여러분의 '본가(本家)'입니다. 본가는 `대한민국 서울시 강남구`라는 고유한 주소 체계(BuildContext A)를 가지고 있습니다.
  • showModalBottomSheet로 생성된 바텀 시트: 여러분이 친구들과 파티를 하기 위해 잠시 빌린 '파티룸'입니다. 이 파티룸은 본가 위에 떠 있는 것처럼 보이지만, 실제로는 `대한민국 서울시 마포구`라는, 본가와는 전혀 다른 별개의 주소 체계(BuildContext B)를 가집니다.

이제, 당신이 '본가'에서 "집 전체 리모델링 시작!"이라고 외치며 `setState`를 호출했다고 상상해 보세요. 이 명령은 '본가'의 주소(BuildContext A)가 관할하는 모든 공간(벽지, 가구, 조명 등)을 새롭게 단장시킬 것입니다. 하지만 이 리모델링 명령이 수 킬로미터 떨어진 별개의 주소 체계를 가진 '파티룸'(BuildContext B)의 인테리어에 영향을 미칠 수 있을까요? 당연히 불가능합니다. 파티룸은 본가의 리모델링 소식을 들을 수도 없고, 알 필요도 없습니다.

이것이 바로 `showModalBottomSheet`에서 `setState`가 실패하는 이유를 정확히 설명합니다. 바텀 시트를 호출한 페이지의 `setState`는 `BuildContext` A를 가진 위젯 트리의 재구축(rebuild)을 유발할 뿐, 완전히 다른 라우트와 다른 `BuildContext` B를 가진 바텀 시트에게는 어떠한 신호도 전달하지 못합니다. 바텀 시트는 자신이 갱신되어야 한다는 사실을 전혀 인지하지 못하고, 처음 렌더링된 모습 그대로 멈춰 있게 되는 것입니다.

이 근본적인 원리를 이해했다면, 해결책은 명확해집니다. '본가'에서 소리치는 것을 멈추고, '파티룸' 내부에서 자체적으로 인테리어를 바꿀 수 있는 메커니즘을 마련해주면 됩니다.

첫 번째 처방전: StatefulBuilder를 이용한 신속하고 국소적인 해결

문제의 원인을 파악했으니, 가장 빠르고 직관적인 해결책부터 살펴보겠습니다. 바로 플러터 SDK에 기본적으로 내장된 StatefulBuilder 위젯을 사용하는 것입니다. 이 위젯은 이름 그대로, 별도의 StatefulWidget 클래스를 생성하는 번거로운 과정 없이 위젯 트리의 특정 부분에 '상태(state)'를 주입하고 관리할 수 있게 해주는 매우 유용한 도구입니다.

StatefulBuildershowModalBottomSheet처럼 고립된 BuildContext를 가진 환경에서 국소적인 상태를 관리하는 데 최적화되어 있습니다. 이 위젯의 핵심은 `builder` 속성입니다. 이 빌더는 (BuildContext context, StateSetter setState)라는 두 개의 매개변수를 제공하는데, 여기서 두 번째 매개변수인 setState가 바로 우리가 찾던 '파티룸 전용 리모델링 스위치'입니다. 이 특별한 `setState` 함수를 호출하면, 페이지 전체가 아닌 **오직 StatefulBuilder가 감싸고 있는 위젯 트리만** 정밀하게 다시 빌드됩니다.

StatefulBuilder 실전 코드 예제

백문이 불여일견입니다. 아래는 바텀 시트 내부에 있는 버튼을 클릭할 때마다 아이콘과 텍스트가 토글되는 간단한 예제입니다. StatefulBuilder를 사용하면 이 기능이 얼마나 간결하게 구현되는지 직접 확인해 보세요.


import 'package:flutter/material.dart';

class BottomSheetExamplePage extends StatelessWidget {
  const BottomSheetExamplePage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('StatefulBuilder 예제'),
        backgroundColor: Colors.teal,
      ),
      body: Center(
        child: ElevatedButton(
          style: ElevatedButton.styleFrom(
            backgroundColor: Colors.teal,
            padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 15),
            textStyle: const TextStyle(fontSize: 16),
          ),
          child: const Text('바텀 시트 열기'),
          onPressed: () {
            // 메인 페이지의 context를 전달하여 바텀 시트를 엽니다.
            _openBottomSheetWithStatefulBuilder(context);
          },
        ),
      ),
    );
  }

  void _openBottomSheetWithStatefulBuilder(BuildContext context) {
    showModalBottomSheet<void>(
      context: context,
      shape: const RoundedRectangleBorder(
        borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
      ),
      builder: (BuildContext bottomSheetContext) {
        // [핵심 1] 상태 변수를 `showModalBottomSheet`의 builder 스코프에 선언합니다.
        // 이 위치에 선언해야 `setState`가 호출되어도 값이 초기화되지 않고 유지됩니다.
        bool isToggled = false;
        int counter = 0;

        // [핵심 2] 바텀 시트의 내용을 `StatefulBuilder`로 감쌉니다.
        // 이 위젯이 바텀 시트 내부에 독립적인 상태 관리 영역을 만들어줍니다.
        return StatefulBuilder(
          // [핵심 3] builder는 context와 함께 'StateSetter' 타입의 로컬 setState 함수를 제공합니다.
          // 이 setState는 오직 StatefulBuilder 내부만 다시 그립니다.
          builder: (BuildContext context, StateSetter setState) {
            return Container(
              height: 300,
              padding: const EdgeInsets.all(24.0),
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: <Widget>[
                  Icon(
                    isToggled ? Icons.lightbulb : Icons.lightbulb_outline,
                    size: 80,
                    color: isToggled ? Colors.amberAccent : Colors.grey[700],
                  ),
                  const SizedBox(height: 16),
                  Text(
                    isToggled ? '전구가 켜졌습니다!' : '전구가 꺼져있습니다.',
                    style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
                  ),
                  const SizedBox(height: 8),
                  Text(
                    '버튼 클릭 횟수: $counter',
                    style: TextStyle(fontSize: 16, color: Colors.grey[600]),
                  ),
                  const SizedBox(height: 24),
                  ElevatedButton(
                    style: ElevatedButton.styleFrom(
                      backgroundColor: Colors.teal,
                      padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12)
                    ),
                    child: const Text('전구 상태 변경 및 카운트 증가'),
                    onPressed: () {
                      // [핵심 4] `StatefulBuilder`가 제공한 로컬 setState를 호출합니다.
                      // 페이지의 setState가 아니므로 바텀 시트만 정확히 갱신됩니다.
                      setState(() {
                        isToggled = !isToggled;
                        counter++;
                      });
                    },
                  ),
                ],
              ),
            );
          },
        );
      },
    );
  }
}

StatefulBuilder 사용법 심층 분석 및 주의사항

  1. 상태 변수의 정확한 위치: 예제 코드의 `[핵심 1]`에서 보듯이, 상태를 저장하는 변수들(isToggled, counter)은 StatefulBuilder의 `builder` 함수 바깥, 그리고 showModalBottomSheet의 `builder` 함수 안쪽에 선언되어야 합니다. 만약 이 변수들을 StatefulBuilderbuilder 내부에 선언하면, setState가 호출되어 위젯이 다시 빌드될 때마다 변수가 초기값으로 계속 초기화되어 상태 변경이 일어나지 않는 것처럼 보이게 됩니다. 이는 매우 흔한 실수이므로 반드시 기억해야 합니다.
  2. StateSetter setState의 역할: [핵심 4]에서 사용된 setState는 페이지 전체를 다시 그리는 일반적인 setState가 아닙니다. 이것은 `StatefulBuilder` 위젯이 내부적으로 관리하는 상태 객체에 연결된 특별한 함수로, 오직 자신을 포함한 자식 위젯들만 다시 그리라는 명령을 내립니다. 따라서 성능 저하 없이 바텀 시트의 UI만 효율적으로 갱신할 수 있습니다.
  3. 최적의 사용 시점: 이 방법은 바텀 시트 내부의 상태가 다른 위젯이나 앱의 전반적인 상태와 연동될 필요가 없을 때, 즉 완전히 독립적이고 국소적일 때 가장 이상적입니다. 간단한 UI 토글, 카운터, 임시적인 텍스트 입력 폼, 필터 옵션의 임시 선택 등 바텀 시트가 닫히면 사라져도 되는 일시적인 상태를 다룰 때 매우 빠르고 효율적인 해결책입니다.
  4. 한계점: 만약 바텀 시트에서 선택한 값이 바텀 시트가 닫힌 후에도 메인 화면에 반영되어야 한다면, StatefulBuilder만으로는 부족합니다. 상태를 외부로 전달하기 위한 콜백 함수 등을 추가로 구현해야 하므로 코드가 복잡해질 수 있습니다. 이러한 경우에는 다음에 소개할 상태 관리 라이브러리를 사용하는 것이 더 나은 선택입니다.

두 번째 처방전: Provider로 완성하는 구조적이고 확장 가능한 해결

만약 여러분의 애플리케이션이 이미 Provider, Riverpod, BLoC과 같은 상태 관리 라이브러리를 사용하고 있거나, 바텀 시트에서의 상호작용이 앱의 다른 부분에도 영향을 미쳐야 하는 복잡한 시나리오를 다루고 있다면, 문제는 좀 더 구조적인 차원에서 접근해야 합니다. 예를 들어, 바텀 시트에서 '다크 모드'를 활성화하면 앱 전체의 테마가 즉시 변경되어야 하거나, 필터 옵션을 선택하면 메인 화면의 상품 목록이 실시간으로 갱신되어야 하는 경우가 여기에 해당합니다.

이런 시나리오에서는 상태를 바텀 시트 내부에 가두는 StatefulBuilder 방식보다, 앱 전역에서 접근 가능한 상태 객체를 Provider를 통해 연동하는 것이 훨씬 더 강력하고 올바른 아키텍처입니다. 핵심 원리는 간단합니다. 상태 관리 객체(`ChangeNotifier` 등)를 앱의 최상단, 즉 바텀 시트를 띄우는 페이지보다 더 높은 곳에 위치시키는 것입니다. 이렇게 하면, showModalBottomSheet가 자신만의 독립적인 `BuildContext`를 가지고 생성되더라도, 위젯 트리를 거슬러 올라가 상위에 존재하는 상태 객체에 얼마든지 접근할 수 있습니다.

상태의 변화를 '구독(listen)'하고 있다가 변경이 발생하면 UI를 자동으로 다시 빌드하게 만드는 Consumer 위젯이나 context.watch 확장 메서드를 사용하면 이 문제를 매우 우아하고 효율적으로 해결할 수 있습니다.

ProviderChangeNotifier를 활용한 실전 예제

먼저, 앱의 상태와 비즈니스 로직을 관리할 `ChangeNotifier` 클래스를 정의합니다. 여기서는 앱의 테마 모드(라이트/다크)를 관리하는 `ThemeProvider`를 예로 들어보겠습니다.


// 1. 상태 관리 로직을 담을 ChangeNotifier 클래스 생성
import 'package:flutter/material.dart';

// 앱의 테마 상태를 관리하는 클래스
class ThemeProvider with ChangeNotifier {
  ThemeMode _themeMode = ThemeMode.light;

  ThemeMode get themeMode => _themeMode;
  bool get isDarkMode => _themeMode == ThemeMode.dark;

  void toggleTheme() {
    _themeMode = isDarkMode ? ThemeMode.light : ThemeMode.dark;
    // notifyListeners()를 호출하여 이 ChangeNotifier를 구독(listen)하는
    // 모든 위젯에게 상태 변경 사실을 알리고 리빌드를 요청합니다.
    notifyListeners();
  }
}

다음으로, 앱의 진입점인 `main.dart` 파일에서 `ChangeNotifierProvider`를 사용하여 위에서 만든 `ThemeProvider` 인스턴스를 앱 전체에 제공합니다.


// 2. main.dart 파일에서 Provider 설정
import 'package:flutter/material.dart';
import 'package.provider/provider.dart';

// (위에서 정의한 ThemeProvider 클래스와 예제 페이지 클래스가 있다고 가정)
void main() {
  runApp(
    // ChangeNotifierProvider로 앱의 최상단을 감싸서
    // 하위 모든 위젯들이 ThemeProvider 인스턴스에 접근할 수 있도록 합니다.
    ChangeNotifierProvider(
      create: (context) => ThemeProvider(),
      child: const MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    // Consumer를 사용해 ThemeProvider의 변경사항을 구독하고 MaterialApp에 반영
    return Consumer<ThemeProvider>(
      builder: (context, themeProvider, child) {
        return MaterialApp(
          title: 'Provider BottomSheet Demo',
          theme: ThemeData.light(),
          darkTheme: ThemeData.dark(),
          themeMode: themeProvider.themeMode, // provider의 상태에 따라 테마 결정
          home: const ProviderBottomSheetPage(),
        );
      },
    );
  }
}

모든 설정이 완료되었습니다. 이제 메인 페이지와 바텀 시트에서 Provider를 사용하여 상태를 읽고 변경하는 방법을 살펴보겠습니다.


class ProviderBottomSheetPage extends StatelessWidget {
  const ProviderBottomSheetPage({super.key});

  @override
  Widget build(BuildContext context) {
    // context.watch를 사용하면 Consumer 위젯 없이도 Provider의 변화를 감지할 수 있습니다.
    final themeProvider = context.watch<ThemeProvider>();

    return Scaffold(
      appBar: AppBar(
        title: Text('Provider 예제 (${themeProvider.isDarkMode ? "다크 모드" : "라이트 모드"})'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(
              themeProvider.isDarkMode ? Icons.nightlight_round : Icons.wb_sunny,
              size: 100,
            ),
            const SizedBox(height: 20),
            ElevatedButton(
              child: const Text('테마 변경 바텀 시트 열기'),
              onPressed: () {
                _openThemeBottomSheet(context);
              },
            ),
          ],
        ),
      ),
    );
  }

  void _openThemeBottomSheet(BuildContext context) {
    showModalBottomSheet<void>(
      context: context,
      // [핵심 1] builder가 제공하는 bottomSheetContext는 새로운 context지만,
      // Provider는 위젯 트리 상위에 있으므로 정상적으로 접근 가능합니다.
      builder: (BuildContext bottomSheetContext) {
        // [핵심 2] 바텀 시트 내부에서도 context.watch/read를 통해 Provider에 접근합니다.
        // Consumer 위젯을 사용해도 동일하게 동작합니다.
        final themeProvider = bottomSheetContext.watch<ThemeProvider>();

        return Container(
          padding: const EdgeInsets.all(20.0),
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: <Widget>[
              const Text('테마 설정', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
              const SizedBox(height: 16),
              SwitchListTile(
                title: const Text('다크 모드'),
                value: themeProvider.isDarkMode,
                onChanged: (bool value) {
                  // [핵심 3] 상태 변경 함수 호출. 여기서는 context.read를 사용합니다.
                  // onChanged 콜백처럼 단순히 함수를 호출하는 곳에서는
                  // 불필요한 리빌드를 방지하기 위해 context.read를 사용하는 것이 권장됩니다.
                  bottomSheetContext.read<ThemeProvider>().toggleTheme();
                },
                secondary: Icon(themeProvider.isDarkMode ? Icons.nightlight_round : Icons.wb_sunny),
              ),
              const SizedBox(height: 10),
              ElevatedButton(
                child: const Text('닫기'),
                onPressed: () => Navigator.pop(bottomSheetContext),
              ),
            ],
          ),
        );
      },
    );
  }
}

Provider 접근 방식의 강력한 장점들

  • 관심사의 완벽한 분리 (Separation of Concerns): UI를 그리는 코드(Widget)와 상태 및 비즈니스 로직을 처리하는 코드(ThemeProvider 클래스)가 명확하게 분리됩니다. 위젯은 오직 상태를 보여주고 사용자 입력을 상태 객체에 전달하는 역할만 수행하므로 코드의 가독성, 재사용성, 유지보수성이 극적으로 향상됩니다.
  • 상태의 중앙 관리 및 단일 진실 공급원 (Single Source of Truth): 바텀 시트에서 변경된 상태가 앱의 다른 모든 부분(AppBar, 메인 화면 아이콘, 심지어 MaterialApp 전체)에 즉시 전파됩니다. 상태가 여러 곳에 흩어져 있지 않고 한 곳(`ThemeProvider`)에서 관리되므로 데이터의 일관성을 보장하기 매우 쉽습니다.
  • 향상된 테스트 용이성: UI와 완전히 분리된 ThemeProvider 클래스는 Flutter 프레임워크에 대한 의존도가 거의 없습니다. 따라서 위젯을 렌더링할 필요 없이 순수한 Dart 코드로 매우 간단하고 빠르게 단위 테스트(Unit Test)를 작성할 수 있습니다.
  • 뛰어난 확장성: 앱의 규모가 커지고 상태 로직이 복잡해져도, Provider 아키텍처는 그 복잡성을 효과적으로 관리할 수 있는 구조를 제공합니다. 새로운 기능이 추가될 때마다 관련된 상태 로직을 해당 Provider에 추가하기만 하면 되므로 확장에 용이합니다.

StatefulBuilder vs. Provider: 당신의 상황에 맞는 최적의 선택

이제 우리는 Flutter의 showModalBottomSheet에서 상태 갱신이 실패하는 이유가 BuildContext의 분리 때문이라는 사실과, 이를 해결하기 위한 두 가지 강력한 무기인 StatefulBuilderProvider를 모두 손에 쥐었습니다. 마지막으로, 어떤 상황에서 어떤 도구를 선택하는 것이 최선인지 명확하게 비교하고 정리해 보겠습니다.

두 해결책 중 하나를 선택하는 기준은 매우 명확합니다. 바로 **"당신이 관리하려는 상태가 바텀 시트라는 '파티룸' 안에서만 유효한가, 아니면 '본가'를 포함한 도시 전체에 영향을 미치는가?"** 라는 질문에 답하는 것입니다.

고려 사항 StatefulBuilder Provider (또는 다른 상태 관리 라이브러리)
핵심 개념 독립적인 위젯의 일부를 감싸 로컬 setState를 제공 위젯 트리 상위에 상태 객체를 제공하고 하위에서 구독/변경
주요 사용 시나리오 상태가 바텀 시트 내에서 완전히 독립적이고 국소적일 때. (예: 간단한 카운터, 체크박스 토글, 임시 텍스트 입력, 닫으면 사라지는 필터 선택) 상태가 앱의 다른 부분과 공유되거나 연동되어야 할 때. (예: 테마 변경, 언어 설정, 사용자 프로필 업데이트, 장바구니 추가)
장점 - 매우 간단하고 빠르다.
- 외부 라이브러리 의존성이 없다.
- 추가적인 클래스나 파일이 필요 없다.
- 보일러플레이트 코드가 거의 없다.
- 코드의 구조화 및 재사용성 향상.
- 명확한 관심사 분리(UI vs Logic).
- 테스트가 용이하다.
- 대규모 애플리케이션에 적합한 확장성.
단점 - 상태를 외부와 공유하기 어렵다.
- 로직이 복잡해지면 UI 코드와 섞여 지저분해질 수 있다.
- 재사용이 어렵고 테스트가 불편하다.
- 초기 설정(Provider, Notifier 클래스 등)이 필요하다.
- 간단한 일회성 기능에는 과하게 느껴질 수 있다(Overkill).
- 외부 라이브러리에 대한 학습이 필요하다.
성능 영향 매우 가볍다. 오직 감싸고 있는 위젯만 리빌드하므로 성능에 거의 영향을 주지 않는다. 잘 사용하면 매우 효율적이다. (context.read, Consumerchild 최적화 등) 잘못 사용하면 불필요한 리빌드를 유발할 수 있다.

결론적으로, showModalBottomSheet를 사용할 때 UI 갱신 문제가 발생하면 가장 먼저 "아, `context`가 달라서 그렇구나!"라고 떠올리는 것이 문제 해결의 절반입니다. 그리고 나서 관리해야 할 상태의 범위와 복잡도를 기준으로, 가볍고 빠른 수술 도구인 StatefulBuilder를 선택할지, 체계적이고 강력한 시스템인 Provider를 선택할지를 결정하면 됩니다.

보너스: 순수 플러터 기능으로 구현하는 또 다른 대안, ValueNotifier

StatefulBuilderProvider 외에도 알아두면 유용한 순수 플러터 기능이 하나 더 있습니다. 바로 ValueNotifierValueListenableBuilder 조합입니다. 이 방법은 StatefulBuilder처럼 간단하면서도, Provider처럼 상태 로직을 UI에서 분리하는 효과를 일부 누릴 수 있는 중간 지점의 해결책입니다.

  • ValueNotifier: 단 하나의 값(value)을 가지고 있으며, 그 값이 변경될 때마다 리스너들에게 알림을 보내는 특별한 ChangeNotifier입니다. 정수, 불리언, 문자열 등 간단한 상태를 관리하기에 적합합니다.
  • ValueListenableBuilder: ValueNotifier를 '구독'하고 있다가, 값이 변경될 때마다 자신의 `builder` 함수를 다시 실행하여 UI를 갱신하는 위젯입니다.

이 조합은 StatefulBuilder의 "상태 변수를 어디에 선언해야 하는지"에 대한 혼란을 없애주고, 상태 로직을 조금 더 명확하게 분리할 수 있다는 장점이 있습니다.

ValueNotifier 실전 코드 예제


void _openBottomSheetWithValueNotifier(BuildContext context) {
  // 1. 상태를 담을 ValueNotifier 인스턴스를 생성합니다. 초기값은 false.
  final ValueNotifier<bool> isToggledNotifier = ValueNotifier<bool>(false);

  showModalBottomSheet<void>(
    context: context,
    builder: (BuildContext bottomSheetContext) {
      // 2. ValueListenableBuilder로 UI 부분을 감쌉니다.
      return ValueListenableBuilder<bool>(
        // 3. 구독할 ValueNotifier를 지정합니다.
        valueListenable: isToggledNotifier,
        // 4. builder는 context, 현재 값(value), 그리고 최적화를 위한 child를 제공합니다.
        builder: (context, isToggled, child) {
          return Container(
            height: 250,
            padding: const EdgeInsets.all(24.0),
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: <Widget>[
                Icon(
                  isToggled ? Icons.check_circle : Icons.check_circle_outline,
                  size: 60,
                  color: isToggled ? Colors.green : Colors.grey,
                ),
                const SizedBox(height: 16),
                Text(
                  isToggled ? '선택되었습니다!' : '선택되지 않았습니다.',
                  style: const TextStyle(fontSize: 18),
                ),
                const SizedBox(height: 24),
                ElevatedButton(
                  child: const Text('상태 토글'),
                  onPressed: () {
                    // 5. ValueNotifier의 .value 속성을 변경하면 자동으로 리스너(Builder)가 갱신됩니다.
                    isToggledNotifier.value = !isToggledNotifier.value;
                  },
                ),
              ],
            ),
          );
        },
      );
    },
  ).whenComplete(() {
    // 6. 바텀 시트가 닫힐 때 Notifier를 dispose하여 메모리 누수를 방지합니다.
    isToggledNotifier.dispose();
  });
}

이 방법은 StatefulBuilder보다 약간의 코드가 더 필요하지만, 상태 객체(isToggledNotifier)와 UI(ValueListenableBuilder)가 명확히 분리되어 더 구조적인 코드를 작성하는 데 도움이 될 수 있습니다. 복잡하지 않은 국소적 상태 관리에 있어 StatefulBuilder의 훌륭한 대안이 될 수 있으니 기억해 두시길 바랍니다.

최종 결론

플러터에서 showModalBottomSheet의 상태 갱신 문제는 모든 개발자가 한 번쯤 마주치는 보편적인 허들입니다. 하지만 오늘 우리는 이 문제의 근본 원인이 '버그'나 '실수'가 아닌, 플러터의 `BuildContext`가 작동하는 논리적이고 예측 가능한 방식 때문임을 명확히 이해했습니다. 별도의 라우트와 오버레이 위에 독립적인 위젯 트리로 생성되기 때문에, 기존 페이지의 `setState` 호출이 전달되지 않는 것은 당연한 결과입니다.

이러한 이해를 바탕으로 우리는 세 가지 효과적인 해결책을 손에 넣었습니다.

  1. StatefulBuilder: 바텀 시트 안에서만 사용되는 일회성, 국소적 상태를 다룰 때 가장 빠르고 간결한 해결사.
  2. Provider (상태 관리 라이브러리): 바텀 시트의 상태가 앱의 다른 부분과 연동되어야 하는 복잡하고 구조적인 문제를 해결하는 가장 강력하고 확장 가능한 아키텍처.
  3. ValueNotifier: StatefulBuilderProvider의 장점을 일부 결합한, 상태와 UI를 분리하는 경량 해결책.

이제 당신은 이 세 가지 도구를 적재적소에 활용하여 어떤 상황에서든 바텀 시트의 상태를 자유자재로 제어할 수 있게 되었습니다. 더 이상 예측 불가능한 UI 때문에 개발 시간을 낭비하거나 스트레스받는 일은 없을 것입니다. 문제의 본질을 꿰뚫어 보는 통찰력과 그것을 해결할 수 있는 다양한 무기를 갖춘 당신은 한 단계 더 성장한 플러터 개발자가 된 것입니다.

Post a Comment