🤖

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

⚠️

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

이미지 로딩 중...

AsyncValue로 상태별 UI 분기 완벽 가이드 - 슬라이드 1/7
A

AI Generated

2025. 12. 11. · 8 Views

AsyncValue로 상태별 UI 분기 완벽 가이드

Riverpod의 AsyncValue를 활용하여 로딩, 데이터, 에러 상태를 우아하게 처리하는 방법을 배웁니다. switch 패턴 매칭부터 커스텀 로딩 위젯까지, 실무에서 바로 사용할 수 있는 다양한 기법을 다룹니다.


목차

  1. AsyncValue 세 가지 상태
  2. switch 패턴 매칭 (Dart 3)
  3. when() 메서드 활용
  4. skipLoadingOnRefresh 옵션
  5. 커스텀 로딩 위젯
  6. 에러 재시도 버튼

1. AsyncValue 세 가지 상태

어느 날 김개발 씨가 사용자 목록을 불러오는 화면을 만들고 있었습니다. API 호출이 시작되면 로딩 스피너를 보여주고, 데이터가 오면 리스트를 표시하고, 에러가 발생하면 에러 메시지를 보여줘야 했습니다.

"이걸 어떻게 깔끔하게 처리하지?" 고민하던 중 선배 박시니어 씨가 다가왔습니다.

AsyncValue는 비동기 작업의 세 가지 상태를 표현하는 sealed class입니다. AsyncLoading은 데이터를 불러오는 중, AsyncData는 데이터가 성공적으로 도착한 상태, AsyncError는 에러가 발생한 상태를 나타냅니다.

이 세 가지 상태만 기억하면 모든 비동기 UI를 깔끔하게 처리할 수 있습니다.

다음 코드를 살펴봅시다.

// AsyncValue의 세 가지 상태를 확인하는 기본 패턴
final userAsync = ref.watch(userProvider);

// 상태 확인 프로퍼티들
if (userAsync.isLoading) {
  // 로딩 중일 때
  return CircularProgressIndicator();
}

if (userAsync.hasError) {
  // 에러가 발생했을 때
  return Text('에러: ${userAsync.error}');
}

if (userAsync.hasValue) {
  // 데이터가 있을 때
  final user = userAsync.value!;
  return Text('안녕하세요, ${user.name}님');
}

김개발 씨는 입사 2개월 차 주니어 플러터 개발자입니다. 오늘은 사용자 프로필 화면을 만들어야 하는데, API에서 데이터를 불러오는 동안 어떤 UI를 보여줘야 할지 고민이 깊어졌습니다.

"로딩 중에는 스피너를 보여주고, 데이터가 오면 내용을 보여주고, 에러가 나면... 음, 이걸 어떻게 구분하지?" 김개발 씨는 boolean 변수를 여러 개 만들어야 하나 생각했습니다.

바로 그때 박시니어 씨가 화면을 보더니 말했습니다. "AsyncValue를 사용하면 훨씬 간단해요." AsyncValue란 무엇일까요? 쉽게 비유하자면, AsyncValue는 마치 택배 배송 상태와 같습니다.

택배를 주문하면 '배송 중', '배송 완료', '배송 실패' 세 가지 상태 중 하나겠죠? AsyncValue도 정확히 이렇게 동작합니다.

배송 중일 때는 물건이 아직 도착하지 않았으니 기다려야 합니다. 이것이 AsyncLoading 상태입니다.

배송이 완료되면 물건을 받을 수 있습니다. 이것이 AsyncData 상태입니다.

만약 주소가 잘못되어 배송이 실패하면, 에러 메시지를 받게 됩니다. 이것이 AsyncError 상태입니다.

왜 AsyncValue가 필요한가? AsyncValue가 없던 시절에는 어땠을까요? 개발자들은 isLoading, hasError, data 같은 변수를 직접 관리해야 했습니다.

로딩 상태를 true로 설정하고, API 호출이 끝나면 false로 바꾸고, 에러가 나면 또 에러 변수를 업데이트하고... 코드가 길어지고 실수하기도 쉬웠습니다.

더 큰 문제는 상태 동기화였습니다. 만약 개발자가 isLoading을 false로 바꾸는 것을 깜빡하면 어떻게 될까요?

화면은 영원히 로딩 스피너만 돌아가게 됩니다. 프로젝트가 커질수록 이런 버그는 찾기도 어려워졌습니다.

AsyncValue의 등장 바로 이런 문제를 해결하기 위해 AsyncValue가 등장했습니다. AsyncValue를 사용하면 하나의 객체로 모든 상태를 관리할 수 있습니다.

로딩인지, 데이터가 있는지, 에러인지 명확하게 구분됩니다. 또한 타입 안전성도 얻을 수 있습니다.

Dart의 타입 시스템이 잘못된 접근을 컴파일 타임에 잡아줍니다. 무엇보다 코드가 간결해진다는 큰 이점이 있습니다.

복잡한 상태 관리 로직을 작성할 필요 없이, AsyncValue의 프로퍼티만 확인하면 됩니다. 상태 확인하기 위의 코드를 한 줄씩 살펴보겠습니다.

먼저 userAsync.isLoading을 보면 현재 로딩 중인지 알 수 있습니다. 이 프로퍼티가 true면 데이터를 불러오는 중이므로 CircularProgressIndicator를 보여줍니다.

다음으로 userAsync.hasError에서는 에러가 발생했는지 확인합니다. 네트워크 오류나 서버 에러가 발생하면 이 값이 true가 되고, userAsync.error로 실제 에러 객체에 접근할 수 있습니다.

마지막으로 userAsync.hasValue로 데이터가 성공적으로 도착했는지 확인합니다. 이 값이 true면 userAsync.value!로 실제 데이터를 가져올 수 있습니다.

