본문 바로가기

Spring Boot/Cache

캐시의 기본 개념과 필요성 : 캐시 적용 고려사항

캐시를 적용할 때 생각해봐야 하는 것들

 

- 캐시를 적용할 때 고려해야 할 핵심 요소

- 어떤 데이터를 캐시할지, 어떤 데이터는 캐시하면 안되는지 판단하기

- 데이터의 크기와 수명 주기(TTL) 설정의 중요성

- 캐시 데이터의 일관성과 최신성을 유지하기 위한 방법

- 상황에 따라 적합한 캐시 전략(읽기, 쓰기, 패턴) 선택

 


개요

캐시를 적용할 때 무엇을 고려해야 하는가?

 

1. 캐시 적합 데이터 식별 : 캐시할 데이터의 특성과 중요성 구분

2. 데이터 크기와 수명 주기 : 캐시의 용량 관리 및 TTL(Time to Live) 설정

3. 일관성과 최신성의 균형 : 원본 데이터 변경 시 캐시 동기화 방식 결정

4. 캐시 전략 선택 : 데이터 읽기, 쓰기 방식에 따른 정책 설계

 


캐시 적합 데이터 식별

1. 개념

모든 데이터를 캐시에 저장하면 오히려 비효율적임

캐시는 자주 조회되지만, 자주 변경되지 않는 데이터에 가장 큰 효과를 낸다

데이터 예시 캐시 적합성 이유
상품 목록, 지역 코드, 카테고리 높음 조회가 잦고, 변경이 드물다
주문 내역, 결제 상태 낮음 데이터가 자주 변경된다
날씨, 뉴스 헤드라인 중간 TTL 설정으로 일정 시간 유지 가능함
로그, 비밀번호, 개인정보 없음(하면 안됨) 보안상 위험, 변경 주기도 높음

 

 

2. 데이터 선정 기준

1) 조회 빈도(Frequency) : 자주 조회되는가?

2) 변경 빈도(Volatility) : 자주 바뀌진 않는가?

3) 최신성 중요도(Freshness) : 최신 데이터가 꼭 필요한가?

 

* 조회 빈도가 높고 변경 빈도가 낮은 데이터일수록 캐싱 효과가 극대화됨

변경이 잦으면 DB조회, 변경 빈도가 적으면 캐시에 적재함

 

 


데이터 크기와 수명 주기(TTL)

1) 데이터 크기 관리

캐시는 메모리 기반 저장소이므로, 저장 공간이 제한적임

너무 큰 데이터를 저장하면 다른 캐시가 삭제(Eviction)되어 성능이 오히려 떨어짐

관리 항목 설명
Key 크기 짧고 명확하게 설계(user:123 형태 등등..)
Value 크기 1MB 이하 권장, 대용량은 별도의 저장소를 사용하기
압축 사용 Gzip, Snappy 등으로 저장 공간 절약 가능

 

 

2) TTL(Time to Live) 설정

캐시 데이터의 유효 시간 설정하기

일정 시간이 지나면 자동으로 삭제되어, 데이터의 최신성을 유지할 수 있음

import java.util.*;

public class CacheTTLExample {
	
	static class CacheItem {
		String value;
		long expireAt;
		
		CacheItem(String value, long expireAt) { // setter
			this.value = value;
			this.expireAt = System.currentTimeMillis() + expireAt;
		}
		
		boolean isExpired() {
			// 현재 시간이 expireAt보다 크면 만료됨
			return System.currentTimeMillis() > expireAt;
		}
	}
	
	private static final Map<String, CacheItem> cache = new HashMap<>(); // cache
	
	public static String get(String key) {
		CacheItem item = cache.get(key);
		if(item != null && !item.isExpired()) { // 필터링
			System.out.println("[Cache Hit] 캐시에서 데이터를 반환합니다.");
			return item.value; // CacheItem
		}
		
		System.out.println("[Cache Miss] 원본에서 데이터를 조회합니다.");
		String value = "데이터 : " + key;
		cache.put(key, new CacheItem(value, 3000)); // value, TTL은 3초	
		return value;
	}
	
	public static void main(String[] args) throws InterruptedException {
		System.out.println(get("A"));
		Thread.sleep(1000);
		System.out.println(get("A"));
		Thread.sleep(4000);
		System.out.println(get("A"));
	}
}

 

결과

