← Dev

provablyfair: 검증 가능한 랜덤 알고리즘 구현기

SaidBySolo
SaidBySolo

2026년 9월 2일


이 결과, 정말 조작되지 않았을까?

온라인에서 랜덤 결과를 제공하는 서비스를 만들거나 사용할 때, 한 번쯤은 이런 의문을 가질 수 있습니다. 특히 결과에 돈이 걸려 있다면, 운영자가 결과를 조작해도 사용자는 알 방법이 없습니다.

Kristóf Poduszló의 연구 「Introduction to Provably Fair Gaming Algorithms」에 따르면, 오늘날 대부분의 온라인 게임 산업은 블랙박스 알고리즘을 사용하여 사용자가 제3자 서비스의 난수 생성을 맹목적으로 신뢰해야 합니다. 이에 대한 해결책으로 등장한 것이 Provably Fair(증명 가능한 공정성)입니다.

Provably Fair는 암호학적 기법을 사용해 운영자와 사용자가 서로를 믿을 필요 없이, 결과가 공정하게 만들어졌음을 수학적으로 검증할 수 있게 합니다. 모든 참여자가 게임 결과의 난수 생성에 동등한 영향력을 가지며, 이를 검증 가능한 방식으로 보장하는 것이 핵심입니다.

이 글에서는 비트코인 도박 커뮤니티에서 널리 알려진 Provably Fair 구현 방식을 살펴보고, 파이썬으로 구현한 provablyfair 라이브러리를 소개합니다. 개발자로서 이 알고리즘이 어떻게 동작하는지 이해하면, 왜 '공정한 도박'도 결국 위험한지 수학적으로 확인할 수 있습니다.

Provably Fair의 아이디어

온라인 도박에서 결과는 서버에서 만들어집니다.

# 서버 내부에서 일어나는 일 (우리는 볼 수 없음)
import random
 
result = random.randint(1, 100)

사용자 입장에서 이것은 블랙박스입니다. 운영자가 if 사용자가_따면: 다시_굴리기를 넣어도 알 방법이 없습니다.

Provably Fair의 핵심 아이디어는 간단합니다.

  1. 결과를 암호학적으로 미리 약속(commitment)해 둡니다
  2. 결과가 나온 후, 약속과 결과가 맞는지 검증할 수 있게 합니다

이렇게 하면 운영자가 결과를 사후에 조작할 수 없습니다.

세 가지 변수로 만드는 공정성

가장 널리 쓰이는 Provably Fair 구현은 세 가지 변수로 결과를 계산합니다.

변수제공자역할
Server Seed운영자결과의 기반이 되는 비밀 값
Client Seed사용자운영자가 결과를 예측하지 못하도록 하는 값
Nonce베팅 시스템매 베팅마다 1씩 증가하는 카운터

사전 커밋먼트: 해시로 약속하기

게임이 시작되기 전, 운영자는 서버 시드의 SHA-256 해시를 사용자에게 미리 공개합니다.

서버 시드 (비공개):
293d5d2ddd365f54759283a8097ab2640cbe6f8864adc2b1b31e65c14c999f04

서버 시드 해시 (사전 공개):
5ac59780d512265230d5efb3cc238886dc1b457a80b54fbf1f920b99c6505801

이것이 중요한 이유는 커밋먼트 스킴(commitment scheme)이 되기 때문입니다.

커밋먼트 스킴은 암호학에서 값을 미리 약속(commit)하고 나중에 공개(reveal)하는 프로토콜입니다. Poduszló의 논문에서는 이를 비대칭 암호화에 빗어 설명합니다. hostSeed는 게임 종료 전까지 비밀로 유지되는 개인키와 같은 역할을 하고, publicSeed는 해시가 공개된 후에야 생성되거나 참여자에 의해 제공됩니다.

핵심은 두 가지 성질입니다.

  • 은닉성(Hiding): 해시만으로는 원본을 알 수 없음
  • 결합성(Binding): 나중에 다른 값으로 바꿔치기할 수 없음

SHA-256 같은 암호학적 해시 함수는 이 두 성질을 모두 만족합니다. 해시를 먼저 공개한 이상 운영자는 게임 도중 서버 시드를 바꿀 수 없습니다. 나중에 원본 시드를 공개했을 때 해시가 일치하는지 확인하면 그만입니다. 이는 게임 중 hostSeed가 변조되지 않았음을 보장하는 무결성 증명이 됩니다.

