문제 - 세이브 파일 결정론과 강종작 방어

서버리스 모바일 게임에서 가장 흔한 치트 중 하나가 강제 종료(강종작)입니다. 유저가 가챠 결과나 전투 결과가 마음에 들지 않을 때 앱을 강제 종료하면, 마지막 저장 시점의 세이브 파일이 그대로 남습니다. 그 다음 실행에서 같은 행동을 반복하면 다른 결과를 받을 수 있습니다 - 사실상 무한 리롤입니다.

이걸 막는 표준 기법이 시드 기반 결정론(seed-based determinism)입니다.

핵심 아이디어는 단순합니다.

  • 가챠/전투 같은 “결정 시점”에서 RNG를 호출하기 직전에 시드 상태를 세이브 파일에 기록해둡니다
  • 다음 실행에서 같은 시드를 불러와 같은 알고리즘을 돌리면 비트 단위로 똑같은 결과가 나와야 합니다
  • 만약 결과가 다르면 누가 중간에 손을 댔거나, RNG 알고리즘이 환경에 따라 다르게 동작한다는 뜻입니다

이 시스템이 성립하려면 한 가지 조건이 절대적입니다. 모든 플랫폼, 빌드, 런타임 버전에서 RNG가 정확히 같은 비트 시퀀스를 뱉어야 합니다.

여기서 C# System.Random이 탈락합니다.

왜 C# System.Random은 부적합한가

.NET 6에서 알고리즘이 바뀐 사건

System.Random은 한 번도 “이 알고리즘을 보장한다”고 명세된 적이 없습니다. .NET 5까지는 Knuth의 subtractive RNG 변형을 썼고, .NET 6부터 인자 없는 생성자(new Random())는 내부적으로 xoshiro256**로 교체됐습니다. 시드를 넣은 생성자(new Random(seed))는 호환성 때문에 옛 알고리즘을 유지합니다.

즉 같은 코드, 같은 시드라도 .NET 런타임 버전이 달라지면 결과가 달라질 수 있습니다. Unity는 자체 Mono 포크를 쓰기 때문에 더 미묘합니다 - Unity 버전, IL2CPP/Mono 백엔드, 플랫폼 타겟에 따라 출력이 달라질 가능성을 원천적으로 배제할 수 없습니다.

32비트 시드의 한계

Random(int seed)는 시드가 int입니다. 시드 공간이 2^32로 제한되며, 부호 비트까지 빼면 실질적으로 더 좁아집니다. 결정론 시스템에서 시드는 유저별, 세션별로 충돌 없이 펼쳐져야 하는데, 32비트는 너무 좁습니다.

내부 state 직렬화가 공개 API가 아니다

세이브 파일에는 “다음 호출이 어떤 값을 뱉을지”를 정의하는 내부 상태(state)를 그대로 저장해야 합니다. System.Random의 내부 배열, 인덱스는 공개 API가 아니고, 리플렉션으로 꺼내더라도 .NET 버전이 바뀌면 의미가 사라집니다.

직접 구현하지 않을 이유가 없다는 결론이 자연스럽게 나옵니다. 그리고 직접 구현할 때 사실상 표준이 된 것이 Xoshiro/Xoroshiro 계열입니다.

Xoshiro 계열 개관 - LFSR + scrambler 구조

Xoshiro/Xoroshiro 계열은 Sebastiano Vigna와 David Blackman이 2018년에 발표한 RNG 패밀리입니다. 이름은 XOR / shift / rotate의 줄임입니다. 학계에서 거의 모든 통계 검정(BigCrush, PractRand 등)을 무리 없이 통과하며, 코드는 한 함수에 다 들어갈 만큼 짧습니다. 빠르고, 메모리도 적게 씁니다.

핵심 설계는 두 단계로 깔끔하게 분리되어 있습니다.

  1. 선형 부분 (state advance): XOR + shift + rotate만으로 상태를 업데이트합니다. 주기를 길게 보장하지만, 자체로는 통계적 약점(특히 하위 비트의 선형성)이 있습니다.
  2. Scrambler (출력 가공): 선형 부분의 약점을 가리는 비선형 후처리입니다. +, ++, ** 세 종류가 있습니다.

저자가 권장하는 scrambler는 다음과 같습니다.

