Riverpod을 사용하여 간단하고 효과적인 캐싱 시스템 구축하기

Yellowtoast·2024년 10월 27일
0

Flutter

목록 보기
6/6
post-thumbnail

Flutter 개발자라면 데이터 관리는 앱의 성능과 사용자 경험에 직접적인 영향을 미친다는 것을 잘 알고 있을 것입니다. 특히, 외부 API(OpenAPI)를 통해 데이터를 가져오는 경우 네트워크 지연이나 서버 상태에 따라 앱의 반응 속도가 달라질 수 있습니다.

이러한 상황에서 백엔드에게 기본적인 캐싱 기능을 부탁할 수 없는 경우, 앱 내부에서 간단한 캐싱 시스템을 구현하여 성능을 향상시키는 것이 중요합니다.

이번 글에서는 Riverpod을 사용하여 로컬 스토리지(Hive 등)를 사용하지 않고도 효과적인 캐싱 시스템을 구현하는 방법을 단계별로 알아보겠습니다.

특히, 캐싱 만료 시간을 지정하고, 다양한 형태의 데이터를 캐싱할 수 있는 범용 캐시 매니저를 만드는 방법을 소개합니다. 이를 통해 불필요한 API 호출을 줄이고, 앱의 반응 속도를 높이며, 사용자에게 보다 원활한 경험을 제공할 수 있습니다.

캐싱의 필요성

a. API 호출의 문제점

외부 API(OpenAPI)를 사용하여 데이터를 가져오는 경우, 다음과 같은 문제점이 발생할 수 있습니다.

  • 네트워크 지연 : 사용자의 네트워크 상태에 따라 API 호출 시간이 달라집니다.
  • 서버 응답 지연 : 백엔드 서버의 상태나 부하에 따라 응답 시간이 변동될 수 있습니다.
  • 비용 문제 : 빈번한 API 호출은 비용을 증가시킬 수 있습니다.
  • 데이터 일관성 : 실시간 데이터가 필요하지 않은 경우에도 불필요한 데이터 요청이 발생할 수 있습니다.

b. 내부 캐싱의 장점

이러한 문제점을 해결하기 위해 앱 내부에서 간단한 캐싱 시스템을 구현하는 것이 유용합니다. 내부 캐싱의 주요 장점은 다음과 같습니다.

  • 성능 향상 : 캐시된 데이터를 즉시 제공하여 앱의 반응 속도를 높입니다.
  • 네트워크 비용 절감 : 동일한 데이터에 대한 반복적인 API 호출을 줄여 네트워크 비용을 절감합니다.
  • 오프라인 지원 : 네트워크 연결이 불안정하거나 없는 상황에서도 캐시된 데이터를 제공할 수 있습니다.
  • 데이터 최신성 유지 : 캐싱 만료 시간을 설정하여 데이터의 최신성을 유지할 수 있습니다.

c. 로컬 스토리지 없이 캐싱하기

로컬 스토리지(Hive, SharedPreferences 등)를 사용하지 않고도 Riverpod만으로 캐싱 시스템을 구현할 수 있습니다. 이는 다음과 같은 장점을 제공합니다.

  • 간결성 : 추가적인 패키지 설치나 설정 없이 Riverpod만으로 캐싱을 관리할 수 있습니다.
  • 유연성 : 메모리 내에서 캐시를 관리하므로, 다양한 형태의 데이터를 손쉽게 캐싱할 수 있습니다.
  • 성능 : 메모리 기반 캐싱은 디스크 기반 캐싱보다 빠르게 데이터를 접근할 수 있습니다.

캐시 매니저 구현

효과적인 캐싱 시스템을 구축하기 위해서는 먼저 캐시 매니저(CacheManager) 를 구현해야 합니다. 이 매니저는 다양한 타입의 데이터를 키를 기반으로 저장하고, 캐시의 만료 시간을 관리합니다.

a. CacheItem 클래스

CacheItem 클래스는 캐시된 데이터를 나타내며, 데이터 자체와 함께 데이터를 저장한 시점을 기록합니다.

