난수가 한 프로세스 안에서만 돌면 어떤 엔진을 쓰든 상관없다. 문제는 결과가 경계를 넘을 때 생긴다. 넘는 경계는 넷이다.

  • 기기 경계 — 서버와 클라이언트가 각자 계산해 같은 판정에 도달해야 하는 구조
  • 시간 경계 — 리플레이, 시드로 재생성하는 세이브·월드
  • 빌드 경계 — 개발은 윈도우, 서비스는 리눅스. 혹은 컴파일러 업그레이드
  • 언어 경계 — C++ 서버와 C#(유니티) 클라이언트

이 글은 그 경계를 넘을 때 무엇이 실제로 깨지는지 재 보고, MT19937을 공용 규격으로 삼아 양쪽을 맞추는 방법을 정리한다. 난수 알고리즘 자체와 C++ 구현은 C++ 난수 알고리즘 — rand부터 PCG까지 직접 구현하며에서 다뤘다.

표기환경
MinGWWindows, g++ 13.2 (libstdc++)
LinuxWSL2 우분투 22.04, g++ 11.4 (libstdc++)
MSVCWindows, Visual Studio 2026 (MSVC STL 14.51)
libc++WSL2, clang 18.1.8 + libc++
C#.NET 9.0.8

무엇이 실제로 갈리는가

엔진은 갈리지 않는다

먼저 좋은 소식이다. std::mt19937은 표준이 상수·시딩 절차·검증값까지 규정해서 어느 툴체인에서도 같은 수열을 낸다.

MinGW / Linux / MSVC   mt19937 10000th = 4123659995   (표준이 요구하는 값)
                       mt19937(42) raw = 1608637542 3421126067 4083286876 787846414

mt19937_64, minstd_rand0, ranlux24_base, seed_seq 초기화까지 확인했고 전부 일치했다. 개발은 윈도우, 서비스는 리눅스인 C++ 구성이라면 엔진 선택으로는 문제가 없다.

분포는 갈린다

표준은 분포가 “어떤 확률분포를 따르는지”만 요구하고 알고리즘을 규정하지 않는다. 같은 엔진·같은 시드로 세 툴체인을 돌린 결과다.

대상 (엔진 mt19937(42) 고정)libstdc++ (MinGW·Linux)libc++ (clang 18)MSVC 14.51
uniform_int_distribution<int>(1,6) 12개356255441311453552333543356255441311
uniform_int_distribution<uint64_t>(0,10^18) 첫 값796542985237037916574339904095010227374540114495098354
uniform_real_distribution<double>(0,1) 첫 값0.796542984287845980.796542984287845980.79654298428784587
normal_distribution<double>(0,1) 두 값-0.55023…, 0.51543…0.51543…, -0.55023…0.51543…, -0.55023…
std::shuffle (10장)2 7 8 1 6 10 9 3 4 57 5 1 10 3 4 2 6 8 95 10 3 4 8 9 2 6 7 1
discrete_distribution({50,30,15,5})101100000101101100000101101100000101

읽는 순서가 중요하다. 주사위(1~6)는 libstdc++와 MSVC가 같지만 libc++는 다르다. 즉 “두 컴파일러에서 같길래 괜찮은 줄 알았다”가 정확히 함정이다. 셋째 구현을 만나는 순간 깨진다.

uint64_t 범위는 셋이 전부 다르고, 실수 분포는 MSVC만 마지막 자리에서 갈린다. 정규분포는 값 쌍은 같은데 돌려주는 순서가 libstdc++만 반대다 — 한 번에 두 개를 만들어 하나를 캐시하는 구조라 어느 쪽을 먼저 주는지가 구현 마음이다. 셔플은 셋 다 다르다.

정리하면 분포에서 일치한 것은 우연이고, 어긋난 것이 원칙이다.

왜 갈리는가 — 엔진을 몇 조각으로 잘라 쓰는지가 다르다

std::shuffle내가 만든 엔진(PCG32)을 넣어도 결과가 갈린다. 엔진 호출 횟수를 세어 보면 이유가 바로 나온다.

