StatefulWidget의 상태가 변경되었음을 프레임워크에 알려 UI를 다시 그리도록(rebuild) 만드는 기본적인 메서드
class SongListScreen extends StatefulWidget { ... }
class _SongListScreenState extends State<SongListScreen> {
List<Song> _playlist = []; // 상태: 이 화면이 들고 있는 데이터
void _addSong(Song song) {
setState(() { // "나 상태 바꿀게, 화면 다시 그려줘"
_playlist.add(song);
});
}
}
간단하고 직관적이지만, 앱이 커질 경우 다음과 같은 문제가 생길 수 있다.
Flutter의 화면은 위젯을 나무처럼 쌓아서 만드는데, 이걸 위젯 트리라고 한다.
RootTabs
├── SongListScreen
│ └── SongCard
└── PlaylistScreen

이런 경우에 setState는 데이터를 위젯 안에 가둬놓아서 다른 화면에서 같은 데이터가 필요하면, 위로 올려서 아래로 내려주는 수 밖에 없게 된다.
이번 프로젝트에서 playlist 데이터가 필요한 곳은 다음 세 군데다.
// RootTabs가 데이터를 들고, 아래로 계속 내려줌
SongListScreen(
onAddedToParent: _addSong, // 콜백 내려줌
)
PlaylistScreen(
playlist: _playlist, // 데이터 내려줌
onRemove: _removeSong, // 콜백 내려줌
)
이 프로젝트의 경우는 화면이 두 개 뿐이라 큰 문제가 되지 않을 수 있지만, 화면이 늘어나게 될 경우,
RootTabs
└── HomeScreen(playlist: _playlist)
└── FeaturedSection(playlist: _playlist)
└── SongRow(playlist: _playlist) // 여기서 실제로 씀
정작 데이터를 쓰지도 않는 중간 위젯들이 전부 데이터를 들고 내려줘야 하는 상황이 발생한다. 이걸 Prop Drilling이라고 한다.
이 문제는 결국, 코드가 길어질수록
즉, 결합도가 너무 높아지게 된다.
// FeaturedSection은 playlist를 쓰지도 않는데
// SongRow에 내려주려고 억지로 받아야 함
class FeaturedSection extends StatelessWidget {
final List<Song> playlist; // 나는 이거 안 쓰는데...
Widget build(BuildContext context) {
return SongRow(playlist: playlist); // 그냥 아래로 전달만 함
}
}
더 직관적인 문제도 존재한다. 이번 프로젝트를 예시로 들자면, 노래 탭에서 노래를 추가하고, 플레이리스트 탭에서 노래가 담긴 것을 확인하고, 삭제를 하면, 플레이리스트에서는 노래가 사라지지만, 다시 노래탭으로 돌아왔을 경우에는 여전히 해당 노래들은 담겨져 있는 것을 확인할 수 있다.
이유는, 세 곳이 각자 독립된 상태를 들고 있기 때문이다.
메모리 어딘가
├── _SongListScreenState { _playlist: [소문의 낙원] }
├── _PlaylistScreenState { _playlist: [] }
└── _RootTabsState { _playlist: [] }
flutter에서 StatefulWidget은 자기 State 객체를 직접 생성하고 소유한다. 이는 완전히 별개인 객체가 각각의 데이터를 들고 있으며, 연결된 통로도 없게 된다.
// SongListScreen — 자기만의 _playlist
final List<Song> _playlist = [];
// RootTabs — 자기만의 _playlist
final List<Song> _playlist = [];
// PlaylistScreen — RootTabs에서 받아서 표시
PlaylistScreen에서 삭제하면 RootTabs._playlist에서는 제거되지만, SongListScreen._playlist는 아무 정보를 받지 못해 여전히 그 곡이 담겨있다고 생각하게 된다. 상태가 여러 곳에 흩어져 있으니까 서로 나타태는 정보가 달라진다.
setState에는 prop drilling과 데이터 정합성 문제가 있었다. Riverpod이라는 상태 관리 라이브러리는 이를 다음과 같이 해결해준다.

