C++ random 확률 분포: uniform·normal·bernoulli·discrete와 엔진 분리 사용법

이 글의 핵심

C++11 <random> 라이브러리의 확률 분포(균등, 정규, 베르누이, 이항, 가중치 선택)를 난수 엔진과 분리해 사용하는 방법을 실전 예제로 정리합니다. rand()와의 차이, 경계값 규칙, 파라미터 검증, 성능 함정까지 다룹니다.

Distribution이란?

확률 분포 (C++11)

C++11의 <random>이 등장하기 전, 개발자들은 대부분 rand() % 6 + 1처럼 C 표준 라이브러리의 rand()를 나머지 연산으로 원하는 범위에 욱여넣는 방식을 썼습니다. 이 방식은 두 가지 근본적인 문제가 있습니다 — 첫째, rand()가 반환하는 값의 범위(RAND_MAX)가 원하는 범위의 배수가 아니면 나머지 연산이 특정 값을 더 자주 뽑히게 만드는 편향을 유발하고, 둘째, rand()는 균등 분포 외의 다른 분포(정규 분포, 이항 분포 등)를 표현할 방법이 없어 그런 분포가 필요하면 직접 수학적으로 변환해야 했습니다. <random>은 “난수를 생성하는 엔진”(mt19937 같은)과 “그 난수를 원하는 확률 분포에 맞게 변환하는 분포 객체”를 완전히 분리했습니다 — 엔진은 통계적으로 검증된 균등 비트 스트림만 생성하는 데 집중하고, uniform_int_distribution이나 normal_distribution 같은 분포 객체가 그 원시 난수를 받아 원하는 확률적 성질(범위, 평균, 표준편차)을 갖도록 수학적으로 변환합니다. 이 분리 덕분에 같은 엔진을 여러 분포와 조합할 수 있고, 각 분포의 통계적 정확성은 표준 라이브러리 구현이 보장합니다.

#include <random>

std::mt19937 gen{std::random_device{}()};

// 균등 분포
std::uniform_int_distribution<> uniform{1, 6};
int dice = uniform(gen);

// 정규 분포
std::normal_distribution<> normal{0.0, 1.0};
double value = normal(gen);

이 첫 줄의 std::mt19937 gen{std::random_device{}()};에는 알아 둘 점이 두 가지 있습니다. 첫째, random_device가 주는 32비트 값 하나로 시드하면 mt19937이 가질 수 있는 거대한 내부 상태(624개의 32비트 워드) 중 2^32가지 시작점만 쓸 수 있습니다. 게임 하나를 돌리는 데는 충분하지만, 대량의 독립 시뮬레이션을 돌리거나 시작 상태가 추측되면 안 되는 경우에는 std::seed_seq로 여러 개의 random_device 값을 모아 시드하는 편이 낫습니다. 둘째, random_device는 구현에 따라 진짜 하드웨어 엔트로피가 아닐 수 있습니다. 오래된 MinGW의 GCC(9.2 이전)에서는 random_device가 매 실행 같은 수열을 돌려줘 “프로그램을 켤 때마다 같은 결과가 나온다”는 버그로 유명했습니다. entropy()가 0을 돌려주는지 확인해 볼 수는 있지만 이 값도 구현마다 신뢰도가 달라, 의심스러우면 실행 결과를 직접 비교하는 것이 가장 확실합니다.

균등 분포

균등 분포는 “범위 안의 모든 값이 뽑힐 확률이 정확히 같아야 한다”는 요구를 표현하며, 정수용(uniform_int_distribution)과 실수용(uniform_real_distribution)이 별도 클래스로 나뉘어 있는 이유는 두 타입이 “균등하다”는 것의 의미 자체가 다르기 때문입니다 — 정수는 유한하고 셀 수 있는 값들의 집합이라 각 값에 정확히 같은 확률을 배정할 수 있지만, 실수는 구간 안에 무한히 많은 값이 있어 “균등”이라는 개념이 확률 밀도(probability density)로 재정의됩니다. 두 클래스의 경계 처리 방식이 다른 것도 이 차이에서 비롯됩니다 — 정수 분포는 양 끝을 포함하는 닫힌 구간 [a, b]를, 실수 분포는 오른쪽 끝을 제외하는 반개방 구간 [a, b)를 따르며, 이 차이를 모르고 코드를 작성하면 뒤에서 다룰 “범위” 문제처럼 경계값에서 예상과 다른 결과를 얻을 수 있습니다.

