← 문제로

17. 보스 기여도 집계와 막타·보상 귀속 (C#)

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

해설 — 보스 기여도 집계와 막타·보상 귀속 (C#)

난이도: 중하

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

요약

ApplyDamage 가 여러 워커 스레드에서 락 없이 같은 BossHp/Contribution/ Dead 를 동시에 읽고 쓴다. (A) Dictionary 누적은 비원자라 동시 공격에서 기여도가 유실되고 Dictionary 자체가 손상될 수 있다. (B) Hp -= dmg 도 비원자라 데미지가 유실되며, if (Hp<=0 && !Dead) { Dead=true; ... } 는 check-then-act 경합이라 두 스레드가 동시에 통과해 보상이 두 번 분배될 수 있다. 막타(LastHitter)는 마지막으로 쓴 스레드가 이기므로 HP 를 0 으로 만든 공격과 무관한 사람이 막타로 기록될 수 있다. (C) 분배는 순회 중 다른 스레드의 누적과 겹쳐 합계가 어긋나고, total==0(아무도 때리지 않았는데 처치 트리거가 잘못 도는 경우) 시 0 으로 나눠 예외가 난다. 정답 한 줄: 보스 단위 락(또는 단일 액터)으로 누적·차감·처치판정·분배를 하나의 임계 구역으로 직렬화하고, 막타는 "HP 를 0 이하로 만든 그 공격"으로 확정한다.


문제점

(A) Dictionary 비원자 누적 — 유실 갱신 / 컬렉션 손상 (동시성) ★간판

  • 분류: lost update + 비스레드세이프 컬렉션 동시 변경.
  • 증상: 두 워커가 같은 attackerId 에 동시에 누적하면 TryGetValue(읽기) → +dmg(계산) → []=(쓰기) 가 인터리빙돼 한쪽 증가분이 사라진다. 서로 다른 키라도 Dictionary 는 스레드세이프가 아니라 동시 삽입이 내부 버킷/리사이즈를 깨뜨려 IndexOutOfRange/무한 루프/엔트리 유실로 이어진다.
  • 재현조건: 보스 1마리에 다수 공격자가 초당 수십~수백 타. 사실상 상시.
  • 근본 원인: 공유 가변 상태(Contribution)에 임계 구역이 없다.

(B) HP 차감·처치 판정 비원자 — 데미지 유실 / 이중 보상 / 막타 오귀속 (동시성) ★간판

  • 분류: read-modify-write 경합 + check-then-act(TOCTOU).
  • 증상:
    • Hp -= dmg 가 두 스레드에서 겹치면 한쪽 데미지가 사라져 보스가 "더 안 죽는다".
    • if (Hp<=0 && !Dead) { Dead=true; Distribute(); } — T1, T2 가 거의 동시에 마지막 타격을 넣어 둘 다 Hp<=0 && !Dead 를 참으로 보면 Distribute 가 두 번 호출돼 드랍/골드가 이중 지급된다.
    • LastHitter = attackerId 는 보호 없이 마지막 writer 가 이기므로, 실제로 HP 를 0 으로 만든 타격이 아니라 그 직후 들어온(이미 죽은 보스에 대한) 타격자가 막타로 기록될 수 있다.
  • 근본 원인: 처치는 "HP 가 0 을 통과하는 단 한 번의 사건"인데, 차감과 임계값 비교가 원자적으로 묶이지 않았다.

(C) 분배 합산의 비일관 스냅샷 / 0 분모 (동시성·정확성)

  • 증상: DistributeRewardsContribution 을 두 번 순회(합계, 분배)하는 동안에도 다른 스레드가 누적 중이면 두 순회가 보는 값이 달라 비율 합이 100% 가 안 되거나 순회 중 수정 예외가 난다. 잘못된 처치 트리거로 total==0 이면 kv.Value * 100 / total 에서 DivideByZeroException.
  • 근본 원인: 분배가 "확정된 불변 스냅샷"이 아니라 살아있는 컬렉션 위에서 돈다.

(보너스) 정수 비율 손실 — 분배 총합 < 100 (정확성)

  • value * 100 / total 정수 나눗셈은 버림이라 합이 100 에 못 미친다. 보상이 드랍 슬롯 배분이면 잔여분(remainder) 처리 규칙이 필요(최대 잔여법/막타 우선 등).

수정안

핵심: ① 보스 단위 락으로 전 구간 직렬화, ② "HP 를 0 이하로 만든 그 타격"을 막타·처치 주체로 단 한 번 확정, ③ 분배는 락 안에서 확정 스냅샷을 만들어 락 밖에서 수행, ④ 0 분모· 정수 잔여 처리.

public class Boss
{
    public readonly object Sync = new object();
    public long Id, Hp, MaxHp, LastHitter;
    public bool Dead;
    public Dictionary<long, long> Contribution = new Dictionary<long, long>();
}

public void ApplyDamage(Boss boss, long attackerId, long dmg)
{
    if (dmg <= 0) return;

    long lastHitter = 0;
    Dictionary<long, long> snapshot = null;

    lock (boss.Sync)
    {
        if (boss.Dead) return;                       // 죽은 뒤 타격은 기여/막타에서 제외

        boss.Contribution.TryGetValue(attackerId, out var acc);
        boss.Contribution[attackerId] = acc + dmg;   // 락 안 → 유실 없음

        boss.Hp -= dmg;
        if (boss.Hp <= 0)
        {
            boss.Dead = true;                        // 처치는 여기서 단 한 번
            boss.LastHitter = attackerId;            // 0 을 통과시킨 그 타격이 막타
            lastHitter = attackerId;
            snapshot = new Dictionary<long, long>(boss.Contribution);  // 확정 스냅샷
        }
        else
        {
            boss.LastHitter = attackerId;            // 표시용(막타 확정은 위에서만)
        }
    }

    if (snapshot != null)                            // 보상은 락 밖에서(긴 작업)
        DistributeRewards(boss, lastHitter, snapshot);
}

private void DistributeRewards(Boss boss, long lastHitter, Dictionary<long, long> contrib)
{
    long total = 0;
    foreach (var v in contrib.Values) total += v;
    if (total <= 0) return;                          // 0 분모 방어

    // 정수 잔여: 비율 내림 후 남은 % 를 기여 큰 순으로 +1
    var shares = new Dictionary<long, long>();
    long assigned = 0;
    foreach (var kv in contrib) { var s = kv.Value * 100 / total; shares[kv.Key] = s; assigned += s; }
    foreach (var k in contrib.OrderByDescending(x => x.Value).Select(x => x.Key))
    { if (assigned >= 100) break; shares[k] += 1; assigned++; }

    _reward.GrantKillRewards(boss, lastHitter, shares);
}

보상 분배를 락 밖으로 뺀 이유: GrantKillRewards 는 DB/네트워크 등 느린 I/O 일 수 있어 락을 오래 잡으면 다른 공격 처리가 막힌다. 락 안에서는 "스냅샷 복제"까지만 한다.


더 나은 설계

1) 단일 액터(보스별 입력 큐)

  • 한 보스의 모든 데미지/판정을 단일 스레드로 직렬 처리하면 락이 사라지고 모든 TOCTOU 가 구조적으로 불가능. 처치/막타가 자연히 "그 한 타격"으로 확정된다. 트레이드오프: 보스 간 병렬성은 보스별 샤딩으로 확보, 한 보스에 공격이 폭증하면 큐가 단일 스레드 병목이 될 수 있어 데미지 배칭(같은 틱 내 합산)으로 완화.

2) 원자 연산으로 핫패스만 가볍게

  • Interlocked.Add(ref boss.Hp, -dmg) 로 차감하고, 반환값이 처음으로 0 이하가 되는 경계를 만든 호출만 처치를 확정(prev>0 && now<=0). 기여도는 ConcurrentDictionaryAddOrUpdate. 락 없이도 정확하며 경합이 심한 보스에 유리. 트레이드오프: 처치 경계 판정 로직이 미묘하므로 단위 테스트 필수.