그렇다고 사용자가 시드를 미리 알면 안 됩니다. 시드를 알면 결과를 전부 미리 계산할 수 있기 때문입니다. 해시만 먼저 공개하고 원본은 게임 종료 후 공개하는 '나중에 열어보는 봉인 봉투' 방식입니다.

실제로 위 해시가 맞는지 직접 확인해 볼 수 있습니다.

echo -n "293d5d2ddd365f54759283a8097ab2640cbe6f8864adc2b1b31e65c14c999f04" | sha256sum
# 5ac59780d512265230d5efb3cc238886dc1b457a80b54fbf1f920b99c6505801

결과 계산: HMAC-SHA512

실제 결과는 다음과 같이 계산합니다.

HMAC-SHA512(server_seed, client_seed+"-"+nonce)\text{HMAC-SHA512}(\text{server\_seed},\ \text{client\_seed} + \text{"-"} + \text{nonce})

HMAC은 키가 있는 해시 함수입니다. 서버 시드를 키로, 클라이언트시드-nonce를 메시지로 사용합니다.

예를 들어 위에서 공개한 시드와 user_seed_123이라는 클라이언트 시드, nonce 0으로 계산하면 다음과 같은 해시가 나옵니다.

python3 -c "
import hmac
from hashlib import sha512
print(hmac.new(
    b'293d5d2ddd365f54759283a8097ab2640cbe6f8864adc2b1b31e65c14c999f04',
    b'user_seed_123-0',
    sha512
).hexdigest())
"
# cd1d40d74cb1e1fb548ee7adb7e14c7cc71ead5328e425fbb2f2324e064ea8107abf327d21131019575f244f87f54a02bd7a22110cef9e58b272acc7afffc88f

이 해시의 앞 5글자 cd1d4를 16진수에서 10진수로 변환하면 840,148840{,}148가 됩니다. 이 값이 백만(999,999999{,}999)을 넘지 않으므로 이 값을 사용합니다.

int('cd1d4', 16)  # 840148

최종적으로 840,148(mod10,000)÷100=1.48840{,}148 \pmod{10{,}000} \div 100 = 1.48라는 결과가 나옵니다. 이 숫자가 주사위 결과입니다.

검증: 사후에 모두 확인

게임이 끝나면 운영자는 원본 서버 시드를 공개합니다. 사용자는 다음을 검증합니다.

  1. 공개된 서버 시드의 SHA-256 해시가 사전에 받은 해시와 일치하는가?
  2. 공개된 서버 시드와 클라이언트 시드, 각 nonce로 결과를 직접 계산했을 때 게임에서 나온 결과와 일치하는가?

두 가지 모두 맞다면 결과는 조작되지 않은 것입니다. 운영자는 자신도 결과를 미리 알 수 없고(클라이언트 시드는 사용자가 정하므로), 사후에 바꿀 수도 없습니다(시드를 바꾸면 해시가 안 맞으므로).

다만 주의할 점이 있습니다. 검증이 가능하다는 것은 '공정하다'는 뜻이지, '유리하다'는 뜻이 아닙니다. 게임 규칙 자체가 어떻게 설계되었는지는 별개의 문제입니다.

더 깊이 알아보기: 다중 플레이어와 Mental Poker

이 글에서 다룬 방식은 단일 플레이어가 운영자와 대결하는 1:1 구조에 집중합니다. 하지만 여러 명이 동시에 베팅하는 게임이나, 플레이어 간에 대결하는 카드 게임에서는 더 복잡한 문제가 생깁니다.

  • 공모 문제(Collusion): 한 플레이어가 시드를 미리 알고 다른 이들에게 몰래 알려주는 부정행위
  • Seeding Event: 아직 생성되지 않은 블록체인 블록 해시를 시드로 사용, 아무도 미리 예측 불가
  • Mental Poker: RSA 암호의 창시자들이 고안한, 통신만으로 완전히 공정한 카드 게임을 하는 프로토콜

이러한 다중 플레이어 환경의 세부 구현이나 Fisher-Yates 셔플의 암호학적 적용 방법은 Kristóf Poduszló의 원문 논문을 참조하세요.

provablyfair: 파이썬으로 구현

이 알고리즘을 파이썬 라이브러리로 만들었습니다. 설치는 간단합니다.

pip install provablyfair

결과 생성

from provablyfair import ProvablyFair
 
f = ProvablyFair("293d5d2ddd365f54759283a8097ab2640cbe6f8864adc2b1b31e65c14c999f04")
r = f.roll("user_seed_123")
 
print(r.roll % (10000) / 100)  # 1.48
print(r.server_seed_hash)      # 5ac59780d... (사전 공개용 해시)

