← 문제로

32. 길드 해산과 자금고/창고 접근의 수명·자금 경합

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

해설 — 길드 해산과 자금고/창고 접근의 수명·자금 경합

난이도: 중

요약

Disband(해산)과 Withdraw(출금)이 같은 길드 상태를 락 없이 만진다. 출금의 "잔액 확인→차감→지급"과 해산의 "환급→0→제거"가 서로 끼어들어 같은 자금이 두 번 빠져나가고(환급 + 출금), 해산으로 길드가 레지스트리에서 사라진 뒤에도 이미 길드 참조를 얻은 출금이 진행돼 좀비 길드에 출금(무에서 자금 생성)이 일어난다. Dictionary 동시 접근 자체도 비스레드세이프다.

문제점

(1) [원자성] 자금 이중 유출 — (B)(C)

  • 증상: 해산이 Bank.Give(leader, g.Funds) 로 전액 환급하기 직전, 출금이 g.Funds >= amount 를 통과해 차감·지급한다. 결과적으로 같은 잔액이 환급과 출금으로 두 번 나간다(자금 복제).
  • 재현: 길드장이 해산을 누르는 순간 임원이 출금. 두 워커가 동시에 g.Funds 를 읽고 각자 지급.
  • 근본원인: 확인-차감-지급(또는 환급-0)이 한 임계구역으로 묶이지 않은 read-modify-write 경합.

(2) [수명/TOCTOU] 제거된 길드에 대한 출금 — (A)(C)

  • 증상: 출금이 TryGetValueg 참조를 얻은 직후 해산이 Remove 한다. 출금은 이미 손에 든 g 로 계속 진행 → 레지스트리에는 없는 "유령 길드"의 자금을 깎아 지급(무에서 자금 생성). 반대로 해산 후 다시 등록된 동명 길드와 섞일 수도.
  • 근본원인: 존재 확인(A)과 실제 사용 사이에 제거(C)가 끼어드는 구간이 보호 되지 않음. 상태 enum(해산됨 여부) 없이 "맵에 있나"로만 판단.

(3) [데이터 레이스] Dictionary 동시 접근

  • _guilds 의 동시 TryGetValue/Remove 는 비스레드세이프(손상·예외 가능).

수정안

길드 단위 락으로 확인·차감·지급(또는 환급·제거)을 한 임계구역에 묶고, "해산됨" 상태 플래그로 좀비 접근을 차단한다.

public sealed class Guild
{
    public long Id; public long LeaderId; public long Funds;
    public List<int> Storage = new();
    public bool Disbanded;                 // 수명 상태
    public readonly object Gate = new();
}

public sealed class GuildService
{
    private readonly ConcurrentDictionary<long, Guild> _guilds = new();

    public bool Withdraw(long guildId, long memberId, long amount)
    {
        if (!_guilds.TryGetValue(guildId, out var g)) return false;
        lock (g.Gate)
        {
            if (g.Disbanded) return false;          // 해산된 길드 거부(좀비 차단)
            if (g.Funds < amount) return false;     // 확인·차감을 같은 락 안에서
            g.Funds -= amount;
            Bank.Give(memberId, amount);
            return true;
        }
    }

    public void Disband(long guildId, long leaderId)
    {
        if (!_guilds.TryGetValue(guildId, out var g)) return;
        lock (g.Gate)
        {
            if (g.Disbanded || g.LeaderId != leaderId) return;
            Bank.Give(g.LeaderId, g.Funds);         // 환급도 같은 락 안에서
            g.Funds = 0;
            g.Storage.Clear();
            g.Disbanded = true;                     // 먼저 무효화 → 이후 출금 거부됨
        }
        _guilds.TryRemove(guildId, out _);          // 표시 후 제거
    }
}

핵심: 자금 변경과 수명 전이를 같은 락에 두고, 제거 전에 Disbanded먼저 무효화해 "맵에서 빠지는 순간에 든 옛 참조"의 출금을 거부한다.

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

  • 영속 트랜잭션: 자금 이동을 메모리가 아니라 DB 단일 트랜잭션(또는 멱등 키) 으로 커밋하면 서버 재기동에도 이중 유출을 막는다. 대신 지연·복잡도 ↑.
  • 상태 머신: 길드 수명을 Active→Disbanding→Disbanded 로 명시하고, 해산 진행 중에는 신규 출금을 막되 진행 중 작업은 정리.
  • 락 입도: 길드 단위 락으로 전역 경합을 피하고 핫 길드만 직렬화.

면접 포인트

  • "객체가 컬렉션에 있나"로 수명을 판단하면 안 된다 — 참조를 든 뒤 제거되면 TOCTOU. 명시적 수명 상태 + 같은 락으로 좀비 접근을 막는다.
  • 환급/출금처럼 같은 자원을 줄이는 경로가 둘이면, 확인·차감을 반드시 같은 임계구역에 둬 총합을 보존.
  • 제거(Remove)와 무효화(플래그) 순서: 무효화 먼저, 제거 나중.