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 'package:intl/intl.dart';
|
||||||
import '../models/subscription_model.dart';
|
import '../models/subscription_model.dart';
|
||||||
import 'exchange_rate_service.dart';
|
import 'exchange_rate_service.dart';
|
||||||
|
import 'cache_manager.dart';
|
||||||
|
|
||||||
/// 통화 단위 변환 및 포맷팅을 위한 유틸리티 클래스
|
/// 통화 단위 변환 및 포맷팅을 위한 유틸리티 클래스
|
||||||
class CurrencyUtil {
|
class CurrencyUtil {
|
||||||
static final ExchangeRateService _exchangeRateService = ExchangeRateService();
|
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) {
|
static String getDefaultCurrency(String locale) {
|
||||||
@@ -80,11 +87,19 @@ class CurrencyUtil {
|
|||||||
String currency,
|
String currency,
|
||||||
String locale,
|
String locale,
|
||||||
) async {
|
) 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);
|
final defaultCurrency = getDefaultCurrency(locale);
|
||||||
|
|
||||||
// 입력 통화가 기본 통화인 경우
|
// 입력 통화가 기본 통화인 경우
|
||||||
if (currency == defaultCurrency) {
|
if (currency == defaultCurrency) {
|
||||||
return _formatSingleCurrency(amount, currency);
|
final result = _formatSingleCurrency(amount, currency);
|
||||||
|
_fmtCache.set(key, result, size: result.length);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// USD 입력인 경우 - 기본 통화로 변환하여 표시
|
// USD 입력인 경우 - 기본 통화로 변환하여 표시
|
||||||
@@ -95,17 +110,23 @@ class CurrencyUtil {
|
|||||||
final primaryFormatted =
|
final primaryFormatted =
|
||||||
_formatSingleCurrency(convertedAmount, defaultCurrency);
|
_formatSingleCurrency(convertedAmount, defaultCurrency);
|
||||||
final usdFormatted = _formatSingleCurrency(amount, 'USD');
|
final usdFormatted = _formatSingleCurrency(amount, 'USD');
|
||||||
return '$primaryFormatted ($usdFormatted)';
|
final result = '$primaryFormatted ($usdFormatted)';
|
||||||
|
_fmtCache.set(key, result, size: result.length);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 영어 사용자가 KRW 선택한 경우
|
// 영어 사용자가 KRW 선택한 경우
|
||||||
if (locale == 'en' && currency == '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(
|
static Future<String> formatSubscriptionAmountWithLocale(
|
||||||
SubscriptionModel subscription, String locale) async {
|
SubscriptionModel subscription, String locale) async {
|
||||||
final price = subscription.currentPrice;
|
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 'dart:convert';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import '../utils/logger.dart';
|
import '../utils/logger.dart';
|
||||||
|
import 'cache_manager.dart';
|
||||||
|
|
||||||
/// 환율 정보 서비스 클래스
|
/// 환율 정보 서비스 클래스
|
||||||
class ExchangeRateService {
|
class ExchangeRateService {
|
||||||
@@ -16,6 +17,14 @@ class ExchangeRateService {
|
|||||||
// 내부 생성자
|
// 내부 생성자
|
||||||
ExchangeRateService._internal();
|
ExchangeRateService._internal();
|
||||||
|
|
||||||
|
// 포맷된 환율 문자열 캐시 (언어별)
|
||||||
|
static final SimpleCacheManager<String> _fmtCache =
|
||||||
|
SimpleCacheManager<String>(
|
||||||
|
maxEntries: 64,
|
||||||
|
maxBytes: 64 * 1024,
|
||||||
|
ttl: const Duration(minutes: 30),
|
||||||
|
);
|
||||||
|
|
||||||
// 캐싱된 환율 정보
|
// 캐싱된 환율 정보
|
||||||
double? _usdToKrwRate;
|
double? _usdToKrwRate;
|
||||||
double? _usdToJpyRate;
|
double? _usdToJpyRate;
|
||||||
@@ -62,6 +71,8 @@ class ExchangeRateService {
|
|||||||
_usdToJpyRate = (data['rates']['JPY'] as num?)?.toDouble();
|
_usdToJpyRate = (data['rates']['JPY'] as num?)?.toDouble();
|
||||||
_usdToCnyRate = (data['rates']['CNY'] as num?)?.toDouble();
|
_usdToCnyRate = (data['rates']['CNY'] as num?)?.toDouble();
|
||||||
_lastUpdated = DateTime.now();
|
_lastUpdated = DateTime.now();
|
||||||
|
// 환율 갱신 시 포맷 캐시 무효화
|
||||||
|
_fmtCache.clear();
|
||||||
Log.d(
|
Log.d(
|
||||||
'환율 갱신 완료: USD→KRW=$_usdToKrwRate, JPY=$_usdToJpyRate, CNY=$_usdToCnyRate');
|
'환율 갱신 완료: USD→KRW=$_usdToKrwRate, JPY=$_usdToJpyRate, CNY=$_usdToCnyRate');
|
||||||
return;
|
return;
|
||||||
@@ -177,32 +188,45 @@ class ExchangeRateService {
|
|||||||
/// 언어별 환율 정보를 포맷팅하여 반환합니다.
|
/// 언어별 환율 정보를 포맷팅하여 반환합니다.
|
||||||
Future<String> getFormattedExchangeRateInfoForLocale(String locale) async {
|
Future<String> getFormattedExchangeRateInfoForLocale(String locale) async {
|
||||||
await _fetchAllRatesIfNeeded();
|
await _fetchAllRatesIfNeeded();
|
||||||
|
// 캐시 키 (locale 기준)
|
||||||
|
final key = 'fx:fmt:$locale';
|
||||||
|
final cached = _fmtCache.get(key);
|
||||||
|
if (cached != null) return cached;
|
||||||
|
|
||||||
|
String result = '';
|
||||||
switch (locale) {
|
switch (locale) {
|
||||||
case 'ko':
|
case 'ko':
|
||||||
final rate = _usdToKrwRate ?? DEFAULT_USD_TO_KRW_RATE;
|
final rate = _usdToKrwRate ?? DEFAULT_USD_TO_KRW_RATE;
|
||||||
return NumberFormat.currency(
|
result = NumberFormat.currency(
|
||||||
locale: 'ko_KR',
|
locale: 'ko_KR',
|
||||||
symbol: '₩',
|
symbol: '₩',
|
||||||
decimalDigits: 0,
|
decimalDigits: 0,
|
||||||
).format(rate);
|
).format(rate);
|
||||||
|
break;
|
||||||
case 'ja':
|
case 'ja':
|
||||||
final rate = _usdToJpyRate ?? DEFAULT_USD_TO_JPY_RATE;
|
final rate = _usdToJpyRate ?? DEFAULT_USD_TO_JPY_RATE;
|
||||||
return NumberFormat.currency(
|
result = NumberFormat.currency(
|
||||||
locale: 'ja_JP',
|
locale: 'ja_JP',
|
||||||
symbol: '¥',
|
symbol: '¥',
|
||||||
decimalDigits: 0,
|
decimalDigits: 0,
|
||||||
).format(rate);
|
).format(rate);
|
||||||
|
break;
|
||||||
case 'zh':
|
case 'zh':
|
||||||
final rate = _usdToCnyRate ?? DEFAULT_USD_TO_CNY_RATE;
|
final rate = _usdToCnyRate ?? DEFAULT_USD_TO_CNY_RATE;
|
||||||
return NumberFormat.currency(
|
result = NumberFormat.currency(
|
||||||
locale: 'zh_CN',
|
locale: 'zh_CN',
|
||||||
symbol: '¥',
|
symbol: '¥',
|
||||||
decimalDigits: 2,
|
decimalDigits: 2,
|
||||||
).format(rate);
|
).format(rate);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
return '';
|
result = '';
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 대략적인 사이즈(문자 길이)로 캐시 저장
|
||||||
|
_fmtCache.set(key, result, size: result.length);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// USD 금액을 KRW로 변환하여 포맷팅된 문자열로 반환합니다.
|
/// USD 금액을 KRW로 변환하여 포맷팅된 문자열로 반환합니다.
|
||||||
|
|||||||
Reference in New Issue
Block a user