← 문제로

10. 파티 전리품 분배 + 기여도 랭킹 갱신

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

해설 — 파티 전리품 분배 + 기여도 랭킹 갱신

난이도: 최상

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

요약

이 코드는 세 개의 공유 가변 상태(전리품 풀, 굴림 목록, 랭킹 점수)어떤 락도 없이 동시 수정한다. 그 결과 (1) 같은 전리품이 두 명에게 가는 복제 분배, (2) 굴림 제출/비교의 레이스로 승자 오선정/누락, (3) 랭킹 점수의 고전적 lost update, (4) 클라가 보낸 굴림값을 검증 없이 신뢰하는 치팅, (5) 여러 Dictionary/List 동시 수정으로 인한 자료구조 손상이 한꺼번에 터진다. 동시성·정합성·치팅이 입체적으로 얽힌 최상급 문제다. 핵심은 "분배(승자선정+지급+풀제거+점수가산)는 하나의 직렬화된 트랜잭션이어야 한다" 와 "락 객체가 아예 없다는 점" 을 동시에 보는 것이다.


문제점

(G)+(I)+(L) 분배 전체가 무락 비원자 — 복제 분배 + lost update (정확성/동시성) ★간판

  • 증상: 같은 전리품이 두 명에게 분배(복제)되고, 랭킹 점수는 한 번만 가산되거나 엉뚱하게 덮어쓰인다.
  • 재현 조건:
    • 같은 lootId 에 대한 Distribute 가 두 스레드에서 동시 호출(분배 트리거 중복, 재전송). 둘 다 (G)에서 loot.Assigned==false 를 관측 → 둘 다 (I)에서 서로 다른 "최고 굴림자" 에게 지급(또는 같은 사람에게 두 번) → 둘 다 Assigned=true. 한 아이템이 두 번 지급된다(복제).
    • 랭킹: 두 분배(또는 다른 길드 분배)가 동시에 (K)에서 같은 GuildScore[g] 값을 읽고 각자 cur + value 를 (L)에서 써, 한쪽 가산이 통째로 유실(lost update).
  • 근본 원인: "이미 분배됐는가" 검사(G)와 "분배 확정"(Assigned=true) 사이, 그리고 점수 read(K)-modify-write(L) 사이가 원자적이지 않다. 더 근본적으로 이 클래스에는 공유 상태를 보호하는 락 객체가 존재하지 않는다. 분배는 승자선정→지급→풀제거→ 점수가산이 하나의 임계 구역이어야 하는데 전부 노출돼 있다.

(D)+(E) 굴림 제출 레이스 — 목록 손상 + 승자 누락 (정확성/동시성)

  • 증상: 여러 파티원이 동시에 굴림을 제출하면 List 동시 Add 로 항목 유실/예외, 또는 (D)에서 두 스레드가 모두 새 리스트를 만들어 한쪽 제출이 사라진다.
  • 재현 조건: SubmitRoll 동시 호출. (D) TryGetValue 가 둘 다 실패 → 둘 다 새 List_rolls[lootId] 에 대입 → 먼저 넣은 리스트의 굴림이 통째로 버려진다. 또는 같은 List 에 동시 Add → 내부 배열 resize 중 손상.
  • 근본 원인: Dictionary/List 가 스레드 안전하지 않은데 락 없이 동시 변경. get-or-create 패턴이 원자적이지 않다.

(H)+(B) 굴림 비교가 진행 중인데 분배 시작 — race + 클라 신뢰 (정확성/보안)

  • 증상: 아직 모든 파티원의 굴림이 안 들어왔는데 Distribute 가 돌면 불완전한 집합에서 승자 선정. 또 winner 가 동률일 때 tie-break 규칙이 없어 비결정적.
  • 클라 신뢰 결함(★치팅): LootRoll.Roll클라가 보낸 값이다. 어뷰저가 roll=int.MaxValue 를 보내면 항상 승리. 굴림은 서버 권위 영역인데 클라가 정한다.
  • 근본 원인: 굴림 수집 완료 조건(모든 파티원 제출/타임아웃)과 분배 시점이 동기화되지 않고, 굴림값 검증/서버 생성이 없다.

