40. 전투 중 리스펙으로 인한 스탯 불일치와 오버힐
난이도 중해설 — 전투 중 리스펙으로 인한 스탯 불일치와 오버힐
난이도: 중
요약
ApplyRespecAsync 는 Strength, 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)
RecalculateDerivedStats는MaxHp를 재계산만 할 뿐CurHp와의 관계를 검증하지 않는다.MaxHp가 줄어드는 리스펙에서는CurHp를Math.Min(CurHp, MaxHp)로 클램프하는 보정이 반드시 필요한데 이 코드에는 없다. - 근본적으로
CharacterStats를 "여러 필드로 쪼개진 가변 공유 상태"로 두고 읽기·쓰기 양쪽에 아무 동기화도 두지 않은 것이 원인이다. 전투 계산이 참조하는 스냅샷은 리스펙 이전 전체 또는 이후 전체, 둘 중 하나여야 한다.
- (A) 세 기본 스탯을
수정안
새 스탯 세트를 별도의 불변 스냅샷(레코드)으로 완성한 뒤, 캐릭터에 적용하는 시점 단 한 번만 원자적으로 교체한다. 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 단계에서 놓치기 쉽다 — 리스펙처럼 "드물지만 전투 중에도 허용되는" 상태 변경 기능을 설계할 때는 처음부터 원자성을 요구사항에 명시해야 한다.
해설 — 전투 중 리스펙으로 인한 스탯 불일치와 오버힐
난이도: 중
요약
ApplyRespec 은 strength, agility, vitality 세 필드를 순차적으로 대입한 뒤 RecalculateDerivedStats 에서 maxHp/atk 를 다시 계산한다. 이 전체 갱신 과정에 어떤 동기화도 없어, 전투 워커 스레드가 그 사이 임의의 시점에 같은 CharacterStats 를 읽으면 정의되지 않은 순서로 뒤섞인 값(일부는 새 리스펙 결과, 일부는 옛 값)을 보게 된다. int 필드들에 대한 동시 읽기/쓰기 자체가 동기화 없이 이루어지므로 이는 미정의 동작(데이터 레이스)이며, 논리적으로도 maxHp 감소 시 curHp 를 보정하지 않아 오버힐 상태가 남는다.
문제점
- 분류: 동기화 없는 공유 가변 상태에 대한 데이터 레이스(undefined behavior) / 파생값 재계산 시 연관 상태 보정 누락
- 증상: 전투 워커 스레드가
atk(새 리스펙 결과)와maxHp(아직 이전 리스펙 결과, 혹은 그 반대)를 함께 읽어 실제로 존재한 적 없는 스탯 조합으로 데미지가 계산된다. C++ 표준상 동기화되지 않은 동시 읽기/쓰기는 데이터 레이스이며 컴파일러 최적화에 따라 더 심각하게(예: 필드를 여러 번 다시 읽어 서로 다른 값을 섞어 쓰는 등) 어긋날 수 있다. 또한vitality감소로maxHp가 줄어도curHp는 그대로라curHp > maxHp인 상태가 지속된다. - 재현 조건: 캐릭터가 전투 중인 상태에서
RespecService::ApplyRespec이 게임 로직 스레드에서 실행되는 동안, 다른 스레드가CombatCalculator::CalculateDamage로 같은CharacterStats인스턴스를 참조하는 타이밍. - 근본 원인:
- (A)
stats.strength = newStr; stats.agility = newAgi; stats.vitality = newVit;를 아무 락이나 원자적 연산 없이 순서대로 대입한다. 이 세 대입과CalculateDamage의 읽기 사이에는 happens-before 관계가 전혀 없어, 컴파일러/CPU 재정렬까지 포함해 어떤 조합이 관측될지 보장할 수 없다. - (B)
RecalculateDerivedStats가maxHp/atk를 갱신하지만curHp와의 정합성(클램프)을 확인하지 않는다. 근본적으로 여러 필드에 걸친 갱신을 "하나의 원자적 스냅샷 교체"로 표현하지 않고 개별 필드 대입으로 흩어놓은 것이 (A)(B) 모두의 공통 원인이다.
- (A)
수정안
기본/파생 스탯을 불변 스냅샷 구조체로 묶고, std::shared_ptr 교체(원자적 store/load)로 전체를 한 번에 바꾼다. curHp 클램프는 스냅샷 교체와 같은 임계구역에서 처리한다.
#include <cstdint>
#include <algorithm>
#include <atomic>
#include <memory>
#include <mutex>
struct StatSnapshot
{
int strength;
int agility;
int vitality;
int maxHp;
int atk;
StatSnapshot(int str, int agi, int vit)
: strength(str), agility(agi), vitality(vit),
maxHp(100 + vit * 10),
atk(5 + str * 2 + agi)
{
}
};
class CharacterStats
{
public:
CharacterStats()
: snapshot_(std::make_shared<StatSnapshot>(0, 0, 0)),
curHp_(100)
{
}
// 전투 계산 스레드는 이 스냅샷 하나만 읽으면 된다 — 항상 같은
// 리스펙 결과에서 나온 strength/agility/vitality/maxHp/atk 조합이다.
std::shared_ptr<const StatSnapshot> LoadSnapshot() const
{
return std::atomic_load_explicit(&snapshot_, std::memory_order_acquire);
}
int LoadCurHp() const
{
return curHp_.load(std::memory_order_acquire);
}
// 스냅샷 교체와 curHp 클램프를 같은 락으로 원자화한다.
void ApplySnapshot(std::shared_ptr<StatSnapshot> next)
{
std::lock_guard<std::mutex> lock(hpMutex_);
std::atomic_store_explicit(&snapshot_, std::shared_ptr<const StatSnapshot>(next),
std::memory_order_release);
int cur = curHp_.load(std::memory_order_relaxed);
if (cur > next->maxHp)
curHp_.store(next->maxHp, std::memory_order_release); // 오버힐 방지
}
private:
std::shared_ptr<const StatSnapshot> snapshot_;
std::atomic<int> curHp_;
std::mutex hpMutex_;
};
class RespecService
{
public:
// 게임 로직 스레드에서 호출된다.
void ApplyRespec(CharacterStats& stats, int newStr, int newAgi, int newVit)
{
// 기본/파생 스탯을 모두 계산해 완성된 스냅샷을 먼저 만든다.
auto next = std::make_shared<StatSnapshot>(newStr, newAgi, newVit);
// 완성된 스냅샷 하나만 원자적으로 교체한다.
stats.ApplySnapshot(next);
}
};
class CombatCalculator
{
public:
// 전투 워커 스레드에서 호출된다.
static int CalculateDamage(const CharacterStats& attacker, const CharacterStats& defender)
{
// 스냅샷을 한 번만 로드해 지역 변수에 고정한다 — atk 와 maxHp 가
// 항상 같은 리스펙 결과에서 나온 값임이 보장된다.
auto atkSnap = attacker.LoadSnapshot();
auto defSnap = defender.LoadSnapshot();
int baseDamage = atkSnap->atk;
(void)defSnap;
return baseDamage;
}
};
더 나은 설계
- 불변 스냅샷 +
shared_ptr원자적 교체(채택안):std::atomic_load/atomic_store로 전체 스탯 조합을 참조 하나로 교체한다. 읽기 쪽은 락 없이 스냅샷 하나만 로드하면 되어 전투 계산처럼 빈번한 읽기 경로의 성능 저하가 거의 없다. C++20 이상에서는std::atomic<std::shared_ptr<T>>를 직접 써서 더 명확하게 표현할 수 있다. - 캐릭터 단위 뮤텍스로 전체 감싸기: 리스펙 적용과 전투 계산 모두 캐릭터별
std::mutex를 잡도록 강제. 구현이 단순하지만 매 데미지 계산마다 락을 타야 해 전투가 잦은 서버에서는 경합이 커진다. - 리스펙을 전투 종료까지 지연: 전투 참여 캐릭터의 리스펙 요청을 큐에 쌓아 두고 전투 종료 시점에 일괄 적용. 동시성 자체를 회피하지만 클라이언트에 "왜 즉시 반영되지 않는가"를 설명해야 한다.
- 트레이드오프: 스냅샷 교체 방식이 읽기 성능과 정확성을 모두 만족하는 최선이지만, 스탯 필드가 늘어날수록 매 리스펙마다 새 힙 객체를 할당하는 비용이 든다. 리스펙처럼 드문 이벤트에는 무시할 만한 비용이나, 매 틱 갱신되는 값에는 오브젝트 풀 등 추가 최적화가 필요하다.
면접 포인트
- 여러 필드로 나뉜 공유 가변 상태를 락/원자적 연산 없이 스레드 간에 주고받으면 C++ 표준상 데이터 레이스이자 미정의 동작이다 — "값이 섞여 보인다" 수준이 아니라 컴파일러 최적화에 따라 더 예상 밖의 결과가 나올 수 있음을 설명할 수 있어야 한다.
- 여러 필드에 걸친 갱신을 원자화하는 표준적인 방법은 "완성된 불변 스냅샷을 만들고 참조를 한 번에 교체"하는 것이다 —
shared_ptr원자적 교체나 버전 태그, 더블 버퍼링 모두 같은 아이디어의 변형이다. - 파생값(maxHp) 재계산 시 그 파생값에 의존하는 다른 상태(curHp)를 같은 갱신 단위에서 함께 보정하지 않으면, 동시성 문제와 무관하게도 논리적 불일치(오버힐)가 남는다는 점을 별개 이슈로 짚을 수 있어야 한다.