17. 보스 기여도 집계와 막타·보상 귀속 (C#)
난이도 중해설 — 보스 기여도 집계와 막타·보상 귀속 (C#)
난이도: 중하
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
ApplyDamage 가 여러 워커 스레드에서 락 없이 같은 Boss 의 Hp/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 분모 (동시성·정확성)
- 증상:
DistributeRewards가Contribution을 두 번 순회(합계, 분배)하는 동안에도 다른 스레드가 누적 중이면 두 순회가 보는 값이 달라 비율 합이 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). 기여도는ConcurrentDictionary의AddOrUpdate. 락 없이도 정확하며 경합이 심한 보스에 유리. 트레이드오프: 처치 경계 판정 로직이 미묘하므로 단위 테스트 필수.
3) 데미지 이벤트 소싱
- 데미지를 이벤트 로그로 남기고 기여도/막타를 그 로그에서 결정론적으로 재구성하면 분쟁(보상 이의제기)·치트 탐지·리플레이에 유리. 트레이드오프: 저장/집계 비용.
4) 막타·기여 정책 분리
- "막타 보상" 과 "기여도 보상" 을 분리해, 막타 스틸 논란을 줄이고 기여 최소 임계(예: 1% 미만 미지급)·디버프(무임승차) 규칙을 데이터로 정의.
면접 포인트
- 면접관이 듣고 싶은 핵심: "처치는 단 한 번 일어나는 사건" 임을 동시성 하에서 어떻게 보장하나 — HP 차감과 임계값 통과를 원자적으로 묶고, 0 을 통과시킨 그 호출만 처치를 확정.
- 예상 질문:
- "보상이 두 번 나가는 시나리오를 인터리빙으로 설명하라." → 두 스레드가 동시에
Hp<=0 && !Dead통과 → 둘 다Distribute.Interlocked경계 판정 또는 락으로 해결. - "막타가 엉뚱한 사람에게 가는 이유는?" →
LastHitter가 보호 없는 마지막 writer. 죽은 뒤 타격까지 덮어쓴다. 처치를 확정한 호출에서만 막타를 박아야 한다. - "락 vs Interlocked, 언제 뭘 쓰나?" → 단순 카운터/경계는 Interlocked, 다중 필드 불변식(HP+기여+스냅샷 일관)은 락/액터.
- "보상이 두 번 나가는 시나리오를 인터리빙으로 설명하라." → 두 스레드가 동시에
변별 메모: content10(파티 전리품 분배)은 이미 확정된 처치 후 분배 규칙이 축이고, 본 문제는 동시 데미지 집계·처치 사건의 원자성·막타 귀속 이 축이다. content16(버프 타이밍)과 "공유 상태 동시 변경" 골격은 닮았으나, 본 문제는 "단 한 번의 처치" 라는 임계값 통과 사건과 보상 분배 정합성이 핵심이다.
해설 — 보스 기여도 집계와 막타·보상 귀속 (C++)
난이도: 중하
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
ApplyDamage 가 여러 워커 스레드에서 동기화 없이 같은 Boss 의 hp/contribution/
dead 를 동시에 건드린다. (A) contribution[attackerId] += dmg 는 비원자일 뿐 아니라,
std::unordered_map 의 동시 삽입은 정의되지 않은 동작(UB) 이다(리해시 중 노드 손상 →
크래시). (B) hp -= dmg 는 read-modify-write 경합으로 데미지가 유실되고,
if (hp<=0 && !dead) { dead=true; ... } 는 check-then-act 경합이라 두 스레드가 동시에
통과해 보상이 두 번 나갈 수 있다. lastHitter 는 보호 없는 마지막 writer 가 이겨
HP 를 0 으로 만든 타격과 무관한 사람이 막타가 될 수 있다. (C) 분배는 살아있는 맵을
두 번 순회해 합계가 어긋나고, total==0 이면 정수 0 나눗셈(UB/크래시).
정답 한 줄: 보스 단위 뮤텍스(또는 단일 액터)로 누적·차감·처치판정·스냅샷을 한 임계
구역으로 묶고, HP 를 0 이하로 만든 그 호출만 처치·막타를 확정한다.
문제점
(A) unordered_map 동시 변경 — 자료구조 UB (동시성) ★간판
- 분류: 비스레드세이프 컨테이너 동시 쓰기.
- 증상: 여러 스레드가
contribution[...] += dmg를 동시에 호출 → 같은 키면 RMW 유실, 다른 키라도 리해시(rehash) 중이면 버킷/노드 포인터가 깨져 세그폴트 또는 무한 루프. C++ 표준상 동일 컨테이너에 대한 동시 비-const 접근은 데이터 레이스(UB). - 재현조건: 보스 1마리 다수 공격자, 맵 성장으로 리해시가 도는 순간.
- 근본 원인: 공유 컨테이너에 임계 구역 부재.
(B) hp 차감·처치 판정 비원자 — 데미지 유실 / 이중 보상 / 막타 오귀속 (동시성) ★간판
- 증상:
hp -= dmg동시 실행 시 한쪽 차감 유실 → 보스가 안 죽음.- 두 스레드가 거의 동시에 막타를 넣어 둘 다
hp<=0 && !dead통과 →DistributeRewards두 번 호출, 드랍/골드 이중 지급. lastHitter = attackerId가 보호 없이 갱신돼, 이미 죽은 보스에 들어온 늦은 타격자가 막타로 기록될 수 있다.
- 근본 원인: HP 차감과 "0 통과 임계값" 비교가 원자적으로 묶이지 않았다.
(C) 분배 비일관 스냅샷 / 0 분모 (동시성·정확성)
- 증상:
DistributeRewards가 맵을 합계·분배로 두 번 순회하는 동안 다른 스레드가 누적 중이면 두 순회 값이 달라 비율 합 불일치 또는 순회 중 리해시로 반복자 무효화(UB). 잘못된 트리거로total==0이면kv.second * 100 / total는 0 나눗셈(정수 0 나눗셈은 UB). - 근본 원인: 확정 불변 스냅샷이 아니라 살아있는 맵 위에서 분배.
(보너스) 정수 비율 버림 — 합이 100 미만 (정확성)
value*100/total정수 나눗셈은 내림 → 잔여 % 가 사라진다. 잔여 배분 규칙 필요.
수정안
핵심: ① 보스별 std::mutex 로 전 구간 직렬화, ② HP 를 0 이하로 만든 호출만 처치·막타
확정, ③ 락 안에서 스냅샷 복제 후 분배는 락 밖에서, ④ 0 분모·잔여 처리.
#include <mutex>
#include <vector>
#include <algorithm>
struct Boss {
std::mutex mtx;
int64_t id = 0, hp = 0, maxHp = 0, lastHitter = 0;
bool dead = false;
std::unordered_map<int64_t, int64_t> contribution;
};
void BossCombatService::ApplyDamage(Boss& boss, int64_t attackerId, int64_t dmg) {
if (dmg <= 0) return;
int64_t lastHitter = 0;
std::unordered_map<int64_t, int64_t> snapshot;
bool killed = false;
{
std::lock_guard<std::mutex> lk(boss.mtx);
if (boss.dead) return; // 죽은 뒤 타격 제외
boss.contribution[attackerId] += dmg; // 락 안 → 안전
boss.hp -= dmg;
boss.lastHitter = attackerId; // 표시용
if (boss.hp <= 0) {
boss.dead = true; // 처치는 여기서 단 한 번
boss.lastHitter = attackerId; // 0 을 통과시킨 그 타격이 막타
lastHitter = attackerId;
snapshot = boss.contribution; // 확정 스냅샷 복제
killed = true;
}
}
if (killed) // 보상은 락 밖(느린 I/O)
DistributeRewards(boss, lastHitter, snapshot);
}
void BossCombatService::DistributeRewards(
const Boss& boss, int64_t lastHitter,
const std::unordered_map<int64_t,int64_t>& contrib) {
int64_t total = 0;
for (auto& kv : contrib) total += kv.second;
if (total <= 0) return; // 0 분모 방어
std::unordered_map<int64_t,int64_t> shares;
int64_t assigned = 0;
for (auto& kv : contrib) { auto s = kv.second*100/total; shares[kv.first]=s; assigned+=s; }
// 잔여 % 를 기여 큰 순으로 +1
std::vector<std::pair<int64_t,int64_t>> v(contrib.begin(), contrib.end());
std::sort(v.begin(), v.end(), [](auto&a, auto&b){ return a.second > b.second; });
for (auto& kv : v) { if (assigned >= 100) break; shares[kv.first] += 1; ++assigned; }
reward_.GrantKillRewards(boss, lastHitter, shares);
}
분배를 락 밖으로 뺀 이유: 보상 지급은 DB/네트워크일 수 있어 락 유지 시간이 길어지면 공격 처리 전체가 직렬화 병목. 락 안에서는 스냅샷 복제까지만.
더 나은 설계
1) 단일 액터(보스별 입력 큐)
- 한 보스의 데미지/판정을 단일 스레드 메시지 큐로 직렬 처리 → 락·UB·TOCTOU 소멸. 처치가 자연히 한 번. 트레이드오프: 한 보스 폭증 시 큐 병목 → 같은 틱 데미지 배칭으로 완화.
2) 원자 카운터로 핫패스 경량화
std::atomic<int64_t> hp에fetch_sub(dmg)로 차감, 반환값으로 prev>0 && prev-dmg<=0 인 호출만 처치 확정(경계 단 한 번). 기여도는 샤딩 카운터 또는 락-스트라이핑. 트레이드오프: 메모리 순서/경계 판정에 주의, 테스트 필수.
3) 데미지 이벤트 로그
- 데미지를 append-only 로그로 남겨 기여/막타를 결정론적으로 재구성 → 분쟁/치트탐지/리플레이 대응. 트레이드오프: 저장·집계 비용.
4) 막타·기여 보상 분리 + 최소 기여 임계
- 막타 스틸 논란 완화, 무임승차 방지(최소 % 미만 미지급)를 데이터 규칙으로.
면접 포인트
- 핵심: "처치는 단 한 번의 사건" 을 동시성 하에서 보장 — HP 차감과 0 통과를 원자적으로, 그 호출만 처치·막타 확정.
- 예상 질문:
- "unordered_map 을 여러 스레드가 동시에 쓰면 왜 죽나?" → 표준상 동시 비-const 접근은 데이터 레이스(UB). 리해시 중 노드 포인터 손상.
- "이중 보상 인터리빙을 설명하라." → 두 스레드 동시
hp<=0 && !dead통과. atomicfetch_sub경계 판정 또는 뮤텍스로 해결. - "atomic hp 로 처치를 어떻게 한 번만 잡나?" →
fetch_sub반환(prev)으로prev>0 && prev-dmg<=0인 호출만 처치 확정.
빌드/검증
g++ -std=c++17 -fsyntax-only problem.cpp # 문제 코드 구문 검증
변별 메모: content10(전리품 분배)은 처치 후 분배 규칙, 본 문제는 동시 집계·처치 원자성· 막타 귀속이 축. C++ 판은 추가로 unordered_map 동시 접근 UB 가 C# 판(Dictionary 손상) 대비 더 치명적(크래시)이라는 점을 강조.