🤖

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

⚠️

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

이미지 로딩 중...

AsyncNotifier로 서버 CRUD 구현 완벽 가이드 - 슬라이드 1/7
A

AI Generated

2025. 12. 11. · 10 Views

AsyncNotifier로 서버 CRUD 구현 완벽 가이드

Riverpod의 AsyncNotifier를 활용하여 서버와 통신하는 CRUD 기능을 구현하는 방법을 단계별로 알아봅니다. 초급 개발자도 쉽게 따라할 수 있도록 실무 예제와 함께 설명합니다.


목차

  1. AsyncNotifier 기본 구조
  2. build()로 초기 데이터 로딩
  3. Create: 새 항목 추가
  4. Update: 항목 수정
  5. Delete: 항목 삭제
  6. 낙관적 업데이트 패턴

1. AsyncNotifier 기본 구조

김개발 씨는 Flutter로 Todo 앱을 만들고 있습니다. 서버에서 데이터를 불러오고 저장하는 기능을 추가해야 하는데, 상태 관리가 복잡해 보입니다.

선배 박시니어 씨가 다가와 말합니다. "AsyncNotifier를 사용하면 간단해요!"

AsyncNotifier는 Riverpod에서 비동기 데이터를 관리하는 강력한 도구입니다. 마치 개인 비서가 서버와의 모든 대화를 대신 처리해주는 것과 같습니다.

로딩 상태, 에러 처리, 데이터 캐싱까지 자동으로 관리해줍니다. 이를 통해 깔끔하고 안전한 CRUD 구현이 가능합니다.

다음 코드를 살펴봅시다.

// Todo 모델 정의
class Todo {
  final String id;
  final String title;
  final bool completed;

  Todo({required this.id, required this.title, this.completed = false});
}

// AsyncNotifier 기본 구조
class TodosNotifier extends AsyncNotifier<List<Todo>> {
  @override
  Future<List<Todo>> build() async {
    // 여기서 초기 데이터를 로딩합니다
    return [];
  }
}

// Provider 정의
final todosProvider = AsyncNotifierProvider<TodosNotifier, List<Todo>>(() {
  return TodosNotifier();
});

김개발 씨는 입사 2개월 차 Flutter 개발자입니다. 오늘은 첫 실전 프로젝트인 Todo 앱을 만들고 있습니다.

서버에서 할 일 목록을 불러오고, 추가하고, 수정하고, 삭제하는 기능을 구현해야 합니다. 처음에는 StatefulWidget으로 시작했습니다.

그런데 코드가 점점 복잡해지더니, 로딩 상태를 관리하고 에러를 처리하는 부분이 엉망이 되어버렸습니다. "이렇게 복잡한 게 맞나?" 하는 의구심이 들었습니다.

바로 그때 선배 박시니어 씨가 코드 리뷰를 하다가 말합니다. "AsyncNotifier를 써보는 게 어때요?

훨씬 깔끔해질 거예요." AsyncNotifier란 정확히 무엇일까요? 쉽게 비유하자면, AsyncNotifier는 마치 유능한 비서와 같습니다. 여러분이 "서버에서 할 일 목록 가져와줘"라고 하면, 비서는 알아서 서버에 연락하고, 기다리고, 결과를 받아와서 정리해줍니다.

중간에 문제가 생기면 "죄송합니다, 서버 연결이 안 됩니다"라고 알려주기도 합니다. 이처럼 AsyncNotifier도 서버와의 모든 통신을 대신 처리해줍니다.

개발자는 "무엇을 가져올지"만 정의하면, 나머지 복잡한 과정은 AsyncNotifier가 알아서 처리합니다. 왜 AsyncNotifier가 필요할까요? AsyncNotifier가 없던 시절, 아니 정확히는 개발자가 직접 상태를 관리하던 시절을 생각해봅시다.

먼저 로딩 중인지 확인하는 isLoading 변수가 필요했습니다. 그다음 에러가 발생했는지 확인하는 error 변수도 필요했습니다.

물론 실제 데이터를 담을 data 변수도 있어야 했습니다. 이 세 가지를 매번 수동으로 관리해야 했습니다.

더 큰 문제는 이 변수들이 서로 동기화되어야 한다는 점이었습니다. 로딩이 시작되면 isLoading을 true로 바꾸고, 성공하면 data를 업데이트하고 isLoading을 false로 바꾸고, 실패하면 error를 설정하고 isLoading을 false로 바꾸는 식이었습니다.

하나라도 빠뜨리면 버그가 발생했습니다. 해결책으로 등장한 AsyncNotifier 바로 이런 문제를 해결하기 위해 AsyncNotifier가 등장했습니다.

AsyncNotifier를 사용하면 세 가지 상태를 자동으로 관리할 수 있습니다. AsyncLoading, AsyncData, AsyncError라는 명확한 상태로 구분되어, 개발자가 실수할 여지가 없습니다.

또한 캐싱 기능도 기본으로 제공되어 불필요한 서버 요청을 줄일 수 있습니다. 무엇보다 코드가 매우 간결해진다는 큰 이점이 있습니다.

복잡한 상태 관리 로직을 직접 작성할 필요 없이, build 메서드 하나만 정의하면 됩니다. 코드 구조를 살펴봅시다 위의 코드를 한 줄씩 살펴보겠습니다.

먼저 Todo 클래스는 할 일 하나를 표현하는 모델입니다. id, title, completed 세 가지 필드를 가지고 있습니다.

실제 서버 API와 주고받을 데이터의 형태입니다. 다음으로 TodosNotifier 클래스가 핵심입니다.

AsyncNotifier를 상속받으며, 제네릭 타입으로 List<Todo>를 지정했습니다. 이는 "이 Notifier는 Todo의 리스트를 관리한다"는 의미입니다.

build 메서드는 AsyncNotifier의 가장 중요한 부분입니다. 이 메서드는 Provider가 처음 사용될 때 자동으로 호출됩니다.

여기서 초기 데이터를 로딩하는 로직을 작성하면 됩니다. 지금은 빈 리스트를 반환하지만, 다음 카드에서 실제 서버 데이터를 가져오도록 개선할 것입니다.

