feat(cache): add SimpleCacheManager and cache formatted rates/amounts in exchange and currency services
This commit is contained in:
97
lib/services/cache_manager.dart
Normal file
97
lib/services/cache_manager.dart
Normal file
@@ -0,0 +1,97 @@
|
||||
class _CacheEntry<T> {
|
||||
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<T> {
|
||||
final int maxEntries;
|
||||
final int maxBytes;
|
||||
final Duration ttl;
|
||||
|
||||
final Map<String, _CacheEntry<T>> _store = <String, _CacheEntry<T>>{};
|
||||
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<String, num> 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<String> _fmtCache =
|
||||
SimpleCacheManager<String>(
|
||||
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<String> 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;
|
||||
}
|
||||
|
||||
/// 구독의 월 비용을 표시 형식에 맞게 변환 (원화 변환 포함, 이벤트 가격 반영) - 기존 호환성 유지
|
||||
|
||||
@@ -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<String> _fmtCache =
|
||||
SimpleCacheManager<String>(
|
||||
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<String> 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로 변환하여 포맷팅된 문자열로 반환합니다.
|
||||
|
||||
Reference in New Issue
Block a user