🤖

본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.

⚠️

본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.

이미지 로딩 중...

Notifier로 Todo 리스트 만들기 실전 가이드 - 슬라이드 1/7
A

AI Generated

2025. 12. 11. · 10 Views

Notifier로 Todo 리스트 만들기 실전 가이드

Riverpod 3.0의 Notifier를 활용하여 실무에서 바로 사용할 수 있는 Todo 리스트를 만들어봅니다. 상태 관리의 기초부터 실전 활용까지 단계별로 학습합니다.


목차

  1. Todo 모델 클래스 정의
  2. TodoNotifier 클래스 작성
  3. 할 일 추가 메서드
  4. 할 일 삭제 메서드
  5. 완료 토글 메서드
  6. UI 연동과 전체 코드

1. Todo 모델 클래스 정의

신입 개발자 이지수 씨는 첫 Flutter 프로젝트로 Todo 앱을 만들기로 했습니다. 멘토인 최선배 개발자가 말했습니다.

"Todo 앱은 상태 관리를 배우기 가장 좋은 예제죠. 먼저 데이터 구조부터 설계해볼까요?"

Todo 모델 클래스는 할 일 항목의 데이터 구조를 정의하는 청사진입니다. 각 할 일이 어떤 정보를 가져야 하는지, 어떻게 복사하거나 비교할 수 있는지를 명확히 정의합니다.

이것은 앱 전체에서 사용될 핵심 데이터 타입입니다.

다음 코드를 살펴봅시다.

class Todo {
  const Todo({
    required this.id,
    required this.title,
    this.isCompleted = false,
  });

  final String id;
  final String title;
  final bool isCompleted;

  // copyWith: 일부 속성만 변경한 새 인스턴스 생성
  Todo copyWith({
    String? id,
    String? title,
    bool? isCompleted,
  }) {
    return Todo(
      id: id ?? this.id,
      title: title ?? this.title,
      isCompleted: isCompleted ?? this.isCompleted,
    );
  }
}

이지수 씨는 화면에 IDE를 띄우고 멘토의 설명을 듣기 시작했습니다. "Todo 앱을 만들려면 먼저 할 일 하나하나가 어떤 정보를 담고 있어야 하는지 정의해야 해요." 최선배 개발자가 종이에 간단한 표를 그렸습니다.

"할 일을 관리하려면 최소한 세 가지 정보가 필요하죠. 첫째, 각 항목을 구분할 고유 ID.

둘째, 무엇을 해야 하는지 알려주는 제목. 셋째, 완료했는지 여부를 나타내는 플래그." 모델 클래스란 무엇일까요? 쉽게 비유하자면, 모델 클래스는 마치 서류 양식과 같습니다.

회사에서 휴가 신청서를 작성할 때 정해진 양식이 있듯이, Todo 데이터도 정해진 형식이 필요합니다. 이름, 날짜, 사유를 적는 칸이 있는 것처럼 Todo에는 id, title, isCompleted라는 정보 칸이 있는 것입니다.

모델 클래스가 없던 시절에는 어땠을까요? 초창기 개발자들은 Map이나 List를 사용해 데이터를 관리했습니다.

어떤 곳에서는 인덱스 0이 제목이고, 다른 곳에서는 인덱스 1이 제목인 식으로 혼란스러웠죠. 오타가 나면 런타임 에러가 발생했고, 어떤 속성이 있는지 IDE가 자동완성 해주지도 않았습니다.

바로 이런 문제를 해결하기 위해 모델 클래스가 등장했습니다. 모델 클래스를 사용하면 타입 안정성이 보장됩니다.

title은 항상 String이고, isCompleted는 항상 bool입니다. 또한 자동완성도 가능해집니다.

todo.을 타이핑하면 IDE가 사용 가능한 속성들을 보여줍니다. 무엇보다 코드의 의도가 명확해진다는 큰 이점이 있습니다.

위의 코드를 한 줄씩 살펴보겠습니다. 먼저 클래스를 const로 선언했습니다.

이는 인스턴스가 불변임을 의미합니다. 다음으로 세 개의 final 필드를 선언했는데, 이들은 한 번 설정되면 변경할 수 없습니다.

isCompleted의 기본값은 false로, 새로운 할 일은 기본적으로 미완료 상태입니다. 가장 중요한 부분은 copyWith 메서드입니다.