(C)+(L) 랭킹 GuildScore 비보호 — lost update + 손상 (정확성/동시성)

  • 위 (L)의 lost update 외에도, GuildScore(Dictionary)를 동시 쓰기 하면 rehash 중 손상/크래시. 랭킹은 서버 전역 공유 상태라 모든 분배가 여기로 몰려 최대 경합 지점.

(F)+(J) 풀 접근/제거 비보호 + 예외 (정확성/동시성)

  • _pools[bossId].First(...) 는 없으면 예외(InvalidOperationException). (J) RemoveAll 과 (F) First/다른 분배의 순회가 동시에 일어나면 컬렉션 변경 예외/손상.

(A) Assigned 플래그만으로 멱등성 — 영속/크래시 취약 (정확성)

  • 인메모리 플래그라 지급(I) 후 (J)/(L) 전에 크래시하면 재처리 시 재분배 가능. 분배 전체가 복구 가능한 단일 트랜잭션이 아니다.

수정안

핵심: ① 보스(또는 loot) 단위 락을 도입해 굴림 수집과 분배를 직렬화, ② 분배 전체 (승자선정→지급→풀제거→점수가산)를 하나의 임계 구역으로, ③ 랭킹은 별도 락(또는 원자 연산)으로 보호하되 락 순서를 고정(loot 락 → board 락)해 데드락 회피, ④ 굴림은 서버 권위로 생성/검증, ⑤ 분배 완료 조건(전원 제출/타임아웃) 명시.

public class LootService
{
    private readonly Dictionary<long, Player> _players;
    private readonly Leaderboard _board;
    private readonly object _boardLock = new object();
    // boss 단위 동기화 객체(분배·굴림 수집 직렬화)
    private readonly Dictionary<long, object> _bossLocks = new();
    private readonly object _bossLocksGuard = new object();

    private readonly Dictionary<long, List<LootItem>> _pools = new();
    private readonly Dictionary<long, List<LootRoll>> _rolls = new();

    private object BossLock(long bossId)
    {
        lock (_bossLocksGuard)
        {
            if (!_bossLocks.TryGetValue(bossId, out var l)) { l = new object(); _bossLocks[bossId] = l; }
            return l;
        }
    }

    // 굴림은 서버가 생성하는 것이 원칙. 클라는 "굴린다" 의사만 보낸다.
    public void SubmitRoll(long bossId, long lootId, long playerId)
    {
        lock (BossLock(bossId))
        {
            int serverRoll = Random.Shared.Next(1, 101);   // 서버 권위 굴림(1..100)
            if (!_rolls.TryGetValue(lootId, out var list))
            {
                list = new List<LootRoll>();
                _rolls[lootId] = list;
            }
            // 중복 제출 방지: 같은 player 가 이미 굴렸으면 무시
            if (list.Exists(r => r.PlayerId == playerId)) return;
            list.Add(new LootRoll { PlayerId = playerId, Roll = serverRoll });
        }
    }

    public bool Distribute(long bossId, long lootId)
    {
        lock (BossLock(bossId))   // 분배 전체를 보스 단위로 직렬화
        {
            if (!_pools.TryGetValue(bossId, out var pool)) return false;
            var loot = pool.Find(i => i.LootId == lootId);
            if (loot == null || loot.Assigned) return false;     // 멱등: 이미 분배됨

            if (!_rolls.TryGetValue(lootId, out var rolls) || rolls.Count == 0)
                return false;
            // (완료 조건: 전원 제출 또는 타임아웃 확인 — 정책에 따라 여기서 검사)

            // 승자 선정(동률 tie-break: PlayerId 등 결정적 규칙)
            var winner = rolls.OrderByDescending(r => r.Roll).ThenBy(r => r.PlayerId).First();
            if (!_players.TryGetValue(winner.PlayerId, out var p)) return false;

            // 지급 + 풀 제거 + 분배 확정(같은 임계 구역 → 복제 불가)
            p.GiveItem(loot.ItemId);
            loot.Assigned = true;
            pool.RemoveAll(i => i.LootId == lootId);
            _rolls.Remove(lootId);

            // 랭킹 갱신: 별도 락. 순서 고정(bossLock 보유 → boardLock) → 데드락 없음
            lock (_boardLock)
            {
                long cur = _board.GuildScore.TryGetValue(p.GuildId, out var s) ? s : 0;
                _board.GuildScore[p.GuildId] = cur + loot.Value;   // RMW가 락 안 → lost update 없음
            }
            return true;
        }
    }
}

