🤖

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

⚠️

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

이미지 로딩 중...

Riverpod 3.0 Retry와 에러 복구 전략 완벽 가이드 - 슬라이드 1/7
A

AI Generated

2025. 11. 30. · 22 Views

Riverpod 3.0 Retry와 에러 복구 전략 완벽 가이드

Riverpod 3.0에서 새롭게 도입된 AsyncValue.retrying 상태와 다양한 에러 복구 전략을 학습합니다. 네트워크 오류 상황에서 자동 재시도 로직을 구현하고 사용자 경험을 최적화하는 방법을 실무 예제와 함께 알아봅니다.


목차

  1. AsyncValue.retrying 상태
  2. ProviderContainer.defaultRetry 설정
  3. ProviderException 에러 래핑
  4. 커스텀 재시도 로직
  5. 네트워크 에러 복구 패턴
  6. 사용자 경험 최적화

1. AsyncValue.retrying 상태

김개발 씨는 회사에서 운영하는 앱의 사용자 불만을 처리하고 있었습니다. "로딩 중에 재시도 버튼을 눌렀는데, 지금 뭐가 되고 있는 건지 모르겠어요." 이런 피드백이 계속 들어왔습니다.

데이터를 다시 불러오는 중인지, 아직 에러 상태인지 사용자에게 명확히 알려줄 방법이 필요했습니다.

AsyncValue.retrying은 Riverpod 3.0에서 새롭게 추가된 상태 플래그입니다. 마치 식당에서 주문한 음식이 늦어져서 다시 요청했을 때, "재조리 중입니다"라는 안내를 받는 것과 같습니다.

이 상태를 활용하면 에러 발생 후 재시도가 진행 중인지 사용자에게 명확하게 알려줄 수 있습니다.

다음 코드를 살펴봅시다.

// Riverpod 3.0의 AsyncValue.retrying 활용
final userDataProvider = FutureProvider<User>((ref) async {
  final response = await api.fetchUser();
  return User.fromJson(response);
});

// 위젯에서 retrying 상태 처리
class UserProfile extends ConsumerWidget {
  Widget build(BuildContext context, WidgetRef ref) {
    final userAsync = ref.watch(userDataProvider);

    // isRetrying으로 재시도 상태 확인
    if (userAsync.isRetrying) {
      return const Text('다시 불러오는 중...');
    }

    return userAsync.when(
      data: (user) => Text(user.name),
      loading: () => const CircularProgressIndicator(),
      error: (error, stack) => RetryButton(
        onPressed: () => ref.invalidate(userDataProvider),
      ),
    );
  }
}

김개발 씨는 입사 6개월 차 플러터 개발자입니다. 어느 날 기획팀에서 긴급 요청이 들어왔습니다.

"사용자들이 네트워크 오류 후 재시도 버튼을 눌러도 반응이 없는 것 같다고 불만을 제기하고 있어요." 로그를 확인해보니 재시도는 정상적으로 동작하고 있었습니다. 문제는 UI였습니다.

재시도 중에도 에러 화면이 그대로 떠 있어서 사용자들이 버튼이 작동하지 않는다고 오해한 것이었습니다. 선배 개발자 박시니어 씨가 다가와 화면을 살펴봤습니다.

"Riverpod 3.0으로 업그레이드했어요? 새로 추가된 isRetrying 상태를 써보세요." 그렇다면 AsyncValue.retrying이란 정확히 무엇일까요?

쉽게 비유하자면, 온라인 쇼핑몰에서 결제가 실패했을 때를 생각해보세요. 다시 결제 버튼을 누르면 "결제 재시도 중..."이라는 메시지가 뜨는 것을 본 적 있을 겁니다.

단순히 "로딩 중"이라고만 표시되는 것보다 훨씬 명확하게 현재 상황을 알 수 있습니다. Riverpod 2.x 버전에서는 이런 구분이 어려웠습니다.