[Cache Miss] 원본에서 데이터를 조회합니다.
데이터 : A
[Cache Hit] 캐시에서 데이터를 반환합니다.
데이터 : A
[Cache Miss] 원본에서 데이터를 조회합니다.
데이터 : A

 

* TTL을 통해 캐시는 일시적 최신성 보장과 성능 균형을 유지한다고 함

 

- 데이터 성격별로 TTL을 다르게 설정함(예 : 뉴스는 30초, 상품목록은 1시간, 영화 데이터는 뭐.. 1주일?)

- TTL이 짧으면 최신성 보장, 길면 효율적 성능 유지가 가능하지만, 균형이 중요함

- 캐시가 가득 찰 경우, 오래된 데이터를 자동으로 제거하는 Eviction 정책을 함께 설계해야함

 


일관성과 최신성의 균형

1) 개념

캐시의 데이터와 실제 DB 데이터가 달라지는 문제캐시 불일치(Cache Inconsistency)라고 함 ** 중요

캐시 불일치 문제를 방치하면 사용자는 오래된 데이터를 보게 되거나, 잘못된 정보가 전파될 수 있음

따라서, 캐시 시스템에서는 DB 변경 시점과 캐시 갱신 시점의 일관성을 유지하는 전략이 매우 중요함

DB 데이터 수정 - 캐시 데이터는 이전 상태임 - 따라서 사용자는 오래된 데이터를 수신받음

 

 

2) 해결 전략 비교

전략 설명 장점 단점
Cache Invalidation DB 변경시 캐시 삭제 단순하고 안전함 첫 요청이 느림
Write-trough DB 갱신과 동시에 캐시 갱신 일관성 유지 쓰기 성능이 저하됨
Background Refresh 주기적으로 캐시를 재생성함 자동 동기화 실시간성이 부족함

 

 

3) Cache Invalidation(캐시 즉시 삭제)

public void updateProduct(UUID id, String newName) {
	// DB를 갱신함, 업데이트
    productRepository.updateName(id, newName);
    // 캐시에서 관련된 값을 제거함
    cache.remove(id);
    
    System.out.println("[Cache Invalidation] 상품 캐시 삭제 완료");
}

 

* 가장 단순하고 안전한 방식! 다만, 캐시가 삭제되므로 다음 요청은 DB를 조회해야하는 번거로움으로 느려질 수 있음

 

 

4) Write-trough(DB와 캐시를 동기적으로 갱신함)

public void updateProductWriteTrough(UUID id, String newName) {
	// DB에 업데이트 반영함
    productRepository.updateName(id, newName);
    
    // 캐시를 삭제하는게 아니고, put을 통해 덮어쓰기함
    cache.put(id, newName);
    
    System.out.println("[Write Trough] DB와 캐시를 동시에 갱신함");
}

 

* 이 방식의 경우 데이터 일관성이 매우 높지만, DB 쓰기와 캐시 쓰기를 동시에 수행해야 하므로 쓰기 성능이 다소 저하될 수 있음

 

대충 Write-Trough 방식

 

 

5) Background Refresh 방식(비동기 재갱신)

import java.util.Map;
import java.util.concurrent.*;

public class BackgroundRefreshExample {
	
	// ?? 자바에서 지원하는 인터페이스임
	private static final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
	private static final Map<String, String> cache = new ConcurrentHashMap<>();
	private static int count;
	
	public static void startAutoRefresh() {
		scheduler.scheduleAtFixedRate(() -> {
			System.out.println("[Background Refresh] 캐시 데이터를 최신 상태로 갱신합니다.");
			count++;
			System.out.println("count : " + count);
			
			// 실제로는 DB에서 최신 데이터를 불러온 다음 캐시를 갱신함
			cache.put("product : 1", "갱신된 상품 정보");
		}, 0, 5, TimeUnit.SECONDS); 
		// Runnable command, long initialDelay, long period, TimeUnit unit
	}
	
	public static void main(String[] args) throws InterruptedException {
		startAutoRefresh();
		Thread.sleep(16000);
	}
}

 

결과

[Background Refresh] 캐시 데이터를 최신 상태로 갱신합니다.
count : 1
[Background Refresh] 캐시 데이터를 최신 상태로 갱신합니다.
count : 2
[Background Refresh] 캐시 데이터를 최신 상태로 갱신합니다.
count : 3

 

