본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.
본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.
이미지 로딩 중...
AI Generated
2025. 12. 11. · 11 Views
ref.watch로 반응형 UI 만들기 완벽 가이드
Riverpod의 ref.watch를 활용하여 상태 변화에 자동으로 반응하는 UI를 구현하는 방법을 실전 예제와 함께 배워봅니다. 초급 개발자도 쉽게 따라할 수 있는 단계별 가이드입니다.
목차
1. ref.watch 기본 사용법
어느 날 김개발 씨가 Flutter 앱을 개발하다가 버튼을 눌러도 화면이 갱신되지 않는 문제를 발견했습니다. 분명 상태는 변경되는데 UI는 그대로였습니다.
선배 박시니어 씨가 코드를 보더니 말했습니다. "ref.watch를 써야죠!"
ref.watch는 Provider의 상태를 구독하고, 상태가 변경될 때마다 자동으로 위젯을 다시 빌드하는 메서드입니다. 마치 유튜브 구독 알림처럼, Provider의 값이 바뀌면 즉시 UI에게 알려주는 역할을 합니다.
이것을 제대로 이해하면 복잡한 상태 관리 로직 없이도 반응형 UI를 쉽게 만들 수 있습니다.
다음 코드를 살펴봅시다.
// 카운터 Provider 정의
final counterProvider = StateProvider<int>((ref) => 0);
// ConsumerWidget에서 ref.watch 사용
class CounterScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// ref.watch로 상태 구독 - 값이 바뀌면 자동으로 리빌드
final count = ref.watch(counterProvider);
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('카운트: $count', style: TextStyle(fontSize: 24)),
ElevatedButton(
// ref.read로 상태 변경만 수행
onPressed: () => ref.read(counterProvider.notifier).state++,
child: Text('증가'),
),
],
),
),
);
}
}
김개발 씨는 입사 2개월 차 주니어 Flutter 개발자입니다. 오늘도 열심히 코드를 작성하던 중, 이상한 버그를 발견했습니다.
버튼을 눌러 카운터 값을 증가시켰는데, 화면에 표시된 숫자는 그대로였습니다. 분명히 디버그 콘솔에는 값이 증가하고 있었는데 말이죠.
선배 개발자 박시니어 씨가 다가와 코드를 살펴봅니다. "아, 여기가 문제네요.
ref.read만 사용하고 ref.watch를 안 써서 생긴 문제예요." 그렇다면 ref.watch란 정확히 무엇일까요? 쉽게 비유하자면, ref.watch는 마치 유튜브 구독 버튼과 같습니다.
좋아하는 채널을 구독하면 새 영상이 올라올 때마다 알림을 받듯이, ref.watch로 Provider를 구독하면 상태가 변경될 때마다 위젯이 자동으로 알림을 받아 화면을 갱신합니다. 이처럼 ref.watch도 상태 변화 감지와 자동 UI 갱신을 담당합니다.
ref.watch가 없던 시절에는 어땠을까요? 개발자들은 상태가 변경될 때마다 setState를 일일이 호출해야 했습니다.
코드가 길어지고, 어디서 setState를 호출해야 하는지 헷갈리기도 쉬웠습니다. 더 큰 문제는 위젯 간 상태 공유였습니다.
부모에서 자식으로, 또 그 자식의 자식으로 데이터를 전달하다 보면 코드가 스파게티처럼 엉켰습니다. 바로 이런 문제를 해결하기 위해 Riverpod와 ref.watch가 등장했습니다.
ref.watch를 사용하면 자동 구독이 가능해집니다. 또한 보일러플레이트 코드 감소라는 이점도 얻을 수 있습니다.
무엇보다 전역 상태 관리가 간편해진다는 큰 장점이 있습니다. 위의 코드를 한 줄씩 살펴보겠습니다.
먼저 2번째 줄의 final counterProvider를 보면 StateProvider를 정의한 것을 알 수 있습니다. 이 Provider는 정수형 상태를 관리합니다.
다음으로 8번째 줄에서는 **ref.watch(counterProvider)**로 상태를 구독합니다. 이 순간부터 count 변수는 Provider의 값과 동기화되며, 값이 바뀔 때마다 build 메서드가 다시 호출됩니다.
마지막으로 16번째 줄에서는 버튼을 누르면 상태가 증가하고, 자동으로 화면이 갱신됩니다. 실제 현업에서는 어떻게 활용할까요?
예를 들어 쇼핑몰 앱을 개발한다고 가정해봅시다. 장바구니 아이콘에 담긴 상품 개수를 표시해야 하는데, 이 숫자는 여러 화면에서 동시에 보여야 합니다.
ref.watch를 활용하면 장바구니 Provider를 한 번만 정의하고, 필요한 모든 위젯에서 구독하면 됩니다. 상품을 추가하거나 제거하면 모든 화면의 숫자가 자동으로 업데이트됩니다.
하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 build 메서드 안에서 ref.read를 사용하는 것입니다.
ref.read는 상태를 구독하지 않으므로 값이 변경되어도 UI가 갱신되지 않습니다. 따라서 UI에 표시할 값은 항상 ref.watch로, 이벤트 핸들러에서 상태를 변경할 때만 ref.read를 사용해야 합니다.
다시 김개발 씨의 이야기로 돌아가 봅시다. 박시니어 씨의 설명을 들은 김개발 씨는 고개를 끄덕였습니다.
"아, 그래서 화면이 안 바뀌었군요!" ref.watch를 제대로 이해하면 더 깔끔하고 유지보수하기 쉬운 코드를 작성할 수 있습니다. 여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.
실전 팁
💡 - build 메서드에서는 ref.watch를 사용하고, 이벤트 핸들러에서는 ref.read를 사용하세요
- ConsumerWidget 대신 Consumer를 사용하면 일부 위젯만 선택적으로 리빌드할 수 있습니다
2. 여러 Provider 동시 구독
김개발 씨가 사용자 프로필 화면을 만들던 중 궁금증이 생겼습니다. 이름과 나이, 두 개의 Provider를 동시에 사용하려면 어떻게 해야 할까요?
박시니어 씨가 웃으며 말했습니다. "간단해요.
ref.watch를 여러 번 호출하면 됩니다."
여러 Provider 동시 구독은 하나의 위젯에서 ref.watch를 여러 번 호출하여 여러 개의 상태를 동시에 관찰하는 패턴입니다. 마치 여러 개의 유튜브 채널을 동시에 구독하는 것처럼, 각각의 Provider가 변경될 때마다 위젯이 반응합니다.
이를 통해 복잡한 UI도 간단하게 구현할 수 있습니다.
다음 코드를 살펴봅시다.
// 여러 개의 Provider 정의
final nameProvider = StateProvider<String>((ref) => 'Guest');
final ageProvider = StateProvider<int>((ref) => 0);
final isLoggedInProvider = StateProvider<bool>((ref) => false);
class UserProfileScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// 여러 Provider를 동시에 구독
final name = ref.watch(nameProvider);
final age = ref.watch(ageProvider);
final isLoggedIn = ref.watch(isLoggedInProvider);
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('이름: $name'),
Text('나이: $age'),
Text('로그인 상태: ${isLoggedIn ? "로그인됨" : "로그아웃"}'),
],
),
),
);
}
}
김개발 씨는 이제 조금씩 Riverpod에 익숙해지고 있었습니다. 오늘은 사용자 프로필 화면을 만드는 작업을 맡았습니다.
이름, 나이, 로그인 상태 등 여러 가지 정보를 표시해야 했습니다. 처음에는 막막했습니다.
"Provider를 하나만 사용할 줄 아는데, 여러 개를 어떻게 써야 하지?" 고민하던 중 박시니어 씨에게 물어봤습니다. 박시니어 씨가 웃으며 답했습니다.
"별거 아니에요. ref.watch를 필요한 만큼 여러 번 호출하면 됩니다." 여러 Provider 동시 구독이란 무엇일까요?
쉽게 비유하자면, 여러 개의 신문을 동시에 구독하는 것과 같습니다. 경제 신문, 스포츠 신문, 연예 신문을 모두 구독하면 각 분야의 최신 소식을 한꺼번에 받아볼 수 있습니다.
마찬가지로 ref.watch를 여러 번 호출하면 각 Provider의 상태 변화를 모두 감지할 수 있습니다. 하나의 Provider만 사용하던 시절에는 어떤 불편함이 있었을까요?
모든 상태를 하나의 커다란 객체에 담아야 했습니다. 예를 들어 사용자 정보를 관리하려면 User 클래스를 만들고, 이름이나 나이 중 하나만 바뀌어도 전체 User 객체를 교체해야 했습니다.
이렇게 하면 불필요한 리빌드가 발생할 수 있었습니다. 나이만 바뀌었는데 이름을 표시하는 위젯까지 다시 그려지는 식이었죠.
바로 이런 문제를 해결하기 위해 상태를 작은 단위로 분리하는 패턴이 권장됩니다. 여러 Provider를 사용하면 관심사의 분리가 가능해집니다.
또한 세밀한 리빌드 제어도 할 수 있습니다. 무엇보다 코드의 가독성이 크게 향상됩니다.
위의 코드를 자세히 살펴보겠습니다. 먼저 2번째부터 4번째 줄에서는 세 개의 독립적인 Provider를 정의했습니다.
각각 이름, 나이, 로그인 상태를 관리합니다. 다음으로 10번째부터 12번째 줄에서는 ref.watch를 세 번 호출하여 모든 Provider를 구독합니다.
이제 어떤 Provider의 값이 바뀌든 위젯이 자동으로 갱신됩니다. 실제 프로젝트에서는 어떻게 활용될까요?
SNS 앱을 개발한다고 가정해봅시다. 타임라인 화면에는 게시글 목록, 알림 개수, 사용자 프로필 사진이 동시에 표시됩니다.
이들은 각각 독립적인 Provider로 관리되며, 하나의 화면에서 모두 구독합니다. 새 게시글이 추가되면 목록만 갱신되고, 알림이 오면 알림 아이콘만 업데이트됩니다.
이렇게 효율적인 렌더링이 가능합니다. 주의해야 할 점이 있습니다.
Provider가 너무 많아지면 관리가 어려워질 수 있습니다. 초보 개발자들은 흔히 모든 것을 별도 Provider로 분리하는 실수를 합니다.
예를 들어 사용자의 성, 이름, 중간 이름을 각각 별도 Provider로 만드는 식입니다. 이렇게 하면 오히려 코드가 복잡해집니다.
논리적으로 함께 변경되는 값들은 하나의 Provider로 묶는 것이 좋습니다. 김개발 씨는 박시니어 씨의 조언을 따라 코드를 작성했습니다.
화면이 완벽하게 작동했습니다. "생각보다 간단하네요!" 여러 Provider를 동시에 구독하는 것은 복잡해 보이지만, 실제로는 매우 직관적입니다.
각 상태를 독립적으로 관리하면서도 하나의 UI에서 조합할 수 있습니다.
실전 팁
💡 - 논리적으로 연관된 상태는 하나의 Provider로 묶고, 독립적인 상태는 분리하세요
- 특정 Provider만 사용하는 위젯은 Consumer로 감싸서 불필요한 리빌드를 방지하세요
3. 의존성 있는 Provider 체인
어느 날 김개발 씨는 복잡한 로직을 구현해야 했습니다. 사용자의 나이를 기반으로 성인 여부를 판단하고, 그에 따라 접근 가능한 컨텐츠를 필터링해야 했습니다.
박시니어 씨가 말했습니다. "Provider끼리 의존 관계를 만들면 됩니다."
의존성 있는 Provider 체인은 한 Provider가 다른 Provider의 값을 읽어서 새로운 값을 계산하는 패턴입니다. 마치 공장의 조립 라인처럼, 앞 단계의 결과물을 받아서 다음 단계의 제품을 만드는 방식입니다.
이를 통해 복잡한 비즈니스 로직을 선언적으로 표현할 수 있습니다.
다음 코드를 살펴봅시다.
// 기본 상태 Provider
final ageProvider = StateProvider<int>((ref) => 20);
// 다른 Provider에 의존하는 Provider
final isAdultProvider = Provider<bool>((ref) {
// ref.watch로 다른 Provider 구독
final age = ref.watch(ageProvider);
return age >= 19;
});
// 또 다른 의존 Provider
final accessLevelProvider = Provider<String>((ref) {
final isAdult = ref.watch(isAdultProvider);
return isAdult ? 'Full Access' : 'Limited Access';
});
class AccessScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final age = ref.watch(ageProvider);
final isAdult = ref.watch(isAdultProvider);
final accessLevel = ref.watch(accessLevelProvider);
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('나이: $age'),
Text('성인 여부: ${isAdult ? "성인" : "미성년자"}'),
Text('접근 레벨: $accessLevel'),
ElevatedButton(
onPressed: () => ref.read(ageProvider.notifier).state++,
child: Text('나이 증가'),
),
],
),
),
);
}
}
김개발 씨는 이번에 조금 복잡한 기능을 구현해야 했습니다. 동영상 스트리밍 앱에서 사용자의 나이에 따라 시청 가능한 컨텐츠를 제한하는 기능이었습니다.
처음 생각한 방법은 나이가 변경될 때마다 성인 여부와 접근 레벨을 함께 업데이트하는 것이었습니다. 하지만 코드가 복잡해지고 실수하기 쉬웠습니다.
"더 나은 방법이 없을까요?" 박시니어 씨에게 물었습니다. 박시니어 씨가 답했습니다.
"Provider끼리 의존 관계를 만들어보세요. 나이가 바뀌면 나머지는 자동으로 재계산됩니다." 의존성 있는 Provider 체인이란 무엇일까요?
쉽게 비유하자면, 엑셀 스프레드시트의 수식과 같습니다. A1 셀에 숫자를 입력하고, B1 셀에는 "=A1*2"라는 수식을 넣으면, A1이 바뀔 때마다 B1도 자동으로 재계산됩니다.
C1에는 "=B1+10"을 넣으면 A1의 변화가 B1을 거쳐 C1까지 전파됩니다. Provider도 이와 똑같은 방식으로 작동합니다.
의존성 없이 모든 상태를 수동으로 관리하던 시절에는 어땠을까요? 하나의 값이 바뀔 때마다 관련된 모든 값을 일일이 업데이트해야 했습니다.
예를 들어 나이를 변경하면 성인 여부를 확인하고, 접근 레벨을 갱신하고, UI를 업데이트하는 코드를 순서대로 작성해야 했습니다. 단계를 하나라도 빠뜨리면 데이터 불일치가 발생했습니다.
더 큰 문제는 의존 관계가 복잡해질수록 관리가 불가능해진다는 점이었습니다. 바로 이런 문제를 해결하기 위해 선언적 상태 관리가 등장했습니다.
의존성 있는 Provider를 사용하면 자동 재계산이 가능해집니다. 또한 단방향 데이터 흐름이 보장됩니다.
무엇보다 비즈니스 로직을 선언적으로 표현할 수 있다는 큰 장점이 있습니다. 위의 코드를 단계별로 분석해봅시다.
먼저 5번째 줄의 isAdultProvider를 보면 ref.watch(ageProvider)로 나이를 구독하는 것을 알 수 있습니다. 이 Provider는 나이가 19세 이상인지 판단합니다.
다음으로 12번째 줄의 accessLevelProvider는 isAdultProvider에 의존합니다. 성인이면 전체 접근, 아니면 제한된 접근을 반환합니다.
마지막으로 35번째 줄에서 나이를 증가시키면 의존 체인이 자동으로 재계산되어 모든 값이 갱신됩니다. 실제 서비스에서는 어떻게 활용될까요?
쇼핑몰 앱을 예로 들어봅시다. 장바구니의 상품 목록(cartItemsProvider)이 있고, 이를 기반으로 총 금액(totalPriceProvider)을 계산합니다.
총 금액에 따라 배송비(shippingFeeProvider)가 결정되고, 최종 결제 금액(finalPriceProvider)이 산출됩니다. 사용자가 상품을 추가하거나 제거하면 이 모든 값이 자동으로 재계산됩니다.
많은 이커머스 플랫폼에서 이런 패턴을 적극적으로 사용하고 있습니다. 주의해야 할 점도 있습니다.
초보 개발자들이 흔히 하는 실수는 순환 참조를 만드는 것입니다. Provider A가 Provider B에 의존하고, Provider B가 다시 Provider A에 의존하면 무한 루프에 빠집니다.
또 다른 실수는 Provider 안에서 ref.read를 사용하는 것입니다. ref.read는 의존성을 만들지 않으므로 값이 변경되어도 재계산되지 않습니다.
Provider 내부에서는 항상 ref.watch를 사용해야 합니다. 김개발 씨는 의존성 있는 Provider를 구현하고 테스트해봤습니다.
나이를 변경하니 성인 여부와 접근 레벨이 자동으로 업데이트되었습니다. "와, 정말 편리하네요!" 의존성 있는 Provider 체인을 제대로 활용하면 복잡한 비즈니스 로직도 깔끔하게 표현할 수 있습니다.
여러분도 이 패턴을 실전에 적용해 보세요.
실전 팁
💡 - Provider 내부에서는 항상 ref.watch를 사용하여 의존성을 명시하세요
- 의존 관계가 복잡해지면 다이어그램으로 그려보면 이해하기 쉽습니다
4. 테마 변경 예제
김개발 씨는 다크 모드 기능을 구현하라는 과제를 받았습니다. 버튼 하나로 전체 앱의 테마를 바꿔야 했습니다.
"이건 어떻게 해야 하지?" 고민하던 중 박시니어 씨가 힌트를 줬습니다. "테마도 결국 상태입니다."
테마 변경 예제는 ref.watch를 활용하여 앱 전체의 색상, 폰트, 스타일을 동적으로 변경하는 실전 패턴입니다. 마치 방의 조명을 켜고 끄듯이, 하나의 Provider 값만 바꾸면 모든 화면이 자동으로 새로운 테마를 적용받습니다.
사용자 경험을 크게 향상시킬 수 있는 필수 기능입니다.
다음 코드를 살펴봅시다.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
// 다크 모드 여부를 관리하는 Provider
final isDarkModeProvider = StateProvider<bool>((ref) => false);
// 테마 데이터를 제공하는 Provider
final themeProvider = Provider<ThemeData>((ref) {
final isDark = ref.watch(isDarkModeProvider);
return isDark
? ThemeData.dark().copyWith(
primaryColor: Colors.teal,
scaffoldBackgroundColor: Colors.grey[900],
)
: ThemeData.light().copyWith(
primaryColor: Colors.blue,
scaffoldBackgroundColor: Colors.white,
);
});
class ThemeApp extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// 테마 Provider 구독
final theme = ref.watch(themeProvider);
return MaterialApp(
theme: theme,
home: ThemeScreen(),
);
}
}
class ThemeScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final isDark = ref.watch(isDarkModeProvider);
return Scaffold(
appBar: AppBar(title: Text('테마 변경 예제')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
isDark ? '다크 모드' : '라이트 모드',
style: TextStyle(fontSize: 24),
),
SizedBox(height: 20),
ElevatedButton(
onPressed: () {
ref.read(isDarkModeProvider.notifier).state = !isDark;
},
child: Text('테마 변경'),
),
],
),
),
);
}
}
김개발 씨는 이번 주 스프린트에서 가장 흥미로운 작업을 배정받았습니다. 바로 다크 모드 기능 구현이었습니다.
최근 많은 사용자들이 밤에 앱을 사용할 때 눈이 부시다는 피드백을 보내왔기 때문입니다. 처음에는 막막했습니다.
"모든 위젯의 색상을 일일이 바꿔야 하나?" 걱정이 앞섰습니다. 박시니어 씨가 지나가다 말했습니다.
"테마도 결국 하나의 상태입니다. Provider로 관리하면 간단해요." 테마 변경을 상태 관리로 어떻게 해결할 수 있을까요?
쉽게 비유하자면, 무대의 조명과 같습니다. 무대 감독이 조명 스위치 하나만 조작하면 전체 무대의 분위기가 바뀝니다.
배우 한 명 한 명에게 다가가서 조명을 조절할 필요가 없습니다. 테마도 마찬가지입니다.
하나의 Provider 값만 변경하면 앱의 모든 화면이 자동으로 새로운 스타일을 적용받습니다. 테마 기능이 없던 시절에는 어땠을까요?
각 위젯마다 색상을 하드코딩해야 했습니다. 다크 모드를 추가하려면 모든 Text 위젯의 색상을 조건문으로 분기해야 했습니다.
새로운 화면을 추가할 때마다 라이트 모드와 다크 모드 색상을 각각 지정해야 했죠. 실수로 하나라도 빠뜨리면 일관성 없는 UI가 만들어졌습니다.
바로 이런 문제를 해결하기 위해 중앙화된 테마 관리가 필요했습니다. 테마를 Provider로 관리하면 일관성 있는 디자인이 자동으로 보장됩니다.
또한 유지보수가 쉬워집니다. 무엇보다 사용자 선호도를 저장하여 앱을 다시 열어도 같은 테마를 유지할 수 있습니다.
위의 코드를 자세히 살펴보겠습니다. 먼저 5번째 줄의 isDarkModeProvider는 다크 모드 활성화 여부를 나타내는 간단한 불리언 값입니다.
다음으로 8번째 줄의 themeProvider는 isDarkModeProvider에 의존하여 적절한 ThemeData 객체를 반환합니다. 조건에 따라 다크 테마 또는 라이트 테마를 제공하죠.
마지막으로 28번째 줄에서 MaterialApp의 theme 속성에 이 Provider를 연결하면, 앱 전체가 자동으로 테마를 적용받습니다. 실제 프로젝트에서는 어떻게 확장할 수 있을까요?
뉴스 리더 앱을 개발한다고 가정해봅시다. 다크 모드뿐만 아니라 폰트 크기 조절, 색상 테마 선택(블루, 그린, 퍼플) 등 다양한 커스터마이징 옵션을 제공할 수 있습니다.
각 설정을 별도 Provider로 관리하고, 최종 themeProvider에서 이들을 모두 조합하여 완전한 ThemeData를 만듭니다. 사용자가 설정을 바꾸는 순간 전체 앱이 즉시 반영됩니다.
주의할 점이 있습니다. 초보 개발자들은 흔히 테마 변경 시 상태를 초기화하는 실수를 합니다.
예를 들어 사용자가 글을 작성하던 중 테마를 변경했는데 입력한 내용이 사라지면 안 됩니다. 테마는 시각적 표현일 뿐이므로 비즈니스 로직이나 사용자 데이터와는 독립적으로 관리해야 합니다.
또한 테마 변경 시 애니메이션을 추가하면 사용자 경험이 더욱 부드러워집니다. 김개발 씨는 테마 기능을 완성하고 여러 번 테스트해봤습니다.
버튼을 누를 때마다 화면이 부드럽게 전환되었습니다. "이제 사용자들이 밤에도 편하게 앱을 쓸 수 있겠네요!" 테마 관리를 Provider로 구현하면 코드가 깔끔해지고 확장성도 좋아집니다.
여러분도 이 패턴을 활용하여 멋진 사용자 경험을 만들어보세요.
실전 팁
💡 - SharedPreferences와 연동하여 테마 설정을 영구 저장하면 좋습니다
- Theme.of(context) 대신 Provider를 사용하면 테마 변경 시 불필요한 리빌드를 방지할 수 있습니다
5. 필터링된 리스트 예제
김개발 씨는 할 일 목록 앱을 개발 중이었습니다. 전체 보기, 완료된 항목만 보기, 미완료 항목만 보기 기능을 구현해야 했습니다.
"리스트를 세 번 만들어야 하나?" 걱정하던 김개발 씨에게 박시니어 씨가 말했습니다. "하나의 리스트를 필터링하면 됩니다."
필터링된 리스트 예제는 원본 데이터와 필터 조건을 별도 Provider로 관리하고, 의존성 있는 Provider로 필터링된 결과를 계산하는 패턴입니다. 마치 커피 필터처럼 원하는 것만 골라내는 방식입니다.
이를 통해 검색, 정렬, 카테고리 필터링 등 다양한 기능을 효율적으로 구현할 수 있습니다.
다음 코드를 살펴봅시다.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
// 할 일 모델
class Todo {
final String id;
final String title;
final bool isCompleted;
Todo({required this.id, required this.title, this.isCompleted = false});
Todo copyWith({String? title, bool? isCompleted}) {
return Todo(
id: id,
title: title ?? this.title,
isCompleted: isCompleted ?? this.isCompleted,
);
}
}
// 필터 타입
enum TodoFilter { all, completed, active }
// 전체 할 일 목록
final todoListProvider = StateProvider<List<Todo>>((ref) => [
Todo(id: '1', title: '장보기', isCompleted: false),
Todo(id: '2', title: '운동하기', isCompleted: true),
Todo(id: '3', title: '공부하기', isCompleted: false),
]);
// 현재 필터
final todoFilterProvider = StateProvider<TodoFilter>((ref) => TodoFilter.all);
// 필터링된 할 일 목록
final filteredTodoListProvider = Provider<List<Todo>>((ref) {
final filter = ref.watch(todoFilterProvider);
final todos = ref.watch(todoListProvider);
switch (filter) {
case TodoFilter.completed:
return todos.where((todo) => todo.isCompleted).toList();
case TodoFilter.active:
return todos.where((todo) => !todo.isCompleted).toList();
case TodoFilter.all:
default:
return todos;
}
});
class TodoListScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final filteredTodos = ref.watch(filteredTodoListProvider);
final currentFilter = ref.watch(todoFilterProvider);
return Scaffold(
appBar: AppBar(title: Text('할 일 목록')),
body: Column(
children: [
// 필터 버튼들
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
FilterChip(
label: Text('전체'),
selected: currentFilter == TodoFilter.all,
onSelected: (_) => ref.read(todoFilterProvider.notifier).state = TodoFilter.all,
),
FilterChip(
label: Text('완료'),
selected: currentFilter == TodoFilter.completed,
onSelected: (_) => ref.read(todoFilterProvider.notifier).state = TodoFilter.completed,
),
FilterChip(
label: Text('미완료'),
selected: currentFilter == TodoFilter.active,
onSelected: (_) => ref.read(todoFilterProvider.notifier).state = TodoFilter.active,
),
],
),
// 할 일 목록
Expanded(
child: ListView.builder(
itemCount: filteredTodos.length,
itemBuilder: (context, index) {
final todo = filteredTodos[index];
return ListTile(
title: Text(todo.title),
leading: Checkbox(
value: todo.isCompleted,
onChanged: (value) {
final todos = ref.read(todoListProvider);
final updatedTodos = todos.map((t) {
return t.id == todo.id
? t.copyWith(isCompleted: value)
: t;
}).toList();
ref.read(todoListProvider.notifier).state = updatedTodos;
},
),
);
},
),
),
],
),
);
}
}
김개발 씨는 할 일 관리 앱의 핵심 기능을 개발하고 있었습니다. 사용자들이 전체 할 일을 보거나, 완료된 것만 보거나, 아직 하지 않은 것만 볼 수 있어야 했습니다.
처음 떠오른 방법은 세 개의 리스트를 만드는 것이었습니다. completedTodos, activeTodos, allTodos.
하지만 곧 문제를 발견했습니다. 할 일을 추가하거나 완료 상태를 변경할 때마다 세 개의 리스트를 모두 업데이트해야 했습니다.
"이건 너무 비효율적인데..." 고민하던 중 박시니어 씨가 말했습니다. "하나의 원본 리스트만 관리하고, 필터링은 Provider에게 맡기세요." 필터링된 리스트를 어떻게 효율적으로 관리할까요?
쉽게 비유하자면, 도서관의 검색 시스템과 같습니다. 도서관에는 모든 책이 보관되어 있고, 사서는 컴퓨터로 장르별, 저자별, 출판연도별로 검색합니다.
책을 물리적으로 여러 곳에 복사해서 보관하지 않습니다. 그저 하나의 데이터베이스를 다양한 조건으로 필터링할 뿐입니다.
Provider도 같은 방식으로 작동합니다. 필터링 기능이 서툴던 시절에는 어떤 문제가 있었을까요?
필터별로 별도의 상태를 관리하면 데이터 동기화가 어려워집니다. 예를 들어 사용자가 "장보기" 항목을 완료했다면, completedTodos에는 추가하고, activeTodos에서는 제거해야 합니다.
하나라도 빠뜨리면 화면마다 다른 데이터가 표시되는 버그가 발생합니다. 코드가 복잡해지고 유지보수가 악몽이 됩니다.
바로 이런 문제를 해결하기 위해 단일 진실 공급원 원칙이 중요합니다. 원본 데이터를 하나만 관리하면 데이터 일관성이 자동으로 보장됩니다.
또한 코드 중복이 사라집니다. 무엇보다 새로운 필터 추가가 매우 쉬워집니다.
위의 코드를 단계별로 분석해봅시다. 먼저 25번째 줄의 todoListProvider는 모든 할 일을 담고 있는 원본 데이터입니다.
다음으로 32번째 줄의 todoFilterProvider는 현재 선택된 필터 조건을 저장합니다. 핵심은 35번째 줄의 filteredTodoListProvider입니다.
이 Provider는 필터와 원본 데이터를 모두 구독하고, switch 문으로 조건에 맞는 항목만 반환합니다. 필터나 데이터가 변경되면 자동으로 재계산됩니다.
실제 서비스에서는 어떻게 활용될까요? 쇼핑몰 앱을 생각해봅시다.
수천 개의 상품이 있고, 사용자는 카테고리(의류, 전자제품, 식품), 가격대(1만원 미만, 1-5만원, 5만원 이상), 평점(4점 이상, 3점 이상) 등으로 필터링합니다. 각 필터 조건을 별도 Provider로 관리하고, 최종 filteredProductsProvider에서 모든 조건을 조합하여 결과를 계산합니다.
필터를 변경할 때마다 서버에 요청하지 않고 클라이언트에서 즉시 결과를 보여줄 수 있습니다. 주의해야 할 점도 있습니다.
데이터가 매우 많을 때는 필터링 성능을 고려해야 합니다. 초보 개발자들은 흔히 매번 전체 리스트를 순회하는 실수를 합니다.
수만 개의 항목을 필터링하면 화면이 버벅거릴 수 있습니다. 이럴 때는 메모이제이션을 활용하거나, 백엔드에서 필터링된 데이터를 받아오는 것을 고려해야 합니다.
또한 복잡한 필터 조건은 별도 클래스로 추상화하면 코드가 더 깔끔해집니다. 김개발 씨는 필터링 기능을 완성하고 테스트해봤습니다.
필터 버튼을 누를 때마다 리스트가 즉시 갱신되었습니다. "생각보다 간단하네요!" 필터링된 리스트를 Provider로 관리하면 복잡한 검색 기능도 선언적으로 구현할 수 있습니다.
여러분도 이 패턴을 적용해보세요.
실전 팁
💡 - 검색어 Provider를 추가하면 실시간 검색 기능도 쉽게 구현할 수 있습니다
- 필터 조건이 복잡하면 FilterConfig 클래스를 만들어서 관리하세요
6. 성능 고려사항
김개발 씨가 개발한 앱이 점점 느려지기 시작했습니다. ref.watch를 열심히 사용했는데 왜 이런 일이 생겼을까요?
박시니어 씨가 코드를 리뷰하며 말했습니다. "ref.watch를 잘못 사용하면 불필요한 리빌드가 폭발적으로 늘어납니다."
성능 고려사항은 ref.watch를 효율적으로 사용하여 불필요한 위젯 리빌드를 최소화하는 최적화 기법입니다. 마치 에너지를 절약하듯이, 필요한 부분만 정확하게 갱신하면 앱이 빠르고 부드럽게 작동합니다.
프로덕션 레벨 앱을 만들기 위한 필수 지식입니다.
다음 코드를 살펴봅시다.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final counterProvider = StateProvider<int>((ref) => 0);
final nameProvider = StateProvider<String>((ref) => 'Guest');
// 나쁜 예: 전체 화면이 리빌드됨
class BadExample extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final counter = ref.watch(counterProvider);
final name = ref.watch(nameProvider);
return Column(
children: [
Text('이름: $name'), // name만 사용
ExpensiveWidget(), // counter와 무관하지만 리빌드됨
Text('카운트: $counter'),
],
);
}
}
// 좋은 예: Consumer로 리빌드 범위 최소화
class GoodExample extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
children: [
// name만 구독
Consumer(
builder: (context, ref, child) {
final name = ref.watch(nameProvider);
return Text('이름: $name');
},
),
ExpensiveWidget(), // 리빌드되지 않음
// counter만 구독
Consumer(
builder: (context, ref, child) {
final counter = ref.watch(counterProvider);
return Text('카운트: $counter');
},
),
],
);
}
}
// 비싼 연산을 하는 위젯
class ExpensiveWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
print('ExpensiveWidget 리빌드됨'); // 리빌드 확인용
return Container(
height: 200,
child: ListView.builder(
itemCount: 1000,
itemBuilder: (context, index) => Text('Item $index'),
),
);
}
}
// select를 사용한 최적화
final userProvider = StateProvider<Map<String, dynamic>>((ref) => {
'name': 'Alice',
'age': 25,
'city': 'Seoul',
});
class SelectExample extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// name만 구독 - age나 city가 바뀌어도 리빌드 안 됨
final name = ref.watch(userProvider.select((user) => user['name']));
return Text('이름: $name');
}
}
김개발 씨의 앱이 출시 2주 만에 사용자 1만 명을 돌파했습니다. 기쁨도 잠시, 리뷰에 "앱이 느려요", "버벅거려요"라는 불만이 쏟아지기 시작했습니다.
김개발 씨는 당황했습니다. 로컬 테스트에서는 문제없었는데 실제 사용자가 늘어나니 성능 문제가 드러난 것입니다.
박시니어 씨가 코드를 리뷰하며 여러 문제점을 지적했습니다. "ref.watch를 ConsumerWidget의 build 메서드 최상단에서 남발하면 안 됩니다.
작은 변경이 전체 위젯 트리를 리빌드시킬 수 있어요." 성능 최적화는 왜 중요할까요? 쉽게 비유하자면, 집을 청소하는 방식과 같습니다.
방 하나만 더러워졌는데 집 전체를 청소하면 시간과 에너지가 낭비됩니다. 더러운 방만 청소하면 효율적입니다.
위젯 리빌드도 마찬가지입니다. 변경된 부분만 정확하게 갱신하면 앱이 빠릿빠릿하게 작동합니다.
성능을 고려하지 않던 시절에는 어떤 문제가 있었을까요? 모든 상태 변경이 전체 화면을 다시 그렸습니다.
간단한 카운터 앱은 문제없지만, 복잡한 리스트나 애니메이션이 있는 화면에서는 프레임 드롭이 발생했습니다. 60fps를 유지하지 못하면 사용자는 버벅거림을 느낍니다.
특히 저사양 기기에서는 앱이 거의 사용 불가능한 수준이 되기도 했습니다. 바로 이런 문제를 해결하기 위해 세밀한 리빌드 제어가 필수적입니다.
Consumer를 사용하면 리빌드 범위를 최소화할 수 있습니다. 또한 select 메서드로 객체의 특정 필드만 구독하면 불필요한 리빌드를 원천 차단할 수 있습니다.
무엇보다 DevTools로 리빌드를 시각화하면 문제점을 쉽게 발견할 수 있습니다. 위의 코드를 비교 분석해봅시다.
먼저 8번째 줄의 BadExample을 보면 build 메서드 최상단에서 counter와 name을 모두 구독합니다. 이렇게 하면 둘 중 하나만 변경되어도 ExpensiveWidget을 포함한 전체 Column이 리빌드됩니다.
반면 26번째 줄의 GoodExample은 Consumer로 필요한 부분만 감쌌습니다. name이 바뀌면 해당 Text만, counter가 바뀌면 해당 Text만 리빌드됩니다.
71번째 줄의 SelectExample은 더 나아가 select로 객체의 특정 필드만 구독합니다. 실제 프로젝트에서는 어떤 상황에 적용할까요?
SNS 피드 앱을 예로 들어봅시다. 각 게시글 카드는 작성자 이름, 프로필 사진, 본문, 좋아요 수, 댓글 수 등 많은 정보를 표시합니다.
사용자가 좋아요를 누르면 좋아요 수만 증가해야 하는데, 잘못 구현하면 전체 카드가 리빌드됩니다. 심지어 다른 게시글까지 리빌드될 수 있습니다.
Consumer와 select를 적절히 사용하면 좋아요 버튼과 숫자만 정확하게 갱신됩니다. 주의해야 할 추가 사항들이 있습니다.
초보 개발자들은 흔히 과도한 최적화를 시도합니다. 모든 위젯을 Consumer로 감싸면 오히려 코드가 복잡해지고 가독성이 떨어집니다.
최적화는 성능 문제가 실제로 발생했을 때 시작하는 것이 좋습니다. Flutter DevTools의 Performance 탭에서 리빌드를 측정하고, 병목 지점을 찾아 집중적으로 개선하세요.
또 다른 팁은 const 생성자를 적극 활용하는 것입니다. 변경되지 않는 위젯은 const로 선언하면 리빌드에서 제외됩니다.
박시니어 씨는 김개발 씨와 함께 코드를 리팩토링했습니다. Consumer를 적절히 배치하고, select를 추가했습니다.
앱을 다시 테스트하니 훨씬 부드러워졌습니다. "이제 사용자들이 만족하겠네요!" 성능 최적화는 처음부터 완벽할 필요는 없습니다.
앱을 만들고, 측정하고, 개선하는 반복적인 과정입니다. ref.watch의 기본 원리를 이해하고, Consumer와 select를 적절히 활용하면 누구나 빠른 앱을 만들 수 있습니다.
실전 팁
💡 - Flutter DevTools의 Performance 탭을 활용하여 리빌드를 시각화하세요
- 변경되지 않는 위젯은 const 생성자로 선언하여 리빌드를 방지하세요
- 복잡한 계산은 Provider 내부에서 수행하고, 위젯은 결과만 받아서 표시하세요
이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!
댓글 (0)
함께 보면 좋은 카드 뉴스
LangGraph Fault Tolerance 장애 복구 완벽 가이드
LangGraph 애플리케이션의 장애 복구 메커니즘을 실무 중심으로 배웁니다. Durable Execution부터 Graph Migrations까지 체크포인트 기반 복구 시스템을 스토리텔링으로 쉽게 이해할 수 있습니다.
LangGraph Time Travel 완벽 가이드
LangGraph의 Time Travel 기능으로 특정 시점으로 돌아가고, 상태를 포크하여 분기하고, 대안 경로를 탐색하는 방법을 배웁니다. 실무에서 디버깅과 실험에 활용하는 실전 가이드입니다.
LangGraph Persistence 완벽 가이드
LangGraph의 Persistence 기능을 활용하여 대화 상태를 저장하고 관리하는 방법을 배웁니다. Thread와 Checkpoint를 이해하고, 상태 조회 및 수정 방법을 실무 중심으로 학습합니다.
Riverpod 3.0 쇼핑 앱 종합 프로젝트 완벽 가이드
Flutter와 Riverpod 3.0을 활용한 실무 수준의 쇼핑 앱 개발 과정을 단계별로 학습합니다. 상품 목록, 장바구니, 주문, 인증, 검색 기능까지 모든 핵심 기능을 구현하며 상태 관리의 실전 노하우를 익힙니다.
Riverpod 3.0 Retry 자동 재시도 완벽 가이드
Riverpod 3.0에 새로 추가된 Retry 기능을 활용하여 네트워크 오류나 일시적인 실패 상황에서 자동으로 재시도하는 방법을 배웁니다. 초급 개발자도 쉽게 따라할 수 있도록 실무 예제와 함께 설명합니다.