플러터(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
그렇다면 이 `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;
});
},
),
],
),
);
},
);
},
);
}
}
코드 분석 및 핵심 포인트:
- 상태 변수의 위치: 상태를 저장하는 변수(`isToggled`)는 `StatefulBuilder`의 `builder` 함수 바깥, 그리고 `showModalBottomSheet`의 `builder` 함수 안쪽에 선언해야 합니다. 만약 `StatefulBuilder`의 `builder` 내부에 선언하면, `setState`가 호출되어 위젯이 다시 빌드될 때마다 변수가 `false`로 초기화되어 버립니다.
- `StateSetter setState`의 사용: 버튼의 `onPressed` 콜백에서 호출하는 `setState`는 페이지의 `setState`가 아니라 `StatefulBuilder`가 매개변수로 전달해 준 로컬 `setState`입니다. 이것이 바텀 시트의 UI만 정확히 갱신할 수 있는 이유입니다.
- 적용 시점: 이 방법은 바텀 시트 내부의 상태가 다른 위젯이나 앱의 전반적인 상태와 연동될 필요 없이, 완전히 독립적으로 작동할 때 가장 이상적입니다. 간단한 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