5. 확률 아이템(가챠) 뽑기 + 천장 시스템
난이도 최상해설 — 확률 아이템(가챠) 뽑기 + 천장 시스템
난이도: 최상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
가챠 결과를 클라이언트가 보낸 난수/시드로 결정한다. 이건 단순 버그가 아니라
확률 조작 치트의 정문을 열어둔 것이다. 여기에 천장 카운터의 동시성 버그,
재화 차감-보상 지급의 비원자성, 멱등성 부재, 공유 컬렉션/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). 또는 동시 쓰기로Dictionaryrehash 중 손상.Pull전체가_pityCounter를 어떤 락도 없이 읽고 쓴다(player.Lock은 골드만 보호, 천장은 보호 안 함). - 근본 원인: 천장 카운터는 플레이어별 공유 가변 상태인데 동기화가 전혀 없다. read-modify-write(증가)가 원자적이지 않다. 게다가 player.Lock과 천장 보호가 분리돼 "한 번의 뽑기" 가 하나의 일관된 상태 전이가 아니다.
(D) System.Random 공유 — 스레드 비안전 (동시성)
- 증상: 여러 스레드가
_rng를 동시에 쓰면(현재 코드는 안 쓰지만 정상 구현에선 서버가 굴려야 함) 내부 상태 손상으로 항상 0 반환 같은 고장이 난다(.NET 알려진 함정). - 근본 원인:
Random인스턴스 공유. 서버 권위 난수로 고치는 순간 이 함정에 빠진다. →Random.Shared(.NET6+),ThreadLocal<Random>, 또는 암호학적 RNG 사용.
멱등성 부재 — 중복 뽑기 (정확성/보안)
- 증상: 클라가 같은 뽑기 요청을 재전송하면 재화가 두 번 빠지고 보상도 두 번. 반대로 어뷰저가 의도적 재전송으로 천장 카운터를 교란.
- 근본 원인: requestId/영수증 기반 멱등성 키가 없다. 요구사항에 명시됐는데 미구현.
가중치 매핑 결함 (정확성)
roll % totalWeight는roll범위(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공유 함정을 엮으면 완성. - 예상 질문:
- "연출을 위해 클라가 결과를 알아야 한다는데, 어떻게 치팅을 막나?" → 서버가 결과를 정한다. 시드만 내려 연출 재생. 공정성 증명이 필요하면 commit-reveal.
- "천장 카운터를 두 기기에서 동시에 건드리면?" → 인메모리 락은 멀티 인스턴스에서 무력. DB 행 잠금/낙관적 락 + 멱등성 키.
- "
System.Random을 여러 스레드가 쓰면 왜 위험한가?" → 내부 상태 손상으로 항상 0 반환 등 고장.Random.Shared/ThreadLocal/CSPRNG로. - "
roll % totalWeight의 문제는?" → modulo bias 로 분포 왜곡. 서버가[0,totalWeight)균등 난수를 직접 뽑아야 함.
해설 — 확률 아이템(가챠) 뽑기 + 천장 시스템
난이도: 최상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
가챠 결과를 클라이언트가 보낸 난수/시드로 결정한다. 이건 단순 버그가 아니라
확률 조작 치트의 정문을 열어둔 것이다. 여기에 천장 카운터의 동시성 버그(공유
unordered_map 무락 RMW = lost update + UB), 재화 차감-보상 지급의 비원자성, 멱등성
부재, 가중치 매핑 결함(modulo bias)까지 겹친다. 보안·동시성·정확성이 한꺼번에 얽힌
최상급 문제.
문제점
(A)(B)(F) 클라이언트가 결과를 결정 — 확률 조작 치트 (보안·치팅) ★최우선
- 증상: 어뷰저가
clientRolls를 최고등급 구간 값으로 채워 보내면 100% 최고등급. 서버는 그 값을 그대로 가중치 매핑에 사용한다. - 재현 조건: 패킷 변조로
req.clientRolls = {최고등급 구간 시작값, ...}. 클라는 신뢰 경계 밖이므로 무조건 가능. - 근본 원인: 가챠 결과 결정은 서버 권위 영역인데 입력을 클라에 맡겼다. "연출 동기화" 는 정당한 요구지만 해법은 "서버가 결과를 정하고 클라에 통보→연출" 이지, "클라가 굴려 서버가 받아쓰기" 가 아니다. 시드(B)를 클라가 보내는 것도 동일 죄. → 결정론적 연출이 필요하면 서버가 시드를 생성해 결과와 함께 내려준다.
(E)→(J) 재화 차감과 보상 지급이 비원자적 — 돈만 빠지고 보상 없음 (정확성/동시성)
- 증상: 골드는 차감됐는데 루프 중간 예외로 일부 보상만 지급되거나 전혀 안 됨.
- 재현 조건:
req.count = 10인데clientRolls.size() = 3→ 4번째에서req.clientRolls[i]가vector::operator[]의 범위 밖 접근 = 미정의 동작(UB, 보통 크래시/쓰레기 값). 골드는 이미 1000 빠졌고 보상은 일부만.banners_.at(...)가 없는 배너에 던지는 경우도 동일하게 차감 후 실패 가능. - 근본 원인: 차감(E)은 락 안, 지급(J)은 락 밖. 전체가 하나의 트랜잭션이 아니다.
롤백 경로 없음. 입력 검증(
count == clientRolls.size(), 배너 존재)도 없다.
(C)(G)(H)(I) 천장 카운터 동시성 — 공유 map 무락 RMW + UB (동시성/정확성/보안)
- 증상: 카운터가 손상되거나, 천장이 앞당겨지거나(공짜 최고등급) 영원히 안 옴.
최악엔
unordered_map동시 변경으로 rehash 중 손상 → 크래시/UB. - 재현 조건: 같은 플레이어가 두 기기에서 동시에 Pull. 두 스레드가 (G)에서 같은
pity 값을 읽고 각자 (I)에서
pity+1을 써 증가가 한 번만 반영(lost update). 또는 동시 쓰기로unordered_map버킷이 손상된다.Pull전체가pityCounter_를 어떤 락도 없이 읽고 쓴다(player.lock은 골드만 보호, 천장은 보호 안 함). C++ 표준 컨테이너는 같은 객체에 대한 동시 쓰기를 보장하지 않는다. - 근본 원인: 천장 카운터는 플레이어별 공유 가변 상태인데 동기화가 전혀 없다. read-modify-write(증가)가 원자적이지 않다. 게다가 player.lock과 천장 보호가 분리돼 "한 번의 뽑기" 가 하나의 일관된 상태 전이가 아니다.
(D) std::mt19937 공유 — 스레드 비안전 (동시성)
- 증상: 여러 스레드가
rng_를 동시에 쓰면(현재 코드는 안 쓰지만 정상 구현에선 서버가 굴려야 함) 내부 상태(메르센 트위스터의 624워드 상태 배열)가 데이터 레이스로 손상돼 분포가 깨지거나 UB. - 근본 원인:
mt19937인스턴스 공유. 서버 권위 난수로 고치는 순간 이 함정에 빠진다. →thread_local std::mt19937, 또는 호출마다 락으로 보호, 또는 CSPRNG 사용.
멱등성 부재 — 중복 뽑기 (정확성/보안)
- 증상: 클라가 같은 뽑기 요청을 재전송하면 재화가 두 번 빠지고 보상도 두 번. 반대로 어뷰저가 의도적 재전송으로 천장 카운터를 교란.
- 근본 원인: requestId/영수증 기반 멱등성 키가 없다. 요구사항에 명시됐는데 미구현.
가중치 매핑 결함 (정확성)
roll % totalWeight는roll범위(0..9999)와totalWeight가 다르면 확률 분포가 왜곡된다(modulo bias). 또roll이 음수면(클라가 음수 보냄) 음수 모듈로 → 음수point→ 루프가 아무것도 못 잡고 마지막 항목 반환(편향). 서버가std::uniform_int_distribution(0, totalWeight-1)로 직접 뽑아야 분포가 정확하다.
수정안
원칙: ① 결과는 서버 RNG로만 결정(클라 입력은 연출용으로만), ② 한 번의 뽑기 = 재화·천장·보상·영수증이 하나의 트랜잭션, ③ 멱등성 키, ④ 천장은 플레이어 단위 락 (또는 영속 트랜잭션)으로 직렬화, ⑤ RNG는 thread_local.
std::vector<GachaItem> Pull(const std::string& requestId,
const std::string& bannerId, int count, Player& player) {
auto bit = banners_.find(bannerId);
if (bit == banners_.end()) throw std::runtime_error("bad banner");
if (count <= 0 || count > 10) throw std::runtime_error("bad count");
auto& pool = bit->second;
int64_t cost = (int64_t)count * 100;
static thread_local std::mt19937 rng{std::random_device{}()}; // 스레드별 RNG
std::vector<GachaItem> results;
results.reserve(count);
// 한 플레이어의 가챠는 직렬화: 골드+천장+보상+멱등성을 하나의 임계 구역으로
std::lock_guard<std::mutex> lk(player.lock);
// 멱등성: 이미 처리된 요청이면 영수증 반환
if (auto r = receipts_.find(requestId); r != receipts_.end()) return r->second;
if (player.gold < cost) throw std::runtime_error("not enough");
int totalWeight = 0; for (auto& it : pool) totalWeight += it.weight;
int pity = pityCounter_.count(player.id) ? pityCounter_[player.id] : 0;
std::uniform_int_distribution<int> dist(0, totalWeight - 1);
for (int i = 0; i < count; i++) {
GachaItem picked;
if (pity >= PITY - 1) { picked = PickHighest(pool); pity = 0; }
else {
int point = dist(rng); // 서버 권위 난수, modulo bias 없음
picked = PickByPoint(pool, point);
pity = (picked.grade == MAX_GRADE) ? 0 : pity + 1;
}
results.push_back(picked);
}
// 트랜잭션 커밋: 여기서 한꺼번에 반영
player.gold -= cost;
pityCounter_[player.id] = pity;
for (auto& it : results) GrantReward(player, it);
receipts_[requestId] = results;
return results;
}
- 클라 연출 동기화가 필요하면: 서버가
serverSeed를 생성·기록하고 결과와 함께 내려줘 클라가 같은 시드로 연출만 재생한다(결과는 서버가 이미 확정).
더 나은 설계
1) 신뢰 경계 원칙을 코드로 못박기
- 클라에서 오는 모든 값(
clientRolls,seed)은 연출 힌트일 뿐 결과에 영향 0. 서버 RNG가 유일한 진실. 가능하면 결과를 서버에서 정하고 결정론적 시드만 회신. - 트레이드오프: 결과를 먼저 정해 내려주면 클라가 "결과를 미리 안다" → 연출 스킵/ 스포일러. 민감하면 commit-reveal(서버가 결과를 암호학적으로 커밋 후 연출 끝에 reveal)로 공정성+연출 둘 다 확보.
2) 천장·재화·보상은 영속 트랜잭션 (DB)
- 인메모리 락은 멀티 인스턴스/크래시에 무력하다. 같은 플레이어가 다른 게임 서버에 동시에 붙으면 인메모리 락으로는 직렬화 불가. 천장 카운터/재화/영수증을 DB 트랜잭션 + 행 잠금(또는 낙관적 버전)으로 처리하고, requestId UNIQUE 로 멱등성.
- 트레이드오프: DB 왕복 지연. 가챠는 과금/규제(확률 고지) 대상이라 감사 로그와 정합성이 절대 우선 — 정당한 비용.
3) 확률표·천장 규칙을 서버 설정/데이터로 외부화 + 감사
- 가챠 확률은 법적 고지 대상(국가별 규제). 코드 상수가 아니라 검증된 설정 데이터로 관리하고, 모든 뽑기를 영수증으로 영속화해 재현·감사·고객지원 가능하게.
4) mt19937 vs CSPRNG
- 일반 가챠는
thread_local std::mt19937로 충분. 단 예측 불가성이 중요한(외부 검증/PvP 보상) 경우 암호학적 RNG(예: OS CSPRNG)를 사용한다.
면접 포인트
- 면접관이 듣고 싶은 핵심: 신뢰 경계(클라는 적이다) 를 즉시 짚는 것. "클라가
난수/시드를 보내 결과를 정한다" 가 가장 큰 결함임을 1순위로 지목하고, "연출 동기화
요구는 서버가 결과+시드를 내려주는 방식으로 푼다(commit-reveal)" 까지 제시하면 시니어
수준. 거기에 천장 동시성(lost update + 컨테이너 UB), 트랜잭션/멱등성,
mt19937공유 함정을 엮으면 완성. - 예상 질문:
- "연출을 위해 클라가 결과를 알아야 한다는데, 어떻게 치팅을 막나?" → 서버가 결과를 정한다. 시드만 내려 연출 재생. 공정성 증명이 필요하면 commit-reveal.
- "천장 카운터를 두 기기에서 동시에 건드리면?"
→ lost update +
unordered_map동시 변경 UB. 같은 락 안에서 RMW, 멀티 인스턴스면 DB 행 잠금/낙관적 락 + 멱등성 키. - "
std::mt19937을 여러 스레드가 공유하면 왜 위험한가?" → 624워드 내부 상태에 데이터 레이스 → 분포 손상/UB.thread_local또는 락/CSPRNG. - "
roll % totalWeight의 문제는?" → modulo bias 로 분포 왜곡, 음수 roll 이면 음수 인덱스. 서버가uniform_int_distribution(0, totalWeight-1)로 직접 뽑아야 함.