Flutter에서는 불변 객체를 사용하는 것이 권장되므로, 기존 객체의 일부만 변경하려면 새 객체를 만들어야 합니다. copyWith는 바로 이 작업을 편리하게 해줍니다.

실제 현업에서는 어떻게 활용할까요? 예를 들어 전자상거래 앱을 개발한다고 가정해봅시다.

상품 목록, 장바구니, 주문 내역 등 모든 데이터는 모델 클래스로 정의됩니다. Product 클래스, CartItem 클래스, Order 클래스 등이 명확한 구조를 가지고 있어야 팀원들이 협업하기 쉽고, 버그도 줄어듭니다.

하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 모든 필드를 mutable하게 만드는 것입니다.

var나 late를 남발하면 어디서 값이 변경되는지 추적하기 어려워집니다. 따라서 final로 선언하고 copyWith로 변경하는 패턴을 사용해야 합니다.

다시 이지수 씨의 이야기로 돌아가 봅시다. 최선배 개발자의 설명을 들은 이지수 씨는 고개를 끄덕였습니다.

"아, 그래서 Flutter 앱에서 모든 데이터를 클래스로 정의하는 거군요!" Todo 모델을 제대로 정의하면 이후 상태 관리가 훨씬 수월해집니다. 여러분도 어떤 앱을 만들든 먼저 모델 클래스부터 설계해 보세요.

실전 팁

💡 - copyWith 메서드를 직접 작성하는 게 번거롭다면 freezed 패키지를 사용하면 자동 생성할 수 있습니다

  • 모델 클래스에는 비즈니스 로직을 넣지 말고 순수한 데이터 구조만 유지하세요
  • JSON 직렬화가 필요하면 fromJson, toJson 메서드를 추가하세요

2. TodoNotifier 클래스 작성

"모델은 만들었으니 이제 상태를 관리할 차례예요." 최선배 개발자가 새 파일을 열었습니다. "Riverpod 3.0에서는 Notifier를 사용해서 상태를 관리하죠.

예전 StateNotifier보다 훨씬 직관적이에요."

TodoNotifier는 Todo 리스트의 상태를 관리하고 변경하는 역할을 담당하는 클래스입니다. Notifier를 상속받아 초기 상태를 정의하고, 상태를 변경하는 메서드들을 제공합니다.

이것은 UI와 비즈니스 로직을 분리하는 핵심 구조입니다.

다음 코드를 살펴봅시다.

import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'todo_notifier.g.dart';

@riverpod
class TodoNotifier extends _$TodoNotifier {
  @override
  List<Todo> build() {
    // 초기 상태 정의
    return [
      const Todo(
        id: '1',
        title: 'Riverpod 학습하기',
        isCompleted: false,
      ),
    ];
  }

  // 이후 메서드들이 추가될 예정
}

이지수 씨는 코드를 보며 궁금해졌습니다. "Notifier는 정확히 무엇을 하는 건가요?" 최선배 개발자가 화이트보드에 그림을 그리기 시작했습니다.

"Notifier는 상태를 담고 있는 상자라고 생각하면 됩니다. 이 상자 안에는 현재 Todo 리스트가 들어있고, 우리는 이 상자를 통해서만 리스트를 변경할 수 있어요." Notifier의 개념을 이해해봅시다. 은행 계좌를 생각해보세요.

여러분은 직접 금고에 들어가 돈을 넣거나 뺄 수 없습니다. 반드시 창구 직원을 통해 입금이나 출금을 해야 하죠.

Notifier도 마찬가지입니다. UI에서 직접 상태를 변경하는 게 아니라, Notifier가 제공하는 메서드를 통해서만 변경할 수 있습니다.

이렇게 하면 누가 언제 상태를 바꿨는지 추적하기 쉽고, 검증 로직도 한 곳에서 관리할 수 있습니다. 예전에는 어떻게 상태를 관리했을까요?

초기 Flutter에서는 StatefulWidget의 setState를 사용했습니다. 간단한 앱에서는 괜찮았지만, 여러 화면에서 같은 데이터를 공유해야 할 때 문제가 생겼습니다.

데이터를 위젯 트리 전체로 전달해야 했고, 깊이 중첩된 위젯에서는 코드가 지저분해졌습니다. Provider가 나왔지만 보일러플레이트 코드가 많았죠.

Riverpod 3.0의 Notifier가 이 모든 문제를 깔끔하게 해결했습니다. 코드 생성을 활용해 보일러플레이트를 최소화했습니다.

