← 문제로

40. 전투 중 리스펙으로 인한 스탯 불일치와 오버힐

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

해설 — 전투 중 리스펙으로 인한 스탯 불일치와 오버힐

난이도: 중

요약

ApplyRespecAsyncStrength, Agility, Vitality 세 필드를 순차적으로 대입한 뒤 RecalculateDerivedStats 에서 MaxHp/Atk 를 다시 계산한다. 이 여러 단계의 필드 갱신이 하나의 원자적 스냅샷 전환으로 묶여 있지 않기 때문에, 전투 계산 스레드가 그 사이 어느 시점에 같은 CharacterStats 객체를 읽으면 "일부는 새 리스펙 결과, 일부는 옛 리스펙 결과"가 섞인 값을 보게 된다. 또한 MaxHp 가 리스펙으로 줄어드는 경우 CurHp 를 함께 보정하는 로직이 없어, 리스펙 직후 CurHp > MaxHp 인 오버힐처럼 보이는 상태가 남는다.

문제점

  • 분류: 비원자적 다중 필드 갱신(torn write) / 파생값 재계산 누락 보정
  • 증상: 전투 계산 스레드가 Atk(리스펙 후 새 값)와 MaxHp(아직 리스펙 전 옛 값, 혹은 그 반대 조합)를 동시에 읽어 실제로는 존재한 적 없는 조합의 데미지/최대체력 값으로 전투가 계산된다. 리스펙으로 Vitality 가 줄어 MaxHp 가 감소해도 CurHp 는 그대로라 CurHp 가 새 MaxHp 를 초과한 채 남고, 이후 회복 계산이나 체력바 표시가 100% 를 넘는 등 비정상적으로 보인다.
  • 재현 조건: 캐릭터가 전투 중인 상태에서 리스펙 요청이 들어오고, ApplyRespecAsync 실행 도중(Strength/Agility/Vitality 대입 사이, 또는 그 대입 완료 후 RecalculateDerivedStats 호출 전) CombatCalculator.CalculateDamage 가 같은 CharacterStats 인스턴스를 참조하는 타이밍.
  • 근본 원인:
    • (A) 세 기본 스탯을 stats.Strength = str; stats.Agility = agi; stats.Vitality = vit; 처럼 개별 필드 대입으로 갱신한다. 이 세 줄 사이에는 잠금이나 스냅샷 전환이 없어, 다른 스레드가 이 중간 상태(예: Strength 는 새 값, Vitality 는 옛 값)를 그대로 관측할 수 있다. 게다가 기본 스탯 갱신과 파생 스탯(MaxHp/Atk) 재계산 사이에도 시간차가 있어, "기본 스탯은 새 값인데 파생 스탯은 아직 옛 값"인 창이 한 번 더 열린다.
    • (B) RecalculateDerivedStatsMaxHp 를 재계산만 할 뿐 CurHp 와의 관계를 검증하지 않는다. MaxHp 가 줄어드는 리스펙에서는 CurHpMath.Min(CurHp, MaxHp) 로 클램프하는 보정이 반드시 필요한데 이 코드에는 없다.
    • 근본적으로 CharacterStats 를 "여러 필드로 쪼개진 가변 공유 상태"로 두고 읽기·쓰기 양쪽에 아무 동기화도 두지 않은 것이 원인이다. 전투 계산이 참조하는 스냅샷은 리스펙 이전 전체 또는 이후 전체, 둘 중 하나여야 한다.

수정안

새 스탯 세트를 별도의 불변 스냅샷(레코드)으로 완성한 뒤, 캐릭터에 적용하는 시점 단 한 번만 원자적으로 교체한다. CurHp 클램프도 같은 교체 시점에 함께 처리한다.

using System.Threading;
using System.Threading.Tasks;

public sealed class StatSnapshot
{
    public readonly int Strength;
    public readonly int Agility;
    public readonly int Vitality;
    public readonly int MaxHp;
    public readonly int Atk;

    public StatSnapshot(int str, int agi, int vit)
    {
        Strength = str;
        Agility = agi;
        Vitality = vit;
        MaxHp = 100 + vit * 10;
        Atk = 5 + str * 2 + agi;
    }
}

public sealed class CharacterStats
{
    // 기본/파생 스탯을 하나의 스냅샷으로 묶어 원자적으로 교체한다.
    private StatSnapshot _snapshot = new StatSnapshot(0, 0, 0);
    private int _curHp;

    public StatSnapshot Snapshot => Volatile.Read(ref _snapshot);
    public int CurHp => Volatile.Read(ref _curHp);