Scrambler특징권장 용도
+가장 빠르지만 하위 비트에 선형 잔재가 남음부동소수점 [0,1) 변환처럼 상위 비트만 쓰는 용도
**곱셈 기반, 통계적으로 매우 강함범용 (구버전 추천)
++덧셈 + 회전 + 덧셈, **보다 약간 빠르고 통계적 품질도 충분현재 범용 추천

Vigna 본인이 2019년 이후 글에서 “범용으로는 ++를 써라”고 정리했기 때문에, 이 글에서도 ++ 변종 세 가지(xoroshiro128++, xoshiro128++, xoshiro256++)에 집중합니다.

시각화 - Bad LCG vs Xoshiro256++

알고리즘 품질 차이는 통계 검정 수치로만 보면 잘 와닿지 않습니다. 1024 x 1024 비트맵으로 직접 그려 보면 직관이 잡힙니다.

왼쪽은 교과서적인 LCG(x_{n+1} = 1103515245 * x_n + 12345 mod 2^31)의 출력에서 low byte를 그대로 grayscale로 그린 것입니다. 출력의 하위 8비트 주기가 256이라, 1024 = 4 * 256 정렬에 맞아떨어져 또렷한 세로 줄무늬가 드러납니다. 오른쪽은 같은 방식으로 그린 Xoshiro256++ 출력 - 정렬 흔적 없이 백색 노이즈가 채워집니다.

다만 한 가지는 정확히 짚어 두겠습니다. 모던 환경의 표준 Random이 모두 왼쪽처럼 나쁘지는 않습니다. .NET 6+의 무인자 Random()은 내부적으로 xoshiro256**을 쓰므로 위 오른쪽과 비슷한 그림이 나옵니다. 이 시각화는 어디까지나 RNG 품질 스펙트럼의 양 극단을 보여 주는 것이고, 저희가 직접 구현 RNG를 쓰는 진짜 이유는 시각적 품질이 아니라 앞서 짚은 결정론, 플랫폼 일관성, Burst, 감사 가능성에 있습니다.

Xoshiro 계열의 추가 장점 - Burst, jump, auditability

알고리즘 품질만 따지면 후보는 더 있습니다 (PCG, Mersenne Twister, ChaCha8 등). 그럼에도 게임 클라이언트에서 Xoshiro가 사실상 표준이 된 데에는 실무적인 이유가 몇 가지 있습니다.

Burst / Job System 호환성

Xoshiro의 state는 ulong/uint 같은 primitive 값 타입뿐이고, 한 호출 안에 분기, 할당이 없습니다. 그대로 struct로 작성하면 다음이 자연스럽게 따라옵니다.

  • [BurstCompile] 대상이 됩니다 - SIMD 수준까지 최적화 가능
  • IJob/IJobParallelFor 안에서 by-value로 안전하게 들고 다닙니다
  • NativeArray<Xoshiro256PlusPlus>로 워커마다 RNG 하나씩 펼치는 패턴이 자연스럽습니다

Burst를 쓰지 않는 프로젝트에서도 이 구조는 그대로 유효합니다. GC alloc이 없으니 매 프레임 호출되는 코드(파티클, 절차적 생성, 셰이더 입력 보조)에 박아도 부담이 없습니다. Class 기반 RNG라면 인스턴스 풀링을 고민해야 할 자리가, struct 하나로 사라집니다. 이게 레퍼런스 코드들이 Burst를 쓰지 않는 환경에서도 굳이 직접 구현 RNG를 박아넣는 이유 중 하나입니다.

Jump-ahead로 결정론적 stream 분기

뒤의 부록에서 자세히 다루지만 여기서 짚어두면, 한 시드에서 jump()를 N번 부르면 수학적으로 절대 겹치지 않는 N개의 RNG stream을 얻을 수 있습니다. System.Random에는 이런 개념이 없습니다 - 멀티 스트림이 필요하면 시드를 다르게 줘서 새 인스턴스를 만들어야 하고, 충돌 회피는 사용자 책임입니다. Xoshiro의 jump는 비겹침을 알고리즘 차원에서 보장해 줍니다.

Auditability - 한국 가챠 확률 규제 관점