실무 활용 사례 실제 현업에서는 어떻게 활용할까요? 예를 들어 소셜 미디어 앱을 개발한다고 가정해봅시다.

사용자가 피드를 새로고침할 때마다 최신 게시물을 불러와야 합니다. 이때 AsyncValue를 활용하면 "당겨서 새로고침" 제스처를 감지했을 때 자동으로 로딩 상태가 되고, 데이터가 오면 자연스럽게 리스트가 업데이트됩니다.

많은 스타트업에서 이런 패턴을 적극적으로 사용하고 있습니다. 주의사항 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수 중 하나는 hasValue를 확인하지 않고 바로 value에 접근하는 것입니다. 이렇게 하면 데이터가 없을 때 null 참조 에러가 발생할 수 있습니다.

따라서 반드시 hasValue를 먼저 확인하거나, 뒤에서 배울 when() 메서드를 사용해야 합니다. 정리 다시 김개발 씨의 이야기로 돌아가 봅시다.

박시니어 씨의 설명을 들은 김개발 씨는 고개를 끄덕였습니다. "아, 세 가지 상태만 기억하면 되는구나!" AsyncValue의 세 가지 상태를 제대로 이해하면 더 깔끔하고 안전한 비동기 UI를 만들 수 있습니다.

여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.

실전 팁

💡 - hasValue 확인 필수: value에 접근하기 전에 항상 hasValue를 확인하세요

  • 에러 처리: hasError일 때 사용자에게 친절한 메시지를 보여주세요
  • 로딩 표시: 사용자 경험을 위해 로딩 인디케이터를 적절히 사용하세요

2. switch 패턴 매칭 (Dart 3)

김개발 씨가 if 문으로 AsyncValue의 상태를 체크하는 코드를 작성하고 있었습니다. 그런데 코드가 점점 길어지고 복잡해졌습니다.

"뭔가 더 깔끔한 방법이 없을까?" 고민하던 중, 박시니어 씨가 "Dart 3의 switch 표현식을 써보세요"라고 조언했습니다.

Dart 3의 switch 패턴 매칭은 AsyncValue의 상태를 더욱 우아하게 처리할 수 있는 강력한 기능입니다. sealed class의 모든 케이스를 컴파일러가 체크해주므로 누락된 상태 처리를 방지할 수 있습니다.

switch 표현식은 값을 반환하므로 위젯을 직접 리턴할 수 있어 매우 편리합니다.

다음 코드를 살펴봅시다.

// Dart 3 switch 패턴 매칭으로 상태 분기
final userAsync = ref.watch(userProvider);

return switch (userAsync) {
  AsyncData(:final value) => Column(
    children: [
      Text('이름: ${value.name}'),
      Text('이메일: ${value.email}'),
    ],
  ),
  AsyncError(:final error) => Column(
    children: [
      Icon(Icons.error, color: Colors.red),
      Text('에러 발생: $error'),
    ],
  ),
  _ => const CircularProgressIndicator(),
};

김개발 씨는 어제 배운 if 문으로 AsyncValue를 체크하는 코드를 열심히 작성했습니다. 하지만 화면이 5개, 10개로 늘어나면서 비슷한 코드를 계속 반복해서 쓰게 되었습니다.

"if (userAsync.isLoading) 하고, if (userAsync.hasError) 하고, if (userAsync.hasValue) 하고... 매번 이렇게 써야 하나?" 김개발 씨는 조금 지루함을 느꼈습니다.

박시니어 씨가 김개발 씨의 화면을 보더니 미소를 지었습니다. "Dart 3가 나오면서 훨씬 좋은 방법이 생겼어요.

switch 패턴 매칭을 사용하면 됩니다." switch 패턴 매칭이란? 쉽게 비유하자면, switch 패턴 매칭은 마치 자동 분류 시스템과 같습니다. 택배 센터에서 물건이 도착하면 자동으로 "서울", "부산", "대구" 등으로 분류되는 것처럼, AsyncValue가 들어오면 자동으로 AsyncData, AsyncError, AsyncLoading으로 분류되어 각각 다른 처리를 할 수 있습니다.

전통적인 switch 문과 다른 점은, Dart 3의 switch는 표현식이라는 것입니다. 즉, 값을 반환할 수 있습니다.

이것은 마치 삼항 연산자처럼 동작하지만 훨씬 더 강력합니다. 왜 switch 패턴 매칭을 사용할까? if 문으로 상태를 체크하는 방식에는 몇 가지 문제가 있었습니다.

첫째, 누락 가능성입니다. AsyncLoading 케이스를 처리하는 것을 깜빡할 수 있습니다.

컴파일러는 이것을 잡아주지 못합니다. 둘째, 코드의 중복입니다.

비슷한 패턴을 여러 곳에서 반복해서 작성해야 했습니다. 셋째, 가독성의 문제였습니다.

if-else가 중첩되면서 코드가 점점 복잡해지고, 어떤 상태에서 어떤 위젯을 보여주는지 한눈에 파악하기 어려워졌습니다. switch 패턴 매칭의 장점 바로 이런 문제를 해결하기 위해 Dart 3에 패턴 매칭이 도입되었습니다.

switch 패턴 매칭을 사용하면 완전성 검사가 가능해집니다. AsyncValue는 sealed class이므로, 컴파일러가 모든 케이스를 처리했는지 확인해줍니다.

만약 AsyncError 케이스를 빼먹으면 컴파일 에러가 발생합니다. 또한 구조 분해가 가능합니다.

AsyncData(:final value) 같은 문법으로 데이터를 바로 추출할 수 있습니다. 별도로 .value를 호출할 필요가 없어 코드가 간결해집니다.

