← 문제로

23. 두 플레이어가 동시에 막타를 넣어 보상 귀속이 갈리는 상황

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

해설 — 두 플레이어가 동시에 막타를 넣어 보상 귀속이 갈리는 상황

난이도: 중상

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

요약

ApplyDamage가 공유 Monster(특히 Hp, Dead, DamageBy)를 락 없이 여러 스레드에서 갱신한다. "Dead 검사 → HP 차감 → HP<=0 검사 → 처치"가 하나의 원자 단위가 아니라(A)(B), 두 공격이 거의 동시에 들어오면 둘 다 m.Dead==false를 통과한 뒤 둘 다 HP<=0 을 보고 둘 다 _grantKillCredit 을 호출한다(이중 처치·보상 중복). Hp -= damageDamageBy Dictionary 자체도 데이터 경쟁으로 손상된다. 정답 한 줄: 몬스터 단위로 데미지 적용~죽음 판정을 원자화하고, "죽음 확정" 을 정확히 한 번만 일어나게(락 또는 CAS) 하여 막타 크레딧을 단 한 호출에만 귀속시킨다.


문제점

(A)(B) 죽음 판정 TOCTOU — 이중 처치/보상 중복 (동시성·정합) ★간판

  • 분류 태그: check-then-act / lost update.
  • 증상: HP=100, 두 스레드가 각각 60 데미지.
    • T1: Dead=false 통과 → Hp = 100-60 = 40
    • T2: Dead=false 통과 → Hp = 40-60 = -20
    • T1: Hp<=0? T1 이 읽는 시점엔 이미 -20 → true → Dead=true, 크레딧 지급
    • T2: Hp<=0? 여전히 -20(또는 자기 차감 후) → true → 크레딧 다시 지급 실제로는 Hp -= damageHp<=0 사이 인터리빙이 다양해, 둘 다 처치 분기에 들어가는 경우가 존재한다. 결과: 같은 몬스터가 두 번 죽고 보상이 두 번 나간다.
  • 재현조건: 막타 근처에서 두 데미지 패킷이 동시 처리.
  • 근본 원인: HP 차감과 "죽음 확정"이 같은 임계구역이 아니고, Dead 전이가 한 번만 일어나도록 보장되지 않는다(check-then-set).

(공통) Hp/Dictionary 데이터 경쟁 — 동시성 ★간판

  • 증상: m.Hp -= damage 는 읽기-수정-쓰기라 동시 실행 시 갱신 유실(데미지가 사라짐). m.DamageBy 동시 Add/인덱싱은 손상·예외. 기여도 합계가 틀어진다.
  • 근본 원인: 비원자 복합 연산 + 비스레드세이프 컬렉션에 동기화 없음.

(정책) 막타 = 마지막 공격자라는 단순 귀속 — 설계

  • 증상: 동시 막타 시 누가 "마지막"인지 인터리빙에 따라 비결정적. 기여도(DamageBy)를 모으면서도 분배에 쓰지 않는다.
  • 근본 원인: 죽음 확정 시점의 귀속 정책이 모호.

수정안

핵심: 몬스터 단위 락으로 데미지~죽음 판정을 원자화, 죽음은 한 번만 확정.

public class Monster
{
    public long MonsterId;
    public int  Hp;
    public bool Dead = false;
    public Dictionary<long, long> DamageBy = new();
    public readonly object Gate = new();
}

public void ApplyDamage(long monsterId, long attackerId, int damage)
{
    if (damage <= 0) return;                    // 음수/0 데미지 방어
    Monster m;
    lock (_lock) { if (!_monsters.TryGetValue(monsterId, out m)) return; }

    bool justDied = false;
    lock (m.Gate)
    {
        if (m.Dead) return;                     // 이미 죽음 → 무시(데미지/크레딧 없음)
        m.Hp -= damage;
        m.DamageBy[attackerId] = m.DamageBy.GetValueOrDefault(attackerId) + damage;
        if (m.Hp <= 0)
        {
            m.Dead = true;                      // 죽음 확정: 락 안이라 정확히 한 번
            justDied = true;
        }
    }
    if (justDied)
        _grantKillCredit(attackerId, monsterId);   // 락 밖에서 부작용(단 한 번)
}

포인트

  • Dead 검사 + HP 차감 + Dead 확정이 같은 락 안 → 두 스레드 중 하나만 justDied.
  • 보상 콜백은 락 밖에서(재진입/데드락 회피).
  • lock-free로 하려면 Interlocked.Add(ref Hp, -damage) 후 "0 이하로 처음 만든 스레드" 만 처치하도록 Interlocked.CompareExchangeDead 플래그를 0→1 전이(아래 설계 참고).

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

  1. CAS 기반 막타 확정(lock-free): int hpInterlocked.Add(ref hp, -damage)로 차감한다. 이 함수는 차감 후 값(after)을 반환하므로 이전 값은 before = after + damage 로 복원한다. before > 0 && after <= 0 이면 "0 이하로 처음 떨어뜨린 그 스레드". 그 스레드만 Interlocked.CompareExchange(ref _deadFlag,1,0)==0으로 죽음을 확정해 한 번만 처리. 트레이드오프: 기여도 누적은 여전히 동기화 필요.
  2. 기여도 기반 보상 분배: 막타 단일 귀속 대신 DamageBy 비율로 경험치/드롭을 나눠 "막타 강탈" 논란을 줄임(필드 보스 표준). 트레이드오프: 정책/연산 복잡, 어뷰징(힐러/탱커 기여 미반영) 보정 필요.
  3. 단일 스레드 시뮬레이션 틱: 한 몬스터(또는 한 존)의 전투를 같은 스레드가 처리하면 락 없이 원자성 확보. 트레이드오프: 샤딩/부하 분배 설계.
  4. 음수/과대 데미지 검증: 서버 권위로 데미지 상한·쿨다운 검증(치팅 방어).

면접 포인트 (예상 질문)

  1. 두 데미지가 동시에 들어올 때 "둘 다 처치 분기"에 들어가는 인터리빙을 단계별로 설명하라. Dead 플래그만으로는 왜 부족한가?
  2. Interlocked(CAS)로 막타를 한 번만 확정하는 방법을 코드로 써보라.
  3. 막타 단일 귀속 vs 기여도 분배의 장단점과, 각 방식의 어뷰징 시나리오는?