#include <random>

std::mt19937 gen{std::random_device{}()};

// 정수
std::uniform_int_distribution<> intDist{0, 99};
int randomInt = intDist(gen);

// 실수
std::uniform_real_distribution<> realDist{0.0, 1.0};
double randomDouble = realDist(gen);

uniform_int_distribution<>의 빈 꺾쇠는 기본 타입 int를 뜻합니다. 템플릿 인자로 쓸 수 있는 타입에는 제약이 있어서, short, int, long, long long과 그 unsigned 버전만 허용되고 char나 std::uint8_t는 허용되지 않습니다. uniform_int_distribution<std::uint8_t>로 바이트를 뽑는 코드는 GCC에서는 컴파일되기도 하지만 표준상 정의되지 않은 동작이고, MSVC에서는 static_assert로 컴파일이 실패합니다. 바이트가 필요하면 uniform_int_distribution<int>{0, 255}로 뽑은 뒤 변환합니다.

균등 분포를 쓰는 가장 큰 이유는 앞에서 말한 rand() % n의 편향을 없애기 위해서입니다. uniform_int_distribution은 엔진이 낸 값이 범위를 고르게 나누지 못하는 “나머지 구간”에 떨어지면 그 값을 버리고 다시 뽑는(rejection) 방식으로 정확한 균등성을 보장합니다. 그래서 한 번의 dist(gen) 호출이 엔진을 몇 번 호출할지는 고정되어 있지 않고, 이 점이 뒤에서 설명할 “플랫폼마다 결과가 다른” 문제의 원인 중 하나이기도 합니다.

실전 예시

예시 1: 정규 분포

10,000개의 표본을 뽑아 히스토그램으로 만드는 이 예시는 정규 분포의 두 파라미터(평균 100, 표준편차 15)가 실제로 무엇을 의미하는지 눈으로 확인하게 해 줍니다 — 종 모양 곡선의 중심이 100 근처에 몰리고, 68%의 표본이 대략 평균 ± 1표준편차(85~115) 범위에 들어가는 “68-95-99.7 규칙”이 히스토그램의 별표(*) 분포에서 그대로 드러납니다. 이런 분포는 IQ 점수, 측정 오차, 자연에서 관찰되는 많은 연속량을 모델링하는 데 쓰이며, 게임에서 “적의 능력치를 평균 근처에 몰리게 생성하되 가끔 극단값도 나오게” 하고 싶을 때도 균등 분포 대신 정규 분포를 선택하는 이유가 여기 있습니다 — 균등 분포라면 최솟값과 최댓값도 평균만큼 자주 나오지만, 정규 분포는 극단값이 자연스럽게 드물게 나옵니다.

#include <random>
#include <map>
#include <cmath>
#include <iostream>
#include <string>

int main() {
    std::mt19937 gen{std::random_device{}()};
    std::normal_distribution<> dist{100.0, 15.0};  // 평균 100, 표준편차 15
    
    std::map<int, int> histogram;
    
    for (int i = 0; i < 10000; ++i) {
        double value = dist(gen);
        ++histogram[static_cast<int>(std::round(value / 10) * 10)];
    }
    
    for (const auto& [value, count] : histogram) {
        std::cout << value << ": " << std::string(count / 100, '*') << std::endl;
    }
}

예시 2: 베르누이 분포

베르누이 분포는 “성공 확률이 p인 단 한 번의 시행”이라는 가장 단순한 확률 모델을 표현하며, uniform_real_distribution{0.0, 1.0}로 뽑은 값이 0.7보다 작은지 직접 비교하는 방식으로도 같은 효과를 낼 수 있지만 bernoulli_distribution{0.7}이 그 의도(“70% 확률로 참”)를 코드 자체에 훨씬 명확하게 드러냅니다. 게임의 크리티컬 히트 판정, 네트워크 패킷 손실 시뮬레이션, A/B 테스트에서 사용자를 그룹에 배정하는 것처럼 “이분법적 결과가 특정 확률로 갈리는” 모든 상황이 이 분포로 자연스럽게 모델링됩니다.

