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

난이도 중 해설 보기 →

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

결함 코드 · C#
// 시나리오 (서버-클라): 캐릭터는 리스펙(스탯 포인트 재분배) 기능을 통해
// strength, agility, vitality 세 기본 스탯에 배분한 포인트를 초기화하고
// 새로 재분배할 수 있다. 파생 스탯인 최대체력(MaxHp)과 공격력(Atk)은 세
// 기본 스탯으로부터 계산된다.
//
// 문제는, 캐릭터가 전투 중이어도 리스펙 요청을 막지 않는다는 점이다.
// 리스펙 처리 스레드가 strength, agility, vitality, 그리고 파생값
// MaxHp/Atk 를 순서대로 갱신하는 동안, 전투 계산 스레드는 언제든 같은
// 캐릭터 객체의 스탯 필드들을 읽어 데미지와 최대체력을 계산할 수 있다.
//
// 요구사항:
//   - 전투 계산 로직이 특정 시점의 스탯을 읽을 때, 기본 스탯 세 개와 그로부터
//     파생된 MaxHp/Atk 는 항상 "같은 리스펙 결과" 기준으로 일관되어야 한다
//     (일부는 새 값, 일부는 옛 값이 섞여서는 안 된다).
//   - 리스펙으로 MaxHp 가 줄어드는 경우, 현재체력(CurHp)이 새로운 MaxHp 를
//     초과한 채로 남아 있어서는 안 된다(오버힐처럼 보이는 상태 방지).
//
// 과제: (A)(B) 지점을 검토해 위 요구사항이 깨지는 경로를 찾고 수정하라.

using System.Threading.Tasks;

public sealed class CharacterStats
{
    public int Strength;
    public int Agility;
    public int Vitality;

    public int MaxHp;
    public int Atk;

    public int CurHp;
}

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);

        // (A) 기본 스탯 세 필드를 하나씩 순차적으로 갱신한다.
        //     세 대입 사이사이에 다른 스레드가 이 객체를 읽을 수 있다.
        stats.Strength = str;
        stats.Agility = agi;
        stats.Vitality = vit;

        // 파생 스탯은 기본 스탯이 모두 갱신된 뒤 별도로 재계산한다.
        RecalculateDerivedStats(stats);
    }

    private void RecalculateDerivedStats(CharacterStats stats)
    {
        int newMaxHp = 100 + stats.Vitality * 10;
        int newAtk = 5 + stats.Strength * 2 + stats.Agility;

        stats.MaxHp = newMaxHp;
        stats.Atk = newAtk;

        // (B) CurHp 가 새로운 MaxHp 를 초과하는지는 여기서 확인하지 않는다.
        //     또한 이 시점 이전까지 MaxHp/Atk 와 CurHp 는 서로 다른 시점의
        //     값을 섞어서 참조당할 수 있다.
    }
}

public static class CombatCalculator
{
    // 전투 계산 스레드에서 호출된다. 데미지 산정에 Atk 와 MaxHp 를 함께 쓴다.
    public static int CalculateDamage(CharacterStats attacker, CharacterStats defender)
    {
        int atk = attacker.Atk;
        int defenderMaxHp = defender.MaxHp;

        // MaxHp 대비 비율로 고정 데미지 보정을 주는 등, 두 파생값이 같은
        // 스냅샷 기준이라고 가정하고 계산이 이루어진다(구현 생략).
        int baseDamage = atk;
        return baseDamage;
    }
}
내 리뷰 · C#
내 답안 · 자동 저장

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