"데이터를 위젯 밖으로 꺼내서, 어디서든 직접 꺼내 쓰게 만들자."
// setState: 데이터가 위젯 안에 갇혀있음
_SongListScreenState { _playlist: [...] }
_PlaylistScreenState { _playlist: [...] }
_RootTabsState { _playlist: [...] }
// Riverpod: 데이터가 위젯 밖 전역에 있음
playlistProvider { _playlist: [...] } ← 하나만 존재
├── SongListScreen이 꺼내 씀
├── PlaylistScreen이 꺼내 씀
└── RootTabs가 꺼내 씀
Riverpod에 대해 좀 더 자세히 설명하자면,
Flutter의 상태관리 라이브러리로, 위젯 트리 외부에 상태를 선언하고 어디서든 접근할 수 있게 해주는 것이 핵심이다.

3가지 핵심 포인트다.
// lib/providers/playlist_provider.dart
class PlaylistNotifier extends Notifier<List<Song>> {
List<Song> build() => const []; // 초기값: 빈 리스트
void addSong(Song song) {
final alreadyAdded = state.any((item) => item.id == song.id);
if (alreadyAdded) return;
state = [...state, song]; // 새 리스트로 교체
}
void removeSong(Song song) {
state = state.where((item) => item.id != song.id).toList();
}
}
// 전역 창고 선언
final playlistProvider = NotifierProvider<PlaylistNotifier, List<Song>>(
() => PlaylistNotifier(),
);
playlistProvider는 앱 어디서든 접근할 수 있는 전역 상태 저장소다.
위젯 트리에 종속되지 않기 때문에 어느 화면에서든 직접 참조할 수 있다.
이 패턴의 핵심은 상태가 '단 한 곳에만 존재'한다는 점이다. 어느 화면에서 변경해도 사용자는 동일한 최신 상태를 확인할 수 있다.
main.dart에서 앱 전체를 ProviderScope로 감싸줘야 한다. Provider들이 동작하는 범위를 지정해주게 된다.
void main() {
runApp(
const ProviderScope( // 이걸 빠뜨리면 Provider가 동작 안 함
child: MusicApp(),
),
);
}
// StatelessWidget 대신 ConsumerWidget 사용
class SongListScreen extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
// 창고에서 꺼내 씀
final playlist = ref.watch(playlistProvider);
// playlist가 바뀌면 이 위젯만 자동으로 다시 그려짐
}
}
ref.watch를 쓰면 데이터가 바뀔 때 해당 위젯만 자동으로 다시 그려줄 수 있다. 이는 코드의 유지보수 측면이 향상되는 효과다.
3. ref.read
// 버튼 눌렀을 때
onAdd: () {
ref.read(playlistProvider.notifier).addSong(song);
// addSong 호출 → state 바뀜 → watch하는 위젯 전부 자동 업데이트
}
ref.watch와 ref.read는 둘 다 WidgetRef 객체의 메서드다.
| ref.watch | ref.read | |
|---|---|---|
| 사용 위치 | build 메서드 안 | 이벤트 핸들러 안 |
| 구독 여부 | 구독함 (변경 시 rebuild) | 구독 안 함 |
| 주 용도 | UI에 데이터 표시 | 상태 변경 메서드 호출 |
직접적인 비교를 해보자.
// setState 버전 — 콜백/데이터를 계속 내려줘야 함
SongListScreen(onAddedToParent: _addSong)
PlaylistScreen(playlist: _playlist, onRemove: _removeSong)
// Riverpod 버전 — 파라미터 없음
SongListScreen()
PlaylistScreen()
// setState 버전 — 창고가 세 곳
_SongListScreenState._playlist // 각자 독립
_RootTabsState._playlist // 각자 독립
PlaylistScreen(playlist: ...) // prop으로 받음
// Riverpod 버전 — 창고가 하나
ref.watch(playlistProvider) // 세 곳 모두 동일
Riverpod은 flutter 상태 관리 패키지 중 현재 가장 많이 쓰이는 축에 속하며, 대규모 앱에 적합하다고 한다.
단순히 상태 공유뿐 아니라 비동기 처리(FutureProvider, AsyncNotifier),
의존성 주입 등 다양한 문제를 일관된 방식으로 해결할 수 있어서
팀 단위 프로젝트에서 특히 선호된다.