마지막으로 todosProvider는 우리가 만든 TodosNotifier를 사용할 수 있도록 등록하는 부분입니다. AsyncNotifierProvider를 사용하여 정의하며, 이제 앱의 어디서든 ref.watch(todosProvider)로 Todo 목록에 접근할 수 있습니다.

실무에서는 어떻게 활용할까요? 실제 쇼핑몰 앱을 개발한다고 가정해봅시다. 상품 목록을 서버에서 불러오고, 장바구니에 추가하고, 주문을 생성하는 모든 과정에서 AsyncNotifier를 활용할 수 있습니다.

예를 들어 ProductsNotifier는 상품 목록을 관리하고, CartNotifier는 장바구니를 관리하고, OrdersNotifier는 주문 내역을 관리하는 식입니다. 각각의 Notifier는 독립적으로 동작하면서도, 필요할 때 서로 연동될 수 있습니다.

많은 기업에서 이런 패턴을 적극적으로 사용하고 있습니다. 코드가 예측 가능하고, 테스트하기 쉽고, 유지보수가 편하기 때문입니다.

주의할 점도 있습니다 초보 개발자들이 흔히 하는 실수 중 하나는 build 메서드 안에서 state를 수정하려는 것입니다. build는 초기 데이터를 "반환"하는 메서드이지, 상태를 "변경"하는 메서드가 아닙니다.

상태 변경은 별도의 메서드를 만들어야 합니다. 또 다른 실수는 AsyncNotifier를 너무 많이 만드는 것입니다.

관련된 데이터는 하나의 Notifier로 묶어서 관리하는 것이 좋습니다. 예를 들어 할 일의 상태(completed)를 별도의 Notifier로 분리하기보다는, Todo 객체 자체에 포함시키는 것이 낫습니다.

정리하며 다시 김개발 씨의 이야기로 돌아가 봅시다. 박시니어 씨의 설명을 들은 김개발 씨는 눈이 반짝였습니다.

"이렇게 하면 제가 직접 로딩 상태를 관리할 필요가 없겠네요!" AsyncNotifier의 기본 구조를 이해하면, 서버와 통신하는 모든 기능을 일관되고 안전하게 구현할 수 있습니다. 다음 카드에서는 build 메서드를 활용해 실제로 서버에서 데이터를 불러오는 방법을 배워보겠습니다.

실전 팁

💡 - AsyncNotifier의 제네릭 타입은 관리하려는 데이터의 타입을 정확히 지정하세요

  • build 메서드는 초기 로딩 전용입니다. 데이터 변경은 별도 메서드로 분리하세요
  • Provider 이름은 관리하는 데이터를 명확히 나타내도록 지으세요 (예: todosProvider, productsProvider)

2. build()로 초기 데이터 로딩

김개발 씨는 AsyncNotifier의 기본 구조를 배웠습니다. 이제 진짜 서버에서 데이터를 불러올 차례입니다.

"build 메서드 안에 API 호출 코드를 넣으면 되는 건가요?" 박시니어 씨가 고개를 끄덕이며 실제 코드를 보여줍니다.

build 메서드는 AsyncNotifier가 생성될 때 자동으로 호출되는 초기화 함수입니다. 마치 앱이 시작될 때 자동으로 창고에서 물건을 가져오는 것과 같습니다.

여기서 서버 API를 호출하면 로딩, 성공, 실패가 자동으로 관리됩니다. 사용자가 화면을 열었을 때 데이터가 준비되어 있는 마법 같은 경험을 제공할 수 있습니다.

다음 코드를 살펴봅시다.

// API 서비스 (가상의 HTTP 클라이언트)
class TodoApi {
  Future<List<Todo>> fetchTodos() async {
    // 실제로는 http.get 등을 사용
    await Future.delayed(Duration(seconds: 1));
    return [
      Todo(id: '1', title: 'Flutter 공부하기'),
      Todo(id: '2', title: 'AsyncNotifier 마스터하기'),
    ];
  }
}

class TodosNotifier extends AsyncNotifier<List<Todo>> {
  @override
  Future<List<Todo>> build() async {
    // 서버에서 초기 데이터 로딩
    final api = TodoApi();
    return await api.fetchTodos();
  }
}

김개발 씨는 이제 실제로 서버에서 데이터를 가져와야 합니다. 처음에는 막막했습니다.

"어디서 API를 호출해야 하지? StatefulWidget의 initState처럼 뭔가 초기화 함수가 있나?" 박시니어 씨가 웃으며 말합니다.

"build 메서드가 바로 그 역할을 해요. Provider가 처음 사용될 때 자동으로 호출되거든요." build 메서드의 역할 build 메서드는 마치 가게를 여는 순간 자동으로 재고를 확인하는 시스템과 같습니다.

손님(사용자)이 가게에 들어오면, 주인(앱)은 이미 선반에 상품(데이터)이 준비되어 있어야 합니다. AsyncNotifier의 build는 정확히 이 역할을 합니다.

Provider가 처음 watch되거나 read될 때, Riverpod이 자동으로 build를 호출합니다. 그리고 그 결과를 기다렸다가 화면에 표시해줍니다.

중요한 점은 build가 Future를 반환한다는 것입니다. 즉, 비동기 작업이 완료될 때까지 기다린다는 의미입니다.

이 기다리는 동안 Riverpod은 자동으로 "로딩 중" 상태를 만들어줍니다. 서버 API 호출하기 실제 앱에서는 http 패키지나 dio 같은 HTTP 클라이언트를 사용합니다.

위 예제에서는 간단히 TodoApi 클래스를 만들어 서버 통신을 시뮬레이션했습니다. fetchTodos 메서드를 보면 await Future.delayed로 1초의 지연을 만들었습니다.

실제 네트워크 요청을 하는 것처럼 시간이 걸리는 상황을 재현한 것입니다. 그리고 하드코딩된 Todo 리스트를 반환합니다.

실무에서는 이렇게 작성할 것입니다. "await http.get('https://api.example.com/todos')"와 같은 형태로 말이죠.

하지만 핵심 원리는 똑같습니다. 시간이 걸리는 작업을 await로 기다린 후, 결과를 반환하는 것입니다.

build 메서드 안에서 일어나는 일 TodosNotifier의 build 메서드를 다시 봅시다. 먼저 TodoApi 인스턴스를 생성합니다.