isLoading만으로는 최초 로딩인지, 새로고침인지, 에러 후 재시도인지 구분할 수 없었기 때문입니다. 개발자들은 별도의 상태 변수를 만들어 관리해야 했고, 이는 코드 복잡도를 높이는 원인이 되었습니다.

바로 이런 문제를 해결하기 위해 Riverpod 3.0에서 isRetrying 속성이 추가되었습니다. isRetrying은 AsyncValue가 에러 상태에서 다시 데이터를 불러오려고 시도할 때 true가 됩니다.

이를 통해 최초 로딩, 리프레시, 에러 복구 상황을 명확하게 구분할 수 있습니다. 위의 코드를 살펴보겠습니다.

userAsync.isRetrying을 먼저 체크하여 재시도 중인 상황을 우선 처리합니다. 이렇게 하면 에러 화면 대신 "다시 불러오는 중..."이라는 메시지를 보여줄 수 있습니다.

실제 현업에서는 어떻게 활용할까요? 예를 들어 금융 앱에서 잔액 조회가 실패했을 때, 사용자가 재시도 버튼을 누르면 "잔액을 다시 확인하고 있습니다"라고 표시할 수 있습니다.

이런 세심한 피드백이 사용자 신뢰도를 높여줍니다. 주의할 점도 있습니다.

isRetrying 체크는 when 메서드보다 먼저 수행해야 합니다. when 내부에서는 이미 에러 상태로 분기되어 있기 때문에 재시도 상태를 별도로 처리하기 어렵습니다.

김개발 씨는 코드를 수정한 후 다시 테스트해봤습니다. 이제 재시도 버튼을 누르면 즉시 "다시 불러오는 중..." 메시지가 표시되었습니다.

사용자 불만 건수가 눈에 띄게 줄어들었습니다.

실전 팁

💡 - isRetrying 체크는 when 메서드 호출 전에 수행하세요

  • 재시도 중임을 나타내는 UI는 로딩 UI와 다르게 디자인하면 더 명확합니다

2. ProviderContainer.defaultRetry 설정

프로젝트가 커지면서 김개발 씨는 고민에 빠졌습니다. 앱 전체에 수십 개의 Provider가 있는데, 각각에 재시도 로직을 일일이 추가하자니 코드가 너무 반복되었습니다.

"한 곳에서 설정하면 전체에 적용되는 방법이 없을까요?"

ProviderContainer.defaultRetry는 앱 전체의 Provider에 적용되는 기본 재시도 정책을 설정하는 기능입니다. 마치 회사 전체에 적용되는 보안 정책처럼, 한 번 설정해두면 모든 Provider가 동일한 재시도 규칙을 따르게 됩니다.

개별적으로 설정할 필요 없이 일관된 에러 복구 전략을 구현할 수 있습니다.

다음 코드를 살펴봅시다.

// main.dart에서 전역 재시도 정책 설정
void main() {
  final container = ProviderContainer(
    // 기본 재시도 정책 설정
    defaultRetry: Retry(
      maxAttempts: 3,           // 최대 3회 재시도
      delay: Duration(seconds: 1), // 1초 간격
      delayFactor: 2.0,        // 지수 백오프 (1초, 2초, 4초)
      retryIf: (error) {
        // 네트워크 에러만 재시도
        return error is SocketException ||
               error is TimeoutException;
      },
    ),
  );

  runApp(
    UncontrolledProviderScope(
      container: container,
      child: const MyApp(),
    ),
  );
}

김개발 씨는 코드 리뷰를 받던 중 박시니어 씨로부터 지적을 받았습니다. "이 재시도 로직, Provider마다 복사해서 붙여넣기 했네요.

나중에 정책이 바뀌면 수십 군데를 다 수정해야 해요." 맞는 말이었습니다. 회사에서 재시도 간격을 1초에서 2초로 바꾸라고 하면, 모든 Provider를 찾아다니며 수정해야 했습니다.

실수로 누락되는 곳이 생기기 쉬웠고, 테스트도 어려웠습니다. 그렇다면 어떻게 해결할 수 있을까요?

