← 문제로

16. 버프/디버프 중첩·만료 타이밍 경합 (서버 권위)

난이도 중
내 리뷰 · C#
해설 · C#

해설 — 버프/디버프 중첩·만료 타이밍 경합 (서버 권위)

난이도: 중

답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계

요약

세 메서드(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 ApplyBuffExpireAt 을 미래로 갱신하는 사이, T2 SweepExpired 가 갱신 직전의 옛 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 절감.

면접 포인트

  • 면접관이 듣고 싶은 핵심: 세 경로(쓰기·정리·읽기)가 같은 상태를 공유할 때 일관성을 어떻게 보장하나 — 유닛 단위 직렬화 + 순회/제거 분리 + 단조 시계.
  • 예상 질문:
    1. "왜 갱신했는데 버프가 사라지나?" → 적용과 스윕이 비원자라, 스윕이 갱신 직전의 ExpireAt 을 보고 만료 처리(유령 만료). 같은 락으로 묶어야 한다.
    2. "왜 벽시계가 문제인가?" → NTP/수동 보정으로 점프 가능. 지속시간은 단조 시계로.
    3. "MaxStack 초과는 어떻게 막나?" → if(Stack<Max) Stack++ 를 락/원자로. check-act 분리.

변별 메모: content11(스킬 사용 검증/쿨다운)은 사용 가능 여부의 서버 권위 판정이 축이고, 본 문제는 적용 후 상태(버프 목록)의 동시 변경·만료 타이밍·중첩 경계가 축이다. session14(브로드캐스트 순회-수정)와 "순회 중 수정" 증상은 닮았으나, 본 문제는 거기에 더해 갱신 vs 만료 타이밍 경합·중첩 상한·시계 정확성 이라는 게임로직 정합성이 핵심이다.