@riverpod 어노테이션 하나면 Provider가 자동으로 생성됩니다. 또한 타입 안정성이 완벽하게 보장됩니다.

컴파일 타임에 에러를 잡아주므로 런타임 버그가 줄어듭니다. 코드를 단계별로 살펴봅시다.

먼저 @riverpod 어노테이션은 riverpod_generator에게 "이 클래스로 Provider를 만들어줘"라고 알려줍니다. part 선언은 생성된 코드가 들어갈 파일을 지정합니다.

build 명령을 실행하면 todo_notifier.g.dart 파일이 자동 생성됩니다. TodoNotifier는 _$TodoNotifier를 상속받습니다.

이 클래스는 자동 생성되는 베이스 클래스입니다. build 메서드는 초기 상태를 반환하는 역할을 합니다.

여기서는 샘플 Todo 항목 하나를 담은 리스트를 반환했습니다. 실무에서는 어떻게 활용될까요?

실제 앱에서는 build 메서드에서 로컬 데이터베이스나 SharedPreferences에서 저장된 Todo를 불러올 수 있습니다. 혹은 API 호출로 서버에서 데이터를 가져올 수도 있죠.

초기화 로직이 복잡해도 build 메서드 안에 깔끔하게 정리할 수 있습니다. 주의할 점이 있습니다.

초보자들이 자주 하는 실수는 build 메서드에서 비동기 작업을 직접 하는 것입니다. build는 동기 함수이므로, 비동기 데이터가 필요하면 AsyncNotifier를 사용해야 합니다.

또 다른 실수는 build 메서드 안에서 state를 변경하려는 것입니다. build는 초기값만 반환해야 합니다.

이지수 씨가 질문했습니다. "그런데 _$TodoNotifier는 어디서 온 건가요?" 최선배 개발자가 웃으며 답했습니다.

"아, 그건 코드 생성기가 만들어주는 거예요. flutter pub run build_runner build 명령을 실행하면 자동으로 생성됩니다." Notifier의 골격을 잡았으니 이제 실제 기능을 추가할 차례입니다.

다음 단계에서는 할 일을 추가하는 메서드를 작성해봅시다.

실전 팁

💡 - 코드 생성은 flutter pub run build_runner watch로 자동화할 수 있습니다

  • AsyncNotifier를 사용하면 비동기 초기화가 가능합니다
  • 여러 Notifier를 조합해서 복잡한 상태 관리도 깔끔하게 처리할 수 있습니다

3. 할 일 추가 메서드

"Todo 리스트에 새 항목을 추가하려면 어떻게 해야 할까요?" 이지수 씨가 물었습니다. 최선배 개발자가 답했습니다.

"state를 직접 수정하는 게 아니라, 새로운 리스트를 만들어서 state에 할당해야 해요. 불변성을 유지하는 게 핵심이죠."

할 일 추가 메서드는 사용자가 입력한 제목으로 새로운 Todo를 생성하고, 기존 리스트에 추가하는 기능을 담당합니다. 고유 ID를 자동으로 생성하고, 불변성을 유지하면서 상태를 업데이트합니다.

다음 코드를 살펴봅시다.

import 'package:uuid/uuid.dart';

class TodoNotifier extends _$TodoNotifier {
  final _uuid = const Uuid();

  @override
  List<Todo> build() {
    return [
      const Todo(
        id: '1',
        title: 'Riverpod 학습하기',
        isCompleted: false,
      ),
    ];
  }

  void addTodo(String title) {
    // 새 Todo 생성
    final newTodo = Todo(
      id: _uuid.v4(), // 고유 ID 생성
      title: title,
      isCompleted: false,
    );

    // 불변성 유지: 새 리스트 생성
    state = [...state, newTodo];
  }
}

이지수 씨는 메서드를 작성하면서 의문이 생겼습니다. "왜 state.add(newTodo)처럼 직접 추가하면 안 되나요?" 이것은 많은 초보자들이 겪는 혼란입니다.

최선배 개발자가 설명을 시작했습니다. "좋은 질문이에요.

Flutter의 상태 관리는 불변성 원칙에 기반하고 있거든요." 불변성이란 무엇일까요? 도서관을 생각해봅시다. 여러분이 책 목록을 확인하고 있는데, 누군가 몰래 책을 빼거나 추가한다면 혼란스러울 것입니다.

대신 사서가 "새로운 목록이 업데이트되었습니다"라고 공지하면 모두가 최신 목록을 받아볼 수 있죠. 불변성도 마찬가지입니다.