그리고 fetchTodos를 호출하여 결과를 기다립니다. 마지막으로 받아온 Todo 리스트를 반환합니다.

이 간단한 세 줄의 코드 뒤에서 Riverpod은 많은 일을 합니다. build가 호출되는 순간 state는 AsyncLoading이 됩니다.

화면에서는 이를 감지하여 로딩 스피너를 보여줄 수 있습니다. fetchTodos가 성공적으로 완료되면 state는 AsyncData로 바뀝니다.

이제 화면에서 실제 Todo 목록을 표시할 수 있습니다. 만약 중간에 에러가 발생하면 state는 AsyncError가 됩니다.

에러 메시지를 화면에 표시할 수 있습니다. 이 모든 과정이 자동으로 처리됩니다.

개발자가 직접 isLoading = true, isLoading = false를 설정할 필요가 없습니다. UI에서 사용하기 이렇게 만든 Provider를 UI에서 사용하는 것은 매우 간단합니다.

Consumer나 ConsumerWidget을 사용하여 ref.watch(todosProvider)를 호출하면 됩니다. 반환되는 값은 AsyncValue 타입입니다.

when 메서드를 사용하면 로딩, 성공, 에러 세 가지 경우를 깔끔하게 처리할 수 있습니다. 예를 들어 "loading: () => CircularProgressIndicator(), data: (todos) => ListView(...), error: (err, stack) => Text('에러 발생')" 같은 식입니다.

언제 build가 호출될까요? build 메서드는 몇 가지 경우에 자동으로 호출됩니다. 첫째, Provider가 처음 사용될 때입니다.

사용자가 Todo 목록 화면을 열면, todosProvider를 watch하게 되고, 그 순간 build가 실행됩니다. 둘째, ref.invalidate나 ref.refresh를 호출했을 때입니다.

이는 "다시 새로 고침 해줘"라고 요청하는 것과 같습니다. build가 다시 실행되어 최신 데이터를 가져옵니다.

셋째, Provider가 dispose된 후 다시 사용될 때입니다. 예를 들어 다른 화면으로 갔다가 돌아오면, Provider가 재생성되면서 build가 다시 실행될 수 있습니다.

실무 활용 사례 실제 뉴스 앱을 개발한다고 생각해봅시다. 사용자가 앱을 열면 가장 먼저 최신 뉴스 목록을 보여줘야 합니다.

ArticlesNotifier의 build 메서드에서 뉴스 API를 호출하도록 구현하면, 앱이 시작되자마자 자동으로 뉴스를 불러옵니다. 사용자는 앱을 열고 1-2초만 기다리면 바로 최신 뉴스를 볼 수 있습니다.

또한 pull-to-refresh 기능을 추가할 때도 간단합니다. ref.refresh(articlesProvider)를 호출하면 build가 다시 실행되어 최신 뉴스를 가져옵니다.

주의사항 초보 개발자들이 자주 하는 실수는 build 안에서 state를 직접 수정하려는 것입니다. "state = AsyncData(newList)"처럼 말이죠.

하지만 build는 단순히 데이터를 "반환"하는 메서드입니다. state 수정은 별도의 메서드에서 해야 합니다.

또 다른 실수는 build가 너무 무거운 작업을 하는 것입니다. build는 Provider가 생성될 때마다 실행될 수 있으므로, 가능한 한 빠르게 완료되어야 합니다.

만약 복잡한 계산이 필요하다면, 서버에서 처리하거나 별도의 isolate를 사용하는 것이 좋습니다. 정리하며 김개발 씨는 이제 확신을 갖게 되었습니다.

"build 메서드만 제대로 작성하면, 앱이 시작될 때 자동으로 데이터를 불러오는구나!" 박시니어 씨가 칭찬합니다. "맞아요.

이제 다음 단계는 새로운 Todo를 추가하는 Create 기능이에요." build로 초기 데이터를 로딩하는 방법을 이해했다면, CRUD의 첫 번째 단계인 Read를 완성한 것입니다. 이제 본격적으로 데이터를 조작하는 방법을 배워봅시다.

실전 팁

💡 - build 메서드는 순수 함수처럼 작성하세요. 외부 상태를 변경하지 말고, 데이터만 반환하세요

  • API 호출 로직은 별도의 Repository나 Service 클래스로 분리하면 테스트와 유지보수가 쉬워집니다
  • 에러 처리는 try-catch보다 API 레벨에서 하는 것이 좋습니다. AsyncNotifier가 자동으로 AsyncError로 변환해줍니다

3. Create: 새 항목 추가

김개발 씨가 Todo 목록을 화면에 띄우는 데 성공했습니다. 이제 사용자가 새로운 할 일을 추가할 수 있어야 합니다.

"서버에 POST 요청을 보내고, 성공하면 목록을 업데이트해야 하는데 어떻게 하죠?" 박시니어 씨가 addTodo 메서드를 작성하는 방법을 알려줍니다.

Create 기능은 AsyncNotifier에 새로운 메서드를 추가하여 구현합니다. 마치 창고에 새 물건을 추가하는 것처럼, 서버에 데이터를 전송하고 로컬 상태도 업데이트합니다.

state.value로 현재 데이터에 접근하고, state = AsyncData로 새 상태를 설정합니다. 이 과정에서 로딩 상태도 자동으로 관리됩니다.

다음 코드를 살펴봅시다.

class TodoApi {
  Future<Todo> createTodo(String title) async {
    await Future.delayed(Duration(milliseconds: 500));
    // 서버가 생성된 Todo를 반환한다고 가정
    return Todo(
      id: DateTime.now().toString(),
      title: title,
    );
  }
}

class TodosNotifier extends AsyncNotifier<List<Todo>> {
  @override
  Future<List<Todo>> build() async {
    return await TodoApi().fetchTodos();
  }

  Future<void> addTodo(String title) async {
    // 서버에 새 Todo 생성 요청
    final newTodo = await TodoApi().createTodo(title);

    // 현재 목록에 추가
    final currentTodos = state.value ?? [];
    state = AsyncData([...currentTodos, newTodo]);
  }
}