// cache_item.dart
class CacheItem<T> {
  final T data;
  final DateTime timestamp;

  CacheItem(this.data, this.timestamp);
}

b. CacheManager 클래스

CacheManager는 모든 캐시 항목을 관리하는 클래스입니다. 이 클래스는 데이터를 설정하고 가져오는 기능뿐만 아니라, 특정 키의 캐시를 무효화하거나 모든 캐시를 초기화하는 기능도 제공합니다.

// cache_manager.dart
import 'cache_item.dart';

class CacheManager {
  final Map<String, CacheItem<dynamic>> _cache = {};

  // 데이터 설정
  void setData<T>(String key, T data) {
    _cache[key] = CacheItem<T>(data, DateTime.now());
  }

  // 데이터 가져오기
  CacheItem<T>? getData<T>(String key) {
    final item = _cache[key];
    if (item is CacheItem<T>) {
      return item;
    }
    return null;
  }

  // 특정 키의 캐시 무효화
  void invalidate(String key) {
    _cache.remove(key);
  }

  // 모든 캐시 무효화
  void clear() {
    _cache.clear();
  }
}

기본 캐시 시스템의 문제점

a. 키 충돌(Key Collision)

단순히 키로만 데이터를 저장할 경우, 동일한 키 값에 서로 다른 타입의 데이터가 저장될 수 있습니다. 예를 들어, youtube_abc123이라는 키에 YouTube 데이터와 Content Detail 데이터를 모두 저장할 수 있습니다. 이는 타입 불일치 오류를 초래하거나 의도치 않은 데이터 접근을 가능하게 합니다.

b. 요청 파라미터 다양성

API 요청 시 사용되는 파라미터들이 다양할 경우, 동일한 엔드포인트라도 다른 파라미터 조합으로 인해 서로 다른 데이터를 가져올 수 있습니다. 단순한 키로는 이러한 다양한 요청을 구분하기 어렵습니다.

개선된 캐시 매니저 구현

위의 문제점을 해결하기 위해, 고유한 캐시 키 생성과 타입 안전성을 확보하는 개선된 CacheManager를 구현합니다.

a. 고유한 캐시 키 생성

캐시 키는 요청의 모든 파라미터를 포함하여 고유하게 생성되어야 합니다. 이를 위해 다음과 같은 방식을 사용할 수 있습니다:

  • 컴포지트 키(Composite Key): 여러 파라미터를 조합하여 하나의 고유한 키를 생성합니다.
  • 해싱(Hashing): 요청 파라미터를 해싱하여 고유한 키를 생성합니다.
    컴포지트 키 생성
    요청의 모든 파라미터를 문자열로 조합하여 고유한 키를 생성합니다.

컴포지트 키 생성

요청의 모든 파라미터를 문자열로 조합하여 고유한 키를 생성합니다.

String generateCacheKey(String prefix, Map<String, dynamic> params) {
  final sortedKeys = params.keys.toList()..sort();
  final paramString = sortedKeys.map((key) => '$key=${params[key]}').join('&');
  return '$prefix?$paramString';
}

해싱을 통한 키 생성

요청 파라미터를 해싱하여 고유한 키를 생성합니다. 이를 위해 crypto 패키지를 사용할 수 있습니다.

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

String generateHashedCacheKey(String prefix, Map<String, dynamic> params) {
  final sortedKeys = params.keys.toList()..sort();
  final paramString = sortedKeys.map((key) => '$key=${params[key]}').join('&');
  final bytes = utf8.encode(paramString);
  final digest = sha256.convert(bytes);
  return '$prefix?hash=${digest.toString()}';
}

해싱 방식은 파라미터가 많거나 길 때 유용하며, 키의 길이를 일정하게 유지할 수 있는 장점이 있습니다.

b. 타입 안전성 확보