libstdc++ (MinGW·Linux)   std::shuffle 10장 → 엔진 호출 5회 → 2 10 4 3 6 8 7 1 9 5
MSVC                      std::shuffle 10장 → 엔진 호출 9회 → 10 4 7 1 9 6 3 5 8 2

10장이면 교환은 9번이다. MSVC는 교환마다 32비트를 하나씩 쓰고, libstdc++는 32비트 하나에서 작은 인덱스 두 개를 뽑아 5번으로 끝낸다. 같은 수열을 받아도 자르는 방식이 달라 순열이 달라진다.

분포도 마찬가지로 소비량이 정해져 있지 않다.

호출엔진 소비
uniform_int(0, 9)1회
uniform_int<uint64_t>(0, 10^18)2회
uniform_real(0, 1)2회
normal(0,1) 첫 번째 값4회
normal(0,1) 두 번째 값0회

정규분포가 두 번째 값에서 엔진을 안 쓰는 건 한 번에 두 개를 만들어 캐시하기 때문이고, 위 표에서 libstdc++와 MSVC의 정규분포가 같은 값 쌍인데 순서만 뒤바뀐 이유도 그것이다.

엔진을 고정해도 소비 방식이 고정되지 않으면 재현되지 않는다.

값을 담는 타입도 갈린다

엔진 출력이 같아도 그다음 산술에서 갈리는 경로가 하나 더 있다. std::mt19937::result_typeuint_fast32_t이고, 이건 크기가 고정이 아니다.

환경sizeof(mt19937::result_type)auto v = g(); v * v
MinGW (Windows)43708516516
MSVC (Windows)43708516516
Linux (g++)82587714741531801764

v는 셋 다 1608637542인데, auto로 받아 곱하면 윈도우에서는 32비트에서 잘리고 리눅스에서는 안 잘린다. 해시·좌표 계산·시드 조합에 한 번만 태워도 결과가 갈라지고, 파일이나 패킷에 그대로 쓰면 4바이트와 8바이트가 오간다. 엔진 출력은 uint32_t로 받는다.

여기서 갈라지는 축이 둘이라는 게 드러난다. 하나는 표준 라이브러리 구현(분포·셔플)이고 다른 하나는 OS/ABI의 타입 폭이다. 앞의 것은 MinGW와 리눅스가 같은 편이고, 뒤의 것은 MinGW와 MSVC가 같은 편이다. “윈도우냐 리눅스냐”로는 어느 쪽도 설명되지 않는다.

언어가 다르면 알고리즘부터 다르다

C# 클라이언트가 상대라면 위의 이야기는 사치다. 애초에 다른 생성기를 돌린다.

기본 난수알고리즘
C++ 서버std::mt19937MT19937
C# 로직System.RandomKnuth의 뺄셈 방식(subtractive)을 고친 것
유니티 클라이언트UnityEngine.RandomXorshift 128
C++  std::mt19937(42)      → 1608637542 3421126067 4083286876 787846414
C#   new Random(42).Next() → 1434747710  302596119  269548474 1122627734

유니티 문서는 자기 생성기를 “an Xorshift 128 algorithm, based on the paper Xorshift RNGs by George Marsaglia”라고 밝히고 있다. 그리고 System.Random은 같은 시드라도 “different versions of .NET”에서 다른 수열이 나올 수 있다고 문서가 경고한다 — 런타임을 올리면 바뀔 수 있다.

MT19937을 공용 규격으로 삼기

여기서 MT19937이 갖는 장점은 품질도 속도도 아니다. 표준이 검증값을 정해 두어서, 내가 옮긴 구현이 맞는지 증명할 수 있다는 점이다. 기본 시드(5489)로 10000번 돌린 값이 4123659995가 나오면 그 구현은 정통 MT19937이다.

C#으로 옮기기

C#에는 MT19937이 없으니 직접 넣는다. C++ 표준이 규정한 상수와 시딩 절차를 그대로 옮기면 된다.