김개발 씨는 화면에 Todo 목록을 성공적으로 띄웠습니다. 이제 사용자가 "운동하기"라는 새 할 일을 추가하려고 합니다.

버튼을 누르면 어떤 일이 일어나야 할까요? 먼저 서버에 새 할 일을 저장해야 합니다.

그리고 서버가 성공 응답을 보내면, 화면의 목록도 업데이트되어야 합니다. 이 모든 과정을 어떻게 구현할까요?

Create 메서드 만들기 AsyncNotifier는 단순히 데이터를 읽기만 하는 것이 아닙니다. 클래스 안에 원하는 메서드를 자유롭게 추가할 수 있습니다.

addTodo라는 메서드를 만들어봅시다. 이 메서드는 사용자가 입력한 title을 받아서 서버에 전송하고, 성공하면 로컬 상태를 업데이트합니다.

마치 도서관에 새 책을 추가하는 것과 같습니다. 먼저 중앙 시스템에 책을 등록하고, 등록이 완료되면 서가에도 책을 꽂아 넣습니다.

두 가지 작업이 모두 완료되어야 사용자가 새 책을 볼 수 있습니다. state.value로 현재 데이터 접근하기 AsyncNotifier에서 현재 데이터에 접근하려면 state.value를 사용합니다.

state는 AsyncValue 타입이므로, 실제 데이터는 value 속성 안에 있습니다. 만약 아직 로딩 중이거나 에러가 발생했다면 value는 null일 수 있습니다.

그래서 "state.value ?? []"처럼 null 체크를 해주는 것이 안전합니다.

이는 "현재 Todo 목록이 있으면 그것을 사용하고, 없으면 빈 리스트를 사용해"라는 의미입니다. 서버에 데이터 전송하기 TodoApi의 createTodo 메서드는 서버에 POST 요청을 보내는 역할을 합니다.

실제로는 http.post나 dio.post를 사용하겠지만, 여기서는 간단히 시뮬레이션했습니다. 중요한 점은 서버가 생성된 Todo 객체를 반환한다는 것입니다.

특히 id 같은 필드는 서버에서 자동으로 생성되는 경우가 많습니다. 따라서 서버의 응답을 받아서 그것을 로컬 상태에 추가해야 정확한 데이터를 유지할 수 있습니다.

만약 클라이언트에서 임시로 id를 만들어 추가한다면, 나중에 서버와 동기화 문제가 발생할 수 있습니다. 항상 서버가 반환한 데이터를 신뢰하세요.

상태 업데이트하기 await createTodo로 서버 요청이 성공적으로 완료되면, 이제 로컬 상태를 업데이트할 차례입니다. "state = AsyncData([...currentTodos, newTodo])"라는 한 줄의 코드를 봅시다.

여기서 스프레드 연산자 ...currentTodos는 기존 목록의 모든 항목을 펼쳐놓습니다. 그리고 마지막에 newTodo를 추가합니다.

이렇게 하면 새로운 리스트가 만들어집니다. 중요한 점은 기존 리스트를 직접 수정하지 않는다는 것입니다.

"currentTodos.add(newTodo)"처럼 하지 않습니다. 대신 새로운 리스트를 만들어서 state에 할당합니다.

이를 **불변성(Immutability)**이라고 하며, Flutter의 상태 관리에서 매우 중요한 개념입니다. state에 AsyncData를 할당하는 이유 state는 단순한 List가 아니라 AsyncValue 타입입니다.

따라서 "state = newList"처럼 직접 할당할 수 없습니다. 반드시 AsyncData로 감싸서 할당해야 합니다.

이렇게 하면 Riverpod이 "아, 데이터가 성공적으로 업데이트되었구나"라고 인식합니다. 그리고 이 Provider를 watch하고 있는 모든 위젯에게 알림을 보내 화면을 다시 그리도록 합니다.

UI에서 호출하기 UI 코드에서는 매우 간단합니다. ref.read(todosProvider.notifier).addTodo('운동하기')처럼 호출하면 됩니다.

여기서 notifier를 사용하는 것이 중요합니다. ref.watch(todosProvider)는 데이터를 읽고 변화를 감지합니다.

반면 ref.read(todosProvider.notifier)는 Notifier 인스턴스 자체에 접근하여 메서드를 호출합니다. 버튼의 onPressed 콜백 안에서 이렇게 호출하면, 사용자가 버튼을 누르는 순간 새 Todo가 추가되고 화면이 자동으로 업데이트됩니다.

에러 처리는 어떻게 할까요? 위 코드에는 try-catch가 없습니다. 만약 서버 요청이 실패하면 어떻게 될까요?

AsyncNotifier는 자동으로 에러를 잡아서 state를 AsyncError로 변경합니다. UI에서 AsyncValue의 when이나 whenData를 사용하면 에러 상태를 감지하여 적절한 메시지를 표시할 수 있습니다.

물론 필요하다면 try-catch로 직접 에러를 처리할 수도 있습니다. 예를 들어 "서버 연결 실패" 같은 토스트 메시지를 보여주고 싶다면, catch 블록에서 SnackBar를 띄울 수 있습니다.

실무 활용 사례 쇼핑몰 앱에서 사용자가 상품을 장바구니에 추가하는 기능을 생각해봅시다. CartNotifier의 addToCart 메서드는 서버에 장바구니 추가 요청을 보냅니다.

서버가 성공 응답을 보내면, 로컬의 cartItems 리스트에 새 상품을 추가합니다. 동시에 화면 우측 상단의 장바구니 아이콘에 표시되는 개수도 자동으로 업데이트됩니다.

이 모든 과정이 한 줄의 메서드 호출로 완성됩니다. 깔끔하고 직관적입니다.

정리하며 김개발 씨는 이제 자신감이 생겼습니다. "새 데이터를 추가하는 것도 결국 메서드 하나 만들면 되는구나!" 박시니어 씨가 말합니다.

"맞아요. 이제 Update와 Delete도 똑같은 패턴으로 구현할 수 있어요." Create 기능을 이해했다면, CRUD의 나머지 부분도 쉽게 익힐 수 있습니다.

다음은 기존 항목을 수정하는 Update 기능을 배워봅시다.

실전 팁

