캐시 완전 정복 — Cache Aside부터 TTL, 무효화까지
캐시, 왜 알아야 하나요?
같은 데이터를 계속 읽는 API는 생각보다 많습니다. 상품 상세, 홈 화면 블록, 사용자 프로필, 코드 테이블처럼 "조금 전에도 읽었던 값"을 다시 가져오는 요청이 반복됩니다.
- 조회 요청은 많은데 데이터 변경은 드뭅니다
- 같은 SQL이나 외부 API 호출이 짧은 시간 안에 반복됩니다
- DB나 원본 서비스는 이미 느린데, 트래픽까지 몰리면 더 흔들립니다
이럴 때 캐시는 응답 시간을 줄이고, 원본 저장소의 부하를 낮추고, 순간 트래픽을 흡수하는 데 큰 도움이 됩니다.
다만 캐시는 만능이 아닙니다. 캐시는 "진실의 원본"이 아니라, 자주 읽는 값을 잠시 복사해 두는 계층입니다. 이 점을 놓치면 TTL을 길게만 잡거나, 무효화를 빼먹거나, 오히려 일관성을 망가뜨리기 쉽습니다.
기준: 이 글은 Redis 같은 원격 캐시를 기준으로 설명합니다. 하지만
hit,miss,TTL, 무효화 같은 핵심 원리는 인메모리 캐시에도 거의 그대로 적용됩니다.
Phase 1. 캐시는 정확히 무엇을 해결할까?
캐시의 가장 기본적인 목적은 비싼 읽기를 반복하지 않게 만드는 것입니다.
예를 들어 상품 상세 API가 있다고 가정해 보겠습니다.
요청 1 → DB 조회 → 결과 반환
요청 2 → DB 조회 → 결과 반환
요청 3 → DB 조회 → 결과 반환
같은 상품을 짧은 시간 안에 여러 번 읽는다면, 원본 저장소는 같은 일을 계속 반복합니다.
캐시를 두면 흐름이 이렇게 바뀝니다.
요청 1 → 캐시 miss → DB 조회 → 캐시에 저장 → 결과 반환
요청 2 → 캐시 hit → 캐시에서 바로 반환
요청 3 → 캐시 hit → 캐시에서 바로 반환
이때 얻는 이점은 세 가지입니다.
- 응답 시간 감소 — 네트워크 왕복, 디스크 I/O, 복잡한 조인 비용을 반복하지 않습니다
- 원본 저장소 보호 — DB나 외부 서비스가 처리해야 할 읽기 수를 줄일 수 있습니다
- 순간 트래픽 완충 — 갑자기 요청이 몰려도 인기 데이터는 캐시가 먼저 받쳐 줍니다
cache hit와 cache miss
실무에서 가장 많이 보게 되는 두 단어입니다.
cache hit: 캐시에 값이 있어서 원본 조회 없이 바로 반환한 경우cache miss: 캐시에 값이 없어서 원본 저장소를 다시 읽은 경우
캐시는 결국 miss를 얼마나 줄이느냐의 문제입니다. 그리고 그 과정에서 값이 얼마나 오래된 상태로 허용될 수 있느냐를 함께 판단해야 합니다.
Phase 2. 어떤 데이터를 캐시해야 할까?
모든 읽기 데이터를 캐시할 필요는 없습니다. 캐시 대상은 보통 아래 세 조건 중 두세 개를 만족할 때 효과가 큽니다.
1. 읽기 빈도가 높다
자주 읽히는 데이터일수록 캐시 효과가 큽니다.
- 상품 상세
- 인기 게시글 목록
- 홈 화면 큐레이션 블록
- 공통 코드 테이블
반대로 거의 읽히지 않는 데이터는 캐시에 올려도 hit가 잘 나지 않습니다.
2. 원본 조회 비용이 비싸다
원본 저장소 조회가 비싸면 캐시 이점이 커집니다.
- 조인이 많은 SQL
- 외부 API 호출
- 집계/계산이 필요한 응답
- 여러 저장소를 조합해 만든 View 객체
3. 약간의 지연 반영이 허용된다
캐시는 본질적으로 원본과 약간 어긋날 수 있는 구조입니다. 그래서 변경 직후 잠시 이전 값이 보여도 직접적인 오류로 이어지지 않는 데이터와 잘 맞습니다.
반대로 이런 데이터는 신중해야 합니다.
- 결제 직후 재고 수량
- 중복 차감이 치명적인 쿠폰 수량
- 승인/권한 상태처럼 즉시성이 중요한 값
한눈에 보는 캐시 적합도
| 데이터 유형 | 캐시 적합도 | 이유 |
|---|---|---|
| 상품 상세, 프로필, 코드 테이블 | 높음 | 읽기가 많고 변경 빈도가 낮습니다 |
| 홈 화면 블록, 추천 결과 | 높음 | 조립 비용이 크고 약간의 지연 허용이 가능합니다 |
| 검색 결과 첫 페이지 | 중간 | 효과는 크지만 조건 조합과 무효화 범위가 넓을 수 있습니다 |
| 실시간 재고, 결제 상태 | 낮음 | 오래된 값이 직접적인 오류로 이어질 수 있습니다 |
핵심은 이것입니다.
읽기는 많고, 만들기 비싸고, 약간 늦게 반영돼도 괜찮은 값이 캐시에 잘 맞습니다
Phase 3. 대표 전략은 무엇이고, 왜 Cache Aside가 자주 쓰일까?
캐시는 "어디에 저장할 것인가"보다 언제 채우고, 언제 갱신하고, 언제 버릴 것인가가 더 중요합니다.
가장 많이 쓰는 전략: Cache Aside
애플리케이션이 캐시를 직접 조회하고, 없으면 원본 저장소를 읽은 뒤 캐시에 채우는 방식입니다.
fun getProduct(productId: Long): ProductView {
val cacheKey = "product:detail:$productId"
cache.get(cacheKey)?.let { return it }
val product = productRepository.findViewById(productId)
cache.put(cacheKey, product, ttl = Duration.ofMinutes(10))
return product
}
흐름은 단순합니다.
1. 캐시 조회
2. 있으면 바로 반환
3. 없으면 원본 조회
4. 결과를 캐시에 저장
5. 응답 반환
이 전략이 많이 쓰이는 이유는 명확합니다.
- 구현이 단순합니다
- 어떤 데이터에 캐시를 붙일지 애플리케이션이 직접 제어할 수 있습니다
- 처음에는 일부 API에만 선택적으로 도입하기 쉽습니다
대부분의 서비스는 Cache Aside부터 시작해도 충분합니다.
쓰기 시점 전략은 어떻게 다를까?
읽기 전략만 보면 반쪽입니다. 쓰기가 들어올 때 캐시를 어떻게 다룰지도 정해야 합니다.
| 전략 | 동작 방식 | 잘 맞는 상황 |
|---|---|---|
Cache Aside |
읽을 때 miss면 채움, 쓸 때는 보통 캐시 삭제 | 가장 일반적인 웹 서비스 읽기 캐시 |
Write Through |
애플리케이션이 캐시에 쓰고, 캐시가 원본 저장소에도 동기적으로 반영 | 캐시 계층이 쓰기 경로까지 함께 책임질 때 |
Write Around |
DB에만 쓰고, 쓰기 시 캐시는 채우지 않음 | 쓰기는 많고 다시 읽힐지 불확실하며 기존 캐시는 TTL 또는 별도 무효화 정책으로 관리할 때 |
실무에서는 Cache Aside + 변경 시 삭제(evict) 조합이 가장 흔합니다.
참고:
Write Through는 보통 캐시가 원본 저장소 쓰기까지 함께 처리하는 구조를 가리킵니다. 애플리케이션이 DB와 캐시를 각각 직접 갱신하는 방식과는 구분해서 보는 편이 더 정확합니다.
왜 "쓰기 후 삭제"가 자주 쓰일까?
예를 들어 상품 이름을 수정한다고 가정해 보겠습니다.
1. 상품 이름을 DB에 반영한다
2. 트랜잭션 커밋이 끝난다
3. product:detail:{id} 캐시를 삭제한다
이 방식은 캐시를 직접 새 값으로 맞추는 것보다 단순합니다.
- 여러 캐시 키를 동시에 정확히 업데이트하기 어렵습니다
- 상세, 목록, 추천 결과처럼 같은 데이터를 참조하는 캐시가 여러 개일 수 있습니다
- 일단 삭제해 두면 다음 읽기에서 최신 값으로 다시 채울 수 있습니다
즉, 갱신(update)보다 삭제(evict)가 더 단순하고 안전한 경우가 많습니다.
참고: 쓰기 트랜잭션이 롤백될 수 있는 경로라면, 캐시 삭제 시점도 함께 봐야 합니다. 많은 경우 DB 반영이 확정된 뒤에 무효화하는 편이 더 안전합니다.
Phase 4. 캐시 키는 어떻게 설계해야 할까?
캐시에서 값만큼 중요한 것이 키입니다. 키 설계가 흐리면 hit율이 떨어지고, 무효화 범위도 관리하기 어려워집니다.
좋은 키의 조건
- 결정적이어야 합니다 — 같은 요청이면 항상 같은 키가 나와야 합니다
- 충분히 구체적이어야 합니다 — 서로 다른 결과가 같은 키를 공유하면 안 됩니다
- 무효화 가능해야 합니다 — 나중에 어떤 범위를 지울지 예상할 수 있어야 합니다
예를 들어 이런 키는 비교적 읽기 쉽고 관리하기 좋습니다.
product:detail:123
user:profile:42
home:section:block:summer-sale
반대로 조건이 많은 목록 캐시는 더 신중해야 합니다.
search:q=shoes&sort=popular&page=1
이런 키는 얼핏 맞아 보이지만, 아래 문제가 숨어 있습니다.
- 파라미터 순서가 바뀌면 다른 키가 됩니다
- 공백, 대소문자, 기본값 포함 여부가 다르면
hit가 깨집니다 - 조건 조합이 너무 많으면 키 수가 폭발합니다
실무에서 자주 쓰는 보완 방법
data class ProductSearchCacheKey(
val query: String,
val sort: String,
val page: Int,
)
fun toCacheKey(key: ProductSearchCacheKey): String =
"product-search:${key.query.trim().lowercase()}:${key.sort}:${key.page}"
핵심은 요청 파라미터를 정규화한 뒤 키를 만들기입니다.
버전 접두어도 도움이 된다
캐시 구조가 바뀌거나 직렬화 형식이 바뀌면 기존 값을 한 번에 버리고 싶을 때가 있습니다.
v1:product:detail:123
v2:product:detail:123
이렇게 버전 접두어를 두면 대규모 마이그레이션이나 직렬화 변경 시 운영이 훨씬 단순해집니다. Redis 직렬화 이슈 글도 결국 "캐시에 저장된 값의 형식"을 신경 써야 했던 사례입니다.
Phase 5. TTL은 어떻게 정해야 할까?
TTL(Time To Live)은 캐시 값이 얼마나 오래 살아 있을지를 정하는 만료 시간입니다.
cache.put("product:detail:123", product, ttl = Duration.ofMinutes(10))
TTL을 짧게 잡으면 오래된 값이 줄어들지만 miss가 늘고, 길게 잡으면 hit율은 좋아지지만 값이 낡을 가능성이 커집니다.
즉, TTL은 성능 숫자가 아니라 일관성과 비용 사이의 타협점입니다.
TTL을 정할 때 보는 기준
- 데이터가 얼마나 자주 바뀌는가
- 오래된 값이 얼마나 큰 문제를 만드는가
- 원본 조회 비용이 얼마나 큰가
- 변경 이벤트로 무효화할 수 있는가
예를 들어:
| 데이터 | 예시 TTL | 생각해야 할 점 |
|---|---|---|
| 코드 테이블, 공통 설정 | 수십 분 ~ 수시간 | 자주 안 바뀌고 재조회 비용이 낮습니다 |
| 상품 상세, 프로필 | 수분 ~ 수십 분 | 변경은 가끔 있지만 조회 빈도는 높습니다 |
| 홈 화면 블록, 추천 결과 | 수십 초 ~ 수분 | 트래픽이 높고 지연 반영 허용 폭이 비교적 큽니다 |
| 재고, 가격, 권한 상태 | 짧게 또는 TTL 미의존 | 오래된 값의 위험이 커서 이벤트 무효화가 더 중요합니다 |
TTL에 정답 숫자는 없습니다. 다만 다음 원칙은 유효합니다.
- 변경이 드문 값은 길게
- 오래된 값이 위험한 값은 짧게 또는 무효화 중심으로
- 원본 조회가 매우 비싸면
TTL만 짧게 잡아서는 효과가 약합니다
Phase 6. 무효화는 왜 중요하고, 어떻게 해야 할까?
TTL만 믿고 운영하면 결국 "변경 직후에 오래된 값이 계속 보인다"는 문제가 생깁니다. 이때 필요한 것이 캐시 무효화(invalidation) 입니다.
가장 단순한 방법: 변경 후 삭제
1. 사용자 프로필을 DB에 반영한다
2. 트랜잭션 커밋이 끝난다
3. user:profile:{id} 캐시를 삭제한다
이 방식은 구현이 간단하고, 다음 조회에서 최신 값으로 다시 채워집니다.
관련 키가 여러 개면 어떻게 할까?
여기서부터 무효화가 어려워집니다. 사용자 프로필 하나가 아래 캐시에 동시에 들어 있을 수 있습니다.
user:profile:42feed:card:user:42ranking:top-sellers
그래서 무효화는 단순히 "키 하나 삭제"가 아니라 어떤 화면과 조회 결과가 그 값을 복제하고 있는지 추적하는 문제가 됩니다.
이런 경우에는 아래 방식이 자주 쓰입니다.
- 상세 캐시는 개별 키 삭제
- 목록/집계 캐시는 짧은
TTL로 타협 - 관리자 수정처럼 명확한 변경 이벤트는 메시지 기반 무효화
실제로 Discovery 캐시 글에서도 블록 수정 이벤트를 받아 선택적으로 캐시를 지우는 방식을 사용했습니다.
TTL과 무효화는 경쟁 관계가 아니다
둘은 보통 함께 씁니다.
TTL: 언젠가는 자연스럽게 사라지게 만드는 안전망- 무효화: 변경 직후 오래된 값을 빠르게 없애는 수단
즉, TTL만으로 최신성을 보장하려고 하지 말고, 최신성이 중요할수록 무효화를 더 적극적으로 붙여야 합니다.
Phase 7. 캐시에서 자주 망가지는 지점은 무엇일까?
캐시는 적용 자체보다 운영 중 함정이 더 많습니다.
1. 캐시를 붙였는데 hit가 안 나오는 경우
보통은 키 설계가 흔들린 경우가 많습니다.
- 요청마다 timestamp가 들어간다
- 정렬/필터 기본값 처리 방식이 일정하지 않다
- 사용자별로 달라야 할 응답을 공용 키로 저장한다
캐시는 "저장했다"보다 같은 요청이 같은 키로 다시 들어오는가가 중요합니다.
2. 캐시 미스가 한꺼번에 몰리는 경우
인기 키 TTL이 동시에 끝나면 여러 요청이 한꺼번에 원본 저장소로 몰릴 수 있습니다. 흔히 cache stampede라고 부르는 문제입니다.
00:00:00 인기 상품 캐시 만료
00:00:01 요청 100개가 동시에 miss
00:00:01 DB로 100개 요청이 몰림
이럴 때는 다음 같은 완화책을 검토합니다.
TTL에 약간의 랜덤 지터를 넣기- 아주 인기 있는 키는 선갱신(refresh)하기
- 한 요청만 재계산하고 나머지는 잠시 기다리게 하기
3. 존재하지 않는 값 때문에 원본을 계속 치는 경우
없는 상품 ID를 계속 조회하면 매번 캐시 miss 후 DB까지 내려갑니다. 이를 막기 위해 없는 결과를 아주 짧게 캐시하는 negative caching 을 적용하는 경우가 있습니다.
다만 이 방식은 실제로 값이 생길 수 있는 데이터와 섞이면 주의가 필요합니다. 많은 캐시 계층은 null 자체를 그대로 저장하지 않거나 miss와 구분하기 위해 별도 sentinel 값을 사용하므로, "없음"도 데이터라는 전제를 분명히 해야 합니다.
4. 직렬화 형식이 바뀌어 기존 캐시가 깨지는 경우
캐시는 값만 저장하는 것이 아니라 값의 형식도 함께 운영하는 문제입니다.
- 클래스 구조 변경
- 날짜 포맷 변경
- 컬렉션 타입 변경
- 다형성 직렬화 설정 변경
이런 문제는 Redis 직렬화 이슈 글처럼 실제 장애로 이어질 수 있습니다. 그래서 캐시도 스키마처럼 다뤄야 합니다.
한눈에 보는 선택 기준
지금까지 내용을 실무 기준으로 줄이면 이렇게 정리할 수 있습니다.
| 질문 | 기본 선택 |
|---|---|
| 읽기가 많고 변경은 드문가 | 캐시 검토 가치가 큽니다 |
| 오래된 값이 치명적인가 | TTL보다 무효화 중심으로 봅니다 |
| 어떤 키를 지워야 할지 추적 가능한가 | 개별 캐시 운영이 쉬워집니다 |
| 같은 조건의 요청이 반복되는가 | 캐시 hit 가능성이 높습니다 |
| 조건 조합이 너무 많은가 | 목록 캐시는 범위를 좁히거나 짧은 TTL로 제한합니다 |
정리
- 캐시는 반복되는 비싼 읽기를 줄이는 계층입니다 — 원본 저장소를 완전히 대체하는 것이 아닙니다
- 캐시 대상은 읽기 빈도, 조회 비용, 지연 허용 범위를 같이 봐야 합니다
- 대부분의 서비스는
Cache Aside부터 시작해도 충분합니다 — 읽을 때 채우고, 변경 시에는 보통 삭제하는 방식이 가장 단순합니다 - 좋은 캐시는
TTL보다 키 설계와 무효화가 더 중요합니다 —hit율과 운영 난이도가 여기서 갈립니다 TTL은 최신성을 보장하는 장치가 아니라 타협점입니다 — 최신성이 중요할수록 이벤트 기반 무효화를 함께 써야 합니다- 캐시는 운영 중 깨지는 지점까지 설계해야 합니다 —
cache stampede,negative caching, 직렬화 변경까지 함께 봐야 실무에서 버틸 수 있습니다