Riverpod 3.0에서는 ProviderContainer를 생성할 때 defaultRetry 옵션을 지정할 수 있습니다. 이것은 마치 아파트 관리사무소에서 전체 세대에 적용되는 규칙을 정하는 것과 같습니다.

개별 세대가 따로 규칙을 만들 필요 없이, 관리사무소의 규칙을 따르면 됩니다. Retry 클래스는 여러 가지 옵션을 제공합니다.

maxAttempts는 최대 재시도 횟수를 지정합니다. 보통 3회 정도가 적당합니다.

너무 많으면 사용자를 오래 기다리게 하고, 너무 적으면 일시적인 네트워크 문제를 복구하지 못합니다. delay는 재시도 사이의 대기 시간입니다.

delayFactor를 함께 사용하면 지수 백오프 전략을 구현할 수 있습니다. 지수 백오프란 재시도할 때마다 대기 시간을 늘리는 방식입니다.

첫 번째 재시도는 1초, 두 번째는 2초, 세 번째는 4초를 기다립니다. 왜 지수 백오프가 필요할까요?

서버에 문제가 생겼을 때 모든 클라이언트가 동시에 재시도하면 서버 부하가 더 심해집니다. 대기 시간을 늘려가면 서버가 복구될 시간을 벌어줄 수 있습니다.

retryIf 콜백은 어떤 에러에 대해 재시도할지 결정합니다. 모든 에러를 재시도하면 안 됩니다.

예를 들어 인증 실패(401 에러)는 재시도해봤자 계속 실패합니다. 네트워크 연결 문제나 타임아웃처럼 일시적인 문제만 재시도해야 합니다.

코드에서 UncontrolledProviderScope를 사용한 것에 주목하세요. 기본 ProviderScope 대신 직접 생성한 컨테이너를 사용하려면 이 위젯을 써야 합니다.

실제 프로젝트에서는 환경별로 다른 재시도 정책을 적용하기도 합니다. 개발 환경에서는 빠른 피드백을 위해 재시도 횟수를 줄이고, 프로덕션에서는 안정성을 위해 횟수를 늘립니다.

김개발 씨는 defaultRetry를 설정한 후 기존 Provider들의 중복 코드를 모두 제거했습니다. 코드 라인 수가 30%나 줄었고, 재시도 정책을 바꿀 때도 main.dart 한 곳만 수정하면 되었습니다.

실전 팁

💡 - retryIf에서 복구 불가능한 에러(인증 실패, 권한 없음 등)는 제외하세요

  • 지수 백오프의 delayFactor는 보통 1.5~2.0 사이가 적당합니다

3. ProviderException 에러 래핑

디버깅을 하던 김개발 씨가 한숨을 쉬었습니다. 에러 로그에는 "SocketException"이라고만 나와 있었습니다.

어떤 Provider에서 발생한 건지, 어떤 작업 중이었는지 전혀 알 수 없었습니다. "에러가 발생한 맥락을 알 수 있으면 좋겠는데..."

ProviderException은 Provider에서 발생한 에러를 추가 정보와 함께 감싸주는 래퍼 클래스입니다. 마치 택배 송장에 보내는 사람, 받는 사람, 물품 정보가 적혀 있듯이, 에러가 발생한 Provider 정보와 원본 에러를 함께 담아줍니다.

이를 통해 디버깅 시 문제의 원인을 더 빠르게 파악할 수 있습니다.

다음 코드를 살펴봅시다.

// ProviderException으로 에러 정보 보강
final orderProvider = FutureProvider<Order>((ref) async {
  try {
    final response = await api.fetchOrder();
    return Order.fromJson(response);
  } catch (error, stackTrace) {
    // 에러를 ProviderException으로 래핑
    throw ProviderException(
      provider: orderProvider,
      error: error,
      stackTrace: stackTrace,
      message: '주문 정보 조회 실패',
    );
  }
});