sealed class Mt19937 {
  const int N = 624, M = 397;
  const uint MatrixA = 0x9908B0DFu, UpperMask = 0x80000000u, LowerMask = 0x7FFFFFFFu;
  readonly uint[] mt = new uint[N];
  int mti = N + 1;
 
  public Mt19937(uint seed = 5489u) {          // C++ 기본 시드와 같다
    mt[0] = seed;
    for (uint i = 1; i < N; i++)               // 시딩 곱수 1812433253
      mt[i] = unchecked(1812433253u * (mt[i - 1] ^ (mt[i - 1] >> 30)) + i);
    mti = N;
  }
 
  public uint Next() {
    if (mti >= N) {                            // twist
      for (int k = 0; k < N; k++) {
        uint y = (mt[k] & UpperMask) | (mt[(k + 1) % N] & LowerMask);
        uint next = mt[(k + M) % N] ^ (y >> 1);
        if ((y & 1) != 0) next ^= MatrixA;
        mt[k] = next;
      }
      mti = 0;
    }
    uint x = mt[mti++];                        // tempering
    x ^= x >> 11;
    x ^= (x << 7) & 0x9D2C5680u;
    x ^= (x << 15) & 0xEFC60000u;
    x ^= x >> 18;
    return x;
  }
}

돌려 보면 이렇다.

C#   mt19937 10000th = 4123659995        ← 표준 요구값과 일치
C#   mt19937(42) raw = 1608637542 3421126067 4083286876 787846414
C++  mt19937(42) raw = 1608637542 3421126067 4083286876 787846414

옮길 때 걸리는 것 둘.

  • unchecked — 시딩의 곱셈은 32비트에서 넘치는 것을 전제로 한다. 프로젝트를 /checked+로 빌드하면 그 자리에서 System.OverflowException이 난다.
  • 부호 없는 타입uint로 통일한다. int로 두면 >>가 산술 시프트가 되어 최상위 비트가 번진다.

분포도 같이 옮겨야 한다

엔진만 맞추면 절반이다. uniform_int_distribution은 C#에 없고, 있어도 구현이 다르다. 양쪽에 같은 유계 난수를 넣는다.

static uint BoundedLemire(Mt19937 rng, uint n) {   // [0, n)
  ulong m = (ulong)rng.Next() * n;
  uint l = (uint)m;
  if (l < n) {
    uint t = (uint)(-(int)n) % n;
    while (l < t) { m = (ulong)rng.Next() * n; l = (uint)m; }
  }
  return (uint)(m >> 32);
}

C++ 쪽은 앞 글의 bounded_lemire를 그대로 쓴다. 시드 12345로 주사위를 20번 굴린 결과다.

C++  66212125444663465341
C#   66212125444663465341

셔플과 가중치 뽑기도 마찬가지다. 피셔-예이츠와 누적합 이분 탐색을 양쪽에 같은 코드로 두면 순열까지 맞는다. 표준 알고리즘(std::shuffle)은 쓰지 않는다.

엔진 상태를 주고받기

중간부터 이어 돌리려면 시드가 아니라 상태를 넘겨야 한다. C++에는 operator<<가 있으니 그걸 쓰면 될 것 같지만, 구현마다 형식이 다르다.

시드 42로 5번 뽑은 뒤 상태를 찍어 비교한 결과다.

구현토큰 수첫 세 값마지막 값
libstdc++625723970371 1229153189 41704120095 (=소비 인덱스)
MSVC6244081230161 2732361568 13612389613342822751

같은 엔진, 같은 상태인데 텍스트가 다르다. 각자 자기가 쓴 것을 읽어 복원하는 것은 잘 되지만(양쪽 다 복원 후 이어 뽑은 값이 일치했다), 한쪽이 쓴 문자열을 다른 쪽이 읽는 것은 보장되지 않는다.

그래서 상태 교환은 직접 정한 형식으로 한다. 필요한 건 두 가지뿐이다.

  • 상태 배열 624개 (uint32)
  • 다음에 꺼낼 위치(소비 인덱스) 1개