2024년 3월부터 시행된 한국 게임산업법 개정안은 확률형 아이템의 종류, 확률, 기간을 의무 표시하도록 규정합니다. 이게 실무에 던지는 진짜 부담은 “확률을 적어 둬라”가 아니라, 분쟁이 생겼을 때 표시한 확률대로 RNG가 실제로 동작했음을 입증할 수 있어야 한다는 점입니다.

표준 라이브러리의 블랙박스 RNG는 이 자리에서 자기 발목을 잡습니다. 알고리즘이 명세되어 있지 않고 런타임 버전에 따라 동작이 흔들리면, 내부 QA도 외부 감사도 결과를 비트 단위로 재현, 검증할 수 없습니다.

직접 구현한 Xoshiro는 그 반대입니다.

  • 알고리즘이 1차 출처(prng.di.unimi.it)에 정의되어 있습니다
  • 시드와 호출 횟수만 주어지면 누구든 같은 비트 시퀀스를 재구성할 수 있습니다
  • 통계 검정(BigCrush, PractRand) 통과 결과가 공개되어 있습니다

가챠 결과 분쟁이 생겨도 “이 시드에서 N번째 호출의 결과는 정확히 이 값이었다”를 비트 단위로 재구성할 수 있습니다. 이 재현 가능성이 곧 감사 가능성(auditability)이고, 한국처럼 확률 규제가 강한 시장에서는 이게 기술적 우아함이 아니라 법적 방어선입니다.

SplitMix64 - 저자가 추천한 시드 분배기

Xoshiro 계열에는 공통적인 함정이 있습니다. state가 전부 0이 되면 영원히 0만 출력한다는 점입니다. 선형 연산만으로 상태를 갱신하기 때문에, 0 입력은 0 출력이 되는 것이 수학적으로 자명합니다.

그래서 사용자가 시드 1개(예: 유저 ID 해시 + 세션 카운터)에서 256비트 state 4개를 펼치고 싶을 때, “그냥 시드를 4번 쪼개 넣자”고 하면 안 됩니다. 시드가 우연히 0이거나 패턴이 단순하면 초기 출력 품질이 망가집니다.

저자는 이 문제의 해법으로 SplitMix64라는 별도의 작은 RNG를 명시적으로 추천합니다.

인용

We suggest to use a SplitMix64 generator […] to fill the state of our generators starting from a 64-bit seed, as research has shown that initialization must be performed with a generator radically different in nature from the one initialized to avoid correlation on similar seeds.

SplitMix64는 그 자체로도 통계적으로 우수한 64비트 RNG이지만, 여기서는 시드 분배기로만 씁니다. 알고리즘 4단계 분해, 매직 상수의 출처, unchecked 블록의 의의는 SplitMix64 - 64비트 정수 비트 믹서와 시드 분배기에 따로 정리해 두었습니다. 이 글에서는 시드 분배기 인터페이스만 짚습니다.

SplitMix64.cs
public struct SplitMix64
{
    public ulong State;
    public SplitMix64(ulong seed) { State = seed; }
    public ulong Next() { /* 4-stage mix, 자세한 분해는 위 링크 참조 */ }
}

핵심 성질은 0 시드에서도 첫 호출이 비0 출력을 만들고, 시드 1비트 차이가 출력 약 절반을 뒤바꾼다는 두 가지입니다. 이 두 성질이 곧 시드 분배기로서 요구되는 조건입니다.

이 SplitMix64를 한 번 만들고 Next()를 N번 호출해서 다른 RNG의 state를 채웁니다.

Xoshiro256PlusPlus 초기화 부분
public Xoshiro256PlusPlus(ulong seed)
{
    // 시드 1개를 SplitMix64로 4번 펼쳐 256비트 state를 안전하게 채운다
    // (시드가 0이어도 SplitMix64는 비0 출력을 만들어 all-zero state 함정을 회피)
    var sm = new SplitMix64(seed);
    s0 = sm.Next();
    s1 = sm.Next();
    s2 = sm.Next();
    s3 = sm.Next();
}

이렇게 펼치면 시드가 0이어도 SplitMix64는 0이 아닌 출력을 만들어내므로 all-zero state가 만들어지지 않습니다.

Xoroshiro128++ - 가벼운 64비트 RNG

세 가지 변종 중 가장 작은 state(128비트, 즉 ulong 2개)를 가집니다. 출력은 64비트이고, 주기는 2^128 - 1입니다.

수학적 정의:

state 업데이트:

