← 문제로

21. 일일/주간 초기화 시점과 진행도 갱신이 겹치는 상황 (C#)

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

해설 — 일일/주간 초기화 시점과 진행도 갱신이 겹치는 상황 (C#)

난이도: 상

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

요약

_players(Dictionary)와 각 DailyMission락 없이 게임 워커 다수(AddProgress)와 스케줄러(ResetAll)가 동시에 만진다. ① AddProgress가 새 키를 삽입하는 도중 ResetAllforeach로 순회하면 InvalidOperationException(컬렉션 변경) 또는 손상(A)(D). ② 날짜 비교 후 리셋–누적이 비원자라 ResetAll과 겹치면 진행도 유실/오귀속(B)(C). ③ 리셋 책임이 "스케줄러 전역 리셋" 과 "AddProgress 자가 리셋" 두 곳에 중복돼 경계에서 증가분이 초기화로 덮이거나 어제 날짜에 귀속된다. 정답 한 줄: Dictionary/엔트리 접근을 동기화하고, 리셋 책임을 lazy 자가 리셋 한 곳으로 단일화하며, 리셋–누적을 원자화한다.


문제점

(A)(D) 순회 중 삽입 → 컬렉션 변경 예외/손상 — 동시성 ★간판

  • 분류 태그: data race / collection modified during enumeration.
  • 증상: ResetAllforeach (var kv in _players) 도중 다른 스레드 AddProgress_players[playerId] = dm 로 새 키 삽입 → InvalidOperationException("Collection was modified"). 동시 읽기/쓰기로 내부 버킷 손상·무한 루프도 가능.
  • 재현조건: 자정 리셋 순회와 신규 플레이어 활동이 겹침.
  • 근본 원인: 공유 Dictionary 동기화 부재.

(B)(C) 리셋–누적 비원자 → 유실/오귀속 (동시성·정합) ★간판

  • 분류 태그: lost update / TOCTOU.
  • 증상 1(유실): T1 dm.Progress += amount 직전 T2 ResetAlldm.Progress=0 → 상호 덮어쓰기로 증가분 또는 리셋이 사라진다.
  • 증상 2(오귀속/이중 리셋): 자정 직후 ResetAll이 아직 안 돈 사이 AddProgressdm.Day != currentDay자가 리셋 후 누적 → 직후 ResetAll이 또 0 으로 → 방금 쌓은 진행도 증발.
  • 근본 원인: 리셋 책임 중복 + 비원자 갱신.

(정합) Progress/ClaimedToday 일관성 — 정합

  • 증상: 부분 리셋이 관측되면 "Progress=0 인데 ClaimedToday=true" 모순(보상 누락/중복).

수정안

핵심: 엔트리 단위 lock + lazy 리셋으로 책임 단일화. ConcurrentDictionary로 map 안전화.

public class DailyMission
{
    public int  Day = 0, Progress = 0, Target = 10;
    public bool ClaimedToday = false;
    public readonly object Gate = new();
}

public class DailyMissionManager
{
    private readonly System.Collections.Concurrent.ConcurrentDictionary<long, DailyMission>
        _players = new();

    public void AddProgress(long playerId, int amount, int currentDay)
    {
        if (amount <= 0) return;
        var dm = _players.GetOrAdd(playerId, _ => new DailyMission());
        lock (dm.Gate)
        {
            if (dm.Day != currentDay)            // lazy 리셋: 접근 시 1회
            { dm.Day = currentDay; dm.Progress = 0; dm.ClaimedToday = false; }
            dm.Progress += amount;
        }
    }

    public bool IsComplete(long playerId, int currentDay)
    {
        if (!_players.TryGetValue(playerId, out var dm)) return false;
        lock (dm.Gate)
        {
            if (dm.Day != currentDay) return false;   // 날짜 지났으면 미완료
            return dm.Progress >= dm.Target;
        }
    }
    // 전역 ResetAll 제거: 리셋은 lazy 로 단일화.
    // 자정 정산(미수령 보상 등)이 필요하면 멱등한 별도 배치로 분리.
}

포인트

  • lazy 리셋: 전역 순회 대신 엔트리 접근 시 Day 비교로 1회 리셋 → 순회-삽입 충돌과 리셋 경합이 동시에 사라진다.
  • ConcurrentDictionary.GetOrAdd로 삽입 안전 + 엔트리 락으로 리셋–누적 원자화.
  • 조회도 currentDay를 받아 "지난 날 진행도"를 0으로 취급(일관 읽기).

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

  1. 세대(epoch) 기반 lazy 리셋: 전역 currentDay만 올리고 엔트리는 지연 리셋 → 자정에 대량 행을 건드리지 않아 스파이크 없음. 조회 시 day 비교 필요.
  2. 샤딩 락: 엔트리별 락 객체가 부담이면 N개 샤드 락으로 절충.
  3. Interlocked 패킹: long 에 (day<<32 | progress) 패킹 후 CAS 로 lock-free 갱신. 구현 난도↑.
  4. 영속화 멱등: 자정 배치를 멱등 키로 이중 실행 방지, 재기동 시 day 비교로 복구.

면접 포인트 (예상 질문)

  1. foreach 순회 중 다른 스레드가 Dictionary 에 키를 넣으면 왜 예외가 나는가?
  2. 전역 일괄 리셋 vs lazy 리셋의 동시성·부하 트레이드오프는?
  3. 리셋 직후 들어온 진행도가 "어제"에 귀속되는 인터리빙과, lazy 리셋이 이를 막는 원리는?