Flutter/개발 일지

[Flutter 개발일지] Riverpod 리팩토링: StateNotifier 통합 적용

@leem 2025. 3. 5. 15:21
Flutter에서 Riverpod를 활용해 노트 작성, 수정, 삭제 등의 기능을 구현할 때,
여러 가지 방식이 혼용되면 유지보수가 어려워질 수 있다.
이번에 노트 관련 ViewModel을 통일하고, StateNotifier 기반으로 관리하는 방식으로 리팩토링했다.
이 글에서는 기존 코드의 문제점, 변경 사항, 흐름을 정리해본다.

기존 코드의 문제점

1. 비일관적인 상태 관리

  • 노트 목록 → StateNotifierProvider
  • 노트 상세 → FutureProvider.autoDispose.family
  • 노트 작성 → StateNotifierProvider 없이 직접 호출

🔥 문제점:

  • 같은 기능을 다르게 관리하니 유지보수가 어려움
  • FutureProvider는 한 번만 호출되고 상태 변경을 감지하지 않음 → UI 업데이트 누락

2. 상태 변경 후 UI 갱신이 불안정

  • fetchNotes()를 호출해도 invalidate()만 수행하고, 다시 데이터를 가져오지 않는 경우 발생
  • Future.microtask() 같은 비동기 패턴이 섞여 있어 흐름이 복잡

3. 노트 작성 시 상태 관리 부재

  • 작성 도중 로딩, 성공, 실패 상태를 명확하게 구분할 필요가 있음
  • AsyncValue<void>를 사용하면 로딩 중 버튼 비활성화, 에러 발생 시 스낵바 표시 가능
  1.  

리팩토링 방향

  1. 모든 노트 관련 기능을 StateNotifierProvider 기반으로 통일
  2. 노트 작성도 상태(로딩, 성공, 실패)를 관리하도록 AsyncValue<void> 적용
  3. 노트 수정/삭제 후 noteListViewModelProvider를 즉시 갱신하도록 처리
  4. 불필요한 Future.microtask 패턴 제거

변경된 코드 흐름

1. 노트 목록 ViewModel (NoteListViewModel)

  • StateNotifierProvider를 사용하여 노트 리스트를 관리
  • fetchNotes()를 통해 유저 ID 기준으로 노트 데이터를 불러옴
  • 최신순 정렬 기능 추가
final noteListViewModelProvider = StateNotifierProvider<NoteListViewModel, List<Note>>(
  (ref) => NoteListViewModel(ref.read(noteRepositoryProvider)),
);

class NoteListViewModel extends StateNotifier<List<Note>> {
  final NoteRepository _repository;

  NoteListViewModel(this._repository) : super([]);

  Future<void> fetchNotes(int userId) async {
    if (userId == 0) {
      state = [];
      return;
    }

    try {
      final response = await _repository.findAllByUser(userId: userId);
      final notes = response['response']
          ?.map<Note>((json) => Note.fromJson(json))
          .toList() ?? [];
          
      state = notes;
    } catch (e) {
      state = [];
    }
  }
}
 

2. 노트 상세 ViewModel (NoteDetailViewModel)

  • 기존의 FutureProvider.autoDispose.family를 StateNotifier 기반으로 변경
  • 북마크 변경 시 UI가 즉시 반영되도록 state = state.copyWith(...) 패턴 사용
final noteDetailViewModelProvider = StateNotifierProvider.family<NoteDetailViewModel, Note?, int>(
  (ref, noteId) => NoteDetailViewModel(ref.read(noteRepositoryProvider), noteId),
);

class NoteDetailViewModel extends StateNotifier<Note?> {
  final NoteRepository _repository;
  final int noteId;

  NoteDetailViewModel(this._repository, this.noteId) : super(null) {
    fetchNote();
  }

  Future<void> fetchNote() async {
    try {
      final response = await _repository.findById(id: noteId);
      if (response.isNotEmpty) {
        state = Note.fromJson(response);
      }
    } catch (e) {
      state = null;
    }
  }

