Cache Aside 패턴과 캐시 무효화

Cache Aside 패턴과 캐시 무효화

한눈에 보기

Cache Aside의 일반적인 쓰기 순서는 DB 트랜잭션 커밋 후 캐시 삭제다. 캐시를 먼저 삭제하면 DB 변경 전에 다른 요청이 구값을 다시 채울 수 있다. 그러나 커밋 후 삭제도 삭제 실패와 지연된 read가 구값을 다시 채우는 race를 완전히 없애지는 못한다. TTL, 재시도 가능한 invalidation event, 데이터 version과 업무상 stale 허용 시간을 함께 설계해야 한다.

목차

문제가 되는 상황

사용자 프로필을 10분 동안 캐시한다고 하자.

async function getUserProfile(userId: number): Promise<UserProfile | null> {
  const key = `cache:user-profile:v2:${userId}`;
  const cached = await redis.get(key);

  if (cached) {
    return decodeProfile(cached);
  }

  const profile = await userRepository.findProfile(userId);

  if (profile) {
    await redis.set(key, encodeProfile(profile), { EX: 600 });
  }

  return profile;
}

프로필 수정은 DB만 바꾼다.

await userRepository.updateProfile(userId, input);

DB에는 새 이름이 저장됐지만 Redis에는 TTL이 끝날 때까지 예전 이름이 남는다. 이를 해결하려고 캐시 삭제를 추가한다.

await userRepository.updateProfile(userId, input);
await redis.del(profileCacheKey(userId));

이 순서가 일반적인 출발점이지만, 두 저장소를 하나의 원자적 트랜잭션으로 묶지 않는 이상 실패 구간은 남는다. 중요한 것은 “완벽한 한 줄”을 찾는 것이 아니라 가능한 경쟁과 실패를 나열하고 stale 허용 시간 안에서 복구되게 만드는 것이다.

Cache Aside의 읽기와 쓰기 책임

Cache Aside에서는 애플리케이션이 두 경로를 직접 조정한다.

읽기:

1. Redis GET
2. hit면 반환
3. miss면 DB SELECT
4. 결과를 Redis SET
5. 반환

쓰기:

1. DB를 authoritative source로 갱신
2. 관련 cache key 무효화
3. 다음 읽기가 최신 DB 값으로 다시 채움
flowchart LR
    W[Write Request] --> D[(Database Commit)]
    D --> I[Cache Invalidation]
    I --> R[Next Read Miss]
    R --> D2[(Read New DB Value)]
    D2 --> C[(Populate Cache)]

이 구조에서는 cache value가 DB에서 다시 계산 가능한 파생 데이터다. Redis 전체가 비어도 DB로 복구할 수 있어야 한다. 이 관계는 Redis를 캐시로 사용할 때 원본 데이터와의 관계에서 먼저 정리했다.

쓰기 경로의 목표는 다음 두 가지다.

  1. DB 변경이 실패했는데 캐시만 새 상태를 보여 주지 않는다.
  2. DB 변경이 성공했는데 캐시가 오래된 상태로 무기한 남지 않는다.

캐시를 먼저 삭제하면 생기는 경쟁

“사용자가 수정 요청을 했으니 우선 캐시부터 지우자”라고 구현할 수 있다.

await redis.del(profileCacheKey(userId));
await userRepository.updateProfile(userId, input);

두 요청이 겹치면 구값이 다시 채워진다.

sequenceDiagram
    participant W as Write Request
    participant R as Read Request
    participant C as Redis
    participant D as Database
    W->>C: DEL profile
    R->>C: GET profile
    C-->>R: miss
    R->>D: SELECT profile
    D-->>R: old value
    R->>C: SET old value
    W->>D: UPDATE new value, COMMIT
    Note over C,D: cache에는 old, DB에는 new

캐시 삭제와 DB UPDATE 사이에 read가 들어와 이전 DB 값을 다시 캐시한다. write가 끝난 뒤 두 번째 삭제가 없다면 TTL 동안 stale value가 남는다.

DB UPDATE가 실패할 수도 있다.

cache 삭제 성공
DB UPDATE 실패
→ 정합성은 DB 기준으로 유지되지만 불필요한 miss와 부하 발생

이 결과는 잘못된 데이터보다 낫지만 선삭제는 stale 재적재 race를 넓힌다. 그래서 Cache Aside에서는 보통 DB 커밋 후 삭제를 기본으로 둔다.

