캐시를 적용할 때 생각해봐야 하는 것들
- 캐시를 적용할 때 고려해야 할 핵심 요소
- 어떤 데이터를 캐시할지, 어떤 데이터는 캐시하면 안되는지 판단하기
- 데이터의 크기와 수명 주기(TTL) 설정의 중요성
- 캐시 데이터의 일관성과 최신성을 유지하기 위한 방법
- 상황에 따라 적합한 캐시 전략(읽기, 쓰기, 패턴) 선택
개요
캐시를 적용할 때 무엇을 고려해야 하는가?
1. 캐시 적합 데이터 식별 : 캐시할 데이터의 특성과 중요성 구분
2. 데이터 크기와 수명 주기 : 캐시의 용량 관리 및 TTL(Time to Live) 설정
3. 일관성과 최신성의 균형 : 원본 데이터 변경 시 캐시 동기화 방식 결정
4. 캐시 전략 선택 : 데이터 읽기, 쓰기 방식에 따른 정책 설계
캐시 적합 데이터 식별
1. 개념
모든 데이터를 캐시에 저장하면 오히려 비효율적임
캐시는 자주 조회되지만, 자주 변경되지 않는 데이터에 가장 큰 효과를 낸다
| 데이터 예시 | 캐시 적합성 | 이유 |
| 상품 목록, 지역 코드, 카테고리 | 높음 | 조회가 잦고, 변경이 드물다 |
| 주문 내역, 결제 상태 | 낮음 | 데이터가 자주 변경된다 |
| 날씨, 뉴스 헤드라인 | 중간 | TTL 설정으로 일정 시간 유지 가능함 |
| 로그, 비밀번호, 개인정보 | 없음(하면 안됨) | 보안상 위험, 변경 주기도 높음 |
2. 데이터 선정 기준
1) 조회 빈도(Frequency) : 자주 조회되는가?
2) 변경 빈도(Volatility) : 자주 바뀌진 않는가?
3) 최신성 중요도(Freshness) : 최신 데이터가 꼭 필요한가?
* 조회 빈도가 높고 변경 빈도가 낮은 데이터일수록 캐싱 효과가 극대화됨
데이터 크기와 수명 주기(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 변경 시점과 캐시 갱신 시점의 일관성을 유지하는 전략이 매우 중요함
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 쓰기와 캐시 쓰기를 동시에 수행해야 하므로 쓰기 성능이 다소 저하될 수 있음
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)
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
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 접근을 관리할 때 |
'Spring Boot > Cache' 카테고리의 다른 글
| 캐시 아키텍처와 종류 : 주요 캐시 솔루션 비교 (0) | 2026.01.02 |
|---|---|
| 캐시 아키텍처와 종류 : 캐시 계층 구조 (0) | 2025.12.31 |
| 캐시의 기본 개념과 필요성 : 대용량 트래픽에서 캐시의 역할 (0) | 2025.12.21 |
| 캐시의 기본 개념과 필요성 : 캐시의 핵심 원리 (0) | 2025.12.19 |
| 캐시의 기본 개념과 필요성 : 캐시의 정의와 목적 (0) | 2025.12.19 |