캐시는 빠르게 만들기 위한 도구이지만, 잘못 다루면 오히려 느리고 불안정한 구조를 만든다.
그래서 캐시는 기능이 아니라 '읽기 전략', '갱신 전략', '장애 대응'의 세 축으로 보는 편이 안전하다.
1. 읽기 전략
읽기 전략의 핵심은 무엇을 어디서 읽을 것인가를 분명히 하는 일이다.
1-1. 기준 원본과 조회용 데이터를 분리한다
먼저 정해야 하는 것은 아래 네 가지다.
- 정답을 가지는 기준 원본은 무엇인가
- 캐시에 올릴 조회용 데이터는 무엇인가
- 어떤 요청을 캐시할 것인가
- 어떤 요청은 캐시하지 않을 것인가
캐시는 기준 원본이 아니다.
캐시는 기준 원본을 빠르게 보여주기 위한 복제본이다.
조회가 복잡하다면 조회용 데이터를 별도로 두는 편이 낫다.
- 여러 저장소를 자주 묶어 읽는 경우
- 같은 계산을 반복하는 경우
- 목록과 상세가 비슷한 데이터를 공유하는 경우
예시 코드
// 기준 원본
User findUser(Long id);
// 조회용 데이터
UserSummary findUserSummary(Long id);1-2. 캐시 대상은 반복성, 비용, 허용 가능한 지연으로 정한다
모든 데이터를 캐시할 필요는 없다.
아래 조건을 많이 만족할수록 캐시 후보가 된다.
- 같은 요청이 반복된다
- 읽기 빈도가 쓰기 빈도보다 높다
- 계산 비용이나 조회 비용이 크다
- 짧은 지연 반영을 허용할 수 있다
반대로 아래 성격의 데이터는 캐시 이득이 작거나 위험이 크다.
- 거의 호출되지 않는다
- 매우 자주 바뀐다
- 즉시 일관성이 절대적으로 필요하다
- 조건 조합이 지나치게 많다
예시 코드
boolean isCacheable(QueryCondition condition) {
return condition.isRepeated()
&& condition.isExpensive()
&& condition.allowsShortDelay();
}1-3. 캐시 단위는 작을수록 유연하다
응답 전체를 그대로 캐시하면 단순하다.
하지만 일부 값만 바뀌어도 전체를 다시 만들어야 하고, 무효화 범위도 커진다.
그래서 보통은 캐시 단위를 나눈다.
- 단건 캐시: 한 개 엔티티 또는 한 개 조회 결과
- 목록 캐시: 목록 구조만 저장
- 집계 캐시: 개수, 순위, 통계
예시 코드
record ItemCache(Long id, String name, int score) {}
record ListCache(List<Long> ids, long totalCount) {}1-4. 목록은 구조와 항목 값을 분리해서 읽는다
목록 응답은 보통 두 층으로 나뉜다.
- 어떤 항목이 포함되는가
- 각 항목이 어떤 값을 가지는가
이 둘을 분리하면 아래 장점이 생긴다.
- 목록과 상세가 같은 단건 캐시를 재사용한다
- 일부 항목만 비어 있어도 부분 복구가 가능하다
- 목록 구조가 그대로면 단건 캐시만 다시 채우면 된다
예시 코드
ListCache listCache = cache.get(listKey, () -> loadList());
List<ItemView> result = listCache.ids().stream()
.map(id -> cache.get("item:" + id, () -> loadItem(id)))
.filter(value -> value != null)
.toList();1-5. 모든 요청을 캐시하지 않는다
캐시는 넓게 붙일수록 좋지 않은 경우가 많다.
- 키 수가 너무 많아진다
- 갱신 범위가 커진다
- 메모리 사용량이 커진다
- 실제로는 거의 재사용되지 않는 요청까지 관리하게 된다
그래서 보통은 자주 반복되는 구간만 캐시한다.
- 첫 페이지
- 기본 정렬
- 자주 쓰는 필터 조합
- 재사용률이 높은 조회 조건
예시 코드
boolean isListCacheable(int page, int size) {
return page == 0 && size == 20;
}1-6. 읽기 성능은 적중과 비적중을 나눠서 본다
캐시 성능은 한 숫자로 보면 안 된다.
- 적중: 캐시 구조의 성능
- 비적중: 원본 조회 구조의 성능
따라서 아래를 나눠서 봐야 한다.
- 단건 적중
- 단건 비적중
- 동시 요청 적중
- 동시 요청 비적중
- 지속 요청 적중
예시 코드
// 적중 측정
cache.put("item:1", loadItem(1L));
measure(() -> queryService.find(1L));
// 비적중 측정
cache.evict("item:1");
measure(() -> queryService.find(1L));2. 갱신 전략
갱신 전략의 핵심은 언제, 무엇을, 얼마나 넓게 바꿀 것인가다.
캐시 문제의 대부분은 저장보다 갱신 범위 판단에서 발생한다.
2-1. 무효화는 삭제가 아니라 영향 범위 계산이다
핵심 질문은 아래와 같다.
'이 변경은 어떤 응답을 바꾸는가'
변경은 보통 세 유형으로 나뉜다.
- 값만 바꾸는 변경
- 정렬에 영향을 주는 변경
- 포함 여부를 바꾸는 변경
값만 바꾸는 변경
- 이름 변경
- 설명 변경
- 상태 문구 변경
이 경우는 보통 단건 캐시만 갱신하면 된다.
정렬에 영향을 주는 변경
- 가격 변경
- 점수 변경
- 인기 수치 변경
이 경우는 단건 캐시뿐 아니라 정렬 결과를 담는 목록 캐시도 함께 봐야 한다.
포함 여부를 바꾸는 변경
- 생성
- 삭제
- 공개/비공개 전환
- 검색 조건에 걸리는 상태 변경
이 경우는 목록 구성 자체가 바뀌므로 관련 목록 캐시를 다시 판단해야 한다.
예시 코드
void changePrice(Long id, int price) {
repository.changePrice(id, price);
cache.put("item:" + id, loadItem(id));
cache.evict("list:price:0");
}2-2. 대표적인 갱신 방식
조회 시 적재
- 읽을 때 캐시를 먼저 본다
- 없으면 원본에서 읽고 캐시에 넣는다
가장 널리 쓰인다.
다만 같은 데이터가 한꺼번에 비면 원본 조회가 몰릴 수 있다.
예시 코드
UserView find(Long id) {
return cache.get("user:" + id, () -> loadUser(id));
}즉시 반영 갱신
- 원본이 바뀐 직후 관련 캐시를 즉시 다시 적재한다
정합성은 좋아지지만, 쓰기 비용이 늘어난다.
그래서 모든 캐시에 일괄 적용하기보다 영향이 큰 캐시에만 적용하는 편이 현실적이다.
예시 코드
void changeName(Long id, String name) {
repository.changeName(id, name);
cache.put("user:" + id, loadUser(id));
}자연 만료
- 일정 시간이 지나면 캐시가 스스로 사라진다
자연 만료는 주 전략이라기보다 안전망에 가깝다.
지연 반영을 완전히 없애는 수단은 아니다.
예시 코드
void cacheUser(Long id) {
cache.put("user:" + id, loadUser(id), Duration.ofMinutes(3));
}2-3. 집계성 데이터는 일반 값보다 더 조심해야 한다
개수, 순위, 점수 같은 데이터는 일반 필드보다 더 자주 어긋난다.
- 자주 바뀐다
- 목록 순서에 직접 영향을 준다
- 계산 결과와 표시 값이 따로 놀기 쉽다
이런 값은 아래 원칙으로 보는 편이 낫다.
- 기준 원본과 표시용 값을 분리한다
- 갱신은 원자적으로 처리한다
- 장기적으로는 다시 계산하는 안전망을 둔다
예시 코드
void increaseScore(Long id) {
repository.increaseScore(id);
cache.evict("ranking:main");
}2-4. 알고 허용하는 지연 반영 구간을 남겨둔다
모든 값을 즉시 맞추려 하면 갱신 비용이 너무 커질 수 있다.
따라서 일부 구간은 의도적으로 짧은 지연 반영을 허용하기도 한다.
중요한 것은 허용 여부보다 문서화다.
- 어떤 데이터가 즉시 반영 대상인가
- 어떤 데이터가 자연 만료에 맡겨지는가
- 왜 그렇게 선택했는가
이 기준이 문서에 없으면, 의도된 선택도 나중에는 버그처럼 보이기 쉽다.
3. 장애 대응
장애 대응의 핵심은 캐시가 비거나 느려질 때 시스템이 어떻게 버틸 것인가다.
3-1. 같은 데이터가 한꺼번에 비는 상황을 따로 막는다
같은 시점에 같은 데이터가 동시에 비면 원본 조회가 몰린다.
이 문제는 보통 세 층으로 나눠서 막는다.
만료 시간 분산
모든 키가 동시에 사라지지 않게 만료 시간을 조금씩 흩어 놓는다.
예시 코드
Duration withJitter(Duration base) {
long extra = ThreadLocalRandom.current().nextLong(0, base.toMillis() / 10 + 1);
return base.plusMillis(extra);
}선제 갱신
완전히 비기 직전에 일부 요청이 미리 다시 채우게 한다.
예시 코드
if (isNearExpiry(cacheKey)) {
executor.submit(() -> cache.put(cacheKey, loadSource()));
}키 단위 잠금
같은 키가 비었을 때 한 요청만 원본을 읽게 한다.
예시 코드
ItemView find(Long id) {
return lock.execute("item:" + id, () ->
cache.get("item:" + id, () -> loadItem(id))
);
}3-2. 부분 누락은 전체 실패와 다르게 다룬다
목록 캐시는 전체 적중과 전체 비적중만 있는 것이 아니다.
일부 항목만 비는 부분 누락이 자주 생긴다.
이 경우는 전체 실패로 볼 필요가 없다.
- 빠진 항목만 다시 읽으면 된다
- 목록 구조는 그대로 유지할 수 있다
- 불필요한 전체 삭제를 줄일 수 있다
예시 코드
List<ItemView> filled = ids.stream()
.map(id -> cache.get("item:" + id, () -> loadItem(id)))
.filter(value -> value != null)
.toList();3-3. 캐시 저장소 장애 시에는 느려져도 살아 있어야 한다
캐시 저장소는 보조 수단이다.
캐시 저장소 장애가 전체 서비스 장애로 번지면 의존 관계가 잘못된 경우가 많다.
더 안전한 방향은 아래와 같다.
- 캐시 예외는 격리한다
- 실패 시 원본 조회로 넘어간다
- 대신 응답 시간 증가는 감수한다
즉 이상적인 상태는 빠르지 않아도 살아 있는 상태다.
예시 코드
ItemView find(Long id) {
try {
return cache.get("item:" + id, () -> loadItem(id));
} catch (Exception e) {
return loadItem(id);
}
}3-4. 자주 보이는 이상 징후와 먼저 볼 것
상세는 맞는데 목록 순서가 이상하다
- 정렬에 영향을 주는 필드가 바뀌었는가
- 정렬 목록 캐시가 갱신 대상이었는가
- 의도적으로 자연 만료에 맡긴 구간인가
수정 직후 예전 값이 보인다
- 단건 캐시가 갱신되었는가
- 조회용 데이터가 최신인가
- 저장이 끝나기 전에 캐시가 먼저 갱신된 것은 아닌가
삭제된 데이터가 간헐적으로 보인다
- 목록 캐시가 다시 만들어졌는가
- 삭제 이후 참조가 남은 캐시가 있는가
- 응답 조립 단계에서 없는 데이터를 걸러내는가
요청이 몰리면 데이터베이스가 갑자기 치솟는다
- 같은 키의 비적중이 몰리는가
- 캐시를 붙이지 않은 요청이 많아졌는가
- 캐시 저장소 장애로 원본 조회가 급증하는가
예시 코드
if (isSameKeyMissBursting()) {
log.warn("same key miss is increasing");
}3-5. 실제 점검 순서는 짧게 유지한다
문제를 좁힐 때는 아래 순서가 가장 단순하다.
- 기준 원본이 맞는지 본다
- 조회용 데이터가 맞는지 본다
- 단건 캐시 문제인지 목록 캐시 문제인지 나눈다
- 이 변경이 어떤 응답을 바꾸는 유형인지 본다
- 즉시 반영 대상이 맞는지 확인한다
- 자연 만료로 허용한 구간인지 본다
- 같은 데이터 비적중이 몰렸는지 본다
- 캐시 저장소 장애 여부를 본다
예시 코드
void checkOrder() {
checkSource();
checkReadModel();
checkItemCache();
checkListCache();
checkInvalidationScope();
}정리
캐시는 결국 세 가지로 정리된다.
- 읽기 전략: 무엇을 어디서 읽을 것인가
- 갱신 전략: 언제 무엇을 다시 채울 것인가
- 장애 대응: 비거나 느려질 때 어떻게 버틸 것인가
이 세 축만 분리해서 봐도 캐시 문제는 훨씬 빨리 좁혀진다.
캐시 문제를 좁히는 마지막 질문은 아래와 같다.
'정답은 어디에 있고, 어떤 시점에 어떤 응답이 달라져야 하는가'