← 문제로

54. 재적용 버프의 조기 만료 (stale 일회성 타이머 + 무조건 제거)

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

해설 — 재적용 버프의 조기 만료 (stale 일회성 타이머 + 무조건 제거)

난이도: 중

요약

ApplyOrRefresh가 재적용될 때마다 만료 시각 (A)만 갱신하고, 매번 새 일회성 제거 콜백을 예약한다. 그러나 먼저 예약된 오래된 콜백은 취소되지 않는다. 그 오래된 콜백이 원래 예정 시각(최초 부여 +10초)에 실행되면 RemoveExpired가 (B)에서 만료 시각을 확인하지 않고 무조건 버프를 제거한다. 결과적으로 재적용해 놓은 버프가 갱신된 만료 시각보다 훨씬 일찍 사라진다. 게다가 _buffs 딕셔너리를 전투 스레드와 타이머 스레드가 락 없이 공유해 자료구조 자체가 레이스에 노출된다.

문제점

  • 분류태그: stale timer / check 없는 무조건 삭제 + 공유 자료구조 데이터 레이스
  • 증상: 버프를 계속 재적용(도트 갱신)했는데도 10초마다 한 번씩 버프가 툭 끊긴다. 특히 재적용 간격이 지속시간보다 짧을 때 "왜 갱신했는데 사라지지?" 하는 버그로 보고된다.
  • 재현조건: t=0 부여(만료 10s, 콜백#1 예약 t=10) → t=6 재적용(만료 16s, 콜백#2 예약 t=16). t=10에 콜백#1이 실행되어 버프를 제거 → 실제 만료(16s) 전에 사라짐.
  • 근본원인: (1) 만료 판단의 단일 진실 원천이 ExpireAtMs인데, 제거 콜백이 이 값을 보지 않고 예약 시점만 믿는다. (2) 재적용이 이전 예약을 취소/재조정하지 않는다. (3) 공유 딕셔너리 접근이 비동기·비원자다.

수정안

제거 콜백을 "그 시점에 실제로 만료됐는지" 재확인하도록 만들고(멱등·자기검증), 공유 상태를 락으로 보호한다.

private readonly object _gate = new object();

public void ApplyOrRefresh(int playerId, long nowMs)
{
    lock (_gate)
    {
        if (!_buffs.TryGetValue(playerId, out var buff))
        {
            buff = new Buff();
            _buffs[playerId] = buff;
        }
        buff.ExpireAtMs = nowMs + DurationMs;
    }
    // 예약은 계속 여러 개 걸려도 무해하도록, 콜백이 스스로 만료를 재검증한다.
    _scheduler.ScheduleAfter(DurationMs, () => RemoveExpired(playerId));
}

public void RemoveExpired(int playerId)
{
    lock (_gate)
    {
        if (_buffs.TryGetValue(playerId, out var b) && NowMs() >= b.ExpireAtMs)
            _buffs.Remove(playerId);   // 실제로 만료됐을 때만 제거
    }
}

핵심: 여러 개의 stale 콜백이 떠 있어도, 각 콜백이 ExpireAtMs와 현재 시각을 비교하므로 아직 유효한 버프는 절대 지우지 않는다(제거 연산의 멱등·조건화). NowMs()는 콜백 실행 시점의 시계.

더 나은 설계

  • 재조정형 타이머: 재적용 시 이전 예약을 취소(또는 토큰/세대 번호로 무효화)하고 하나만 유지. 트레이드오프: 취소 가능한 스케줄러 API와 토큰 관리 필요. stale 콜백이 애초에 실행되지 않아 스캔 비용도 준다.
  • 만료를 이벤트가 아니라 폴링으로: 타임휠/우선순위 큐에 (ExpireAtMs, playerId)를 넣고, 만료 스캔이 항상 최신 ExpireAtMs만 보게 한다. 재적용은 키의 우선순위만 갱신. 대규모 버프에 유리.
  • 세대(generation) 토큰: 콜백에 부여 당시 세대 번호를 캡처시키고, 제거 시 현재 세대와 다르면 무시(ABA 회피). 취소 API가 없을 때 유용.

면접 포인트

  • "삭제/만료 콜백은 예약 시점이 아니라 실행 시점의 상태로 판단해야 한다"는 원칙(자기검증·멱등).
  • 재적용(refresh)에서 이전 타이머를 취소하지 않으면 왜 조기 만료가 생기는가, 세대 토큰/타임휠이 이를 어떻게 없애는가.
  • 공유 자료구조(딕셔너리)에 서로 다른 스레드가 접근할 때의 최소 보호 범위.