← 문제로

1. 마스터-읽기복제본 복제 지연(read-your-writes 위반)

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

해설 — 마스터-읽기복제본 복제 지연(read-your-writes 위반)

난이도: 상

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

요약

UpdateAndEcho 가 마스터에 쓴 직후 복제본에서 다시 읽는다(A). 복제는 비동기라 그 시점 복제본엔 아직 옛 값이 있어 방금 쓴 값을 못 읽는다(read-your-writes 위반) — 닉네임을 바꿨는데 직후 응답엔 옛 닉네임이 나온다. 더 나쁜 건 (B) 그 stale 값을 캐시에 저장해, 복제가 따라잡은 뒤에도 캐시 TTL 동안 옛 값이 계속 반환된다(stale 고착). (C) GetProfile 의 캐시미스 read-through 도 복제본을 읽어 같은 stale 을 캐시에 굳힐 수 있다. 정답 한 줄: 방금 쓴 직후의 읽기는 마스터로 보내거나(read-your-writes), 복제 위치(LSN/버전)를 확인해 따라잡힌 복제본에서만 읽고, 캐시는 쓴 값으로 직접 갱신(write-through)하거나 무효화한다.

변별: concurrency_memory/problem13(캐시-DB 이중 쓰기의 일관성 — 같은 노드 캐시/DB 동기 갱신 순서)과 달리, 본 문제는 비동기 복제 지연으로 인한 read-your-writes 위반과 그 stale 의 캐시 고착이 핵심이다(마스터/복제본 분리 토폴로지).


문제점

(A) 쓰기 직후 복제본 읽기 — read-your-writes 위반 (정합/일관성) ★간판

  • 분류 태그: replication-lag / stale-read.
  • 증상: WriteMaster 직후 ReadReplica 는 복제 지연(수십~수백 ms) 동안 옛 값을 돌려줄 수 있다. 사용자가 방금 바꾼 값을 자기 화면에서 못 보는 전형적 일관성 버그. 변경 성공 응답에 옛 값이 실려 "저장이 안 됐나?" 오해를 부른다.
  • 재현조건: write 직후, 복제가 따라잡기 전(고부하·지연 시 흔함)에 같은 키를 복제본에서 읽음.
  • 근본 원인: 비동기 복제 토폴로지에서 "자기 쓰기" 직후 읽기를 복제본으로 보냄.

(B) stale 값 캐싱 — stale 고착 (정합/캐시) ★간판

  • 분류 태그: cache-poisoning-with-stale.
  • 증상: (A)로 읽은 옛 값을 _cache 에 저장한다. 복제가 곧 따라잡아도 캐시는 갱신되지 않아 TTL/무효화 전까지 계속 옛 값을 반환한다. 짧은 불일치가 장기 불일치로 증폭된다.
  • 근본 원인: "쓴 값" 이 아니라 "복제본에서 읽은(신뢰 못 할) 값" 으로 캐시를 채운다.

(C) read-through 캐시미스 경합 — stale 재고착 (정합)

  • 증상: GetProfile 캐시미스 시 복제본을 읽는데, 그 직전 다른 경로가 마스터에 새 값을 썼다면 복제 지연으로 옛 값을 읽어 다시 캐시에 굳힐 수 있다.
  • 근본 원인: 캐시 채움의 소스가 지연된 복제본이고, 일관성 토큰/버전 확인이 없다.

(참고) _cache 동시성

  • Dictionary 캐시가 여러 요청 스레드에서 동시 접근되면 손상/예외. 동시성 컬렉션/락 필요.

수정안

핵심: ① 방금 쓴 직후 읽기는 마스터 또는 버전 확인된 복제본, ② 캐시는 쓴 값으로 직접 갱신(write-through) 또는 무효화, ③ read-your-writes 윈도우 관리.

public string UpdateAndEcho(long playerId, string key, string value)
{
    _db.WriteMaster(playerId, key, value);

    // (A) 방금 쓴 값은 마스터에서 읽거나(read-your-writes), 애초에 쓴 값 자체를 사용
    //     네트워크 한 번 더 아끼려면 'value' 를 그대로 신뢰해도 됨(마스터가 받아들인 값).
    string authoritative = value;   // 또는 _db.ReadMaster(playerId, key);

    // (B) 캐시는 '쓴 값' 으로 직접 갱신(write-through) — stale 을 채우지 않는다
    var map = GetOrAddMap(playerId);
    map[key] = authoritative;

    return authoritative;
}

public string GetProfile(long playerId, string key)
{
    if (TryCache(playerId, key, out var v)) return v;

    // (C) 캐시미스 read-through: 일반 읽기는 복제본 OK. 단,
    //     '최근에 이 키를 쓴' 플레이어면 read-your-writes 윈도우 동안 마스터로.
    string fromDb = RecentlyWrote(playerId, key)
        ? _db.ReadMaster(playerId, key)
        : _db.ReadReplica(playerId, key);

    SetCache(playerId, key, fromDb);
    return fromDb;
}

포인트

  • "방금 쓴 값" 은 마스터가 수락한 value 자체 또는 ReadMaster 로 — 복제본의 옛 값을 믿지 않는다.
  • 캐시는 권위 있는 값(쓴 값) 으로 채운다(write-through) → stale 고착 차단.
  • read-your-writes 윈도우(예: 쓰기 후 N초, 플레이어/키 단위)에는 읽기를 마스터로 라우팅하거나 복제본의 복제 위치를 확인.
  • _cacheConcurrentDictionary/락으로 보호.

더 나은 설계 (+트레이드오프)

  1. 복제 위치(LSN/GTID) 기반 일관성 읽기: 쓰기가 반환한 LSN 을 들고, 그 LSN 이상 따라잡은 복제본에서만 읽는다(또는 따라잡을 때까지 대기). 트레이드오프: 클라/세션에 LSN 토큰 전파 필요.
  2. 세션 일관성(sticky to master) 윈도우: 쓰기 직후 일정 시간 그 세션의 읽기를 마스터로. 단순하지만 마스터 부하↑. 윈도우 크기 튜닝 필요.
  3. write-through / write-around 캐시 정책 명확화: 쓰기 시 캐시를 권위값으로 갱신하거나 무효화. stale 을 절대 캐시에 넣지 않음. 트레이드오프: 캐시 무효화 복잡도.
  4. 단조 읽기(monotonic reads): 한 사용자가 같은 데이터에 대해 더 옛 값을 보지 않도록 복제본 고정/버전 비교. 트레이드오프: 라우팅 상태 관리.

면접 포인트 (예상 질문)

  1. read-your-writes 일관성이란? 마스터-복제본 구조에서 왜 깨지고, 어떻게 보장하는가 (마스터 라우팅 / LSN 대기 / 쓴 값 사용)?
  2. 복제 지연으로 읽은 stale 값을 캐시에 넣으면 왜 "짧은 불일치"가 "긴 불일치"로 커지나? write-through 가 어떻게 막는가?
  3. 모든 읽기를 마스터로 보내면 정확하지만 왜 안 되나? 복제본 분산과 정확성을 어떻게 절충 하겠는가(윈도우/토큰)?