    // 스냅샷과 CurHp 클램프를 같은 락 안에서 원자적으로 반영한다.
    private readonly object _hpLock = new object();

    public void ApplySnapshot(StatSnapshot next)
    {
        lock (_hpLock)
        {
            Volatile.Write(ref _snapshot, next);
            if (_curHp > next.MaxHp)
                _curHp = next.MaxHp; // 오버힐 방지
        }
    }
}

public interface IStatRepository
{
    Task<(int str, int agi, int vit)> LoadAllocatedPointsAsync(ulong characterId);
}

public sealed class RespecService
{
    private readonly IStatRepository _repo;

    public RespecService(IStatRepository repo)
    {
        _repo = repo;
    }

    public async Task ApplyRespecAsync(CharacterStats stats, ulong characterId)
    {
        var (str, agi, vit) = await _repo.LoadAllocatedPointsAsync(characterId);

        // 기본 스탯과 파생 스탯을 모두 계산해 완성된 스냅샷을 먼저 만든다.
        var next = new StatSnapshot(str, agi, vit);

        // 캐릭터에는 완성된 스냅샷 하나만 원자적으로 반영한다.
        // CurHp 클램프도 같은 임계구역에서 함께 처리된다.
        stats.ApplySnapshot(next);
    }
}

public static class CombatCalculator
{
    public static int CalculateDamage(CharacterStats attacker, CharacterStats defender)
    {
        // 스냅샷을 한 번만 읽어 지역 변수에 고정한다 — Atk 와 MaxHp 가
        // 항상 같은 리스펙 결과에서 나온 값임이 보장된다.
        var atkSnap = attacker.Snapshot;
        var defSnap = defender.Snapshot;

        int baseDamage = atkSnap.Atk;
        return baseDamage;
    }
}

더 나은 설계

  • 불변 스냅샷 + 원자적 교체(채택안): 기본 스탯과 파생 스탯을 하나의 불변 객체로 묶고, 참조 교체 한 번(Volatile.Write)으로 전체를 갱신한다. 읽는 쪽은 참조를 한 번만 읽으면 항상 일관된 조합을 보게 되어 락 없이도 읽기 쪽 원자성을 얻을 수 있다. CurHp 클램프처럼 스냅샷 교체와 함께 반드시 일어나야 하는 부수 효과만 짧은 락으로 감싸면 된다.
  • 캐릭터 단위 락으로 전체 갱신 감싸기: 리스펙 적용과 전투 계산 모두 캐릭터별 락을 획득하도록 강제. 구현이 단순하지만 전투 계산처럼 매우 자주 호출되는 읽기 경로까지 락을 타게 되어 성능에 불리하다.
  • 리스펙 자체를 전투 중 지연/큐잉: 전투에 참여한 캐릭터는 리스펙 요청을 즉시 적용하지 않고 전투 종료 시점까지 큐에 쌓아 두었다가 적용. 동시성 문제 자체를 회피할 수 있지만 "왜 리스펙이 바로 반영되지 않는가"를 클라이언트에 설명해야 하는 UX 비용이 든다.
  • 트레이드오프: 불변 스냅샷 방식은 읽기 성능이 가장 중요한 전투 계산 경로에 적합해 이 시나리오의 최선이다. 다만 스탯 필드가 계속 늘어날수록 스냅샷 레코드를 매번 새로 만드는 할당 비용이 생기므로, 리스펙처럼 드문 이벤트에만 쓰고 매 틱 갱신되는 값(예: 버프 지속시간)에는 다른 전략이 필요하다.

면접 포인트

  • 여러 필드로 나뉜 가변 상태를 "하나씩 순서대로" 갱신하면, 그 필드들을 함께 읽는 쪽 입장에서는 중간의 어느 조합이든 관측될 수 있다 — 이를 막으려면 갱신 자체를 하나의 원자적 단위(불변 스냅샷 교체, 락, 트랜잭션)로 만들어야 한다.
  • 파생값(MaxHp, Atk)을 기본값과 별도 필드로 캐싱해 둘 때는, 파생값 재계산과 그 파생값에 의존하는 다른 상태(CurHp)의 보정을 같은 갱신 단위 안에 포함시켜야 "오버힐" 같은 논리적 불일치가 생기지 않는다.
  • 이런 종류의 버그는 낮은 확률로만 재현되어 QA 단계에서 놓치기 쉽다 — 리스펙처럼 "드물지만 전투 중에도 허용되는" 상태 변경 기능을 설계할 때는 처음부터 원자성을 요구사항에 명시해야 한다.