은 비트 좌회전(left rotate)입니다. C# 구현은 다음과 같습니다.

Xoroshiro128PlusPlus.cs
public struct Xoroshiro128PlusPlus
{
    private ulong s0, s1;    // 128비트 state = ulong 두 개
 
    public Xoroshiro128PlusPlus(ulong seed)
    {
        var sm = new SplitMix64(seed);
        s0 = sm.Next();
        s1 = sm.Next();
    }
 
    // 좌회전: 왼쪽으로 밀려난 비트가 오른쪽 끝으로 다시 들어옴. 정보 손실 없는 비트 셔플
    private static ulong Rotl(ulong x, int k) => (x << k) | (x >> (64 - k));
 
    public ulong Next()
    {
        unchecked
        {
            // ++ scrambler: 두 state의 합을 회전시키고 다시 s0을 더함.
            // 선형 부분에서 새는 하위 비트 패턴을 가리는 비선형 후처리
            ulong result = Rotl(s0 + s1, 17) + s0;
 
            // 이하 state advance (선형 부분, LFSR 류)
            s1 ^= s0;                              // s1에 s0 정보 주입
            s0 = Rotl(s0, 49) ^ s1 ^ (s1 << 21);   // 회전, XOR, shift 조합으로 s0 갱신
            s1 = Rotl(s1, 28);                     // s1 비트 위치를 돌려 다음 라운드 입력 다양화
 
            return result;
        }
    }
}

state advance가 한 줄로 끝날 만큼 짧습니다. 메모리 16바이트에 한 호출당 산술 연산 몇 개라, CPU L1 캐시 안에서 다 끝납니다.

Xoshiro128++ - 32비트 출력 RNG

state는 128비트(uint 4개), 출력은 32비트이고, 주기는 2^128 - 1입니다.

64비트 RNG가 있는데 32비트짜리가 따로 필요한 이유는 두 가지입니다.

  • 플랫폼 호환성: 32비트 ARM 같은 환경에서 64비트 정수 연산은 분리된 명령어로 컴파일됩니다. 호출량이 많을 때 차이가 생깁니다.
  • 출력 폭이 32비트면 충분한 경우: 화면 좌표, 인덱스, RGBA 채널값 등. 굳이 64비트로 만들어 절반을 버릴 필요가 없습니다.

알고리즘은 256++와 구조가 동일하지만 회전, 시프트 상수가 다릅니다.

Xoshiro128PlusPlus.cs
public struct Xoshiro128PlusPlus
{
    private uint s0, s1, s2, s3;    // 32비트 4개 = 128비트 state
 
    public Xoshiro128PlusPlus(ulong seed)
    {
        var sm = new SplitMix64(seed);
        // SplitMix64는 64비트 출력이라, 한 번 호출당 32비트 state 두 개를 잘라낸다
        ulong a = sm.Next();
        ulong b = sm.Next();
        s0 = (uint)(a);          // a의 하위 32비트
        s1 = (uint)(a >> 32);    // a의 상위 32비트
        s2 = (uint)(b);
        s3 = (uint)(b >> 32);
    }
 
    private static uint Rotl(uint x, int k) => (x << k) | (x >> (32 - k));
 
    public uint Next()
    {
        unchecked
        {
            // ++ scrambler: 32비트 출력. 폭이 좁은 만큼 회전 양도 7로 작음 (256++의 23 대응)
            uint result = Rotl(s0 + s3, 7) + s0;
 
            uint t = s1 << 9;     // 이후 단계에서 s2에 합류시킬 임시값을 미리 떠둔다
 
            // 4-way 고리형 XOR (xo'shi'ro의 'shi' = shuffle)
            s2 ^= s0;
            s3 ^= s1;
            s1 ^= s2;
            s0 ^= s3;
 
            s2 ^= t;              // 빼두었던 t를 s2에 합류시켜 한 번 더 흔든다
 
            s3 = Rotl(s3, 11);    // 'ro' = rotate. s3 비트 위치를 돌려 다음 라운드 입력을 다양화
 
            return result;
        }
    }
}

s0 + s3로 시작해서 4개 변수가 서로 XOR되며 섞이는 구조가 보입니다. 이 “고리형 셔플”이 xoshiro의 shi(shift) 부분이고, 마지막의 Rotl(s3, 11)ro(rotate)입니다. 이름 그대로입니다.