ProvablyFair 생성자에 서버 시드를 전달하거나, 생략하면 secrets.token_hex(32)로 안전한 랜덤 시드를 자동 생성합니다. 객체를 만들 때 서버 시드 해시도 함께 계산되므로, 게임 시작 전에 사용자에게 공개할 수 있습니다.

검증

from provablyfair import ProvablyFair, verify_roll, RolledData
 
f = ProvablyFair("293d5d2ddd365f54759283a8097ab2640cbe6f8864adc2b1b31e65c14c999f04")
r = f.roll("user_seed_123")
 
# 나중에 다른 곳에서 검증
verified_data = RolledData(
    roll=840148,
    nonce=0,
    server_seed_hash='5ac59780d512265230d5efb3cc238886dc1b457a80b54fbf1f920b99c6505801',
    client_seed='user_seed_123'
)
 
print(verify_roll(f.server_seed, verified_data))  # True

verify_roll는 서버 시드와 저장된 결과 데이터를 받아 처음부터 다시 계산한 뒤 비교합니다. 해시 일치 여부와 결과 재현 여부를 모두 확인하는 것입니다.

구현의 뼈대

라이브러리의 핵심 로직은 약 40줄입니다. 수학적 복잡성보다는 관례에 대한 정확한 구현이 중요합니다.

from dataclasses import asdict, dataclass
from hashlib import sha256, sha512
from hmac import new
from secrets import token_hex
 
class ProvablyFair:
    def __init__(self, server_seed: str = None) -> None:
        self.server_seed = server_seed or self.generate_server_seed()
        self.server_seed_hash = self.hash_server_seed(self.server_seed)
 
    def generate_server_seed(self) -> str:
        return token_hex(32)
 
    def hash_server_seed(self, server_seed: str):
        return sha256(server_seed.encode()).hexdigest()
 
    def roll(self, client_seed: str, nonce: int = 0) -> RolledData:
        hmac_hash = new(
            self.server_seed.encode(),
            f"{client_seed}-{nonce}".encode(),
            sha512,
        ).hexdigest()
 
        count = 0
        while True:
            roll_number_str = hmac_hash[count : count + 5]
            roll_number = int(roll_number_str, 16)
            if roll_number > 999999:
                count += 5
            else:
                break
 
        return RolledData(roll_number, nonce, self.server_seed_hash, client_seed)

몇 가지 설계 포인트가 있습니다.

  • 결정적 결과: 같은 시드와 nonce로는 항상 같은 결과가 나옵니다. 이것이 검증을 가능하게 합니다.
  • overflow 처리: 5글자 해시가 999,999를 넘으면 다음 5글자로 넘어가는 while 루프. 확률은 낮지만 정확성을 위해 필요합니다.
  • 데이터클래스 반환: RolledData는 @dataclass로 정의되어 결과와 검증에 필요한 모든 정보를 담습니다.

이제 결과를 만들고 검증하는 방법은 충분히 다뤘습니다. 이 알고리즘으로 생성된 숫자는 암호학적으로 '공정'합니다. 운영자도 결과를 미리 알 수 없고, 베팅 후에 바꿀 수도 없습니다.

그런데 여기서 한 가지 의문이 남습니다. 왜 이렇게까지 검증 가능성을 강조할까요? 그리고 공정한 결과가 나온다고 해서, 사용자에게 정말 유리한 게임일까요?

공정함 뒤에 숨겨진 것: 하우스 엣지

'공정함'을 검증할 수 있다는 사실에 집중하다 보면, 정작 중요한 것을 놓치기 쉽습니다. 결과가 조작되지 않는다는 것과 결과가 유리하다는 것은 완전히 다른 이야기입니다.

전형적인 비트코인 다이스 게임의 규칙을 살보면, 공정함 뒤에 숨겨진 수학적 장치가 보입니다. 이것이 하우스 엣지(House Edge)입니다.

확률과 배당률의 비대칭

전형적인 다이스 게임의 규칙입니다.

  • 0~99.99 사이의 숫자가 랜덤으로 나옴
  • 사용자가 '오버' 또는 '언더'를 선택
  • 예: 49.5 미만에 베팅하면 승리 확률 49.5%
  • 승리 시 배당: 1.98배 (패배 시 베팅금 몰수)

여기서 함정이 있습니다. 승리 확률은 49.5%지만, 배당률은 2배가 아니라 1.98배입니다. 이 0.02의 차이가 의미하는 바를 계산해 보겠습니다.

기대값 계산: 숨겨진 수수료

