13. 캐시(Redis)와 DB 이중 쓰기 일관성 · 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)→ T2Set(cache,200)→ T2db=200→ T1db=100. 캐시=200, DB=100. - lost update: 호출자가
cur=GetGold(); UpdateGold(pid, cur+10)식으로 계산하면, 두 호출자가 같은cur를 읽고 각자 +10 → 한 번만 반영(+10 유실).
- 최종값 불일치: T1
- 근본 원인: 갱신이 절대값 덮어쓰기이고 동시성 제어(락/버전/원자 증감)가 없다.
(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 은 항상.
면접 포인트
- 핵심: "두 저장소에 둘 다 쓰면 반드시 어긋난다" 를 인지하고, 진실원본 + 무효화 + 동시성 제어(버전/락/원자연산)로 재구성하는 능력.
- 예상 질문:
- "캐시를 update 하지 말고 delete 하라는 이유는?" → 동시 read-through 와의 stale-set 레이스 창을 줄이고, 다음 읽기에서 원본으로 재충전.
- "그래도 'DB write → cache delete' 사이 레이스가 남는다. 어떻게?" → 짧은 TTL 안전망, 버전 기반 set 거부, 또는 아웃박스로 무효화 보장.
- "절대값 덮어쓰기 vs 원자 증감, 언제 무엇을?" → 재화 변동은 원자 증감/버전 CAS 로 lost update 방지. 절대값은 권위 계산이 단일 지점일 때만.
해설 — 캐시(Redis)와 DB 이중 쓰기 일관성 · C++
난이도: 상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
C# 판과 동일한 이중 쓰기(dual write) 결함이다. (A) 캐시를 먼저, DB를 나중에 쓰는데
원자적이지 않다. (B) db_.UpdateGold 가 예외를 던지면(네트워크 호출) 캐시엔 새 값,
DB엔 옛 값이 남아 영구 불일치 — C++ 에선 예외가 함수를 빠져나가며 캐시 롤백도 없다.
(C) 동시 갱신 시 최종값 불일치/lost update, (D) read-through 미스 채움과 쓰기의 경쟁으로
stale 값이 캐시에 고정된다. C++ 고유로 (E) std::stoll(*cached) 가 손상/비정수 캐시
값에서 std::invalid_argument/out_of_range 예외를 던지고, 동기 호출이 스레드를 막는다.
정답의 한 줄: DB를 단일 진실원본으로 원자 갱신(버전/CAS)하고 캐시는 무효화하며, 부분
실패와 stale-set 을 TTL·버전·분산 락으로 방어.
문제점
(A) 캐시 먼저, DB 나중 — 쓰기 순서/비원자 (일관성) ★간판
redis_.Set후db_.UpdateGold. 둘 사이엔 원자성이 없어 항상 어긋날 창이 있다. 원본을 먼저 확정하고 캐시는 무효화하는 방향이어야 한다.
(B) 부분 실패 시 불일치 영구화 — 내구성/예외안전 (일관성)
- 증상: 캐시엔 새 값, DB엔 옛 값이 영구히 남는다.
- 재현 조건:
db_.UpdateGold가 예외(타임아웃/제약위반). 예외가UpdateGold를 빠져나가지만 캐시는 이미 바뀐 뒤라 보상(rollback)이 없다. - 근본 원인: 두 저장소 갱신에 트랜잭션/보상이 없고 순서도 잘못됐다.
(C) 동시 갱신 lost update / 최종값 불일치 — 동시성 ★간판
- 최종값 불일치: T1
Set(cache,100)→T2Set(cache,200)→T2db=200→T1db=100→ 캐시=200, DB=100. 호출자가 read 후newGold를 계산했다면 그 자체가 lost update. - 절대값 덮어쓰기 + 동시성 제어 부재가 원인.
(D) read-through stale set — 동시성 (일관성)
- 미스 → T_read 가
db_.GetGold로 옛값 읽음 → 그 사이 T_write 가 DB 갱신+캐시 무효화 → T_read 가 옛값을redis_.Set으로 고정. TTL 없으면 영구.
(E) 파싱/블로킹 견고성 — C++ 고유 (견고성)
std::stoll(*cached)는 손상/비정수 값에서 예외를 던진다(서비스 중단 가능). 방어적 파싱 필요. 또한 동기 Redis/DB 호출이 호출 스레드를 막아 처리량 저하 — 비동기/풀링 고려.
수정안
void AddGold(int64_t playerId, int64_t delta) { // 절대값 대신 원자 증감
// 1) 원본을 먼저 원자적으로 갱신: DB의 UPDATE ... SET gold = gold + delta
db_.AddGold(playerId, delta);
// 2) 캐시는 갱신이 아니라 무효화 (다음 읽기에서 원본으로 재충전)
redis_.Del(Key(playerId));
}
int64_t GetGold(int64_t playerId) {
if (auto cached = redis_.Get(Key(playerId))) {
int64_t v;
if (TryParseI64(*cached, v)) return v; // (E) 방어적 파싱
redis_.Del(Key(playerId)); // 손상 값은 버린다
}
int64_t val = db_.GetGold(playerId);
redis_.SetWithTtl(Key(playerId), std::to_string(val), /*ttlSec=*/30); // (D) TTL 안전망
return val;
}
- 무효화(Del) vs 갱신(Set): 동시 read-through 와의 stale-set 레이스를 줄이려면 "DB 후 캐시 Del" 이 낫다. 그래도 미세 레이스가 남으니 짧은 TTL 을 둔다.
- 절대값 갱신이 필요하면
version컬럼 +WHERE version=@v(CAS)로 lost update 방지, 캐시엔(value,version)저장해 낮은 버전 set 거부. - 여러 서버 동시 갱신은 분산 락(펜싱 토큰; concurrency_memory/problem12 참조) 또는 DB 원자 연산으로 직렬화.
더 나은 설계
1) 무효화 신뢰성 — 아웃박스/CDC
- DB 트랜잭션에 아웃박스 레코드를 함께 커밋하고 CDC/메시지로 캐시 무효화를 at-least-once 보장. 부분 실패에 강함(트레이드오프: 인프라 복잡도).
2) 동기 → 비동기/배치
- 동기 호출이 스레드를 막는다. 비동기 클라이언트·파이프라이닝·연결 풀로 처리량 확보.
3) 단일 소유(샤딩)
- 캐릭터별 변경을 특정 서버/액터가 소유하면 분산 동시성이 사라진다(핸드오프 필요).
4) 캐시 스키마/버전
- 캐시 페이로드에 스키마 버전·체크섬, 항상 TTL. 손상/구버전 파싱 사고 방지.
면접 포인트
- 핵심: 이중 쓰기는 반드시 어긋난다 → 진실원본 + 무효화 + 동시성 제어. C++ 에선 예외 안전(부분 갱신 롤백 부재)·방어적 파싱·블로킹 호출도 함께 본다.
- 예상 질문:
- "DB 쓰기가 예외면 캐시는? 예외 안전을 어떻게 보장하나?" → 원본 우선·캐시 무효화로 순서를 바꾸고, 실패 시 캐시는 어차피 무효(다음 읽기 재충전).
- "캐시를 update 말고 delete 하라는 이유는?" → 동시 read-through 의 stale-set 레이스 완화 + 원본 재충전.
- "
std::stoll이 던지면?" → 방어적 파싱으로 손상 값 폐기, 서비스 중단 방지.
구문 검증:
g++ -std=c++17 -fsyntax-only problem.cpp통과.