SplitMix64 출력은 64비트라서 한 번 호출에서 32비트 두 개를 뽑는 식으로 4개 state를 채웁니다.

Xoshiro256++ - 256비트 state, 64비트 출력 (범용 표준)

저자가 “범용으로는 이걸 써라”고 가장 적극적으로 추천하는 변종입니다. state는 256비트, 출력은 64비트, 주기는 2^256 - 1입니다.

Xoshiro256PlusPlus.cs
public struct Xoshiro256PlusPlus
{
    private ulong s0, s1, s2, s3;    // 64비트 4개 = 256비트 state
 
    public Xoshiro256PlusPlus(ulong seed)
    {
        var sm = new SplitMix64(seed);
        s0 = sm.Next();
        s1 = sm.Next();
        s2 = sm.Next();
        s3 = sm.Next();
    }
 
    private static ulong Rotl(ulong x, int k) => (x << k) | (x >> (64 - k));
 
    public ulong Next()
    {
        unchecked
        {
            // ++ scrambler: 64비트 출력. 폭이 두 배이므로 회전 양도 7 -> 23으로 키움
            ulong result = Rotl(s0 + s3, 23) + s0;
 
            ulong t = s1 << 17;   // shift 양도 9 -> 17로 폭에 맞춰 확장
 
            // 4-way 고리형 XOR (128++와 정확히 같은 구조, 자료형만 ulong)
            s2 ^= s0;
            s3 ^= s1;
            s1 ^= s2;
            s0 ^= s3;
 
            s2 ^= t;
 
            s3 = Rotl(s3, 45);    // 회전 양 11 -> 45 (64비트 폭에 맞춤)
 
            return result;
        }
    }
}

128++와 비교하면 자료형이 uint ulong으로 바뀌고 회전, 시프트 상수가 7, 9, 11 23, 17, 45로 커진 것 외에 차이가 없습니다. 같은 골격, 다른 폭.

상태 공간이 2^256으로 압도적으로 넓기 때문에 같은 시드에서 파생된 여러 서브 RNG를 분리하기에도 안전합니다. 가챠 RNG, 전투 RNG, 맵 생성 RNG를 각각 따로 굴려도 cycle이 겹칠 일이 사실상 없습니다.

한 항을 빼면 무슨 일이 벌어지는가 - feedback tap 실험

위 세 알고리즘을 따라 읽다 보면 “이 항이 정말 필요한가” 의심이 드는 자리들이 있습니다. 예를 들어 Xoroshiro128++의 state advance 마지막 줄.

s0 = Rotl(s0, 49) ^ s1 ^ (s1 << 21);   // <- 이 항은 왜 있는가?

회전과 XOR로 충분히 섞이는 것 같은데 마지막의 (s1 << 21)은 왜 굳이 들어가 있을까요. 이 항을 빼고 같은 비트맵 시각화를 돌려 보면 답이 곧바로 나옵니다.

왼쪽이 정상, 오른쪽이 (s1 << 21) 한 항만 제거한 버전입니다. 같은 시드, 같은 코드에서 한 줄만 차이인데 출력이 백색 노이즈에서 명백한 세로 줄무늬로 무너집니다. 앞서 본 Bad LCG 그림과 같은 종류의 결함입니다.

이 항이 LFSR의 feedback tap입니다. 각 연산을 비트 단위 가역성으로 쪼개 보면 왜 이게 결정적인지 보입니다.

  • 회전 (Rotl): 비트 자리의 cyclic permutation. 정보 손실 0, 위치만 재배치 (가역)
  • XOR: 두 ulong을 같은 비트 자리에서 합산 (가역)
  • 좌시프트 (<< 21): 하위 비트를 위로 밀어올리고, 밀려나간 상위 21비트는 버려짐 (비가역)

세 항 중 시프트만 비가역입니다. 이 비가역성이 LFSR을 LFSR이게 만듭니다. 상태 갱신을 GF(2) 위 128차원 선형 변환으로 보면, 그 변환의 특성다항식이 primitive polynomial일 때만 주기가 이 됩니다. Blackman & Vigna가 (49, 21, 28) 삼중을 체계적 탐색으로 선별한 이유가 정확히 이 조건을 만족시키기 위해서고, 시프트 항은 그 polynomial을 만들어 주는 핵심 자리입니다.