락 순서는 항상 BossLock → _boardLock 로 고정한다. _boardLock 을 잡은 채 다시 BossLock 을 잡는 경로가 없으므로 역전이 없어 데드락이 발생하지 않는다. _bossLocks 자체의 get-or-create 도 _bossLocksGuard 로 원자화한다.


더 나은 설계

1) 분배를 영속 트랜잭션으로(복제는 환불 불가)

인메모리 락은 크래시/멀티 인스턴스에 무력하다. 아이템 복제는 경제를 파괴하므로:

  • 분배를 DB 트랜잭션으로: UPDATE loot SET assigned=1, owner=? WHERE loot_id=? AND assigned=0affected=1 일 때만 지급·점수 가산을 같은 트랜잭션에 커밋. DB가 "한 번만 분배" 를 직렬화로 보장. 트레이드오프: DB 왕복. 복제 방지가 절대 우선이라 정당.

2) 랭킹은 경합 핫스팟 — 분리·집계로

  • 모든 분배가 전역 GuildScore 로 몰리면 단일 락이 병목이다. 점수 가산을 이벤트로 append(원장) 하고 랭킹은 주기적/비동기 집계(또는 샤딩된 카운터 + 머지)로 도출하면 핫스팟이 사라진다. 실시간 정확도와 처리량의 트레이드오프를 상황에 맞게 선택.
  • 원자적 증가만 필요하면 DB UPDATE ... SET score = score + ? 또는 Interlocked/분산 카운터로 lost update 를 구조적으로 제거.

3) 굴림은 서버 권위 + 결정적 연출

  • 굴림값은 서버 RNG로 생성. 연출 동기화가 필요하면 시드를 내려 클라가 재생. 공정성 증명이 필요하면 commit-reveal. 분배 완료 조건(전원 제출/타임아웃)을 FSM으로 명시해 "불완전 집합 분배" 를 차단.

4) 단일 액터 모델

  • 보스 인스턴스(레이드)를 단일 스레드(액터) 가 소유해 굴림 수집·분배를 직렬 처리하면 락이 사라지고 복제·레이스가 구조적으로 불가능. 랭킹만 별도 서비스로 메시지 패싱.

면접 포인트

  • 면접관이 듣고 싶은 핵심: 공유 상태가 셋(풀·굴림·랭킹)인데 락이 아예 없다는 것을 먼저 보고, "분배는 승자선정+지급+풀제거+점수가산이 하나의 트랜잭션" + "랭킹은 lost update 핫스팟" + "굴림은 서버 권위" 를 한 번에 엮는 것. 락을 두 개 이상 쓰게 되면 락 순서 고정으로 데드락을 피한다는 점까지 말하면 최상급.
  • 예상 질문:
    1. "같은 전리품이 두 명에게 가는 경로를 정확히 설명하라." → Distribute 동시 호출이 둘 다 Assigned==false 를 보고 둘 다 지급(TOCTOU). 검사-지급-확정을 한 락/한 조건부 UPDATE로 직렬화해야 한다.
    2. "랭킹 점수가 사라지는(lost update) 이유와 해법은?" → read-modify-write 가 비원자. 같은 락 안에서 RMW, 또는 DB score = score + ?, 또는 이벤트 원장 + 비동기 집계로 핫스팟 분산.
    3. "락을 둘(boss, board) 쓰면 데드락은?" → 항상 boss→board 순서로만 잡는다. board 를 잡은 채 boss 를 잡는 경로를 만들지 않으면 역전이 없어 안전.
    4. "클라가 보낸 roll 을 쓰면?" → int.MaxValue 로 항상 승리. 서버가 굴려야 하고, 연출이 필요하면 시드/commit-reveal.