// 에러 핸들링 시 상세 정보 활용
void handleError(Object error) {
  if (error is ProviderException) {
    logger.error(
      '${error.message} - Provider: ${error.provider.name}',
      error.error,
      error.stackTrace,
    );
  }
}

새벽 2시, 김개발 씨의 휴대폰이 울렸습니다. 프로덕션 서버에서 에러가 대량 발생하고 있다는 알림이었습니다.

급하게 로그를 확인했지만, "TimeoutException"이라는 메시지만 반복되고 있었습니다. 앱에는 수십 개의 API 호출이 있었습니다.

어떤 API에서 타임아웃이 발생한 건지 알 수 없었습니다. 결국 모든 Provider를 하나씩 확인해야 했고, 문제를 찾는 데만 2시간이 걸렸습니다.

다음 날 박시니어 씨가 말했습니다. "에러를 ProviderException으로 감싸면 그런 고생 안 해도 돼요." ProviderException이란 무엇일까요?

병원에 가면 의사 선생님이 증상뿐만 아니라 언제부터 아팠는지, 어떤 상황에서 아팠는지를 물어봅니다. 단순히 "배가 아프다"는 정보만으로는 진단하기 어렵기 때문입니다.

ProviderException도 마찬가지입니다. 에러 자체뿐만 아니라 에러가 발생한 맥락 정보를 함께 담아줍니다.

ProviderException에는 네 가지 핵심 정보가 포함됩니다. provider는 에러가 발생한 Provider 참조입니다.

error는 원본 에러 객체입니다. stackTrace는 에러 발생 지점의 스택 트레이스입니다.

message는 개발자가 추가한 설명 메시지입니다. 이렇게 래핑하면 어떤 장점이 있을까요?

첫째, 로그만 보고도 문제 지점을 특정할 수 있습니다. "주문 정보 조회 실패 - Provider: orderProvider"라는 로그가 찍히면 바로 어디를 봐야 하는지 알 수 있습니다.

둘째, 에러 모니터링 도구와 연동하기 좋습니다. Sentry나 Firebase Crashlytics 같은 도구에 Provider 이름을 태그로 추가하면, 어떤 Provider에서 에러가 많이 발생하는지 대시보드에서 한눈에 볼 수 있습니다.

셋째, 사용자에게 더 의미 있는 에러 메시지를 보여줄 수 있습니다. "알 수 없는 오류" 대신 "주문 정보를 불러오지 못했습니다"라고 표시할 수 있습니다.

코드를 다시 살펴보면, try-catch로 에러를 잡은 후 ProviderException으로 감싸서 다시 던지고 있습니다. 원본 에러와 스택 트레이스를 보존하면서 추가 정보를 덧붙이는 것이 핵심입니다.

주의할 점이 있습니다. 모든 에러를 무조건 래핑하면 코드가 장황해집니다.

핵심 비즈니스 로직이나 외부 API 호출처럼 에러 추적이 중요한 곳에만 선별적으로 적용하세요. 그 이후로 김개발 씨는 중요한 Provider에 모두 ProviderException 래핑을 추가했습니다.

다음에 새벽 알림이 올 때는 로그만 보고 5분 만에 문제를 해결할 수 있었습니다.

실전 팁

💡 - 모든 Provider에 적용하기보다 외부 API 호출이나 핵심 로직에 선별 적용하세요

  • message에는 사용자에게 보여줄 수 있는 친절한 문구를 넣어두면 재활용하기 좋습니다

4. 커스텀 재시도 로직

defaultRetry로 기본 정책을 설정했지만, 김개발 씨에게 새로운 요구사항이 들어왔습니다. "결제 API는 재시도하면 안 돼요.

중복 결제가 될 수 있으니까요." 특정 Provider에는 전역 설정과 다른 재시도 로직이 필요했습니다.

커스텀 재시도 로직은 개별 Provider에 특화된 재시도 전략을 구현하는 방법입니다. 마치 회사 전체 복장 규정이 있지만 특정 부서는 예외를 두는 것처럼, 전역 설정을 오버라이드하거나 완전히 새로운 재시도 로직을 적용할 수 있습니다.

