본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.
본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.
이미지 로딩 중...
AI Generated
2025. 12. 11. · 10 Views
ref.read로 이벤트 핸들러 구현하기
Riverpod의 ref.read와 ref.watch의 차이를 이해하고, 버튼 클릭이나 폼 제출 같은 이벤트 핸들러에서 ref.read를 올바르게 사용하는 방법을 배웁니다. 실무에서 자주 하는 실수와 해결 방법까지 다룹니다.
목차
1. ref.read vs ref.watch 차이
Flutter 입문 3주 차인 김개발 씨는 Riverpod을 사용하다가 혼란에 빠졌습니다. "ref.read와 ref.watch가 둘 다 상태를 읽는 건데, 대체 무슨 차이가 있는 거지?" 선배 개발자 박시니어 씨가 모니터를 보더니 웃으며 말했습니다.
"아, 그거 초보자들이 가장 많이 헷갈려 하는 부분이에요."
ref.watch는 상태를 구독하여 변경될 때마다 위젯을 다시 그립니다. 마치 유튜브 채널을 구독하면 새 영상이 올라올 때마다 알림을 받는 것과 같습니다.
반면 ref.read는 현재 순간의 상태만 한 번 읽어옵니다. 이것은 필요할 때만 영상을 검색해서 보는 것과 비슷합니다.
이 차이를 이해하면 불필요한 리빌드를 막고 성능을 최적화할 수 있습니다.
다음 코드를 살펴봅시다.
// ref.watch: 상태를 구독하여 변경 시 위젯 리빌드
final counter = ref.watch(counterProvider);
Text('카운터: $counter'); // 상태 변경 시 자동으로 업데이트
// ref.read: 현재 값만 한 번 읽기 (이벤트 핸들러용)
onPressed: () {
// 버튼 클릭 시에만 현재 값을 읽어옴
final currentValue = ref.read(counterProvider);
print('현재 카운터: $currentValue');
// 상태 변경도 ref.read 사용
ref.read(counterProvider.notifier).increment();
}
김개발 씨는 카운터 앱을 만들다가 이상한 현상을 발견했습니다. 버튼을 누를 때마다 화면이 두 번씩 깜빡이는 것처럼 보였습니다.
코드를 살펴보니 버튼의 onPressed 안에서 ref.watch를 사용하고 있었습니다. 박시니어 씨가 설명을 시작했습니다.
"ref.watch와 ref.read는 완전히 다른 용도로 만들어진 거예요." ref.watch가 하는 일 ref.watch는 마치 CCTV처럼 상태를 계속 감시합니다. 상태가 조금이라도 변하면 즉시 위젯에게 알려서 화면을 다시 그리게 만듭니다.
마치 뉴스 속보 알림을 켜둔 것과 같습니다. 뉴스가 업데이트되면 자동으로 알림이 오는 것처럼요.
예를 들어 쇼핑몰 앱에서 장바구니 개수를 화면에 표시한다고 생각해봅시다. 사용자가 상품을 추가할 때마다 장바구니 아이콘 옆의 숫자가 자동으로 바뀌어야 합니다.
이럴 때 ref.watch를 사용합니다. ref.read가 하는 일 반면 ref.read는 "지금 이 순간" 상태가 뭔지만 확인합니다.
그 이후에 상태가 변해도 전혀 신경 쓰지 않습니다. 마치 친구에게 "지금 몇 시야?"라고 한 번만 물어보는 것과 같습니다.
시간이 계속 흐르지만, 물어본 순간의 시각만 알면 되는 경우가 있죠. 버튼을 눌렀을 때 어떤 동작을 실행하는 경우가 대표적입니다.
버튼이 눌린 그 순간의 상태만 필요하지, 상태가 변할 때마다 버튼이 반응할 필요는 없습니다. 왜 구분해서 사용해야 할까 김개발 씨가 물었습니다.
"그럼 그냥 다 ref.watch 쓰면 안 돼요? 그게 더 자동으로 업데이트되니까 좋은 거 아닌가요?" 박시니어 씨가 고개를 저었습니다.
"그렇게 하면 큰 문제가 생겨요." 첫 번째 문제는 성능입니다. ref.watch는 상태를 감시하는 CCTV를 설치하는 것과 같습니다.
CCTV가 너무 많으면 시스템이 느려지겠죠? 마찬가지로 불필요하게 ref.watch를 많이 쓰면 앱이 느려집니다.
두 번째 문제는 예상치 못한 동작입니다. onPressed 안에서 ref.watch를 쓰면, 상태가 변할 때마다 빌드 메서드가 실행되고, 그러면 또 새로운 onPressed 함수가 만들어집니다.
마치 회전문에 갇힌 것처럼 끝없이 반복될 수 있습니다. 올바른 사용법 화면에 표시할 데이터는 ref.watch로 구독합니다.
Text, Container, ListView 같은 위젯에 보여줄 값들이죠. 반면 버튼 클릭, 폼 제출, 스크롤 이벤트 같은 일회성 동작에서는 ref.read를 사용합니다.
코드를 다시 보면 명확합니다. Text 위젯은 counter 값이 바뀔 때마다 새로운 숫자를 보여줘야 하니 ref.watch를 사용합니다.
하지만 onPressed는 버튼이 눌린 그 순간에만 실행되면 되니 ref.read를 사용합니다. 실무 활용 실제 프로젝트에서는 어떨까요?
로그인 화면을 예로 들어봅시다. 이메일과 비밀번호 입력 필드가 있고, 로그인 버튼이 있습니다.
입력 필드의 값은 ref.watch로 구독하여 화면에 표시합니다. 하지만 로그인 버튼의 onPressed에서는 ref.read로 현재 입력값을 읽어와 서버에 전송합니다.
김개발 씨는 이제 확실히 이해했습니다. "아, 화면에 표시할 건 watch, 이벤트 처리할 땐 read군요!" 박시니어 씨가 엄지를 치켜세웠습니다.
"정확해요. 이 원칙만 기억하면 Riverpod의 90%는 마스터한 거예요."
실전 팁
💡 - build 메서드 안에서 UI에 표시할 값: ref.watch 사용
- onPressed, onSubmitted 같은 콜백 함수: ref.read 사용
- 상태 변경 메서드 호출: ref.read(provider.notifier) 사용
2. onPressed에서 ref.read 사용
김개발 씨는 할 일 관리 앱을 만들고 있었습니다. "할 일 추가" 버튼을 누르면 새 항목이 리스트에 추가되는 간단한 기능이었습니다.
그런데 자꾸 에러가 발생했습니다. 박시니어 씨가 코드를 보더니 "onPressed에서 ref.watch를 쓰면 안 된다니까요"라고 말했습니다.
onPressed 핸들러는 사용자가 버튼을 누르는 순간에만 실행되는 일회성 함수입니다. 마치 엘리베이터 버튼을 누르는 것과 같습니다.
한 번 누르면 엘리베이터가 오고, 계속 감시할 필요가 없죠. 따라서 onPressed 안에서는 ref.read를 사용하여 현재 상태를 읽거나 상태 변경 메서드를 호출해야 합니다.
다음 코드를 살펴봅시다.
final todoListProvider = StateNotifierProvider<TodoList, List<String>>((ref) {
return TodoList();
});
// 잘못된 예: onPressed에서 ref.watch 사용 (절대 하지 말 것!)
ElevatedButton(
onPressed: () {
// ❌ 빌드 메서드 밖에서 ref.watch 사용하면 에러!
// final todos = ref.watch(todoListProvider);
},
child: Text('추가'),
);
// 올바른 예: onPressed에서 ref.read 사용
ElevatedButton(
onPressed: () {
// ✅ ref.read로 Notifier에 접근하여 메서드 호출
ref.read(todoListProvider.notifier).addTodo('새 할 일');
},
child: Text('할 일 추가'),
);
김개발 씨는 처음에 이해가 되지 않았습니다. "버튼을 눌렀을 때 현재 할 일 목록을 가져오려면 watch를 써야 하는 거 아닌가요?" 박시니어 씨가 천천히 설명하기 시작했습니다.
왜 onPressed에서 ref.watch를 쓰면 안 될까 Riverpod의 설계 원칙을 이해해야 합니다. ref.watch는 위젯의 생명주기와 연결되어 있습니다.
위젯이 생성될 때 구독을 시작하고, 위젯이 사라질 때 구독을 해제합니다. 마치 넷플릭스를 켜면 자동으로 이어보기가 시작되고, 앱을 끄면 재생이 멈추는 것과 같습니다.
그런데 onPressed는 함수입니다. 위젯이 아니라 그냥 코드 덩어리일 뿐이죠.
함수에는 생명주기가 없습니다. 버튼을 누르면 실행되고, 끝나면 사라집니다.
그래서 ref.watch를 사용할 수 없는 겁니다. 실제로 onPressed 안에서 ref.watch를 사용하려고 하면 Riverpod이 에러를 던집니다.
"여기서는 watch를 쓸 수 없어요!"라고 말이죠. ref.read가 정답인 이유 onPressed는 "지금 당장" 무언가를 실행하는 곳입니다.
사용자가 버튼을 눌렀으니 즉시 반응해야 합니다. 상태를 구독하고 지켜볼 필요가 없습니다.
딱 그 순간의 상태만 알면 충분합니다. 쇼핑몰 앱을 예로 들어봅시다.
"장바구니에 추가" 버튼을 눌렀을 때를 생각해보세요. 버튼이 눌린 순간 장바구니 Provider의 addItem 메서드를 호출하면 됩니다.
장바구니 상태가 변하는 걸 계속 지켜볼 필요가 없죠. 그냥 "추가해!"라고 명령만 하면 됩니다.
코드 분석 위 코드의 올바른 예를 보겠습니다. ref.read(todoListProvider.notifier)는 무슨 뜻일까요?
todoListProvider는 할 일 목록을 관리하는 Provider입니다. notifier는 이 목록을 변경할 수 있는 권한을 가진 관리자입니다.
마치 도서관의 사서와 같습니다. 사서만이 책을 추가하거나 삭제할 수 있죠.
ref.read로 notifier에 접근한 다음, addTodo 메서드를 호출합니다. "새 할 일"이라는 문자열을 넘겨주면, notifier가 내부적으로 상태를 업데이트합니다.
ConsumerWidget에서 사용 실무에서는 ConsumerWidget이나 Consumer를 사용합니다. 그러면 ref를 자동으로 받을 수 있습니다.
dart class TodoScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { return ElevatedButton( onPressed: () { ref.read(todoListProvider.notifier).addTodo('새 할 일'); }, child: Text('추가'), ); } } build 메서드 안에 있지만, onPressed 함수는 나중에 실행될 코드입니다. 그래서 ref.read를 사용하는 겁니다.
StatefulWidget에서는 어떻게 StatefulWidget을 사용한다면 ConsumerStatefulWidget으로 바꿔야 합니다. 그러면 ref를 사용할 수 있습니다.
김개발 씨가 코드를 수정했습니다. 이제 버튼을 누르면 정확히 한 번만 할 일이 추가됩니다.
화면이 깜빡이지도 않고, 에러도 발생하지 않습니다. "완벽해요!" 박시니어 씨가 칭찬했습니다.
"이제 이벤트 핸들러의 기본은 마스터했네요."
실전 팁
💡 - onPressed, onTap, onLongPress 등 모든 제스처 핸들러에서 ref.read 사용
- **ref.read(provider.notifier)**로 상태 변경 메서드 호출
- ConsumerWidget 또는 Consumer로 ref 접근
3. Notifier 메서드 호출
김개발 씨는 이제 ref.read를 이해했지만, 새로운 의문이 생겼습니다. "Provider에서 직접 상태를 바꾸면 안 되나요?
왜 notifier를 거쳐야 하죠?" 박시니어 씨는 화이트보드를 꺼내며 설명을 시작했습니다. "Provider는 읽기 전용 창구고, Notifier는 수정 권한을 가진 관리자예요."
StateNotifierProvider는 상태와 로직을 분리합니다. Provider 자체는 현재 상태만 제공하는 읽기 전용입니다.
마치 은행 잔액 조회 창구와 같죠. notifier는 상태를 변경할 수 있는 유일한 주체입니다.
입출금 창구에 해당합니다. 이렇게 분리하면 코드가 안전하고 예측 가능해집니다.
다음 코드를 살펴봅시다.
// StateNotifier 정의: 상태 변경 로직을 담는 클래스
class CounterNotifier extends StateNotifier<int> {
CounterNotifier() : super(0); // 초기값 0
// 상태 변경 메서드들
void increment() => state++; // state는 현재 상태
void decrement() => state--;
void reset() => state = 0;
void setTo(int value) => state = value;
}
// Provider 생성
final counterProvider = StateNotifierProvider<CounterNotifier, int>((ref) {
return CounterNotifier();
});
// 버튼에서 사용
ElevatedButton(
onPressed: () {
// notifier를 통해 메서드 호출
ref.read(counterProvider.notifier).increment();
},
child: Text('+1'),
);
박시니어 씨가 화이트보드에 두 개의 박스를 그렸습니다. 하나는 "상태"라고 쓰고, 다른 하나는 "Notifier"라고 썼습니다.
상태와 로직의 분리 "상태는 그냥 데이터예요. 숫자, 문자열, 객체 같은 거죠.
반면 Notifier는 그 데이터를 어떻게 바꿀지 아는 똑똑한 관리자입니다." 은행을 예로 들어봅시다. 여러분의 계좌 잔액이 "상태"입니다.
그 잔액은 그냥 숫자일 뿐입니다. 하지만 잔액을 바꾸려면 은행 시스템(Notifier)을 거쳐야 합니다.
그냥 직접 잔액을 마음대로 바꿀 수 없죠. 만약 잔액을 직접 수정할 수 있다면 어떻게 될까요?
보안 문제가 생기고, 거래 내역도 남지 않고, 혼란이 발생합니다. Notifier를 거치면 모든 변경이 기록되고, 규칙에 따라 안전하게 처리됩니다.
StateNotifier 클래스 이해하기 CounterNotifier는 StateNotifier를 상속받습니다. StateNotifier<int>에서 int는 "이 Notifier가 관리하는 상태의 타입"을 의미합니다.
카운터니까 정수형이죠. 생성자에서 super(0)을 호출합니다.
이것은 "초기 상태는 0이다"라는 뜻입니다. 마치 은행 계좌를 새로 열 때 잔액 0원으로 시작하는 것과 같습니다.
state 변수의 마법 StateNotifier 안에서는 state라는 특별한 변수를 사용할 수 있습니다. 이것이 현재 상태를 가리킵니다.
state++을 하면 상태가 1 증가합니다. 여기서 중요한 점은, state를 변경하면 Riverpod이 자동으로 모든 구독자에게 알립니다.
마치 라디오 방송국이 새 뉴스를 송출하면 모든 청취자의 라디오에서 소리가 나오는 것처럼요. 메서드로 로직 캡슐화 increment, decrement 같은 메서드를 만든 이유가 뭘까요?
그냥 state++을 직접 호출하면 안 될까요? 메서드로 만들면 여러 장점이 있습니다.
첫째, 이름이 명확합니다. increment()는 "증가한다"는 의미가 분명하죠.
둘째, 나중에 로직을 추가하기 쉽습니다. 예를 들어 "100을 넘으면 0으로 리셋"같은 규칙을 추가할 수 있습니다.
dart void increment() { if (state >= 100) { state = 0; } else { state++; } } 셋째, 테스트하기 쉽습니다. 메서드 단위로 동작을 검증할 수 있으니까요.
실무에서의 활용 실제 프로젝트에서는 더 복잡한 로직이 들어갑니다. 쇼핑몰의 장바구니를 생각해봅시다.
dart class CartNotifier extends StateNotifier<List<Product>> { CartNotifier() : super([]); void addProduct(Product product) { state = [...state, product]; // 불변성 유지 } void removeProduct(String productId) { state = state.where((p) => p.id != productId).toList(); } void clearCart() { state = []; } } 각 메서드가 명확한 책임을 가집니다. addProduct는 상품을 추가하고, removeProduct는 제거하고, clearCart는 전체 비우기입니다.
왜 notifier를 거쳐야 할까 ref.read(counterProvider)는 현재 상태인 int를 반환합니다. 숫자를 받는 거죠.
숫자에는 increment 메서드가 없습니다. ref.read(counterProvider.notifier)는 CounterNotifier 인스턴스를 반환합니다.
이 객체에는 increment, decrement 같은 메서드가 있습니다. 그래서 메서드를 호출할 수 있는 겁니다.
김개발 씨가 이해했다는 표정을 지었습니다. "아, 그래서 .notifier를 붙이는 거군요!" 박시니어 씨가 고개를 끄덕였습니다.
"맞아요. 상태를 읽을 땐 그냥 provider, 상태를 바꿀 땐 provider.notifier예요."
실전 팁
💡 - StateNotifier 안에서 state 변경: 모든 구독자에게 자동 알림
- 메서드로 로직 캡슐화: 코드 재사용성과 테스트 용이성 향상
- 불변성 유지: 리스트나 객체는 새로 생성하여 대입
4. 폼 제출 예제
김개발 씨는 회원가입 화면을 만들고 있었습니다. 이름, 이메일, 비밀번호를 입력받아서 "가입하기" 버튼을 누르면 서버에 전송하는 기능이었습니다.
"이럴 땐 어떻게 ref.read를 사용해야 하죠?" 박시니어 씨가 실무에서 가장 많이 쓰는 패턴을 알려주기 시작했습니다.
폼 제출은 여러 입력 필드의 값을 한꺼번에 처리하는 이벤트입니다. 마치 우체국에서 택배를 보낼 때 주소, 받는 사람, 전화번호를 모두 작성한 후 "발송" 버튼을 누르는 것과 같습니다.
onPressed 핸들러에서 ref.read로 현재 입력값을 모두 읽어와서, Provider의 메서드를 호출하여 비즈니스 로직을 실행합니다.
다음 코드를 살펴봅시다.
// 폼 상태를 관리하는 StateNotifier
class SignUpNotifier extends StateNotifier<AsyncValue<void>> {
SignUpNotifier() : super(const AsyncValue.data(null));
Future<void> signUp(String name, String email, String password) async {
state = const AsyncValue.loading(); // 로딩 시작
try {
// 서버 API 호출 (시뮬레이션)
await Future.delayed(Duration(seconds: 2));
// await authRepository.signUp(name, email, password);
state = const AsyncValue.data(null); // 성공
} catch (e, stack) {
state = AsyncValue.error(e, stack); // 에러 처리
}
}
}
final signUpProvider = StateNotifierProvider<SignUpNotifier, AsyncValue<void>>((ref) {
return SignUpNotifier();
});
// 폼 제출 버튼
ElevatedButton(
onPressed: () {
// 각 TextField의 컨트롤러에서 값을 읽어옴
final name = nameController.text;
final email = emailController.text;
final password = passwordController.text;
// Provider의 메서드 호출
ref.read(signUpProvider.notifier).signUp(name, email, password);
},
child: Text('가입하기'),
);
박시니어 씨가 실무 경험을 공유하기 시작했습니다. "폼 처리는 거의 모든 앱에서 필요해요.
로그인, 회원가입, 프로필 수정, 리뷰 작성 등등..." 폼 처리의 전형적인 흐름 사용자가 폼을 작성하고 제출 버튼을 누릅니다. 그러면 다음과 같은 일이 벌어져야 합니다.
첫째, 입력값을 모읍니다. 이름, 이메일, 비밀번호 같은 필드들의 현재 값을 가져옵니다.
둘째, 유효성을 검사합니다. 이메일 형식이 맞는지, 비밀번호가 충분히 강한지 확인합니다.
셋째, 서버에 전송합니다. API를 호출하여 회원가입을 요청합니다.
넷째, 결과를 처리합니다. 성공하면 다음 화면으로 이동하고, 실패하면 에러 메시지를 표시합니다.
TextEditingController 사용 Flutter에서 입력 필드의 값을 관리하는 방법은 여러 가지입니다. 가장 기본적인 방법은 TextEditingController를 사용하는 것입니다.
dart final nameController = TextEditingController(); final emailController = TextEditingController(); final passwordController = TextEditingController(); TextField( controller: nameController, decoration: InputDecoration(labelText: '이름'), ); 버튼을 눌렀을 때 nameController.text로 현재 입력된 텍스트를 읽어올 수 있습니다. AsyncValue로 비동기 상태 관리 회원가입은 시간이 걸리는 작업입니다.
서버와 통신하는 동안 사용자에게 "처리 중입니다"라고 알려줘야 합니다. 또한 에러가 발생하면 적절한 메시지를 보여줘야 하죠.
AsyncValue는 Riverpod이 제공하는 특별한 타입입니다. 세 가지 상태를 표현할 수 있습니다.
AsyncValue.loading은 "지금 작업 중"입니다. AsyncValue.data는 "성공했고, 결과가 있음"입니다.
AsyncValue.error는 "실패했고, 에러 정보가 있음"입니다. 마치 택배 배송 상태와 같습니다.
"배송 중", "배송 완료", "배송 실패" 세 가지 상태가 있는 것처럼요. 코드 동작 분석 signUp 메서드를 살펴봅시다.
먼저 state를 loading으로 바꿉니다. 그러면 UI에서 로딩 스피너를 표시할 수 있습니다.
try 블록 안에서 실제 서버 API를 호출합니다. 코드 예제에서는 Future.delayed로 시뮬레이션했지만, 실제로는 http 패키지나 dio를 사용하여 네트워크 요청을 보냅니다.
성공하면 state를 data로 바꿉니다. 실패하면 catch 블록이 실행되어 state를 error로 바꿉니다.
UI에서 상태 반영하기 버튼을 누른 후 로딩 상태를 표시하려면 어떻게 할까요? dart final signUpState = ref.watch(signUpProvider); signUpState.when( data: (_) => Text('가입 성공!'), loading: () => CircularProgressIndicator(), error: (e, _) => Text('에러: $e'), ); ref.watch로 signUpProvider를 구독하면, 상태가 바뀔 때마다 UI가 자동으로 업데이트됩니다.
when 메서드를 사용하면 세 가지 상태를 간편하게 처리할 수 있습니다. 실무 팁: 폼 유효성 검사 실제 프로젝트에서는 서버에 보내기 전에 클라이언트에서 먼저 검사합니다.
dart onPressed: () { final name = nameController.text.trim(); final email = emailController.text.trim(); final password = passwordController.text; if (name.isEmpty) { showSnackBar('이름을 입력하세요'); return; } if (!email.contains('@')) { showSnackBar('올바른 이메일을 입력하세요'); return; } if (password.length < 6) { showSnackBar('비밀번호는 6자 이상이어야 합니다'); return; } ref.read(signUpProvider.notifier).signUp(name, email, password); } 이렇게 하면 불필요한 서버 요청을 줄일 수 있고, 사용자 경험도 좋아집니다. 더 나아가기: FormKey 사용 Flutter는 Form 위젯과 GlobalKey를 사용하는 방법도 제공합니다.
더 복잡한 폼에서는 이 방법이 유용합니다. 김개발 씨는 회원가입 화면을 완성했습니다.
버튼을 누르면 로딩 스피너가 돌고, 2초 후 성공 메시지가 뜹니다. "와, 진짜 앱 같아요!" 박시니어 씨가 웃었습니다.
"이제 진짜 앱 만드는 개발자가 된 거예요."
실전 팁
💡 - AsyncValue: 비동기 작업의 로딩, 성공, 에러 상태를 간편하게 관리
- 유효성 검사: 서버 요청 전 클라이언트에서 먼저 검증
- when 메서드: 세 가지 상태를 깔끔하게 처리
5. 네비게이션 예제
김개발 씨는 로그인 버튼을 만들었습니다. 로그인이 성공하면 홈 화면으로 이동해야 했습니다.
"화면 이동도 이벤트 핸들러니까 ref.read를 쓰면 되겠죠?" 박시니어 씨가 고개를 끄덕였습니다. "맞아요.
하지만 네비게이션에는 조금 특별한 패턴이 있어요."
네비게이션은 화면을 전환하는 작업입니다. 마치 책의 페이지를 넘기는 것과 같습니다.
버튼을 눌렀을 때(onPressed) 상태를 확인하고 조건에 따라 화면을 이동합니다. ref.read로 현재 상태를 읽어와서 로그인 여부를 확인하거나, Provider의 메서드를 호출한 후 결과에 따라 Navigator를 사용합니다.
다음 코드를 살펴봅시다.
// 로그인 상태 Provider
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
return AuthNotifier();
});
class AuthState {
final bool isLoggedIn;
final String? userId;
AuthState({required this.isLoggedIn, this.userId});
}
class AuthNotifier extends StateNotifier<AuthState> {
AuthNotifier() : super(AuthState(isLoggedIn: false));
Future<bool> login(String email, String password) async {
await Future.delayed(Duration(seconds: 1));
// 실제로는 서버 API 호출
state = AuthState(isLoggedIn: true, userId: 'user123');
return true; // 성공 여부 반환
}
}
// 로그인 버튼 (네비게이션 포함)
ElevatedButton(
onPressed: () async {
final email = emailController.text;
final password = passwordController.text;
// ref.read로 로그인 메서드 호출
final success = await ref.read(authProvider.notifier)
.login(email, password);
if (success) {
// 성공 시 화면 이동
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => HomeScreen()),
);
} else {
// 실패 시 에러 메시지
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('로그인 실패')),
);
}
},
child: Text('로그인'),
);
박시니어 씨가 설명을 시작했습니다. "네비게이션은 앱의 흐름을 제어하는 중요한 부분이에요." 화면 전환의 두 가지 방법 Flutter의 Navigator는 두 가지 주요 메서드를 제공합니다.
push는 새 화면을 스택에 쌓습니다. 마치 책 위에 다른 책을 올려놓는 것과 같습니다.
뒤로 가기 버튼을 누르면 이전 화면으로 돌아갑니다. pushReplacement는 현재 화면을 새 화면으로 교체합니다.
로그인 화면에서 홈 화면으로 이동할 때 사용합니다. 로그인 화면으로 다시 돌아갈 수 없게 하려면 이 방법을 써야 합니다.
왜 async/await를 사용할까 코드를 보면 onPressed가 async로 선언되어 있습니다. 왜 그럴까요?
login 메서드는 Future를 반환합니다. 서버와 통신하는 동안 기다려야 하기 때문입니다.
await를 사용하면 결과를 받을 때까지 대기했다가 다음 코드를 실행합니다. 만약 await 없이 그냥 호출하면 어떻게 될까요?
login이 끝나기도 전에 if (success) 코드가 실행되어 버립니다. 마치 레스토랑에서 주문하자마자 음식이 나오길 기대하는 것과 같습니다.
기다려야 음식이 나오죠. 상태 기반 네비게이션 실무에서는 더 복잡한 패턴을 사용합니다.
로그인 상태에 따라 자동으로 화면을 전환하는 방식입니다. dart @override Widget build(BuildContext context, WidgetRef ref) { final authState = ref.watch(authProvider); // 로그인 상태에 따라 자동으로 화면 결정 if (authState.isLoggedIn) { return HomeScreen(); } else { return LoginScreen(); } } 이렇게 하면 버튼에서 Navigator를 호출할 필요가 없습니다.
상태만 바꾸면 화면이 자동으로 전환됩니다. 이것을 선언적 네비게이션이라고 합니다.
go_router 패키지 활용 더 큰 앱에서는 go_router 같은 패키지를 사용합니다. Riverpod과 완벽하게 통합됩니다.
dart final routerProvider = Provider<GoRouter>((ref) { final authState = ref.watch(authProvider); return GoRouter( redirect: (context, state) { if (!authState.isLoggedIn && state.location != '/login') { return '/login'; // 미로그인 시 로그인 화면으로 } return null; // 그대로 진행 }, routes: [ GoRoute(path: '/login', builder: (_, __) => LoginScreen()), GoRoute(path: '/home', builder: (_, __) => HomeScreen()), ], ); }); authState가 바뀌면 자동으로 리디렉션이 실행됩니다. 코드 어디에도 Navigator.push가 없습니다.
상태만 관리하면 네비게이션이 자동으로 처리됩니다. 실무 시나리오: 조건부 네비게이션 때로는 여러 조건을 확인해야 합니다.
예를 들어 쇼핑몰에서 "구매하기" 버튼을 눌렀을 때를 생각해봅시다. dart onPressed: () { final authState = ref.read(authProvider); final cartState = ref.read(cartProvider); if (!authState.isLoggedIn) { // 미로그인 시 로그인 화면으로 Navigator.push(context, MaterialPageRoute( builder: (_) => LoginScreen(), )); return; } if (cartState.isEmpty) { // 장바구니가 비어있으면 경고 showDialog(context: context, builder: (_) => AlertDialog( title: Text('장바구니가 비어있습니다'), )); return; } // 모든 조건 통과 시 결제 화면으로 Navigator.push(context, MaterialPageRoute( builder: (_) => CheckoutScreen(), )); } ref.read로 여러 Provider의 상태를 읽어와서 조건을 확인합니다.
각 조건에 따라 다른 화면으로 이동하거나 메시지를 표시합니다. BuildContext와 ref Navigator를 사용하려면 BuildContext가 필요합니다.
ConsumerWidget을 사용하면 build 메서드에서 context와 ref를 모두 받을 수 있어 편리합니다. 김개발 씨는 로그인 화면을 완성했습니다.
이메일과 비밀번호를 입력하고 버튼을 누르니 1초 후 홈 화면으로 부드럽게 전환됩니다. 뒤로 가기를 눌러도 로그인 화면으로 돌아가지 않습니다.
"완벽해요!" 김개발 씨가 기뻐했습니다. 박시니어 씨도 만족스러운 표정을 지었습니다.
실전 팁
💡 - pushReplacement: 로그인 → 홈처럼 뒤로 가기가 필요 없는 경우
- go_router: 복잡한 네비게이션은 전문 패키지 사용 권장
- 선언적 네비게이션: 상태 변경만으로 화면 자동 전환
6. 흔한 실수와 해결법
김개발 씨는 일주일 동안 Riverpod으로 앱을 만들었습니다. 그런데 이상한 버그들이 자꾸 발생했습니다.
"왜 화면이 계속 깜빡일까요?" "왜 이 에러가 뜨는 거죠?" 박시니어 씨가 웃으며 말했습니다. "초보자들이 모두 겪는 과정이에요.
하나씩 해결해봅시다."
ref.read와 ref.watch를 사용할 때 초보 개발자들이 흔히 하는 실수가 있습니다. 빌드 메서드에서 ref.read 사용, onPressed에서 ref.watch 사용, Provider를 너무 자주 재생성 같은 문제들입니다.
이런 실수를 이해하고 해결법을 알면 안정적인 앱을 만들 수 있습니다.
다음 코드를 살펴봅시다.
// ❌ 실수 1: build 메서드에서 ref.read 사용
@override
Widget build(BuildContext context, WidgetRef ref) {
final counter = ref.read(counterProvider); // 잘못됨!
return Text('$counter'); // 상태가 바뀌어도 UI 업데이트 안 됨
}
// ✅ 해결: ref.watch 사용
@override
Widget build(BuildContext context, WidgetRef ref) {
final counter = ref.watch(counterProvider); // 올바름
return Text('$counter'); // 상태 변경 시 자동 업데이트
}
// ❌ 실수 2: 상태를 직접 수정
ref.read(counterProvider)++; // 에러 발생!
// ✅ 해결: notifier를 통해 메서드 호출
ref.read(counterProvider.notifier).increment();
// ❌ 실수 3: Provider를 함수 안에서 생성
void myFunction() {
final provider = Provider((ref) => MyService()); // 잘못됨!
}
// ✅ 해결: Provider는 최상위 또는 클래스 static 변수로
final myProvider = Provider((ref) => MyService());
박시니어 씨가 김개발 씨의 코드를 살펴보며 하나씩 짚어주기 시작했습니다. 실수 1: build에서 ref.read 사용 가장 흔한 실수입니다.
많은 초보자들이 "상태를 읽는다"는 생각에 ref.read를 사용합니다. 하지만 이렇게 하면 화면이 업데이트되지 않습니다.
왜 그럴까요? ref.read는 "지금 이 순간"의 값만 가져옵니다.
구독을 하지 않기 때문에 나중에 상태가 바뀌어도 위젯은 모릅니다. 마치 신문을 한 번 사서 읽고 그대로 보관하는 것과 같습니다.
새로운 뉴스가 나와도 신문은 그대로입니다. ref.watch는 다릅니다.
상태를 구독하여 변경될 때마다 build 메서드를 다시 실행합니다. 신문을 정기 구독하면 매일 새 신문이 배달되는 것처럼요.
실수 2: 상태를 직접 수정 김개발 씨가 이렇게 작성한 코드가 있었습니다. ```dart onPressed: () { ref.read(counterProvider)++; // 에러!
} ``` 이것은 두 가지 문제가 있습니다. 첫째, counterProvider는 int를 반환합니다.
int는 불변 타입이라 ++ 연산을 해도 원본이 바뀌지 않습니다. 둘째, 설령 바뀐다고 해도 Riverpod은 이 변경을 감지하지 못합니다.
올바른 방법은 notifier의 메서드를 호출하는 것입니다. notifier는 상태를 변경할 권한을 가진 유일한 주체입니다.
메서드 안에서 state를 바꾸면 Riverpod이 자동으로 모든 구독자에게 알립니다. 실수 3: Provider를 잘못된 위치에 선언 Provider는 반드시 최상위 레벨에 선언해야 합니다.
함수 안이나 클래스 안에 선언하면 안 됩니다. ```dart // ❌ 잘못된 예 class MyWidget extends ConsumerWidget { final myProvider = Provider((ref) => MyService()); // 에러!
@override Widget build(BuildContext context, WidgetRef ref) { // ... } } // ✅ 올바른 예 final myProvider = Provider((ref) => MyService()); class MyWidget extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final service = ref.watch(myProvider); // ...
} } ``` 왜 그럴까요? Provider는 앱 전체에서 단 하나만 존재해야 하는 싱글톤입니다.
위젯이 생성될 때마다 새로운 Provider를 만들면 상태가 초기화되어 버립니다. 실수 4: 순환 의존성 Provider A가 Provider B를 참조하고, Provider B가 다시 Provider A를 참조하면 순환 의존성 에러가 발생합니다.
dart // ❌ 순환 의존성 final providerA = Provider((ref) { final b = ref.watch(providerB); // B를 참조 return 'A: $b'; }); final providerB = Provider((ref) { final a = ref.watch(providerA); // A를 참조 - 순환! return 'B: $a'; }); 이럴 때는 설계를 다시 해야 합니다.
공통 로직을 별도 Provider로 분리하거나, 의존성 방향을 한쪽으로 통일합니다. 실수 5: 상태 불변성 위반 리스트나 객체를 다룰 때 주의해야 합니다.
dart // ❌ 잘못된 예: 기존 리스트를 직접 수정 void addTodo(String todo) { state.add(todo); // 리스트 자체는 바뀌지 않아서 UI 업데이트 안 됨 } // ✅ 올바른 예: 새 리스트를 생성 void addTodo(String todo) { state = [...state, todo]; // 새 리스트를 만들어 대입 } Riverpod은 state가 완전히 새로운 객체로 바뀌어야 변경을 감지합니다. 기존 객체의 내부만 수정하면 인지하지 못합니다.
실수 6: 메모리 누수 Provider를 dispose하지 않으면 메모리 누수가 발생할 수 있습니다. 특히 StreamProvider나 수동으로 리소스를 관리하는 경우 주의해야 합니다.
dart final myProvider = StateNotifierProvider.autoDispose<MyNotifier, MyState>((ref) { final notifier = MyNotifier(); // 정리 작업 등록 ref.onDispose(() { notifier.dispose(); }); return notifier; }); autoDispose를 붙이면 Provider가 더 이상 사용되지 않을 때 자동으로 해제됩니다. 디버깅 팁 문제가 생기면 다음을 확인하세요.
첫째, ProviderObserver를 사용하여 모든 Provider의 변경 사항을 로그로 출력합니다. 둘째, Riverpod의 공식 DevTools를 설치하면 Provider 상태를 시각적으로 확인할 수 있습니다.
셋째, 에러 메시지를 꼼꼼히 읽으세요. Riverpod은 친절한 에러 메시지를 제공합니다.
김개발 씨는 자신의 코드에서 실수 1과 실수 5를 발견했습니다. 고치고 나니 모든 버그가 사라졌습니다.
"와, 이제 제대로 동작해요!" 박시니어 씨가 어깨를 두드렸습니다. "실수를 통해 배우는 게 가장 빠른 방법이에요.
이제 여러분은 Riverpod의 ref.read를 완전히 마스터했습니다!"
실전 팁
💡 - build에서는 항상 ref.watch: UI 업데이트를 위해 필수
- 불변성 유지: 리스트/객체는 새로 생성하여 대입
- autoDispose: 메모리 누수 방지를 위해 적극 활용
- ProviderObserver: 디버깅 시 상태 변경 추적에 유용
이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!
댓글 (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 기능을 활용하여 네트워크 오류나 일시적인 실패 상황에서 자동으로 재시도하는 방법을 배웁니다. 초급 개발자도 쉽게 따라할 수 있도록 실무 예제와 함께 설명합니다.