← 문제로

13. 캐시(Redis)와 DB 이중 쓰기 일관성 · C#

난이도 상
내 리뷰 · C#
해설 · C#

해설 — 캐시(Redis)와 DB 이중 쓰기 일관성 · C#

난이도: 상

답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계

요약

이 코드는 이중 쓰기(dual write) 의 전형적 함정에 모두 빠져 있다. (A) 캐시를 먼저 쓰고 DB를 나중에 쓰는데, 둘은 원자적이지 않다. (B) DB 쓰기가 실패하면 캐시에는 DB에 없는 값이 남아 영구 불일치가 된다. (C) 같은 캐릭터에 대한 두 갱신이 인터리브되면 캐시와 DB의 최종값이 서로 다르게 남을 수 있고, 애초에 newGold 를 호출자가 "읽고 계산"했다면 그 자체가 lost update 다. (D) read-through 의 미스 채움(DB read → cache set) 사이에 쓰기가 끼면 오래된 값이 캐시에 고정된다(언젠가 TTL 로만 풀림). 정답의 한 줄: DB를 단일 진실원본으로 삼아 원자 갱신(또는 버전/CAS)하고, 캐시는 "갱신"이 아니라 "무효화(delete)" 하며, 동시성은 분산 락/버전으로 막는다.


문제점

(A) 캐시 먼저, DB 나중 — 쓰기 순서/비원자 (일관성) ★간판

  • 증상: 부분 실패·동시성에서 캐시가 원본과 어긋난다.
  • 재현 조건: redis.Set 성공 후 db.Update 가 예외(타임아웃/제약위반)로 실패 → 캐시엔 새 값, DB엔 옛 값. 이후 읽기는 캐시의 (존재하지 않아야 할) 값을 신뢰.
  • 근본 원인: 두 저장소에 "둘 다 갱신" 하는 모델은 원자성이 없어 항상 어긋날 창이 있다. 원본을 먼저 확정하고 캐시는 무효화하는 방향이어야 한다.

(B) 부분 실패 시 불일치 영구화 — 내구성/일관성

  • (A)의 결과가 TTL 없는(또는 긴) 캐시에서 영구 고정. 보상 트랜잭션/무효화가 없다.

(C) 동시 갱신 lost update / 최종값 불일치 — 동시성 ★간판

  • 증상: 캐시는 200인데 DB는 100처럼 최종값이 갈리거나, 한 갱신이 사라진다.
  • 재현 조건:
    • 최종값 불일치: T1 Set(cache,100) → T2 Set(cache,200) → T2 db=200 → T1 db=100. 캐시=200, DB=100.
    • lost update: 호출자가 cur=GetGold(); UpdateGold(pid, cur+10) 식으로 계산하면, 두 호출자가 같은 cur 를 읽고 각자 +10 → 한 번만 반영(+10 유실).
  • 근본 원인: 갱신이 절대값 덮어쓰기이고 동시성 제어(락/버전/원자 증감)가 없다.

(D) read-through 의 stale set — 동시성 (일관성)

  • 증상: 캐시에 오래된 값이 박혀 TTL 까지 안 풀린다.
  • 재현 조건: 캐시 미스 → T_read 가 db.GetGold 로 옛값(100) 읽음 → 그 사이 T_write 가 DB=200 + 캐시 무효화 → T_read 가 redis.Set(cache,100) 으로 옛값을 캐시에 고정.
  • 근본 원인: 미스 채움과 쓰기 무효화의 순서 경쟁. (cache-aside 의 고전적 레이스)

(보조) 직렬화/파싱 견고성

  • long.Parse(cached) 가 손상/형식오류 값에서 예외. 캐시 값 스키마/버전·방어적 파싱 필요.

수정안

핵심: ① DB가 단일 진실원본, ② 쓰기는 DB 갱신 후 캐시 delete(무효화), ③ 동시성은 원자 증감 또는 버전/CAS, ④ read-through 의 stale set 는 버전/락/SETNX 로 방어.

public async Task AddGold(long playerId, long delta)   // 절대값 대신 증감(원자)
{
    // 1) 원본을 먼저 원자적으로 갱신 (DB의 UPDATE ... SET gold = gold + @delta)
    await _db.AddGoldAsync(playerId, delta);

    // 2) 캐시는 갱신이 아니라 '무효화' (다음 읽기에서 원본으로 재충전)
    await _redis.DeleteAsync(Key(playerId));
}

public async Task<long> GetGold(long playerId)
{
    var cached = await _redis.GetAsync(Key(playerId));
    if (cached != null && long.TryParse(cached, out var v)) return v;

    long val = await _db.GetGoldAsync(playerId);
    // stale set 방어: 짧은 TTL + (가능하면) 버전 기반 SET, 또는 단일 채움 락
    await _redis.SetWithTtlAsync(Key(playerId), val.ToString(), ttlSeconds: 30);
    return val;
}
  • 무효화(delete) vs 갱신(set): 동시성에서 "DB 후 캐시 delete" 가 "캐시 set" 보다 어긋날 창이 작다(set 은 옛 read-through 와 경쟁해 stale 고정 가능). 그래도 "DB write → cache delete" 도 미세 레이스가 있어, 짧은 TTL 을 안전망으로 둔다.
  • 절대값 갱신이 꼭 필요하면 낙관적 버전(예: version 컬럼 + WHERE version=@v)으로 lost update 를 막고, 캐시에는 (value,version) 을 저장해 더 낮은 버전의 set 을 거부한다.
  • 여러 서버 동시 갱신은 분산 락(Redlock/펜싱 토큰; concurrency_memory/problem12 참조) 또는 DB 원자 연산/트랜잭션으로 직렬화.

더 나은 설계

1) 무효화 신뢰성 — 아웃박스/CDC

  • "DB 커밋 + 캐시 무효화" 도 무효화 메시지 유실 가능. DB 트랜잭션에 아웃박스 레코드를 같이 커밋하고, CDC/메시지로 캐시 무효화를 at-least-once 보장. 트레이드오프: 인프라 복잡도↑, 그러나 부분 실패에 강함.

2) write-through / write-behind 의 선택

  • write-through(캐시→DB 동기)는 일관성↑ 지연↑. write-behind(캐시→DB 비동기)는 지연↓ 유실 위험↑. 골드 같은 재화는 원본 우선(DB) + 캐시 무효화 가 무난.

3) 단일 소유(샤딩) 모델

  • 한 캐릭터의 모든 변경을 특정 서버/액터가 소유하면 분산 동시성 자체가 사라진다(핸드오프 필요). MMO 필드 서버의 일반적 접근.

4) 캐시 값 스키마/버전

  • 캐시 페이로드에 스키마 버전·체크섬을 넣어 손상/구버전 파싱 사고를 방지. TTL 은 항상.

면접 포인트

  • 핵심: "두 저장소에 둘 다 쓰면 반드시 어긋난다" 를 인지하고, 진실원본 + 무효화 + 동시성 제어(버전/락/원자연산)로 재구성하는 능력.
  • 예상 질문:
    1. "캐시를 update 하지 말고 delete 하라는 이유는?" → 동시 read-through 와의 stale-set 레이스 창을 줄이고, 다음 읽기에서 원본으로 재충전.
    2. "그래도 'DB write → cache delete' 사이 레이스가 남는다. 어떻게?" → 짧은 TTL 안전망, 버전 기반 set 거부, 또는 아웃박스로 무효화 보장.
    3. "절대값 덮어쓰기 vs 원자 증감, 언제 무엇을?" → 재화 변동은 원자 증감/버전 CAS 로 lost update 방지. 절대값은 권위 계산이 단일 지점일 때만.