#include <random>

int main() {
    std::mt19937 gen{std::random_device{}()};
    std::bernoulli_distribution dist{0.7};  // 70% 확률
    
    int success = 0;
    for (int i = 0; i < 100; ++i) {
        if (dist(gen)) {
            ++success;
        }
    }
    
    std::cout << "성공: " << success << "/100" << std::endl;
}

예시 3: 이항 분포

이항 분포는 앞서 다룬 베르누이 시행을 “여러 번 반복했을 때 성공 횟수가 몇 번일지”로 확장한 것입니다 — binomial_distribution{10, 0.5}는 “성공 확률 50%인 시행을 10번 반복했을 때 성공 횟수”의 분포를 표현하며, 매번 dist(gen)을 호출할 때마다 그 10번의 시행을 시뮬레이션한 뒤 성공 횟수만 반환합니다(개별 시행 결과 하나하나를 직접 볼 필요는 없다는 뜻입니다). 동전을 10번 던져 앞면이 몇 번 나오는지, 100명의 사용자 중 몇 명이 특정 기능을 클릭하는지처럼 “고정된 횟수의 독립 시행에서 성공 횟수”를 다루는 통계적 시뮬레이션에서 이 분포가 직접 등장합니다.

#include <random>

int main() {
    std::mt19937 gen{std::random_device{}()};
    std::binomial_distribution<> dist{10, 0.5};  // 10번 시도, 50% 확률
    
    for (int i = 0; i < 10; ++i) {
        int successes = dist(gen);
        std::cout << "성공 횟수: " << successes << std::endl;
    }
}

예시 4: 가중치 선택

이 예시는 게임의 아이템 드롭 확률처럼 “여러 항목 중 하나를 서로 다른 확률로 뽑는” 실무에서 가장 자주 마주치는 패턴입니다 — discrete_distribution은 weights 벡터를 정규화(전체 합이 1이 되도록 자동으로 나눔)해 각 인덱스가 그 상대적 가중치에 비례하는 확률로 뽑히도록 합니다. weights = {0.5, 0.3, 0.15, 0.04, 0.01}이 반드시 합이 1일 필요는 없다는 점도 주목할 만합니다 — {50, 30, 15, 4, 1}처럼 정수 비율로 써도 discrete_distribution이 내부적으로 정규화하므로 정확히 같은 결과를 얻습니다. dist(gen)이 반환하는 것은 items 문자열이 아니라 그 인덱스라는 점도 실수하기 쉬운 부분이며, items[index]로 실제 값을 찾아야 한다는 것을 코드가 명확히 보여줍니다.

#include <random>
#include <vector>
#include <string>
#include <map>
#include <iostream>

int main() {
    std::vector<std::string> items = {"common", "uncommon", "rare", "epic", "legendary"};
    std::vector<double> weights = {0.5, 0.3, 0.15, 0.04, 0.01};
    
    std::mt19937 gen{std::random_device{}()};
    std::discrete_distribution<> dist{weights.begin(), weights.end()};
    
    std::map<std::string, int> drops;
    
    for (int i = 0; i < 1000; ++i) {
        int index = dist(gen);
        ++drops[items[index]];
    }
    
    for (const auto& [item, count] : drops) {
        std::cout << item << ": " << count << std::endl;
    }
}

discrete_distribution의 가중치에는 몇 가지 규칙이 있습니다. 가중치는 음수가 아니어야 하고 합이 0보다 커야 하며, 이를 어기면 정의되지 않은 동작입니다. 가중치가 0인 항목은 절대 뽑히지 않으므로 “이벤트 기간에만 등장하는 아이템”을 가중치 0으로 꺼 두는 방식도 가능합니다. 생성 시 가중치를 정규화하고 누적 확률표를 만드는 데 항목 수에 비례하는 시간이 들기 때문에, 이 분포는 특히 반복문 안에서 새로 만들지 말아야 합니다. 가중치를 자주 바꿔야 한다면 매번 분포를 새로 만드는 대신 param()으로 새 파라미터 객체를 넘기는 방법을 쓸 수 있습니다.

분포 종류