무엇보다 표현식으로 동작한다는 큰 이점이 있습니다. switch 전체가 하나의 값을 반환하므로, return 문에 바로 사용할 수 있습니다.

코드가 훨씬 선언적이고 읽기 쉬워집니다. 코드 분석 위의 코드를 한 줄씩 살펴보겠습니다.

먼저 switch (userAsync)로 userAsync 객체를 switch에 넘깁니다. 그리고 각 케이스를 패턴으로 매칭합니다.

AsyncData(:final value) 부분이 핵심입니다. 이것은 "AsyncData 타입이면서, 내부의 value를 추출해서 value 변수에 담아라"는 의미입니다.

이렇게 하면 별도로 userAsync.value!를 호출하지 않아도 바로 데이터를 사용할 수 있습니다. AsyncError(:final error) 부분도 마찬가지입니다.

에러 객체를 바로 추출해서 사용할 수 있습니다. 마지막 _는 와일드카드 패턴으로, "나머지 모든 경우"를 의미합니다.

여기서는 AsyncLoading 상태를 처리합니다. CircularProgressIndicator를 반환하여 로딩 중임을 보여줍니다.

실무 활용 사례 실제 현업에서는 어떻게 활용할까요? 예를 들어 뉴스 앱을 개발한다고 가정해봅시다.

기사 목록, 상세 화면, 댓글 목록 등 여러 화면에서 API 데이터를 불러옵니다. 각 화면마다 switch 패턴 매칭을 사용하면 일관된 방식으로 로딩/에러/데이터 상태를 처리할 수 있습니다.

코드 리뷰를 하는 동료도 금방 이해할 수 있어 협업이 수월해집니다. 주의사항 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수 중 하나는 와일드카드 패턴을 너무 일찍 사용하는 것입니다. 모든 케이스를 명시적으로 처리하는 것이 더 안전할 때가 많습니다.

와일드카드를 사용하면 새로운 케이스가 추가되어도 컴파일러가 경고해주지 않습니다. 정리 다시 김개발 씨의 이야기로 돌아가 봅시다.

switch 패턴 매칭을 배운 김개발 씨는 기존 코드를 리팩토링했습니다. "와, 코드가 정말 깔끔해졌어요!" Dart 3의 switch 패턴 매칭을 제대로 이해하면 더 안전하고 읽기 쉬운 코드를 작성할 수 있습니다.

여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.

실전 팁

💡 - 완전성 검사 활용: sealed class의 모든 케이스를 처리했는지 컴파일러가 확인하게 하세요

  • 구조 분해 사용: :final value 문법으로 데이터를 바로 추출하세요
  • 명시적 처리: 가능하면 와일드카드보다 모든 케이스를 명시적으로 처리하세요

3. when() 메서드 활용

김개발 씨가 switch 패턴 매칭을 잘 사용하고 있었는데, 박시니어 씨가 "when() 메서드도 알아두면 좋아요"라고 말했습니다. "switch와 뭐가 다른데요?" 김개발 씨가 물었습니다.

"더 간편하고 콜백 방식이라 함수형 프로그래밍 스타일에 잘 맞아요"라고 박시니어 씨가 답했습니다.

when() 메서드는 AsyncValue가 제공하는 편리한 헬퍼 메서드입니다. data, loading, error 세 개의 콜백을 받아서 현재 상태에 맞는 콜백을 자동으로 실행합니다.

함수형 프로그래밍 스타일을 선호하는 개발자들이 많이 사용하며, 코드가 매우 간결해집니다.

다음 코드를 살펴봅시다.

// when() 메서드로 상태별 UI 반환
final userAsync = ref.watch(userProvider);

return userAsync.when(
  data: (user) => Column(
    children: [
      CircleAvatar(
        backgroundImage: NetworkImage(user.avatarUrl),
      ),
      Text(user.name, style: TextStyle(fontSize: 20)),
      Text(user.email, style: TextStyle(color: Colors.grey)),
    ],
  ),
  loading: () => const Center(
    child: CircularProgressIndicator(),
  ),
  error: (err, stack) => Center(
    child: Text('오류: $err'),
  ),
);

김개발 씨는 switch 패턴 매칭을 마스터했다고 생각했습니다. 그런데 팀 코드 리뷰를 하던 중, 다른 동료가 when()이라는 메서드를 사용하는 것을 발견했습니다.

"어? 이건 또 뭐지?" 김개발 씨는 궁금해졌습니다.

switch도 충분히 깔끔한데, when()은 어떤 점이 다를까요? 박시니어 씨가 설명을 시작했습니다.

"when()은 AsyncValue가 기본으로 제공하는 메서드예요. 함수형 프로그래밍 스타일로 상태를 처리할 수 있죠." when() 메서드란? 쉽게 비유하자면, when()은 마치 레스토랑의 주문 시스템과 같습니다.

손님이 와서 "메뉴가 준비되면 서빙해주세요, 조리 중이면 기다리겠습니다, 재료가 없으면 알려주세요"라고 미리 지시를 내려놓는 것과 같습니다. when() 메서드는 세 가지 콜백 함수를 받습니다.

data 콜백은 데이터가 있을 때, loading 콜백은 로딩 중일 때, error 콜백은 에러가 발생했을 때 실행됩니다. AsyncValue가 알아서 현재 상태를 판단하고 적절한 콜백을 호출해줍니다.

왜 when()을 사용할까? switch 패턴 매칭도 좋지만, 몇 가지 아쉬운 점이 있었습니다. 첫째, 보일러플레이트 코드가 있습니다.

AsyncData(:final value) 같은 패턴을 매번 작성해야 했습니다. 둘째, 가독성입니다.

switch 문법에 익숙하지 않은 개발자는 코드를 읽기 어려울 수 있습니다. 셋째, 일관성의 문제였습니다.