캐시 시스템에서 데이터 타입의 안전성을 확보하려면 다음과 같은 방법을 사용할 수 있습니다.

  • 타입 정보를 키에 포함 : 데이터의 타입 정보를 캐시 키에 포함하여 동일한 키로 다른 타입의 데이터를 저장하지 않도록 합니다.
  • 제네릭 캐시 관리 : CacheManager가 제네릭 타입을 지원하여 타입에 따라 별도의 캐시 공간을 할당합니다.

타입 정보를 키에 포함

String generateTypedCacheKey(String prefix, Map<String, dynamic> params, Type type) {
  final sortedKeys = params.keys.toList()..sort();
  final paramString = sortedKeys.map((key) => '$key=${params[key]}').join('&');
  final bytes = utf8.encode(paramString);
  final digest = sha256.convert(bytes);
  return '$prefix?hash=${digest.toString()}&type=${type.toString()}';
}

제네릭 CacheManager

CacheManager를 제네릭으로 설계하여 타입 안전성을 보장합니다.

// cache_manager.dart
import 'cache_item.dart';

class CacheManager {
  final Map<String, CacheItem<dynamic>> _cache = {};

  // 데이터 설정
  void setData<T>(String key, T data) {
    _cache[key] = CacheItem<T>(data, DateTime.now());
  }

  // 데이터 가져오기
  CacheItem<T>? getData<T>(String key) {
    final item = _cache[key];
    if (item is CacheItem<T>) {
      return item;
    }
    return null;
  }

  // 특정 키의 캐시 무효화
  void invalidate(String key) {
    _cache.remove(key);
  }

  // 모든 캐시 무효화
  void clear() {
    _cache.clear();
  }
}

Riverpod Provider 설정

개선된 CacheManager와 API 서비스를 Riverpod을 통해 Provider로 설정합니다.

a. CacheManager Provider

CacheManager 인스턴스를 Riverpod의 Provider로 등록하여 애플리케이션 전체에서 접근할 수 있도록 합니다.

// providers.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'cache_manager.dart';

// CacheManager Provider
final cacheManagerProvider = Provider<CacheManager>((ref) => CacheManager());

b. API 서비스 Provider

API 호출을 담당할 ApiService 클래스를 정의하고, 이를 Provider로 등록합니다.

// api_service.dart
import 'dart:async';

class ApiService {
  // YouTube 데이터 가져오기 시뮬레이션
  Future<String> fetchYouTubeData(String videoId) async {
    await Future.delayed(Duration(seconds: 2)); // 네트워크 지연 시뮬레이션
    return "YouTube 데이터 for $videoId";
  }

  // 콘텐츠 상세 데이터 가져오기 시뮬레이션
  Future<String> fetchContentDetail(String contentId) async {
    await Future.delayed(Duration(seconds: 3)); // 네트워크 지연 시뮬레이션
    return "Content Detail for $contentId";
  }
}
// providers.dart (계속)
import 'api_service.dart';

// ApiService Provider
final apiServiceProvider = Provider<ApiService>((ref) => ApiService());

캐싱 로직 구현

개선된 캐시 매니저를 활용하여 캐싱 로직을 구현합니다. 이는 요청마다 고유한 캐시 키를 생성하고, 타입 안전성을 확보하며, 캐시 만료 시간을 설정하는 과정을 포함합니다.

a. 캐싱 도우미 함수

fetchWithCache 함수는 지정된 키에 대한 캐시를 확인하고, 캐시가 유효하면 캐시된 데이터를 반환합니다. 그렇지 않으면 데이터를 가져와 캐시에 저장한 후 반환합니다.

// cache_helper.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'cache_manager.dart';
import 'package:crypto/crypto.dart';
import 'dart:convert';

/// 컴포지트 키 생성 함수 (해싱 및 타입 포함)
String generateTypedCacheKey(String prefix, Map<String, dynamic> params, Type type) {
  final sortedKeys = params.keys.toList()..sort();
  final paramString = sortedKeys.map((key) => '$key=${params[key]}').join('&');
  final bytes = utf8.encode(paramString);
  final digest = sha256.convert(bytes);
  return '$prefix?hash=${digest.toString()}&type=${type.toString()}';
}