이렇게 많은 분포가 표준 라이브러리에 포함된 이유는, 각각이 서로 다른 실세계 현상을 정확하게 모델링하기 위한 고유한 수학적 형태를 갖고 있기 때문입니다 — geometric_distribution은 “처음 성공이 나올 때까지 걸리는 시행 횟수”(예: 처음 6이 나올 때까지 주사위를 몇 번 굴려야 하는가), poisson_distribution은 “단위 시간당 발생하는 사건의 횟수”(예: 한 시간 동안 걸려오는 전화 수), exponential_distribution은 “다음 사건이 발생하기까지 걸리는 시간”(포아송 과정의 사건 간 간격)을 표현합니다. 이들은 서로 밀접하게 연관되어 있으면서도(포아송과 지수 분포는 같은 현상의 “횟수”와 “간격”을 각각 표현) 미묘하게 다른 질문에 답하므로, 다루려는 현상이 정확히 어떤 질문에 해당하는지 파악하는 것이 올바른 분포를 고르는 첫걸음입니다.

// 균등
std::uniform_int_distribution<>      // 정수
std::uniform_real_distribution<>     // 실수

// 베르누이
std::bernoulli_distribution          // 참/거짓
std::binomial_distribution<>         // 이항
std::geometric_distribution<>        // 기하

// 정규
std::normal_distribution<>           // 정규
std::lognormal_distribution<>        // 로그 정규

// 포아송
std::poisson_distribution<>          // 포아송
std::exponential_distribution<>      // 지수

// 샘플링
std::discrete_distribution<>         // 가중치
std::piecewise_constant_distribution<>

자주 발생하는 문제

문제 1: 범위

앞서 “균등 분포” 절에서 설명했듯, 정수 분포와 실수 분포는 경계 처리 방식이 근본적으로 다릅니다 — uniform_int_distribution{0, 9}는 0과 9를 모두 포함하는 10개의 값 중 하나를 뽑지만, uniform_real_distribution{0.0, 1.0}은 0.0은 나올 수 있어도 1.0은 (수학적으로는) 결코 나오지 않는 반개방 구간입니다. 이는 사소해 보이지만 정수 배열의 인덱스를 뽑을 때(uniform_int_distribution{0, arr.size() - 1}처럼 -1을 명시적으로 빼야 하는 것. 이때 arr가 비어 있으면 size() - 1이 size_t의 최댓값이 되고, uniform_int_distribution<>{0, arr.size() - 1}처럼 int 분포에 중괄호로 넘기면 size_t → int 좁힘 변환 때문에 컴파일 에러가 납니다. 인덱스용이면 uniform_int_distribution<std::size_t>를 쓰고 빈 컨테이너를 먼저 검사해야 합니다)나 실수 확률을 다룰 때(0.0과 1.0 경계에서 미묘한 부등호 방향 실수) 실제 버그로 이어지는 흔한 함정이며, 두 분포를 오가며 코드를 작성할 때는 항상 이 경계 규칙을 다시 확인하는 습관이 필요합니다.

// uniform_int_distribution: [a, b] (포함)
std::uniform_int_distribution<> intDist{0, 9};  // 0~9

// uniform_real_distribution: [a, b) (반개방)
std::uniform_real_distribution<> realDist{0.0, 1.0};  // 0.0 <= x < 1.0

실수 분포의 “b는 나오지 않는다”는 약속은 수학적 의도일 뿐, 실제 구현에서는 반올림 때문에 드물게 b가 나올 수 있다는 점도 알려져 있습니다(generate_canonical의 반올림 문제로 표준 위원회 이슈 LWG 2524에 등록된 사례이며, 특히 float에서 잘 드러납니다). 결과를 배열 인덱스로 바꾸는 코드(int idx = x * n;)라면 이 드문 경우에 idx == n이 되어 범위를 벗어나므로, 정수 인덱스가 필요하면 처음부터 정수 분포를 쓰는 편이 안전합니다.

문제 2: 파라미터