DB 커밋 후 캐시 삭제가 기본인 이유

먼저 authoritative source를 확정한다.

async function updateUserProfile(
  userId: number,
  input: UpdateProfileInput,
): Promise<void> {
  await database.transaction(async (tx) => {
    await tx.users.updateProfile(userId, input);
    await tx.auditLogs.insert({
      userId,
      action: "PROFILE_UPDATED",
    });
  });

  await redis.del(profileCacheKey(userId));
}

DB UPDATE가 롤백되면 cache는 기존 DB 값과 같은 상태이므로 굳이 바꿀 필요가 없다. 커밋이 성공한 뒤 삭제하면 다음 read는 새 DB 값을 채운다.

sequenceDiagram
    participant W as Write Request
    participant C as Redis
    participant D as Database
    participant R as Next Read
    W->>D: UPDATE profile
    D-->>W: COMMIT
    W->>C: DEL profile
    C-->>W: deleted
    R->>C: GET profile
    C-->>R: miss
    R->>D: SELECT profile
    D-->>R: new value
    R->>C: SET new value
삭제는 커밋 이후에 실행한다

트랜잭션 함수 안에서 cache delete를 호출하면 DB가 나중에 롤백되거나 잠금 보유 시간이 외부 I/O만큼 늘어난다. DB 트랜잭션과 Redis 명령의 경계를 분리한다.

커밋 뒤 캐시 삭제가 실패하는 구간

DB와 Redis는 하나의 로컬 트랜잭션에 속하지 않는다.

DB COMMIT 성공
↓
프로세스 종료 또는 네트워크 오류
↓
Redis DEL 미실행·실패

DB에는 new, cache에는 old가 남는다.

sequenceDiagram
    participant A as Application
    participant D as Database
    participant C as Redis
    A->>D: UPDATE
    D-->>A: COMMIT success
    Note over A: process crash
    A-xC: DEL never sent

단순히 cache delete를 try/catch하고 로그만 남기면 TTL만이 복구 수단이다.

await userRepository.updateProfile(userId, input);

try {
  await redis.del(profileCacheKey(userId));
} catch (error) {
  logger.error({ userId, error }, "cache invalidation failed");
}

이 방식도 stale 허용 시간이 짧지 않고 실패율이 매우 낮다면 실용적일 수 있다. 그러나 권한·가격처럼 10분 stale이 큰 문제라면 로그만으로 부족하다.

재시도 큐에 넣는 코드도 enqueue 자체가 DB 커밋과 원자적이지 않으면 같은 gap이 있다.

DB commit
process crash
queue enqueue

이 gap을 없애기 위한 방법이 transactional outbox다.

느린 Cache Miss가 구값을 다시 채우는 경쟁

DB 커밋 후 cache delete 순서를 지켜도 더 미묘한 race가 있다.

  1. Read A가 cache miss를 본다.
  2. Read A가 DB에서 old value를 읽고 응답이 지연된다.
  3. Write B가 DB를 new value로 갱신하고 cache를 삭제한다.
  4. Read A가 뒤늦게 old value를 cache에 SET한다.
sequenceDiagram
    participant A as Slow Read A
    participant B as Write B
    participant C as Redis
    participant D as Database
    A->>C: GET
    C-->>A: miss
    A->>D: SELECT
    D-->>A: old value
    Note over A: network or processing delay
    B->>D: UPDATE new value
    D-->>B: COMMIT
    B->>C: DEL key
    A->>C: SET old value
    Note over C,D: 삭제 뒤 구값이 다시 들어감

DB의 일반 SELECT가 트랜잭션 snapshot에서 old value를 읽었거나, 읽은 뒤 SET까지 애플리케이션이 지연되면 가능하다. 빈도는 낮을 수 있지만 hot key와 느린 쿼리에서는 재현할 수 있다.

이 문제를 완화하는 선택지는 다음과 같다.

어느 것도 공짜가 아니다. 필요한 정합성 수준과 실제 race 빈도로 선택한다.

TTL은 최종 안전망이지 무효화 전략의 대체가 아니다

캐시 삭제가 실패하거나 old value가 재적재돼도 TTL이 있으면 stale 상태는 무기한 지속되지 않는다.

cache stale at 10:00
TTL 5 minutes
→ 늦어도 10:05 이후 miss와 refresh

그러나 “어차피 5분 후 갱신된다”가 모든 데이터에 허용되는 것은 아니다.

