C의 난수는 <stdlib.h>의 두 함수에서 나옵니다. 다음 값을 만들어 내는 rand()와 시작점을 정하는 srand()입니다. 이들은 진짜 무작위가 아닙니다. 시드로부터 결정론적으로 계산되는 유사난수 수열이고, 이것이 암호에는 한계이고 테스트에는 기능입니다.
rand()와 RAND_MAX
rand()는 0에서 RAND_MAX 사이의 int를 양 끝 포함으로 반환합니다. RAND_MAX는 최소 32767임이 보장되는 매크로이고, 리눅스와 macOS에서는 2147483647입니다.
두 번 실행해 보세요. 두 번 다 숫자가 똑같은데, 버그가 아닙니다.
srand로 씨앗 뿌리기
srand를 부르지 않으면 수열은 srand(1)을 부른 것처럼 동작합니다. 같은 시드, 같은 수열, 매번 그렇습니다. 실행마다 다른 숫자를 얻으려면 바뀌는 무언가로 씨앗을 뿌리세요. 관례적으로 현재 시각입니다.
<time.h>의 time(NULL)은 1970년 시작 이후의 초를 반환하므로 실행마다 다른 시드를 얻습니다. unsigned int 캐스팅은 time_t를 좁히는 것에 대한 경고를 잠재웁니다.
씨앗 뿌리기에 관한 규칙 셋이고, 사람들은 셋 다 틀립니다.
딱 한 번, main 시작에서 뿌리세요. 매 rand() 전에 srand를 부르는 것이 고전적인 안티패턴입니다. 1초 안에 끝나는 반복문 안에서는 time(NULL)이 매 반복 같은 값을 반환하므로, 같은 숫자로 다시 씨앗을 뿌리고 rand()가 매번 같은 첫 값을 반환합니다. 출력은 똑같은 "난수"가 한 줄로 늘어선 모습이 됩니다.
무작위성을 "개선"하려고 다시 씨앗을 뿌리지 마세요. 생성기의 품질은 내부 상태가 전진하는 데서 나옵니다. 그 상태를 되돌리면 수열을 버리는 것입니다.
time(NULL)은 해상도가 1초입니다. 같은 초에 시작된 두 프로그램은 같은 수열을 얻습니다. 게임에는 괜찮고, 독립성이 중요한 무엇에는 틀렸습니다.
범위 안의 숫자
표준적인 관용구는 나머지 연산자를 씁니다.
rand() % n /* 0에서 n-1 */
rand() % n + min /* min에서 min+n-1 */
min부터 max까지 양 끝을 포함하려면 가능한 값의 개수가 max - min + 1입니다.
+ 1이 off-by-one 오류가 사는 자리입니다. rand() % 6은 0에서 5를 주므로 주사위는 rand() % 6 + 1입니다. "6을 포함하려고" rand() % 7 + 1이라고 쓰면 일곱 면짜리 주사위가 됩니다.
나머지 편향에 대한 정직한 메모
rand() % n은 n이 RAND_MAX + 1을 정확히 나누지 않는 한 완벽하게 균등하지 않습니다.
작은 숫자로 생각해 봅시다. RAND_MAX가 9라면 - 즉 rand()가 0에서 9까지 똑같이 그럴듯한 열 값을 반환한다면 - rand() % 3은 0,3,6,9를 0으로, 1,4,7을 1로, 2,5,8을 2로 대응시킵니다. 결과 0은 열 가지 중 네 가지로 나오고, 1과 2는 각각 세 가지입니다. 0이 33% 더 잦습니다.
실제 RAND_MAX에서도 같은 치우침이 존재하는데 훨씬 작을 뿐입니다. 남는 값은 앞쪽 (RAND_MAX + 1) % n개의 결과이고, 각각 대략 21억분의 1의 추가 기회를 얻습니다. 주사위 던지기나 섞은 카드, 시뮬레이션에서는 측정되지 않습니다. %를 쓰고 넘어가세요.
정말 중요할 때 - 통계 작업이나 보안 관련 무엇 - 은 남는 값을 접어 넣는 대신 거부하세요.
이 반복문은 치우침을 일으킬 작은 값 구간을 버리고 다시 뽑습니다. 금방 끝납니다. 거부되는 조각은 전체에서 사라질 만큼 작은 비율이니까요.
진짜로 보안이 중요한 무작위성이라면 아무리 조심해도 rand()는 틀린 도구입니다. macOS와 BSD에서는 arc4random_buf, 리눅스에서는 getrandom(), 윈도우에서는 BCryptGenRandom을 쓰세요.
실수 난수
RAND_MAX로 나눠 [0.0, 1.0]에 떨어뜨린 뒤 배율을 적용합니다.
(double) rand()의 캐스팅은 필수입니다. 그것이 없으면 rand() / RAND_MAX는 정수 나눗셈이라 거의 항상 0이 되고, 20억분의 1의 확률로 최댓값을 맞혔을 때만 1이 됩니다. "실수 난수가 전부 0"처럼 보이는 버그죠. 이유는 형변환을 보세요.
재현 가능한 수열
고정된 시드는 실행마다 동일한 수열을 주는데, 테스트나 디버깅, 공유 가능한 레벨 코드가 있는 게임에서 바로 그것을 원합니다.
시드 42는 쓰일 때마다 같은 다섯 숫자를 만듭니다. 이 실행에서도, 같은 라이브러리의 다른 실행에서도요. 그 재현성 때문에 시뮬레이션은 시드를 고를 수 있게 해야 합니다. 평소에는 시계로 돌리고, 버그를 재현할 때는 고정 시드를 넘기세요.
한 가지 주의점. 주어진 시드에 대한 수열은 이식성이 없습니다. C 라이브러리마다 다른 생성기를 쓰므로, glibc의 시드 42와 윈도우의 시드 42는 다른 숫자를 줍니다. 한 기계 안에서 재현 가능하지, 기계를 넘나들며 그렇지는 않습니다.
주사위 게임
모든 것을 한데 모아 봅시다. 한 번만 씨앗 뿌리기, 범위용 헬퍼, 그리고 결과를 집계하는 배열입니다.
히스토그램은 7에서 정점을 찍고 2와 12 쪽으로 가늘어져야 합니다. 7을 만드는 방법은 여섯 가지이고 2나 12를 만드는 방법은 각각 하나뿐이니까요. 여기서 평평한 분포를 내놓는 생성기는 고장 난 것입니다.
관련 페이지 둘. 표준 라이브러리가 <stdlib.h>의 나머지를 안내하고, 수학 함수가 <math.h>를 다룹니다. 난수가 실제 계산에 흘러드는 순간 필요해질 것입니다.
자주 묻는 질문
C에서 난수는 어떻게 만드나요?
<stdlib.h>를 포함하고, main 시작에서 srand((unsigned) time(NULL))로 한 번 씨앗을 뿌린 뒤(<time.h>가 필요합니다) 값마다 rand()를 호출하세요. rand()는 0 이상 RAND_MAX 이하의 int를 반환합니다.
C에서 두 값 사이의 난수를 어떻게 얻나요?
rand() % (max - min + 1) + min을 쓰세요. 1에서 6 사이의 주사위라면 rand() % 6 + 1입니다. % n이 결과를 0..n-1로 대응시키고 min을 더해 창을 옮깁니다. 개수가 양 끝을 모두 포함하도록 하는 것이 + 1이 하는 일입니다.
제 C 프로그램이 왜 매번 같은 난수를 출력하나요?
srand를 부른 적이 없기 때문입니다. 시드가 없으면 rand()는 1로 씨앗을 뿌린 것처럼 동작하므로, 실행할 때마다 똑같은 수열이 나옵니다. 프로그램 시작에서 srand((unsigned) time(NULL))을 한 번 부르세요. 매 rand() 호출 전이 아니라 한 번입니다. 그러면 오히려 나빠집니다.
난수 생성에서 나머지 편향이란 무엇인가요?
rand() % n은 n이 RAND_MAX + 1을 정확히 나눌 때만 완벽하게 균등합니다. 그렇지 않으면 앞쪽 몇 값이 전체 범위에서 한 번씩 더 나타나 아주 약간 더 잦아집니다. RAND_MAX가 2147483647이고 n이 작다면 그 치우침은 게임이나 시뮬레이션이 알아챌 수준을 한참 밑돕니다. 암호나 통계에는 거부 반복문이나 제대로 된 생성기를 쓰세요.