본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.
본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.
이미지 로딩 중...
AI Generated
2025. 11. 30. · 55 Views
AsyncNotifier로 비동기 상태 관리 완벽 가이드
Flutter Riverpod의 AsyncNotifier를 활용하여 비동기 상태를 효과적으로 관리하는 방법을 배웁니다. CRUD 작업부터 낙관적 업데이트, 에러 복구까지 실무에서 바로 활용할 수 있는 패턴을 다룹니다.
목차
1. AsyncNotifier란?
김개발 씨는 Flutter 앱에서 서버 데이터를 불러오는 기능을 구현하고 있었습니다. 로딩 중일 때는 스피너를 보여주고, 에러가 나면 에러 메시지를, 성공하면 데이터를 보여줘야 하는데 상태 관리가 점점 복잡해지고 있었습니다.
AsyncNotifier는 Riverpod에서 비동기 상태를 관리하기 위해 설계된 특별한 Notifier입니다. 마치 레스토랑의 주방장이 주문을 받아 요리하고, 손님에게 음식이 준비되었는지 알려주는 것처럼 작동합니다.
로딩, 성공, 에러라는 세 가지 상태를 AsyncValue라는 하나의 타입으로 깔끔하게 표현할 수 있습니다.
다음 코드를 살펴봅시다.
// AsyncNotifier 기본 구조
@riverpod
class TodoList extends _$TodoList {
// build() 메서드에서 초기 데이터를 비동기로 로드합니다
@override
Future<List<Todo>> build() async {
// 서버에서 할 일 목록을 가져옵니다
final repository = ref.watch(todoRepositoryProvider);
return repository.fetchTodos();
}
// 상태를 변경하는 메서드들을 정의합니다
Future<void> addTodo(String title) async {
// 여기서 비동기 작업을 수행합니다
}
}
김개발 씨는 입사 6개월 차 Flutter 개발자입니다. 오늘 팀장님으로부터 새로운 기능 개발을 맡게 되었습니다.
사용자의 할 일 목록을 서버에서 불러와 화면에 표시하고, 추가, 수정, 삭제까지 가능하게 해야 합니다. 처음에는 단순하게 생각했습니다.
StatefulWidget에서 setState를 호출하면 되지 않을까? 하지만 코드를 작성하다 보니 문제가 생겼습니다.
로딩 상태를 위한 변수, 에러 메시지를 위한 변수, 실제 데이터를 위한 변수... 변수가 계속 늘어났습니다.
선배 개발자 박시니어 씨가 김개발 씨의 코드를 보더니 고개를 저었습니다. "이렇게 하면 나중에 유지보수하기 정말 힘들어요.
AsyncNotifier를 써보는 게 어때요?" 그렇다면 AsyncNotifier가 정확히 무엇일까요? 쉽게 비유하자면, AsyncNotifier는 마치 식당의 주방 시스템과 같습니다.
손님이 주문을 하면 주방에서 요리가 시작되고, 요리 중에는 "조리 중"이라는 표시가 뜹니다. 요리가 완성되면 음식이 나오고, 만약 재료가 없으면 "죄송합니다"라는 메시지가 전달됩니다.
AsyncNotifier도 마찬가지로 데이터를 요청하면 로딩 상태가 되고, 성공하면 데이터를, 실패하면 에러를 전달합니다. AsyncNotifier가 없던 시절에는 어땠을까요?
개발자들은 isLoading, errorMessage, data 같은 여러 변수를 직접 관리해야 했습니다. 더 큰 문제는 이 변수들 사이의 상태 동기화였습니다.
로딩이 끝났는데 isLoading을 false로 바꾸는 것을 깜빡하면 스피너가 영원히 돌아갔습니다. 에러가 발생했는데 이전 데이터가 그대로 화면에 남아있기도 했습니다.
바로 이런 문제를 해결하기 위해 Riverpod 2.0에서 AsyncNotifier가 등장했습니다. AsyncNotifier를 사용하면 **AsyncValue<T>**라는 단일 타입으로 세 가지 상태를 모두 표현할 수 있습니다.
AsyncValue.loading()은 로딩 중을, AsyncValue.data(value)는 성공을, AsyncValue.error(error, stackTrace)는 에러를 나타냅니다. 이 세 상태는 절대로 동시에 존재할 수 없기 때문에 상태 불일치 문제가 원천적으로 차단됩니다.
위의 코드를 살펴보겠습니다. 먼저 @riverpod 어노테이션은 코드 생성을 위한 표시입니다.
이것을 통해 _$TodoList라는 부모 클래스가 자동으로 생성됩니다. build() 메서드는 이 Notifier가 처음 읽힐 때 자동으로 호출되어 초기 데이터를 가져옵니다.
반환 타입이 **Future<List<Todo>>**이기 때문에 비동기 작업임을 명확히 알 수 있습니다. 실제 현업에서는 어떻게 활용할까요?
예를 들어 쇼핑몰 앱을 개발한다고 가정해봅시다. 상품 목록을 서버에서 불러올 때, 장바구니에 상품을 추가할 때, 결제를 처리할 때 모두 비동기 작업이 필요합니다.
각각의 기능에 AsyncNotifier를 적용하면 일관된 방식으로 상태를 관리할 수 있습니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.
박시니어 씨의 설명을 들은 후 AsyncNotifier로 코드를 다시 작성했습니다. 변수 세 개가 하나로 줄었고, 상태 불일치 버그도 사라졌습니다.
"이렇게 깔끔해질 줄은 몰랐어요!"
실전 팁
💡 - AsyncNotifier는 Riverpod 2.0 이상에서 사용 가능하며, riverpod_generator 패키지와 함께 쓰면 보일러플레이트 코드가 줄어듭니다
- UI에서는 when() 메서드로 로딩, 에러, 데이터 상태를 쉽게 분기 처리할 수 있습니다
2. build() 메서드와 초기 데이터
김개발 씨가 AsyncNotifier를 사용해 보기로 결심했습니다. 그런데 build() 메서드가 도대체 언제 호출되는 건지, 왜 생성자 대신 build()를 쓰는 건지 궁금해졌습니다.
build() 메서드는 AsyncNotifier의 심장과도 같습니다. 이 메서드는 Provider가 처음 읽힐 때 자동으로 호출되어 초기 상태를 설정합니다.
마치 가게가 문을 열 때 오늘 판매할 물건을 진열하는 것처럼, build()는 이 Notifier가 관리할 초기 데이터를 준비합니다.
다음 코드를 살펴봅시다.
@riverpod
class UserProfile extends _$UserProfile {
@override
Future<User> build() async {
// ref를 통해 다른 Provider에 접근할 수 있습니다
final authService = ref.watch(authServiceProvider);
final userId = authService.currentUserId;
// 현재 로그인한 사용자의 프로필을 가져옵니다
final repository = ref.watch(userRepositoryProvider);
final user = await repository.getUser(userId);
// 반환된 값이 state의 초기값이 됩니다
return user;
}
}
박시니어 씨가 김개발 씨에게 물었습니다. "클래스에서 보통 초기화는 어디서 해요?" 김개발 씨가 대답했습니다.
"생성자에서요!" 박시니어 씨가 고개를 끄덕이며 말했습니다. "맞아요.
그런데 AsyncNotifier에서는 조금 다릅니다." 일반적인 클래스에서는 생성자에서 초기화 작업을 수행합니다. 하지만 AsyncNotifier에서는 build() 메서드가 그 역할을 대신합니다.
왜 이런 설계를 했을까요? 쉽게 비유하자면, build() 메서드는 마치 카페의 오픈 준비와 같습니다.
카페가 문을 열기 전에 원두를 갈고, 우유를 준비하고, 에스프레소 머신을 예열합니다. 손님이 첫 주문을 하기 전에 모든 준비가 완료되어야 합니다.
build()도 마찬가지로 UI가 이 Provider를 처음 읽기 전에 필요한 데이터를 미리 준비합니다. build() 메서드의 가장 큰 특징은 ref에 접근할 수 있다는 점입니다.
ref를 통해 다른 Provider를 읽거나 구독할 수 있습니다. 위 코드에서 보시는 것처럼 authServiceProvider에서 현재 로그인한 사용자 ID를 가져오고, userRepositoryProvider를 통해 실제 사용자 정보를 조회합니다.
이런 의존성 주입이 생성자에서는 불가능합니다. 또 한 가지 중요한 점은 build()가 비동기 함수라는 것입니다.
async 키워드가 붙어 있기 때문에 await를 사용하여 서버 요청 같은 비동기 작업을 수행할 수 있습니다. build()가 실행되는 동안 상태는 자동으로 AsyncLoading이 됩니다.
작업이 완료되면 반환된 값이 AsyncData로 감싸져서 상태가 됩니다. build() 메서드가 호출되는 시점도 중요합니다.
Provider가 처음으로 읽힐 때 build()가 호출됩니다. 예를 들어 ref.watch(userProfileProvider)가 처음 실행되는 순간입니다.
이후에는 캐시된 상태를 반환하기 때문에 build()가 다시 호출되지 않습니다. 단, ref.invalidate()를 호출하면 Provider가 무효화되어 다음에 읽힐 때 build()가 다시 실행됩니다.
실무에서 흔히 하는 실수가 있습니다. build() 안에서 상태를 직접 변경하려고 하는 것입니다.
build()는 오직 초기 상태를 반환하는 용도로만 사용해야 합니다. 상태 변경은 별도의 메서드에서 수행해야 합니다.
또한 build() 안에서 ref.watch()를 사용하면 해당 Provider가 변경될 때마다 build()가 다시 실행되므로 주의해야 합니다. 김개발 씨는 이제 build() 메서드의 역할을 이해했습니다.
"초기화는 build()에서, 상태 변경은 별도 메서드에서!" 박시니어 씨가 웃으며 말했습니다. "정확해요.
이 원칙만 지키면 AsyncNotifier를 잘 다룰 수 있어요."
실전 팁
💡 - build()에서 ref.watch()를 사용하면 의존하는 Provider가 변경될 때 자동으로 다시 실행됩니다
- 파라미터가 필요한 경우 family 수정자를 사용하여 build(int id)처럼 인자를 받을 수 있습니다
3. state = AsyncLoading() 패턴
김개발 씨는 할 일을 추가하는 기능을 구현하고 있었습니다. 서버에 데이터를 보내는 동안 사용자에게 로딩 중임을 알려주고 싶은데, 어떻게 해야 할지 막막했습니다.
state = AsyncLoading() 패턴은 비동기 작업이 시작될 때 명시적으로 로딩 상태로 전환하는 기법입니다. 마치 엘리베이터가 움직이기 시작하면 층수 표시판이 깜빡이는 것처럼, 사용자에게 "지금 작업 중입니다"라고 알려주는 역할을 합니다.
이 패턴을 통해 UI가 적절한 로딩 인디케이터를 보여줄 수 있습니다.
다음 코드를 살펴봅시다.
Future<void> addTodo(String title) async {
// 1. 현재 데이터를 임시 저장합니다
final previousState = state;
// 2. 로딩 상태로 전환합니다
state = const AsyncLoading();
// 3. 비동기 작업을 수행합니다
state = await AsyncValue.guard(() async {
final repository = ref.read(todoRepositoryProvider);
await repository.addTodo(title);
// 4. 갱신된 목록을 반환합니다
return repository.fetchTodos();
});
}
김개발 씨는 할 일 추가 버튼을 눌렀을 때 어떤 일이 일어나야 하는지 정리해 보았습니다. 버튼을 누르면 서버에 요청을 보내고, 요청이 완료되면 목록이 갱신되어야 합니다.
그런데 서버 요청은 시간이 걸립니다. 그 사이에 사용자는 아무런 피드백 없이 기다려야 할까요?
박시니어 씨가 해결책을 알려주었습니다. "AsyncLoading 상태를 활용하면 돼요." 쉽게 비유하자면, 이것은 마치 온라인 쇼핑 주문 과정과 같습니다.
결제 버튼을 누르면 "결제 처리 중..."이라는 화면이 나타납니다. 이 화면은 시스템이 열심히 일하고 있다는 신호입니다.
결제가 완료되면 "주문이 완료되었습니다"라는 화면으로 바뀝니다. AsyncLoading도 마찬가지로 "데이터 처리 중"이라는 신호를 UI에 보냅니다.
코드를 단계별로 살펴보겠습니다. 첫 번째 단계에서는 현재 상태를 저장합니다.
왜 이렇게 할까요? 만약 작업이 실패했을 때 이전 상태로 되돌리기 위함입니다.
사용자가 이미 보고 있던 할 일 목록을 갑자기 잃어버리면 안 되니까요. 두 번째 단계에서 **AsyncLoading()**으로 상태를 변경합니다.
이 순간 UI에서 ref.watch()로 이 Provider를 구독하고 있던 위젯들은 로딩 상태를 감지합니다. when() 메서드의 loading 콜백이 실행되어 스피너나 스켈레톤 UI가 나타납니다.
세 번째 단계가 핵심입니다. **AsyncValue.guard()**는 try-catch를 AsyncValue로 감싸주는 헬퍼 함수입니다.
콜백 안의 코드가 성공하면 반환값을 AsyncData로 감싸고, 예외가 발생하면 AsyncError로 감쌉니다. 직접 try-catch를 작성하는 것보다 훨씬 깔끔합니다.
왜 guard()를 사용해야 할까요? 만약 guard() 없이 직접 await를 사용한다면, 예외가 발생했을 때 직접 catch하고 state를 AsyncError로 설정해야 합니다.
코드가 길어지고 실수하기 쉽습니다. guard()는 이 모든 것을 한 줄로 처리해 줍니다.
주의할 점이 있습니다. AsyncLoading()을 설정하면 기존 데이터가 사라집니다.
목록에 10개의 할 일이 있었는데 로딩 상태가 되면 그 목록이 화면에서 사라지고 스피너만 보입니다. 이것이 원하는 동작일 수도 있지만, 때로는 이전 데이터를 유지하면서 로딩 인디케이터를 보여주고 싶을 수도 있습니다.
그럴 때는 **AsyncLoading().copyWithPrevious(state)**를 사용합니다. 이렇게 하면 로딩 상태이면서도 이전 데이터에 접근할 수 있습니다.
UI에서는 이전 데이터 위에 반투명한 로딩 오버레이를 표시하는 식으로 활용할 수 있습니다. 김개발 씨는 이 패턴을 적용한 후 사용자 경험이 훨씬 좋아졌다고 느꼈습니다.
버튼을 누르면 즉시 로딩 표시가 나타나고, 작업이 완료되면 목록이 갱신됩니다. "이제 앱이 훨씬 반응적으로 느껴져요!"
실전 팁
💡 - AsyncValue.guard()는 try-catch를 대체하여 코드를 간결하게 만들어 줍니다
- 이전 데이터를 유지하려면 AsyncLoading().copyWithPrevious(state)를 사용하세요
4. CRUD 작업 구현
김개발 씨는 이제 할 일 목록의 전체 기능을 구현해야 합니다. 조회는 build()에서 했으니, 이제 생성, 수정, 삭제를 추가해야 합니다.
이 네 가지 작업을 어떻게 일관된 패턴으로 구현할 수 있을까요?
CRUD는 Create, Read, Update, Delete의 약자로, 데이터를 다루는 네 가지 기본 작업을 말합니다. AsyncNotifier에서는 Read는 build() 메서드가 담당하고, 나머지 세 가지는 별도의 메서드로 구현합니다.
각 메서드는 비동기 작업을 수행하고 상태를 적절히 갱신하는 책임을 가집니다.
다음 코드를 살펴봅시다.
@riverpod
class TodoList extends _$TodoList {
@override
Future<List<Todo>> build() async {
return ref.read(todoRepositoryProvider).fetchTodos();
}
// Create: 새 할 일 추가
Future<void> addTodo(String title) async {
final repo = ref.read(todoRepositoryProvider);
await repo.addTodo(title);
ref.invalidateSelf(); // 목록 새로고침
}
// Update: 완료 상태 토글
Future<void> toggleTodo(int id) async {
final repo = ref.read(todoRepositoryProvider);
await repo.toggleComplete(id);
ref.invalidateSelf();
}
// Delete: 할 일 삭제
Future<void> deleteTodo(int id) async {
final repo = ref.read(todoRepositoryProvider);
await repo.deleteTodo(id);
ref.invalidateSelf();
}
}
박시니어 씨가 화이트보드에 표를 그렸습니다. "모든 데이터 작업은 결국 이 네 가지로 귀결돼요.
조회, 생성, 수정, 삭제. 이걸 CRUD라고 부르죠." 김개발 씨는 고개를 끄덕였습니다.
학교에서 배운 적이 있습니다. 하지만 AsyncNotifier에서 이것을 어떻게 구현하는지는 처음입니다.
쉽게 비유하자면, AsyncNotifier는 마치 도서관의 사서 시스템과 같습니다. 책 목록 조회(Read)는 도서관 문을 열 때 자동으로 이루어집니다.
새 책 등록(Create), 책 정보 수정(Update), 책 폐기(Delete)는 사서가 별도로 처리합니다. 그리고 변경이 있을 때마다 목록을 갱신하여 최신 상태를 유지합니다.
먼저 Read 작업을 살펴보겠습니다. build() 메서드가 Read를 담당합니다.
Provider가 처음 읽힐 때 서버에서 할 일 목록을 가져옵니다. 이 부분은 앞서 배웠으므로 익숙하실 겁니다.
다음은 Create 작업입니다. addTodo() 메서드를 보면, 먼저 repository의 addTodo()를 호출하여 서버에 새 할 일을 저장합니다.
그리고 **ref.invalidateSelf()**를 호출합니다. 이 메서드는 현재 Provider를 무효화하여 다음에 읽힐 때 build()가 다시 실행되도록 합니다.
결과적으로 서버에서 최신 목록을 다시 가져오게 됩니다. Update와 Delete도 같은 패턴입니다.
서버에 변경 요청을 보내고, invalidateSelf()로 목록을 갱신합니다. 이 패턴이 좋은 이유는 일관성에 있습니다.
어떤 작업을 하든 항상 서버의 최신 상태를 반영하게 됩니다. 클라이언트와 서버의 데이터 불일치 문제를 근본적으로 방지합니다.
하지만 이 방식에는 단점도 있습니다. 매번 전체 목록을 새로 가져오기 때문에 네트워크 요청이 두 번 발생합니다.
할 일 추가 요청 한 번, 목록 조회 요청 한 번. 데이터가 적을 때는 문제없지만, 목록이 수천 개라면 비효율적일 수 있습니다.
더 효율적인 방법도 있습니다. 서버 요청 후 반환된 데이터를 직접 상태에 반영하는 방식입니다.
예를 들어 addTodo()가 새로 생성된 Todo 객체를 반환한다면, 그것을 기존 목록에 추가하여 state를 갱신할 수 있습니다. 다음 카드에서 배울 낙관적 업데이트와 연결되는 개념입니다.
김개발 씨는 모든 CRUD 작업을 구현했습니다. 할 일을 추가하면 목록에 나타나고, 체크하면 완료 표시가 되고, 삭제하면 사라집니다.
"기본 기능이 다 작동해요!" 박시니어 씨가 말했습니다. "좋아요.
이제 사용자 경험을 더 개선해볼까요?"
실전 팁
💡 - ref.invalidateSelf()는 현재 Provider만 무효화하고, ref.invalidate(otherProvider)는 다른 Provider도 무효화할 수 있습니다
- 작업 결과를 반환받아 직접 state를 수정하면 네트워크 요청을 줄일 수 있습니다
5. 낙관적 업데이트
김개발 씨가 앱을 테스트하던 중 불편한 점을 발견했습니다. 할 일을 추가하면 잠깐 로딩이 표시되고 나서야 목록에 나타납니다.
네트워크가 느린 환경에서는 답답함이 더 심해집니다. 더 빠르게 반응하는 방법이 없을까요?
**낙관적 업데이트(Optimistic Update)**는 서버 응답을 기다리지 않고 UI를 먼저 갱신하는 기법입니다. 마치 카드 결제할 때 영수증이 나오기도 전에 "결제되었습니다"라고 말하는 것과 같습니다.
대부분의 요청은 성공하기 때문에 미리 성공을 가정하고 화면을 업데이트합니다. 실패하면 그때 되돌립니다.
다음 코드를 살펴봅시다.
Future<void> addTodo(String title) async {
// 1. 현재 상태와 임시 Todo를 준비합니다
final previousState = state;
final tempTodo = Todo(id: -1, title: title, isCompleted: false);
// 2. 낙관적으로 UI를 먼저 업데이트합니다
state = AsyncData([
...previousState.value ?? [],
tempTodo,
]);
// 3. 서버에 요청을 보냅니다
try {
final repo = ref.read(todoRepositoryProvider);
final newTodo = await repo.addTodo(title);
// 4. 성공하면 임시 데이터를 실제 데이터로 교체합니다
state = AsyncData([
...previousState.value ?? [],
newTodo,
]);
} catch (e, st) {
// 5. 실패하면 이전 상태로 롤백합니다
state = previousState;
state = AsyncError(e, st);
}
}
박시니어 씨가 김개발 씨에게 질문했습니다. "할 일을 추가할 때 서버 응답이 오는 데 얼마나 걸려요?" 김개발 씨가 대답했습니다.
"보통 200-300밀리초요. 네트워크 상태가 안 좋으면 1초까지도 걸려요." 박시니어 씨가 말했습니다.
"1초면 사용자에게는 꽤 긴 시간이에요. 그런데 99%의 요청은 성공하잖아요.
그렇다면 왜 매번 기다려야 할까요?" 이것이 바로 낙관적 업데이트의 핵심 아이디어입니다. 쉽게 비유하자면, 낙관적 업데이트는 마치 식당 주문 시스템과 같습니다.
웨이터가 주문을 받으면 주방에 전달하기도 전에 "네, 주문 받았습니다"라고 말합니다. 주방에서 재료가 없다고 하면 그때 "죄송합니다"라고 말하면 됩니다.
대부분은 문제없이 진행되기 때문에 손님은 빠른 응답을 경험합니다. 코드를 단계별로 살펴보겠습니다.
첫 번째 단계에서 이전 상태를 저장합니다. 이것은 보험과 같습니다.
만약 작업이 실패하면 이 상태로 되돌릴 수 있습니다. 또한 임시 Todo 객체를 만듭니다.
id가 -1인 것은 아직 서버에서 실제 ID를 받지 않았기 때문입니다. 두 번째 단계에서 UI를 먼저 업데이트합니다.
사용자가 추가한 할 일이 즉시 화면에 나타납니다. 이 시점에서 사용자는 이미 성공했다고 느낍니다.
실제로 서버 요청은 아직 시작도 안 했지만요. 세 번째 단계에서 서버에 요청을 보냅니다.
이 요청은 백그라운드에서 진행됩니다. 사용자는 이미 결과를 보고 있기 때문에 기다리는 느낌이 없습니다.
네 번째 단계는 성공 시 처리입니다. 서버가 생성된 Todo를 반환하면, 임시 데이터를 실제 데이터로 교체합니다.
사용자는 이 교체를 인식하지 못합니다. 화면에는 이미 같은 내용이 표시되어 있으니까요.
다섯 번째 단계는 실패 시 처리입니다. 예외가 발생하면 저장해둔 이전 상태로 롤백합니다.
그리고 에러 상태를 설정하여 사용자에게 알립니다. 낙관적 업데이트를 사용할 때 주의할 점이 있습니다.
모든 작업에 적용하면 안 됩니다. 결제, 계정 삭제 같은 중요하고 되돌리기 어려운 작업은 반드시 서버 응답을 확인해야 합니다.
낙관적 업데이트는 실패해도 사용자 경험에 큰 문제가 없는 작업에 적합합니다. 또한 충돌 처리도 고려해야 합니다.
여러 사용자가 동시에 같은 데이터를 수정하는 경우, 낙관적 업데이트가 복잡해질 수 있습니다. 이런 경우에는 서버의 최종 상태를 다시 동기화하는 로직이 필요합니다.
김개발 씨는 낙관적 업데이트를 적용한 후 앱의 반응 속도가 훨씬 빨라진 것을 느꼈습니다. "버튼을 누르면 바로 추가돼요!
마법 같아요." 박시니어 씨가 웃으며 말했습니다. "마법이 아니라 좋은 UX 패턴이에요.
하지만 에러 처리를 잘해야 해요."
실전 팁
💡 - 낙관적 업데이트는 실패 가능성이 낮고 롤백이 쉬운 작업에 적합합니다
- 임시 ID를 사용할 때는 서버 응답 후 실제 ID로 교체하는 것을 잊지 마세요
6. 에러 복구 전략
김개발 씨가 배포 후 버그 리포트를 받았습니다. "할 일 추가가 가끔 실패해요.
그런데 에러 메시지만 뜨고 아무것도 할 수 없어요." 에러가 발생했을 때 사용자가 다시 시도하거나 복구할 수 있게 하려면 어떻게 해야 할까요?
에러 복구 전략은 실패한 작업을 우아하게 처리하고 사용자가 다시 시도할 수 있도록 하는 방법론입니다. 마치 자동차가 펑크났을 때 스페어 타이어로 교체하고 계속 갈 수 있는 것처럼, 에러가 발생해도 앱이 완전히 멈추지 않고 복구할 수 있어야 합니다.
좋은 에러 처리는 사용자 신뢰를 유지하는 핵심입니다.
다음 코드를 살펴봅시다.
class TodoNotifier extends _$TodoNotifier {
// 마지막으로 실패한 작업을 저장합니다
({String action, dynamic params})? _lastFailedOperation;
Future<void> addTodo(String title) async {
final previousState = state;
state = const AsyncLoading();
try {
final repo = ref.read(todoRepositoryProvider);
final todos = await repo.addTodo(title);
state = AsyncData(todos);
_lastFailedOperation = null; // 성공하면 초기화
} catch (e, st) {
// 실패한 작업 정보를 저장합니다
_lastFailedOperation = (action: 'addTodo', params: title);
state = AsyncError(e, st);
}
}
// 재시도 메서드
Future<void> retry() async {
if (_lastFailedOperation == null) return;
final op = _lastFailedOperation!;
if (op.action == 'addTodo') await addTodo(op.params);
}
}
박시니어 씨가 심각한 표정으로 말했습니다. "에러가 발생하는 건 어쩔 수 없어요.
네트워크는 불안정하고, 서버도 가끔 문제가 생기죠. 중요한 건 에러가 발생했을 때 어떻게 하느냐예요." 김개발 씨는 고개를 끄덕였습니다.
지금까지는 에러가 나면 그냥 에러 메시지를 보여주기만 했습니다. 사용자는 앱을 껐다 켜거나, 화면을 나갔다 들어와야만 다시 시도할 수 있었습니다.
쉽게 비유하자면, 좋은 에러 복구는 마치 좋은 고객 서비스와 같습니다. 음식이 잘못 나왔을 때 "죄송합니다.
다시 해드릴까요?"라고 묻는 것처럼, 앱도 "작업이 실패했습니다. 다시 시도하시겠어요?"라고 물어야 합니다.
에러 복구의 첫 번째 원칙은 이전 상태를 보존하는 것입니다. 에러가 발생했다고 사용자의 데이터를 날려버리면 안 됩니다.
위 코드에서 previousState를 저장하는 이유가 여기 있습니다. 에러가 나도 사용자가 이미 입력한 내용이나 보고 있던 목록은 유지되어야 합니다.
두 번째 원칙은 실패한 작업을 기억하는 것입니다. 코드에서 _lastFailedOperation 변수가 이 역할을 합니다.
어떤 작업이 실패했는지, 어떤 파라미터로 호출되었는지를 저장합니다. 이 정보가 있으면 retry() 메서드에서 똑같은 작업을 다시 수행할 수 있습니다.
세 번째 원칙은 명확한 재시도 방법을 제공하는 것입니다. retry() 메서드는 저장된 작업 정보를 바탕으로 마지막에 실패한 작업을 다시 시도합니다.
UI에서는 에러 화면에 "다시 시도" 버튼을 배치하고, 이 버튼이 retry()를 호출하도록 연결하면 됩니다. UI에서는 어떻게 처리할까요?
AsyncValue의 when() 메서드를 사용하면 됩니다. error 콜백에서 에러 메시지와 함께 재시도 버튼을 보여줍니다.
hasValue 속성을 확인하면 에러 상태에서도 이전 데이터에 접근할 수 있어서, 에러 토스트만 보여주고 기존 화면은 유지하는 것도 가능합니다. 더 세련된 방법도 있습니다.
자동 재시도를 구현할 수 있습니다. 네트워크 에러가 발생하면 잠시 기다렸다가 자동으로 2-3번 재시도하는 것입니다.
지수 백오프(exponential backoff)를 적용하면 더 좋습니다. 1초 후 재시도, 실패하면 2초 후 재시도, 다시 실패하면 4초 후 재시도하는 식입니다.
또한 에러 유형에 따른 다른 처리도 고려해야 합니다. 네트워크 에러는 재시도하면 해결될 가능성이 높습니다.
하지만 인증 에러(401)는 재시도해도 소용없고, 로그인 화면으로 이동해야 합니다. 서버 에러(500)는 잠시 기다린 후 재시도하면 해결될 수도 있습니다.
에러 코드에 따라 적절한 복구 전략을 선택해야 합니다. 김개발 씨는 모든 비동기 메서드에 에러 복구 로직을 추가했습니다.
이제 에러가 발생해도 사용자가 당황하지 않습니다. 친절한 메시지와 함께 재시도 버튼이 나타나고, 버튼을 누르면 다시 시도됩니다.
"이제야 진짜 완성된 것 같아요!"
실전 팁
💡 - 네트워크 에러에는 자동 재시도를, 인증 에러에는 로그인 리다이렉트를 적용하세요
- AsyncValue.hasValue를 활용하면 에러 상태에서도 이전 데이터를 표시할 수 있습니다
이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!
댓글 (0)
함께 보면 좋은 카드 뉴스
AAA급 게임 프로젝트 완벽 가이드
Flutter와 Flame 엔진을 활용하여 AAA급 퀄리티의 모바일 게임을 개발하는 전체 과정을 다룹니다. 기획부터 앱 스토어 출시까지, 실무에서 필요한 모든 단계를 이북처럼 술술 읽히는 스타일로 설명합니다.
빌드와 배포 자동화 완벽 가이드
Flutter 앱 개발에서 GitHub Actions를 활용한 CI/CD 파이프라인 구축부터 앱 스토어 자동 배포까지, 초급 개발자도 쉽게 따라할 수 있는 빌드 자동화의 모든 것을 다룹니다.
게임 분석과 메트릭스 완벽 가이드
Flutter와 Flame으로 개발한 게임의 성공을 측정하고 개선하는 방법을 배웁니다. Firebase Analytics 연동부터 A/B 테스팅, 리텐션 분석까지 데이터 기반 게임 운영의 모든 것을 다룹니다.
게임 보안과 치팅 방지 완벽 가이드
Flutter와 Flame 게임 엔진에서 클라이언트 보안부터 서버 검증까지, 치터들로부터 게임을 보호하는 핵심 기법을 다룹니다. 초급 개발자도 쉽게 따라할 수 있는 실전 보안 코드와 함께 설명합니다.
애니메이션 시스템 커스터마이징 완벽 가이드
Flutter와 Flame 게임 엔진에서 고급 애니메이션 시스템을 구현하는 방법을 다룹니다. 스켈레탈 애니메이션부터 절차적 애니메이션까지, 게임 개발에 필요한 핵심 애니메이션 기법을 실무 예제와 함께 배워봅니다.