팀원마다 switch를 작성하는 스타일이 달라서, 코드베이스 전체가 통일되지 않을 수 있습니다. when()의 장점 바로 이런 문제를 해결하기 위해 when() 메서드가 제공됩니다.

when()을 사용하면 명시적인 콜백으로 의도가 명확해집니다. data:, loading:, error: 라벨이 붙어있어서 어떤 상태를 처리하는지 한눈에 보입니다.

또한 자동 타입 추론이 가능합니다. data 콜백의 파라미터는 자동으로 올바른 타입이 추론되므로, 별도의 캐스팅이 필요 없습니다.

무엇보다 함수형 스타일을 선호하는 개발자들에게 친숙하다는 큰 이점이 있습니다. React나 SwiftUI 같은 선언적 UI 프레임워크를 사용해본 개발자라면 금방 이해할 수 있는 패턴입니다.

코드 분석 위의 코드를 한 줄씩 살펴보겠습니다. 먼저 userAsync.when()을 호출합니다.

when() 메서드는 세 개의 named parameter를 받습니다. data: (user) => ... 부분이 핵심입니다.

데이터가 있을 때 실행되는 콜백이며, user 파라미터로 실제 데이터를 받습니다. 타입 추론 덕분에 user는 자동으로 User 타입이 됩니다.

여기서는 사용자의 아바타, 이름, 이메일을 보여주는 Column 위젯을 반환합니다. loading: () => ... 부분은 로딩 중일 때 실행됩니다.

파라미터가 없고, CircularProgressIndicator를 중앙에 배치한 위젯을 반환합니다. error: (err, stack) => ... 부분은 에러가 발생했을 때 실행됩니다.

err는 에러 객체, stack은 스택 트레이스입니다. 여기서는 에러 메시지를 Text 위젯으로 보여줍니다.

실무 활용 사례 실제 현업에서는 어떻게 활용할까요? 예를 들어 이커머스 앱을 개발한다고 가정해봅시다.

상품 상세 페이지에서 리뷰 목록을 불러올 때 when()을 사용하면 깔끔합니다. 리뷰가 로딩 중일 때는 스켈레톤 UI를, 리뷰가 로드되면 리스트를, 에러가 나면 재시도 버튼을 보여주는 것을 when() 하나로 처리할 수 있습니다.

많은 프로덕션 앱에서 이런 패턴을 사용합니다. when() vs switch 그렇다면 when()과 switch 중 무엇을 선택해야 할까요?

정답은 "팀의 코딩 스타일"에 달려있습니다. 함수형 프로그래밍 스타일을 선호하면 when()이 좋습니다.

패턴 매칭을 선호하면 switch가 좋습니다. 둘 다 동일한 결과를 만들어내므로, 팀 컨벤션에 맞춰 일관되게 사용하는 것이 중요합니다.

주의사항 하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 error 콜백에서 stack을 무시하는 것입니다.

디버깅할 때 스택 트레이스는 매우 중요한 정보입니다. 개발 환경에서는 stack도 함께 출력하면 버그를 찾기 쉬워집니다.

정리 다시 김개발 씨의 이야기로 돌아가 봅시다. when() 메서드를 배운 김개발 씨는 "이게 더 읽기 편한데요?"라고 말했습니다.

박시니어 씨가 미소를 지으며 답했습니다. "본인에게 편한 방법을 선택하면 돼요." when() 메서드를 제대로 이해하면 더 간결하고 함수형 스타일의 코드를 작성할 수 있습니다.

여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.

실전 팁

💡 - 일관성 유지: 팀에서 when()을 사용하기로 했다면 프로젝트 전체에서 일관되게 사용하세요

  • 스택 트레이스 활용: 개발 환경에서는 error 콜백의 stack 파라미터도 출력하세요
  • 화살표 함수: 간단한 위젯은 화살표 함수로, 복잡한 로직은 블록 함수로 작성하세요

4. skipLoadingOnRefresh 옵션

김개발 씨가 "당겨서 새로고침" 기능을 구현했는데, 문제가 생겼습니다. 새로고침할 때마다 화면이 로딩 스피너로 바뀌면서 기존 데이터가 사라졌습니다.

"사용자 경험이 별로인데..." 고민하던 중, 박시니어 씨가 "skipLoadingOnRefresh를 사용해보세요"라고 조언했습니다.

skipLoadingOnRefresh는 AsyncValue를 새로고침할 때 로딩 상태를 건너뛰는 옵션입니다. ref.refresh()나 invalidate()로 데이터를 다시 불러올 때, 기존 데이터를 유지하면서 백그라운드에서 업데이트합니다.

사용자는 깜빡임 없이 부드러운 경험을 할 수 있습니다.

다음 코드를 살펴봅시다.

// skipLoadingOnRefresh 옵션 사용
@riverpod
Future<List<Post>> posts(PostsRef ref) async {
  final response = await http.get(Uri.parse('https://api.example.com/posts'));
  return (jsonDecode(response.body) as List)
      .map((json) => Post.fromJson(json))
      .toList();
}

// Consumer 위젯에서 사용
final postsAsync = ref.watch(postsProvider);

return RefreshIndicator(
  onRefresh: () => ref.refresh(postsProvider.future),
  child: postsAsync.when(
    skipLoadingOnRefresh: true, // 핵심!
    data: (posts) => ListView.builder(
      itemCount: posts.length,
      itemBuilder: (context, index) => PostCard(post: posts[index]),
    ),
    loading: () => const CircularProgressIndicator(),
    error: (err, stack) => Text('에러: $err'),
  ),
);

