16. 버프/디버프 중첩·만료 타이밍 경합 (서버 권위)
난이도 중해설 — 버프/디버프 중첩·만료 타이밍 경합 (서버 권위)
난이도: 중
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
세 메서드(ApplyBuff/SweepExpired/RecalcStats)가 같은 Unit.Buffs 를 락 없이
동시에 읽고 쓴다. (B)의 SweepExpired 는 치명적으로 순회 중 Dictionary.Remove 를
호출해 InvalidOperationException 으로 스윕 스레드가 죽거나 일부만 정리된다. 적용과 스윕이
겹치면 "갱신된 직후의 버프"가 만료 판정 타이밍에 걸려 사라지는 유실 갱신/유령 만료가
나고, 비원자 if(Stack<Max) Stack++ 는 동시 적용 시 MaxStack 초과가 가능하다. 만료
기준이 DateTime.UtcNow(벽시계)라 NTP 보정/서머타임으로 점프하면 만료가 빨라지거나 늦어진다.
정답 한 줄: 유닛 단위 락(또는 단일 액터)으로 적용/스윕/재계산을 직렬화하고, 순회와 제거를
분리하며, 만료는 단조 시계 기준으로 판정한다.
문제점
(B) 순회 중 Dictionary.Remove — 컬렉션 수정 예외 (동시성/버그) ★간판
- 증상:
foreach (var kv in u.Buffs) { ... u.Buffs.Remove(kv.Key); }는 순회 중 구조 변경이라InvalidOperationException: Collection was modified. 정확히는Remove자체가 아니라 제거 직후의 다음MoveNext()(enumerator 진행) 에서 던진다(그래서 마지막 원소만 지우면 안 던질 수도 있다). 만료 대상이 둘 이상이거나 중간에 있으면 거의 매번 스윕 스레드가 죽어 만료가 사실상 동작하지 않는다(버프가 영원히 남음). - 근본 원인: 열거 중 원본 컬렉션을 변경했다. 제거 대상을 먼저 모으고 나서 지워야 한다.
(A)+(B) 적용과 스윕의 비원자 경합 — 유실 갱신 / 유령 만료 (동시성) ★간판
- 증상: T1
ApplyBuff가ExpireAt을 미래로 갱신하는 사이, T2SweepExpired가 갱신 직전의 옛ExpireAt을 읽어 "만료됨"으로 판단하고 제거 → 방금 새로 건 버프가 즉시 사라진다(유령 만료). 반대로 T2 가Remove와 T1 의TryGetValue/Buffs[id]=가 인터리빙되면Dictionary내부 손상·잘못된 값. - 근본 원인: 같은 가변 상태를 보호하는 임계 구역이 없다. 적용·스윕·재계산이 하나의 직렬화 단위가 아니다.
(A) 비원자 중첩 증가 — MaxStack 초과 (동시성)
- 증상:
if (b.Stack < b.MaxStack) b.Stack++;가 두 스레드에서 동시에 실행되면 둘 다Stack < Max를 보고 각각 ++ → MaxStack 초과. 디버프라면 의도보다 강하게 걸린다. - 근본 원인: check-then-act 비원자. 락 또는 원자 연산 필요.
(C) RecalcStats 도 락 없이 순회 — 만료 직전 값/예외 (동시성)
- 증상: 재계산이 스윕/적용과 겹치면 순회 중 수정 예외 또는 막 만료된 버프를 포함해
스탯 과대 계산. 캐시
AttackPower가 찰나의 잘못된 값으로 굳는다. - 근본 원인: 읽기 경로도 같은 락/스냅샷으로 보호해야 한다.
벽시계(DateTime.UtcNow) 만료 — 시간 점프 (정확성)
- 증상: NTP 보정/수동 시간 변경으로
UtcNow가 뒤로/앞으로 점프하면 만료가 잘못 판정된다. 쿨다운/지속시간 류는 벽시계에 의존하면 안 된다. - 근본 원인: 경과시간 판정은 단조 시계(
Environment.TickCount64/Stopwatch)로 해야 한다.
수정안
핵심: ① 유닛 단위 락으로 적용/스윕/재계산 직렬화, ② 스윕은 제거 대상 수집 후 일괄 제거, ③ 중첩 증가는 락 안에서, ④ 만료는 단조 시계 틱 기준.
public class Buff
{
public int BuffId, Stack, MaxStack, PerStackPower;
public long ExpireTick; // 단조 시계 기준 만료 (ms)
}
public void ApplyBuff(Unit u, int buffId, int maxStack, int perStackPower)
{
long now = Environment.TickCount64;
lock (u.SyncRoot)
{
if (u.Buffs.TryGetValue(buffId, out var b))
{
if (b.Stack < b.MaxStack) b.Stack++; // 락 안 → 초과 없음
b.ExpireTick = now + _durationMs; // 갱신
}
else
{
u.Buffs[buffId] = new Buff {
BuffId = buffId, Stack = 1, MaxStack = maxStack,
PerStackPower = perStackPower, ExpireTick = now + _durationMs
};
}
}
}
public void SweepExpired(Unit u)
{
long now = Environment.TickCount64;
lock (u.SyncRoot)
{
// 순회 중 제거 금지: 대상 먼저 수집 후 일괄 제거
List<int> dead = null;
foreach (var kv in u.Buffs)
if (kv.Value.ExpireTick <= now)
(dead ??= new List<int>()).Add(kv.Key);
if (dead != null)
foreach (var id in dead) u.Buffs.Remove(id);
}
}
public void RecalcStats(Unit u)
{
int power = 0;
lock (u.SyncRoot)
{
foreach (var b in u.Buffs.Values)
power += b.Stack * b.PerStackPower;
}
Volatile.Write(ref u.AttackPower, power);
}
적용 시점에 만료된 항목을 같은 락에서 즉시 정리하면(lazy expire) 스윕이 놓친 항목도 스탯에 새지 않는다. 위에선 명료성을 위해 분리.
더 나은 설계
1) 단일 액터(유닛별 입력 큐)
- 한 유닛의 적용/스윕/재계산/공격을 단일 스레드로 직렬 처리하면 락이 사라지고 모든 TOCTOU 가 구조적으로 불가능. 필드 서버에서 흔한 패턴. 트레이드오프: 유닛 간 병렬성은 샤딩으로 확보.
2) Lazy expiration (스윕 부하 분산)
- 모든 유닛을 주기 스윕하는 대신, 버프를 읽는 시점(재계산/조회)에 만료를 판정해 거른다. 타이머는 "다음 만료까지" 최소 힙으로 깨워 O(log n) 처리(타이머 휠/우선순위 큐).
3) 중첩·갱신 정책 명시
- 갱신 시 지속시간을 "새로 덮어쓰기 vs 남은 시간에 더하기", 중첩별 개별 만료 vs 단일 만료 등 룰을 데이터로 정의. 디버프 면역/감쇄(diminishing returns)도 같은 임계 구역에서.
4) 스탯 재계산 트리거 최소화
- 매 틱 전체 재계산 대신, 버프 변경 이벤트(적용/만료) 시에만 dirty 플래그로 재계산. 대규모 전투에서 CPU 절감.
면접 포인트
- 면접관이 듣고 싶은 핵심: 세 경로(쓰기·정리·읽기)가 같은 상태를 공유할 때 일관성을 어떻게 보장하나 — 유닛 단위 직렬화 + 순회/제거 분리 + 단조 시계.
- 예상 질문:
- "왜 갱신했는데 버프가 사라지나?" → 적용과 스윕이 비원자라, 스윕이 갱신 직전의
ExpireAt을 보고 만료 처리(유령 만료). 같은 락으로 묶어야 한다. - "왜 벽시계가 문제인가?" → NTP/수동 보정으로 점프 가능. 지속시간은 단조 시계로.
- "MaxStack 초과는 어떻게 막나?" →
if(Stack<Max) Stack++를 락/원자로. check-act 분리.
- "왜 갱신했는데 버프가 사라지나?" → 적용과 스윕이 비원자라, 스윕이 갱신 직전의
변별 메모: content11(스킬 사용 검증/쿨다운)은 사용 가능 여부의 서버 권위 판정이 축이고, 본 문제는 적용 후 상태(버프 목록)의 동시 변경·만료 타이밍·중첩 경계가 축이다. session14(브로드캐스트 순회-수정)와 "순회 중 수정" 증상은 닮았으나, 본 문제는 거기에 더해 갱신 vs 만료 타이밍 경합·중첩 상한·시계 정확성 이라는 게임로직 정합성이 핵심이다.
해설 — 버프/디버프 중첩·만료 타이밍 경합 (C++)
난이도: 중
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
세 메서드가 같은 Unit::buffs 를 락 없이 동시 접근한다. (B)의 SweepExpired 는
erase(it) 후 ++it 를 하는데, unordered_map::erase 는 지운 반복자를 무효화하므로
그다음 ++it 는 Use-After-Free / UB(C# 처럼 예외도 아님). 적용과 스윕이 겹치면 갱신
직후 버프가 만료 판정에 걸려 사라지는 유령 만료, 비원자 if(stack<max) stack++ 로
maxStack 초과가 난다. 만료 기준이 time(nullptr)(벽시계, 초 단위)라 시간 점프·낮은
해상도 문제까지 있다. 정답 한 줄: 유닛 단위 mutex(또는 단일 액터)로 직렬화하고, erase 는
반환 반복자(it = erase(it))로 안전 순회하며, 만료는 steady_clock 기준으로 판정한다.
문제점
(B) erase 후 ++it — 반복자 무효화 UB (메모리/동시성) ★간판
- 증상:
for (it=begin; it!=end; ++it) { ... buffs.erase(it); }—erase(it)는it을 무효화한다. 무효 반복자를++하면 UB(크래시/무한루프/메모리 손상). C++ 은 검출하지 않는다. - 재현 조건: 만료된 버프가 하나라도 있으면 그 즉시 발현.
- 근본 원인: 연관 컨테이너 순회 삭제는
it = buffs.erase(it);패턴으로 해야 한다.
(A)+(B) 적용·스윕 데이터 레이스 — 유령 만료 / 컨테이너 손상 (동시성) ★간판
- 증상: 타이머 스레드의 스윕과 로직 스레드의
find/operator[]삽입이 락 없이 겹치면unordered_map의 버킷/노드가 동시 변경되어 UB. 또한 T1 이expireAt을 갱신하기 직전 T2 가 옛 값을 보고 만료 제거 → 방금 건 버프가 사라진다. - 근본 원인: 공유 가변 컨테이너에 임계 구역 부재.
unordered_map은 동시 쓰기 안전 X.
(A) 비원자 중첩 증가 — maxStack 초과 (동시성)
- 증상: 두 스레드가 동시에
stack < maxStack을 보고 각각stack++→ 초과. - 근본 원인: check-then-act 비원자. 락 안에서 처리.
(C) RecalcStats 락 없는 순회 — 손상/과대 계산 (동시성)
- 증상: 재계산이 스윕/적용과 겹치면 순회 중 컨테이너 변경으로 UB 또는 만료 직전 값
합산. 캐시
attackPower가 잘못 굳는다. - 근본 원인: 읽기 경로도 같은 락/스냅샷으로 보호 필요.
time(nullptr) 만료 — 시간 점프 + 1초 해상도 (정확성)
- 증상: 벽시계라 NTP/수동 보정에 점프. 초 단위라 짧은 버프(예: 500ms)를 표현 못 함.
- 근본 원인: 경과시간은
std::chrono::steady_clock(단조, 고해상도)로 판정.
수정안
핵심: ① 유닛 mutex 로 직렬화, ② it = erase(it) 안전 순회, ③ 중첩은 락 안에서,
④ steady_clock 기준 만료.
#include <chrono>
#include <mutex>
using Clock = std::chrono::steady_clock;
struct Buff {
int buffId, stack, maxStack, perStackPower;
Clock::time_point expireAt;
};
struct Unit {
int64_t id;
std::mutex mtx;
std::unordered_map<int, Buff> buffs;
std::atomic<int> attackPower{0};
};
// 지속시간을 ms 단위로 보관(짧은 버프 표현). 생성자도 ms 로 받도록 맞춘다.
class BuffService {
public:
explicit BuffService(int durationMs) : durationMs_(durationMs) {}
void ApplyBuff(Unit& u, int buffId, int maxStack, int perStackPower);
void SweepExpired(Unit& u);
void RecalcStats(Unit& u);
private:
int durationMs_;
};
void BuffService::ApplyBuff(Unit& u, int buffId, int maxStack, int perStackPower) {
auto exp = Clock::now() + std::chrono::milliseconds(durationMs_);
std::lock_guard<std::mutex> lk(u.mtx);
auto it = u.buffs.find(buffId);
if (it != u.buffs.end()) {
Buff& b = it->second;
if (b.stack < b.maxStack) b.stack++; // 락 안 → 초과 없음
b.expireAt = exp; // 갱신
} else {
u.buffs.emplace(buffId, Buff{buffId, 1, maxStack, perStackPower, exp});
}
}
void BuffService::SweepExpired(Unit& u) {
auto now = Clock::now();
std::lock_guard<std::mutex> lk(u.mtx);
for (auto it = u.buffs.begin(); it != u.buffs.end(); ) {
if (it->second.expireAt <= now)
it = u.buffs.erase(it); // 반환 반복자로 안전 진행
else
++it;
}
}
void BuffService::RecalcStats(Unit& u) {
int power = 0;
{
std::lock_guard<std::mutex> lk(u.mtx);
for (auto& kv : u.buffs)
power += kv.second.stack * kv.second.perStackPower;
}
u.attackPower.store(power, std::memory_order_release);
}
더 나은 설계
1) 단일 액터(유닛별 입력 큐)
- 한 유닛의 적용/스윕/재계산/공격을 단일 스레드 직렬 처리하면 락·TOCTOU 가 사라진다. 유닛 간 병렬성은 샤딩으로. 트레이드오프: 핫 유닛(보스) 부하 집중은 별도 고려.
2) Lazy expiration + 타이머 휠
- 전체 주기 스윕 대신 읽는 시점에 만료를 거르고, 타이머 휠/최소 힙으로 "다음 만료"만 깨운다. 대규모 전투에서 O(log n) 또는 O(1) 분산.
3) 중첩·갱신 정책 데이터화
- 지속시간 갱신 방식(덮어쓰기 vs 가산), 중첩별 개별 만료, 디버프 감쇄(DR)를 데이터로 정의해 같은 임계 구역에서 일관 적용.
4) 재계산 트리거 최소화
- 매 틱 전체 재계산 대신 버프 변경 시 dirty 플래그로만 재계산.
면접 포인트
- 면접관이 듣고 싶은 핵심: 연관 컨테이너 순회 삭제의 올바른 관용구(
it = erase(it))와 타이머/로직 스레드 간 공유 상태 직렬화, 그리고steady_clock의 이유. - 예상 질문:
- "
erase(it); ++it;가 왜 UB 인가?" → erase 가 그 반복자를 무효화. 무효 반복자 증가는 정의되지 않음. C++ 은 검출 안 함. - "C# 트윈과 차이?" → C# 은 순회 중 수정 시 예외라도 던지나, C++ 은 조용한 UB.
- "왜
steady_clock?" →system_clock/time()은 벽시계라 보정으로 점프. 지속시간은 단조 시계.
- "
변별 메모: content11(스킬 쿨다운/사용 검증)은 사용 가능 여부 판정이 축, 본 문제는 적용 이후 버프 상태의 동시 변경·만료 타이밍·중첩 경계가 축. C++ 트윈은 반복자 무효화 UB 와 시계 선택을 C# 의 예외/
TickCount64와 대비해 학습 포인트로 삼는다.