기존 객체를 수정하는 게 아니라 새 객체를 만들어서 전체를 교체하는 것입니다. 불변성을 무시하면 어떤 일이 벌어질까요?

state.add()처럼 직접 수정하면 Riverpod은 변경을 감지하지 못합니다. 리스트의 참조 주소가 같기 때문이죠.

결과적으로 UI가 업데이트되지 않는 버그가 발생합니다. 이런 버그는 찾기도 어렵고, 디버깅하는 데 몇 시간씩 걸릴 수 있습니다.

스프레드 연산자가 해결책입니다. [...state, newTodo] 구문은 기존 state의 모든 항목을 펼쳐서 새 리스트에 담고, 마지막에 newTodo를 추가합니다.

결과는 완전히 새로운 리스트 객체입니다. 참조 주소가 달라지므로 Riverpod이 변경을 감지하고 UI를 자동으로 업데이트합니다.

코드의 세부 사항을 살펴봅시다. 먼저 uuid 패키지를 사용해 고유 ID를 생성합니다.

UUID는 전 세계적으로 고유한 식별자를 만들어주므로, 중복 걱정 없이 사용할 수 있습니다. v4()는 랜덤 UUID를 생성하는 메서드입니다.

newTodo 객체를 생성할 때 title은 매개변수로 받은 값을 사용하고, isCompleted는 false로 초기화합니다. 새로 추가되는 항목은 당연히 미완료 상태여야 하니까요.

마지막으로 state = [...state, newTodo]로 상태를 업데이트합니다. 이 한 줄이 실행되면 Riverpod이 변경을 감지하고, 이 Provider를 구독하는 모든 위젯에 알림을 보냅니다.

위젯들은 자동으로 rebuild되어 새로운 Todo가 화면에 나타납니다. 실제 앱에서는 어떻게 활용될까요?

쇼핑몰 앱의 장바구니를 생각해봅시다. 사용자가 상품을 추가할 때마다 addToCart 메서드가 호출됩니다.

스프레드 연산자로 기존 장바구니 목록에 새 상품을 추가하고, UI는 즉시 업데이트됩니다. 같은 패턴이 위시리스트, 알림 목록, 채팅 메시지 등 모든 리스트 기반 기능에 적용됩니다.

주의할 점도 있습니다. 리스트가 매우 크다면 스프레드 연산자는 성능 문제를 일으킬 수 있습니다.

수천 개의 항목을 매번 복사하는 것은 비효율적이죠. 이런 경우 불변 컬렉션 라이브러리를 사용하거나, 페이지네이션으로 데이터를 나눠서 관리하는 것이 좋습니다.

또 다른 실수는 title 검증을 빠뜨리는 것입니다. 빈 문자열이나 공백만 있는 제목은 추가하지 않도록 검증 로직을 추가해야 합니다.

이지수 씨가 코드를 실행해봤습니다. 텍스트 필드에 "Flutter 위젯 공부하기"를 입력하고 추가 버튼을 눌렀더니 리스트에 새 항목이 나타났습니다.

"와, 정말 작동하네요!" 최선배 개발자가 미소 지었습니다. "이제 삭제 기능도 만들어볼까요?"

실전 팁

💡 - title이 비어있거나 공백만 있으면 early return으로 추가를 막으세요

  • uuid 패키지 대신 timestamp를 ID로 쓸 수도 있지만, 동시 추가 시 충돌 가능성이 있습니다
  • 추가 시 애니메이션 효과를 주려면 AnimatedList를 사용하세요

4. 할 일 삭제 메서드

"추가했으면 삭제도 할 수 있어야겠죠?" 최선배 개발자가 말했습니다. 이지수 씨가 고개를 끄덕였습니다.

"where 메서드를 사용하면 되나요?" "정확합니다. 조건에 맞는 항목만 남기고 새 리스트를 만드는 거죠."

할 일 삭제 메서드는 특정 ID를 가진 Todo를 리스트에서 제거하는 기능을 수행합니다. where 메서드로 필터링하여 해당 ID가 아닌 항목들만 모아 새 리스트를 만들고, 불변성을 유지하면서 상태를 업데이트합니다.

다음 코드를 살펴봅시다.

class TodoNotifier extends _$TodoNotifier {
  final _uuid = const Uuid();

  @override
  List<Todo> build() {
    return [
      const Todo(id: '1', title: 'Riverpod 학습하기'),
    ];
  }

