Flutter로 앱을 개발하다 보면 사용자와의 상호작용을 위해 다이얼로그(Dialog)를 띄우는 경우는 매우 흔합니다. 특히 비동기 작업의 진행 상황을 보여주는 로딩 다이얼로그나, 실시간으로 변경되는 데이터를 표시해야 하는 다이얼로그는 필수적입니다. 이때 많은 개발자들이 상태 관리 라이브러리로 Provider를 사용하는데, 이상하게도 다이얼로그의 내용만큼은 마음처럼 쉽게 갱신되지 않는 경험을 하곤 합니다. 분명 Provider의 `notifyListeners()`를 호출했는데, 왜 다이얼로그는 묵묵부답일까요?
이러한 현상은 Flutter의 위젯 트리와 `BuildContext`에 대한 이해가 부족할 때 발생하는 대표적인 문제입니다. `showDialog` 함수는 현재 위젯 트리 위에 새로운 '레이어' 또는 '경로(Route)'를 만들어 다이얼로그를 띄웁니다. 즉, 다이얼로그는 자신만의 독립적인 `BuildContext`를 갖게 되며, 이로 인해 부모 위젯의 상태 변화를 곧바로 감지하지 못하는 '상태 단절' 현상이 발생하는 것입니다.
이번 글에서는 이 고질적인 문제를 `StatefulBuilder`와 같은 임시방편이 아닌, Provider의 기능을 100% 활용하여 가장 우아하고 구조적으로 해결하는 방법을 심층적으로 다루어 보겠습니다. 이 글을 끝까지 읽으신다면, 더 이상 다이얼로그 갱신 문제로 골머리를 앓지 않게 될 것입니다.
1. 무엇이 문제인가? 다이얼로그가 상태 변화를 감지 못하는 이유
백문이 불여일견입니다. 간단한 카운터 예제를 통해 문제가 발생하는 상황을 직접 재현해 보겠습니다. 화면에는 숫자를 표시하는 텍스트와 다이얼로그를 띄우는 버튼, 그리고 숫자를 1씩 증가시키는 버튼이 있습니다. 우리의 목표는 숫자를 증가시키는 버튼을 누를 때마다, 화면의 숫자뿐만 아니라 화면에 떠 있는 다이얼로그의 숫자도 함께 증가하는 것입니다.
상태 모델: `CounterModel`
먼저 상태를 관리할 `ChangeNotifier`를 정의합니다.
import 'package:flutter/material.dart';
class CounterModel extends ChangeNotifier {
int _count = 0;
int get count => _count;
void increment() {
_count++;
// 상태가 변경되었음을 리스너들에게 알립니다.
notifyListeners();
}
}
메인 위젯 설정
앱의 최상단에서 `ChangeNotifierProvider`를 사용하여 `CounterModel`을 하위 위젯 트리에 제공합니다.
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
void main() {
runApp(
ChangeNotifierProvider(
create: (context) => CounterModel(),
child: const MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
home: CounterScreen(),
);
}
}
문제의 다이얼로그 코드 (잘못된 접근 방식)
이제 `CounterScreen`에서 다이얼로그를 띄우고 숫자를 증가시켜 보겠습니다. 많은 개발자들이 처음 시도하는 방식입니다.
class CounterScreen extends StatelessWidget {
const CounterScreen({super.key});
void _showCounterDialog(BuildContext context) {
showDialog(
context: context,
builder: (BuildContext dialogContext) {
// 다이얼로그 내부에서 Consumer를 사용해 CounterModel을 구독하려고 시도
return Consumer<CounterModel>(
builder: (context, model, child) {
return AlertDialog(
title: const Text("실시간 카운터"),
content: Text(
"현재 숫자는 ${model.count} 입니다.",
style: const TextStyle(fontSize: 24),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text("닫기"),
)
],
);
},
);
},
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("다이얼로그 갱신 문제"),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text("화면의 숫자:"),
// 화면의 숫자는 정상적으로 갱신됩니다.
Consumer<CounterModel>(
builder: (context, model, child) => Text(
'${model.count}',
style: Theme.of(context).textTheme.headlineMedium,
),
),
const SizedBox(height: 20),
ElevatedButton(
onPressed: () => _showCounterDialog(context),
child: const Text("다이얼로그 띄우기"),
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
// 버튼을 누를 때마다 CounterModel의 상태를 변경합니다.
Provider.of<CounterModel>(context, listen: false).increment();
},
child: const Icon(Icons.add),
),
);
}
}
위 코드를 실행하고 '다이얼로그 띄우기' 버튼을 눌러 다이얼로그를 띄운 다음, 우측 하단의 '+' 버튼을 계속 눌러보세요. 어떤 결과가 나타나나요? 화면 중앙의 숫자는 1, 2, 3... 으로 잘 증가하지만, 정작 다이얼로그 안의 숫자는 처음 띄웠을 때의 숫자에 그대로 머물러 있습니다. 이것이 바로 우리가 해결해야 할 문제입니다.
왜 이런 현상이 발생할까요?
핵심은 `showDialog`의 `builder`가 받는 `BuildContext` (코드에서는 `dialogContext`로 명명)에 있습니다. 이 `dialogContext`는 `CounterScreen`의 `context`와는 다른, 다이얼로그만을 위한 별개의 위젯 트리와 연결되어 있습니다. 우리가 `main.dart`에서 `ChangeNotifierProvider`를 선언했지만, 새로 생성된 다이얼로그의 위젯 트리는 이 Provider를 직접적으로 '인식'하지 못하는 상태가 됩니다. 따라서 다이얼로그 내부의 `Consumer`는 자신이 구독해야 할 `CounterModel`을 찾지 못하거나, 찾더라도 상태 변화 알림을 제대로 수신하지 못하게 되는 것입니다.
2. 우아한 해결책: `ChangeNotifierProvider.value`의 등장
이 문제를 해결하는 열쇠는 바로 `ChangeNotifierProvider.value` 생성자에 있습니다. Provider 패키지를 사용하다 보면 `ChangeNotifierProvider()`와 `ChangeNotifierProvider.value()` 두 가지를 보게 되는데, 이 둘의 차이점을 아는 것이 매우 중요합니다.
-
`ChangeNotifierProvider()` (기본 생성자):
- 역할: `ChangeNotifier`의 새로운 인스턴스를 생성합니다.
- 생명주기 관리: Provider가 위젯 트리에서 제거될 때, 자신이 생성했던 `ChangeNotifier` 인스턴스를 자동으로 `dispose()` 해줍니다.
- 주 사용처: 특정 위젯 하위 트리에서만 사용될 새로운 상태 객체를 만들 때 사용합니다. 앱의 최상단이나 특정 페이지의 시작점에서 주로 사용됩니다.
-
`ChangeNotifierProvider.value()` (명명된 생성자):
- 역할: 새로운 인스턴스를 만들지 않고, 이미 존재하는 `ChangeNotifier`의 인스턴스를 값(value)으로 받아서 제공합니다.
- 생명주기 관리: Provider가 제공하는 값의 생명주기를 관리하지 않습니다. 즉, `dispose()`를 호출하지 않습니다. 값의 생성과 폐기는 개발자가 직접 관리해야 합니다.
- 주 사용처: 이미 상위 위젯 트리 어딘가에 존재하는 상태 객체를, 리스트 아이템이나 새로운 경로(Route) 등 다른 위젯 하위 트리에 '재공유'하거나 '연결'해줄 때 사용합니다.
우리의 다이얼로그 문제 상황에 이 두 가지를 대입해 봅시다. `CounterModel` 인스턴스는 이미 `main.dart`에서 `ChangeNotifierProvider`를 통해 생성되어 앱 전역에서 관리되고 있습니다. 우리는 다이얼로그를 위해 새로운 `CounterModel`을 만들 필요가 없습니다. 단지 이미 존재하는 그 `CounterModel` 인스턴스를, 새로 생성된 다이얼로그의 위젯 트리가 접근할 수 있도록 '다리'를 놓아주기만 하면 됩니다.
이 역할에 완벽하게 부합하는 것이 바로 `ChangeNotifierProvider.value`입니다. 기존 모델을 그대로 전달하여, 다이얼로그의 컨텍스트와 메인 앱의 컨텍스트가 동일한 상태 객체를 바라보게 만들어 주는 것입니다.
3. 해결 코드: 단계별 심층 분석
이제 `ChangeNotifierProvider.value`를 사용하여 앞서 발생했던 문제를 해결해 보겠습니다. `CounterScreen`의 `_showCounterDialog` 함수를 아래와 같이 수정합니다.
void _showCounterDialog(BuildContext context) {
// --- 1단계: 기존 Provider 인스턴스 가져오기 ---
// listen: false 옵션이 매우 중요합니다.
// 여기서 상태 변화를 감지할 필요가 없으며, 단지 인스턴스 참조만 필요하기 때문입니다.
// 만약 listen: true(기본값)라면, CounterModel이 변경될 때마다 이 _showCounterDialog 함수를
// 포함하는 CounterScreen 위젯의 build 메소드가 불필요하게 재실행됩니다.
final counterModel = Provider.of<CounterModel>(context, listen: false);
showDialog(
context: context,
// 다이얼로그의 context는 builder를 통해 새로 생성됩니다. (이름을 dialogContext로 명확히 함)
builder: (BuildContext dialogContext) {
// --- 2단계: 기존 인스턴스를 새로운 Provider로 주입 ---
// ChangeNotifierProvider.value를 사용하여 위에서 가져온 counterModel 인스턴스를
// 다이얼로그의 위젯 트리에 제공(주입)합니다.
// 이제 이 Provider 하위의 위젯들은 counterModel에 접근할 수 있습니다.
return ChangeNotifierProvider.value(
value: counterModel,
// --- 3단계: Consumer로 상태 변화 감지 및 UI 갱신 ---
// Consumer는 이제 자신의 상위 위젯 트리에서 가장 가까운 CounterModel Provider를 찾습니다.
// 방금 우리가 만들어준 ChangeNotifierProvider.value를 찾게 되고,
// 이 Provider가 제공하는 counterModel의 변화를 성공적으로 감지하게 됩니다.
child: Consumer<CounterModel>(
builder: (context, model, child) {
// model.increment()가 호출되어 notifyListeners()가 실행될 때마다
// 이 builder 부분이 재실행되어 UI가 갱신됩니다.
return AlertDialog(
title: const Text("실시간 카운터 (해결됨)"),
content: Text(
"현재 숫자는 ${model.count} 입니다.",
style: const TextStyle(fontSize: 24),
),
actions: [
TextButton(
// 다이얼로그를 닫을 때는 다이얼로그 자신의 context를 사용해야 합니다.
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text("닫기"),
)
],
);
},
),
);
},
);
}
코드를 단계별로 다시 한번 자세히 살펴보겠습니다.
-
`Provider.of<CounterModel>(context, listen: false)`
함수의 가장 첫 줄에서 `Provider.of`를 호출합니다. 여기서 가장 중요한 부분은 `listen: false` 옵션입니다. 이 옵션은 "`CounterModel`의 상태가 바뀌더라도 이 코드를 실행하는 위젯(즉, `CounterScreen`)을 다시 빌드하지 말라"는 의미입니다. 우리는 단지 다이얼로그에 전달해 줄 `CounterModel` 객체의 '참조' 또는 '주소값'만 필요한 것이지, `CounterScreen` 자체가 `CounterModel`의 변화에 반응할 필요는 없기 때문입니다. (화면에 숫자를 표시하는 `Consumer`가 그 역할을 이미 하고 있습니다.) 이 옵션을 빼먹으면 불필요한 위젯 리빌딩이 발생하여 앱 성능에 미세한 악영향을 줄 수 있습니다.
-
`ChangeNotifierProvider.value(value: counterModel, ...)`
이것이 바로 해결의 핵심입니다. `showDialog`의 `builder` 내부에, 다이얼로그의 최상위 위젯으로 `ChangeNotifierProvider.value`를 배치합니다. 그리고 `value` 속성에 1단계에서 얻어온 `counterModel` 인스턴스를 그대로 전달합니다. 이 한 줄의 코드로 인해, 메인 앱의 상태(`counterModel`)와 다이얼로그의 상태가 완벽하게 동기화되는 '연결고리'가 생성됩니다.
-
`Consumer<CounterModel>(...)`
이제 `Consumer`는 이전과 달리 정상적으로 작동합니다. `Consumer`는 위젯 트리 상에서 자신과 가장 가까운 `ChangeNotifierProvider<CounterModel>`을 찾습니다. 수정된 코드에서는 바로 위에 `ChangeNotifierProvider.value`가 존재하므로, 이 Provider가 제공하는 `counterModel`을 구독하게 됩니다. 따라서 앱의 다른 곳에서 `counterModel.increment()`가 호출되고 `notifyListeners()`가 실행되면, 이 `Consumer`는 상태 변화를 즉시 감지하고 자신의 `builder` 함수를 재실행하여 `Text` 위젯을 새로운 `count` 값으로 갱신하게 됩니다.
이제 수정한 코드로 다시 앱을 실행하고 테스트해보세요. 다이얼로그를 띄운 상태에서 '+' 버튼을 누르면, 화면의 숫자와 다이얼로그 안의 숫자가 동시에 완벽하게 증가하는 것을 확인할 수 있습니다.
4. 실전 활용 예제: 비동기 로딩 다이얼로그 만들기
이 패턴은 단순히 카운터를 갱신하는 것 이상의 강력한 활용성을 가집니다. 가장 대표적인 예가 바로 비동기 작업의 진행 상황을 보여주는 로딩 다이얼로그입니다.
서버에서 데이터를 다운로드하고, 압축을 풀고, 데이터를 파싱하는 일련의 작업이 있다고 가정해 봅시다. 각 단계마다 사용자에게 현재 어떤 작업이 진행 중인지 알려주면 사용자 경험(UX)이 크게 향상됩니다.
`LoadingNotifier` 상태 모델 정의
import 'package:flutter/material.dart';
class LoadingNotifier extends ChangeNotifier {
String _message = "준비 중...";
double _progress = 0.0;
bool _isFinished = false;
String get message => _message;
double get progress => _progress;
bool get isFinished => _isFinished;
Future<void> startLoadingProcess() async {
_isFinished = false;
_progress = 0.0;
_message = "데이터 다운로드 시작...";
notifyListeners();
await Future.delayed(const Duration(seconds: 2)); // 2초간 다운로드 시뮬레이션
_progress = 0.5;
_message = "다운로드 완료, 압축 해제 중...";
notifyListeners();
await Future.delayed(const Duration(seconds: 2)); // 2초간 압축 해제 시뮬레이션
_progress = 1.0;
_message = "모든 작업 완료!";
_isFinished = true;
notifyListeners();
}
}
위 `LoadingNotifier`는 진행 메시지(`message`), 진행률(`progress`), 완료 여부(`isFinished`)를 상태로 가집니다. `startLoadingProcess` 메소드는 각 단계마다 상태를 변경하고 `notifyListeners()`를 호출하여 UI 갱신을 요청합니다.
로딩 다이얼로그 UI 및 호출 함수
// 로딩 다이얼로그를 보여주는 함수
void showLoadingDialog(BuildContext context) {
final loadingNotifier = Provider.of<LoadingNotifier>(context, listen: false);
showDialog(
context: context,
barrierDismissible: false, // 로딩 중에는 밖을 터치해도 닫히지 않도록 설정
builder: (BuildContext dialogContext) {
return ChangeNotifierProvider.value(
value: loadingNotifier,
child: Consumer<LoadingNotifier>(
builder: (context, notifier, child) {
// 작업이 완료되면 자동으로 다이얼로그를 닫도록 처리
// build 메소드 내에서 Navigator.pop을 바로 호출하면 에러가 발생하므로,
// build가 끝난 직후에 실행되도록 addPostFrameCallback을 사용합니다.
if (notifier.isFinished) {
WidgetsBinding.instance.addPostFrameCallback((_) {
Navigator.of(dialogContext).pop();
});
}
return AlertDialog(
title: const Text("처리 중"),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(notifier.message),
const SizedBox(height: 16),
LinearProgressIndicator(value: notifier.progress),
],
),
);
},
),
);
},
);
}
// 메인 화면의 버튼에서 호출할 함수
void _startProcess(BuildContext context) {
// 다이얼로그를 먼저 띄웁니다.
showLoadingDialog(context);
// 그 다음, 비동기 작업을 시작합니다.
// listen: false로 notifier 인스턴스만 가져와서 작업을 실행시킵니다.
Provider.of<LoadingNotifier>(context, listen: false).startLoadingProcess();
}
이 코드를 앱에 적용하고 `_startProcess` 함수를 호출하는 버튼을 만들어 실행하면, "데이터 다운로드 시작..." -> "다운로드 완료, 압축 해제 중..." -> "모든 작업 완료!" 순서로 메시지가 바뀌고 프로그레스 바가 채워지는 것을 실시간으로 볼 수 있습니다. 작업이 모두 끝나면 `isFinished` 플래그를 통해 다이얼로그가 자동으로 닫히는 깔끔한 로직까지 구현할 수 있습니다.
이처럼 `ChangeNotifierProvider.value`를 활용한 다이얼로그 갱신 패턴은 Flutter 앱의 상태 관리 로직을 일관되고 예측 가능하게 만들어주며, 코드의 재사용성과 유지보수성을 크게 향상시킵니다.
결론: 기억해야 할 핵심 원칙
Flutter에서 Provider를 사용하여 다이얼로그의 내용을 동적으로 갱신하는 문제를 해결하기 위한 여정을 마무리하며, 반드시 기억해야 할 핵심 원칙들을 정리해 보겠습니다.
- 독립된 `BuildContext`를 인지하라: `showDialog`는 새로운 위젯 트리를 생성하며, 이는 부모와는 다른 자신만의 `BuildContext`를 가집니다. 이것이 상태 동기화 문제의 근본적인 원인입니다.
- 상태 연결의 다리는 `ChangeNotifierProvider.value`: 이미 존재하는 상태 모델(ChangeNotifier)을 새로운 위젯 트리(다이얼로그)에 연결해 줄 때는, 새로운 인스턴스를 만드는 `ChangeNotifierProvider()`가 아닌, 기존 인스턴스를 재사용하는 `ChangeNotifierProvider.value()`를 사용해야 합니다.
- 불필요한 리빌드는 `listen: false`로 막아라: 다이얼로그에 전달할 상태 모델의 참조를 얻기 위해 `Provider.of`를 사용할 때는, `listen: false` 옵션을 습관처럼 사용하여 불필요한 위젯 리빌딩을 방지하고 성능을 최적화해야 합니다.
- `Consumer`는 제 위치에서 사용하라: UI 갱신을 담당하는 `Consumer` 위젯은 반드시 `ChangeNotifierProvider.value`의 하위에 위치시켜야, 올바른 Provider를 찾아 상태 변화를 제대로 구독할 수 있습니다.
이 패턴은 단순히 다이얼로그에만 국한되지 않습니다. `showModalBottomSheet`, `Navigator.push`를 통해 새로운 페이지로 이동할 때 등, 기존의 `BuildContext`와 분리된 새로운 위젯 트리에 상태를 공유해야 하는 모든 상황에 동일하게 적용할 수 있는 매우 강력하고 범용적인 기법입니다.
이제 여러분은 Flutter와 Provider를 사용하여 더욱 견고하고 반응성이 뛰어난 애플리케이션을 만들 수 있는 훌륭한 무기를 하나 더 갖추게 되었습니다. 이 원리를 잘 이해하고 활용하여, 사용자에게 끊김 없는 최상의 경험을 선사하는 앱을 개발하시길 바랍니다.
정말 감사합니다. 큰 도움이 되었습니다.
ReplyDelete