이 항을 빼면 특성다항식이 primitive를 잃고, 상태 공간이 작은 cycle 여러 개로 쪼개집니다. 주기가 에서 수만~수천만 step 수준으로 폭락합니다. 1024 x 1024 = 약 100만 픽셀이라 이미 한 번 사이클이 도는 사이에 패턴이 노출됩니다.

같은 실험을 Xoshiro256++에서 s2 ^= t; 줄을 빼면 똑같이 줄무늬가 나옵니다 - 그 한 줄도 256++의 feedback tap이기 때문입니다.

상수를 함부로 바꾸지 말 것

(49, 21, 28)(50, 21, 28)이나 (49, 22, 28)로 한 칸만 옮겨도 같은 식으로 망가질 수 있습니다. Vigna 공식 사이트가 “Don’t change the constants”라고 못 박는 이유입니다. 이 숫자들은 “잘 작동하는 값” 정도가 아니라 primitive polynomial 조건을 만족시키는 매우 좁은 후보 중 하나입니다.

비교표 - 어떤 걸 골라야 하는가

알고리즘state 크기출력주기용도
xoroshiro128++128 bit64 bit2^128 - 1메모리 빡빡한 곳, 모바일 가벼운 RNG
xoshiro128++128 bit32 bit2^128 - 132비트 출력만 필요할 때
xoshiro256++256 bit64 bit2^256 - 1범용 추천, 서브 RNG 분기 많을 때

저희 프로젝트는 결국 xoroshiro128++를 메인 RNG로 골랐습니다. 근거는 다음과 같습니다.

  • 서브 RNG 가짓수가 많지 않다: 도메인별(가챠, 전투, 맵 등)로 펼치긴 하지만 인스턴스가 손에 꼽히는 수준이라, 2^128 state로도 분기 후 cycle 충돌 가능성이 사실상 0입니다. 굳이 2^256까지 갈 필요가 없었습니다.
  • 출력은 64비트가 필요: 32비트 변종은 좌표, 인덱스 위주로 쓸 때 의미가 있는데, 저희는 가중치 추첨, 확률 판정 등에서 부동소수점 [0,1) 변환까지 쓰는 자리가 많아 64비트 출력이 자연스럽습니다. 64비트 ARM이 표준인 지금 32비트 변종을 골라서 얻을 이득도 없습니다.
  • 풋프린트가 가볍다: state 16바이트 + 한 호출에 산술 몇 개로 끝납니다. RNG가 가챠 테이블, 전투 데미지, 맵 변형, UI 연출까지 코드 곳곳에 박혀 들어가는 사용 패턴에 부담 없이 맞물립니다.

실무 구현에서 만난 변형들

위 코드는 self-contained 예제입니다. 실제 프로젝트에 박아 보면 몇 가지 의도적인 변형이 자연스럽게 따라붙습니다. 저희 RandomModule을 예로 짚어 두겠습니다.

인터페이스 + class - struct를 포기하는 자리

글의 예제는 struct였지만, 실제 RNG는 sealed classIDeterministicRng 인터페이스를 채우는 형태로 갔습니다.

IDeterministicRng.cs
public interface IDeterministicRng
{
    double NextDouble();
    int NextInt(int min, int max);
    bool NextBool(float chance);
    T Pick<T>(IList<T> list);
    List<T> Shuffle<T>(IList<T> list);
    T WeightedPick<T>(IDictionary<T, float> weights);
}

의식적인 트레이드오프입니다.

  • 버린 것: Burst 컴파일 직접 대상화. struct가 아니므로 [BurstCompile] 안에서 by-value로 못 다룹니다.
  • 얻은 것: 다형성. 테스트에서 결정론 RNG 대신 MockRng를 주입할 수 있고, 같은 인터페이스로 결정론/비결정론 RNG를 동일하게 다룰 수 있습니다.

Burst를 쓰지 않는 프로젝트라면 이 트레이드는 인터페이스 쪽이 거의 항상 이깁니다. RNG 호출 빈도가 “매 프레임 수만 번”이 아니라 “이벤트 발생 시 수 회”라면 RNG 인스턴스 발급 시 발생하는 GC alloc 비용도 무시 가능합니다.

PRNG와 시드 분배기의 3계층 분리