💡 - 서버가 반환한 데이터를 그대로 사용하세요. 클라이언트에서 임의로 id를 생성하면 동기화 문제가 발생합니다

  • 스프레드 연산자로 새 리스트를 만들어 불변성을 유지하세요. 기존 리스트를 직접 수정하지 마세요
  • state 업데이트는 항상 AsyncData로 감싸서 할당해야 Riverpod이 변화를 감지합니다

4. Update: 항목 수정

김개발 씨가 새 할 일을 추가하는 데 성공했습니다. 그런데 사용자가 "운동하기"를 "아침 운동하기"로 수정하고 싶어 합니다.

"기존 항목을 찾아서 내용을 바꿔야 하는데, 리스트에서 어떻게 찾죠?" 박시니어 씨가 map을 사용한 우아한 방법을 알려줍니다.

Update 기능은 리스트에서 특정 항목을 찾아 수정하는 작업입니다. 마치 서랍장에서 특정 물건을 꺼내 다른 것으로 교체하는 것과 같습니다.

map 함수를 사용하여 id가 일치하는 항목만 새 데이터로 교체하고, 나머지는 그대로 유지합니다. 서버 업데이트가 성공하면 로컬 상태도 동기화됩니다.

다음 코드를 살펴봅시다.

class TodoApi {
  Future<Todo> updateTodo(String id, {String? title, bool? completed}) async {
    await Future.delayed(Duration(milliseconds: 500));
    // 서버가 업데이트된 Todo를 반환
    return Todo(
      id: id,
      title: title ?? '업데이트된 항목',
      completed: completed ?? false,
    );
  }
}

class TodosNotifier extends AsyncNotifier<List<Todo>> {
  @override
  Future<List<Todo>> build() async => await TodoApi().fetchTodos();

  Future<void> updateTodo(String id, {String? title, bool? completed}) async {
    // 서버에 업데이트 요청
    final updatedTodo = await TodoApi().updateTodo(id, title: title, completed: completed);

    // 리스트에서 해당 항목만 교체
    final currentTodos = state.value ?? [];
    state = AsyncData(
      currentTodos.map((todo) => todo.id == id ? updatedTodo : todo).toList(),
    );
  }
}

김개발 씨는 Todo 목록에 여러 항목을 추가했습니다. 그런데 사용자가 "운동하기"의 제목을 바꾸고 싶어 합니다.

또는 완료 체크박스를 눌러서 completed를 true로 바꾸고 싶어 합니다. 이런 상황에서 어떻게 해야 할까요?

리스트에서 특정 항목을 찾아서 내용을 수정해야 합니다. 그런데 리스트는 불변성을 유지해야 하므로, 직접 수정할 수는 없습니다.

Update의 핵심: 찾아서 교체하기 Update 기능의 핵심은 **"찾아서 교체하기"**입니다. 마치 책장에서 특정 책을 찾아서 개정판으로 교체하는 것과 같습니다.

먼저 어떤 책(Todo)을 교체할지 알아야 합니다. 그래서 id를 파라미터로 받습니다.

그다음 새로운 내용(title, completed)을 받아서 서버에 전송합니다. 서버가 성공 응답을 보내면, 로컬 리스트에서도 해당 항목을 새 버전으로 교체합니다.

선택적 파라미터 사용하기 updateTodo 메서드를 보면 title과 completed가 물음표(?)로 선택적 파라미터로 정의되어 있습니다. 이는 "제목만 바꾸고 싶을 수도 있고, 완료 상태만 바꾸고 싶을 수도 있어"라는 유연성을 제공합니다.

예를 들어 "updateTodo('1', title: '새 제목')"처럼 호출하면 제목만 바뀌고, "updateTodo('1', completed: true)"처럼 호출하면 완료 상태만 바뀝니다. 실무에서는 이런 유연성이 매우 중요합니다.

사용자가 체크박스만 눌렀는데 제목까지 다시 서버로 보낼 필요는 없으니까요. map으로 리스트 변환하기 가장 중요한 부분은 map 함수를 사용하는 부분입니다.

"currentTodos.map((todo) => todo.id == id ? updatedTodo : todo)"라는 코드를 자세히 봅시다.

map은 리스트의 각 항목을 순회하면서 변환합니다. 각 항목(todo)에 대해 조건을 검사합니다.

"todo.id == id"라는 조건이 참이면, 즉 우리가 찾는 항목이면 updatedTodo로 교체합니다. 조건이 거짓이면, 즉 다른 항목이면 그대로 todo를 유지합니다.

마치 컨베이어 벨트 위의 상자들을 검사하는 것과 같습니다. "이 상자가 내가 찾는 거야?"라고 물어보고, 맞으면 새 상자로 교체하고, 아니면 그냥 통과시킵니다.

toList()를 잊지 마세요 map 함수는 Iterable을 반환합니다. 하지만 우리의 state 타입은 List<Todo>입니다.

따라서 마지막에 **.toList()**를 호출하여 Iterable을 List로 변환해야 합니다. 이 작은 부분을 놓치면 타입 에러가 발생합니다.

Dart는 타입에 엄격하므로, 항상 정확한 타입을 사용해야 합니다. 불변성을 유지하는 이유 왜 기존 리스트를 직접 수정하지 않고 새 리스트를 만들까요?

Flutter와 Riverpod은 객체의 참조(reference)가 바뀌었을 때만 변화를 감지합니다. 만약 "currentTodos[0].title = '새 제목'"처럼 직접 수정하면, 리스트 객체 자체는 여전히 같은 참조를 가지고 있습니다.

Riverpod은 "변화가 없네?"라고 생각하여 화면을 다시 그리지 않습니다. 반면 새 리스트를 만들어 state에 할당하면, 참조가 바뀌었으므로 Riverpod은 즉시 변화를 감지합니다.

그리고 UI에 알림을 보내 화면을 업데이트합니다. 부분 업데이트 vs 전체 교체 위 코드는 서버가 반환한 updatedTodo로 완전히 교체합니다.

하지만 때로는 일부 필드만 바꾸고 싶을 수 있습니다. 그럴 때는 copyWith 같은 메서드를 Todo 클래스에 추가하면 좋습니다.