공개 프로필 이름: 5분 stale 허용 가능
상품 가격: 프로모션 정책에 따라 수 초만 허용
계정 차단: stale 권한 허용 불가
재고 확정: cache를 최종 판정에 사용하지 않음

TTL을 데이터별 stale budget으로 관리한다.

const cachePolicy = {
  publicProfile: { ttlSeconds: 600, staleBudgetSeconds: 600 },
  catalogPrice: { ttlSeconds: 30, staleBudgetSeconds: 30 },
  authorization: { ttlSeconds: 5, staleBudgetSeconds: 0 },
} as const;

authorization처럼 stale budget이 0인데 cache invalidation을 완전히 보장할 수 없다면 cache 사용 범위를 줄이거나 원본을 재확인한다.

TTL만 사용하고 쓰기 때 무효화를 전혀 하지 않으면 write 직후 항상 최대 TTL만큼 stale할 수 있다. write-through invalidation과 TTL 안전망을 함께 두는 이유다.

캐시 값을 갱신하기보다 삭제를 선호하는 이유

DB update 후 cache에 새 값을 SET할 수도 있다.

const updated = await userRepository.updateProfile(userId, input);
await redis.set(
  profileCacheKey(userId),
  encodeProfile(toProfileView(updated)),
  { EX: 600 },
);

하지만 cache value가 여러 테이블의 JOIN, 권한, locale과 계산 결과를 포함하면 write 요청의 반환 객체만으로 정확한 cache representation을 만들기 어렵다.

profile cache =
user row
+ team name
+ avatar URL
+ plan display
+ feature flags

user row 하나를 갱신한 결과로 전체 cache를 덮으면 다른 구성 요소가 누락되거나 오래될 수 있다. 삭제하면 다음 read가 현재 source들을 다시 조회해 일관된 projection을 만든다.

동시 write 순서도 문제다.

sequenceDiagram
    participant A as Write A
    participant B as Write B
    participant D as Database
    participant C as Redis
    A->>D: name=Alice
    B->>D: name=Bob
    A->>C: SET Alice (지연)
    B->>C: SET Bob
    A->>C: SET Alice 완료
    Note over C,D: DB Bob, cache Alice

삭제 역시 순서 문제를 완전히 없애지는 않지만 뒤늦은 delete는 miss를 만들 뿐 구값을 직접 저장하지 않는다. 다음 read가 DB 최신 상태를 채운다.

새 값을 SET할 수 있는 경우

cache representation이 한 row와 정확히 대응하고 version 비교가 가능하며 write 순서를 통제할 수 있다면 update-on-write가 합리적일 수 있다. 기본 선택은 단순한 delete이고 측정 후 바꾼다.

Version을 이용해 오래된 값의 덮어쓰기를 막기

DB row에 단조 증가 version을 둔다.

UPDATE users
SET display_name = :name,
    version = version + 1
WHERE id = :id;

캐시 envelope에도 version을 넣는다.

type CachedProfile = {
  schemaVersion: 2;
  dataVersion: number;
  value: UserProfile;
};

오래된 read가 version 7을 채우려 할 때 cache에 version 8이 있으면 거부한다. Redis Lua script로 compare-and-set을 원자적으로 만들 수 있다.

local current = redis.call("GET", KEYS[1])

if current then
  local decoded = cjson.decode(current)
  if decoded.dataVersion > tonumber(ARGV[1]) then
    return 0
  end
end

redis.call("SET", KEYS[1], ARGV[2], "EX", ARGV[3])
return 1

호출 예시는 의사 코드다.

await cache.setIfVersionNotOlder({
  key,
  dataVersion: profile.version,
  encodedValue: encodeProfile(profile),
  ttlSeconds: 600,
});

주의할 점:

단일 entity cache에는 적용하기 쉽지만 복합 목록에는 version 관리가 복잡하다.

Outbox로 무효화 이벤트를 잃지 않기

DB 변경과 invalidation 의도를 같은 트랜잭션에 저장한다.

BEGIN;

UPDATE users
SET display_name = :name,
    version = version + 1
WHERE id = :user_id;

INSERT INTO outbox_events (
  event_id,
  aggregate_type,
  aggregate_id,
  event_type,
  payload,
  occurred_at
) VALUES (
  :event_id,
  'USER',
  :user_id,
  'USER_PROFILE_CHANGED',
  jsonb_build_object('userId', :user_id),
  CURRENT_TIMESTAMP
);

COMMIT;