  void addTodo(String title) {
    final newTodo = Todo(
      id: _uuid.v4(),
      title: title,
      isCompleted: false,
    );
    state = [...state, newTodo];
  }

  void removeTodo(String id) {
    // id가 일치하지 않는 항목들만 필터링
    state = state.where((todo) => todo.id != id).toList();
  }
}

이지수 씨는 코드를 보며 생각했습니다. "remove 메서드를 쓰면 안 되나?" 이것 역시 불변성과 관련된 중요한 포인트입니다.

최선배 개발자가 설명을 이어갔습니다. "List의 remove나 removeAt은 기존 리스트를 직접 수정하는 메서드예요.

우리는 새로운 리스트를 만들어야 합니다." where 메서드의 동작 원리를 알아봅시다. 레스토랑에서 예약 명단을 관리한다고 생각해봅시다. 김철수 님이 예약을 취소했을 때, 직원은 기존 명단에서 이름을 지우는 게 아니라 김철수 님을 제외한 나머지 손님들로 새 명단을 작성합니다.

where 메서드도 똑같이 작동합니다. 조건을 만족하는 항목들만 선택해서 새 컬렉션을 만드는 것입니다.

직접 수정하는 방식의 문제점은 무엇일까요? state.removeWhere()처럼 직접 수정하면 UI 업데이트가 되지 않습니다.

Riverpod은 객체의 참조가 바뀌었는지 확인하는데, 같은 객체를 수정하면 참조가 그대로이므로 변경을 감지하지 못합니다. 또한 실행 취소 기능을 구현하기도 어려워집니다.

필터링 패턴이 깔끔한 해결책입니다. where((todo) => todo.id != id) 부분을 천천히 읽어봅시다.

"각 todo에 대해, id가 매개변수로 받은 id와 같지 않으면 포함시켜라"는 의미입니다. 즉, 삭제할 ID를 가진 항목만 제외하고 나머지는 모두 포함하는 것이죠.

where는 Iterable을 반환하므로 toList()를 호출해 List로 변환합니다. 이렇게 만들어진 새 리스트를 state에 할당하면 끝입니다.

코드 흐름을 따라가 봅시다. 사용자가 할 일 옆의 삭제 버튼을 누릅니다.

버튼의 onPressed 콜백에서 removeTodo('some-id')를 호출합니다. where 메서드가 state를 순회하며 조건을 체크합니다.

id가 'some-id'인 항목은 제외되고, 나머지는 새 리스트에 담깁니다. state가 업데이트되고 UI가 자동으로 rebuild됩니다.

실무에서의 활용 사례를 볼까요? SNS 앱에서 게시글을 삭제할 때, 이메일 앱에서 메일을 휴지통으로 보낼 때, 쇼핑 앱에서 장바구니 상품을 제거할 때 모두 같은 패턴을 사용합니다.

심지어 서버 API 호출 후 성공하면 로컬 상태에서도 항목을 제거하는 낙관적 업데이트에도 활용됩니다. 주의해야 할 사항이 있습니다.

존재하지 않는 ID로 removeTodo를 호출하면 어떻게 될까요? where 조건을 만족하는 항목이 없으므로 아무것도 제거되지 않습니다.

에러가 발생하지는 않지만, 사용자에게 "항목을 찾을 수 없습니다" 같은 피드백을 주는 게 좋습니다. 또 다른 고려사항은 삭제 확인 다이얼로그입니다.

실수로 삭제하는 것을 방지하려면 removeTodo를 호출하기 전에 "정말 삭제하시겠습니까?" 확인을 받는 것이 좋습니다. 이지수 씨가 삭제 기능을 테스트해봤습니다.

항목을 추가하고 삭제 버튼을 누르니 깔끔하게 사라졌습니다. "생각보다 간단하네요!" 최선배 개발자가 웃으며 답했습니다.

"Dart의 컬렉션 메서드를 잘 활용하면 코드가 정말 간결해지죠." 이제 완료 표시 기능만 추가하면 기본적인 Todo 앱이 완성됩니다.

실전 팁

💡 - 삭제 전 다이얼로그로 확인을 받으면 사용자 경험이 좋아집니다

  • 실행 취소 기능이 필요하면 삭제된 항목을 임시로 저장해두세요
  • 여러 항목을 한 번에 삭제하려면 whereIn 같은 헬퍼를 만들거나 removeWhere를 활용하세요

5. 완료 토글 메서드

