C++11 random 라이브러리: rand() % n이 편향되는 이유와 mt19937·분포 사용법
이 글의 핵심
rand()의 모듈로 편향 문제에서 출발해 C++11 <random>의 엔진(mt19937)과 분포를 분리해 쓰는 방법을 정리합니다. 시드를 매번 같은 값으로 쓰는 버그, 플랫폼에 따라 결정적일 수 있는 random_device, 분포 결과의 이식성, 엔진별 성능 차이(직접 측정)도 다룹니다.
rand() 문제점
rand() % N이 왜 문제인지는 대부분 “옛날 방식이라서” 정도로 뭉뚱그려 알고 있지만, 실제로는 수학적으로 명확한 편향(bias)이 있습니다. C 표준상 RAND_MAX는 최소 32767이 보장될 뿐이고, 이 값에 1을 더한 RAND_MAX+1이 나누려는 N으로 정확히 나누어떨어지는 경우는 거의 없습니다. 예를 들어 RAND_MAX가 32767이고 N이 100이라면, RAND_MAX+1(32768)을 100으로 나누면 327.68이 남습니다. 즉 rand()가 반환할 수 있는 32768개의 정수를 100개의 구간으로 나눌 때, 0부터 67까지의 값은 328번씩 나올 기회를 얻지만 68부터 99까지의 값은 327번씩만 나올 기회를 얻습니다. 이 차이는 통계적으로 미미해 보여도, 몬테카를로 시뮬레이션이나 게임의 확률 밸런싱처럼 수백만 번 반복되는 코드에서는 눈에 띄는 왜곡으로 누적됩니다.
RAND_MAX가 최소값인 32767에 머무는 플랫폼이 실제로 흔하다는 점도 기억해 둘 만합니다. Windows의 MSVC 런타임과 MinGW 계열이 그렇습니다(이 글의 측정에 쓴 TDM-GCC 10.3에서도 32767이 출력됩니다). 이런 환경에서 rand() % 100000을 쓰면 편향 수준이 아니라 32768 이상의 값이 아예 나오지 않습니다. 리눅스 glibc에서는 RAND_MAX가 2147483647이라 같은 코드가 멀쩡히 돌아가기 때문에, 크로스 플랫폼 프로젝트에서 이 버그는 Windows 빌드에서만 드러나곤 합니다.
두 번째 문제는 엔진 자체의 품질입니다. 많은 C 표준 라이브러리 구현에서 rand()는 선형 합동 생성기(Linear Congruential Generator, LCG)를 사용하는데, 이 알고리즘은 하위 비트일수록 주기가 짧고 패턴이 쉽게 드러난다는 약점이 있습니다. 그래서 rand() % 2로 홀짝을 뽑으면 특정 구현에서는 완전히 무작위하지 않은, 예측 가능한 패턴이 나타나기도 합니다. 세 번째 문제는 스레드 안전성입니다. rand()는 내부적으로 숨은 전역 상태를 쓰고, C/C++ 표준은 rand()의 스레드 안전성을 요구하지 않습니다. 구현에 따라 락을 걸거나 스레드별 상태를 두기도 하지만, 이식성 있는 코드라면 여러 스레드에서 동시에 호출하는 것을 데이터 레이스로 간주해야 합니다.
// ❌ 구식 방법
std::srand(std::time(nullptr));
int x = std::rand() % 100; // 0-99
// 문제점:
// 1. 균등 분포 아님 (모듈로 편향, RAND_MAX+1이 N의 배수가 아니면 발생)
// 2. RAND_MAX가 32767인 플랫폼에서는 큰 범위를 아예 못 뽑음
// 3. 엔진 품질 낮음 (LCG 하위 비트 패턴 반복)
// 4. 스레드 안전성이 표준으로 보장되지 않음 (숨은 전역 상태)
현대적 난수 (C++11)
C++11의 <random>이 채택한 설계는 “엔진(engine)“과 “분포(distribution)“를 완전히 분리하는 방식입니다. 엔진은 결정론적인 규칙에 따라 균일하게 분포된 비트 시퀀스를 생성하는 역할만 하고, 분포는 그 비트 시퀀스를 받아 원하는 확률 모델(균등, 정규, 포아송 등)로 변환하는 역할만 합니다. 이렇게 관심사를 나눈 이유는 실용적입니다. 같은 엔진 하나로 정수 균등 분포, 실수 균등 분포, 정규 분포를 동시에 뽑아낼 수 있고, 엔진을 mt19937에서 다른 것으로 바꾸더라도 분포 코드는 그대로 재사용할 수 있습니다. rand()처럼 난수 생성과 범위 변환이 한 함수에 뭉쳐 있던 구조와 비교하면 훨씬 유연합니다.
아래 예제에서 random_device는 시드 값 하나만 만들어내는 용도로 쓰이고, 실제 난수는 mt19937 엔진이 매 호출마다 생성하며, uniform_int_distribution이 그 값을 1~100 범위로 균등하게 매핑합니다. 흐름으로 쓰면 random_device → (시드) → mt19937 → (균등 비트) → 분포 → 결과입니다. 이 세 객체의 역할을 헷갈려서 random_device로 난수를 직접 여러 번 뽑는 실수를 하는 경우가 있는데, random_device는 엔진보다 호출 비용이 크고 플랫폼에 따라 OS나 하드웨어 엔트로피 소스를 거치기 때문에 시드 생성 이외의 용도로 반복 호출하는 것은 적합하지 않습니다.
#include <iostream>
#include <random>
int main() {
// 시드 전용 (반복 호출용 아님)
std::random_device rd;
// 난수 엔진 - 실제 비트 시퀀스는 여기서 생성
std::mt19937 gen(rd());
// 분포 - 비트 시퀀스를 원하는 확률 모델로 변환
std::uniform_int_distribution<> dis(1, 100);
for (int i = 0; i < 10; i++) {
std::cout << dis(gen) << " ";
}
}
난수 엔진
세 엔진은 각각 다른 트레이드오프를 갖고 있어서 무조건 하나만 쓰면 안 됩니다. mt19937(Mersenne Twister)은 주기가 2^19937-1로 사실상 반복되지 않는 수준이고 통계적 균일성도 뛰어나 대부분의 애플리케이션에 적합하지만, 내부 상태가 624개의 32비트 정수(약 2.5KB)를 차지해 메모리에 민감한 임베디드 환경에서는 부담이 될 수 있습니다. minstd_rand는 선형 합동 생성기라 상태가 정수 하나뿐이라 가볍지만, 앞서 설명한 하위 비트 패턴 문제 때문에 통계적 품질을 요구하는 시뮬레이션에는 적합하지 않습니다. ranlux24는 감산 방식(subtract-with-carry) 엔진의 출력 중 상당 부분을 버리는(discard_block_engine) 방식으로 상관관계를 없애 통계적 품질을 높였고, 그 대가로 버리는 값까지 계산해야 하므로 셋 중 가장 느립니다.
default_random_engine도 있지만 어떤 엔진인지가 구현 정의라서, libstdc++와 MSVC에서 서로 다른 엔진이 선택됩니다. 같은 시드로도 플랫폼마다 결과가 달라지고 품질도 보장되지 않으므로, 빠른 프로토타입이 아니라면 mt19937처럼 이름이 정해진 엔진을 명시하는 편이 낫습니다.
실무에서는 “특별한 이유가 없다면 mt19937을 쓰고, 상태 크기가 문제이면서 통계적 엄밀함이 필요 없는 경우에만 minstd_rand로 내려간다”는 원칙이 합리적입니다. 반대로 암호학적 용도(토큰 생성, 세션 키)에는 이 세 엔진 중 어느 것도 쓰면 안 됩니다. mt19937은 연속된 출력 624개만 관찰하면 내부 상태 전체를 복원해 이후 출력을 모두 예측할 수 있고, 32비트 정수 하나로 시드하면 가능한 초기 상태가 2^32가지뿐이라 전수 탐색도 현실적입니다. 보안이 필요한 코드는 OS가 제공하는 암호학적 난수 API(리눅스의 getrandom(), 윈도우의 BCryptGenRandom 등)를 직접 써야 합니다.
// Mersenne Twister (권장, 통계적 품질 높음, 상태 약 2.5KB)
std::mt19937 gen32; // 32비트 출력
std::mt19937_64 gen64; // 64비트 출력
// 선형 합동 생성기 (상태 작음, 품질 낮음)
std::minstd_rand lcg;
// subtract-with-carry + discard_block (품질 높지만 가장 느림)
std::ranlux24 lux;
// 구현 정의 엔진 (플랫폼마다 다름, 비권장)
std::default_random_engine def;
엔진에 관해 하나 더 알아 둘 사실은 엔진의 출력 시퀀스는 표준이 정확히 규정한다는 점입니다. 표준은 기본 시드(5489)로 생성한 mt19937의 10000번째 출력이 4123659995여야 한다고 명시하고 있고, 실제로 어느 컴파일러에서 돌려도 같은 값이 나옵니다. 반면 다음 절에서 볼 분포는 그렇지 않습니다.
분포
균등 분포
uniform_int_distribution이 rand() % N의 모듈로 편향을 어떻게 피하는지는 표준에 구체적인 알고리즘이 강제되어 있지는 않지만, 대부분의 구현은 거부 샘플링(rejection sampling)을 사용합니다. 엔진이 뽑아낸 값의 범위를 요청받은 구간의 배수가 되는 만큼만 사용하고, 그 경계를 벗어나는 값은 버리고 다시 뽑는 방식입니다. 예를 들어 1~6 범위가 필요한데 엔진 출력 범위가 6으로 나누어떨어지지 않는다면, 나누어떨어지지 않는 나머지 구간에 해당하는 값이 나올 경우 그 값을 버리고 엔진을 한 번 더 호출합니다. 이렇게 하면 확률적으로 아주 드물게 재시도가 발생할 수 있지만, 결과적으로 모든 값이 정확히 동일한 확률로 나오는 것이 보장됩니다. rand() % N처럼 “빠르지만 편향된” 방식과 “느릴 수 있지만 정확한” 방식 사이의 트레이드오프를 표준 라이브러리가 대신 해결해 주는 셈입니다.
정수 범위는 uniform_int_distribution, 실수 범위는 uniform_real_distribution으로 구분해서 씁니다. 또 하나 헷갈리기 쉬운 부분이 구간의 끝입니다. uniform_int_distribution<>(1, 6)은 양 끝을 포함하는 닫힌 구간 [1, 6]이지만, uniform_real_distribution<>(0.0, 1.0)은 끝을 포함하지 않는 반열린 구간 [0.0, 1.0)입니다.
// 정수 - 닫힌 구간 [1, 6], 편향 없이 균등하게 매핑
std::uniform_int_distribution<> intDis(1, 6); // 주사위
int dice = intDis(gen);
// 실수 - 반열린 구간 [0.0, 1.0)
std::uniform_real_distribution<> realDis(0.0, 1.0);
double x = realDis(gen);
분포 객체는 엔진에 비하면 가볍지만, 같은 파라미터로 반복해서 뽑는다면 루프 밖에서 한 번 만들어 재사용하는 편이 깔끔합니다. 실제 무작위성은 엔진에서 나오므로 분포를 재사용해도 결과의 무작위성에는 영향이 없습니다. 반대로 호출할 때마다 범위가 달라진다면 분포를 매번 새로 만들 필요 없이 param_type을 넘기는 오버로드를 쓸 수 있습니다.
std::mt19937 gen{std::random_device{}()};
// ✅ 같은 범위라면 분포를 루프 밖에서 한 번만 생성
std::uniform_int_distribution<> dist{0, 99};
for (int i = 0; i < 100; ++i) {
int r = dist(gen);
}
// ✅ 범위가 호출마다 다르면 param_type으로 전달
using P = std::uniform_int_distribution<>::param_type;
int d6 = dist(gen, P{1, 6});
int d100 = dist(gen, P{1, 100});
정규 분포
정규 분포는 “평균 근처에 몰리고 양 극단으로 갈수록 드물어지는” 현실 세계의 많은 현상(키, 시험 점수, 측정 오차)을 모델링할 때 씁니다. 균등 분포와 달리 이론적으로 값의 범위에 상한·하한이 없다는 점에 주의해야 합니다. 예를 들어 아래 코드의 normal_distribution<>(100.0, 15.0)은 평균 100, 표준편차 15인 분포지만, 아주 낮은 확률로는 음수나 200을 넘는 값도 나올 수 있습니다. IQ나 게임 스탯처럼 값의 범위를 보장해야 하는 경우에는 normal_distribution으로 뽑은 값을 std::clamp로 잘라내는 후처리가 필요합니다.
std::normal_distribution<> normalDis(100.0, 15.0); // 평균 100, 표준편차 15
double iq = std::clamp(normalDis(gen), 40.0, 160.0);
기타 분포
각 분포는 특정 확률 모델을 이미 코드로 검증된 형태로 제공하기 때문에, 직접 수식을 구현하는 것보다 훨씬 안전합니다. bernoulli_distribution은 동전 던지기나 크리티컬 히트 판정처럼 참/거짓 하나만 필요할 때, binomial_distribution은 “성공 확률 p인 시행을 n번 반복했을 때 성공 횟수”를 모델링할 때(예: 불량률 시뮬레이션) 씁니다. poisson_distribution은 “단위 시간당 평균 발생 횟수”가 알려진 사건(콜센터에 걸려오는 전화 수, 서버 요청 수)을 시뮬레이션할 때 적합하고, exponential_distribution은 그 사건과 사건 “사이의 대기 시간”을 모델링할 때 짝을 이루어 씁니다. 포아송 분포와 지수 분포를 같이 알아두면 큐잉 시스템이나 트래픽 시뮬레이션 코드를 작성할 때 유용합니다. 분포별 파라미터와 선택 기준은 C++ Distribution 가이드에서 더 자세히 다룹니다.
// 베르누이 (참/거짓)
std::bernoulli_distribution coinFlip(0.5); // 50%
bool result = coinFlip(gen);
// 이항 분포 - 성공 확률 p인 시행을 n번 반복했을 때 성공 횟수
std::binomial_distribution<> binDis(10, 0.5);
int heads = binDis(gen);
// 포아송 분포 - 단위 시간당 평균 발생 횟수가 알려진 사건 모델링
std::poisson_distribution<> poisDis(4.0);
int events = poisDis(gen);
// 지수 분포 - 사건과 사건 사이의 대기 시간 모델링 (평균 1/λ)
std::exponential_distribution<> expDis(1.0);
double wait = expDis(gen);
실전 예시
아래 예시들은 이 글의 코드 전체가 #include <random>, <iostream> 등 필요한 헤더를 포함한다고 가정합니다.
예시 1: 주사위 시뮬레이션
분포가 정말 균등한지 눈으로 확인하는 가장 간단한 방법은 여러 번 뽑아서 히스토그램을 그려 보는 것입니다. 10000번 굴리면 각 눈이 대략 1667번 근처로 나와야 합니다.
#include <iostream>
#include <map>
#include <random>
#include <string>
int main() {
std::random_device rd;
std::mt19937 gen(rd());
std::uniform_int_distribution<> dice(1, 6);
std::map<int, int> histogram;
for (int i = 0; i < 10000; i++) {
histogram[dice(gen)]++;
}
for (const auto& [value, count] : histogram) {
std::cout << value << ": " << std::string(count / 100, '*') << '\n';
}
}
예시 2: 랜덤 문자열
static 지역 변수로 엔진과 분포를 한 번만 만들어 두는 패턴입니다. 단, 이렇게 만든 문자열은 테스트 데이터나 임시 파일 이름 정도에만 써야 합니다. 앞서 설명했듯 mt19937의 출력은 예측 가능하므로, 비밀번호 재설정 토큰이나 세션 ID를 이 함수로 만들면 안 됩니다.
#include <random>
#include <string>
std::string generateRandomString(std::size_t length) {
static std::mt19937 gen{std::random_device{}()};
static const std::string chars =
"0123456789"
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
"abcdefghijklmnopqrstuvwxyz";
static std::uniform_int_distribution<std::size_t> dis(0, chars.size() - 1);
std::string result;
result.reserve(length);
for (std::size_t i = 0; i < length; i++) {
result += chars[dis(gen)];
}
return result;
}
예시 3: 셔플과 샘플링
std::shuffle은 엔진을 인자로 직접 받기 때문에 mt19937 같은 고품질 엔진으로 균등한 셔플을 보장합니다. 예전 코드에서 보이는 std::random_shuffle은 내부 난수 소스가 구현 정의(흔히 rand())라 품질을 통제할 수 없었고, C++14에서 폐기 예정, C++17에서 삭제되었습니다. 레거시 코드를 최신 표준으로 빌드하다가 random_shuffle이 없다는 에러를 만나면 std::shuffle에 엔진을 넘기는 형태로 바꾸면 됩니다.
전체에서 N개만 무작위로 뽑고 싶을 때 복사본을 통째로 섞고 앞의 N개를 자르는 코드를 자주 보는데, C++17부터는 std::sample이 있습니다. 원본을 수정하지 않고, 입력이 크고 N이 작을 때 전체를 섞는 것보다 효율적입니다.
#include <algorithm>
#include <iterator>
#include <random>
#include <vector>
int main() {
std::mt19937 gen{std::random_device{}()};
std::vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
// 전체 셔플
std::shuffle(v.begin(), v.end(), gen);
// 원본은 그대로 두고 3개 무작위 추출 (C++17)
std::vector<int> picked;
std::sample(v.begin(), v.end(), std::back_inserter(picked), 3, gen);
}
예시 4: 가중치 랜덤
std::discrete_distribution은 각 항목에 서로 다른 가중치를 부여해 선택할 때 씁니다. 게임의 드롭 테이블처럼 희귀 아이템일수록 확률을 낮춰야 하는 경우가 대표적입니다. 가중치는 합이 1일 필요가 없고 내부에서 정규화되며, 반환값은 가중치 배열의 인덱스입니다.
#include <iostream>
#include <map>
#include <random>
int main() {
std::mt19937 gen{std::random_device{}()};
// 가중치: 10%, 30%, 60%
std::discrete_distribution<> dis({10, 30, 60});
std::map<int, int> histogram;
for (int i = 0; i < 10000; i++) {
histogram[dis(gen)]++;
}
for (const auto& [choice, count] : histogram) {
std::cout << "선택 " << choice << ": " << count << "회\n";
}
}
예시 5: 엔진을 클래스로 캡슐화하기
난수가 여러 곳에서 필요할 때 매번 시드-엔진-분포 세 줄을 반복하기보다, 엔진 하나를 멤버로 가진 클래스로 감싸는 방식이 흔히 쓰입니다. 핵심은 엔진(무작위성의 원천)은 한 번만 시드하고 계속 이어서 쓰되, 시드를 생성자 인자로 받을 수 있게 열어 두는 것입니다. 기본값으로는 매 실행마다 다른 결과가 나오고, 테스트나 버그 재현 때는 고정 시드를 넘겨 같은 시퀀스를 다시 만들 수 있습니다.
#include <algorithm>
#include <random>
class GameRandom {
std::mt19937 gen_;
public:
explicit GameRandom(unsigned seed = std::random_device{}()) : gen_{seed} {}
int rollDice(int sides = 6) {
std::uniform_int_distribution<> dist{1, sides};
return dist(gen_);
}
bool chance(double p) { // p 확률로 true
std::bernoulli_distribution dist{p};
return dist(gen_);
}
template <typename It>
void shuffle(It first, It last) { std::shuffle(first, last, gen_); }
};
// 사용
GameRandom rng; // 실행마다 다른 결과
int damage = rng.rollDice(20); // 1d20
if (rng.chance(0.2)) damage *= 2;
GameRandom replay{12345}; // 재현용: 항상 같은 시퀀스
이 클래스 인스턴스 하나를 여러 스레드가 공유하면 gen_에 대한 데이터 레이스가 되므로, 스레드마다 인스턴스를 따로 두어야 합니다.
시드 설정
시드를 어떻게 고를지는 “재현 가능해야 하는가, 진짜 무작위여야 하는가”라는 질문 하나로 갈립니다. 테스트 코드나 시뮬레이션 재현이 필요한 경우(같은 입력으로 항상 같은 결과가 나와야 디버깅이 가능한 상황)에는 std::mt19937 gen3(12345)처럼 고정 시드를 씁니다. 반대로 게임의 전투 결과나 추첨처럼 매 실행마다 달라야 하는 경우에는 random_device로 시드를 뽑는 것이 맞습니다. time(0)으로 시드를 주는 방식은 초 단위 해상도라는 근본적인 한계가 있어서, 아래에서 설명할 실전 버그로 이어지기 쉽습니다.
seed_seq는 잘 알려지지 않았지만 중요한 도구입니다. mt19937의 내부 상태는 624개의 32비트 워드(19968비트)인데, 생성자에 32비트 정수 하나만 넘기면 나머지 워드는 그 값 하나로부터 결정론적으로 채워집니다. 그 결과 가능한 초기 상태는 2^32가지뿐이고, 엔진이 가진 상태 공간의 극히 일부만 쓰게 됩니다. 시뮬레이션을 수만 번 돌리면 서로 다른 실행이 같은 시드를 받을 확률(생일 문제)도 생각보다 빨리 커집니다. random_device로 여러 개의 값을 뽑아 seed_seq에 넣으면 이 값들을 섞어 상태 전체를 초기화하므로, 엄밀한 통계적 독립성이 필요한 시뮬레이션이라면 정수 하나짜리 시드보다 이 방식을 권장합니다.
이번 작업을 하면서 실제로 겪었던 문제 하나를 공유하자면, 멀티스레드로 시뮬레이션을 병렬화한 코드에서 각 워커 스레드가 mt19937 gen(time(nullptr))으로 자기 엔진을 초기화하도록 짰던 적이 있습니다. 스레드 풀에서 여러 워커가 거의 동시에(같은 초 안에) 시작되면 time(nullptr)이 반환하는 값이 완전히 동일해서, 서로 다른 스레드인데도 정확히 같은 시드로 초기화되어 똑같은 난수 시퀀스를 뽑아내는 버그였습니다. 겉보기엔 각 스레드가 독립적으로 무작위 값을 뽑는 것처럼 보였지만, 실제로는 몬테카를로 시뮬레이션 결과가 스레드 수와 무관하게 특정 패턴으로 수렴하는 이상 현상이 발견되어서야 원인을 찾았습니다. 해결은 각 스레드 ID나 random_device를 시드에 섞어 넣는 것으로 간단했지만, 디버깅 과정에서 “난수가 안 무작위하다”는 증상 자체를 의심하기까지 시간이 꽤 걸렸습니다. 스레드마다 반드시 서로 다른 엔트로피 소스로 시드를 주거나, thread_local 엔진에 스레드 인덱스를 섞은 시드를 쓰는 습관을 들인 계기가 된 사건입니다.
// 시간 기반 (재현 불가, 같은 초 안에서는 시드 충돌 위험)
std::mt19937 gen1(static_cast<unsigned>(std::time(nullptr)));
// random_device (매 실행마다 다름)
std::random_device rd;
std::mt19937 gen2(rd());
// 고정 시드 (재현 가능, 테스트/시뮬레이션 재현용)
std::mt19937 gen3(12345);
// random_device 값 여러 개를 seed_seq로 섞어 상태 전체를 초기화
std::array<std::uint32_t, 8> seeds;
std::generate(seeds.begin(), seeds.end(), std::ref(rd));
std::seed_seq seq(seeds.begin(), seeds.end());
std::mt19937 gen4(seq);
자주 발생하는 문제
문제 1: 매번 엔진 생성
random_device는 플랫폼에 따라 OS나 하드웨어 엔트로피 소스(CPU의 RDRAND 명령어, /dev/urandom 등)에 접근하는데, 이 접근 자체가 시스템 콜을 수반할 수 있어 엔진 호출보다 훨씬 비쌉니다. 게다가 mt19937은 생성할 때마다 약 2.5KB의 상태를 초기화해야 합니다. 함수가 호출될 때마다 random_device와 mt19937을 새로 만드는 코드는 겉보기엔 자연스러워 보여도, 반복 호출되는 루프 안에서는 성능 병목의 원인이 됩니다. static으로 선언하면 엔진이 프로그램 생애 동안 단 한 번만 초기화되고 이후 호출에서는 상태만 이어받아 다음 값을 뽑습니다.
다만 static 지역 변수의 초기화는 스레드 안전하지만(C++11부터 보장) 이후의 호출 자체는 스레드 안전하지 않다는 점은 주의해야 합니다. 여러 스레드가 같은 static mt19937 gen을 동시에 호출하면 내부 상태 갱신 과정에서 데이터 레이스가 발생하므로, 멀티스레드 환경이라면 thread_local로 바꾸는 것이 가장 간단합니다. thread_local이면 스레드마다 자기 엔진을 갖고, 각 엔진이 처음 쓰일 때 random_device로 따로 시드되므로 앞의 “같은 초에 같은 시드” 문제도 함께 피할 수 있습니다. 자세한 동작은 C++ thread_local을 참고하세요.
// ❌ 비효율 (매 호출마다 random_device 호출 + 엔진 상태 초기화)
int getRandom() {
std::random_device rd;
std::mt19937 gen(rd());
std::uniform_int_distribution<> dis(1, 100);
return dis(gen);
}
// ✅ static (단일 스레드 한정)
int getRandom() {
static std::mt19937 gen{std::random_device{}()};
static std::uniform_int_distribution<> dis(1, 100);
return dis(gen);
}
// ✅ thread_local (멀티스레드에서 스레드마다 독립된 엔진)
int randomInt(int lo, int hi) {
thread_local std::mt19937 gen{std::random_device{}()};
std::uniform_int_distribution<> dist{lo, hi};
return dist(gen);
}
문제 2: 시드 재사용
이 실수는 겉보기엔 사소해 보이지만 실전에서 의외로 자주 발생합니다. 엔진을 루프 안에서 매번 새로 만들면서 같은 시드를 넘기면, 매번 “처음부터 다시 시작”하는 셈이라 항상 첫 번째로 생성되는 값만 반복해서 뽑히게 됩니다. 이 패턴은 단위 테스트를 작성하다가 “왜 10번을 뽑았는데 다 같은 값이지?”라는 의문에서 발견되는 경우가 많은데, 원인은 난수 생성기 자체의 문제가 아니라 엔진 인스턴스를 매번 새로 만들었다는 호출부의 실수입니다. 엔진 하나를 만들어서 계속 재사용해야 내부 상태가 호출마다 전진하면서 서로 다른 값을 내놓습니다.
과거에 CI에서만 간헐적으로 실패하는 flaky 테스트를 디버깅한 적이 있는데, 원인이 정확히 이 패턴과 시드 문제가 섞여 있었습니다. 테스트 코드가 매 테스트 케이스마다 mt19937 gen(time(nullptr))으로 새 엔진을 만들어 랜덤 입력을 생성했는데, 로컬에서는 테스트가 순차적으로 느리게 실행되어 문제가 드러나지 않았지만, CI 러너에서 병렬로 여러 테스트가 같은 초에 동시에 실행되면서 서로 다른 테스트 케이스가 같은 시드로 초기화되어 예상치 못한 입력 조합이 발생했습니다. 결국 특정 입력 조합에서만 실패하는 로직 버그가 “가끔씩만” 재현되는 것처럼 보였던 것입니다. 테스트 코드에서는 반드시 고정 시드를 명시적으로 박아 두고, 실패 시 그 시드 값을 로그에 남겨서 재현 가능하게 만드는 습관이 이런 문제를 막아줍니다.
// ❌ 같은 시드로 매번 새 엔진 생성 → 항상 같은 값
for (int i = 0; i < 10; i++) {
std::mt19937 gen(12345);
std::cout << gen() << '\n';
}
// ✅ 엔진 재사용 → 호출마다 상태가 전진해 다른 값 생성
std::mt19937 gen(12345);
for (int i = 0; i < 10; i++) {
std::cout << gen() << '\n';
}
문제 3: 범위 편향
앞서 수학적으로 설명한 모듈로 편향이 실제 코드에서 가장 자주 발생하는 지점입니다. rand() % 100처럼 짧고 익숙해 보이는 코드가 리뷰에서 잘 걸러지지 않는 이유는, 편향의 크기가 작을 때는 눈으로 봐서 티가 나지 않기 때문입니다. 하지만 N이 RAND_MAX+1에 비해 큰 값일수록(예: RAND_MAX가 32767인데 N이 10000처럼 큰 경우) 편향의 정도도 커집니다. 32768을 10000으로 나누면 나머지가 2768이므로, 02767은 4번씩, 27689999는 3번씩 나올 기회를 얻어 앞쪽 값이 약 33% 더 자주 나옵니다. uniform_int_distribution으로 바꾸는 것은 단순한 스타일 개선이 아니라, 통계적으로 유의미한 결과를 요구하는 코드에서는 필수적인 수정입니다.
// ❌ 편향됨 (RAND_MAX+1이 100의 배수가 아니면 균등하지 않음)
int x = std::rand() % 100;
// ✅ 균등 분포 (거부 샘플링 등으로 편향 없이 매핑)
std::uniform_int_distribution<> dis(0, 99);
int y = dis(gen);
문제 4: random_device가 항상 비결정적이지는 않다
random_device가 이름과 달리 항상 비결정적이지는 않다는 점도 짚어야 합니다. C++ 표준은 비결정적 소스를 쓸 수 없는 구현이라면 random_device를 의사난수 엔진으로 구현해도 된다고 허용합니다. 실제로 GCC 9.2 이전의 MinGW 계열 libstdc++에서는 random_device가 고정된 시퀀스를 내놓아서, 프로그램을 재실행해도 항상 같은 난수가 나오는 문제가 유명했습니다. 이런 환경에서 random_device만 믿고 시드를 주면 원인을 찾기 어려운 “매번 같은 결과” 버그가 생깁니다.
이 동작을 rd.entropy()로 판별하려는 코드도 종종 보이는데, 믿을 만한 방법이 아닙니다. 이 글을 쓰면서 TDM-GCC 10.3(Windows)에서 직접 확인해 보니, random_device는 실행할 때마다 다른 값을 정상적으로 반환하는데도 entropy()는 0을 반환했습니다. 반대로 결정적인 구현이 0이 아닌 값을 반환하는 경우도 있을 수 있습니다. 크로스 플랫폼 코드라면 대상 컴파일러에서 프로그램을 두 번 실행해 rd() 값이 실제로 달라지는지 확인하는 것이 가장 확실합니다. random_device의 구현별 차이는 C++ random_device에서 따로 정리했습니다.
그리고 애초에 암호학적으로 안전한 난수가 필요한 경우(세션 토큰, 임시 비밀번호, 암호화 키)라면 <random> 엔진을 쓰지 말고 운영체제가 제공하는 전용 API를 사용해야 합니다. <random>의 엔진들은 통계적 품질은 뛰어나지만 내부 상태를 알면 이후 출력을 전부 예측할 수 있어, 암호학적 안전성은 애초에 설계 목표가 아니기 때문입니다.
문제 5: 같은 시드인데 플랫폼마다 결과가 다르다
고정 시드로 재현성을 확보했다고 생각했는데, Linux(GCC)에서 만든 테스트 기대값이 Windows(MSVC)나 macOS(Clang/libc++)에서 깨지는 경우가 있습니다. 앞서 말했듯 mt19937의 출력은 표준이 비트 단위로 규정하지만, uniform_int_distribution, normal_distribution 같은 분포가 엔진 출력을 변환하는 알고리즘은 구현마다 다릅니다. 같은 mt19937(12345)에 같은 uniform_int_distribution<>(1, 100)을 붙여도 표준 라이브러리가 다르면 다른 숫자가 나올 수 있다는 뜻입니다.
따라서 “같은 시드면 어디서나 같은 결과”가 필요한 경우(게임의 리플레이 파일, 네트워크 동기화, 플랫폼 간 골든 테스트)에는 엔진만 표준 라이브러리에 맡기고, 범위 변환은 직접 작성한 함수로 고정하거나 알고리즘이 명시된 서드파티 라이브러리를 쓰는 편이 안전합니다. 같은 플랫폼, 같은 표준 라이브러리 안에서의 재현만 필요하다면 이 문제는 신경 쓰지 않아도 됩니다.
문제 6: 분포 객체를 루프마다 새로 만들기
분포 객체를 루프 안에서 매번 만드는 코드는 결과가 틀리지는 않지만 불필요한 생성 비용이 붙습니다. 같은 범위·같은 파라미터로 여러 번 뽑는다면 분포는 루프 밖에서 한 번만 만들고 재사용하면 됩니다. 무작위성의 원천은 엔진이므로 분포를 재사용해도 결과의 품질은 달라지지 않습니다.
std::mt19937 gen{std::random_device{}()};
// ❌ 매 반복마다 분포 생성
for (int i = 0; i < 100; ++i) {
std::uniform_int_distribution<> dist{0, 99};
int r = dist(gen);
}
// ✅ 한 번 만들고 재사용
std::uniform_int_distribution<> dist{0, 99};
for (int i = 0; i < 100; ++i) {
int r = dist(gen);
}
한 가지 주의할 점은 분포가 상태를 가질 수 있다는 것입니다. 대표적으로 normal_distribution은 libstdc++ 등에서 값 두 개를 한 번에 만들고 하나를 다음 호출용으로 저장해 둡니다. 그래서 엔진을 같은 시드로 다시 seed()해도 분포 객체를 그대로 재사용하면 첫 값이 이전 실행의 남은 값으로 나올 수 있습니다. 시퀀스를 처음부터 재현하려면 엔진을 다시 시드할 때 dist.reset()도 함께 호출하거나 분포를 새로 만드세요.
성능 비교
“현대적이고 품질 좋은 방식이 옛날 방식보다 느리지 않을까”라는 의문은 당연히 나올 수 있습니다. 아래 코드로 엔진별로 1억 번씩 값을 생성하는 시간을 이 글을 쓴 Windows 머신에서 직접 측정했습니다. 컴파일러는 TDM-GCC 10.3, 옵션은 -std=c++20 -O2입니다. 결과를 sink에 누적해 출력하는 이유는, 결과를 쓰지 않는 루프를 컴파일러가 통째로 제거해 “0ms”가 나오는 흔한 벤치마크 실수를 피하기 위해서입니다.
#include <chrono>
#include <cstdlib>
#include <ctime>
#include <iostream>
#include <random>
int main() {
const int N = 100'000'000;
unsigned sink = 0;
auto ms = [](auto a, auto b) {
return std::chrono::duration_cast<std::chrono::milliseconds>(b - a).count();
};
std::srand(static_cast<unsigned>(std::time(nullptr)));
auto t0 = std::chrono::steady_clock::now();
for (int i = 0; i < N; i++) sink += std::rand();
auto t1 = std::chrono::steady_clock::now();
std::mt19937 gen{std::random_device{}()};
for (int i = 0; i < N; i++) sink += gen();
auto t2 = std::chrono::steady_clock::now();
std::cout << "rand(): " << ms(t0, t1) << "ms\n"
<< "mt19937: " << ms(t1, t2) << "ms\n"
<< "(sink " << sink << ")\n";
}
같은 방식으로 다른 엔진까지 넣어 두 번 실행한 결과입니다(1억 회 기준, 반올림).
| 생성기 | 1억 회 소요 시간 | 비고 |
|---|---|---|
std::rand() | 약 0.9~1.0초 | MSVCRT 구현, 출력은 15비트뿐 |
std::mt19937 | 약 0.23초 | 32비트 출력 |
std::minstd_rand | 약 0.30초 | 상태는 가장 작음 |
std::mt19937_64 | 약 0.39초 | 64비트 출력 |
std::ranlux24 | 약 3.0초 | 버리는 값까지 계산하므로 가장 느림 |
std::random_device | 100만 회에 약 29ms (호출당 약 29ns) | 엔진보다 약 10배 이상 느림 |
이 환경에서는 mt19937이 rand()보다 네 배 가량 빨랐습니다. rand()는 라이브러리 함수 호출과 스레드별 상태 조회를 거치는 반면, mt19937은 지역 객체의 상태만 갱신하고 인라인되기 때문입니다. 숫자 자체는 CPU, 컴파일러, 표준 라이브러리에 따라 달라지므로 절대값보다는 상대적인 크기만 참고하세요. 실제 애플리케이션에서 체감되는 비용 차이는 엔진 종류보다 random_device를 얼마나 자주 호출하느냐, 엔진을 매번 새로 만드느냐에 더 크게 좌우됩니다.
FAQ
Q1: rand() vs random?
A:
- rand(): 모듈로 편향이 있고,
RAND_MAX가 32767인 플랫폼에서는 범위도 좁으며, 엔진 품질이 낮고 스레드 안전성이 표준으로 보장되지 않습니다. - random: 엔진과 분포가 분리되어 있어 편향 없이 균등한 값을 얻을 수 있고, 정규·포아송 등 다양한 확률 모델을 표준으로 제공합니다.
Q2: random_device는 항상 사용해야 하나요?
A: 시드로만 사용하세요. random_device는 엔진보다 호출 비용이 크고 플랫폼에 따라 완전한 비결정성을 보장하지 않으므로, 난수 값 자체를 반복해서 뽑는 용도로는 적합하지 않습니다. 실제 난수 생성은 mt19937 같은 엔진에 맡기세요.
Q3: 어떤 엔진을 사용하나요?
A: 특별한 이유가 없다면 mt19937(64비트 값이 필요하면 mt19937_64)이 대부분의 경우에 적합합니다. 메모리가 극도로 제한된 임베디드 환경이라면 minstd_rand를, 속도보다 통계적 엄밀함이 중요하다면 ranlux24/ranlux48을 고려하세요. default_random_engine은 플랫폼마다 달라서 권장하지 않습니다.
Q4: 재현 가능한 난수는?
A: 고정 시드를 사용하세요. 테스트 코드라면 실패 시 사용된 시드 값을 로그로 남겨두는 것이 이후 동일한 실패를 재현하는 데 큰 도움이 됩니다. 다른 표준 라이브러리(예: GCC와 MSVC) 사이에서도 같은 결과가 필요하다면 분포 변환을 직접 구현해야 한다는 점도 기억하세요.
Q5: 스레드 안전한가요?
A: 엔진과 분포 객체 자체는 스레드 안전하지 않습니다. 스레드마다 별도의 엔진을 두거나(thread_local) 뮤텍스로 보호해야 하며, 시드를 time(nullptr)처럼 초 단위로 주면 같은 초에 시작된 스레드끼리 같은 시드로 초기화될 수 있으니 random_device나 스레드 ID 등을 섞어 시드를 구별해야 합니다.
같이 보면 좋은 글
- C++ random 확률 분포: uniform·normal·bernoulli·discrete와 엔진 분리 사용법
- C++ std::random_device: 운영체제 엔트로피, mt19937 시드와 seed_seq, 플랫폼 차이
- C++ thread_local
- C++ async & launch
- C++ atomic: 락 없는 카운터, 메모리 순서, 언제 mutex 대신 쓰나