별도 publisher가 이벤트를 전달하고 cache invalidator가 key를 삭제한다.

flowchart LR
    A[DB transaction] --> U[users update]
    A --> O[outbox insert]
    O --> P[Publisher]
    P --> Q[Message Broker]
    Q --> I[Cache Invalidator]
    I --> R[(Redis DEL)]

DB commit이 됐다면 outbox row도 남으므로 프로세스가 직후 죽어도 publisher가 나중에 처리할 수 있다. 전달은 중복될 수 있으므로 invalidation consumer는 멱등해야 한다. DEL은 key가 없어도 안전해 멱등 처리와 잘 맞는다.

async function handleProfileChanged(event: ProfileChangedEvent) {
  await redis.del(profileCacheKey(event.userId));
}

outbox가 해결하는 것은 이벤트 유실 gap이다. DB commit과 cache delete 사이의 짧은 stale window, 메시지 지연과 old read 재적재 가능성은 남는다. TTL과 version을 함께 사용한다.

자세한 publisher·중복·보관 설계는 Outbox Pattern으로 DB와 이벤트 발행 일치시키기에서 이어진다.

Delayed Double Delete는 언제 고려할까

쓰기 후 즉시 delete하고, 지연 뒤 다시 delete해 늦은 old read가 채운 값을 제거하는 패턴이 있다.

await userRepository.updateProfile(userId, input);
await redis.del(key);

await delayedInvalidationQueue.enqueue({
  key,
  runAfterMs: 500,
});
DB commit
→ immediate delete
→ old slow read SET
→ delayed delete가 stale 제거

지연 시간은 old read가 끝나는 시간보다 길어야 하지만 고정값으로 완전한 보장을 할 수 없다. 500ms보다 느린 read가 있으면 다시 race가 남고, 너무 길면 stale window가 길어진다.

또한 프로세스의 setTimeout()만 사용하면 재시작 때 두 번째 delete가 사라진다.

// 신뢰할 수 없는 예
setTimeout(() => redis.del(key), 500);

durable delayed queue나 outbox의 예정 이벤트로 실행해야 한다. double delete는 간단한 완화책이지 strong consistency를 만드는 알고리즘은 아니다. 실제 stale 재적재가 관측되고 짧은 TTL만으로 부족할 때 제한적으로 고려한다.

여러 캐시와 목록 캐시 무효화

사용자 이름 변경이 영향을 주는 key가 하나가 아닐 수 있다.

profile:user:42
team-members:team:10:page:1
post-detail:501
search-result:query:...

모든 파생 key를 정확히 열거하기 어려워진다. 선택지는 다음과 같다.

Entity cache만 무효화

목록에는 ID만 캐시하고 각 entity를 별도 cache에서 가져온다. 여러 round trip과 조립 비용이 생길 수 있다.

Tag 또는 dependency index

user:42가 포함된 cache key 목록을 별도 set으로 관리하고 일괄 삭제한다. dependency set 자체의 정리와 race가 필요하다.

cache-deps:user:42
→ profile:user:42
→ team-members:team:10:v7
→ post-detail:501:v3

Namespace version

팀 목록 namespace version을 증가시키고 key에 version을 포함한다.

team:10:list-version = 8
cache:team-members:team-10:v8:page-1

이전 key는 TTL로 사라진다. 일괄 delete가 필요 없지만 version 조회가 추가되고 orphan key가 잠시 남는다.

목록은 짧은 TTL만 사용

정확한 무효화가 너무 복잡하고 stale 허용이 가능하다면 목록 TTL을 짧게 둔다. entity 상세은 명시적으로 삭제한다.

캐시 그래프가 복잡해질수록 “DB 쿼리가 실제로 병목인가?”를 다시 묻는다. 무효화 시스템의 운영 비용이 절약한 DB 비용보다 클 수 있다.

Write Through와 Write Behind 비교

Cache Aside 외에도 쓰기 패턴이 있다.

패턴 쓰기 흐름 장점 위험·비용
Cache Aside DB write → cache invalidate DB가 명확한 원본, 단순 stale window와 invalidation 실패
Write Through cache 계층이 DB와 함께 동기 write 읽기 cache가 즉시 최신 cache 계층이 write path 병목·실패점
Write Behind cache write 후 비동기로 DB 반영 낮은 write latency, batch 가능 cache 손실 시 원본 유실, 순서·복구 복잡
Write Around DB에 쓰고 cache는 read miss 때 채움 write로 cache 오염 적음 첫 read miss, invalidation 필요

