← 문제로

13. 시즌 랭킹 마감과 종료 직전 점수 갱신 경합 · C#

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

해설 — 시즌 랭킹 마감과 종료 직전 점수 갱신 경합 · C#

난이도: 중상

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

요약

시즌 경계에서 확정(FinalizeSeason)과 점수 가산(AddScore)이 동기화 없이 경쟁하는 것이 핵심이다. (A) 종료 판정이 평범한(volatile 도 아닌) bool _ended 라 검사-후-가산 사이에 Finalize 가 끼어들어 점수가 유실되거나 비워진/다음 시즌 보드에 오귀속된다. (B) 발생 시각 eventTimeMs 를 무시해 종료 이전 발생·지연 도착분은 버려지고 종료 이후 발생분이 섞인다. (C) Dictionary 동시 접근으로 데이터 손상, (D) FinalizeSeason 이 라이브 딕셔너리를 OrderByDescending 으로 순회하는 도중 워커가 삽입하면 "Collection was modified" 예외(또는 손상)가 난다. 정답의 한 줄: 시각 기준 귀속 + 락으로 스왑 후 스냅샷을 확정.


문제점

(A) 종료 판정이 비원자 플래그 — TOCTOU/가시성 (동시성) ★간판

  • 증상: 종료 직전 점수가 사라지거나 비워진 보드/다음 시즌에 들어간다.
  • 재현 조건: 워커 T1 이 if(_ended) 통과 → 컨텍스트 스위치. 타이머 T2 가 Finalize 로 _ended=true, 정렬/지급, _scores.Clear(), _ended=false. 깨어난 T1 이 비워진 보드에 가산.
  • 근본 원인: 검사와 가산이 한 임계 구역이 아니고, _endedvolatile/lock 없이 스레드 간 가시성도 보장되지 않는다(JIT/CPU 재배열·캐시).

(B) 발생 시각 미사용 — 경계 귀속 오류 (정확성/공정성)

  • 종료 이전 발생했지만 지연 도착한 점수가 유실되고, 종료 이후 발생분이 이번 시즌에 섞인다. 귀속 기준이 "도착 순간"이 아니라 eventTimeMs 여야 한다.

(C) Dictionary 동시 접근 — 데이터 레이스 (동시성)

  • 여러 워커의 _scores[pid]=... 동시 실행은 lost update + 내부 버킷 손상(드물게 IndexOutOfRange/무한루프). 타이머 순회와도 경쟁.

(D) 라이브 컬렉션 순회 중 변경 — 예외/손상 (동시성)

  • _ended=true 가 워커를 즉시 막지 못한다(가시성). 플래그를 막 통과한 워커가 _scores 를 수정하는 동안 OrderByDescending(...).ToList() 가 열거하면 InvalidOperationException (Collection was modified). Clear() 직후 삽입은 유령 데이터.

(보조) Finalize 재진입 — 견고성

  • 두 번 호출되면 보상 이중 지급. 멱등 가드 필요.

수정안

private readonly object _gate = new object();
private Dictionary<long, long> _cur  = new();
private Dictionary<long, long> _next = new();
private bool _finalized = false;

public void AddScore(long pid, long delta, long eventTimeMs)
{
    lock (_gate)
    {
        // (B) 발생 시각으로 귀속
        var board = (eventTimeMs <= _seasonEndMs) ? _cur : _next;
        board.TryGetValue(pid, out long cur);
        board[pid] = cur + delta;
    }
}

public void FinalizeSeason()
{
    Dictionary<long, long> finalized;
    lock (_gate)
    {
        if (_finalized) return;            // 멱등
        _finalized = true;
        finalized = _cur;                   // 라이브 맵을 떼어내고
        _cur = _next;                       // 다음 시즌 승격
        _next = new Dictionary<long, long>();
    }
    // 락 밖: 스냅샷에 대해 정렬/지급
    int rank = 1;
    foreach (var kv in finalized.OrderByDescending(k => k.Value))
    {
        if (rank > TopN) break;
        _sink.Grant(kv.Key, rank++);
    }
}
  • 정렬/지급 같은 무거운 작업은 락 밖에서 스냅샷에 대해 수행 → 워커 블로킹 최소화.
  • 더 단순하게는 ConcurrentDictionary 로 바꾸되, 경계 일관성(스왑/멱등)은 여전히 락/ Interlocked 로 보장해야 한다.

더 나은 설계

1) 단일 액터/채널

  • 보드 갱신·확정을 한 채널(예: System.Threading.Channels)로 직렬화 → 락 제거, 경계 단순. 처리량은 플레이어 해시별 샤딩으로 확장.

2) 시각 워터마크

  • "이 시각 이전 발생분 모두 도착" 워터마크가 _seasonEndMs 를 넘을 때 확정. 지연 이벤트 안전 흡수(스트림 처리 표준).

3) 멱등·내구성

  • 확정 순위/지급 내역을 영속화 후 멱등 키로 지급. 타이머 중복/재기동 이중 지급 방지.

4) 동점 타이브레이커

  • 점수만으로 정렬하면 동점 순위 비결정적. 보조 키(도달 시각 등)로 안정 정렬.

면접 포인트

  • 핵심: 경계 동시성을 플래그 하나로 표현하면 TOCTOU·가시성 문제. 스왑+스냅샷, 발생 시각 귀속, 멱등.
  • 예상 질문:
    1. "_endedvolatile 로만 바꾸면 충분한가?" → 아니다. 가시성은 개선돼도 검사-가산의 원자성(TOCTOU)은 그대로. 임계 구역이 필요.
    2. "지연 도착한 정당 점수를 어떻게 살리나?" → 발생 시각 귀속 + 이중 보드/워터마크.
    3. "정렬 중 'Collection was modified' 가 왜 나나?" → 라이브 컬렉션을 열거하는 동안 다른 스레드가 수정. 스냅샷을 떼어 락 밖에서 처리.