표준 표준편차(standard deviation)는 정의상 음수가 될 수 없는 값(제곱근의 결과)이므로, normal_distribution에 음수 표준편차를 넘기는 것은 수학적으로 의미가 없는 요청이며 이런 경우 표준은 그 동작을 정의하지 않습니다(구현에 따라 크래시하거나 이상한 값을 낼 수 있습니다). param()으로 현재 설정된 파라미터를 다시 조회할 수 있다는 것도 유용한 기능입니다 — 특히 분포 객체를 함수에 넘겨 받은 코드가 “이 분포가 정확히 어떤 평균과 표준편차로 설정되어 있는가”를 디버깅 로그로 남기거나, 런타임에 검증하고 싶을 때 이 인터페이스가 필요합니다.

// 정규 분포: 평균, 표준편차
std::normal_distribution<> dist{100.0, 15.0};

// ❌ 잘못된 파라미터
// std::normal_distribution<> dist{100.0, -15.0};  // 음수 표준편차

// 파라미터 확인
auto params = dist.param();
std::cout << "평균: " << params.mean() << std::endl;
std::cout << "표준편차: " << params.stddev() << std::endl;

문제 3: 재설정

reset()이 존재하는 이유를 이해하려면 일부 분포(특히 normal_distribution의 일반적인 구현이 쓰는 Box-Muller 변환 같은 알고리즘)가 내부 상태를 캐시할 수 있다는 사실을 알아야 합니다 — 이런 알고리즘은 균등 난수 두 개를 조합해 정규분포 값을 두 개 만들어내는데, 그중 하나는 즉시 반환하고 나머지 하나는 다음 호출을 위해 내부에 저장해 둡니다. 이 캐시된 상태 때문에, 만약 분포 객체의 파라미터를 바꾸거나 기반 엔진의 시드를 재설정했는데 그 분포 객체에 캐시된 이전 상태가 남아있으면 예상과 다른(이전 파라미터의 영향을 받은) 값이 섞여 나올 수 있으며, reset()은 이런 내부 캐시를 명시적으로 비워 다음 호출부터 완전히 새로운 상태로 시작하게 만듭니다. 뒤 줄의 dist = uniform_int_distribution<>{100, 199}처럼 아예 새 분포 객체로 재대입하는 것도 같은 목적(이전 상태를 완전히 지우는 것)을 달성하는 또 다른 방법입니다.

std::mt19937 gen{std::random_device{}()};
std::uniform_int_distribution<> dist{0, 99};

// 분포 재설정
dist.reset();

// 새로운 범위
dist = std::uniform_int_distribution<>{100, 199};

문제 4: 성능

분포 객체를 새로 만드는 비용은 분포마다 다릅니다. uniform_int_distribution은 두 경계값만 저장하므로 생성 비용이 사실상 무시할 만하지만, discrete_distribution처럼 생성 시 확률표를 만드는 분포는 항목 수에 비례하는 비용이 들고, normal_distribution은 새로 만들면 앞 절의 캐시가 버려져 엔진 호출이 늘어납니다. 그보다 훨씬 비싼 실수는 엔진을 반복문 안에서 만드는 것입니다. std::mt19937은 약 2.5KB의 상태를 시드로 채우는 작업이 필요하고, std::random_device는 호출마다 운영체제의 엔트로피 소스를 읽는 시스템 호출이 될 수 있습니다. 게다가 루프 안에서 시간 기반 시드(time(nullptr))로 엔진을 새로 만들면 1초 안의 모든 반복이 같은 값을 받습니다. 엔진(gen)과 분포(dist) 모두 반복문 바깥에서 한 번만 생성하고, 매 반복에서는 오직 dist(gen) 호출만 반복하는 것이 올바른 사용 패턴입니다. 이는 앞서 다른 글들에서 반복해 다룬 “매번 생성하지 말고 재사용하라”는 원칙이 난수 생성에도 그대로 적용되는 사례입니다.

// 분포 생성 비용
std::mt19937 gen{std::random_device{}()};

// ❌ 매번 생성
for (int i = 0; i < 1000; ++i) {
    std::uniform_int_distribution<> dist{0, 99};
    int r = dist(gen);
}

// ✅ 재사용
std::uniform_int_distribution<> dist{0, 99};
for (int i = 0; i < 1000; ++i) {
    int r = dist(gen);
}

활용 패턴