Write Behind는 Redis가 임시 source of truth 역할을 하므로 단순 cache보다 durability 요구가 높다.

Client write
→ Redis success
→ DB async write 전에 Redis loss
→ 데이터 영구 손실

이 패턴이 필요한 성능 요구가 명확하지 않다면 Cache Aside 또는 write-through DB를 우선한다.

정합성 요구가 높은 데이터는 캐시 범위를 줄인다

Cache Aside는 bounded staleness를 허용하는 읽기에 잘 맞는다. 다음 최종 판정에는 source DB를 사용한다.

재고

화면에는 캐시된 근사 수량을 보여도 주문 성공은 원자적 조건 UPDATE로 결정한다.

UPDATE products
SET stock = stock - :quantity
WHERE id = :product_id
  AND stock >= :quantity;

권한

메뉴 표시에는 캐시를 사용할 수 있지만 민감한 write API는 현재 권한 또는 짧은 version token을 재검증한다.

결제

캐시된 상태를 보고 중복 결제를 실행하지 않는다. idempotency key와 원본 payment 상태를 확인한다.

사용량 한도

Redis atomic counter를 원본으로 선택할 수 있지만 key 유실과 reconciliation 정책을 명시해야 한다. DB cache와는 다른 데이터 모델이다.

stale을 허용할 수 없는데 두 시스템을 원자적으로 묶을 수 없다면 cache hit를 최종 결정에 사용하지 않는다

성능을 위해 조회 화면만 캐시하고 mutation은 authoritative operation을 거치게 한다.

동시성 테스트와 운영 지표

정상 hit/miss 테스트만으로 invalidation race를 검증할 수 없다.

선삭제 race 재현

it("DB commit 전 읽기가 구값을 채울 수 있음을 재현한다", async () => {
  await cache.delete(key);

  const slowRead = readProfileWithPauseAfterDb();
  await waitUntilDbReadCompleted();

  await updateProfileInDb(newProfile);
  resumeCachePopulate();
  await slowRead;

  expect(await cache.get(key)).toEqual(oldProfile);
});

커밋 후 delete 실패

it("무효화 실패 이벤트를 durable queue에서 재시도한다", async () => {
  redis.failNextCommand("DEL");

  await updateProfile({ userId: 42, name: "new-name" });
  await invalidationWorker.runPending();

  expect(await redis.get(profileCacheKey(42))).toBeNull();
});

old version overwrite 방지

it("낮은 dataVersion은 새 cache를 덮지 않는다", async () => {
  await cache.setVersioned(key, { dataVersion: 8, value: newProfile });
  await cache.setVersioned(key, { dataVersion: 7, value: oldProfile });

  expect(await cache.get(key)).toMatchObject({ dataVersion: 8 });
});

운영 지표:

지표 의미
invalidation attempts/failures 삭제 성공률
invalidation lag DB commit부터 cache delete까지 시간
outbox backlog/oldest age 무효화 이벤트 지연
cache hit after write 수정 직후 stale hit 의심
version reject count 오래된 populate 경쟁 빈도
cache miss/load latency refill 비용
DB fallback QPS invalidation·expiry가 만든 부하
stale report 사용자 관측 정합성 문제

분산 trace에 DB commit, event publish, Redis DEL과 다음 read 시각을 같은 correlation ID로 연결하면 rare race를 분석하기 쉽다.

결론

Cache Aside 쓰기의 기본 순서는 authoritative DB를 커밋한 뒤 cache key를 삭제하는 것이다. 선삭제는 DB 변경 전 read가 old value를 다시 채우는 race를 만든다. 삭제 대신 새 값을 SET하면 복합 projection과 동시 write 순서 때문에 더 오래된 값이 마지막에 저장될 수 있어, 다음 read가 원본에서 재구성하게 하는 delete가 보통 단순하다.

그러나 DB commit 후 delete도 완벽한 원자성을 제공하지 않는다. 프로세스가 gap에서 죽으면 invalidation이 유실되고, 이미 DB old value를 읽은 느린 miss가 삭제 뒤 stale 값을 다시 채울 수 있다. TTL로 stale 상한을 두고, 중요 데이터에는 outbox 기반 재시도, data version compare-and-set과 필요 시 delayed delete를 조합한다. stale을 허용할 수 없는 재고·권한·결제의 최종 판정은 cache가 아니라 authoritative operation이 담당해야 한다.

관련 노트

참고 자료