C++ Random | '난수 생성' 가이드 | 핵심 개념과 실전 활용
이 글의 핵심
C++ Random의 C++, Random, "난수, Random이란?를 실전 예제와 함께 상세히 설명합니다.
Random이란?
C++11 <random> 라이브러리는 고품질 난수 생성을 위한 표준 라이브러리입니다. C 스타일 rand()보다 더 강력하고 유연합니다.
기존 C 스타일 rand()는 내부적으로 단순한 선형 합동 생성기(LCG)를 사용하는 경우가 많아 주기가 짧고 하위 비트에서 패턴이 쉽게 드러나는 문제가 있습니다. 특히 rand() % N 방식으로 범위를 좁히면 N이 RAND_MAX + 1의 약수가 아닌 이상 특정 구간의 값이 다른 구간보다 조금 더 자주 뽑히는 모듈로 편향(modulo bias) 이 발생합니다. <random>은 이 문제를 근본적으로 해결하기 위해 난수를 만드는 엔진(engine)과 그 값을 원하는 확률 분포로 변환하는 분포(distribution)를 명확히 분리한 설계를 택했습니다. 엔진은 균등하게 분포된 비트 스트림만 생성하고, 분포 객체가 그 스트림을 균등·정규·이항 등 원하는 통계적 모양으로 바꿔줍니다. 아래는 주사위를 굴리는 가장 기본적인 예제입니다.
#include <random>
std::random_device rd;
std::mt19937 gen{rd()};
std::uniform_int_distribution<> dist{1, 6};
int dice = dist(gen); // 1~6
이 코드에서 random_device는 가능하다면 운영체제의 하드웨어 엔트로피 소스로부터 한 번 쓸 시드 값을 얻고, mt19937은 그 시드로 내부 상태를 초기화한 뒤 긴 주기의 의사난수 비트 스트림을 생성하는 메르센 트위스터 엔진입니다. 마지막으로 uniform_int_distribution<>{1, 6}이 그 비트 스트림을 1부터 6까지의 균등한 정수로 매핑합니다. 역할이 이렇게 분리되어 있기 때문에 엔진은 그대로 두고 분포 객체만 바꾸면 같은 난수 소스에서 완전히 다른 통계적 성질을 가진 값을 뽑아낼 수 있습니다.
왜 필요한가?:
- 품질: 균등 분포 보장
- 유연성: 다양한 분포 지원
- 재현성: 시드로 결과 재현
- 타입 안전: 템플릿 기반
rand()와 C++11 <random>의 차이를 직접 비교해보면 왜 표준이 이렇게 바뀌었는지 더 분명해집니다. 아래 코드는 동일하게 0~99 사이 정수 하나를 뽑는 두 가지 방식을 나란히 보여줍니다.
// ❌ rand(): 비균등, 예측 가능
int r = rand() % 100; // 0~99 (비균등)
// ✅ C++11 random: 균등, 고품질
std::mt19937 gen{std::random_device{}()};
std::uniform_int_distribution<> dist{0, 99};
int r = dist(gen); // 0~99 (균등)
rand() % 100은 RAND_MAX 값에 따라 0~99 각 숫자가 뽑힐 확률이 완전히 균등하지 않을 수 있고, 전역 상태를 사용하기 때문에 멀티스레드 환경에서 안전하지 않습니다. 반면 uniform_int_distribution은 표준이 요구하는 알고리즘(대개 거부 샘플링 기반)으로 편향 없는 결과를 보장하며, 엔진 인스턴스를 스레드마다 따로 두면 병렬로도 안전하게 쓸 수 있습니다. 실행 속도 차이는 크지 않지만, 정확성과 예측 불가능성 측면에서 C++11 random을 쓰는 것이 사실상 표준 관행으로 자리 잡았습니다.
Random 구조:
flowchart LR
A[random_device] -->|시드| B[엔진 mt19937]
B -->|난수| C[분포 uniform_int_distribution]
C -->|최종 난수| D[결과]
3단계 구조:
| 단계 | 역할 | 예시 |
|---|---|---|
| 1. 시드 | 초기값 생성 | std::random_device rd; |
| 2. 엔진 | 난수 생성 | std::mt19937 gen{rd()}; |
| 3. 분포 | 범위 조정 | std::uniform_int_distribution<> dist{0, 99}; |
전체 흐름을 다시 정리하면, random_device가 만든 시드 값이 mt19937 엔진의 내부 상태를 초기화하고, 엔진은 호출될 때마다 균등하게 분포된 정수를 생성하며, 마지막으로 분포 객체가 그 값을 원하는 범위와 확률 모양으로 변환합니다. 아래 코드는 이 세 단계를 주석과 함께 명시적으로 나눠서 보여줍니다. 실무에서는 이 세 줄을 매번 반복하기보다 클래스나 함수 안에 캡슐화해서 재사용하는 것이 일반적입니다.
// 1. 시드: 하드웨어 엔트로피
std::random_device rd;
// 2. 엔진: 메르센 트위스터
std::mt19937 gen{rd()};
// 3. 분포: 0~99 균등 분포
std::uniform_int_distribution<> dist{0, 99};
// 4. 생성
int random = dist(gen);
기본 구조
앞서 살펴본 시드-엔진-분포 3단계를 하나의 코드 블록으로 압축하면 다음과 같습니다. 실무에서는 이 네 줄을 난수가 필요한 곳마다 그대로 반복해서 쓰기보다, 클래스 멤버나 static/thread_local 변수로 엔진을 한 번만 만들어 재사용하는 패턴을 주로 사용합니다. 여기서는 우선 각 단계가 어떤 순서로 조합되는지 전체 흐름을 확인합니다.
#include <random>
// 1. 시드
std::random_device rd;
// 2. 엔진
std::mt19937 gen{rd()};
// 3. 분포
std::uniform_int_distribution<> dist{0, 99};
// 4. 생성
int random = dist(gen);
실전 예시
아래 네 가지 예시는 게임 개발, 유틸리티 함수, 카드 셔플, 확률 기반 선택처럼 실무에서 자주 마주치는 난수 사용 패턴을 보여줍니다. 각 예시는 앞서 설명한 시드-엔진-분포 구조를 실제 문제에 어떻게 적용하는지에 초점을 맞춥니다.
예시 1: 주사위
Dice 클래스는 엔진과 분포를 멤버 변수로 두어 매번 새로 만들지 않고 재사용합니다. 생성자에서 random_device로 한 번만 시드를 얻고, roll()을 호출할 때마다 이미 만들어진 엔진과 분포를 그대로 사용하기 때문에 반복 호출 시 오버헤드가 없습니다. 이런 패턴은 게임 로직처럼 같은 종류의 난수를 반복적으로 뽑아야 할 때 표준적으로 쓰입니다.
#include <random>
class Dice {
std::mt19937 gen;
std::uniform_int_distribution<> dist;
public:
Dice() : gen{std::random_device{}()}, dist{1, 6} {}
int roll() {
return dist(gen);
}
};
int main() {
Dice dice;
for (int i = 0; i < 10; ++i) {
std::cout << dice.roll() << " ";
}
}
예시 2: 범위 난수
randomInt와 randomDouble은 함수 내부에 static으로 엔진을 선언해 첫 호출 이후에는 시드 비용 없이 바로 난수를 반환하는 유틸리티 함수입니다. 정수 범위는 uniform_int_distribution, 실수 범위는 uniform_real_distribution을 사용한다는 점에 주의해야 합니다. 다만 static 지역 변수는 멀티스레드 환경에서 초기화 경쟁은 C++11부터 안전하지만, 이후의 dist(gen) 호출 자체는 스레드 안전하지 않으므로 여러 스레드에서 동시에 호출하면 안 됩니다.
#include <random>
int randomInt(int min, int max) {
static std::random_device rd;
static std::mt19937 gen{rd()};
std::uniform_int_distribution<> dist{min, max};
return dist(gen);
}
double randomDouble(double min, double max) {
static std::random_device rd;
static std::mt19937 gen{rd()};
std::uniform_real_distribution<> dist{min, max};
return dist(gen);
}
int main() {
std::cout << randomInt(1, 100) << std::endl;
std::cout << randomDouble(0.0, 1.0) << std::endl;
}
예시 3: 셔플
std::shuffle은 컨테이너의 원소 순서를 무작위로 뒤섞는 표준 알고리즘으로, 카드 게임이나 무작위 순서가 필요한 리스트 처리에 자주 사용됩니다. 과거 C++03의 std::random_shuffle은 내부적으로 품질이 낮은 난수 소스를 사용했지만, std::shuffle은 엔진을 인자로 직접 받기 때문에 mt19937 같은 고품질 엔진을 사용해 실제로 균등한 셔플 결과를 보장합니다.
#include <random>
#include <algorithm>
#include <vector>
int main() {
std::vector<int> cards = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
std::random_device rd;
std::mt19937 gen{rd()};
std::shuffle(cards.begin(), cards.end(), gen);
for (int card : cards) {
std::cout << card << " ";
}
}
예시 4: 가중치 선택
std::discrete_distribution은 각 항목에 서로 다른 확률(가중치)을 부여해 선택하고 싶을 때 사용합니다. 예를 들어 게임에서 희귀 아이템일수록 등장 확률을 낮추는 드롭 테이블이나, 시뮬레이션에서 사건 발생 확률이 균등하지 않은 상황을 모델링할 때 적합합니다. 생성자에 가중치 배열의 이터레이터 범위를 넘기면 내부적으로 누적 분포를 계산해 두고, 호출할 때마다 그 가중치에 비례한 인덱스를 반환합니다.
#include <random>
#include <vector>
int main() {
std::vector<int> items = {1, 2, 3, 4, 5};
std::vector<double> weights = {0.1, 0.2, 0.3, 0.2, 0.2};
std::random_device rd;
std::mt19937 gen{rd()};
std::discrete_distribution<> dist{weights.begin(), weights.end()};
// 가중치에 따라 선택
for (int i = 0; i < 10; ++i) {
int index = dist(gen);
std::cout << items[index] << " ";
}
}
난수 엔진
C++ 표준 라이브러리는 여러 종류의 엔진을 제공하며, 각각 생성 속도·주기 길이·내부 상태 크기·통계적 품질 사이에서 서로 다른 트레이드오프를 갖습니다. 실무에서는 대부분 mt19937(또는 64비트 결과가 필요하면 mt19937_64)를 기본 선택지로 삼는데, 주기가 2^19937-1로 매우 길고 통계적 품질도 대부분의 용도에 충분히 좋기 때문입니다. 다만 내부 상태가 624개의 32비트 정수(약 2.5KB)로 상당히 크기 때문에 메모리가 극도로 제한된 임베디드 환경에서는 부담이 될 수 있습니다. default_random_engine은 구현체마다 실제 엔진이 다를 수 있어 이식성이 필요 없는 프로토타입 코드 외에는 권장되지 않고, minstd_rand 같은 선형 합동 엔진은 상태가 작고 빠르지만 통계적 품질이 낮아 품질이 중요한 시뮬레이션에는 적합하지 않습니다.
// mt19937: 메르센 트위스터 (권장)
std::mt19937 gen;
// mt19937_64: 64비트
std::mt19937_64 gen64;
// default_random_engine
std::default_random_engine gen_default;
// minstd_rand: 선형 합동
std::minstd_rand gen_lcg;
자주 발생하는 문제
문제 1: 시드
시드를 어떻게 주느냐에 따라 프로그램의 동작이 완전히 달라집니다. 고정된 정수를 시드로 쓰면 실행할 때마다 정확히 같은 난수 시퀀스가 나오므로 디버깅이나 테스트에는 유용하지만, 실제 서비스 로직에는 부적합합니다. random_device는 가능한 경우 OS의 하드웨어 엔트로피를 사용해 매번 다른 시드를 제공하므로 프로덕션 코드의 기본 선택지이며, 시간 기반 시드는 같은 초 안에 여러 인스턴스가 생성되면 동일한 시드를 공유할 위험이 있어 권장되지 않습니다.
// ❌ 고정 시드
std::mt19937 gen{42}; // 항상 같은 난수
// ✅ random_device
std::random_device rd;
std::mt19937 gen{rd()};
// ✅ 시간 기반 (비권장)
std::mt19937 gen{static_cast<unsigned>(std::time(nullptr))};
문제 2: 전역 상태
매번 함수를 호출할 때마다 random_device와 mt19937을 새로 만들면 두 가지 문제가 생깁니다. 첫째, random_device는 하드웨어 엔트로피 소스에 접근하는 비용이 커서 반복 호출 시 성능이 눈에 띄게 저하됩니다. 둘째, 짧은 시간 안에 여러 번 호출하면 시드 값이 우연히 겹칠 가능성도 배제할 수 없습니다. static으로 선언해 함수 최초 호출 시 한 번만 초기화하면 이후 호출은 이미 만들어진 엔진 상태를 이어서 사용하므로 성능과 통계적 품질이 모두 개선됩니다.
// ❌ 매번 생성
int random() {
std::random_device rd;
std::mt19937 gen{rd()}; // 느림
std::uniform_int_distribution<> dist{0, 99};
return dist(gen);
}
// ✅ static
int random() {
static std::random_device rd;
static std::mt19937 gen{rd()};
static std::uniform_int_distribution<> dist{0, 99};
return dist(gen);
}
문제 3: 분포 재사용
분포 객체는 엔진과 달리 그 자체로 상태를 크게 가지지 않기 때문에 매번 새로 만들어도 치명적인 성능 문제는 아니지만, 루프 안에서 반복 생성하는 것은 불필요한 오버헤드입니다. 같은 파라미터(같은 범위, 같은 확률)로 여러 번 뽑아야 한다면 분포 객체를 루프 밖에서 한 번만 만들고 재사용하는 편이 더 깔끔하고 빠릅니다. 중요한 점은 분포를 재사용해도 실제 난수를 생성하는 것은 엔진이므로, 분포를 공유하는 것이 결과의 무작위성 자체에 영향을 주지는 않는다는 것입니다.
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);
}
문제 4: rand() 사용
레거시 코드베이스에서는 여전히 rand()를 흔히 볼 수 있지만, 새로 작성하는 코드에서는 피하는 것이 좋습니다. rand()는 구현체마다 내부 알고리즘과 RAND_MAX 값이 달라 이식성이 떨어지고, 앞서 설명한 모듈로 편향 문제와 전역 상태로 인한 스레드 안전성 문제를 동시에 안고 있습니다. <random>으로 교체하면 이런 문제를 표준화된 방식으로 해결할 수 있습니다.
// ❌ rand() (C 스타일, 비권장)
int r = rand() % 100; // 균등하지 않음
// ✅ C++11 random
std::mt19937 gen{std::random_device{}()};
std::uniform_int_distribution<> dist{0, 99};
int r = dist(gen);
분포 종류
<random>은 정수, 실수, 불리언 값을 다양한 확률 모양으로 생성할 수 있도록 여러 분포 클래스를 제공합니다. uniform_int_distribution과 uniform_real_distribution은 각각 정수·실수 범위에서 균등하게 값을 뽑을 때 쓰고, normal_distribution은 평균과 표준편차를 지정해 정규분포(키, 측정 오차 등 자연 현상 모델링)를 만들 때 사용합니다. binomial_distribution과 poisson_distribution은 각각 이항 실험(성공/실패 반복)과 단위 시간당 사건 발생 횟수를 모델링할 때, exponential_distribution은 대기 시간이나 수명 분포를 모델링할 때 적합합니다. bernoulli_distribution은 단일 확률값 하나로 참/거짓을 결정하는 가장 단순한 분포로, 크리티컬 히트 판정처럼 이진 확률 이벤트에 자주 쓰입니다.
// 정수
std::uniform_int_distribution<> uniform{0, 99};
std::binomial_distribution<> binomial{10, 0.5};
std::poisson_distribution<> poisson{4.0};
// 실수
std::uniform_real_distribution<> uniformReal{0.0, 1.0};
std::normal_distribution<> normal{0.0, 1.0};
std::exponential_distribution<> exponential{1.0};
// 불리언
std::bernoulli_distribution bernoulli{0.7};
실무 패턴
아래 세 가지 패턴은 게임 로직, 데이터 샘플링, 통계 시뮬레이션이라는 서로 다른 도메인에서 <random>을 어떻게 캡슐화해서 재사용하는지 보여줍니다. 공통적으로 엔진은 한 번만 만들고, 분포는 필요한 만큼 나눠서 관리하는 구조를 따릅니다.
패턴 1: 게임 난수
GameRandom 클래스는 하나의 엔진을 멤버로 두고, 주사위 굴리기·확률 판정·크리티컬 히트처럼 서로 다른 목적의 메서드에서 이 엔진을 공유합니다. 각 메서드는 호출될 때마다 필요한 분포를 로컬로 만드는데, 분포 객체 생성 비용이 매우 작기 때문에 이 정도는 성능에 큰 영향을 주지 않습니다. 핵심은 엔진(무작위성의 원천)을 한 번만 시드하고 계속 이어서 사용한다는 점입니다.
class GameRandom {
std::mt19937 gen_;
public:
GameRandom() : gen_{std::random_device{}()} {}
// 주사위
int rollDice(int sides = 6) {
std::uniform_int_distribution<> dist{1, sides};
return dist(gen_);
}
// 확률 (0.0 ~ 1.0)
double chance() {
std::uniform_real_distribution<> dist{0.0, 1.0};
return dist(gen_);
}
// 크리티컬 히트 (20% 확률)
bool criticalHit() {
std::bernoulli_distribution dist{0.2};
return dist(gen_);
}
};
// 사용
GameRandom rng;
int damage = rng.rollDice(20); // 1d20
if (rng.criticalHit()) {
damage *= 2;
}
패턴 2: 샘플링
randomSample은 전체 데이터에서 무작위로 N개를 추출하는 함수 템플릿입니다. 구현 방식은 복사본을 만든 뒤 std::shuffle로 전체를 섞고 앞의 N개만 잘라내는 것인데, 이는 전체 데이터가 그렇게 크지 않을 때 간단하고 이해하기 쉬운 접근입니다. 데이터 크기가 매우 크고 N이 훨씬 작다면 전체를 섞는 대신 저수지 샘플링(reservoir sampling) 같은 알고리즘을 고려하는 것이 더 효율적입니다.
#include <random>
#include <vector>
#include <algorithm>
template<typename T>
std::vector<T> randomSample(const std::vector<T>& data, size_t n) {
if (n >= data.size()) {
return data;
}
std::vector<T> result = data;
std::random_device rd;
std::mt19937 gen{rd()};
std::shuffle(result.begin(), result.end(), gen);
result.resize(n);
return result;
}
// 사용
std::vector<int> data = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
auto sample = randomSample(data, 3); // 3개 무작위 선택
패턴 3: 시뮬레이션
Simulation 클래스는 통계 시뮬레이션에서 흔히 필요한 정규분포와 지수분포를 멤버로 미리 준비해 둡니다. 생성자에 시드를 인자로 받을 수 있게 해 두었기 때문에, 기본값으로는 매번 다른 결과가 나오지만 테스트할 때는 고정 시드를 넘겨 재현 가능한 시뮬레이션을 돌릴 수 있습니다. 이런 구조는 몬테카를로 시뮬레이션이나 이벤트 기반 시스템의 대기 시간 모델링처럼, 여러 번 반복 실행하면서 통계적 경향을 관찰해야 하는 코드에 특히 유용합니다.
#include <random>
class Simulation {
std::mt19937 gen_;
std::normal_distribution<> normal_;
std::exponential_distribution<> exponential_;
public:
Simulation(unsigned seed = std::random_device{}())
: gen_{seed},
normal_{0.0, 1.0}, // 평균 0, 표준편차 1
exponential_{1.0} {} // 람다 1
// 정규 분포 (키, 몸무게 등)
double normalValue() {
return normal_(gen_);
}
// 지수 분포 (대기 시간 등)
double exponentialValue() {
return exponential_(gen_);
}
};
// 사용
Simulation sim;
double height = 170.0 + sim.normalValue() * 10.0; // 평균 170cm
double waitTime = sim.exponentialValue(); // 평균 1초
FAQ
Q1: Random은 무엇인가요?
A: C++11 난수 생성 라이브러리입니다. 고품질 난수를 생성하며, 다양한 분포를 지원합니다.
#include <random>
std::mt19937 gen{std::random_device{}()};
std::uniform_int_distribution<> dist{0, 99};
int r = dist(gen);
Q2: 구조는?
A: 시드 + 엔진 + 분포 3단계 구조입니다.
std::random_device rd; // 1. 시드
std::mt19937 gen{rd()}; // 2. 엔진
std::uniform_int_distribution<> dist; // 3. 분포
int r = dist(gen); // 4. 생성
Q3: 어떤 엔진을 사용해야 하나요?
A: std::mt19937 (메르센 트위스터) 를 권장합니다.
std::mt19937 gen{std::random_device{}()}; // 32비트
std::mt19937_64 gen64{std::random_device{}()}; // 64비트
Q4: rand()를 사용하면 안 되나요?
A: 비권장합니다. rand()는 비균등 분포, 낮은 품질, 전역 상태 문제가 있습니다.
C/C++ 예제 코드입니다.
// ❌ rand(): 비균등
int r = rand() % 100;
// ✅ C++11 random: 균등
std::uniform_int_distribution<> dist{0, 99};
int r = dist(gen);
Q5: 시드는 어떻게 설정하나요?
A: std::random_device 를 권장합니다.
C/C++ 예제 코드입니다.
// ✅ random_device: 하드웨어 엔트로피
std::random_device rd;
std::mt19937 gen{rd()};
// ❌ 시간 기반: 예측 가능
std::mt19937 gen{static_cast<unsigned>(std::time(nullptr))};
Q6: 재현 가능한 난수를 생성하려면?
A: 고정 시드를 사용합니다.
testRandom 함수의 구현 예제입니다.
// 재현 가능
std::mt19937 gen{42}; // 항상 같은 난수
// 테스트에 유용
void testRandom() {
std::mt19937 gen{12345};
// 항상 같은 결과
}
Q7: 스레드 안전한가요?
A: 스레드 안전하지 않습니다. 각 스레드마다 별도의 엔진을 사용해야 합니다.
threadFunc 함수의 구현 예제입니다.
// ❌ 전역 엔진: 경쟁 조건
std::mt19937 globalGen;
void threadFunc() {
int r = std::uniform_int_distribution<>{0, 99}(globalGen); // 위험
}
// ✅ thread_local
thread_local std::mt19937 gen{std::random_device{}()};
void threadFunc() {
int r = std::uniform_int_distribution<>{0, 99}(gen); // 안전
}
Q8: Random 학습 리소스는?
A:
- “C++ Primer” by Stanley Lippman
- “Effective Modern C++” by Scott Meyers
- cppreference.com - Random
관련 글: random_device, mt19937, distribution.
한 줄 요약: C++11 random은 고품질 난수 생성을 위한 표준 라이브러리입니다.
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
관련 글
이 글에서 다루는 키워드 (관련 검색어)
C++, random, generator, distribution, C++11 등으로 검색하시면 이 글이 도움이 됩니다.