← 문제로

5. 확률 아이템(가챠) 뽑기 + 천장 시스템

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

해설 — 확률 아이템(가챠) 뽑기 + 천장 시스템

난이도: 최상

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

요약

가챠 결과를 클라이언트가 보낸 난수/시드로 결정한다. 이건 단순 버그가 아니라 확률 조작 치트의 정문을 열어둔 것이다. 여기에 천장 카운터의 동시성 버그, 재화 차감-보상 지급의 비원자성, 멱등성 부재, 공유 컬렉션/Random 의 스레드 비안전성, 가중치 매핑 결함까지 겹친다. 보안·동시성·정확성이 한꺼번에 얽힌 최상급 문제.


문제점

(A)(B)(F) 클라이언트가 결과를 결정 — 확률 조작 치트 (보안·치팅) ★최우선

  • 증상: 어뷰저가 ClientRolls 를 최고등급 구간 값으로 채워 보내면 100% 최고등급. 서버는 그 값을 그대로 가중치 매핑에 사용한다.
  • 재현 조건: 패킷 변조로 req.ClientRolls = [최고등급 구간 시작값, ...]. 클라는 신뢰 경계 밖이므로 무조건 가능.
  • 근본 원인: 가챠 결과 결정은 서버 권위 영역인데 입력을 클라에 맡겼다. "연출 동기화" 는 정당한 요구지만 해법은 "서버가 결과를 정하고 클라에 통보→연출" 이지, "클라가 굴려 서버가 받아쓰기" 가 아니다. 시드(B)를 클라가 보내는 것도 동일 죄. → 결정론적 연출이 필요하면 서버가 시드를 생성해 결과와 함께 내려준다.

(E)→(J) 재화 차감과 보상 지급이 비원자적 — 돈만 빠지고 보상 없음 (정확성/동시성)

  • 증상: 골드는 차감됐는데 루프 중간 예외(_banners[req.BannerId] 없음, ClientRolls[i] 인덱스 초과 등)로 일부 보상만 지급되거나 전혀 안 됨.
  • 재현 조건: req.Count = 10 인데 ClientRolls.Length = 3 → 4번째에서 IndexOutOfRangeException. 골드는 이미 1000 빠졌고 보상은 3개만.
  • 근본 원인: 차감(E)은 락 안, 지급(J)은 락 밖. 전체가 하나의 트랜잭션이 아니다. 롤백 경로 없음. 입력 검증(Count == ClientRolls.Length, 배너 존재)도 없다.

(C)(G)(H)(I) 천장 카운터 동시성 — 공유 Dictionary 비보호 + race (동시성/정확성/보안)

  • 증상: 카운터가 손상되거나, 천장이 앞당겨지거나(공짜 최고등급) 영원히 안 옴. 최악엔 Dictionary 내부 손상으로 크래시.
  • 재현 조건: 같은 플레이어가 두 기기에서 동시에 Pull. 두 스레드가 (G)에서 같은 pity 값을 읽고 각자 (I)에서 pity+1 을 써 증가가 한 번만 반영(lost update). 또는 동시 쓰기로 Dictionary rehash 중 손상. Pull 전체가 _pityCounter어떤 락도 없이 읽고 쓴다(player.Lock은 골드만 보호, 천장은 보호 안 함).
  • 근본 원인: 천장 카운터는 플레이어별 공유 가변 상태인데 동기화가 전혀 없다. read-modify-write(증가)가 원자적이지 않다. 게다가 player.Lock과 천장 보호가 분리돼 "한 번의 뽑기" 가 하나의 일관된 상태 전이가 아니다.

(D) System.Random 공유 — 스레드 비안전 (동시성)

  • 증상: 여러 스레드가 _rng 를 동시에 쓰면(현재 코드는 안 쓰지만 정상 구현에선 서버가 굴려야 함) 내부 상태 손상으로 항상 0 반환 같은 고장이 난다(.NET 알려진 함정).
  • 근본 원인: Random 인스턴스 공유. 서버 권위 난수로 고치는 순간 이 함정에 빠진다. → Random.Shared(.NET6+), ThreadLocal<Random>, 또는 암호학적 RNG 사용.

멱등성 부재 — 중복 뽑기 (정확성/보안)

  • 증상: 클라가 같은 뽑기 요청을 재전송하면 재화가 두 번 빠지고 보상도 두 번. 반대로 어뷰저가 의도적 재전송으로 천장 카운터를 교란.
  • 근본 원인: requestId/영수증 기반 멱등성 키가 없다. 요구사항에 명시됐는데 미구현.

가중치 매핑 결함 (정확성)

  • roll % totalWeightroll 범위(0..9999)와 totalWeight 가 다르면 확률 분포가 왜곡된다(modulo bias). 또 roll 이 음수면(클라가 음수 보냄) 음수 인덱스 동작. 서버가 [0, totalWeight) 균등 난수를 직접 뽑아야 분포가 정확하다.

수정안

원칙: ① 결과는 서버 RNG로만 결정(클라 입력은 연출용으로만, 검증 대상), ② 한 번의 뽑기 = 재화·천장·보상·영수증이 하나의 트랜잭션, ③ 멱등성 키, ④ 천장은 플레이어 단위 락(또는 영속 트랜잭션)으로 직렬화.