  Future<void> toggleBookmark() async {
    if (state == null || state!.noteId == null) return;

    final updatedPinStatus = !state!.notePin;
    try {
      await _repository.updateNotePin(state!.noteId!, updatedPinStatus);
      state = state!.copyWith(notePin: updatedPinStatus);
    } catch (e) {}
  }
}

3. 노트 작성 ViewModel (NoteViewModel)

  • 작성 요청을 StateNotifier 기반으로 변경
  • 상태를 AsyncValue<void>로 관리하여 로딩 중 상태, 성공 여부, 에러 처리 가능
  • submitNote() 이후 UI에서 상태를 반영 가능
final noteViewModelProvider = StateNotifierProvider<NoteViewModel, AsyncValue<void>>(
  (ref) => NoteViewModel(ref.read(noteRepositoryProvider)),
);

class NoteViewModel extends StateNotifier<AsyncValue<void>> {
  final NoteRepository _repository;

  NoteViewModel(this._repository) : super(const AsyncData(null));

  Future<void> submitNote(Note note) async {
    state = const AsyncLoading();
    try {
      final result = await _repository.save(note.toJson());
      state = const AsyncData(null);
    } catch (e, stack) {
      state = AsyncError(e, stack);
    }
  }
}

4. 노트 작성 화면 (NoteWritePage)

  • 작성 후 noteListViewModelProvider를 invalidate하여 리스트 갱신
  • 상태를 ref.watch(noteViewModelProvider)로 감지하여 버튼 상태 변경
class NoteWritePage extends ConsumerStatefulWidget {
  const NoteWritePage({super.key});

  @override
  _NoteWritePageState createState() => _NoteWritePageState();
}

class _NoteWritePageState extends ConsumerState<NoteWritePage> {
  final TextEditingController _titleController = TextEditingController();
  final TextEditingController _contentController = TextEditingController();

  Future<void> _handleNoteCompletion(BuildContext context) async {
    final title = _titleController.text.trim();
    final content = _contentController.text.trim();

    if (title.isEmpty || content.isEmpty) {
      CommonSnackbar.warning(context, '제목과 내용을 입력해주세요.');
      return;
    }

    final note = Note(title: title, content: content, userId: ref.read(sessionProvider).id ?? 0);

    try {
      await ref.read(noteViewModelProvider.notifier).submitNote(note);
      ref.invalidate(noteListViewModelProvider); // 노트 리스트 갱신

      Navigator.pushAndRemoveUntil(
        context,
        MaterialPageRoute(builder: (context) => MainScreen(initialIndex: 3)),
        (route) => false,
      );
    } catch (e) {
      CommonSnackbar.error(context, '노트 저장 실패: $e');
    }
  }

  @override
  Widget build(BuildContext context) {
    final isLoading = ref.watch(noteViewModelProvider).isLoading;
    
    return Scaffold(
      appBar: AppBar(
        title: Text('노트 작성'),
        actions: [
          IconButton(
            icon: isLoading ? CircularProgressIndicator() : Icon(Icons.check),
            onPressed: isLoading ? null : () => _handleNoteCompletion(context),
          )
        ],
      ),
      body: NoteWriteBody(titleController: _titleController, contentController: _contentController),
    );
  }
}

 

항목 리팩토링 전 리팩토링 후
상태 관리 방식 StateNotifier, FutureProvider 혼용 StateNotifierProvider로 통일
노트 목록 관리 StateNotifierProvider 동일 (fetchNotes() 최적화)
노트 상세 조회 FutureProvider.autoDispose.family StateNotifierProvider.family로 변경
노트 작성 상태 관리 없이 Repository 직접 호출 StateNotifierProvider 사용 (AsyncValue<void>)
UI 갱신 방식 invalidate()만 수행 → 업데이트 누락 가능 fetchNotes()를 즉시 호출하여 갱신
불필요한 패턴 Future.microtask() 같은 복잡한 흐름 존재 제거 후 깔끔한 로직으로 개선
에러 핸들링 개별적으로 try-catch 처리 AsyncValue<void>로 통합