/// 캐싱 도우미 함수
Future<T> fetchWithCache<T>({
  required WidgetRef ref,
  required String prefix,
  required Map<String, dynamic> params,
  required Future<T> Function() fetchFunction,
  required Duration expiration,
}) async {
  final cacheManager = ref.read(cacheManagerProvider);
  final key = generateTypedCacheKey(prefix, params, T);
  final cachedItem = cacheManager.getData<T>(key);

  if (cachedItem != null) {
    // 캐시 만료 시간 확인
    if (DateTime.now().difference(cachedItem.timestamp) < expiration) {
      print('캐시에서 데이터 로드: $key');
      return cachedItem.data;
    } else {
      // 캐시 무효화
      cacheManager.invalidate(key);
      print('캐시 만료: $key');
    }
  }

  // 데이터 가져오기
  final data = await fetchFunction();
  // 캐시에 저장
  cacheManager.setData<T>(key, data);
  print('캐시에 데이터 저장: $key');
  return data;
}

b. 데이터 Provider 정의

FutureProvider.family을 사용하여 특정 키와 데이터를 기반으로 Provider를 정의합니다. 각 API 요청은 고유한 파라미터를 포함하여 고유한 캐시 키를 생성합니다.

// providers.dart (계속)
import 'cache_helper.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

// YouTube 데이터 Provider
final youtubeProvider = FutureProvider.family<String, String>((ref, videoId) async {
  final prefix = 'youtube';
  final params = {'videoId': videoId};
  final expiration = Duration(minutes: 10);
  final apiService = ref.read(apiServiceProvider);

  return await fetchWithCache<String>(
    ref: ref,
    prefix: prefix,
    params: params,
    fetchFunction: () => apiService.fetchYouTubeData(videoId),
    expiration: expiration,
  );
});

// 콘텐츠 상세 데이터 Provider
final contentProvider = FutureProvider.family<String, String>((ref, contentId) async {
  final prefix = 'content';
  final params = {'contentId': contentId};
  final expiration = Duration(minutes: 10);
  final apiService = ref.read(apiServiceProvider);

  return await fetchWithCache<String>(
    ref: ref,
    prefix: prefix,
    params: params,
    fetchFunction: () => apiService.fetchContentDetail(contentId),
    expiration: expiration,
  );
});

캐시 무효화 및 관리

캐싱된 데이터를 관리하고, 필요에 따라 캐시를 무효화하거나 데이터를 새로 고침할 수 있습니다.

a. 캐시 무효화

특정 키의 캐시를 무효화하고 데이터를 다시 가져옵니다. 이는 데이터가 업데이트되었거나, 특정 조건에서 데이터를 새로고침해야 할 때 유용합니다.

// 예시: 캐시 무효화 버튼 추가

ElevatedButton(
  onPressed: () {
    ref.invalidate(youtubeProvider(videoId));
  },
  child: Text('YouTube 캐시 무효화'),
),

b. 전체 캐시 무효화

모든 캐시를 무효화하고 싶을 때는 CacheManagerclear 메서드를 호출합니다. 이는 앱의 전반적인 데이터를 새로고침해야 할 때 유용합니다.

// 전체 캐시 무효화 버튼 추가

ElevatedButton(
  onPressed: () {
    ref.read(cacheManagerProvider).clear();
  },
  child: Text('전체 캐시 무효화'),
),

Riverpod 기반 캐싱과 로컬 스토리지 캐싱 비교

캐싱 시스템을 구현할 때, Riverpod을 사용한 메모리 기반 캐싱로컬 스토리지를 사용한 캐싱 두 가지 접근 방식을 고려할 수 있습니다. 각각의 방법은 장단점이 있으며, 프로젝트의 요구사항에 따라 적합한 방식을 선택하는 것이 중요합니다.

a. Riverpod을 사용한 메모리 기반 캐싱

장점

간결성 및 간편함

추가적인 패키지나 설정 없이, Riverpod의 Provider만으로 캐싱을 구현할 수 있습니다. 이는 프로젝트의 복잡성을 줄이고 빠른 개발을 가능하게 합니다.