예를 들어 "todo.copyWith(completed: true)"처럼 사용하면, completed만 바뀌고 나머지 필드는 원래 값을 유지합니다. 실무에서는 대부분 copyWith 패턴을 사용합니다.

코드가 더 명확하고 안전하기 때문입니다. UI에서 체크박스 업데이트하기 가장 흔한 사용 사례는 체크박스입니다.

사용자가 할 일을 완료했을 때 체크하면, 즉시 서버에 반영되어야 합니다. Checkbox의 onChanged 콜백에서 "ref.read(todosProvider.notifier).updateTodo(todo.id, completed: value)"처럼 호출하면 됩니다.

사용자가 체크박스를 누르는 순간, 서버에 업데이트 요청이 가고, 성공하면 화면도 자동으로 업데이트됩니다. 낙관적 업데이트와의 차이 위 방식은 "서버가 성공 응답을 보낸 후에" 화면을 업데이트합니다.

즉, 서버 응답을 기다리는 동안 화면은 변하지 않습니다. 만약 즉각적인 반응이 중요하다면, 낙관적 업데이트(Optimistic Update) 패턴을 사용할 수 있습니다.

이는 서버 요청을 보내기 전에 먼저 화면을 업데이트하는 방식입니다. 다음 카드에서 자세히 배워봅시다.

정리하며 김개발 씨가 감탄합니다. "map 함수 하나로 이렇게 깔끔하게 업데이트할 수 있다니!" 박시니어 씨가 웃으며 말합니다.

"Dart의 함수형 프로그래밍 기능을 잘 활용하면 코드가 정말 간결해져요. 이제 Delete도 배워볼까요?" Update 기능을 이해했다면, 리스트를 변환하는 기술을 익힌 것입니다.

이는 Flutter 개발에서 매우 자주 사용되는 패턴이니 꼭 기억하세요.

실전 팁

💡 - map 함수는 원본 리스트를 변경하지 않고 새 리스트를 반환합니다. 불변성 유지에 완벽합니다

  • copyWith 메서드를 Todo 클래스에 추가하면 부분 업데이트가 더 쉬워집니다
  • 체크박스처럼 자주 바뀌는 UI는 낙관적 업데이트를 고려하세요 (다음 카드에서 다룹니다)

5. Delete: 항목 삭제

김개발 씨는 이제 Todo를 추가하고 수정할 수 있게 되었습니다. 마지막으로 완료된 할 일을 삭제하는 기능이 필요합니다.

"삭제는 리스트에서 항목을 빼는 건데, 어떻게 하죠?" 박시니어 씨가 where 함수를 소개합니다.

Delete 기능은 리스트에서 특정 항목을 제거하는 작업입니다. 마치 책장에서 낡은 책을 빼내는 것과 같습니다.

where 함수를 사용하여 삭제할 항목을 제외한 나머지만 남깁니다. 서버에서 삭제 성공 응답을 받으면 로컬 상태도 동기화됩니다.

다음 코드를 살펴봅시다.

class TodoApi {
  Future<void> deleteTodo(String id) async {
    await Future.delayed(Duration(milliseconds: 500));
    // 서버에서 해당 Todo 삭제
    // 성공하면 아무것도 반환하지 않음
  }
}

class TodosNotifier extends AsyncNotifier<List<Todo>> {
  @override
  Future<List<Todo>> build() async => await TodoApi().fetchTodos();

  Future<void> deleteTodo(String id) async {
    // 서버에 삭제 요청
    await TodoApi().deleteTodo(id);

    // 리스트에서 해당 항목 제거
    final currentTodos = state.value ?? [];
    state = AsyncData(
      currentTodos.where((todo) => todo.id != id).toList(),
    );
  }
}

김개발 씨의 Todo 앱이 거의 완성되어 갑니다. 이제 사용자가 완료한 할 일을 삭제할 수 있어야 합니다.

"장보기"를 끝냈으니 목록에서 지우고 싶다고 합니다. 삭제 버튼을 누르면 어떤 일이 일어나야 할까요?

먼저 서버에 "이 항목을 지워주세요"라고 요청해야 합니다. 서버가 삭제에 성공하면, 화면에서도 해당 항목이 사라져야 합니다.

Delete의 핵심: 필터링 Delete 기능의 핵심은 **"원하지 않는 것을 걸러내기"**입니다. 마치 체로 곡식을 거르듯이, 리스트에서 삭제할 항목만 빼고 나머지를 남깁니다.

Update가 "찾아서 교체하기"였다면, Delete는 "찾아서 제외하기"입니다. 둘 다 리스트를 변환하지만, 방식이 조금 다릅니다.

where 함수로 필터링하기 where 함수는 조건에 맞는 항목만 남기는 필터입니다. "currentTodos.where((todo) => todo.id != id)"라는 코드를 봅시다.

각 항목에 대해 "todo.id != id" 조건을 검사합니다. 즉, "지우려는 항목이 아닌가?"라고 물어봅니다.

조건이 참인 항목(지우려는 것이 아닌 항목)만 통과시킵니다. 조건이 거짓인 항목(지우려는 항목)은 걸러집니다.

결과적으로 삭제할 항목을 제외한 모든 항목이 남습니다. 마치 손님 명단에서 특정 사람을 빼는 것과 같습니다.

"김철수가 아닌 사람"만 새 명단에 적으면, 김철수는 자연스럽게 제외됩니다. map vs where의 차이 Update에서는 map을 사용했고, Delete에서는 where를 사용했습니다.

차이가 뭘까요? map은 리스트의 각 항목을 "변환"합니다.

입력 리스트와 출력 리스트의 길이는 같습니다. 단지 일부 항목의 내용이 바뀔 뿐입니다.

where는 리스트의 항목을 "필터링"합니다. 조건에 맞는 항목만 남기므로, 출력 리스트가 입력보다 짧을 수 있습니다.

Update는 "3개를 받아서 3개를 반환하되, 하나는 내용이 바뀜"입니다. Delete는 "3개를 받아서 2개를 반환함"입니다.

서버 API는 왜 아무것도 반환하지 않나요? deleteTodo API를 보면 Future<void>입니다. 즉, 성공하면 아무 데이터도 반환하지 않습니다.