멱등성이 보장되지 않는 API나 특수한 요구사항이 있는 경우에 유용합니다.

다음 코드를 살펴봅시다.

// 재시도하면 안 되는 결제 Provider
final paymentProvider = FutureProvider.autoDispose<PaymentResult>(
  (ref) async {
    final response = await paymentApi.processPayment();
    return PaymentResult.fromJson(response);
  },
  // 개별 Provider에서 재시도 비활성화
  retry: Retry.none,
);

// 커스텀 재시도 로직이 필요한 Provider
final criticalDataProvider = FutureProvider<CriticalData>(
  (ref) async {
    return await fetchCriticalData();
  },
  // 더 공격적인 재시도 설정
  retry: Retry(
    maxAttempts: 5,
    delay: Duration(milliseconds: 500),
    delayFactor: 1.5,
    maxDelay: Duration(seconds: 10),
    retryIf: (error) => error is NetworkException,
    onRetry: (attempt, error) {
      analytics.logRetry(attempt, error.toString());
    },
  ),
);

김개발 씨는 결제 기능을 구현하던 중 심각한 문제를 발견했습니다. 네트워크가 불안정할 때 결제 API가 재시도되면서 한 고객에게 같은 금액이 두 번 청구된 것입니다.

defaultRetry 설정이 모든 Provider에 적용되면서 생긴 문제였습니다. 멱등성이라는 개념을 이해해야 합니다.

멱등성이란 같은 요청을 여러 번 보내도 결과가 같은 성질입니다. 데이터 조회는 멱등합니다.

열 번 조회해도 같은 데이터가 돌아옵니다. 하지만 결제는 멱등하지 않습니다.

두 번 요청하면 두 번 결제됩니다. 이런 상황에서는 Retry.none을 사용합니다.

코드의 첫 번째 예제를 보면, paymentProvider에 retry: Retry.none을 지정했습니다. 이렇게 하면 전역 defaultRetry 설정과 관계없이 이 Provider는 절대 재시도하지 않습니다.

반대로 더 적극적인 재시도가 필요한 경우도 있습니다. 두 번째 예제의 criticalDataProvider는 앱 구동에 반드시 필요한 핵심 데이터를 불러옵니다.

이 데이터 없이는 앱이 동작하지 않으므로, 기본 설정보다 더 많이 재시도하도록 설정했습니다. maxAttempts: 5로 최대 5회까지 재시도합니다.

maxDelay는 지수 백오프로 대기 시간이 늘어나도 최대 10초를 넘지 않도록 제한합니다. 무한정 기다리게 하면 사용자가 앱을 종료해버리기 때문입니다.

새로 추가된 onRetry 콜백은 재시도가 발생할 때마다 호출됩니다. 여기서 재시도 횟수와 에러 정보를 분석 도구에 전송하면, 어떤 API가 자주 재시도되는지 모니터링할 수 있습니다.

실무에서 어떤 API에 재시도를 적용할지 판단하는 기준이 있습니다. GET 요청은 대부분 재시도해도 안전합니다.

POST, PUT, DELETE 요청은 서버 측에서 멱등성을 보장하는지 확인해야 합니다. 결제, 포인트 차감, 메시지 발송 같은 기능은 재시도를 비활성화하고, 실패 시 사용자에게 수동 재시도를 요청하는 것이 안전합니다.

또 한 가지 팁이 있습니다. autoDispose와 함께 사용할 때는 Provider가 dispose된 후 재시도가 발생하지 않도록 주의해야 합니다.

Riverpod 3.0에서는 이를 자동으로 처리해주지만, 커스텀 로직을 작성할 때는 ref.mounted 체크를 잊지 마세요. 김개발 씨는 결제 관련 Provider에 Retry.none을 적용하고, 고객 서비스팀에 사과 메일을 보냈습니다.