네 항목은 이 글에서 다룬 분포들을 실제로 어떤 상황에 골라 쓸지 요약합니다 — 배열 인덱스나 주사위처럼 모든 값이 동등해야 하면 균등 분포, 자연스러운 변동(측정값, 능력치)을 흉내 내려면 정규 분포, 여러 항목을 서로 다른 비율로 뽑아야 하면 가중치 분포, 단순한 확률적 이분법(성공/실패, 참/거짓)이 필요하면 베르누이 분포를 선택하는 것이 출발점입니다. 실무에서는 이 네 가지만으로 대부분의 시뮬레이션·게임 로직 요구를 충족할 수 있고, 나머지 특수한 분포(포아송, 지수, 기하 등)는 그 현상이 정확히 그 수학적 모델에 대응될 때만 선택적으로 꺼내 쓰면 충분합니다.

재현성이 요구되는 곳에서는 한 가지를 꼭 기억해야 합니다. 표준은 mt19937 같은 엔진의 알고리즘은 정확히 규정하지만, 분포가 엔진 출력을 변환하는 알고리즘은 구현에 맡깁니다. 그래서 같은 시드로 만든 uniform_int_distribution<>{1, 6}이라도 GCC(libstdc++), Clang(libc++), MSVC에서 서로 다른 주사위 수열이 나옵니다. 저장된 시드로 게임 맵을 재생성하거나, 서버와 클라이언트가 같은 시드로 같은 결과를 계산해야 하는 락스텝 방식 게임에서 이 차이가 버그로 나타나는데, 한 플랫폼에서만 테스트하면 드러나지 않아 출시 후에야 발견되는 경우가 많습니다. 이런 요구가 있다면 엔진의 원시 출력만 쓰고 범위 변환은 직접 구현하거나, 분포 구현을 코드로 고정해 둔 라이브러리를 쓰는 것이 안전합니다. 또 엔진과 분포 객체는 모두 상태를 가지므로 여러 스레드가 하나를 공유하면 데이터 경쟁이 생깁니다. 스레드마다 thread_local std::mt19937을 두는 것이 흔한 해법입니다.

// 1. 균등 난수
std::uniform_int_distribution<> uniform{0, 99};

// 2. 정규 분포
std::normal_distribution<> normal{0.0, 1.0};

// 3. 가중치 선택
std::discrete_distribution<> discrete{weights.begin(), weights.end()};

// 4. 참/거짓
std::bernoulli_distribution bernoulli{0.5};

FAQ

Q1: 같은 시드로 실행했는데 컴파일러를 바꾸니 결과가 달라졌습니다.

A: 정상입니다. 엔진(mt19937)의 출력은 모든 표준 구현에서 같지만, 분포 객체가 그 출력을 원하는 범위로 바꾸는 알고리즘은 표준 라이브러리마다 다릅니다. 플랫폼 간 결과가 같아야 한다면 엔진 출력을 직접 변환하는 코드를 작성하세요.

Q2: uniform_int_distribution<uint8_t>로 바이트를 뽑아도 되나요?

A: 안 됩니다. 표준은 short부터 unsigned long long까지의 정수 타입만 허용하며, char·uint8_t는 정의되지 않은 동작입니다. uniform_int_distribution<int>{0, 255}로 뽑은 뒤 static_cast<std::uint8_t>로 바꾸세요.

Q3: normal_distribution의 두 번째 인자는 분산인가요, 표준편차인가요?

A: 표준편차입니다. 분산 225를 원한다면 normal_distribution<>{mean, 15.0}처럼 제곱근을 넘겨야 합니다. 반면 lognormal_distribution의 두 인자는 결과값이 아니라 그 로그값의 평균과 표준편차라는 점도 자주 혼동됩니다.

Q4: 게임 아이템 드롭이나 셔플에 이 분포들을 써도 되나요?

A: 게임 로직과 시뮬레이션에는 적합합니다. 다만 mt19937은 출력 624개를 관찰하면 내부 상태를 복원할 수 있어 다음 값을 예측할 수 있으므로, 도박성 보상이나 토큰·비밀번호 생성처럼 예측되면 안 되는 용도에는 운영체제의 암호학적 난수(getrandom, BCryptGenRandom)나 이를 감싼 라이브러리를 써야 합니다. 셔플은 직접 구현하지 말고 std::shuffle(v.begin(), v.end(), gen)을 쓰세요.


같이 보면 좋은 글