C#에서 배열을 순서 그대로 쓰고 인덱스를 뒤에 붙이면 libstdc++ 형식과 같은 값이 나온다(우연이지만 확인은 됐다). 실전에서는 그 우연에 기대지 말고 자기 포맷을 정의하고 양쪽에 파서를 둔다. 크기는 624 × 4바이트 = 2496바이트, 여기에 인덱스를 더해 2.5KB다. 매 프레임 주고받을 물건은 아니고, 세션 시작이나 체크포인트에서 한 번 맞추는 용도다.

어긋남을 일찍 잡기

이런 종류의 버그는 한참 뒤에, 재현이 안 되는 형태로 드러난다. 두 겹으로 막는다.

1. 빌드마다 검증 벡터를 돌린다. 양쪽 테스트에 같은 기대값을 박아 둔다. 값 1000개를 해시 하나로 줄이면 비교가 간단하다.

mt19937(12345) 첫 1000개의 FNV-1a 64 = 15251478601651558344
Lemire 주사위 20개                    = 66212125444663465341

C++(MinGW·Linux)와 C#에서 모두 같은 값이 나왔다. 이 테스트가 깨지면 런타임 업그레이드든 컴파일러 교체든 그 자리에서 잡힌다. 게임을 띄워 보고 이상하다는 것보다 훨씬 낫다.

2. 실행 중에는 체크섬을 비교한다. 일정 주기(프레임, 틱)마다 양쪽이 시뮬레이션 상태의 해시와 난수 소비 횟수를 교환해 맞춰 본다. 어긋난 순간의 틱 번호를 알면 원인 후보가 그 한 틱으로 좁혀진다. 소비 횟수를 함께 보내는 이유는, 한쪽에만 있는 코드가 난수를 하나 더 뽑아 이후가 통째로 밀리는 사고가 가장 흔하기 때문이다.

MT19937이 최선인가

이기종 공유라는 목적만 놓고 보면 MT19937의 장점은 검증값이 표준에 있다는 것 하나다. 나머지는 오히려 불리하다.

항목MT19937PCG32
상태 크기2504바이트16바이트
상태 전송2.5KB16바이트
시딩 비용926 ns수 ns
뽑기 속도1.4~5.3 ns1.0~1.7 ns
스트림 분리없음있음
검증 기준표준에 있음직접 정해야 함

PCG32도 C++과 C#에서 같은 값을 낸다는 것은 확인했다.

C++  PCG32(42) raw = 1307692281 3850602322 1491967504 4091771729
C#   PCG32(42) raw = 1307692281 3850602322 1491967504 4091771729

그러니 새로 시작한다면 PCG32 쪽이 낫다. 상태가 16바이트라 체크포인트에 통째로 실어 보낼 수 있고, 스트림을 나누면 “한쪽만 난수를 하나 더 뽑는” 사고의 영향 범위를 용도별로 가둘 수 있다. 검증값은 직접 만들어 테스트에 박아 두면 그만이다.

MT19937이 답이 되는 경우는 하나다 — 이미 한쪽이 MT19937로 돌아가고 있어 그 수열을 바꿀 수 없을 때. 그때는 위의 포팅과 검증 벡터로 반대편을 맞추면 된다.

이 글의 모든 출력은 MinGW g++ 13.2 / WSL2 우분투 g++ 11.4 / clang 18.1.8 + libc++ / MSVC 14.51 / .NET 9.0.8에서 같은 로직을 돌려 직접 비교한 것이다. 유니티는 이 환경에 없어 문서 근거로만 적었다.

값을 비교할 때 printf에 난수 호출 두 개를 넣지 않았다. 인자 평가 순서는 컴파일러 재량이라, 그렇게 재면 libstdc++와 MSVC의 값 순서가 다른 것처럼 보인다 — 실제로 처음에 그 함정에 빠져 잘못 읽었다. 위 표는 호출을 한 줄씩 분리해 다시 잰 값이다.

참고