23. 두 플레이어가 동시에 막타를 넣어 보상 귀속이 갈리는 상황
난이도 중해설 — 두 플레이어가 동시에 막타를 넣어 보상 귀속이 갈리는 상황
난이도: 중상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
ApplyDamage가 공유 Monster(특히 Hp, Dead, DamageBy)를 락 없이 여러 스레드에서
갱신한다. "Dead 검사 → HP 차감 → HP<=0 검사 → 처치"가 하나의 원자 단위가 아니라(A)(B),
두 공격이 거의 동시에 들어오면 둘 다 m.Dead==false를 통과한 뒤 둘 다 HP<=0 을 보고
둘 다 _grantKillCredit 을 호출한다(이중 처치·보상 중복). Hp -= damage와
DamageBy 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 -= damage와Hp<=0사이 인터리빙이 다양해, 둘 다 처치 분기에 들어가는 경우가 존재한다. 결과: 같은 몬스터가 두 번 죽고 보상이 두 번 나간다.
- T1:
- 재현조건: 막타 근처에서 두 데미지 패킷이 동시 처리.
- 근본 원인: 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.CompareExchange로Dead플래그를 0→1 전이(아래 설계 참고).
더 나은 설계 (+트레이드오프)
- CAS 기반 막타 확정(lock-free):
int hp를Interlocked.Add(ref hp, -damage)로 차감한다. 이 함수는 차감 후 값(after)을 반환하므로 이전 값은before = after + damage로 복원한다.before > 0 && after <= 0이면 "0 이하로 처음 떨어뜨린 그 스레드". 그 스레드만Interlocked.CompareExchange(ref _deadFlag,1,0)==0으로 죽음을 확정해 한 번만 처리. 트레이드오프: 기여도 누적은 여전히 동기화 필요. - 기여도 기반 보상 분배: 막타 단일 귀속 대신
DamageBy비율로 경험치/드롭을 나눠 "막타 강탈" 논란을 줄임(필드 보스 표준). 트레이드오프: 정책/연산 복잡, 어뷰징(힐러/탱커 기여 미반영) 보정 필요. - 단일 스레드 시뮬레이션 틱: 한 몬스터(또는 한 존)의 전투를 같은 스레드가 처리하면 락 없이 원자성 확보. 트레이드오프: 샤딩/부하 분배 설계.
- 음수/과대 데미지 검증: 서버 권위로 데미지 상한·쿨다운 검증(치팅 방어).
면접 포인트 (예상 질문)
- 두 데미지가 동시에 들어올 때 "둘 다 처치 분기"에 들어가는 인터리빙을 단계별로
설명하라.
Dead플래그만으로는 왜 부족한가? Interlocked(CAS)로 막타를 한 번만 확정하는 방법을 코드로 써보라.- 막타 단일 귀속 vs 기여도 분배의 장단점과, 각 방식의 어뷰징 시나리오는?
해설 — 두 플레이어가 동시에 막타를 넣어 보상 귀속이 갈리는 상황 (C++)
난이도: 중상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
ApplyDamage가 공유 Monster(hp, dead, damageBy)를 락 없이 여러 스레드에서
갱신한다. "dead 검사 → hp 차감 → hp<=0 검사 → 처치 확정"이 원자 단위가 아니라(A)(B), 두
공격이 동시에 들어오면 둘 다 dead==false를 통과한 뒤 둘 다 처치 분기에 들어가
grantKillCredit_이 두 번 호출된다(이중 처치·보상 중복). hp -= damage(RMW)와
damageBy(map) 동시 접근은 데이터 경쟁/UB 다. 정답 한 줄: 몬스터 단위로 데미지~죽음
판정을 원자화하고, 죽음 확정을 정확히 한 번만(락 또는 atomic CAS) 일어나게 한다.
문제점
(A)(B) 죽음 판정 TOCTOU — 이중 처치/보상 중복 (동시성·정합) ★간판
- 분류 태그: check-then-act / lost update.
- 증상: hp=100, 두 스레드 각 60 데미지. 둘 다
dead=false 통과 → 차감 인터리빙 뒤 둘 다hp<=0을 관측 → 둘 다dead=true+grantKillCredit_. 같은 몬스터가 두 번 죽고 보상이 두 번 나간다. - 재현조건: 막타 근처 두 데미지 패킷 동시 처리.
- 근본 원인: 차감과 죽음 확정이 같은 임계구역이 아니고
dead전이가 1회 보장 안 됨.
(공통) hp/map 데이터 경쟁 — 동시성 (UB) ★간판
- 증상:
m.hp -= damage(읽기-수정-쓰기) 동시 실행 → 갱신 유실(데미지 증발).m.damageBy[attackerId] += damage동시 삽입은 rehash 중 UB·손상. C++ 메모리모델상 데이터 경쟁은 정의되지 않은 동작. - 근본 원인: 비원자 복합연산 + 비동기화 컨테이너.
(정책) 막타=마지막 공격자 단순 귀속 — 설계
- 증상: 동시 막타 시 비결정적 귀속.
damageBy를 모으면서도 분배에 안 씀.
수정안
핵심: 몬스터 단위 mutex 로 데미지~죽음 판정 원자화, 죽음 1회 확정.
struct Monster {
std::int64_t monsterId = 0;
int hp = 0;
bool dead = false;
std::unordered_map<std::int64_t, std::int64_t> damageBy;
std::mutex mtx;
};
void ApplyDamage(std::int64_t monsterId, std::int64_t attackerId, int damage) {
if (damage <= 0) return; // 음수/0 방어
Monster* m = nullptr;
{ std::lock_guard<std::mutex> g(mapMtx_);
auto it = monsters_.find(monsterId);
if (it == monsters_.end()) return;
m = &it->second; } // map 구조변경이 없다는 전제(아래 설계 참고)
bool justDied = false;
{ std::lock_guard<std::mutex> g(m->mtx);
if (m->dead) return; // 이미 죽음 → 무시
m->hp -= damage;
m->damageBy[attackerId] += damage;
if (m->hp <= 0) { m->dead = true; justDied = true; } }
if (justDied) grantKillCredit_(attackerId, monsterId); // 락 밖, 단 한 번
}
주의: Monster* 를 락 밖에서 들고 있으므로, monsters_ 에서 몬스터를 erase/재배치하는
경로가 있으면 shared_ptr<Monster> 로 바꾸거나 erase 를 같은 락으로 직렬화해야 댕글링이
없다(스폰/디스폰이 동시에 일어나는 실서버에서 중요).
lock-free 대안:
// hp 를 std::atomic<int>, dead 를 std::atomic<bool> 로.
int before = hp.fetch_sub(damage, std::memory_order_acq_rel);
int after = before - damage;
if (before > 0 && after <= 0) { // 0 이하로 "처음" 떨어뜨린 스레드만
bool expected = false;
if (dead.compare_exchange_strong(expected, true)) // 죽음 1회 확정
grantKillCredit_(attackerId, monsterId);
}
// damageBy 누적은 여전히 별도 동기화 필요.
더 나은 설계 (+트레이드오프)
- CAS 기반 막타 확정(lock-free): 위 대안. 고경합에서 빠름. 기여도 누적은 별도 동기화.
- 기여도 기반 보상 분배:
damageBy비율로 경험치/드롭 분배(필드 보스 표준). 막타 강탈 논란↓. 정책 복잡, 힐/탱 기여 보정 필요. - 단일 스레드 시뮬레이션 틱: 한 몬스터/존 전투를 한 스레드가 처리 → 락 불필요. 샤딩 설계 필요.
- 데미지 검증: 서버 권위로 상한/쿨다운(치팅 방어).
면접 포인트 (예상 질문)
- 두 데미지 동시 처리 시 "둘 다 처치 분기" 인터리빙을 단계별로.
dead플래그만으론 왜 부족한가? fetch_sub+compare_exchange로 막타를 한 번만 확정하는 원리를 설명하라.- 락 밖에서
Monster*를 들고 있을 때의 수명 위험과, 스폰/디스폰 동시 상황 대처는?