이 0.02의 차이가 실제로 얼마나 큰 의미인지 수학적으로 계산해 보겠습니다.

E=(0.495×0.98)+(0.505×(−1))=0.4851−0.505=−0.0199E = (0.495 \times 0.98) + (0.505 \times (-1)) = 0.4851 - 0.505 = -0.0199

베팅 1회당 평균 1.99% 손실입니다. 얼마 안 되는 것처럼 보이지만, 이것이 하우스 엣지의 본질입니다. 운영자는 매 베팅에서 약 2%씩 가져가는 구조이고, 이것은 베팅 횟수가 늘어날수록 누적됩니다.

def expected_value(win_prob: float, payout_multiplier: float) -> float:
    """1회 베팅의 기대값 (베팅금 1 단위 기준)"""
    if win_prob <= 0 or win_prob >= 1:
        raise ValueError("승리 확률은 0과 1 사이여야 함")
    
    win_return = payout_multiplier - 1  # 순이익 (베팅금 제외)
    lose_return = -1                    # 베팅금 손실
    
    ev = (win_prob * win_return) + ((1 - win_prob) * lose_return)
    return ev
 
# 다이스 게임 예시
print(expected_value(0.495, 1.98))  # -0.0199...

장기적으로는 반드시 손해

이 구조에서 사용자는 결코 이길 수 없습니다. 베팅을 반복할수록 큰 수의 법칙이 작동합니다.

import random
from provablyfair import ProvablyFair
 
def simulate_gambling(rolls: int = 10000, house_edge: float = 0.0199):
    """도박 시뮬레이션: 공정한 랜덤에서도 하우스 엣지만으로 손실 발생"""
    pf = ProvablyFair()
    balance = 1000.0  # 초기 자본
    bet_amount = 10.0
    
    for i in range(rolls):
        r = pf.roll(f"user_seed_{i}")
        roll_result = (r.roll % 10000) / 100  # 0.00 ~ 99.99
        
        # 49.5 미만에 베팅 (승리 확률 49.5%)
        if roll_result < 49.5:
            balance += bet_amount * 0.98  # 1.98배 배당
        else:
            balance -= bet_amount
        
        if balance <= 0:
            break
    
    return balance
 
# 시뮬레이션 실행
final_balance = simulate_gambling()
print(f"10,000회 베팅 후 잔액: {final_balance:.2f}")  # 초기 1000에서 손실

실제로 실행보면 '공정한' 랜덤 결과에서조차 잔액은 꾸준히 감소합니다. 하우스 엣지가 작동하기 때문입니다. 이것이 도박의 수학적 본질입니다.

'공정하게 지는 것'과 '조작당해서 지는 것'의 차이는 사용자 경험에는 큰 차이가 없습니다. 둘 다 결국 돈을 잃습니다.

그렇다면 왜 운영자들은 Provably Fair를 내세울까요? 답은 간단합니다. 조작의 의심을 없애 신뢰를 얻으면, 더 많은 사용자가 더 오래 베팅하기 때문입니다. 하우스 엣지가 있다면 사용자가 많을수록, 오래 플레이할수록 운영자의 수익은 보장됩니다.

'공정한 랜덤'은 도박의 본질을 바꾸지 않습니다. 기술적으로는 흥미로운 개념이지만, 도박의 맥락에서는 사용자의 불신을 해소해 더 많은 참여를 유도하는 수단이 됩니다.

마무리

Provably Fair는 암호학을 사용해 신뢰를 프로그래밍하는 흥미로운 알고리즘입니다. 블록체인, 추첨 시스템, 온라인 투표 등 다양한 곳에 응용될 수 있습니다. provablyfair 라이브러리도 그러한 기술적 학습의 일환으로 만들어졌습니다.

다만 도박 맥락에서는 '검증 가능한 공정성'이 결과 생성 과정의 투명성만 보장할 뿐, 게임의 수학적 구조가 사용자에게 불리하다는 사실은 바꾸지 않습니다. 하우스 엣지가 있는 한, 사용자가 장기적으로 이길 확률은 0%입니다.

기술을 이해한다는 것은 그 기술이 어떤 목적으로 사용되는지까지 파악하는 것을 포함합니다. Provably Fair의 원리를 아는 것은 도박의 메커니즘을 이해하는 것이고, 이는 도박으로부터 자신을 지키는 지식이 될 수 있습니다.

참고 자료


provablyfair는 GitHub에서 MIT 라이선스로 공개되어 있습니다. 이 라이브러리는 교육 목적이며, 어떤 형태의 도박도 권장하지 않습니다.