← 문제로

20. 캐릭터 삭제/복구와 진행 중 비동기 저장의 경합

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

해설 — 캐릭터 삭제/복구와 진행 중 비동기 저장의 경합

난이도: 최상

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

요약

삭제/복구/퍼지와 느린 비동기 저장(UPSERT) 이 동기화·순서·버전 보장 없이 경합한다. (A) SaveAsync 는 메모리 스냅샷을 떠 느린 Upsert(없으면 insert) 를 하는데, 그 사이 Delete+MarkDeleted 가 끝나면 늦게 도착한 UPSERT 가 삭제된 행을 다시 insert → 좀비 부활(삭제 유실). (B) MarkDeleted 와 in-flight Upsert 사이에 순서/펜싱이 없어 최종 DB 상태가 레이스로 결정되고, _live 는 락 없는 Dictionary 라 손상 위험. (C) Restore 가 캐릭터를 되살려도 Delete 가 건 퍼지 타이머가 유예 만료 후 그대로 HardDelete 를 실행해 복구된 캐릭터를 영구 삭제한다(취소/세대 검사 없음). 정답 한 줄: 캐릭터 단위로 모든 영속 연산을 직렬화(락/액터)하고, 모든 쓰기를 단조 버전으로 펜싱하며, 삭제는 행 제거가 아닌 상태(tombstone)로 두고, 퍼지는 (id, 삭제 세대)로 멱등·취소 가능하게 한다.

변별: concurrency11/19 는 "저장 순서·coalesce(늦은 저장이 최신을 덮음)" 가 핵심이고, 본 문제는 "삭제/복구/퍼지 생명주기(tombstone·grace·복구 취소)" 가 핵심이다. "늦은 저장이 덮어쓴다" 테마는 공유하나 여기서는 그 덮어쓰기가 삭제를 되살리는 방향이다.


문제점

(A) in-flight 저장이 삭제를 되살림 — 좀비 부활 / 삭제 유실 (동시성/버그) ★간판

  • 증상: 인터리빙:
    1. T1 SaveAsync(7)_live[7] 스냅샷을 뜸 → 느린 Upsert 진입(아직 미완).
    2. T2 Delete(7): _live.Remove(7) + _db.MarkDeleted(7) 완료.
    3. T1 의 Upsert(snapshot) 가 그제서야 DB 도달 → 행이 "삭제됨" 이었는데 UPSERT 가 다시 살아있는 행으로 덮어쓰거나 insert삭제한 캐릭터가 부활.
  • 재현조건: 저장 주기와 삭제가 겹치고 DB 쓰기가 느릴 때(부하 시 상시). 결제/탈퇴 직후 자동 저장과 겹치면 실제 운영 사고.
  • 근본 원인: 저장 스냅샷이 "삭제 이전" 상태이고, 쓰기에 삭제 상태 확인/버전 펜싱이 없다. UPSERT 의 insert 의미가 tombstone 을 무시.

(C) 퍼지가 복구를 무시하고 영구 삭제 — 복구 캐릭터 소멸 (동시성/버그)

  • 증상: Delete(7)SchedulePurge(7, 600). 유예 내 Restore(7)_live[7] 복구 + ClearDeleted. 그러나 600초 후 예약된 Purge(7) 가 무조건 HardDelete(7) → 방금 복구한 캐릭터가 영구 삭제. 타이머는 복구를 모른다(취소·세대 검사 없음).
  • 근본 원인: 퍼지가 "삭제 시점의 결정" 을 무조건 집행. 복구가 그 결정을 무효화하는 메커니즘(세대/취소 토큰/상태 재확인)이 없다.

(B) Restore ↔ Delete/Save 순서 경합 + 스냅샷 신선도 (동시성/정확성)

  • 증상: Restore_db.Load(id) 로 읽는데, in-flight Upsert(A) 가 아직 안 끝났으면 옛 상태를 로드하거나, Restore 직후 늦은 Upsert 가 더 옛 스냅샷으로 덮어써 복구 상태가 퇴행. DeleteRestore 가 거의 동시면 "메모리엔 복구, DB 엔 삭제표시" 같은 메모리-DB 불일치도 가능.
  • 근본 원인: 연산 간 순서·버전이 없어 "가장 최근 결정" 을 식별할 수 없다.

(공통) 락 없는 Dictionary 동시 접근 — 손상/예외

  • _liveSaveAsync/Delete/Restore 가 동시 TryGetValue/Remove/인덱서 → 비스레드세이프 Dictionary 손상, InvalidOperationException, 무한루프 가능.

수정안

핵심: ① 캐릭터 단위 락(또는 액터)로 직렬화, ② 단조 버전 펜싱 으로 모든 쓰기 조건화, ③ 삭제는 행 제거가 아니라 상태(deleted)+버전(tombstone), ④ 퍼지는 삭제 세대로 멱등·취소.

// DB 계약(개념): 모든 쓰기는 버전/상태 조건부 (CAS)
//   UpsertIfNewer(ch, expectVersion): row.version < ch.version 일 때만 적용,
//     단 row.deleted=true 면 절대 insert/되살리기 금지(tombstone 우선).
//   HardDeleteIfGen(id, deleteGen): 현재 삭제 세대가 일치할 때만 영구 삭제.
public sealed class CharacterEntry
{
    public readonly object Gate = new();
    public Character Mem;          // 메모리 상태(없으면 null)
    public long Version;           // 단조 증가
    public bool Deleted;
    public long DeleteGen;         // 삭제할 때마다 증가(퍼지 매칭/복구 취소용)
}

