Flutter Dio 인터셉터를 이용한 API 응답 캐싱

발행: (2026년 1월 8일 오후 03:02 GMT+9)
9 min read
원문: Dev.to

Source: Dev.to

(위 링크에 있는 글의 전체 내용을 제공해 주시면 한국어로 번역해 드리겠습니다.)

How the Caching Interceptor Works

FeatureDescription
Persist GET responsesGET 메서드 응답만 캐시됩니다. 이는 일반적으로 데이터 조회 작업을 나타내며 캐시해도 안전합니다.
Network‑first strategy네트워크 연결이 가능한 경우, 인터셉터가 API에서 최신 데이터를 가져옵니다.
Fallback to cache네트워크 오류가 발생하면 인터셉터가 자동으로 이전에 캐시된 응답으로 폴백합니다.
Automatic cache updates성공적인 응답은 향후 오프라인 접근을 위해 자동으로 캐시를 업데이트합니다.

1️⃣ Storage Abstraction Layer

스토리지 추상화를 만들면 인터셉터 로직을 건드리지 않고도 기본 스토리지 메커니즘(예: SharedPreferences, Hive, Secure Storage)을 교체할 수 있는 유연성을 얻을 수 있습니다.

/// Simple key‑value storage interface for caching
abstract class CacheStorage {
  Future get(String key);
  Future set(String key, String value);
  Future remove(String key);
}

/// Implementation using SharedPreferences (via AppPreference)
class CacheStorageImpl implements CacheStorage {
  CacheStorageImpl(this._pref);
  final AppPreference _pref;

  @override
  Future get(String key) async => _pref.getString(key);

  @override
  Future set(String key, String value) async =>
      await _pref.setString(key, value);

  @override
  Future remove(String key) async => await _pref.remove(key);
}

2️⃣ 캐싱 인터셉터 구현

import 'dart:convert';
import 'package:dio/dio.dart';

class CacheInterceptor implements Interceptor {
  final CacheStorage storage;

  CacheInterceptor(this.storage);

  // -------------------------------------------------
  // 1️⃣ onRequest – let the request pass through unchanged
  // -------------------------------------------------
  @override
  void onRequest(
      RequestOptions options, RequestInterceptorHandler handler) {
    handler.next(options);
  }

  // -------------------------------------------------
  // 2️⃣ onResponse – cache successful GET responses
  // -------------------------------------------------
  @override
  void onResponse(
      Response response, ResponseInterceptorHandler handler) {
    if (response.requestOptions.method.toUpperCase() == 'GET') {
      _saveResponseToCache(response);
    }
    handler.next(response);
  }

  // -------------------------------------------------
  // 3️⃣ onError – try to serve cached data on network errors
  // -------------------------------------------------
  @override
  void onError(DioException err, ErrorInterceptorHandler handler) async {
    // Attempt to retrieve cached data for GET requests
    if (err.requestOptions.method.toUpperCase() == 'GET') {
      final cached = await _getCachedResponse(err.requestOptions);
      if (cached != null) {
        // Return cached response instead of the error
        return handler.resolve(cached);
      }
    }

    // Show a “no internet” modal for network‑related errors
    if (_isNetworkError(err)) {
      await NoInternetModalWidget.show();
    }

    // Propagate the original error if no cache is available
    handler.next(err);
  }

  // -------------------------------------------------
  // Helper methods
  // -------------------------------------------------
  bool _isNetworkError(DioException err) {
    return err.type == DioExceptionType.connectionTimeout ||
        err.type == DioExceptionType.sendTimeout ||
        err.type == DioExceptionType.receiveTimeout ||
        err.type == DioExceptionType.connectionError;
  }

  Future _getCachedResponse(RequestOptions options) async {
    try {
      final cacheKey = '${options.uri}';
      final cacheEntry = await storage.get(cacheKey);
      if (cacheEntry == null) return null;

      final Map decodedCache = jsonDecode(cacheEntry);
      return Response(
        requestOptions: options,
        data: decodedCache,
        statusCode: 200,
      );
    } catch (_) {
      // Corrupted cache – ignore and let the error propagate
      return null;
    }
  }

  Future _saveResponseToCache(Response response) async {
    final cacheKey = '${response.realUri}';
    final cacheEntry = jsonEncode(response.data);
    await storage.set(cacheKey, cacheEntry);
  }
}

라이프사이클 메서드 요약

MethodWhat It Does
onRequest요청을 변경 없이 그대로 통과시킵니다.
onResponse요청이 GET인 경우 전체 URI를 키로 사용해 응답을 캐시에 저장합니다.
onError실패한 GET 요청에 대해 캐시된 응답을 가져오려고 시도합니다. 찾으면 캐시 데이터를 사용해 오류를 해결하고, 그렇지 않으면 (네트워크 오류인 경우) “no internet” 모달을 표시하고 오류를 전파합니다.

3️⃣ Dio와 인터셉터 사용하기

import 'package:dio/dio.dart';

class ApiClient {
  late final Dio dio;