삭제는 "없애는" 작업이므로, 반환할 데이터가 없습니다. 서버는 단지 "삭제 성공했어요" 또는 "삭제 실패했어요"라는 상태만 알려주면 됩니다.

만약 성공했다면, await는 에러 없이 완료됩니다. 실패했다면 예외(Exception)가 발생하여 AsyncNotifier가 자동으로 에러 상태로 전환됩니다.

await 이후 상태 업데이트 "await TodoApi().deleteTodo(id)" 다음 줄로 넘어왔다는 것은 삭제가 성공했다는 의미입니다. 이제 안심하고 로컬 상태를 업데이트할 수 있습니다.

where로 해당 항목을 제외한 새 리스트를 만들고, state에 할당합니다. 만약 서버 요청이 실패했다면, await에서 예외가 발생하여 그다음 코드는 실행되지 않습니다.

따라서 로컬 상태는 변하지 않고 그대로 유지됩니다. 이는 데이터 일관성을 지키는 중요한 안전장치입니다.

UI에서 삭제 버튼 만들기 실제 UI에서는 각 Todo 항목 옆에 삭제 버튼을 배치하는 경우가 많습니다. IconButton의 onPressed에서 "ref.read(todosProvider.notifier).deleteTodo(todo.id)"를 호출하면 됩니다.

사용자가 휴지통 아이콘을 누르는 순간, 서버에 삭제 요청이 가고, 성공하면 화면에서 해당 항목이 사라집니다. 좀 더 친절하게 하려면 "정말 삭제하시겠습니까?" 같은 확인 대화상자를 먼저 띄우는 것이 좋습니다.

showDialog로 사용자의 의사를 한 번 더 확인하면 실수로 삭제하는 것을 방지할 수 있습니다. 다중 삭제는 어떻게 할까요? 때로는 여러 항목을 한 번에 삭제하고 싶을 수 있습니다.

예를 들어 "완료된 항목 모두 삭제" 기능 같은 것입니다. 그럴 때는 deleteCompletedTodos 같은 메서드를 추가로 만들면 됩니다.

"state = AsyncData(currentTodos.where((todo) => !todo.completed).toList())"처럼 completed가 false인 항목만 남기면 됩니다. 서버 API도 "/todos/completed" 같은 엔드포인트로 일괄 삭제를 지원한다면 더 효율적입니다.

삭제 실패 시 사용자에게 알리기 만약 네트워크 오류로 삭제가 실패하면 어떻게 될까요? AsyncNotifier는 자동으로 state를 AsyncError로 바꿉니다.

UI에서 이를 감지하여 "삭제에 실패했습니다" 같은 SnackBar를 띄울 수 있습니다. 또는 try-catch로 직접 에러를 잡아서 처리할 수도 있습니다.

사용자 경험(UX)을 고려하여 적절한 피드백을 제공하세요. 정리하며 김개발 씨가 뿌듯해합니다.

"Create, Update, Delete를 모두 구현했네요!" 박시니어 씨가 칭찬합니다. "잘했어요.

이제 기본적인 CRUD는 마스터한 거예요. 하지만 한 가지 더 중요한 패턴이 있어요.

바로 낙관적 업데이트입니다." Delete 기능을 이해했다면, where를 활용한 필터링 기술을 익힌 것입니다. 이제 마지막으로 사용자 경험을 극대화하는 낙관적 업데이트 패턴을 배워봅시다.

실전 팁

💡 - where 함수는 조건에 맞는 항목만 남깁니다. "!="를 사용하여 특정 항목을 제외하세요

  • 삭제 전에 확인 대화상자를 띄워 사용자의 실수를 방지하세요
  • 다중 삭제 기능은 별도 메서드로 구현하면 코드가 명확해집니다

6. 낙관적 업데이트 패턴

김개발 씨의 Todo 앱이 완성되었습니다. 그런데 사용자가 체크박스를 누를 때마다 서버 응답을 기다리는 동안 약간 딜레이가 느껴집니다.

"좀 더 즉각적으로 반응하게 할 수는 없나요?" 박시니어 씨가 낙관적 업데이트 패턴을 소개합니다.

낙관적 업데이트는 서버 응답을 기다리지 않고 먼저 화면을 업데이트하는 패턴입니다. 마치 "아마 성공할 거야"라고 낙관하며 미리 변경하는 것과 같습니다.

만약 서버 요청이 실패하면 이전 상태로 되돌립니다(rollback). 사용자 경험이 훨씬 빠르고 부드러워집니다.

다음 코드를 살펴봅시다.

class TodosNotifier extends AsyncNotifier<List<Todo>> {
  @override
  Future<List<Todo>> build() async => await TodoApi().fetchTodos();

  Future<void> toggleTodoOptimistic(String id) async {
    // 1. 현재 상태 백업
    final previousState = state;

    // 2. 즉시 UI 업데이트 (낙관적)
    final currentTodos = state.value ?? [];
    state = AsyncData(
      currentTodos.map((todo) =>
        todo.id == id ? Todo(id: todo.id, title: todo.title, completed: !todo.completed) : todo
      ).toList(),
    );

    try {
      // 3. 서버에 요청
      final todo = currentTodos.firstWhere((t) => t.id == id);
      await TodoApi().updateTodo(id, completed: !todo.completed);
    } catch (e) {
      // 4. 실패 시 롤백
      state = previousState;
      rethrow; // 에러를 다시 던져서 UI에서 처리 가능하게
    }
  }
}

김개발 씨의 Todo 앱을 사용자들이 테스트해봤습니다. 대부분 만족했지만, 한 가지 피드백이 있었습니다.

"체크박스를 눌렀을 때 반응이 느린 것 같아요." 실제로 측정해보니 서버 응답까지 평균 300ms 정도 걸렸습니다. 그리 길지 않지만, 사용자는 즉각적인 반응을 기대합니다.

"클릭하자마자 체크 표시가 나타나야" 자연스럽게 느껴집니다. 왜 느리게 느껴질까요? 지금까지 구현한 방식은 "서버 우선" 접근법입니다.

사용자가 체크박스를 누르면, 먼저 서버에 요청을 보내고, 서버가 성공 응답을 보낼 때까지 기다린 후, 그제서야 화면을 업데이트합니다. 이 방식은 안전합니다.