빠른 접근 속도

메모리 내에 캐시를 저장하므로 데이터 접근 속도가 매우 빠릅니다. 디스크 기반 캐싱에 비해 읽기/쓰기 속도가 우수합니다.

타입 안전성

제네릭을 활용하여 데이터 타입의 안전성을 보장할 수 있습니다. 이는 코드의 안정성과 유지보수성을 높여줍니다.

유연성

다양한 형태의 데이터를 캐싱할 수 있으며, 각 데이터마다 다른 만료 시간을 설정할 수 있습니다. 또한, 캐시 무효화나 갱신 로직을 자유롭게 설계할 수 있습니다.

단점

앱 재시작 시 데이터 손실

메모리 내에 캐시를 저장하기 때문에, 앱이 종료되거나 재시작되면 캐시된 데이터가 모두 사라집니다. 이는 지속적인 데이터 저장이 필요한 경우 문제가 될 수 있습니다.

메모리 사용량

모든 캐시 데이터를 메모리에 저장하므로, 대량의 데이터를 캐싱할 경우 메모리 사용량이 증가할 수 있습니다. 이는 특히 저사양 기기에서 앱의 성능 저하를 초래할 수 있습니다.

b. 로컬 스토리지를 사용한 캐싱

장점

데이터 지속성

로컬 스토리지(Hive, SharedPreferences 등)에 데이터를 저장하면 앱이 종료되거나 재시작되더라도 캐시된 데이터가 유지됩니다. 이는 사용자 경험을 향상시키는 데 큰 도움이 됩니다.

큰 데이터 저장 가능

로컬 스토리지는 메모리보다 더 많은 데이터를 저장할 수 있습니다. 이는 대용량 데이터를 캐싱할 때 유용합니다.

데이터 일관성 유지

앱이 다시 시작되었을 때도 캐시 데이터를 유지할 수 있어, 데이터 일관성을 유지하기 용이합니다. 또한, 데이터 동기화 로직을 추가하여 서버와의 일관성을 유지할 수 있습니다.

메모리 부담 감소

데이터를 디스크에 저장함으로써 메모리 사용량을 줄일 수 있습니다. 이는 특히 저사양 기기에서 앱의 안정성을 높여줍니다.

단점

설정 및 관리의 복잡성

로컬 스토리지를 사용하려면 추가적인 패키지를 설치하고, 데이터 직렬화/역직렬화 로직을 구현해야 합니다. 이는 개발 초기 설정을 복잡하게 만들 수 있습니다.

속도 문제

디스크 기반 저장소는 메모리 기반 저장소보다 읽기/쓰기 속도가 느립니다. 이는 특히 대용량 데이터를 자주 접근할 경우 성능 저하를 초래할 수 있습니다.

추가적인 오류 처리 필요

디스크 접근 중 발생할 수 있는 오류(예: 읽기/쓰기 실패)에 대한 처리가 필요합니다. 이는 코드의 복잡성을 증가시킬 수 있습니다.

결론

Flutter 애플리케이션에서 캐싱 시스템을 구현할 때, Riverpod을 사용한 메모리 기반 캐싱과 로컬 스토리지를 사용한 캐싱은 각각의 장단점이 있습니다. 프로젝트의 요구사항과 상황에 따라 적합한 방법을 선택하는 것이 중요합니다.

  • 작은 데이터 세트 및 빠른 접근이 필요한 경우 : Riverpod을 사용한 메모리 기반 캐싱이 적합합니다.
  • 데이터의 지속성과 대용량 데이터가 필요한 경우 : 로컬 스토리지 기반 캐싱이 더 적합합니다.
  • 혼합 접근 방식 : 두 가지 방식을 혼합하여 사용하는 것도 좋은 전략입니다. 예를 들어, 자주 접근하는 작은 데이터는 메모리 기반 캐시에 저장하고, 덜 자주 접근하거나 대용량 데이터는 로컬 스토리지에 저장하는 방식입니다.
profile
Flutter App Developer

0개의 댓글