다행히 중복 결제된 건은 환불 처리되었고, 같은 실수는 다시 발생하지 않았습니다.

실전 팁

💡 - POST, PUT, DELETE 요청은 서버 API의 멱등성 여부를 확인 후 재시도 적용 여부를 결정하세요

  • onRetry 콜백으로 재시도 현황을 모니터링하면 네트워크 품질 이슈를 조기에 발견할 수 있습니다

5. 네트워크 에러 복구 패턴

앱 출시 후 김개발 씨는 통계를 보며 고민에 빠졌습니다. 지하철이나 엘리베이터처럼 네트워크가 불안정한 환경에서 앱 이탈률이 유독 높았습니다.

단순히 재시도하는 것만으로는 부족했습니다. 오프라인 상황도 고려한 종합적인 에러 복구 전략이 필요했습니다.

네트워크 에러 복구 패턴은 다양한 네트워크 상황에 대응하는 종합적인 전략입니다. 마치 비행기가 기상 악화 시 대체 공항으로 우회하거나 출발을 지연하는 것처럼, 네트워크 상태에 따라 캐시 활용, 오프라인 모드 전환, 연결 복구 대기 등 다양한 대응책을 조합합니다.

다음 코드를 살펴봅시다.

// 연결 상태 모니터링 Provider
final connectivityProvider = StreamProvider<ConnectivityResult>((ref) {
  return Connectivity().onConnectivityChanged;
});

// 네트워크 에러 복구 패턴 적용
final productListProvider = FutureProvider<List<Product>>((ref) async {
  final connectivity = ref.watch(connectivityProvider);
  final cache = ref.watch(cacheProvider);

  // 오프라인 상태면 캐시 반환
  if (connectivity.value == ConnectivityResult.none) {
    final cached = await cache.get('products');
    if (cached != null) return cached;
    throw OfflineException('오프라인 상태입니다');
  }

  try {
    final products = await api.fetchProducts();
    // 성공 시 캐시 저장
    await cache.set('products', products);
    return products;
  } catch (e) {
    // 네트워크 실패 시 캐시로 폴백
    final cached = await cache.get('products');
    if (cached != null) return cached;
    rethrow;
  }
});

어느 날 김개발 씨는 출퇴근 시간에 직접 앱을 사용해보았습니다. 지하철에서 터널을 지날 때마다 앱이 에러 화면을 보여주고, 터널을 빠져나와도 자동으로 복구되지 않았습니다.

사용자 입장에서 매우 불편한 경험이었습니다. 박시니어 씨와 함께 개선 방안을 논의했습니다.

"재시도만으로는 부족해요. 네트워크 상태를 실시간으로 감지하고, 상황에 맞게 대응해야 합니다." 네트워크 에러 복구 패턴의 핵심은 세 가지입니다.

첫째, 연결 상태 모니터링. 둘째, 캐시 활용.

셋째, 연결 복구 시 자동 갱신. 코드의 첫 번째 부분인 connectivityProvider를 보세요.

StreamProvider로 네트워크 연결 상태 변화를 실시간으로 구독합니다. Connectivity 패키지를 사용하면 WiFi, 모바일 데이터, 오프라인 상태를 구분할 수 있습니다.

productListProvider에서는 이 연결 상태를 활용합니다. 먼저 현재 연결 상태를 확인합니다.

오프라인이면 API 호출을 시도하지 않고 바로 캐시에서 데이터를 가져옵니다. 불필요한 네트워크 요청을 줄이고 빠른 응답을 제공할 수 있습니다.

온라인 상태에서 API 호출이 성공하면 결과를 캐시에 저장합니다. 이렇게 저장된 캐시는 나중에 오프라인 상황에서 사용됩니다.

뉴스 앱이나 쇼핑 앱에서 이전에 본 콘텐츠가 오프라인에서도 보이는 것이 바로 이 패턴입니다. API 호출이 실패하면 어떻게 할까요?

바로 에러를 던지는 대신 캐시를 확인합니다. 캐시에 데이터가 있으면 그것을 반환합니다.