김개발 씨는 SNS 앱의 피드 화면을 만들고 있었습니다. 사용자가 화면을 당기면 최신 게시물을 불러오는 "당겨서 새로고침" 기능을 구현했습니다.

그런데 테스트를 해보니 이상한 현상이 발생했습니다. 새로고침할 때마다 화면 전체가 로딩 스피너로 바뀌면서 기존에 보던 게시물들이 사라졌습니다.

"이건 좀 이상한데..." 김개발 씨는 불편함을 느꼈습니다. 박시니어 씨가 다가와서 화면을 보더니 말했습니다.

"아, skipLoadingOnRefresh 옵션을 써야 해요." skipLoadingOnRefresh란? 쉽게 비유하자면, skipLoadingOnRefresh는 마치 책을 읽다가 업데이트된 버전을 받는 것과 같습니다. 일반적으로는 책을 닫고 새 버전을 기다려야 하지만, skipLoadingOnRefresh를 사용하면 책을 계속 읽으면서 백그라운드에서 조용히 새 버전을 받습니다.

기본적으로 AsyncValue는 데이터를 다시 불러올 때 AsyncLoading 상태로 돌아갑니다. 이것은 첫 로딩일 때는 맞지만, 새로고침일 때는 사용자 경험이 좋지 않습니다.

왜 skipLoadingOnRefresh가 필요한가? 새로고침할 때마다 로딩 상태로 돌아가면 어떤 문제가 생길까요? 첫째, 깜빡임 현상이 발생합니다.

사용자가 보던 콘텐츠가 갑자기 사라지고 로딩 스피너가 나타났다가 다시 콘텐츠가 나타납니다. 이것은 시각적으로 불편합니다.

둘째, 스크롤 위치 손실입니다. 사용자가 피드를 한참 내려서 보고 있었는데, 새로고침하면 맨 위로 돌아갑니다.

매우 짜증나는 경험입니다. 셋째, 인지 부하가 증가합니다.

로딩 스피너가 나타나면 사용자는 "기다려야 하나?"라고 생각하게 됩니다. 실제로는 백그라운드에서 빠르게 업데이트되는데도 말이죠.

skipLoadingOnRefresh의 동작 바로 이런 문제를 해결하기 위해 skipLoadingOnRefresh 옵션이 등장했습니다. skipLoadingOnRefresh를 true로 설정하면 기존 데이터를 유지합니다.

AsyncLoading 상태로 돌아가지 않고, 이전 AsyncData 상태를 그대로 보여줍니다. 사용자는 계속 콘텐츠를 볼 수 있습니다.

동시에 백그라운드에서 업데이트가 진행됩니다. 새로운 데이터가 도착하면 자연스럽게 화면이 갱신됩니다.

또한 isRefreshing 플래그를 사용할 수 있습니다. 이것으로 작은 로딩 인디케이터를 상단에 표시할 수 있습니다.

코드 분석 위의 코드를 한 줄씩 살펴보겠습니다. 먼저 postsProvider는 일반적인 FutureProvider입니다.

API에서 게시물 목록을 불러오는 비동기 함수를 제공합니다. RefreshIndicator 위젯이 핵심입니다.

사용자가 화면을 당기면 onRefresh 콜백이 실행됩니다. 여기서 ref.refresh(postsProvider.future)를 호출하여 데이터를 다시 불러옵니다.

skipLoadingOnRefresh: true 부분이 가장 중요합니다. 이것을 when() 메서드에 전달하면, refresh 중에는 loading 콜백이 실행되지 않습니다.

대신 기존의 data 콜백이 계속 실행되어 기존 게시물 목록을 보여줍니다. 새 데이터가 도착하면 자동으로 data 콜백이 새 데이터로 다시 실행되어 화면이 업데이트됩니다.

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

실시간 시세를 보여주는 화면에서 사용자가 새로고침을 자주 합니다. 매번 로딩 스피너가 나타나면 어떤 가격에서 거래할지 판단하기 어렵습니다.

skipLoadingOnRefresh를 사용하면 기존 시세를 보면서 새 시세가 업데이트되기를 기다릴 수 있습니다. 실제 증권사 앱들이 이런 패턴을 사용합니다.

isRefreshing과 함께 사용하기 더 나은 사용자 경험을 위해 isRefreshing을 활용할 수 있습니다. postsAsync.isRefreshing 프로퍼티는 백그라운드에서 새로고침 중인지 알려줍니다.

이것을 사용하면 화면 상단에 작은 프로그레스 바를 표시할 수 있습니다. 사용자는 "아, 지금 업데이트 중이구나"를 알 수 있으면서도 기존 콘텐츠를 계속 볼 수 있습니다.

주의사항 하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 첫 로딩에도 skipLoadingOnRefresh를 적용하는 것입니다.

첫 로딩일 때는 보여줄 데이터가 없으므로 로딩 인디케이터를 표시하는 것이 맞습니다. skipLoadingOnRefresh는 오직 refresh 할 때만 적용됩니다.

정리 다시 김개발 씨의 이야기로 돌아가 봅시다. skipLoadingOnRefresh를 적용한 김개발 씨는 앱을 다시 테스트했습니다.

"오, 이제 훨씬 부드럽네요!" 사용자 경험이 크게 개선되었습니다. skipLoadingOnRefresh 옵션을 제대로 이해하면 더 세련된 UI를 만들 수 있습니다.

여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.

실전 팁

💡 - 새로고침에만 적용: 첫 로딩에는 적용되지 않으며, 오직 refresh/invalidate 시에만 동작합니다

  • isRefreshing 활용: 백그라운드 업데이트 중임을 사용자에게 알리세요
  • RefreshIndicator 조합: 당겨서 새로고침 패턴과 함께 사용하면 최고의 UX를 제공합니다

5. 커스텀 로딩 위젯

