← 문제로

25. 업적 달성 판정과 동시 다발 이벤트 집계

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

해설 — 업적 달성 판정과 동시 다발 이벤트 집계

난이도: 중상

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

요약

OnProgress 가 공유 Achievement(Progress, Unlocked)와 Dictionary 들을 락 없이 동시 갱신한다. (A) ach.Progress += amount 는 읽기-수정-쓰기라 동시 실행 시 증가 유실 (몹을 잡아도 카운트가 누락). (B) "Unlocked 검사 → Progress 증가 → >= Threshold 검사 → 달성/보상" 이 원자 단위가 아니라, 두 이벤트가 임계 근처에서 동시에 들어오면 둘 다 미달성을 통과한 뒤 둘 다 임계 도달을 보고 둘 다 보상한다(이중 달성·보상 중복). (C) Dictionary 는 스레드세이프하지 않아 동시 읽기/쓰기/등록이 손상·예외를 낸다. 정답 한 줄: (플레이어,업적) 단위로 진행도 증가~달성 확정을 원자화하고, "달성 확정" 을 정확히 한 번만 일어나게(락 또는 CAS) 하여 보상을 단 한 번만 지급한다.

변별: problem14(퀘스트 보상 중복 수령 — 단일 요청의 멱등성/더블클릭)와 달리, 본 문제는 여러 스레드의 누적 카운터 집계 정확성 + 임계 도달 시 단 1회 달성 확정 이 핵심이다.


문제점

(A) 진행도 비원자 누적 — 증가 유실 (동시성) ★간판

  • 분류 태그: lost update.
  • 증상: ach.Progress += amountread → add → write. 두 스레드가 각각 +1 하면 둘 다 옛 값을 읽고 +1 해 써, 실제로는 +1 만 반영(1 유실). "몹 100마리" 가 영원히 99 에서 멈출 수 있다.
  • 재현조건: 같은 (플레이어,업적)에 이벤트가 동시 도착.
  • 근본 원인: 복합 연산에 동기화 없음.

(B) 달성 판정 TOCTOU — 이중 달성/보상 중복 (동시성·정합) ★간판

  • 분류 태그: check-then-act.
  • 증상: Threshold=100, Progress=99. 두 스레드가 각각 +1:
    • T1: Unlocked=false 통과 → Progress = 100
    • T2: Unlocked=false 통과 → Progress = 101(또는 인터리빙에 따라 100)
    • T1: Progress>=100? true → Unlocked=true, 보상 지급
    • T2: Progress>=100? true → 보상 다시 지급 Unlocked 검사와 설정 사이가 열려 있어 둘 다 보상 분기에 들어간다.
  • 근본 원인: 진행 증가와 "달성 확정"이 같은 임계구역이 아니고, Unlocked 전이가 한 번만 일어나도록 보장되지 않는다(check-then-set).

(C) 비스레드세이프 Dictionary — 데이터 경쟁/예외 (동시성)

  • 증상: _byPlayer/map 에 대한 동시 TryGetValue/Register 인덱싱은 내부 구조를 손상시키고 InvalidOperationException(컬렉션 변경) 또는 무한 루프를 낸다. Register 가 로그인 스레드에서, OnProgress 가 워커에서 동시에 일어나면 위험.
  • 근본 원인: 공유 컬렉션에 동기화/동시성 컬렉션 미사용.

수정안

핵심: (플레이어,업적) 단위 락으로 증가~달성 판정을 원자화, 달성은 한 번만 확정.

public class Achievement
{
    public int  AchievementId;
    public long Threshold;
    public long Progress;
    public bool Unlocked;
    public readonly object Gate = new();
}

public class AchievementService
{
    // 동시 등록/조회 대비 — 동시성 컬렉션
    private readonly System.Collections.Concurrent.ConcurrentDictionary<long,
        System.Collections.Concurrent.ConcurrentDictionary<int, Achievement>> _byPlayer = new();
    private readonly Action<long, int> _grantReward;

    public void OnProgress(long playerId, int achievementId, long amount)
    {
        if (amount <= 0) return;
        if (!_byPlayer.TryGetValue(playerId, out var map)) return;
        if (!map.TryGetValue(achievementId, out var ach)) return;

        bool justUnlocked = false;
        lock (ach.Gate)
        {
            if (ach.Unlocked) return;              // 이미 달성 → 누적/보상 없음
            ach.Progress += amount;                // 락 안이라 유실 없음
            if (ach.Progress >= ach.Threshold)
            {
                ach.Unlocked = true;               // 달성 확정: 정확히 한 번
                justUnlocked = true;
            }
        }
        if (justUnlocked)
            _grantReward(playerId, achievementId); // 부작용은 락 밖에서(단 한 번)
    }

    public void Register(long playerId, Achievement ach)
    {
        var map = _byPlayer.GetOrAdd(playerId,
            _ => new System.Collections.Concurrent.ConcurrentDictionary<int, Achievement>());
        map[ach.AchievementId] = ach;
    }
}

포인트

  • Unlocked 검사 + Progress 증가 + Unlocked 확정이 같은 락 → 하나의 스레드만 justUnlocked.
  • 보상 콜백은 락 밖에서(재진입/데드락 회피).
  • 컬렉션은 ConcurrentDictionary 로 동시 등록/조회 안전.

lock-free 대안

ProgressInterlocked.Add(ref Progress, amount) 로 누적한다(반환값 = 누적 값). 누적 후 값이 처음으로 >= Threshold 가 된 그 스레드만 Interlocked.CompareExchange(ref _unlockedFlag, 1, 0) == 0 으로 달성을 확정해 보상한다. ("이전 값 < Threshold && 이후 값 >= Threshold" 판정으로 경계 한 번만 통과.) 트레이드오프: 음수 amount/리셋이 있으면 경계 판정이 복잡해진다.


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

  1. 이벤트 소싱 + 단일 집계기: 진행 이벤트를 (플레이어,업적) 파티션 큐로 보내 한 스레드가 순차 집계하면 락 없이 원자성·정확성. 트레이드오프: 파티셔닝/지연.
  2. 멱등 보상(지급 측에서 한 번 더 방어): _grantReward 자체가 (플레이어,업적) 키로 멱등(중복 무시)하게 → 판정 버그가 나도 이중 지급 방지(problem17 멱등성 연계).
  3. 원자 카운터 + 단조 unlock 플래그: 위 lock-free 안. 카운터·플래그를 DB/Redis 의 원자 연산으로 올리면 다중 게임서버에서도 정확. 트레이드오프: 왕복 비용.
  4. 배치 집계: 고빈도 이벤트는 스레드로컬로 모았다가 주기적으로 원자 반영(경합 감소). 트레이드오프: 달성 판정 지연(준실시간).

면접 포인트 (예상 질문)

  1. Threshold 근처에서 두 이벤트가 동시에 들어올 때 "둘 다 달성 분기"에 들어가는 인터리빙을 단계별로 설명하라. Unlocked 플래그만으로 왜 부족한가?
  2. Interlocked.Add 의 반환값을 이용해 "임계를 처음 넘긴 스레드" 만 보상하는 코드를 써보라.
  3. 다중 게임서버에서 같은 플레이어의 업적 이벤트가 분산돼 들어올 때 정확한 집계와 1회 달성을 어떻게 보장하겠는가?