3) 데미지 이벤트 소싱

  • 데미지를 이벤트 로그로 남기고 기여도/막타를 그 로그에서 결정론적으로 재구성하면 분쟁(보상 이의제기)·치트 탐지·리플레이에 유리. 트레이드오프: 저장/집계 비용.

4) 막타·기여 정책 분리

  • "막타 보상" 과 "기여도 보상" 을 분리해, 막타 스틸 논란을 줄이고 기여 최소 임계(예: 1% 미만 미지급)·디버프(무임승차) 규칙을 데이터로 정의.

면접 포인트

  • 면접관이 듣고 싶은 핵심: "처치는 단 한 번 일어나는 사건" 임을 동시성 하에서 어떻게 보장하나 — HP 차감과 임계값 통과를 원자적으로 묶고, 0 을 통과시킨 그 호출만 처치를 확정.
  • 예상 질문:
    1. "보상이 두 번 나가는 시나리오를 인터리빙으로 설명하라." → 두 스레드가 동시에 Hp<=0 && !Dead 통과 → 둘 다 Distribute. Interlocked 경계 판정 또는 락으로 해결.
    2. "막타가 엉뚱한 사람에게 가는 이유는?" → LastHitter 가 보호 없는 마지막 writer. 죽은 뒤 타격까지 덮어쓴다. 처치를 확정한 호출에서만 막타를 박아야 한다.
    3. "락 vs Interlocked, 언제 뭘 쓰나?" → 단순 카운터/경계는 Interlocked, 다중 필드 불변식(HP+기여+스냅샷 일관)은 락/액터.

변별 메모: content10(파티 전리품 분배)은 이미 확정된 처치 후 분배 규칙이 축이고, 본 문제는 동시 데미지 집계·처치 사건의 원자성·막타 귀속 이 축이다. content16(버프 타이밍)과 "공유 상태 동시 변경" 골격은 닮았으나, 본 문제는 "단 한 번의 처치" 라는 임계값 통과 사건과 보상 분배 정합성이 핵심이다.