김개발 씨가 만든 앱의 모든 화면에서 똑같은 CircularProgressIndicator가 나타났습니다. "좀 더 브랜드에 맞는 로딩 화면을 만들 수 없을까?" 고민하던 중, 디자이너가 로딩 애니메이션 시안을 보내왔습니다.

"이걸 어떻게 적용하지?" 김개발 씨가 물었고, 박시니어 씨가 "커스텀 위젯을 만들면 돼요"라고 답했습니다.

커스텀 로딩 위젯은 앱의 브랜드와 디자인 시스템에 맞는 로딩 인디케이터를 만드는 방법입니다. AsyncValue의 loading 상태에서 기본 CircularProgressIndicator 대신 로고 애니메이션, 스켈레톤 UI, 또는 커스텀 디자인을 보여줄 수 있습니다.

일관된 브랜드 경험을 제공하는 데 필수적입니다.

다음 코드를 살펴봅시다.

// 커스텀 로딩 위젯 정의
class BrandLoadingIndicator extends StatelessWidget {
  const BrandLoadingIndicator({super.key});

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Image.asset('assets/logo.png', width: 80, height: 80),
          const SizedBox(height: 16),
          const Text('로딩 중...', style: TextStyle(color: Colors.grey)),
          const SizedBox(height: 8),
          const LinearProgressIndicator(),
        ],
      ),
    );
  }
}

// AsyncValue에서 사용
userAsync.when(
  data: (user) => UserProfile(user: user),
  loading: () => const BrandLoadingIndicator(), // 커스텀 위젯 사용
  error: (err, stack) => ErrorWidget(error: err),
);

김개발 씨는 앱 개발이 거의 완료되어 가고 있었습니다. 그런데 디자이너 최디자인 씨가 찾아왔습니다.

"김개발님, 로딩 화면이 너무 평범해요. 우리 브랜드 아이덴티티를 살릴 수 있을까요?" 최디자인 씨가 보여준 시안에는 회사 로고가 부드럽게 회전하고, 그 아래 브랜드 컬러의 프로그레스 바가 있었습니다.

"이거 괜찮은데?" 김개발 씨도 마음에 들었지만, "이걸 어떻게 구현하지?"라고 고민했습니다. 박시니어 씨가 조언했습니다.

"커스텀 로딩 위젯을 만들면 됩니다. 한 번만 만들어두면 앱 전체에서 재사용할 수 있어요." 커스텀 로딩 위젯이란? 쉽게 비유하자면, 커스텀 로딩 위젯은 마치 가게의 간판과 같습니다.

모든 가게에는 문이 있지만, 간판은 각 가게마다 다릅니다. 그 간판이 브랜드를 표현하죠.

기본 CircularProgressIndicator는 기능적으로는 문제없지만, 모든 앱이 똑같이 생겼습니다. 사용자는 "아, 또 로딩이네"라고만 생각합니다.

하지만 브랜드 로고와 컬러가 들어간 로딩 화면을 보면, "아, 이 앱이구나"라고 인식하게 됩니다. 왜 커스텀 로딩 위젯이 필요한가? 기본 로딩 인디케이터만 사용하면 어떤 문제가 있을까요?

첫째, 브랜드 정체성 부족입니다. 모든 앱이 똑같은 회색 스피너를 사용하면 차별화되지 않습니다.

사용자는 앱을 기억하기 어렵습니다. 둘째, 일관성 부족입니다.

디자인 시스템을 구축했는데 로딩 화면만 기본 위젯을 쓰면 어색합니다. 버튼은 브랜드 컬러인데 로딩 스피너는 회색이면 통일감이 없습니다.

셋째, 사용자 경험의 문제입니다. 로딩 시간이 길 때, 단순한 스피너보다는 브랜드 메시지나 팁을 보여주면 사용자가 덜 지루해합니다.

커스텀 위젯의 장점 바로 이런 문제를 해결하기 위해 커스텀 로딩 위젯을 만듭니다. 커스텀 위젯을 사용하면 브랜드 일관성을 유지할 수 있습니다.

앱의 모든 로딩 화면이 동일한 디자인을 사용하므로 전문적으로 보입니다. 또한 재사용성이 뛰어납니다.

한 번만 만들어두면 앱 전체에서 사용할 수 있습니다. 무엇보다 사용자 경험 향상이라는 큰 이점이 있습니다.

로딩 중에 유용한 팁을 보여주거나, 재미있는 애니메이션을 넣으면 사용자가 기다리는 시간이 덜 지루합니다. 코드 분석 위의 코드를 한 줄씩 살펴보겠습니다.

먼저 BrandLoadingIndicator라는 StatelessWidget을 정의합니다. 이것이 앱 전체에서 사용할 커스텀 로딩 위젯입니다.

Center 위젯으로 화면 중앙에 배치하고, Column으로 세로로 요소들을 나열합니다. Image.asset으로 회사 로고를 표시합니다.

로고 크기는 80x80으로 설정했습니다. SizedBox로 간격을 두고, "로딩 중..." 텍스트를 회색으로 표시합니다.

사용자에게 현재 상태를 명확히 알려주는 것입니다. 마지막으로 LinearProgressIndicator를 추가합니다.

진행 상황을 시각적으로 보여줍니다. 이것도 앱의 테마 컬러를 따라갑니다.

실제 사용할 때는 loading: () => const BrandLoadingIndicator()처럼 when() 메서드의 loading 콜백에 전달하면 됩니다. 다양한 커스텀 위젯 패턴 실무에서는 다양한 커스텀 로딩 위젯을 사용합니다.

스켈레톤 UI는 실제 콘텐츠 모양과 비슷한 회색 박스를 보여줍니다. 사용자는 "곧 이런 형태의 콘텐츠가 나오겠구나"를 미리 알 수 있습니다.