"마지막으로 체크박스를 눌러서 완료 상태를 바꾸는 기능이 필요해요." 최선배 개발자가 말했습니다. 이지수 씨가 물었습니다.

"이것도 새 리스트를 만들어야 하나요?" "정확합니다. 하나만 수정해도 불변성을 유지해야 하죠."

완료 토글 메서드는 특정 ID를 가진 Todo의 완료 상태를 반전시키는 기능입니다. map 메서드로 각 항목을 순회하며, 해당 ID를 찾으면 copyWith로 isCompleted를 변경한 새 객체를 만들고, 나머지는 그대로 유지합니다.

다음 코드를 살펴봅시다.

class TodoNotifier extends _$TodoNotifier {
  final _uuid = const Uuid();

  @override
  List<Todo> build() {
    return [
      const Todo(id: '1', title: 'Riverpod 학습하기'),
    ];
  }

  void addTodo(String title) {
    final newTodo = Todo(id: _uuid.v4(), title: title);
    state = [...state, newTodo];
  }

  void removeTodo(String id) {
    state = state.where((todo) => todo.id != id).toList();
  }

  void toggleTodo(String id) {
    state = state.map((todo) {
      // 해당 ID를 찾으면 완료 상태 반전
      if (todo.id == id) {
        return todo.copyWith(isCompleted: !todo.isCompleted);
      }
      // 다른 항목은 그대로 반환
      return todo;
    }).toList();
  }
}

이지수 씨는 코드를 보며 잠시 생각에 잠겼습니다. "map 메서드는 처음 봐요." 최선배 개발자가 친절히 설명하기 시작했습니다.

"map은 리스트의 각 항목을 변환해서 새 리스트를 만드는 메서드예요. 마치 공장의 컨베이어 벨트처럼요." map 메서드를 이해해봅시다. 세탁소를 생각해봅시다.

더러운 옷들이 들어오면 하나씩 세탁기를 거쳐 깨끗한 옷이 되어 나옵니다. 어떤 옷은 얼룩이 심해서 특별 처리를 하고, 어떤 옷은 가볍게 헹구기만 하죠.

map도 마찬가지입니다. 각 항목에 대해 변환 함수를 적용하고, 결과를 모아 새 리스트를 만듭니다.

왜 findIndex로 찾아서 수정하면 안 될까요? 일부 개발자들은 final index = state.indexWhere(...) 후 state[index] = ...로 직접 수정하려고 합니다.

하지만 이것은 두 가지 문제가 있습니다. 첫째, 기존 리스트를 변경하므로 불변성이 깨집니다.

둘째, final List는 요소 변경이 가능하지만 Riverpod이 감지하지 못합니다. map과 copyWith의 조합이 정석입니다.

map((todo) => ...)은 각 todo를 순회합니다. if (todo.id == id) 조건으로 변경할 항목을 찾습니다.

찾았다면 todo.copyWith(isCompleted: !todo.isCompleted)로 완료 상태만 반전시킨 새 Todo 객체를 생성합니다. 찾지 못했다면 todo를 그대로 반환합니다.

느낌표 연산자(!)는 논리 부정입니다. true면 false로, false면 true로 바꿉니다.

즉, 완료된 항목은 미완료로, 미완료 항목은 완료로 토글되는 것입니다. 단계별로 실행을 따라가 봅시다.

사용자가 두 번째 Todo의 체크박스를 탭합니다. onChanged 콜백에서 toggleTodo('id-2')가 호출됩니다.

map이 리스트를 순회하기 시작합니다. 첫 번째 항목은 id가 다르므로 그대로 반환됩니다.

두 번째 항목은 id가 일치하므로 isCompleted가 반전된 새 객체가 만들어집니다. 나머지 항목들도 그대로 반환됩니다.

toList()로 새 리스트가 완성되고 state에 할당됩니다. 실제 프로젝트에서는 어떻게 쓰일까요?

프로젝트 관리 툴에서 태스크의 상태를 '진행중', '완료', '보류'로 변경할 때 같은 패턴을 씁니다. 온라인 쇼핑몰에서 상품의 찜하기 상태를 토글할 때도 마찬가지입니다.

음악 플레이어에서 곡의 좋아요 상태를 바꿀 때도 동일한 로직이 적용됩니다. 주의할 점이 있습니다.

