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

난이도 최상 해설 보기 →

결함을 모두 찾고 원인·수정안·더 나은 설계를 제시하라. 마커 (A)(B) 는 주목 위치 힌트다.

결함 코드 · C#
// ============================================================================
// [코드리뷰 문제] C# - 캐릭터 삭제/복구와 진행 중 비동기 저장의 경합
// ----------------------------------------------------------------------------
// 시나리오 (영속화 / 동시성):
//   - 캐릭터 상태(골드/레벨 등)는 메모리에서 실시간으로 바뀌고, 비동기 저장 워커가
//     주기적으로 SaveAsync(id) 를 호출해 메모리 스냅샷을 DB 에 UPSERT 한다(느린 I/O).
//   - 캐릭터 삭제(Delete)는 소프트 삭제다: DB 에 삭제 표시(MarkDeleted)하고 메모리에서
//     내린 뒤, 일정 유예시간(grace) 후 실제 영구 삭제(Purge=HardDelete)를 예약한다.
//   - 유예시간 안에는 복구(Restore)가 가능하다: DB 에서 다시 로드해 메모리에 올린다.
//   - 게임 플레이가 진행 중인 상태에서 삭제가 들어올 수 있고, 저장/삭제/복구/퍼지는
//     서로 다른 스레드에서 동시에 일어날 수 있다.
//
// 요구사항:
//   - 삭제 후 늦게 끝난 저장이 캐릭터를 되살리면 안 된다(좀비 부활/삭제 유실 금지).
//   - 유예 내 복구는 일관된 최신 상태로 살아나야 하고, 이후 예약된 퍼지에 의해
//     복구된 캐릭터가 영구 삭제되면 안 된다.
//   - 동시 저장/삭제/복구에도 자료구조 손상이나 정의되지 않은 동작이 없어야 한다.
//
// 과제:
//   이 코드의 잠재적 문제를 모두 찾고, 왜/어떻게 좀비 부활·삭제 유실·복구 캐릭터
//   소멸이 나는지(동시 인터리빙 포함) 설명하고, 수정안과 더 나은 설계를 제시하라.
//   (먼저 직접 리뷰를 적은 뒤 answer.md 와 대조할 것)
// ============================================================================

using System;
using System.Collections.Generic;

public class Character
{
    public long Id;
    public int  Gold;
    public int  Level;
    public bool Deleted;
    public long Version;   // 존재하지만 일관되게 쓰이지 않음
}

public interface IDatabase
{
    void Upsert(Character ch);     // 없으면 insert, 있으면 update (느림)
    void MarkDeleted(long id);     // deleted=true 표시
    void ClearDeleted(long id);    // deleted=false
    Character Load(long id);       // 행을 읽어 Character 로
    void HardDelete(long id);      // 행을 영구 삭제
}

public class CharacterStore
{
    private readonly Dictionary<long, Character> _live = new();
    private readonly IDatabase _db;
    public CharacterStore(IDatabase db) { _db = db; }

    // 비동기 저장 워커가 주기적으로 호출
    public void SaveAsync(long id)
    {
        // (A)
        if (!_live.TryGetValue(id, out var ch)) return;
        var snapshot = new Character { Id = ch.Id, Gold = ch.Gold, Level = ch.Level };
        // 느린 DB 쓰기 (수십~수백 ms 소요)
        _db.Upsert(snapshot);      // UPSERT: 행이 없으면 새로 insert
    }

    // 소프트 삭제 + 유예 후 퍼지 예약
    public void Delete(long id)
    {
        // (B)
        if (_live.TryGetValue(id, out var ch))
            ch.Deleted = true;
        _live.Remove(id);
        _db.MarkDeleted(id);
        SchedulePurge(id, graceSec: 600);
    }

    // 유예 내 복구
    public void Restore(long id)
    {
        // (C)
        var ch = _db.Load(id);
        ch.Deleted = false;
        _db.ClearDeleted(id);
        _live[id] = ch;
    }

    // 유예 만료 후 실제 영구 삭제(타이머 콜백)
    private void Purge(long id)
    {
        // (C)
        _db.HardDelete(id);
    }

    private void SchedulePurge(long id, int graceSec)
    {
        // graceSec 후 Purge(id) 를 호출하도록 타이머 등록 (구현 생략)
    }
}
내 리뷰 · C#
내 답안 · 자동 저장

작성 후 위 해설 보기에서 모범 해설과 대조하세요.