페이스북이나 인스타그램이 이런 패턴을 사용합니다. 애니메이션 로고는 회사 로고에 회전, 페이드, 스케일 등의 애니메이션을 넣습니다.

브랜드를 강조하면서도 로딩 중임을 알려줍니다. 팁 메시지는 로딩 중에 사용자에게 유용한 정보를 제공합니다.

"알고 계셨나요? 이 기능을 사용하면..." 같은 메시지로 대기 시간을 유익하게 만듭니다.

실무 활용 사례 실제 현업에서는 어떻게 활용할까요? 예를 들어 음식 배달 앱을 개발한다고 가정해봅시다.

메뉴를 불러올 때 스켈레톤 UI로 음식 카드 모양을 미리 보여줍니다. 주문 처리 중에는 배달 오토바이 애니메이션을 보여주며 "곧 맛있는 음식이 도착해요!"라는 메시지를 넣습니다.

이런 작은 디테일이 사용자 만족도를 크게 높입니다. 성능 고려사항 커스텀 로딩 위젯을 만들 때 성능도 신경 써야 합니다.

너무 복잡한 애니메이션을 넣으면 오히려 앱이 느려질 수 있습니다. 특히 저사양 기기에서는 더욱 그렇습니다.

따라서 단순하고 가벼운 애니메이션을 선택하는 것이 좋습니다. 또한 이미지 최적화도 중요합니다.

로고 이미지는 여러 번 사용되므로 크기를 적절히 조절하고, 캐싱을 활용해야 합니다. 주의사항 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수 중 하나는 과도한 디자인입니다. 로딩 위젯이 너무 화려하면 오히려 콘텐츠에서 주의를 빼앗습니다.

적절한 균형을 유지해야 합니다. 정리 다시 김개발 씨의 이야기로 돌아가 봅시다.

커스텀 로딩 위젯을 적용한 김개발 씨는 최디자인 씨에게 결과를 보여줬습니다. "완벽해요!" 최디자인 씨가 만족스러워했습니다.

커스텀 로딩 위젯을 제대로 이해하면 브랜드 정체성이 살아있는 앱을 만들 수 있습니다. 여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.

실전 팁

💡 - 재사용 가능하게: 위젯을 별도 파일로 분리하고 앱 전체에서 재사용하세요

  • 테마 시스템 활용: Theme.of(context)를 사용해 앱 테마 컬러를 자동으로 적용하세요
  • 심플하게: 과도한 애니메이션은 오히려 성능을 저하시킬 수 있습니다

6. 에러 재시도 버튼

김개발 씨가 만든 앱에서 네트워크 에러가 발생했습니다. 화면에 "에러 발생"이라는 메시지만 덩그러니 표시되었습니다.

사용자는 앱을 완전히 종료했다가 다시 켜야 했습니다. "이건 너무 불편한데..." QA 팀에서 피드백이 왔고, 박시니어 씨가 "재시도 버튼을 추가해야죠"라고 조언했습니다.

에러 재시도 버튼은 AsyncValue의 error 상태에서 사용자가 직접 다시 시도할 수 있게 하는 UI 패턴입니다. ref.invalidate() 또는 ref.refresh()를 호출하여 Provider를 다시 실행하고, 사용자는 앱을 재시작하지 않고도 문제를 해결할 수 있습니다.

네트워크 에러 같은 일시적 문제에 필수적입니다.

다음 코드를 살펴봅시다.

// 에러 상태에서 재시도 버튼 구현
class UserProfileScreen extends ConsumerWidget {
  const UserProfileScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final userAsync = ref.watch(userProvider);

    return userAsync.when(
      data: (user) => UserProfileView(user: user),
      loading: () => const BrandLoadingIndicator(),
      error: (error, stackTrace) => Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Icon(Icons.error_outline, size: 60, color: Colors.red),
            const SizedBox(height: 16),
            Text('에러 발생: $error'),
            const SizedBox(height: 24),
            ElevatedButton.icon(
              onPressed: () => ref.invalidate(userProvider), // 재시도!
              icon: const Icon(Icons.refresh),
              label: const Text('다시 시도'),
            ),
          ],
        ),
      ),
    );
  }
}

김개발 씨는 앱을 거의 완성했다고 생각했습니다. 그런데 QA 팀에서 버그 리포트가 날아왔습니다.

"네트워크가 끊겼을 때 에러 메시지만 나오고 아무것도 할 수 없어요. 앱을 완전히 종료했다가 다시 켜야 해요." 김개발 씨는 깜짝 놀랐습니다.

"에러 메시지를 보여줬는데 뭐가 문제지?" 그런데 직접 테스트해보니 정말 불편했습니다. 와이파이를 잠깐 껐다 켜면 되는 간단한 문제인데, 앱을 완전히 재시작해야 한다니요.

박시니어 씨가 코드를 보더니 말했습니다. "재시도 버튼이 없네요.

사용자가 직접 다시 시도할 수 있게 해야죠." 에러 재시도 버튼이란? 쉽게 비유하자면, 에러 재시도 버튼은 마치 자동판매기의 환불 버튼과 같습니다. 음료수가 나오지 않았을 때, 기계를 통째로 리셋하는 것이 아니라 환불 버튼을 눌러서 다시 시도할 수 있는 것처럼, 앱도 에러가 나면 간단히 재시도할 수 있어야 합니다.

사용자는 왜 에러가 났는지 정확히 모릅니다. 와이파이 문제일 수도, 서버 문제일 수도, 일시적인 버그일 수도 있습니다.

이럴 때 "다시 시도" 버튼 하나만 있으면 많은 문제가 해결됩니다. 왜 재시도 버튼이 필요한가? 재시도 기능이 없으면 어떤 문제가 생길까요?

