TroubleShooting & Study/Architecture & Design Pattern

[WIL] Round5. 캐시 설계 (읽기 전략, 갱신 전략, 장애 대응)

DH_0518 2026. 3. 15. 22:30

캐시는 빠르게 만들기 위한 도구이지만, 잘못 다루면 오히려 느리고 불안정한 구조를 만든다.
그래서 캐시는 기능이 아니라 '읽기 전략', '갱신 전략', '장애 대응'의 세 축으로 보는 편이 안전하다.


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. 실제 점검 순서는 짧게 유지한다

문제를 좁힐 때는 아래 순서가 가장 단순하다.

  1. 기준 원본이 맞는지 본다
  2. 조회용 데이터가 맞는지 본다
  3. 단건 캐시 문제인지 목록 캐시 문제인지 나눈다
  4. 이 변경이 어떤 응답을 바꾸는 유형인지 본다
  5. 즉시 반영 대상이 맞는지 확인한다
  6. 자연 만료로 허용한 구간인지 본다
  7. 같은 데이터 비적중이 몰렸는지 본다
  8. 캐시 저장소 장애 여부를 본다

예시 코드

void checkOrder() {
    checkSource();
    checkReadModel();
    checkItemCache();
    checkListCache();
    checkInvalidationScope();
}

정리

캐시는 결국 세 가지로 정리된다.

  • 읽기 전략: 무엇을 어디서 읽을 것인가
  • 갱신 전략: 언제 무엇을 다시 채울 것인가
  • 장애 대응: 비거나 느려질 때 어떻게 버틸 것인가

이 세 축만 분리해서 봐도 캐시 문제는 훨씬 빨리 좁혀진다.

캐시 문제를 좁히는 마지막 질문은 아래와 같다.

'정답은 어디에 있고, 어떤 시점에 어떤 응답이 달라져야 하는가'