public List<GachaItem> Pull(Guid requestId, GachaRequest req, Player player)
{
    if (!_banners.TryGetValue(req.BannerId, out var pool)) throw new Exception("bad banner");
    if (req.Count <= 0 || req.Count > 10) throw new Exception("bad count");
    long cost = (long)req.Count * 100;

    var results = new List<GachaItem>(req.Count);
    var rng = Random.Shared;     // 스레드 안전 (.NET6+)

    // 한 플레이어의 가챠는 직렬화: 골드+천장+보상+멱등성을 하나의 임계 구역으로
    lock (player.Lock)
    {
        // 멱등성: 이미 처리된 요청이면 영수증 반환
        if (_receipts.TryGetValue(requestId, out var prev)) return prev.Items;

        if (player.Gold < cost) throw new Exception("not enough");

        int totalWeight = 0; foreach (var it in pool) totalWeight += it.Weight;
        int pity = _pityCounter.TryGetValue(player.Id, out var c) ? c : 0;

        for (int i = 0; i < req.Count; i++)
        {
            GachaItem picked;
            if (pity >= PITY - 1) { picked = PickHighest(pool); pity = 0; }
            else
            {
                int point = rng.Next(totalWeight);      // 서버 권위 난수, modulo bias 없음
                picked = PickByPoint(pool, point);
                pity = (picked.Grade == MAX_GRADE) ? 0 : pity + 1;
            }
            results.Add(picked);
        }

        // 트랜잭션 커밋: 여기서 한꺼번에 반영(중간 예외 시 위에서 던져 롤백 효과)
        player.Gold -= cost;
        _pityCounter[player.Id] = pity;
        foreach (var it in results) GrantReward(player, it);
        _receipts[requestId] = new Receipt { Items = results, ServerSeed = /*기록*/ 0 };
    }
    return results;
}
  • 클라 연출 동기화가 필요하면: 서버가 serverSeed 를 생성·기록하고 결과와 함께 내려줘 클라가 같은 시드로 연출만 재생한다(결과는 서버가 이미 확정).

더 나은 설계

1) 신뢰 경계 원칙을 코드로 못박기

  • 클라에서 오는 모든 값(ClientRolls, Seed)은 연출 힌트일 뿐 결과에 영향 0. 서버 RNG가 유일한 진실. 가능하면 결과를 서버에서 정하고 결정론적 시드만 회신.
  • 트레이드오프: 결과를 먼저 정해 내려주면 클라가 "결과를 미리 안다" → 연출 스킵/ 스포일러. 민감하면 서버가 결과를 암호학적으로 커밋(commit-reveal) 후 연출 끝에 reveal 하는 패턴으로 공정성+연출 둘 다 확보.

2) 천장·재화·보상은 영속 트랜잭션 (DB)

  • 인메모리 락은 멀티 인스턴스/크래시에 무력하다. 같은 플레이어가 다른 게임 서버에 동시에 붙으면 인메모리 락으로는 직렬화 불가. 천장 카운터/재화/영수증을 DB 트랜잭션 + 행 잠금(또는 낙관적 버전)으로 처리하고, requestId UNIQUE 로 멱등성.
  • 트레이드오프: DB 왕복 지연. 가챠는 과금/규제(확률 고지) 대상이라 감사 로그와 정합성이 절대 우선 — 정당한 비용.

3) 확률표·천장 규칙을 서버 설정/데이터로 외부화 + 감사

  • 가챠 확률은 법적 고지 대상(국가별 규제). 코드 상수가 아니라 검증된 설정 데이터로 관리하고, 모든 뽑기를 영수증으로 영속화해 재현·감사·고객지원 가능하게.

4) Random vs 암호학적 RNG

  • 일반 가챠는 Random.Shared/ThreadLocal<Random> 로 충분. 단 예측 불가성이 중요한(외부 검증/PvP 보상) 경우 RandomNumberGenerator(CSPRNG) 사용.

면접 포인트

  • 면접관이 듣고 싶은 핵심: 신뢰 경계(클라는 적이다) 를 즉시 짚는 것. "클라가 난수/시드를 보내 결과를 정한다" 가 가장 큰 결함임을 1순위로 지목하고, "연출 동기화 요구는 서버가 결과+시드를 내려주는 방식으로 푼다(commit-reveal)" 까지 제시하면 시니어 수준. 거기에 천장 동시성(lost update), 트랜잭션/멱등성, System.Random 공유 함정을 엮으면 완성.
  • 예상 질문:
    1. "연출을 위해 클라가 결과를 알아야 한다는데, 어떻게 치팅을 막나?" → 서버가 결과를 정한다. 시드만 내려 연출 재생. 공정성 증명이 필요하면 commit-reveal.
    2. "천장 카운터를 두 기기에서 동시에 건드리면?" → 인메모리 락은 멀티 인스턴스에서 무력. DB 행 잠금/낙관적 락 + 멱등성 키.
    3. "System.Random 을 여러 스레드가 쓰면 왜 위험한가?" → 내부 상태 손상으로 항상 0 반환 등 고장. Random.Shared/ThreadLocal/CSPRNG로.
    4. "roll % totalWeight 의 문제는?" → modulo bias 로 분포 왜곡. 서버가 [0,totalWeight) 균등 난수를 직접 뽑아야 함.