← 문제로

43. 경험치 누적·레벨업의 비원자 처리 (C#)

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

해설 — 경험치 누적·레벨업의 비원자 처리 (C#)

난이도: 중

요약

같은 플레이어에게 여러 로직 스레드가 동시에 GrantXp를 호출하는데 Xp, Level, StatPoints 갱신이 아무 동기화 없이 검사-처리(read-modify-write)로 이뤄진다. (A)의 Xp = Xp + amount가 lost update 로 경험치를 유실하고, (B)~(C)의 레벨업 루프가 두 스레드에서 겹치면 같은 경험치로 레벨/스탯을 이중 적용하거나 레벨을 건너뛰며, Xp가 음수가 되기도 한다.

문제점

1) [비원자 RMW] 경험치 누적 유실 (증상: 총 경험치 < 부여량)

  • 증상: (A) Xp = Xp + amount는 읽기→더하기→쓰기 3단계다. 두 스레드가 같은 Xp를 읽고 각자 더해 쓰면 한쪽 증가분이 사라진다(lost update). 파티 처치 보상과 퀘스트 보상이 동시에 들어오면 총 경험치가 부여한 합보다 적어진다.
  • 재현조건: 동일 플레이어에 대한 동시 GrantXp.

2) [레벨업 루프 경합] 레벨/스탯 이중 적용·건너뜀 (증상: 스탯 과다/누락, 레벨 스킵)

  • 증상: (B)의 while (Xp >= XpToNext(Level))와 (C)의 Level++; StatPoints += 5가 락 없이 두 스레드에서 인터리브되면:
    • 둘 다 Xp >= 요구치를 통과한 뒤 각자 Level++·StatPoints += 5 → 같은 경험치로 스탯을 두 번 준다.
    • Xp -= XpToNext(Level)와 다른 스레드의 Level++가 겹치면 차감 기준 레벨이 어긋나 Xp음수가 되거나 요구치가 뒤바뀌어 레벨을 건너뛴다.
  • 근본원인: Xp/Level/StatPoints가 하나의 일관된 상태인데 각 필드가 개별·비원자로 갱신된다.

3) [가시성] 필드 동기화 부재 (증상: stale 읽기)

  • Snapshot을 읽는 스레드가 배리어 없이 Level/Xp를 읽으면 서로 다른 시점의 필드가 섞여 보일 수 있다.

수정안

플레이어 진행 상태를 한 단위로 락(또는 단일 스레드 직렬화)으로 원자화한다.

public sealed class PlayerProgress
{
    private readonly object _gate = new();
    private long _xp;
    private int _level = 1;
    private int _statPoints;
    private static long XpToNext(int level) => 100L * level;

    public void GrantXp(long amount)
    {
        if (amount <= 0) return;
        lock (_gate)
        {
            _xp += amount;
            while (_xp >= XpToNext(_level))   // 남는 경험치는 다음 레벨로 이월
            {
                _xp -= XpToNext(_level);
                _level++;
                _statPoints += 5;
            }
        }
    }

    public (int level, long xp, int sp) Snapshot()
    {
        lock (_gate) return (_level, _xp, _statPoints);
    }
}
  • 누적과 레벨업 루프 전체가 한 임계구역이라 lost update·이중 적용·음수·건너뜀이 모두 사라진다.
  • 락 경합이 걱정되면 **플레이어별 단일 스레드(액터)**로 그 플레이어의 모든 진행 갱신을 직렬화한다(플레이어 간 병렬성은 유지).

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

  • 경험치 부여를 이벤트 큐로 직렬화: 각 플레이어의 진행 갱신을 한 큐/한 스레드에서 처리하면 락이 사라지고 순서가 보장된다. 트레이드오프: 큐 지연·역압 관리.
  • 레벨 테이블 누적합(prefix sum): while 루프 대신 누적 경험치→레벨을 이분 탐색으로 O(log n)에 계산하면 대량 경험치에서 루프 폭을 줄인다. 트레이드오프: 테이블 유지.
  • 영속화와의 일관성: 레벨업 보상 지급·DB 반영을 한 트랜잭션으로 묶어 부분 반영(스탯만 주고 레벨 롤백 등)을 막는다.

면접 포인트

  • 비원자 RMW(누적)와 검사-처리 루프(레벨업)가 각각 어떤 이상(유실 vs 이중/건너뜀)을 내는지 인터리빙으로 설명.
  • "여러 필드가 하나의 불변식(Xp·Level·StatPoints 정합)을 이룰 때 임계구역을 어디에 둬야 하는가".
  • 락 vs 플레이어별 액터 직렬화의 트레이드오프, 대량 경험치의 레벨 계산 최적화.