← 문제로

22. 동점자 타이브레이커와 동시 점수 갱신 (C#)

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

해설 — 동점자 타이브레이커와 동시 점수 갱신 (C#)

난이도: 상

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

요약

EntryBestScoreReachedAtMs별개 필드로 두고 락 없이 동시 갱신한다. (A) Submit 이 점수와 시각을 두 번에 나눠 쓰므로 그 사이 Higher 가 읽으면 새 점수 + 옛 시각의 찢어진 쌍으로 순위를 매긴다. (B) cur = e.BestScore 를 읽고 비교 후 쓰는 RMW 라, 두 스레드가 같은 cur 를 읽고 둘 다 통과하면 더 높은 점수가 유실된다(80 이 90 을 덮음). (C) 조건이 newScore >= cur동점에도 ReachedAtMs 를 현재 시각으로 덮어써 "더 이른 도달이 우선" 인 타이브레이커를 깬다. 정답 한 줄: (점수, 시각) 을 하나의 불변 스냅샷으로 묶어 Interlocked.CompareExchange 루프(또는 엔트리 락)로 교체하고, 점수가 "엄격히 클 때만" 갱신해 동점 시 도달 시각을 보존한다.

변별: problem1(zone 통계 단순 lost update), content13(시즌 마감 점수)과 달리, 본 문제는 (점수+도달시각) 복합 키의 원자 갱신 + 타이브레이커 비교 정확성 이 핵심이다.


문제점

(A) (점수, 시각) 비원자 복합 갱신 / 찢어진 읽기 — 정합 (동시성) ★간판

  • 분류 태그: non-atomic compound update / torn read.
  • 증상: e.BestScore = ...e.ReachedAtMs = ... 를 두 번에 나눠 쓴다. 그 사이 Higher 가 읽으면 "새 점수 + 옛 시각" 같은 존재한 적 없는 조합으로 비교한다. 동시 두 Submit 이면 한쪽 점수 + 다른쪽 시각이 섞일 수 있다.
  • 근본 원인: 함께 바뀌어야 할 두 값을 독립 필드로 쪼개 원자 단위를 잘못 잡음.

(B) RMW 비 CAS — 갱신 유실 (동시성) ★간판

  • 분류 태그: lost update / check-then-act.
  • 증상: T1(80)·T2(90)가 같은 cur=70 을 읽고 둘 다 >= 통과 → 인터리빙에 따라 80 이 90 을 덮어 최고점 90 유실.
  • 근본 원인: "현재값을 보고 크면 쓴다" 를 단일 비교-교환으로 하지 않음.

(C) >= 경계 — 타이브레이커 파괴 (정확성/정합)

  • 분류 태그: boundary / semantic.
  • 증상: 동점에도 분기에 들어가 ReachedAtMs = nowMs 로 도달 시각을 최신으로 덮는다. "처음 도달한 시각" 이어야 하는데 늦은 제출이 시각을 늦춰 동점 우선권이 뒤집히거나 순위가 출렁인다.
  • 근본 원인: 갱신은 엄격히 클 때만(>), 동점이면 아무것도 건드리면 안 된다.

(참고) Dictionary 동시성

  • 사전 등록 후 읽기 전용이면 조회는 안전하나, 동시 등록이 있으면 ConcurrentDictionary/락 필요.

수정안

방법 1 — 불변 스냅샷 + Interlocked.CompareExchange (lock-free, 권장)

점수와 시각을 불변 객체 하나로 묶고 참조를 원자 교체:

public sealed class Snap            // 불변
{
    public readonly int  Score;
    public readonly long ReachedAtMs;
    public Snap(int s, long t) { Score = s; ReachedAtMs = t; }
}

public class Entry { public Snap Cur = new Snap(0, 0); }   // 참조 필드

public void Submit(long playerId, int newScore, long nowMs)
{
    if (!_table.TryGetValue(playerId, out var e)) return;
    while (true)
    {
        Snap cur = System.Threading.Volatile.Read(ref e.Cur);
        if (newScore <= cur.Score) return;            // 엄격히 클 때만, 동점은 보존
        var next = new Snap(newScore, nowMs);
        if (Interlocked.CompareExchange(ref e.Cur, next, cur) == cur)
            return;                                   // 성공
        // 실패 시 다른 스레드가 갱신 → 루프 재평가(더 큰 값이면 위에서 종료)
    }
}

public static bool Higher(Entry a, Entry b)
{
    Snap sa = Volatile.Read(ref a.Cur);               // 한 번의 읽기로 일관된 쌍
    Snap sb = Volatile.Read(ref b.Cur);
    if (sa.Score != sb.Score) return sa.Score > sb.Score;
    return sa.ReachedAtMs < sb.ReachedAtMs;           // 동점: 더 이른 도달 우선
}

포인트

  • Snap 한 참조를 읽으면 (점수, 시각) 이 항상 일관된 쌍 → 찢어진 읽기 없음.
  • CompareExchange 루프가 lost update 차단(누가 더 큰 값을 썼으면 <= 로 종료).
  • newScore <= cur.Score엄격히 클 때만 → 동점 시 ReachedAtMs 보존(타이브레이커).
  • 참조 CAS 라 16바이트 패킹/패딩 고민이 없다(GC 가 객체 수명 관리, ABA 무관).

방법 2 — 엔트리 락 (가장 명확)

lock (e.Gate)
{
    if (newScore > e.BestScore) { e.BestScore = newScore; e.ReachedAtMs = nowMs; }
}

비교도 두 엔트리를 정해진 순서로 락(데드락 회피)하고 일관된 쌍을 읽는다.


더 나은 설계 (+트레이드오프)

  1. 64비트 패킹 + Interlocked: 점수(상위 비트)+시각(하위, "이를수록 큰 값" 인코딩)을 long 하나로 묶어 Interlocked.CompareExchange(ref long ...). 단 ms epoch 가 32비트를 넘어 상대 epoch/초 단위로 비트 예산을 맞춰야(정밀도 트레이드오프). 박싱/GC 없음.
  2. 권위는 Redis/DB 원자 연산: 다중 게임서버면 ZADD/Lua 로 점수+타이브레이크 키를 원자 갱신. 트레이드오프: 왕복 비용, 캐시-DB 일관성(problem13 연계).
  3. 불변 스냅샷 전체 교체(랭킹 보드): 보드 전체를 불변 스냅샷으로 만들고 주기적 재계산 → 읽기 무락·일관. 트레이드오프: 준실시간, 메모리.
  4. 단일 정렬 키 환원: "점수×K − 정규화시각" 등으로 타이브레이커를 점수에 내장. 트레이드 오프: 오버플로/정밀도 설계.

면접 포인트 (예상 질문)

  1. BestScoreReachedAtMs 를 각각 갱신하면 "둘 다 단순 대입인데도" 왜 틀리는가? 원자성의 단위를 어떻게 잡아야 하나?
  2. 불변 스냅샷 + Interlocked.CompareExchange(ref Snap) 가 lost update 와 찢어진 읽기를 동시에 해결하는 원리를 설명하라.
  3. >=> 한 글자 차이가 타이브레이커를 어떻게 깨는가? 참조 CAS 와 64비트 패킹 CAS 의 장단점은?