글에서는 RNG 생성자 안에서 SplitMix64를 부르는 형태로 보여 줬지만, 실무에서는 이 둘을 다른 계층으로 분리하는 게 거의 필연입니다. 저희 코드는 이렇게 갈라져 있습니다.

계층책임형태
SplitMix64비트 mix 함수 (stateless)static, ref ulong state
RandomManagermaster seed + tag + discriminator (s0, s1)static class
Xoroshiro128PlusPlus주어진 state로 굴리기sealed class : IDeterministicRng

SplitMix64가 인스턴스가 아니라 ref ulong state를 받는 static 함수인 점이 깔끔합니다. 시드 분배기는 자기 상태를 가질 이유가 없습니다 - 호출자가 들고 있는 state를 직접 갱신할 뿐이고, “한 번 더 섞는다”는 의미만 함수 호출로 표현됩니다. 정적 형태 구현은 SplitMix64 글의 구현 섹션에 두 형태(struct/static)를 나란히 정리해 두었습니다.

핵심은 가운데 계층, RandomManager.For()입니다.

RandomManager.cs
public static IDeterministicRng For(RandomModuleTag tag, params long[] discriminators)
{
    var state = (ulong)_saveSeed;
    SplitMix64.Next(ref state);
    state ^= (ulong)(long)tag;
    SplitMix64.Next(ref state);
 
    foreach (var d in discriminators)
    {
        state ^= (ulong)d;
        SplitMix64.Next(ref state);
    }
 
    var s0 = SplitMix64.Next(ref state);
    var s1 = SplitMix64.Next(ref state);
    return new Xoroshiro128PlusPlus(s0, s1);
}

글에서 “도메인별 서브 RNG 분기”라고 추상적으로 말한 부분이, 여기서는 (saveSeed, tag, discriminators) 튜플을 키로 하는 RNG 팩토리로 구체화됩니다. 같은 키 조합은 항상 같은 RNG, 같은 굴림 시퀀스를 반환합니다. 강종 후 재진입해도 결과가 안 바뀝니다 - 글 도입에서 약속한 강종작 방어가 이 한 메서드로 압축됩니다.

도메인 식별을 enum으로 둔 것도 의도가 있습니다.

RandomModuleTag.cs
public enum RandomModuleTag
{
    Certificate = 1,
    MentorTalkGacha = 2,
    SuddenMission = 3,
    // ...
}

문자열을 안 쓰니 오타로 인한 silent collision이 없고, 명시적 정수값(= 1, = 2, ...)을 박아 두니 나중에 가운데에 새 태그를 추가해도 기존 자리가 흔들리지 않습니다. 결정론 시스템에서 enum 값 변동은 곧 시드 시퀀스 변동이라, 이 정도 방어는 의무에 가깝습니다.

Discriminator로 사건 단위 분리

Forparams long[] discriminators가 의외로 중요한 자리입니다. 같은 도메인에서도 사건마다 결과가 달라야 합니다 - 자격증 도전을 두 번 했으면 두 결과가 달라야 합니다. 매번 새 master seed를 갈 수는 없으니, 호출자가 “이번이 몇 번째 호출인지”를 discriminator로 넘깁니다.

// 자격증 N번째 도전
RandomManager.For(RandomModuleTag.Certificate, attemptCount);
 
// 가챠 (세션 ID + 슬롯 인덱스)
RandomManager.For(RandomModuleTag.MentorTalkGacha, sessionId, slotIndex);

호출 측이 discriminator의 의미를 정의하고, RandomManager는 그걸 비트로 섞는 일에만 책임집니다. 결정론을 깨뜨리지 않으려면 호출자가 같은 사건에 같은 discriminator를 넘겨야 한다는 규약만 지키면 되고, 이 규약은 호출 코드의 자연스러운 모양 안에 묻힙니다(자격증 모듈은 자기 도전 횟수를 들고 있고, 가챠 모듈은 세션, 슬롯을 들고 있습니다).

(덤) 코드 리뷰에서 짚어 둔 허점들