서버가 "OK"라고 할 때까지 기다리니까요. 하지만 사용자 입장에서는 답답합니다.

"분명히 클릭했는데 왜 안 바뀌지?"라는 순간이 생깁니다. 낙관적 업데이트란? **낙관적 업데이트(Optimistic Update)**는 반대로 접근합니다.

"UI 우선" 전략입니다. 사용자가 체크박스를 누르는 순간, "아마 서버 요청은 성공할 거야"라고 낙관적으로 가정하고 먼저 화면을 업데이트합니다.

그리고 백그라운드에서 조용히 서버에 요청을 보냅니다. 대부분의 경우 서버 요청은 성공합니다.

네트워크가 안정적이고, 서버가 정상이라면 99% 이상 성공합니다. 따라서 "낙관적으로" 먼저 변경해도 문제가 거의 없습니다.

마치 식당에서 주문하는 것과 같습니다. 주문을 하면 "아마 음식이 나올 거야"라고 생각하고 기다립니다.

주방에서 재료가 떨어져서 안 된다고 할 수도 있지만, 대부분은 잘 나오니까요. 4단계 낙관적 업데이트 프로세스 위 코드를 단계별로 살펴봅시다.

1단계: 백업 가장 먼저 현재 상태를 previousState에 저장합니다. 이는 나중에 문제가 생겼을 때 되돌리기 위한 백업본입니다.

마치 중요한 문서를 수정하기 전에 복사본을 만들어두는 것과 같습니다. "혹시 실수하면 원본으로 돌아가면 돼"라는 안전장치입니다.

2단계: 즉시 UI 업데이트 서버에 요청하기도 전에 먼저 화면을 업데이트합니다. map 함수로 해당 Todo의 completed를 반대로 바꿉니다.

사용자는 클릭하자마자 체크 표시가 나타나는 것을 봅니다. 매우 빠르고 부드러운 경험입니다.

사용자는 "앱이 빠르다!"라고 느낍니다. 3단계: 서버 요청 이제 조용히 백그라운드에서 서버에 요청을 보냅니다.

await로 결과를 기다립니다. 대부분의 경우 성공합니다.

그러면 아무 일도 일어나지 않습니다. 이미 화면은 업데이트되어 있으니까요.

사용자는 아무것도 모릅니다. 그냥 "앱이 잘 동작한다"고만 생각합니다.

4단계: 실패 시 롤백 하지만 만약 서버 요청이 실패하면 어떻게 될까요? 네트워크 오류, 서버 에러, 권한 문제 등 여러 이유로 실패할 수 있습니다.

catch 블록에서 state를 previousState로 되돌립니다. 이를 **롤백(Rollback)**이라고 합니다.

마치 타임머신을 타고 과거로 돌아가는 것처럼, 화면이 원래 상태로 복구됩니다. 그리고 rethrow로 에러를 다시 던집니다.

이렇게 하면 UI에서 에러를 감지하여 "업데이트 실패" 같은 메시지를 표시할 수 있습니다. 언제 낙관적 업데이트를 사용할까요? 낙관적 업데이트는 모든 경우에 적합하지는 않습니다.

실패 가능성이 낮고, 즉각적인 피드백이 중요한 경우에 사용하세요. 체크박스 토글, 좋아요 버튼, 간단한 설정 변경 같은 경우가 좋은 예입니다.

이런 작업은 대부분 성공하며, 사용자는 즉각적인 반응을 기대합니다. 반면 결제, 회원가입, 중요한 데이터 전송 같은 경우는 낙관적 업데이트를 피하는 것이 좋습니다.

실패했을 때 심각한 문제가 될 수 있으니까요. 롤백 시 사용자에게 알리기 롤백이 발생했을 때 사용자에게 알려주는 것이 좋습니다.

SnackBar로 "네트워크 오류로 변경사항이 취소되었습니다" 같은 메시지를 보여주세요. 사용자는 "아, 업데이트가 안 됐구나.

다시 시도해야겠다"라고 이해할 수 있습니다. 아무 피드백 없이 조용히 롤백하면, 사용자는 혼란스러워합니다.

"분명히 체크했는데 왜 다시 풀렸지?"라고 생각하며 버그라고 오해할 수 있습니다. 실무에서의 활용 SNS 앱의 좋아요 기능을 생각해봅시다.

사용자가 하트 아이콘을 누르면, 즉시 색이 빨갛게 바뀌고 숫자가 올라갑니다. 백그라운드에서 서버에 요청을 보내고, 대부분 성공합니다.

만약 네트워크가 끊겨서 실패하면, 하트가 다시 회색으로 돌아가고 "좋아요 실패" 메시지가 나타납니다. 사용자는 "아, 인터넷이 안 되는구나"라고 이해합니다.

이 패턴은 인스타그램, 페이스북, 트위터 등 거의 모든 SNS 앱에서 사용됩니다. 사용자 경험을 최우선으로 하는 앱들의 공통 전략입니다.

정리하며 김개발 씨가 낙관적 업데이트를 적용하고 다시 테스트했습니다. 사용자들의 반응이 확 달라졌습니다.

"와, 이제 진짜 빠르네요!" 박시니어 씨가 만족스럽게 말합니다. "낙관적 업데이트는 사용자 경험을 극대화하는 강력한 패턴이에요.

하지만 롤백 처리를 잊지 마세요. 안전장치가 있어야 신뢰할 수 있는 앱이 됩니다." AsyncNotifier로 서버 CRUD를 구현하는 여정이 끝났습니다.

기본 구조부터 낙관적 업데이트까지, 이제 여러분은 실무에서 바로 사용할 수 있는 패턴을 모두 배웠습니다. 자신 있게 프로젝트에 적용해 보세요!

실전 팁

💡 - 낙관적 업데이트는 실패 가능성이 낮은 작업에만 사용하세요

  • 반드시 이전 상태를 백업하고, 실패 시 롤백하세요
  • 롤백 시 사용자에게 명확한 피드백(SnackBar, Toast 등)을 제공하세요

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

#Flutter#Riverpod#AsyncNotifier#CRUD#StateManagement#Flutter,Riverpod

댓글 (0)

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

함께 보면 좋은 카드 뉴스