캐싱이 해결하는 실제 문제
모바일 앱을 사용하다 보면 이런 경험이 있을 겁니다. 방금 본 화면으로 다시 돌아갔는데 로딩 스피너가 또 돌아갑니다. 분명 1초 전에 본 데이터인데 왜 다시 가져와야 할까요?
여기서 캐싱이 등장합니다. 캐싱은 한 번 가져온 데이터를 임시로 저장해두고 재사용하는 기법입니다. 마치 우리가 자주 쓰는 물건을 책상 위에 올려놓는 것처럼, 앱도 자주 쓰는 데이터를 '가까운 곳'에 보관합니다.
여기서 캐싱이 등장합니다. 캐싱은 한 번 가져온 데이터를 임시로 저장해두고 재사용하는 기법입니다. 마치 우리가 자주 쓰는 물건을 책상 위에 올려놓는 것처럼, 앱도 자주 쓰는 데이터를 '가까운 곳'에 보관합니다.
캐싱이 없을 때의 문제점
주식 거래 앱을 예로 들어보겠습니다. 사용자가 삼성전자 주가를 확인하는 시나리오를 생각해보죠.
Dart
// 캐싱 없는 Repository
class NoCacheRepository implements QuoteRepository {
final ApiClient _api;
@override
Future<Quote?> getLatestQuote(String symbol) async {
// 매번 네트워크 요청
return await _api.get('/quotes/$symbol');
}
}
// 사용자 시나리오: 종목 상세 화면 진입
// 1. 화면 진입 시 호출 (200ms)
// 2. Pull to refresh (200ms)
// 3. 다른 탭 갔다가 복귀 (200ms)
// 4. 화면 회전 (200ms)
// 총 800ms의 대기 시간 + 4번의 API 호출 비용
실제 사용 코드:
Dart
final quote1 = await repo.getLatestQuote('AAPL'); // API 호출 (200ms)
final quote2 = await repo.getLatestQuote('AAPL'); // 또 API 호출 (200ms)
final quote3 = await repo.getLatestQuote('AAPL'); // 또 API 호출 (200ms)
// 같은 데이터인데 600ms 소요!
모바일 환경의 특수성도 고려해야 합니다:
• 네트워크 비용: 데이터 요금제를 쓰는 사용자
• 배터리 소모: 네트워크 통신은 배터리를 많이 소모
• 불안정한 연결: 지하철, 엘리베이터 등에서의 연결 끊김
• 서버 부하: 불필요한 요청으로 서버 비용 증가
• 네트워크 비용: 데이터 요금제를 쓰는 사용자
• 배터리 소모: 네트워크 통신은 배터리를 많이 소모
• 불안정한 연결: 지하철, 엘리베이터 등에서의 연결 끊김
• 서버 부하: 불필요한 요청으로 서버 비용 증가
캐싱의 작동 원리
캐싱의 기본 아이디어는 간단합니다. "이미 가져온 데이터가 있으면 그걸 쓰고, 없으면 가져오자"입니다.
기본 구현
Dart
class BasicCachedRepository implements QuoteRepository {
final QuoteRepository _remote;
final Map<String, Quote> _cache = {};
BasicCachedRepository(this._remote);
@override
Future<Quote?> getLatestQuote(String symbol) async {
// 캐시 확인
if (_cache.containsKey(symbol)) {
return _cache[symbol];
}
// 원격 조회
final quote = await _remote.getLatestQuote(symbol);
// 캐시 저장
if (quote != null) {
_cache[symbol] = quote;
}
return quote;
}
}
하지만 이 간단한 구현에는 문제가 있습니다. 한 번 캐시되면 영원히 유지됩니다. 주가가 변해도 계속 옛날 가격을 보여주겠죠?
TTL(Time To Live): 캐시의 유효 기간
모든 데이터가 같은 수명을 가질 수는 없습니다. 주가는 초 단위로 변하지만, 사용자 프로필은 거의 변하지 않죠.
TTL은 캐시된 데이터가 "신선한" 상태로 간주되는 시간입니다. 마치 우유의 유통기한처럼, 각 데이터는 자신만의 유효 기간을 가집니다.
TTL은 캐시된 데이터가 "신선한" 상태로 간주되는 시간입니다. 마치 우유의 유통기한처럼, 각 데이터는 자신만의 유효 기간을 가집니다.
TTL 구현
Dart
class CacheEntry<T> {
final T data;
final DateTime timestamp;
final Duration ttl;
CacheEntry({
required this.data,
required this.timestamp,
this.ttl = const Duration(minutes: 5),
});
bool get isExpired {
final age = DateTime.now().difference(timestamp);
return age > ttl;
}
bool get isValid => !isExpired;
}
class TtlCachedRepository implements QuoteRepository {
final QuoteRepository _remote;
final Map<String, CacheEntry<Quote>> _cache = {};
TtlCachedRepository(this._remote);
@override
Future<Quote?> getLatestQuote(String symbol) async {
// 캐시 확인 및 유효성 검증
final cached = _cache[symbol];
if (cached != null && cached.isValid) {
print('캐시 히트: $symbol');
return cached.data;
}
// 만료됐거나 없으면 새로 조회
print('캐시 미스: $symbol - API 호출');
final quote = await _remote.getLatestQuote(symbol);
if (quote != null) {
_cache[symbol] = CacheEntry(
data: quote,
timestamp: DateTime.now(),
ttl: Duration(seconds: 30), // 시세는 30초만 유효
);
}
return quote;
}
}
실제 적용 시 데이터 특성별 TTL 전략:
Dart
class AdaptiveCacheRepository {
final Repository _remote;
final Map<String, CacheEntry<dynamic>> _cache = {};
// 데이터 타입별 다른 TTL
static const Map<Type, Duration> _ttlPolicy = {
Quote: Duration(seconds: 10), // 시세: 자주 변함
Account: Duration(minutes: 5), // 계좌: 가끔 변함
UserProfile: Duration(hours: 24), // 프로필: 거의 안 변함
StaticConfig: Duration(days: 7), // 설정: 매우 드물게 변함
};
Future<T> _getWithCache<T>({
required String key,
required Future<T> Function() fetcher,
Duration? customTtl,
}) async {
final ttl = customTtl ?? _ttlPolicy[T] ?? Duration(minutes: 1);
final cached = _cache[key];
if (cached != null && cached.isValidFor(ttl)) {
return cached.data as T;
}
final fresh = await fetcher();
_cache[key] = CacheEntry(
data: fresh,
timestamp: DateTime.now(),
ttl: ttl,
);
return fresh;
}
}
캐시 메모리 관리: LRU 전략
스마트폰의 메모리는 제한적입니다. 캐시가 계속 커지면 앱이 느려지거나 강제 종료될 수 있습니다.
LRU(Least Recently Used)는 "가장 오래 사용하지 않은 것부터 삭제"하는 전략입니다. 도서관의 책장처럼, 공간이 부족하면 가장 오래 아무도 빌리지 않은 책부터 창고로 보내는 것과 같습니다.
LRU(Least Recently Used)는 "가장 오래 사용하지 않은 것부터 삭제"하는 전략입니다. 도서관의 책장처럼, 공간이 부족하면 가장 오래 아무도 빌리지 않은 책부터 창고로 보내는 것과 같습니다.
LRU 캐시 구현
Dart
class LruCache<K, V> {
final int maxSize;
final LinkedHashMap<K, V> _map = LinkedHashMap();
LruCache({this.maxSize = 100});
V? get(K key) {
if (!_map.containsKey(key)) return null;
// 최근 사용 항목을 맨 뒤로 이동
final value = _map.remove(key)!;
_map[key] = value;
return value;
}
void put(K key, V value) {
// 기존 항목 제거
_map.remove(key);
// 크기 제한 확인
if (_map.length >= maxSize) {
// 가장 오래된 항목(맨 앞) 제거
print('LRU 제거: ${_map.keys.first}');
_map.remove(_map.keys.first);
}
// 새 항목을 맨 뒤에 추가
_map[key] = value;
}
void clear() => _map.clear();
int get size => _map.length;
}
LRU 캐시를 사용한 Repository:
Dart
class LruCachedRepository implements QuoteRepository {
final QuoteRepository _remote;
final LruCache<String, CacheEntry<Quote>> _cache;
LruCachedRepository(
this._remote, {
int maxCacheSize = 50,
}) : _cache = LruCache(maxSize: maxCacheSize);
@override
Future<Quote?> getLatestQuote(String symbol) async {
final cached = _cache.get(symbol);
if (cached != null && cached.isValid) {
return cached.data;
}
final quote = await _remote.getLatestQuote(symbol);
if (quote != null) {
_cache.put(
symbol,
CacheEntry(
data: quote,
timestamp: DateTime.now(),
ttl: Duration(seconds: 30),
),
);
}
return quote;
}
}
작동 방식:
1. 캐시 최대 크기를 정함 (예: 100개 항목)
2. 새 데이터를 추가할 때 크기 확인
3. 꽉 찼으면 가장 오래 사용 안 한 것 삭제
4. 새 데이터 추가
1. 캐시 최대 크기를 정함 (예: 100개 항목)
2. 새 데이터를 추가할 때 크기 확인
3. 꽉 찼으면 가장 오래 사용 안 한 것 삭제
4. 새 데이터 추가
캐시 무효화: 언제 캐시를 버려야 하나
캐시 무효화는 컴퓨터 과학의 난제 중 하나입니다. Phil Karlton의 유명한 말이 있죠: "컴퓨터 과학에서 어려운 것은 딱 두 가지다. 캐시 무효화와 이름 짓기."
시간 기반 무효화
가장 단순한 방법입니다. TTL이 지나면 자동으로 무효화됩니다.
이벤트 기반 무효화
Dart
class EventDrivenCache implements QuoteRepository {
final Map<String, CacheEntry<Quote>> _cache = {};
final EventBus _eventBus;
EventDrivenCache(this._eventBus) {
_subscribeToEvents();
}
void _subscribeToEvents() {
// 주문 체결 시 해당 종목 캐시 무효화
_eventBus.on<OrderFilledEvent>().listen((event) {
print('주문 체결: ${event.symbol} 캐시 무효화');
_cache.remove(event.symbol);
});
// 앱이 포그라운드로 돌아올 때 만료된 것만 정리
_eventBus.on<AppResumedEvent>().listen((_) {
_invalidateExpired();
});
// 네트워크 재연결 시 전체 갱신
_eventBus.on<NetworkReconnectedEvent>().listen((_) {
print('네트워크 재연결: 전체 캐시 초기화');
_cache.clear();
});
}
void _invalidateExpired() {
final before = _cache.length;
_cache.removeWhere((_, entry) => entry.isExpired);
final after = _cache.length;
print('만료 캐시 정리: $before -> $after');
}
}
수동 무효화
Dart
class ManualInvalidationCache implements QuoteRepository {
final Map<String, CacheEntry<Quote>> _cache = {};
// 특정 항목 무효화
void invalidate(String symbol) {
_cache.remove(symbol);
}
// 전체 캐시 초기화
void invalidateAll() {
_cache.clear();
}
// 조건부 무효화
void invalidateWhere(bool Function(String key, Quote value) test) {
_cache.removeWhere((key, entry) => test(key, entry.data));
}
// Pull-to-refresh 구현 예시
Future<Quote?> getLatestQuote(String symbol, {bool forceRefresh = false}) async {
if (!forceRefresh) {
final cached = _cache[symbol];
if (cached != null && cached.isValid) {
return cached.data;
}
}
// forceRefresh가 true면 캐시 무시하고 새로 가져옴
return _fetchAndCache(symbol);
}
}
계층적 캐싱 전략
여러 레벨의 캐시를 조합하면 더 효율적인 시스템을 만들 수 있습니다.
Dart
class SmartCacheRepository implements QuoteRepository {
final QuoteRepository _remote;
final LruCache<String, CacheEntry<Quote>> _memoryCache;
final LocalStorage _localStorage;
SmartCacheRepository(
this._remote,
this._localStorage,
) : _memoryCache = LruCache(maxSize: 100);
@override
Future<Quote?> getLatestQuote(String symbol) async {
// L1: 메모리 캐시 (5초)
final memoryCached = _memoryCache.get(symbol);
if (memoryCached != null &&
memoryCached.age < Duration(seconds: 5)) {
print('L1 캐시 히트: $symbol');
return memoryCached.data;
}
// L2: 로컬 스토리지 (1분)
final localCached = await _localStorage.getQuote(symbol);
if (localCached != null) {
final age = DateTime.now().difference(localCached.timestamp);
if (age < Duration(minutes: 1)) {
print('L2 캐시 히트: $symbol');
// 메모리 캐시도 갱신
_memoryCache.put(symbol, CacheEntry(
data: localCached,
timestamp: DateTime.now(),
));
return localCached;
}
}
// L3: 원격 조회
print('캐시 미스: API 호출 - $symbol');
final quote = await _remote.getLatestQuote(symbol);
if (quote != null) {
// 모든 레벨 갱신
await _updateAllCaches(symbol, quote);
}
return quote;
}
Future<void> _updateAllCaches(String symbol, Quote quote) async {
// 메모리 캐시
_memoryCache.put(symbol, CacheEntry(
data: quote,
timestamp: DateTime.now(),
));
// 로컬 스토리지
await _localStorage.saveQuote(symbol, quote);
}
}
Repository 패턴과 캐시의 조합
Repository 패턴과 캐시를 함께 사용하면 매우 우아한 구조가 됩니다. Repository는 데이터 접근을 추상화하고, 캐시는 그 안에서 조용히 작동합니다.
Dart
// 인터페이스는 그대로
abstract class QuoteRepository {
Future<Quote?> getLatestQuote(String symbol);
}
// 캐시 데코레이터 패턴
class CachedQuoteRepository implements QuoteRepository {
final QuoteRepository _remote;
final Map<String, CacheEntry<Quote>> _cache = {};
CachedQuoteRepository(this._remote);
@override
Future<Quote?> getLatestQuote(String symbol) async {
// 1. 캐시 확인
final cached = _cache[symbol];
if (cached != null && cached.isValid) {
return cached.data;
}
// 2. 없으면 원격에서 가져오기
final quote = await _remote.getLatestQuote(symbol);
// 3. 캐시에 저장
if (quote != null) {
_cache[symbol] = CacheEntry(
data: quote,
timestamp: DateTime.now(),
ttl: Duration(seconds: 30),
);
}
return quote;
}
}
// 사용하는 쪽은 캐시 존재를 모름
class WatchlistScreen {
final QuoteRepository repository; // 캐시 있는지 없는지 모름
Future<void> loadQuote() async {
final quote = await repository.getLatestQuote('AAPL');
// 캐시에서 오든 API에서 오든 상관없이 동작
}
}
Riverpod과 캐시 통합
실제 프로젝트에서 Riverpod과 함께 사용하는 방법입니다.
Dart
// providers.dart
// 캐시 설정 Provider
final cacheConfigProvider = Provider<CacheConfig>((ref) {
return CacheConfig(
defaultTtl: Duration(minutes: 5),
maxMemoryCacheSize: 100,
enableLocalStorage: true,
);
});
// 캐시된 Repository Provider
final cachedQuoteRepositoryProvider = Provider<QuoteRepository>((ref) {
final remote = ref.read(remoteQuoteRepositoryProvider);
final config = ref.read(cacheConfigProvider);
// 개발 환경에서는 TTL 짧게
if (kDebugMode) {
return TtlCachedRepository(
remote,
defaultTtl: Duration(seconds: 5),
);
}
// 운영 환경에서는 스마트 캐싱
return SmartCacheRepository(
remote,
LocalStorage(),
config: config,
);
});
// 캐시 무효화 Provider
final cacheInvalidatorProvider = Provider<CacheInvalidator>((ref) {
final repository = ref.read(cachedQuoteRepositoryProvider);
return CacheInvalidator(
onInvalidate: () {
if (repository is SmartCacheRepository) {
repository.invalidateAll();
}
},
);
});
캐시 성능 측정
캐시가 제대로 작동하는지 측정하는 것이 중요합니다.
Dart
class CacheMetrics {
int hits = 0;
int misses = 0;
int evictions = 0;
double get hitRate => hits / (hits + misses);
Map<String, dynamic> toJson() => {
'hits': hits,
'misses': misses,
'evictions': evictions,
'hitRate': '${(hitRate * 100).toStringAsFixed(1)}%',
'totalRequests': hits + misses,
};
}
class MonitoredCache implements QuoteRepository {
final QuoteRepository _remote;
final LruCache<String, CacheEntry<Quote>> _cache;
final CacheMetrics metrics = CacheMetrics();
MonitoredCache(this._remote) : _cache = LruCache(maxSize: 100);
@override
Future<Quote?> getLatestQuote(String symbol) async {
final cached = _cache.get(symbol);
if (cached != null && cached.isValid) {
metrics.hits++;
_logMetrics('HIT', symbol);
return cached.data;
}
metrics.misses++;
_logMetrics('MISS', symbol);
// 원격 조회
final quote = await _remote.getLatestQuote(symbol);
if (quote != null) {
// 캐시가 꽉 차면 eviction 발생
final sizeBefore = _cache.size;
_cache.put(symbol, CacheEntry(
data: quote,
timestamp: DateTime.now(),
));
if (_cache.size < sizeBefore) {
metrics.evictions++;
}
}
return quote;
}
void _logMetrics(String event, String symbol) {
if (kDebugMode) {
print('Cache $event: $symbol | ${metrics.toJson()}');
}
}
}
캐시 히트율(Hit Rate)이 핵심 지표입니다:
• 히트율 = (캐시에서 찾은 횟수) / (전체 요청 횟수)
• 70% 이상이면 효과적
• 30% 이하면 캐시 전략 재검토 필요
• 히트율 = (캐시에서 찾은 횟수) / (전체 요청 횟수)
• 70% 이상이면 효과적
• 30% 이하면 캐시 전략 재검토 필요
실제 프로젝트 적용 시 고려사항
1. 점진적 적용
처음부터 복잡한 캐싱을 구현하지 마세요.
Dart
// Step 1: 간단한 메모리 캐시
class SimpleCache { }
// Step 2: TTL 추가
class TtlCache extends SimpleCache { }
// Step 3: LRU 추가
class LruTtlCache extends TtlCache { }
// Step 4: 계층적 캐싱
class MultiLevelCache extends LruTtlCache { }
2. 캐시 워밍업
Dart
class AppInitializer {
final QuoteRepository repository;
Future<void> warmupCache() async {
// 앱 시작 시 자주 사용할 데이터 미리 캐싱
final popularSymbols = ['AAPL', 'GOOGL', 'MSFT'];
for (final symbol in popularSymbols) {
repository.getLatestQuote(symbol); // 캐시에 저장됨
}
}
}
3. 테스트 전략
Dart
void main() {
test('캐시 히트 테스트', () async {
final remote = MockQuoteRepository();
final cached = CachedQuoteRepository(remote);
// 첫 번째 호출: 캐시 미스
await cached.getLatestQuote('AAPL');
verify(remote.getLatestQuote('AAPL')).called(1);
// 두 번째 호출: 캐시 히트
await cached.getLatestQuote('AAPL');
verifyNever(remote.getLatestQuote('AAPL')); // 호출 안 됨
});
test('TTL 만료 테스트', () async {
final cached = TtlCachedRepository(
remote,
ttl: Duration(milliseconds: 100),
);
await cached.getLatestQuote('AAPL');
await Future.delayed(Duration(milliseconds: 150));
await cached.getLatestQuote('AAPL');
// TTL 지나서 다시 호출됨
verify(remote.getLatestQuote('AAPL')).called(2);
});
}
마무리
캐싱은 앱 성능을 극적으로 개선할 수 있는 강력한 도구입니다. 하지만 잘못 사용하면 오히려 복잡성만 증가시킬 수 있습니다.
핵심은 균형입니다:
• 너무 짧은 TTL → 캐시 효과 없음
• 너무 긴 TTL → 오래된 데이터 표시
• 너무 큰 캐시 → 메모리 문제
• 너무 작은 캐시 → 캐시 미스 증가
프로젝트의 특성과 사용자의 행동 패턴을 이해하고, 그에 맞는 캐싱 전략을 선택하는 것이 중요합니다. 측정하고, 조정하고, 개선하는 과정을 반복하면서 최적의 전략을 찾아가세요.
핵심은 균형입니다:
• 너무 짧은 TTL → 캐시 효과 없음
• 너무 긴 TTL → 오래된 데이터 표시
• 너무 큰 캐시 → 메모리 문제
• 너무 작은 캐시 → 캐시 미스 증가
프로젝트의 특성과 사용자의 행동 패턴을 이해하고, 그에 맞는 캐싱 전략을 선택하는 것이 중요합니다. 측정하고, 조정하고, 개선하는 과정을 반복하면서 최적의 전략을 찾아가세요.