위 구조 자체는 깔끔하지만, 한 줄씩 따라가 보니 손볼 자리가 몇 군데 있었습니다. 결정론 시스템 만들 때 빠지기 쉬운 함정들이라 정리해 둡니다.

  • WeightedPickIDictionary 순회: 가장 위험합니다. C# Dictionary<TKey, TValue>의 enumeration 순서는 공식적으로 명세되어 있지 않습니다(MSDN이 “do not depend on enumeration order”로 못 박음). Mono / IL2CPP / .NET 버전에 따라 순서가 달라지면 같은 시드 + 같은 weights여도 cumulative 누적이 달라져 다른 키가 뽑힙니다. 결정론이 silent하게 깨지는, 강종작 방어 시스템이 노리는 바로 그 클래스의 버그입니다. 해결: 시그니처를 IList<KeyValuePair<T, float>> 또는 IReadOnlyList<(T item, float weight)>로 바꿔 호출자가 순서를 책임지게 합니다.
  • unchecked 블록 부재: NextUInt64SplitMix64.Next 둘 다 unchecked 없이 산술을 굴립니다. Unity 기본 설정에서는 동작하지만 누군가 csproj에 <CheckForOverflowUnderflow>true</CheckForOverflowUnderflow>를 켜는 순간 첫 호출에서 throw합니다. 비용 0인 무료 보험이니 둘러두는 게 안전합니다.
  • 레거시 비결정론 메서드 공존: 같은 RandomManager 안에 _random = new System.Random() 기반 메서드들이 마이그레이션 중인 상태로 함께 있습니다. 결정론이 깨지면 안 되는 자리에서 실수로 이쪽 API가 불릴 위험을 줄이기 위해, Roslyn analyzer로 호출 금지 룰을 박거나 클래스를 둘로 쪼개 두는 게 안전합니다.
  • Environment.TickCount 시드 품질: 비결정론 경로(NewNonDeterministic())가 32비트 TickCount를 시드로 쓰는데, 부팅 직후엔 엔트로피가 매우 낮고 약 25일마다 wrap합니다. 어뷰징 surface 없는 자리라 큰 문제는 아니지만 DateTime.UtcNow.Ticks(64비트, 100ns 해상도)가 더 안전한 디폴트입니다.

부록 - jump function과 서브 RNG 분기

Xoshiro 계열에는 한 가지 더 유용한 도구가 딸려 옵니다. jump function입니다.

xoshiro256의 경우 jump()를 한 번 호출하면 state가 2^128 step만큼 앞으로 점프합니다. 즉 한 RNG에서 jump를 N번 부르면 서로 절대 겹치지 않는 N개의 stream을 얻을 수 있습니다.

원본 RNG:        [s] --next--> next --next--> ... (2^128 step)
jump 1번:        [s'] = state after 2^128 calls
jump 2번:        [s''] = state after 2 * 2^128 calls

쓰임새는 두 가지입니다.

  • 병렬 시뮬레이션: 워커 스레드마다 독립 RNG가 필요한데 각자 다른 시드를 쓰면 우연히 겹칠 위험이 있습니다. 같은 시드에서 jump로 분기하면 수학적으로 비겹침이 보장됩니다.
  • 도메인별 서브 RNG 분기: 위에서 언급한 가챠/전투/맵 RNG를 시드 1개에서 jump로 펼치는 방법입니다. 다만 게임에서는 SplitMix64로 도메인 ID마다 다른 시드를 만들어 새 RNG를 인스턴스화하는 쪽이 더 단순합니다.

jump 자체의 구현은 미리 계산된 64비트 상수 4개와 XOR/shift 루프로 이루어져 있습니다. 길이는 30줄 남짓입니다. 필요해지면 Vigna 공식 사이트의 C 레퍼런스 코드를 그대로 옮겨오면 됩니다.


결정론 시스템에서 RNG는 “잘 섞이는 함수”가 아니라 “비트 단위로 재현 가능한 함수”입니다. 그래서 환경에 따라 동작이 흔들릴 가능성이 있는 표준 라이브러리 RNG는 후보에서 빠집니다. Xoshiro 계열은 이 두 요구사항(통계적 품질 + 비트 결정론)을 동시에 만족시키고, 코드도 한 화면에 들어올 만큼 짧습니다.

저자가 추천한 SplitMix64로 시드를 펼치고, 프로젝트 규모에 맞는 ++ 변종(보수적으로는 256++, 가지 수가 손에 꼽히면 128++)으로 RNG를 굴립니다 - 이 두 줄이 결정론 RNG 설계의 합리적인 디폴트입니다.