데이터가 최신이 아닐 수 있지만, 에러 화면보다는 낫습니다. 사용자에게는 "오프라인 데이터입니다" 같은 안내를 함께 보여주면 됩니다.

캐시도 없는 최악의 상황에서만 에러를 던집니다. 이때 OfflineException이라는 명확한 예외를 사용하면 UI에서 "인터넷 연결을 확인해주세요" 같은 적절한 메시지를 보여줄 수 있습니다.

한 걸음 더 나아가 봅시다. connectivityProvider가 오프라인에서 온라인으로 바뀌면 자동으로 데이터를 갱신하고 싶습니다.

이를 위해 ref.listen을 사용합니다. dart ref.listen(connectivityProvider, (prev, next) { if (prev?.value == ConnectivityResult.none && next.value != ConnectivityResult.none) { ref.invalidateSelf(); // 연결 복구 시 자동 새로고침 } }); 이렇게 하면 터널을 빠져나오는 순간 자동으로 최신 데이터를 불러옵니다.

사용자가 새로고침 버튼을 누를 필요가 없습니다. 실무에서는 캐시 만료 정책도 중요합니다.

너무 오래된 캐시는 사용하지 않는 것이 좋습니다. 캐시 저장 시 타임스탬프를 함께 저장하고, 일정 시간이 지난 캐시는 무효화하세요.

김개발 씨가 이 패턴을 적용한 후 지하철 이용 시간대의 앱 이탈률이 40%나 감소했습니다. 사용자들은 네트워크가 끊겨도 앱을 계속 사용할 수 있었고, 연결이 복구되면 자동으로 최신 정보로 업데이트되었습니다.

실전 팁

💡 - 캐시 데이터 사용 시 "오프라인 데이터입니다" 같은 안내를 UI에 표시하세요

  • 금융 정보처럼 정확성이 중요한 데이터는 캐시 만료 시간을 짧게 설정하세요

6. 사용자 경험 최적화

기술적인 에러 복구는 완성했지만, 김개발 씨는 UX 팀으로부터 새로운 피드백을 받았습니다. "재시도 중인지, 성공했는지, 실패했는지 사용자가 직관적으로 알 수 있어야 해요.

그리고 수동으로 재시도할 수 있는 방법도 명확하게 제공해주세요."

사용자 경험 최적화는 에러 복구 과정에서 사용자에게 적절한 피드백을 제공하는 것입니다. 마치 비행기가 지연될 때 승객에게 지연 사유와 예상 대기 시간을 안내하는 것처럼, 현재 상태, 진행 상황, 해결 방법을 명확하게 전달해야 합니다.

기술적으로 완벽한 에러 처리도 사용자가 이해하지 못하면 의미가 없습니다.

다음 코드를 살펴봅시다.

// 에러 상태와 재시도 UI 통합 컴포넌트
class DataLoadingWidget extends ConsumerWidget {
  Widget build(BuildContext context, WidgetRef ref) {
    final dataAsync = ref.watch(dataProvider);

    return AnimatedSwitcher(
      duration: Duration(milliseconds: 300),
      child: _buildContent(dataAsync, ref),
    );
  }

  Widget _buildContent(AsyncValue<Data> state, WidgetRef ref) {
    // 재시도 중 상태
    if (state.isRetrying) {
      return RetryingIndicator(
        message: '다시 연결 중입니다...',
        attempt: state.retryCount,
        maxAttempts: 3,
      );
    }

    return state.when(
      data: (data) => DataView(data: data),
      loading: () => ShimmerLoading(),
      error: (error, _) => ErrorRecoveryView(
        message: _getErrorMessage(error),
        onRetry: () => ref.invalidate(dataProvider),
        onCancel: () => Navigator.pop(context),
      ),
    );
  }
}

김개발 씨는 UX 디자이너와 함께 에러 화면을 검토하고 있었습니다. "이 화면 보세요.

그냥 빨간 느낌표 하나에 '오류가 발생했습니다'라고만 나와요. 사용자가 뭘 해야 할지 모르잖아요." 좋은 에러 복구 시스템은 기술적으로 잘 작동하는 것만으로는 부족합니다.

사용자가 현재 상황을 이해하고, 적절한 행동을 취할 수 있도록 안내해야 합니다. AnimatedSwitcher를 사용한 이유를 먼저 설명하겠습니다.

상태가 바뀔 때 화면이 갑자기 바뀌면 사용자가 놀랍니다. 부드러운 전환 애니메이션을 추가하면 상태 변화를 자연스럽게 인지할 수 있습니다.

RetryingIndicator 컴포넌트는 재시도 중임을 시각적으로 보여줍니다. 단순히 "로딩 중"이 아니라 "다시 연결 중입니다"라고 표시합니다.

또한 retryCountmaxAttempts를 표시하여 "3회 중 2회째 시도 중"처럼 진행 상황을 알려줍니다. 왜 진행 상황이 중요할까요?

사람은 불확실한 상황에서 불안을 느낍니다. 언제 끝날지 모르는 기다림은 실제보다 더 길게 느껴집니다.

하지만 "3회 중 2회째"라는 정보가 있으면 곧 결과가 나올 것이라는 기대를 가질 수 있습니다. ErrorRecoveryView는 에러가 최종 확정되었을 때 보여주는 화면입니다.

여기서 중요한 것은 세 가지입니다. 첫째, 명확한 에러 메시지입니다.

"알 수 없는 오류"보다 "인터넷 연결이 불안정합니다" 또는 "서버 점검 중입니다"처럼 구체적인 메시지가 좋습니다. _getErrorMessage 함수에서 에러 타입별로 적절한 메시지를 반환하도록 구현합니다.

둘째, 수동 재시도 버튼입니다. 자동 재시도가 모두 실패한 후에도 사용자가 직접 다시 시도할 수 있어야 합니다.

ref.invalidate를 호출하면 Provider가 다시 실행됩니다. 셋째, 취소 또는 대안 제공입니다.

재시도해도 계속 실패하는 상황에서 사용자는 다른 작업을 하고 싶을 수 있습니다. 뒤로 가기 버튼이나 "나중에 다시 시도" 옵션을 제공하세요.

실무에서 자주 사용하는 패턴을 하나 더 소개합니다. 에러 발생 시 스낵바나 토스트 메시지를 띄우는 것입니다.

dart ref.listen(dataProvider, (prev, next) { next.whenOrNull( error: (error, _) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(_getErrorMessage(error)), action: SnackBarAction( label: '재시도', onPressed: () => ref.invalidate(dataProvider), ), ), ); }, ); }); 이렇게 하면 에러가 발생해도 현재 화면을 유지하면서 하단에 알림을 보여줍니다. 사용자는 이전에 보던 데이터를 계속 볼 수 있고, 원할 때 재시도할 수 있습니다.

마지막으로, ShimmerLoading도 중요합니다. 단순한 원형 로딩 인디케이터보다 콘텐츠의 뼈대를 보여주는 스켈레톤 UI가 체감 로딩 시간을 줄여줍니다.

김개발 씨는 이 모든 피드백을 반영한 후 사용자 만족도 설문을 실시했습니다. "에러가 나도 뭘 해야 할지 알겠어요"라는 긍정적인 피드백이 많았습니다.

기술적인 완성도와 사용자 경험, 두 마리 토끼를 모두 잡은 것입니다.

실전 팁

💡 - 에러 메시지는 기술 용어 대신 사용자가 이해할 수 있는 언어로 작성하세요

  • 재시도 진행 상황을 보여주면 사용자의 체감 대기 시간이 줄어듭니다
  • 자동 재시도 실패 후에도 수동 재시도 옵션은 항상 제공하세요

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

#Flutter#Riverpod#AsyncValue#ErrorHandling#RetryPattern#StateManagement#Flutter,State Management

댓글 (0)

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