* Background Refresh는 정기적으로 캐시를 자동 업데이트하여 실시간성이 덜 중요한 데이터(뉴스, 랭킹 등..)에 적합함

 


캐시 전략 선택

1) 주요 전략 비교

전략 설명 장점 단점
Cache-aside(Lazy Loading) 요청시 캐시가 없으면 DB조회 후 캐시에 저장함 단순하고 널리 사용함 첫 요청이 느림
Write-Through DB에 쓰기 시 캐시도 즉시 갱신 일관성 유지 쓰기의 부하가 증가함
Write-Behind(Write-Back) 캐시에 먼저 쓰고, 나중에 DB에 반영함 빠른 응답 장애시 데이터 유실의 위험
Read-Through 캐시가 DB를 직접 조회함 구조가 단순함 캐시 시스템이 복잡함

 

 

2) Cache-Aside(Lazy Loading)

요청시 캐시에 없으면 DB에서 조회하고 캐시에 저장한다

 

public String getProduct(UUID id) {
	if(cache.containsKey(id)) {
    	return cache.get(id); // cache Hit
    }
    
    // 이후는 cache Miss
    String product = database.findProductById(id);
    cache.put(id, product); // lazy Load
    return product;
}

 

* 가장 일반적인 캐시 전략임. 구현이 간단하고 유지보수가 쉬움

 

 

3) Write-Trough

public saveProduct(UUID id, String name) {
	database.saveProduct(id, name); // DB에 먼저 저장하고
    cache.put(id, name); // 캐시에 즉시 반영함
    System.out.println("[Write-Through] DB와 캐시 동기 반영 완료");
}

 

* 쓰기와 동시에 캐시 갱신이 이뤄져서 항상 최신 상태를 유지함

단, 쓰기 요청이 많을 경우 부하가 높아질 수 있다

 

 

4) Write-Behind(Write-Back) : 비동기처리

import java.util.Map;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.LinkedBlockingQueue;

public class WriteBehindExample {
	private static final Map<String, String> cache = new ConcurrentHashMap<>();
	private static final BlockingQueue<String> writeQueue = new LinkedBlockingQueue<>();
	
	public static void save(String key, String value) {
		cache.put(key, value);
		writeQueue.add(key);
		System.out.println("[Write-Behind] 캐시에 우선 저장 후 큐에 추가");
	}
	
	public static void processQueue() {
		new Thread(() -> {
			while(true) {
				try {
					String key = writeQueue.take();
					System.out.println("DB에 반영 완료 : " + key);
				} catch(InterruptedException ignored) {
					
				}
			}
		}).start();
	}
	
	public static void main(String[] args) {
		processQueue();
		save("user : 1", "홍길동");
	}
}

 

결과

[Write-Behind] 캐시에 우선 저장 후 큐에 추가
DB에 반영 완료 : user : 1

 

 

 

5) Read-Trough

캐시가 있으면 즉시 반환, 없으면 DB에서 조회하고 캐시에 자동으로 저장

 

import java.util.*;

public class ReadThroughCache {
	
	private final Map<String, String> cache = new HashMap<>();
	
	public String get(String key) {
		return cache.computeIfAbsent(key, k -> { // computeIfAbsent 공부하기
			System.out.println("[Read-Through] DB에서 조회 후 캐시에 저장");
			return "DB_DATA_FOR_" + k;
		});
	}
	
	public static void main(String[] args) {
		ReadThroughCache cache = new ReadThroughCache();
		System.out.println(cache.get("A")); // DB를 조회함, Miss
		System.out.println(cache.get("A")); // 캐시를 사용함, Hit
	}
}

 

결과

[Read-Through] DB에서 조회 후 캐시에 저장
DB_DATA_FOR_A
DB_DATA_FOR_A

 

* Read-Through는 캐시가 자동으로 DB를 조회해서 데이터를 채우므로, 개발자가 별도의 로직을 작성할 필요가 없음!

 

 


 

정리

전략 특징 적합한 상황
Cache-Aside 요청시 캐시 미스 발생시에만 DB 조회 대부분의 일반적 웹 서비스
Write-Through 쓰기 시 캐시와 DB를 동시에 갱신함 일관성이 중요한 시스템
Write-Behind 캐시에 먼저 쓰고 나중에 DB에 반영함 로그, 통계 등 지연 허용 데이터
Read-Through 캐시가 DB를 직접 조회함 캐시 시스템이 DB 접근을 관리할 때