  ApiClient(AppPreference appPreference) {
    dio = Dio(
      BaseOptions(
        baseUrl: 'https://api.example.com',
        connectTimeout: const Duration(seconds: 30),
        receiveTimeout: const Duration(seconds: 30),
      ),
    );

    // 1️⃣ Create the cache storage implementation
    final cacheStorage = CacheStorageImpl(appPreference);

    // 2️⃣ Add the cache interceptor
    dio.interceptors.add(CacheInterceptor(cacheStorage));

    // 3️⃣ (Optional) Add other interceptors such as logging, auth, etc.
    dio.interceptors.add(
      LogInterceptor(
        requestBody: true,
        responseBody: true,
      ),
    );
  }
}

이제 ApiClient를 통해 수행되는 모든 GET 요청은 다음을 수행합니다:

  1. 먼저 네트워크 시도 – 가능한 경우 최신 데이터를 가져옵니다.
  2. 응답 캐시 – 성공적인 결과를 오프라인 사용을 위해 저장합니다.
  3. 캐시로 대체 – 네트워크가 실패할 경우 자동으로 캐시된 데이터를 제공합니다.

🎉 정리

  • CacheInterceptor는 Dio 기반 Flutter 앱에 오프라인 지원을 손쉽게 추가할 수 있는 깔끔하고 재사용 가능한 방법을 제공합니다.
  • CacheStorage로 스토리지를 추상화함으로써, SharedPreferences에서 Hive, SQLite, 혹은 보안 스토리지로 단 한 줄의 코드만으로 전환할 수 있습니다.
  • 네트워크 우선 전략을 사용하면 사용자가 온라인일 때 항상 최신 정보를 확인할 수 있으며, 오프라인일 때도 기능적인 경험을 제공할 수 있습니다.

Dio 인터셉터를 이용한 투명 캐싱

class ProductRepository {
  final ApiClient apiClient;

  ProductRepository(this.apiClient);

  Future> getProducts() async {
    try {
      final response = await apiClient.dio.get('/products');
      return (response.data as List)
          .map((json) => Product.fromJson(json))
          .toList();
    } catch (e) {
      // Handle error or rethrow
      rethrow;
    }
  }
}

네트워크가 사용 가능한 경우, 최신 데이터를 가져와 캐시합니다.
오프라인일 때는 오류를 발생시키지 않고 캐시된 응답을 반환합니다.

🔄 투명 캐싱

기존 API 호출을 변경할 필요 없이 캐싱이 자동으로 작동합니다.

📴 오프라인 복원력

네트워크 장애 시 자동으로 캐시된 데이터로 대체됩니다.

🎯 클린 아키텍처

스토리지 추상화를 통한 관심사의 분리.

🔌 쉬운 통합

의존성 주입 패턴과 간단히 통합할 수 있습니다.

가능한 확장

캐시 신선도 판단을 위한 타임스탬프 추가

Future _saveResponseToCache(Response response) async {
  final cacheKey = '${response.realUri}';
  final cacheData = {
    'data': response.data,
    'timestamp': DateTime.now().millisecondsSinceEpoch,
  };
  final cacheEntry = jsonEncode(cacheData);
  await storage.set(cacheKey, cacheEntry);
}

LRU(Least Recently Used) 캐시 제거 구현

class CacheManager {
  final int maxCacheSize;
  final CacheStorage storage;

  Future evictOldestCache() async {
    // Implementation for removing oldest cached entries
  }
}

엔드포인트별 다른 캐싱 동작 구성

class CacheConfig {
  final Duration? ttl;
  final bool enabled;

  CacheConfig({this.ttl, this.enabled = true});
}

// Usage
final cacheConfig = {
  '/products': CacheConfig(ttl: Duration(hours: 1)),
  '/user/profile': CacheConfig(enabled: false),
};

Dio용 캐싱 인터셉터를 구현하면 Flutter 애플리케이션에 큰 이점을 제공합니다. 이 접근 방식은 오프라인‑우선 경험을 견고하게 제공하여 사용자가 인터넷 연결이 없어도 이전에 로드한 콘텐츠에 계속 접근할 수 있게 합니다.

스토리지‑추상화 패턴은 유연성과 유지보수성을 보장하고, 인터셉터는 비즈니스 로직을 어지럽히지 않으면서 캐시 관리의 복잡성을 매끄럽게 처리합니다. 네트워크‑우선 전략은 가능한 경우 최신 데이터를 보장하면서도 연결 문제 발생 시 기능을 유지합니다.

핵심 요점: 이 인터셉터는 네트워크 불안정성을 우아하게 처리하고 뛰어난 사용자 경험을 제공하는 탄탄한 Flutter 앱을 구축하기 위한 견고한 기반을 제공합니다. 위의 기본 구현을 시작점으로 삼고, 필요에 맞게 확장하세요.

이 글이 도움이 되었나요? Flutter 개발자 동료와 공유해 주세요!

행복한 코딩 되세요!

Back to Blog

관련 글

더 보기 »