map은 모든 항목을 순회하므로 리스트가 매우 크면 성능 문제가 생길 수 있습니다. 만약 수천 개의 항목 중 하나만 변경한다면, indexWhere로 인덱스를 찾아 해당 부분만 교체하는 최적화도 고려해볼 수 있습니다.

하지만 대부분의 Todo 앱에서는 항목이 수백 개를 넘지 않으므로 map으로 충분합니다. 또한 존재하지 않는 ID로 toggle을 호출하면 아무 변화가 없습니다.

조건에 맞는 항목이 없으므로 모든 항목이 그대로 반환되기 때문이죠. 필요하다면 변경 성공 여부를 반환하도록 로직을 개선할 수 있습니다.

이지수 씨가 앱을 실행했습니다. 체크박스를 누르니 Todo에 취소선이 그어지고 색이 바뀌었습니다.

"완벽해요!" 최선배 개발자가 만족스럽게 고개를 끄덕였습니다. "이제 UI와 연결하기만 하면 되겠네요."

실전 팁

💡 - copyWith를 사용하므로 다른 속성은 변경되지 않고 안전하게 유지됩니다

  • 완료된 항목을 리스트 맨 아래로 보내려면 sort를 추가로 적용하세요
  • 완료 시각을 기록하려면 Todo 모델에 completedAt 필드를 추가하고 copyWith로 설정하세요

6. UI 연동과 전체 코드

"이제 Notifier를 UI에 연결해봅시다." 최선배 개발자가 새 파일을 열었습니다. "ConsumerWidget을 사용하면 Provider를 구독할 수 있어요." 이지수 씨가 화면을 주시했습니다.

UI 연동은 작성한 TodoNotifier를 실제 Flutter 위젯에서 사용하는 과정입니다. ConsumerWidget으로 Provider를 구독하고, ref.watch로 상태를 읽으며, ref.read로 메서드를 호출합니다.

이것으로 비즈니스 로직과 UI가 완벽하게 분리됩니다.

다음 코드를 살펴봅시다.

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

class TodoListPage extends ConsumerWidget {
  const TodoListPage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 상태 구독
    final todos = ref.watch(todoNotifierProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('Todo 리스트')),
      body: ListView.builder(
        itemCount: todos.length,
        itemBuilder: (context, index) {
          final todo = todos[index];
          return ListTile(
            leading: Checkbox(
              value: todo.isCompleted,
              onChanged: (_) {
                // 완료 상태 토글
                ref.read(todoNotifierProvider.notifier)
                    .toggleTodo(todo.id);
              },
            ),
            title: Text(
              todo.title,
              style: TextStyle(
                decoration: todo.isCompleted
                    ? TextDecoration.lineThrough
                    : null,
              ),
            ),
            trailing: IconButton(
              icon: const Icon(Icons.delete),
              onPressed: () {
                // 항목 삭제
                ref.read(todoNotifierProvider.notifier)
                    .removeTodo(todo.id);
              },
            ),
          );
        },
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => _showAddDialog(context, ref),
        child: const Icon(Icons.add),
      ),
    );
  }

  void _showAddDialog(BuildContext context, WidgetRef ref) {
    final controller = TextEditingController();
    showDialog(
      context: context,
      builder: (_) => AlertDialog(
        title: const Text('할 일 추가'),
        content: TextField(controller: controller),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: const Text('취소'),
          ),
          TextButton(
            onPressed: () {
              if (controller.text.isNotEmpty) {
                ref.read(todoNotifierProvider.notifier)
                    .addTodo(controller.text);
                Navigator.pop(context);
              }
            },
            child: const Text('추가'),
          ),
        ],
      ),
    );
  }
}

이지수 씨는 UI 코드를 보며 감탄했습니다. "생각보다 간단하네요!" 최선배 개발자가 설명을 시작했습니다.

"Riverpod의 진짜 강점이 바로 여기 있어요. 상태 관리 로직과 UI가 완벽하게 분리되어 있죠." ConsumerWidget의 역할을 알아봅시다. 일반 StatelessWidget은 Provider를 구독할 수 없습니다.

ConsumerWidget은 WidgetRef라는 특별한 객체를 제공하는데, 이것이 Provider와 통신하는 다리 역할을 합니다. 마치 리모컨으로 TV를 조작하는 것처럼, ref로 Provider를 조작합니다.

ref.watch와 ref.read의 차이는 무엇일까요? ref.watch는 "이 Provider를 구독하고 변경되면 rebuild 해줘"라는 의미입니다.