첫째, 사용자 이탈입니다. 앱을 종료했다가 다시 켜야 한다면, 많은 사용자가 그냥 포기합니다.

"귀찮아, 나중에 하지 뭐." 특히 모바일 환경에서는 앱 전환 자체가 마찰입니다. 둘째, 부정적 인상입니다.

에러 메시지만 덩그러니 있으면 "이 앱 버그 많네"라는 인상을 줍니다. 하지만 친절한 재시도 버튼이 있으면 "잘 만든 앱이네"라고 생각합니다.

셋째, 고객 지원 부담입니다. 사용자들이 "앱이 안 돼요"라고 문의하면 고객센터에서 "앱을 껐다 켜보세요"라고 답해야 합니다.

재시도 버튼이 있으면 이런 문의가 크게 줄어듭니다. 재시도 버튼 구현 바로 이런 문제를 해결하기 위해 재시도 버튼을 구현합니다.

재시도 버튼을 사용하면 사용자 주도 복구가 가능합니다. 사용자가 네트워크를 다시 연결한 후 직접 재시도할 수 있습니다.

또한 명확한 액션을 제공합니다. "뭘 해야 하지?"가 아니라 "이 버튼을 누르면 되겠구나"를 알려줍니다.

무엇보다 사용자 경험 향상이라는 큰 이점이 있습니다. 간단한 버튼 하나로 앱의 완성도가 크게 올라갑니다.

코드 분석 위의 코드를 한 줄씩 살펴보겠습니다. 먼저 ConsumerWidget을 사용하여 WidgetRef를 받아옵니다.

ref.invalidate()를 호출하려면 ref가 필요합니다. userAsync.when()의 error 콜백에 재시도 UI를 구현합니다.

Column으로 에러 아이콘, 메시지, 버튼을 세로로 배치합니다. Icon(Icons.error_outline)로 사용자에게 시각적으로 에러를 알립니다.

빨간색으로 표시하여 경고의 의미를 전달합니다. Text('에러 발생: $error')로 실제 에러 메시지를 보여줍니다.

개발 환경에서는 상세한 메시지를, 프로덕션에서는 사용자 친화적인 메시지를 보여주는 것이 좋습니다. 핵심은 ElevatedButton.icon입니다.

onPressed: () => ref.invalidate(userProvider) 부분에서 Provider를 무효화합니다. 이렇게 하면 Provider가 처음부터 다시 실행되어 데이터를 다시 불러옵니다.

invalidate vs refresh 재시도를 구현하는 방법은 두 가지가 있습니다. ref.invalidate(userProvider)는 Provider의 상태를 완전히 초기화합니다.

다음번에 이 Provider를 watch할 때 처음부터 다시 실행됩니다. 완전한 재시작이 필요할 때 사용합니다.

ref.refresh(userProvider)는 즉시 Provider를 다시 실행합니다. 현재 화면에서 바로 재시도해야 할 때 사용합니다.

대부분의 경우 refresh가 더 직관적입니다. 실무 활용 사례 실제 현업에서는 어떻게 활용할까요?

예를 들어 날씨 앱을 개발한다고 가정해봅시다. GPS 위치를 가져오는 중에 권한 에러가 발생할 수 있습니다.

이때 "위치 권한을 허용해주세요. 다시 시도" 버튼을 보여주면, 사용자가 설정에서 권한을 켠 후 바로 재시도할 수 있습니다.

앱을 종료했다 다시 켤 필요가 없습니다. 에러 메시지 개선 재시도 버튼과 함께 에러 메시지도 개선해야 합니다.

개발자용 에러 메시지는 사용자에게 도움이 되지 않습니다. "SocketException: Failed host lookup" 같은 메시지 대신 "네트워크 연결을 확인해주세요"처럼 친절하게 바꿔야 합니다.

또한 에러 종류별 메시지를 다르게 할 수 있습니다. 네트워크 에러는 "인터넷 연결을 확인하세요", 서버 에러는 "서버가 응답하지 않습니다.

잠시 후 다시 시도해주세요", 권한 에러는 "권한을 허용해주세요" 같은 식입니다. 로딩 상태 피드백 재시도 버튼을 누르면 다시 로딩 상태가 됩니다.

이때 버튼을 비활성화하거나, 버튼 안에 작은 로딩 스피너를 보여주면 좋습니다. 사용자가 "버튼이 안 눌려"라고 여러 번 누르는 것을 방지할 수 있습니다.

주의사항 하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 무한 재시도입니다.

재시도 버튼을 누르면 바로 다시 API를 호출하는데, 서버가 다운되어 있으면 계속 실패합니다. 재시도 횟수를 제한하거나, 재시도 간격을 두는 것이 좋습니다.

정리 다시 김개발 씨의 이야기로 돌아가 봅시다. 재시도 버튼을 추가한 김개발 씨는 QA 팀에게 다시 테스트를 요청했습니다.

"이제 훨씬 좋네요!" QA 팀도 만족했습니다. 에러 재시도 버튼을 제대로 이해하면 더 견고하고 사용자 친화적인 앱을 만들 수 있습니다.

여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.

실전 팁

💡 - 명확한 액션: "재시도", "다시 시도" 같은 명확한 버튼 라벨을 사용하세요

  • 친절한 메시지: 개발자용 에러 메시지 대신 사용자 친화적인 메시지를 보여주세요
  • 재시도 제한: 무한 재시도를 방지하기 위해 횟수 제한이나 딜레이를 고려하세요

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

#Flutter#Riverpod#AsyncValue#StateManagement#UIPatterns#Flutter,Riverpod

댓글 (0)

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

함께 보면 좋은 카드 뉴스