확률형 기능의 공정성을 검증하는 법 — RNG 통계 검정과 몬테카를로

AI 기술

RNG통계 검정몬테카를로공정성 검증재현성

이 글은 누구를 위한 것인가

  • 확률형 요소(가챠, 랜덤 보상, 미니게임)를 서비스에 넣은 팀
  • "표기 확률과 실제가 같다"를 데이터로 보여야 하는 상황
  • 사용자 민원("확률이 이상하다")에 로그로 답해야 하는 개발자

들어가며

확률형 기능에는 증명 책임이 따라온다. 표기 확률이 1%인데 사용자 커뮤니티에서 "500번 뽑았는데 안 나온다"는 글이 올라오면, 답은 두 가지 중 하나여야 한다.

  • "정상 범위입니다. 1% 확률로 500회 실패할 확률은 0.66%로, 드물지만 충분히 발생합니다."
  • "확인 결과 버그였습니다."

둘 중 무엇인지 모르는 상태가 가장 나쁘다. 이 글은 그 상태를 없애는 절차를 다룬다.


1. 난수원 — Math.random()으로 시작하지 않는다

가장 흔한 출발점의 실수다.

// ❌ 보상 판정에 쓰면 안 된다
if (Math.random() < 0.01) grantRareItem();

Math.random()의 문제는 셋이다.

  1. 예측 가능
     — 알고리즘이 명세돼 있고 시드가 노출되면 다음 값을 계산할 수 있다

  2. 재현 불가
     — 시드를 지정할 수 없어 "그 판정을 다시 재현"이 안 된다
        분쟁이 생겨도 검증할 방법이 없다

  3. 클라이언트 실행
     — 확률 판정이 브라우저에 있으면 조작 가능하다

세 번째가 결정적이다. 확률 판정은 반드시 서버에서 한다. 클라이언트는 결과를 표시할 뿐이다.

서버 쪽 난수는 용도에 따라 나뉜다.

용도선택이유
실제 보상 판정암호학적 난수 (crypto)예측 불가능성이 필요
시뮬레이션·테스트시드 고정 PRNG (Mersenne Twister 등)재현성이 필요
분쟁 검증시드 + 카운터 기록사후 재현이 가능해야
import { randomInt } from 'node:crypto';

// 0 이상 max 미만의 균일 난수 — 모듈로 편향 없음
function pick(max: number): number {
  return randomInt(max);
}

// ❌ 흔한 편향 버그
function biasedPick(max: number): number {
  const buf = crypto.getRandomValues(new Uint32Array(1));
  return buf[0] % max;         // max가 2의 거듭제곱이 아니면 앞쪽 값이 더 자주 나옴
}

모듈로 편향은 눈에 잘 안 띄지만 실재하는 버그다. 2^32max로 나눈 나머지만큼 앞쪽 구간이 더 자주 선택된다. max가 작으면 편향이 미미하지만, 아이템 수가 많거나 확률 구간을 잘게 나누면 검정에서 잡힌다. 브라우저 쪽 API 동작은 Crypto.getRandomValues() 문서에 정의돼 있고, 언어 표준 라이브러리에 편향 없는 정수 생성 함수가 있으면 그걸 쓰는 게 항상 낫다.


2. 재현 가능하게 기록한다

분쟁 대응의 핵심은 판정을 나중에 재현할 수 있는가다.