build 메서드 안에서 사용하며, 상태가 바뀔 때마다 위젯이 다시 그려집니다. 반면 ref.read는 "현재 값만 한 번 읽어줘"라는 의미입니다.

주로 이벤트 핸들러에서 메서드를 호출할 때 씁니다. 초보자들이 자주 하는 실수는 뭘까요?

build 메서드 안에서 ref.read를 사용하는 것입니다. 이렇게 하면 상태가 변경되어도 UI가 업데이트되지 않습니다.

반드시 ref.watch를 써야 합니다. 반대로 onPressed 같은 콜백에서 ref.watch를 쓰면 불필요한 rebuild가 발생할 수 있습니다.

코드의 흐름을 따라가 봅시다. 앱이 시작되면 build가 호출됩니다. ref.watch(todoNotifierProvider)가 실행되며 현재 Todo 리스트를 가져옵니다.

ListView.builder가 각 Todo를 ListTile로 렌더링합니다. 사용자가 체크박스를 탭하면 onChanged가 호출되고, ref.read(todoNotifierProvider.notifier)로 Notifier에 접근하여 toggleTodo를 실행합니다.

toggleTodo가 state를 변경하면 Riverpod이 변경을 감지합니다. ref.watch로 구독 중인 TodoListPage에 알림이 갑니다.

build 메서드가 다시 실행되고, 변경된 상태로 UI가 업데이트됩니다. 모든 과정이 자동으로 일어나며, 개발자는 신경 쓸 필요가 없습니다.

삭제 버튼도 같은 방식입니다. IconButton의 onPressed에서 removeTodo를 호출하면 해당 항목이 제거되고 UI가 자동으로 업데이트됩니다.

애니메이션을 추가하려면 AnimatedList를 사용할 수 있지만, 기본 ListView.builder로도 충분히 부드럽게 작동합니다. FloatingActionButton으로 추가 다이얼로그를 띄웁니다. 사용자가 플러스 버튼을 누르면 _showAddDialog가 호출됩니다.

AlertDialog가 나타나고 TextField에 할 일을 입력합니다. 추가 버튼을 누르면 controller.text가 비어있지 않은지 검증한 후 addTodo를 호출합니다.

Navigator.pop으로 다이얼로그를 닫으면 새 항목이 리스트에 추가되어 있습니다. 실무에서는 더 복잡한 UI가 필요할 수 있습니다.

필터 기능을 추가해 완료된 항목만 보거나 미완료 항목만 볼 수 있습니다. 검색 기능으로 제목에 특정 단어가 포함된 항목만 표시할 수 있죠.

정렬 기능으로 생성 순, 완료 순, 알파벳 순으로 정렬할 수도 있습니다. 이 모든 기능을 별도의 Provider나 Notifier 메서드로 깔끔하게 구현할 수 있습니다.

주의할 점도 있습니다. TextField의 controller를 dispose하지 않으면 메모리 누수가 발생합니다.

StatefulWidget으로 바꾸거나, dialog builder 안에서 controller를 생성하는 방식으로 해결할 수 있습니다. 또한 다이얼로그를 여러 번 열었다 닫으면 controller가 계속 쌓이므로, 사용 후 dispose를 확실히 해야 합니다.

이지수 씨가 앱을 완성했습니다. 항목을 추가하고, 체크하고, 삭제하는 모든 기능이 완벽하게 작동했습니다.

"제가 직접 만든 첫 상태 관리 앱이에요!" 최선배 개발자가 박수를 쳤습니다. "축하합니다.

이제 Riverpod의 기초는 마스터했어요." 이지수 씨는 뿌듯함을 느끼며 코드를 저장했습니다. Notifier로 Todo 리스트를 만드는 것이 처음에는 복잡해 보였지만, 각 단계를 차근차근 따라가니 명확하게 이해할 수 있었습니다.

실전 팁

💡 - ref.watch는 build 안에서만, ref.read는 이벤트 핸들러에서만 사용하세요

  • 복잡한 UI는 별도 위젯으로 분리하면 코드가 깔끔해집니다
  • 로딩 상태나 에러 처리가 필요하면 AsyncNotifier를 사용하세요
  • 실제 앱에서는 로컬 DB(Drift, Hive 등)에 저장하여 앱을 종료해도 데이터가 유지되도록 하세요

이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!

#Flutter#Riverpod#Notifier#StateManagement#TodoList#Flutter,Riverpod

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.

함께 보면 좋은 카드 뉴스