public class CharacterStore
{
    private readonly Dictionary<long, CharacterEntry> _entries = new();
    private readonly object _mapGate = new();
    private readonly IDatabase _db;
    public CharacterStore(IDatabase db) { _db = db; }

    private CharacterEntry GetEntry(long id)
    {
        lock (_mapGate)
        {
            if (!_entries.TryGetValue(id, out var e))
                _entries[id] = e = new CharacterEntry();
            return e;
        }
    }

    public void SaveAsync(long id)
    {
        var e = GetEntry(id);
        Character snap; long ver;
        lock (e.Gate)
        {
            if (e.Mem == null || e.Deleted) return;     // 삭제됐으면 저장 안 함
            ver = ++e.Version;                          // 이 저장의 버전 확정
            snap = Clone(e.Mem); snap.Version = ver;
        }
        // 락 밖에서 느린 I/O. DB 가 버전·tombstone 으로 펜싱:
        _db.UpsertIfNewer(snap, ver);   // row.deleted=true 면 무시(되살리지 않음)
    }

    public void Delete(long id)
    {
        var e = GetEntry(id);
        long gen;
        lock (e.Gate)
        {
            e.Deleted = true;
            e.Mem = null;                 // 메모리에서 내림
            gen = ++e.DeleteGen;          // 이번 삭제 세대
            e.Version++;                  // 삭제도 버전 전진(늦은 저장 펜싱)
        }
        _db.MarkDeleted(id);              // 행은 남기고 deleted=true (tombstone)
        SchedulePurge(id, gen, graceSec: 600);
    }

    public bool Restore(long id)
    {
        var e = GetEntry(id);
        lock (e.Gate)
        {
            if (!e.Deleted) return false;
            var ch = _db.Load(id);        // tombstone 행에서 최신 상태
            ch.Deleted = false;
            ch.Version = ++e.Version;     // 복구도 버전 전진
            e.Deleted = false;
            e.Mem = ch;
            e.DeleteGen++;                // 세대 변경 → 예약된 퍼지를 무효화
            _db.ClearDeleted(id);
            return true;
        }
    }

    private void Purge(long id, long deleteGen)
    {
        var e = GetEntry(id);
        lock (e.Gate)
        {
            if (e.Deleted == false) return;        // 복구됨 → 퍼지 취소
            if (e.DeleteGen != deleteGen) return;  // 세대 불일치(재삭제/복구) → 취소
        }
        _db.HardDeleteIfGen(id, deleteGen);        // DB 도 세대 조건부 영구 삭제
    }

    private void SchedulePurge(long id, long deleteGen, int graceSec) { /* 타이머 등록 */ }
    private static Character Clone(Character c) =>
        new Character { Id = c.Id, Gold = c.Gold, Level = c.Level };
}

포인트

  • 버전 펜싱: UpsertIfNewer 는 더 오래된 스냅샷·tombstone 을 무시 → 늦은 저장이 삭제를 못 되살린다(좀비 부활 차단).
  • 삭제 세대(DeleteGen): 복구는 세대를 바꿔 예약된 퍼지를 자연 무효화, 퍼지는 HardDeleteIfGen 으로 DB 에서도 세대 일치 시에만 영구 삭제 → 복구 캐릭터 소멸 방지.
  • 모든 상태 전이를 엔트리 단위 락으로 직렬화 → 메모리-DB 일관성 + Dictionary 손상 차단.
  • 느린 Upsert 는 락 밖, 단 버전을 락 안에서 확정해 "어느 저장이 최신인지" 가 단조 결정.

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

  1. 캐릭터 단위 액터 + 단일 라이터: 한 캐릭터의 save/delete/restore/purge 를 한 큐로 직렬 처리. 락이 사라지고 순서가 자명. 트레이드오프: 캐릭터별 처리량 상한·핫 엔트리 지연.
  2. 상태 컬럼 + 단조 version 으로 CAS 영속화: 모든 쓰기 WHERE id=? AND version<?, 삭제는 state='deleted'(행 유지). 다중 서버/리플레이에도 동일 불변식. 트레이드오프: 스키마/쿼리 복잡, 읽기 시 상태 필터 필요.
  3. 퍼지를 "삭제 세대 키" 의 멱등 잡으로: 잡 큐에 (id, deleteGen) 으로 등록, 실행 시 세대 재확인. 타이머 취소에 의존하지 않아 재기동에도 안전. 트레이드오프: 잡 인프라 필요.
  4. 삭제/복구를 이벤트 소싱(DeleteRequested/Restored/Purged): 감사·복구·정합성 추적이 쉬워짐. 트레이드오프: 저장량·복잡도 증가.

면접 포인트 (예상 질문)

  1. 느린 비동기 저장이 어떻게 "삭제한 캐릭터를 되살리는지" 인터리빙으로 설명하라. 버전 펜싱(UpsertIfNewer)이 왜 이를 막는가?
  2. 삭제 후 600초 뒤 퍼지 타이머가, 그 사이 복구된 캐릭터를 영구 삭제하는 문제를 "삭제 세대" 로 어떻게 해결하는가? 서버 재기동까지 고려하면 타이머 취소만으로 충분한가?
  3. 소프트 삭제를 "행 제거" 가 아니라 "tombstone 상태" 로 두어야 하는 이유는? UPSERT 의 insert 의미가 왜 위험한가?