Wednesday, July 22, 2020

Flutter 바텀 시트(BottomSheet) 상태가 갱신되지 않는 현상, 완벽하게 파헤치기

플러터(Flutter)로 애플리케이션을 개발할 때, 사용자 경험을 한 단계 끌어올리는 데 `showModalBottomSheet`만큼 효과적인 위젯도 드뭅니다. 화면 하단에서 스르륵 올라오는 이 인터페이스는 필터 옵션을 제공하거나, 사용자에게 간단한 확인을 받거나, 추가적인 액션을 제시하는 등 다채로운 용도로 활용됩니다. 그러나 이 강력하고 유용한 위젯을 사용해 본 개발자라면 누구나 한 번쯤은 좌절스러운 경험을 하게 됩니다. 바로 바텀 시트 내부의 UI가 전혀 갱신되지 않는 문제입니다.

분명 `StatefulWidget`을 사용했고, 버튼을 누를 때마다 `setState`를 호출하도록 로직을 작성했습니다. 아이콘이 바뀌거나, 텍스트가 변경되거나, 목록의 색상이 변하는 등 당연히 일어나야 할 UI 변경이 감감무소식일 때, 개발자는 깊은 혼란에 빠집니다. "내 코드가 잘못되었나? Flutter 버그인가?" 수많은 의심이 머릿속을 스쳐 지나갑니다. 특히 Provider, Riverpod, BLoC과 같은 상태 관리 솔루션을 사용하고 있을 경우, 이 문제는 더욱 미궁 속으로 빠져드는 것처럼 느껴집니다.

만약 당신이 이런 답답함을 겪어보셨다면, 이 글이 바로 당신을 위한 최종 해결책이 될 것입니다. 이 글에서는 `showModalBottomSheet` 내부에서 `setState`가 작동하지 않는 근본적인 원인인 `BuildContext`의 분리 메커니즘을 해부학 수준으로 깊이 있게 분석합니다. 그리고 이 문제를 해결하는 가장 대표적이고 효과적인 두 가지 방법, 즉 `StatefulBuilder`를 이용한 국소적 상태 관리`Provider`를 활용한 전역 상태 연동에 대해, 당장 복사해서 붙여넣어도 완벽하게 동작하는 상세한 코드 예제와 함께 명쾌하게 설명해 드리겠습니다.

문제의 진단: 왜 내 `setState`는 무시당하는가? (BuildContext 심층 분석)

모든 문제 해결의 첫걸음은 정확한 원인 진단입니다. `showModalBottomSheet`의 상태 갱신 실패 문제의 핵심 용의자는 바로 플러터의 심장과도 같은 개념, `BuildContext`입니다.

많은 개발자들이 `context`를 단순히 위젯을 빌드할 때 필요한 '무언가' 정도로 여기지만, 그 실체는 훨씬 더 중요하고 구체적입니다. `BuildContext`는 거대한 위젯 트리(Widget Tree) 구조 내에서 **현재 위젯의 정확한 위치와 정체성을 담고 있는 '좌표' 또는 '주소'**입니다. 우리가 `Theme.of(context)`를 호출하여 앱의 테마 색상을 가져오거나, `Navigator.of(context).pop()`으로 화면을 닫거나, `Provider.of(context)`로 상위 위젯이 제공하는 데이터에 접근하는 모든 행위는 바로 이 '주소'를 통해 이루어집니다. `context`는 위젯 트리 상에서 자신의 '부모'가 누구인지, '조상'이 누구인지를 찾아가는 이정표 역할을 합니다.

그렇다면 이 `BuildContext`가 `showModalBottomSheet`와 만나면 어떤 일이 벌어질까요? 이것이 바로 문제의 핵심입니다. 우리가 `showModalBottomSheet` 함수를 호출하는 순간, 플러터는 현재 화면(Scaffold)의 위젯 트리에 자식으로 바텀 시트를 추가하는 것이 아닙니다. 대신, 완전히 새로운 '라우트(Route)'를 생성하고, 그 위에 독립적인 오버레이(Overlay) 레이어를 띄운 뒤, 그곳에 바텀 시트 위젯 트리를 구축합니다.

이 개념을 현실 세계에 비유하여 설명해 보겠습니다.

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

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

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

