From b034f605100262c66d17c9c3c6b4384a2368f8fc Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Mon, 8 Sep 2025 14:31:44 +0900 Subject: [PATCH] feat(cache): add SimpleCacheManager and cache formatted rates/amounts in exchange and currency services --- lib/services/cache_manager.dart | 97 +++++++++++++++++++++++++ lib/services/currency_util.dart | 44 +++++++++-- lib/services/exchange_rate_service.dart | 32 +++++++- 3 files changed, 164 insertions(+), 9 deletions(-) create mode 100644 lib/services/cache_manager.dart diff --git a/lib/services/cache_manager.dart b/lib/services/cache_manager.dart new file mode 100644 index 0000000..373dea1 --- /dev/null +++ b/lib/services/cache_manager.dart @@ -0,0 +1,97 @@ +class _CacheEntry { + final T value; + final DateTime expiresAt; + final int size; + + _CacheEntry( + {required this.value, required this.expiresAt, required this.size}); + + bool get isExpired => DateTime.now().isAfter(expiresAt); +} + +/// 간단한 메모리 기반 캐시 매니저 (TTL/최대 개수/용량 제한) +class SimpleCacheManager { + final int maxEntries; + final int maxBytes; + final Duration ttl; + + final Map> _store = >{}; + int _currentBytes = 0; + + // 간단한 메트릭 + int _hits = 0; + int _misses = 0; + int _puts = 0; + int _evictions = 0; + + SimpleCacheManager({ + this.maxEntries = 128, + this.maxBytes = 1024 * 1024, // 1MB + this.ttl = const Duration(minutes: 30), + }); + + T? get(String key) { + final entry = _store.remove(key); + if (entry == null) return null; + if (entry.isExpired) { + _currentBytes -= entry.size; + _misses++; + return null; + } + // LRU 갱신: 재삽입으로 가장 최근으로 이동 + _store[key] = entry; + _hits++; + return entry.value; + } + + void set(String key, T value, {int size = 1, Duration? customTtl}) { + final expiresAt = DateTime.now().add(customTtl ?? ttl); + final existing = _store.remove(key); + if (existing != null) { + _currentBytes -= existing.size; + } + _store[key] = _CacheEntry(value: value, expiresAt: expiresAt, size: size); + _currentBytes += size; + _puts++; + _evictIfNeeded(); + } + + void invalidate(String key) { + final removed = _store.remove(key); + if (removed != null) { + _currentBytes -= removed.size; + } + } + + void clear() { + _store.clear(); + _currentBytes = 0; + } + + void _evictIfNeeded() { + // 개수/용량 제한을 넘으면 오래된 것부터 제거 + while (_store.length > maxEntries || _currentBytes > maxBytes) { + if (_store.isEmpty) break; + final firstKey = _store.keys.first; + final removed = _store.remove(firstKey); + if (removed != null) { + _currentBytes -= removed.size; + _evictions++; + } + } + } + + Map dumpMetrics() { + final totalGets = _hits + _misses; + final hitRate = totalGets == 0 ? 0 : _hits / totalGets; + return { + 'entries': _store.length, + 'bytes': _currentBytes, + 'hits': _hits, + 'misses': _misses, + 'hitRate': hitRate, + 'puts': _puts, + 'evictions': _evictions, + }; + } +} diff --git a/lib/services/currency_util.dart b/lib/services/currency_util.dart index 8a8bda5..5d08686 100644 --- a/lib/services/currency_util.dart +++ b/lib/services/currency_util.dart @@ -1,10 +1,17 @@ import 'package:intl/intl.dart'; import '../models/subscription_model.dart'; import 'exchange_rate_service.dart'; +import 'cache_manager.dart'; /// 통화 단위 변환 및 포맷팅을 위한 유틸리티 클래스 class CurrencyUtil { static final ExchangeRateService _exchangeRateService = ExchangeRateService(); + static final SimpleCacheManager _fmtCache = + SimpleCacheManager( + maxEntries: 256, + maxBytes: 256 * 1024, + ttl: const Duration(minutes: 15), + ); /// 언어에 따른 기본 통화 반환 static String getDefaultCurrency(String locale) { @@ -80,11 +87,19 @@ class CurrencyUtil { String currency, String locale, ) async { + // 캐시 조회 + final decimals = (currency == 'KRW' || currency == 'JPY') ? 0 : 2; + final key = 'fmt:$locale:$currency:${amount.toStringAsFixed(decimals)}'; + final cached = _fmtCache.get(key); + if (cached != null) return cached; + final defaultCurrency = getDefaultCurrency(locale); // 입력 통화가 기본 통화인 경우 if (currency == defaultCurrency) { - return _formatSingleCurrency(amount, currency); + final result = _formatSingleCurrency(amount, currency); + _fmtCache.set(key, result, size: result.length); + return result; } // USD 입력인 경우 - 기본 통화로 변환하여 표시 @@ -95,17 +110,23 @@ class CurrencyUtil { final primaryFormatted = _formatSingleCurrency(convertedAmount, defaultCurrency); final usdFormatted = _formatSingleCurrency(amount, 'USD'); - return '$primaryFormatted ($usdFormatted)'; + final result = '$primaryFormatted ($usdFormatted)'; + _fmtCache.set(key, result, size: result.length); + return result; } } // 영어 사용자가 KRW 선택한 경우 if (locale == 'en' && currency == 'KRW') { - return _formatSingleCurrency(amount, currency); + final result = _formatSingleCurrency(amount, currency); + _fmtCache.set(key, result, size: result.length); + return result; } // 기타 통화 입력인 경우 - return _formatSingleCurrency(amount, currency); + final result = _formatSingleCurrency(amount, currency); + _fmtCache.set(key, result, size: result.length); + return result; } /// 구독 목록의 총 월 비용을 계산 (언어별 기본 통화로) @@ -141,7 +162,20 @@ class CurrencyUtil { static Future formatSubscriptionAmountWithLocale( SubscriptionModel subscription, String locale) async { final price = subscription.currentPrice; - return formatAmountWithLocale(price, subscription.currency, locale); + // 구독 단위 캐시 키 (통화/가격/locale + id) + final decimals = + (subscription.currency == 'KRW' || subscription.currency == 'JPY') + ? 0 + : 2; + final key = + 'subfmt:$locale:${subscription.currency}:${price.toStringAsFixed(decimals)}:${subscription.id}'; + final cached = _fmtCache.get(key); + if (cached != null) return cached; + + final result = + await formatAmountWithLocale(price, subscription.currency, locale); + _fmtCache.set(key, result, size: result.length); + return result; } /// 구독의 월 비용을 표시 형식에 맞게 변환 (원화 변환 포함, 이벤트 가격 반영) - 기존 호환성 유지 diff --git a/lib/services/exchange_rate_service.dart b/lib/services/exchange_rate_service.dart index eb3e9bc..9f3a8e6 100644 --- a/lib/services/exchange_rate_service.dart +++ b/lib/services/exchange_rate_service.dart @@ -2,6 +2,7 @@ import 'package:http/http.dart' as http; import 'dart:convert'; import 'package:intl/intl.dart'; import '../utils/logger.dart'; +import 'cache_manager.dart'; /// 환율 정보 서비스 클래스 class ExchangeRateService { @@ -16,6 +17,14 @@ class ExchangeRateService { // 내부 생성자 ExchangeRateService._internal(); + // 포맷된 환율 문자열 캐시 (언어별) + static final SimpleCacheManager _fmtCache = + SimpleCacheManager( + maxEntries: 64, + maxBytes: 64 * 1024, + ttl: const Duration(minutes: 30), + ); + // 캐싱된 환율 정보 double? _usdToKrwRate; double? _usdToJpyRate; @@ -62,6 +71,8 @@ class ExchangeRateService { _usdToJpyRate = (data['rates']['JPY'] as num?)?.toDouble(); _usdToCnyRate = (data['rates']['CNY'] as num?)?.toDouble(); _lastUpdated = DateTime.now(); + // 환율 갱신 시 포맷 캐시 무효화 + _fmtCache.clear(); Log.d( '환율 갱신 완료: USD→KRW=$_usdToKrwRate, JPY=$_usdToJpyRate, CNY=$_usdToCnyRate'); return; @@ -177,32 +188,45 @@ class ExchangeRateService { /// 언어별 환율 정보를 포맷팅하여 반환합니다. Future getFormattedExchangeRateInfoForLocale(String locale) async { await _fetchAllRatesIfNeeded(); + // 캐시 키 (locale 기준) + final key = 'fx:fmt:$locale'; + final cached = _fmtCache.get(key); + if (cached != null) return cached; + String result = ''; switch (locale) { case 'ko': final rate = _usdToKrwRate ?? DEFAULT_USD_TO_KRW_RATE; - return NumberFormat.currency( + result = NumberFormat.currency( locale: 'ko_KR', symbol: '₩', decimalDigits: 0, ).format(rate); + break; case 'ja': final rate = _usdToJpyRate ?? DEFAULT_USD_TO_JPY_RATE; - return NumberFormat.currency( + result = NumberFormat.currency( locale: 'ja_JP', symbol: '¥', decimalDigits: 0, ).format(rate); + break; case 'zh': final rate = _usdToCnyRate ?? DEFAULT_USD_TO_CNY_RATE; - return NumberFormat.currency( + result = NumberFormat.currency( locale: 'zh_CN', symbol: '¥', decimalDigits: 2, ).format(rate); + break; default: - return ''; + result = ''; + break; } + + // 대략적인 사이즈(문자 길이)로 캐시 저장 + _fmtCache.set(key, result, size: result.length); + return result; } /// USD 금액을 KRW로 변환하여 포맷팅된 문자열로 반환합니다.