CREATE TABLE draw_log (
  id            BIGSERIAL PRIMARY KEY,
  user_id       BIGINT NOT NULL,
  table_id      TEXT NOT NULL,        -- 어느 확률표를 썼는가
  table_version INT NOT NULL,         -- ★ 확률표 버전
  server_seed   BYTEA NOT NULL,       -- 서버가 생성한 시드
  nonce         BIGINT NOT NULL,      -- 같은 시드 내 순번
  roll          INT NOT NULL,         -- 실제 뽑힌 값
  result_id     TEXT NOT NULL,        -- 판정 결과
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX ON draw_log (user_id, created_at DESC);
CREATE INDEX ON draw_log (table_id, table_version, created_at);

table_version이 가장 자주 빠진다. 확률표를 조정했는데 버전을 안 남기면, 나중에 집계할 때 서로 다른 확률표의 결과가 섞인다. "표기와 실제가 다르다"는 결론이 나와도 그게 버그인지 표 변경 때문인지 구분할 수 없다.

server_seednonce를 남기면 판정을 그대로 재계산할 수 있다.

import { createHmac } from 'node:crypto';

function deterministicRoll(serverSeed: Buffer, nonce: number, range: number): number {
  const h = createHmac('sha256', serverSeed).update(String(nonce)).digest();
  // 상위 4바이트를 균일하게 사용 (거절 샘플링으로 편향 제거)
  const limit = Math.floor(0xffffffff / range) * range;
  let offset = 0;
  while (offset + 4 <= h.length) {
    const v = h.readUInt32BE(offset);
    if (v < limit) return v % range;
    offset += 4;
  }
  // 해시가 소진되면 nonce를 늘려 재시도
  return deterministicRoll(serverSeed, nonce + 0x1000000, range);
}

거절 샘플링(v < limit 조건)이 앞 절의 모듈로 편향을 없앤다. 이 방식이면 같은 시드와 nonce로 언제든 같은 결과가 나온다.


3. 표기 확률과 실제가 같은지 — 카이제곱 검정

로그가 쌓였으면 검정한다. 가장 기본은 **적합도 검정(goodness-of-fit)**이다.

import numpy as np
from scipy.stats import chisquare

# 표기 확률
declared = {"SSR": 0.01, "SR": 0.09, "R": 0.30, "N": 0.60}

# 실제 관측 (30일치 로그 집계)
observed = {"SSR": 118, "SR": 921, "R": 2987, "N": 5974}
n = sum(observed.values())          # 10,000회

labels = list(declared)
obs = np.array([observed[k] for k in labels])
exp = np.array([declared[k] * n for k in labels])

stat, p = chisquare(f_obs=obs, f_exp=exp)
print(f"chi2 = {stat:.3f}, p = {p:.4f}")
# chi2 = 3.470, p = 0.3246  → 표기 확률과 유의한 차이 없음

계산 방식과 자유도 처리는 scipy의 chisquare 문서에 정리돼 있다.

해석에서 두 가지를 조심해야 한다.

[1] p > 0.05 는 "확률이 맞다"의 증명이 아니다
    "표기와 다르다는 증거가 부족하다"일 뿐이다.
    표본이 적으면 큰 차이도 못 잡는다.

[2] p < 0.05 라고 즉시 버그는 아니다
    검정을 20번 하면 정상이어도 평균 1번은 유의하게 나온다.
    등급별로 따로 검정하면서 다중 비교 보정을 안 하면 오탐이 쌓인다.

필요 표본 수를 먼저 계산해야 한다. 확률 1% 항목을 검증하려면 기대 횟수가 최소 5회는 되어야 하므로 500회로는 부족하다.

# 가장 희귀한 등급의 기대 횟수가 최소 10이 되도록
min_p = min(declared.values())          # 0.01
required_n = int(10 / min_p)            # 1,000회
print(f"최소 필요 표본: {required_n:,}회")

4. 개별 민원 대응 — 이항 검정

전체 분포가 정상이어도 개별 사용자는 "나는 500번 뽑았는데 안 나왔다"고 말한다. 이건 다른 질문이고 다른 계산이 필요하다.

from scipy.stats import binom

p_rare = 0.01
trials = 500

# 500회 동안 한 번도 안 나올 확률
p_zero = binom.pmf(0, trials, p_rare)
print(f"500회 전부 실패할 확률: {p_zero:.4%}")   # 0.6570%

# 즉, 사용자 10,000명 중 약 66명은 이 경험을 한다

이 숫자를 CS 응답에 그대로 쓸 수 있다. "0.66% 확률로 발생하는 일이며, 이용자 1만 명 기준 약 66명이 겪습니다"는 "정상입니다"보다 훨씬 설득력이 있다.

반대로 버그를 의심해야 하는 기준도 이 계산에서 나온다.

# 관측된 결과가 얼마나 극단적인가 (단측 p-value)
observed_hits = 0
p_value = binom.cdf(observed_hits, trials, p_rare)

if p_value < 0.001:
    print("개별 계정 수준에서도 이례적 — 로그 확인 필요")

한 사용자에서 p < 0.001이 나오는 건 흔하지만, 여러 사용자에서 같은 방향으로 반복되면 확률표 적용 버그(예: 특정 조건에서 등급 하나가 누락)를 의심해야 한다.


5. 몬테카를로 — 복합 규칙의 실효 확률

확률표가 단순하면 계산으로 끝나지만, 실제 서비스에는 규칙이 얹힌다.

  · 10회 연속 실패 시 다음 판정 확률 2배 (천장 완화)
  · 100회 누적 시 확정 지급 (천장)
  · 이미 보유한 항목은 재추첨
  · 이벤트 기간 확률 1.5배

이런 조합의 실효 확률은 수식으로 풀기 어렵다. 시뮬레이션이 빠르다.

import numpy as np

rng = np.random.default_rng(seed=42)        # 재현 가능하게 시드 고정

def simulate_user(n_draws: int) -> dict:
    fails = 0
    total_pulls = 0
    hits = 0
    for _ in range(n_draws):
        total_pulls += 1
        p = 0.01 * (2 if fails >= 10 else 1)
        if total_pulls % 100 == 0:           # 천장
            hits += 1; fails = 0; continue
        if rng.random() < p:
            hits += 1; fails = 0
        else:
            fails += 1
    return {"pulls": total_pulls, "hits": hits}

runs = [simulate_user(300) for _ in range(100_000)]
hits = np.array([r["hits"] for r in runs])

print(f"300회 뽑기 시 획득 수 — 평균 {hits.mean():.2f}, 중앙값 {np.median(hits):.0f}")
print(f"90% 구간: {np.percentile(hits, 5):.0f} ~ {np.percentile(hits, 95):.0f}개")
print(f"한 번도 못 얻을 확률: {(hits == 0).mean():.3%}")

시드를 고정하는 것이 중요하다. 시뮬레이션 결과를 근거로 확률표를 조정했는데 다음에 돌렸을 때 숫자가 달라지면, 그 조정의 근거가 사라진다.

이렇게 얻은 분포는 표기 문구를 정하는 데도 쓰인다. "1%"만 쓰는 것보다 "300회 기준 평균 3.4개, 90% 구간 1~6개"를 함께 제시하면 사용자 기대와 실제의 간극이 줄어든다.

확률 곡선 자체가 게임 규칙인 유형은 이 분포 제시가 특히 중요하다. 소셜슬롯플레이의 크래시 게임처럼 배수가 오르다 임의 시점에 종료되는 구조에서는, 평균 배수만 보여주면 사용자가 실제 경험할 분포를 전혀 짐작하지 못한다. 중앙값과 백분위 구간을 함께 제시해야 기대와 실제가 어긋나지 않는다.


6. 난수원 자체를 검증해야 할 때

여기까지는 "확률표대로 나오는가"였다. 난수 생성기 자체를 의심해야 하는 상황이라면 표준 검정 도구가 있다.

[NIST SP 800-22 — 난수 생성기 통계 검정 모음]

  Frequency (Monobit)      0과 1의 비율
  Runs                     연속 구간의 길이 분포
  Longest Run of Ones      최장 연속 구간
  Serial                   중첩 패턴의 빈도
  Approximate Entropy      엔트로피 추정
  ... 총 15종

암호학적 난수 API를 쓰고 있다면 대부분 이 검증은 불필요하다 — 이미 검증된 구현이기 때문이다. 자체 PRNG를 구현했거나, 하드웨어 난수원을 쓰거나, 외부 제공 난수를 받아 쓰는 경우에만 의미가 있다. 검정 항목과 판정 기준은 NIST SP 800-22에 정리돼 있다.

실무에서 더 자주 필요한 건 훨씬 단순한 점검이다.

# 뽑힌 값의 분포가 균일한가 (구간별 카운트)
rolls = np.array(load_recent_rolls(100_000))      # 0 ~ 9999
counts, _ = np.histogram(rolls, bins=100)
stat, p = chisquare(counts)
print(f"균일성 검정 p = {p:.4f}")

# 연속 판정에 상관이 있는가 (자기상관)
print(f"lag-1 자기상관: {np.corrcoef(rolls[:-1], rolls[1:])[0,1]:.5f}")

자기상관이 0에서 뚜렷하게 벗어나면 시드 재사용이나 카운터 미증가 같은 구현 버그를 의심한다. 실제로 가장 흔한 사고가 이것이다 — 요청마다 새 시드를 만들면서 시각 기반 시드를 쓰면, 같은 밀리초에 들어온 요청이 같은 결과를 받는다.


7. 운영 — 상시 관측

일회성 검증으로 끝나지 않는다.

[일간 배치]

  · 확률표 버전별 등급 분포 집계
  · 카이제곱 p-value 기록
  · 자기상관 계수 기록
  · 표본 수가 최소 기준(기대 횟수 10)에 못 미치면 검정 생략하고 표시

[알림 조건]

  p < 0.01 이 3일 연속        → 확인
  자기상관 |r| > 0.02         → 즉시 확인
  특정 등급 미출현 일수 > 기대 → 확률표 적용 버그 의심

"3일 연속"을 조건에 넣는 이유는 다중 비교 때문이다. 매일 검정하면 정상이어도 100일에 1번은 p < 0.01이 나온다. 연속 발생을 조건으로 걸면 오탐이 크게 줄어든다.


8. 정리

  1. 판정은 서버에서, 암호학적 난수로 (모듈로 편향 주의)
  2. 시드·nonce·확률표 버전을 로그에 남겨 재현 가능하게
  3. 전체 분포는 카이제곱 — 최소 표본 수를 먼저 계산
  4. 개별 민원은 이항 검정 — 숫자를 그대로 CS 응답에 사용
  5. 복합 규칙의 실효 확률은 몬테카를로 (시드 고정)
  6. 자기상관은 구현 버그의 가장 좋은 조기 신호

가장 자주 생략되는 건 2번이다. 시드와 확률표 버전을 안 남기면, 나중에 어떤 검정을 해도 "그때 무슨 일이 있었는지"에는 답할 수 없다.