이 근본적인 원리를 이해했다면, 해결책은 명확해집니다. '본가'에서 소리치는 것이 아니라, '파티룸' 내부에서 UI를 변경할 수 있는 장치를 마련해주면 되는 것입니다.

해결책 1: `StatefulBuilder` - 가장 빠르고 직관적인 국소적 해결사

가장 먼저 시도해 볼 수 있는 간단하고 효과적인 해결책은 플러터가 기본적으로 제공하는 `StatefulBuilder` 위젯을 사용하는 것입니다. 이름에서 알 수 있듯이, 이 위젯은 `StatefulWidget` 클래스를 별도로 생성하는 번거로움 없이, 위젯 트리의 특정 부분에 '상태(state)'를 부여하고 관리할 수 있게 해주는 마법 같은 도구입니다.

`StatefulBuilder`는 고립된 `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 예제'),
      ),
      body: Center(
        child: ElevatedButton(
          child: const Text('바텀 시트 열기'),
          onPressed: () {
            _openBottomSheetWithStatefulBuilder(context);
          },
        ),
      ),
    );
  }

  void _openBottomSheetWithStatefulBuilder(BuildContext context) {
    showModalBottomSheet<void>(
      context: context,
      builder: (BuildContext bottomSheetContext) {
        // 1. 상태 변수를 `showModalBottomSheet`의 builder 스코프에 선언합니다.
        // 이렇게 하면 `setState`가 호출되어도 값이 초기화되지 않고 유지됩니다.
        bool isToggled = false;

        // 2. 바텀 시트의 내용을 `StatefulBuilder`로 감쌉니다.
        return StatefulBuilder(
          // 3. builder는 context와 함께 'StateSetter' 타입의 setState 함수를 제공합니다.
          builder: (BuildContext context, StateSetter setState) {
            return Container(
              height: 250,
              padding: const EdgeInsets.all(24.0),
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                crossAxisAlignment: CrossAxisAlignment.center,
                children: <Widget>[
                  Icon(
                    isToggled ? Icons.lightbulb : Icons.lightbulb_outline,
                    size: 60,
                    color: isToggled ? Colors.amber : Colors.grey,
                  ),
                  const SizedBox(height: 16),
                  Text(
                    isToggled ? '전구가 켜졌습니다!' : '전구가 꺼져있습니다.',
                    style: const TextStyle(fontSize: 18),
                  ),
                  const SizedBox(height: 24),
                  ElevatedButton(
                    child: const Text('전구 켜기 / 끄기'),
                    onPressed: () {
                      // 4. `StatefulBuilder`가 제공한 setState를 호출합니다.
                      // 이 호출은 오직 StatefulBuilder 내부의 위젯들만 다시 빌드합니다.
                      setState(() {
                        isToggled = !isToggled;
                      });
                    },
                  ),
                ],
              ),
            );
          },
        );
      },
    );
  }
}

코드 분석 및 핵심 포인트:

  1. 상태 변수의 위치: 상태를 저장하는 변수(`isToggled`)는 `StatefulBuilder`의 `builder` 함수 바깥, 그리고 `showModalBottomSheet`의 `builder` 함수 안쪽에 선언해야 합니다. 만약 `StatefulBuilder`의 `builder` 내부에 선언하면, `setState`가 호출되어 위젯이 다시 빌드될 때마다 변수가 `false`로 초기화되어 버립니다.
  2. `StateSetter setState`의 사용: 버튼의 `onPressed` 콜백에서 호출하는 `setState`는 페이지의 `setState`가 아니라 `StatefulBuilder`가 매개변수로 전달해 준 로컬 `setState`입니다. 이것이 바텀 시트의 UI만 정확히 갱신할 수 있는 이유입니다.
  3. 적용 시점: 이 방법은 바텀 시트 내부의 상태가 다른 위젯이나 앱의 전반적인 상태와 연동될 필요 없이, 완전히 독립적으로 작동할 때 가장 이상적입니다. 간단한 UI 토글, 카운터, 선택 항목 표시 등에 매우 빠르고 효율적인 해결책입니다.

해결책 2: `Provider` - 구조적이고 확장 가능한 전역 상태 연동

만약 여러분의 애플리케이션이 `Provider`와 같은 상태 관리 라이브러리를 이미 사용하고 있다면, 문제는 좀 더 구조적인 차원에서 접근해야 합니다. 바텀 시트에서 이루어진 선택이 앱의 다른 부분(예: 메인 화면의 목록, 앱바의 타이틀)에도 영향을 미쳐야 하는 경우가 대표적입니다. 예를 들어, 바텀 시트에서 '다크 모드'를 활성화하면 앱 전체의 테마가 즉시 변경되어야 합니다. 이런 시나리오에서는 상태를 바텀 시트 내부에 가두는 `StatefulBuilder` 방식보다, 앱 전역에서 공유되는 상태를 `Provider`를 통해 연동하는 것이 훨씬 더 강력하고 올바른 아키텍처입니다.

핵심 원리는 `showModalBottomSheet`의 `builder`가 제공하는 새로운 `BuildContext`를 올바르게 활용하여, 위젯 트리 상위에 존재하는 `Provider`에 접근하는 것입니다. 상태의 변화를 '구독'하고 있다가, 변경이 발생하면 UI를 자동으로 다시 빌드하게 만드는 `Consumer` 위젯이나 `context.watch` 확장 메서드를 사용하면 이 문제를 우아하게 해결할 수 있습니다.

`Provider`와 `Consumer`를 활용한 실전 예제

먼저, 앱의 상태를 관리할 `ChangeNotifier` 클래스를 정의합니다. 이 클래스는 비즈니스 로직을 담는 역할을 합니다.


// 1. 상태 관리 로직을 담을 ChangeNotifier 클래스 생성
// foundation.dart를 import 해야 ChangeNotifier를 사용할 수 있습니다.
import 'package:flutter/foundation.dart';

class AppSettings with ChangeNotifier {
  String _selectedItem = '항목 A';
  String get selectedItem => _selectedItem;

  void updateSelectedItem(String newItem) {
    if (_selectedItem != newItem) {
      _selectedItem = newItem;
      // notifyListeners()를 호출하여 이 ChangeNotifier를 구독하는 모든 위젯에게
      // 상태 변경 사실을 알리고 리빌드를 요청합니다.
      notifyListeners();
    }
  }
}

다음으로, 앱의 최상위 위젯(일반적으로 `MaterialApp` 위)에서 `ChangeNotifierProvider`를 사용하여 `AppSettings` 인스턴스를 앱 전체에 제공합니다.


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

// (위에서 정의한 AppSettings 클래스와 예제 페이지 클래스가 있다고 가정)

void main() {
  runApp(
    // ChangeNotifierProvider로 앱의 최상단을 감싸서
    // 하위 모든 위젯들이 AppSettings 인스턴스에 접근할 수 있도록 합니다.
    ChangeNotifierProvider(
      create: (context) => AppSettings(),
      child: const MyApp(),
    ),
  );
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Provider BottomSheet Demo',
      theme: ThemeData(primarySwatch: Colors.blue),
      home: const ProviderBottomSheetPage(),
    );
  }
}

이제 모든 준비가 끝났습니다. 바텀 시트 내부와 메인 페이지에서 `Consumer` 위젯을 사용하여 `AppSettings`의 변화를 감지하고 UI를 갱신해 보겠습니다.


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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        // 3. 메인 화면의 AppBar도 상태를 구독하여 변경사항을 반영합니다.
        title: Consumer<AppSettings>(
          builder: (context, settings, child) {
            return Text('선택된 항목: ${settings.selectedItem}');
          },
        ),
      ),
      body: Center(
        child: ElevatedButton(
          child: const Text('항목 선택 바텀 시트 열기'),
          onPressed: () {
            _openBottomSheetWithProvider(context);
          },
        ),
      ),
    );
  }

  void _openBottomSheetWithProvider(BuildContext context) {
    showModalBottomSheet<void>(
      context: context,
      // builder가 제공하는 bottomSheetContext는 새로운 context지만,
      // Provider는 위젯 트리 상위에 있으므로 정상적으로 접근 가능합니다.
      builder: (BuildContext bottomSheetContext) {
        // 4. Consumer 위젯으로 AppSettings의 변화를 구독합니다.
        return Consumer<AppSettings>(
          builder: (context, settings, child) {
            final List<String> items = ['항목 A', '항목 B', '항목 C'];
            return Container(
              padding: const EdgeInsets.symmetric(vertical: 20.0),
              child: Column(
                mainAxisSize: MainAxisSize.min,
                children: items.map((item) {
                  return ListTile(
                    title: Text(item),
                    leading: Radio<String>(
                      value: item,
                      groupValue: settings.selectedItem, // 현재 선택된 값
                      onChanged: (String? value) {
                        if (value != null) {
                          // 5. 상태 변경 메서드 호출. `context.read` 사용.
                          // UI를 다시 빌드할 필요 없이, 단순히 함수만 호출할 때는
                          // context.read를 사용하는 것이 더 효율적입니다.
                          context.read<AppSettings>().updateSelectedItem(value);
                          // 선택 후 바텀 시트 닫기
                          Navigator.pop(bottomSheetContext);
                        }
                      },
                    ),
                    onTap: () {
                      context.read<AppSettings>().updateSelectedItem(item);
                       Navigator.pop(bottomSheetContext);
                    },
                  );
                }).toList(),
              ),
            );
          },
        );
      },
    );
  }
}

`Provider` 접근 방식의 장점:

  • 관심사의 완벽한 분리 (Separation of Concerns): UI를 그리는 코드(Widget)와 상태 및 비즈니스 로직을 처리하는 코드(`AppSettings` 클래스)가 명확하게 분리됩니다. 이는 코드의 가독성과 유지보수성을 극적으로 향상시킵니다.
  • 상태의 중앙 관리 및 공유: 바텀 시트에서 변경된 상태가 앱의 다른 모든 부분(AppBar 등)에 즉시 전파됩니다. 상태가 여러 곳에 흩어져 있지 않고 한 곳에서 관리되므로 데이터의 일관성을 보장하기 쉽습니다.
  • 향상된 테스트 용이성: `AppSettings` 클래스는 Flutter 프레임워크에 대한 의존도가 낮기 때문에(UI와 무관), 일반적인 Dart 코드로 매우 간단하게 단위 테스트(Unit Test)를 작성할 수 있습니다.

결론: 어떤 해결책을 언제 선택해야 할까?

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

근본적인 질문은 이것입니다: **"당신이 관리하려는 상태가 바텀 시트라는 '파티룸' 안에서만 유효한가, 아니면 '본가'를 포함한 도시 전체에 영향을 미치는가?"**

고려 사항 `StatefulBuilder` `Provider` (상태 관리 라이브러리)
사용 시나리오 상태가 바텀 시트 내에서 완전히 독립적이고 국소적일 때. (예: 간단한 카운터, 체크박스 토글, 임시 텍스트 입력) 상태가 앱의 다른 부분과 공유되거나 연동되어야 할 때. (예: 테마 변경, 언어 설정, 사용자 프로필 업데이트)
장점 - 매우 간단하고 빠르다.
- 추가적인 클래스나 파일이 필요 없다.
- 보일러플레이트 코드가 거의 없다.
- 코드의 구조화 및 재사용성 향상.
- 명확한 관심사 분리.
- 테스트가 용이하다.
- 대규모 애플리케이션에 적합한 확장성.
단점 - 상태를 외부와 공유하기 어렵다.
- 로직이 복잡해지면 UI 코드와 섞여 지저분해질 수 있다.
- 초기 설정(Provider, Notifier 클래스 등)이 필요하다.
- 간단한 기능에는 과하게 느껴질 수 있다.

결론적으로, `showModalBottomSheet`를 사용할 때 UI 갱신 문제가 발생하면 가장 먼저 "아, `context`가 달라서 그렇구나!"라고 떠올리는 것이 중요합니다. 그리고 나서 관리해야 할 상태의 범위와 복잡도를 기준으로, 가볍고 빠른 `StatefulBuilder`를 선택할지, 구조적이고 확장 가능한 `Provider`를 선택할지를 결정하면 됩니다. 이 두 가지 해결책을 올바르게 이해하고 적재적소에 활용한다면, 더 이상 바텀 시트의 예측 불가능한 동작 때문에 개발 시간을 낭비하는 일은 없을 것입니다.


0 개의 댓글:

Post a Comment