C: rand() 와 srand() 로 난수 만들기
C 언어에서 난수를 만들 때 가장 널리 쓰이는 함수는 rand() 와 srand() 이다. 이 글에서는 이 두 함수의 사용법과 활용법을 알아보고 한계와 대안에 대해서 다루려고 한다.
1. rand()
rand() 함수의 원형은 다음과 같다.
#include <stdlib.h>
int rand(void);
rand() 함수는 [0, RAND_MAX] 범위(0과 RAND_MAX 포함)에서 정수 형태의 의사-난수(pseudo-random number)를 만든다. RAND_MAX 는 rand() 가 만드는 난수의 최대값으로, C 라이브러리에 따라 다르다. 다만, 최소 크기는 0x7FFF(=32767, 15비트)로 보장된다.
1-1. 원하는 범위내의 난수 만들기
rand() 함수는 [0, RAND_MAX] 범위의 난수를 만든다. 하지만, 실제로 필요한 범위는 이와는 다르다. 예를 들어, [10, 20] 사이의 난수를 만들려면 어떻게 해야 할까?
보통 두 가지 방법이 알려져 있다. 하나는 나머지 연산(%)을 이용하는 것이고, 다른 하나는 비례 관계를 이용하는 것이다.
1-1-1. 나머지 연산을 이용하는 방법
나머지 연산을 이용하는 방법부터 알아보자. 나머지 연산을 이용하면 [0, n) 범위(0 포함, n 비포함)를 얻을 수 있다. 예를 들어 n=7 이라면, 어떤 값이 오더라도 7로 나눈 나머지는 결국 0 이상 6 이하의 정수이다. 여기에 적당한 값을 더해 주면 우리가 원하는 범위의 난수를 얻을 수 있다. 이를 함수로 나타내면 다음과 같다.
1 2 3 4 | int randrange(int a, int b) { return (rand() % (b - a + 1)) + a; } |
하지만, 이 방법의 경우 난수가 고르게 만들어지지 않는다는 문제점이 있다. 예를 들어, 난수가 만들어지는 범위는 [0, 8] 이지만, 원하는 범위는 [0, 5] 라면, 나머지 연산에는 6 이 쓰인다. 이 경우, [0, 5] 범위의 난수는 [0, 5] 범위에 잘 대응하지만, [6, 8] 범위의 난수는 [0, 2] 범위에만 대응된다. 따라서 [0, 2] 범위의 난수가 [3, 5] 범위의 난수보다 나타나는 횟수가 더 많아진다. 이는 난수의 분포가 일정하지 않다는 뜻이고, 난수로서의 의미가 없다는 뜻이다. 따라서 나머지 연산을 이용하는 방법보다는 다음에서 다루는 비례 관계를 이용하는 방법을 쓰자.
1-1-2. 비례 관계를 이용하는 방법
비례 관계는 말 그대로 난수와 난수 범위를 비례식을 이용하여 원하는 범위로 바꾸는 것이다.
난수 : 난수 범위 = x : 원하는 범위
결국 원하는 범위의 난수 x 는 다음처럼 계산할 수 있다.
x = 난수 / 난수 범위 * 원하는 범위
함수를 이용하여 표현하면 다음과 같다.
1 2 3 4 | int randrange(int a, int b) { return rand() / ((double)RAND_MAX + 1) * (b - a + 1) + a; } |
여기서 3 번째 줄의 몇 가지 눈여결 볼 만한 부분이 있다.
첫째, 분모가 RAND_MAX 가 아니라 RAND_MAX + 1 이다. 만일 RAND_MAX 가 분모라면, rand() 는 [0, RAND_MAX] 범위의 난수를 만들므로, 결과적으로 [0, 1] 사이의 난수가 발생한다. 문제는 1이 되는 경우가 RAND_MAX 가 발생할 때 딱 한 번 뿐이라는 것이다. 결국 1의 발생 비율은 다른 수의 발생 비율에 비해 현저히 낮아진다. 따라서 + 1 을 함으로써 [0, 1) 범위의 난수를 만드는 것이다.
둘째, RAND_MAX 앞에 (double) 형변환(type-casting)이 있다. RAND_MAX 는 때때로 정수형의 최대치로 정의되기도 한다. 이 경우 + 1 을 하는 순간, 오버 플로우가 되면서 예상하지 않은 결과(음수?)가 나타난다. 따라서, 더 큰 범위의 자료형이 필요하다.
셋째, 그럼 long 이나 long long 이 아니고 double 로 바꾸는 이유는 뭘까? 바로 정수 나눗셈의 문제 때문이다. 분자인 rand() 는 [0, RAND_MAX] 범위의 난수를 발생시킨다. 하지만, 분모는 RAND_MAX + 1 이므로, 정수 나눗셈의 결과는 언제나 0 이다. 따라서 RAND_MAX 를 double 로 형변환하여 정수 나눗셈을 부동소숫점 나눗셈으로 바꿈으로써 [0, 1) 범위의 부동소숫점 난수를 만든다.
이 방법의 경우에는 원하는 범위가 [0, RAND_MAX] 범위에 있는 경우에 유효하다. 만일 원하는 범위가 RAND_MAX 보다 큰 경우, 범위가 클수록 나타나지 않는 난수가 많아진다.
이런 경우에는 두 개의 rand() 를 호출하여 얻은 두 개의 난수를 비트 이동(bit shift)과 비트 합(bit or)을 이용하여 더 큰 난수를 형성하는 방법을 사용하기도 한다. 다만, C 라이브러리에 따라 RAND_MAX 의 값이 다르고, 결국 비트 수가 달라지기 때문에 이를 고려해야 한다. 그렇지 않으면 예상하지 않은 결과를 만날 수 있다.
1-2. 만들어지는 난수의 순서
rand() 함수를 이용해서 원하는 범위의 난수를 만드는 방법을 알아보았다. 그런데 한 가지 문제가 있다. 다음 코드를 컴파일해서 실행시켜 보자.
1 2 3 4 5 6 7 8 9 10 | #include <stdio.h> #include <stdlib.h> int main( void ) { int i; for( i = 0; i < 10; i++ ) printf("%d번째 난수 = %d\n", i + 1, rand()); } |
매번 실행할 때마다 항상 똑같은 순서로 난수가 발생한다. 이것이 의도한 것이라면 상관없지만, 그렇지 않으면 보안 취약점으로 작동할 수 있다. 동일한 C 라이브러리를 이용하는 다른 프로그램에서도 똑같은 순서의 난수를 얻을 수 있기 때문이다. 만약 이 난수가 프로그램에서 중요한 역할을 한다면, 아주 큰 보안 취약점이 된다.
그렇다면 난수가 나타나는 순서를 바꿀 수는 없을까? 당연히 있다. 그게 바로 srand() 함수이다.
2. srand()
srand() 함수의 원형은 다음과 같다.
#include <stdlib.h>
void srand(unsigned seed);
srand() 함수는 seed 값을 받아 앞으로 rand() 함수로 만들어질 난수를 초기화하는 역할을 한다. 다시 말해서, rand() 함수로 만들어지는 난수의 순서를 결정한다. 만약 srand() 를 한 번도 호출하지 않고 rand() 함수를 호출한다면, 이미 정해진 seed 값을 기준으로 난수의 순서가 정해진다. 초기 seed 값은 보통 1 이다. srand() 함수는 프로그램 초반에 한 번 정도만 호출하는 것이 일반적이다.
그런데 문제가 발생한다. srand() 에 적당한 값을 넣어 난수를 초기화한다고 한들, 결국에는 매번 같은 값으로 초기화되니 만들어지는 난수는 매번 같은 순서로 나타난다. srand() 를 쓰지 않을 때와 같은 결과가 나타난다. 결국 seed 값을 임의로 정할 수 있는 방법이 필요하다. 쉽게 떠오르는 것은 rand() 이지만, 지금 이 rand() 때문에 벌어지는 일이니 쓸 수 없다. 그럼 무엇을 써야 하지?
2-1. seed 값 정하기
srand() 의 seed 값을 임의로 정할 수 있을 때, 매번 실행할 때마다 다른 순서의 난수를 얻을 수 있다.
대개 많이 쓰는 방법은 시간을 이용하는 것이다. 그 중에서도 time() 함수를 많이 이용한다. time() 함수의 원형은 다음과 같다.
#include <time.h>
time_t time(time_t *tloc);
time() 함수는 1970-01-01 00:00:00(UTC) 이후로 지난 시간을 초로 바꾸어 알려준다. tloc 이 NULL 이 아니면 tloc 에도 저장한다. 이를 이용해서 다음처럼 seed 값을 정할 수 있다.
srand( time( NULL ));
이렇게 하면 매번 실행할 때마다 난수의 순서가 달라진다.
그런데 주의할 점이 있다. time() 함수는 초 단위로 돌려주기 때문에 1초 미만의 차이로 실행하는 경우 만들어지는 난수의 순서는 같아진다. 이는 곧 보안 취약점으로 이어질 수 있다. 이런 경우에는 time() 함수의 결과에 프로그램의 pid 등을 외부에 노출되지 않은 특정한 값을 더하면 어느 정도 문제를 해결할 수 있다. 또는 더 정밀한 시간 함수 등을 쓸 수도 있다. 예를 들면, gettimeofday() 나 gethrtime() 등을 쓸 수 있다.
하지만 정말 보안에 신경 쓴다면 C 라이브러리에 의존하지 말고, 자체적인 난수 발생기(PRNG, Pseudo-random number generator)를 제작하든지, 믿을 만한 써드-파티 라이브러리 등을 이용하든지, 아니면 OS 나 시스템 또는 전용 장치 등에서 제공하는 암호화된 난수 발생기를 이용해야 한다.
여기서 잠깐 다시 한 번 생각해 보자. 여러 군데에서 동시에 같은 순서의 난수를 만드는 것이 나쁘기만 할까? 때로는 오히려 이 난수를 비교함으로써 서로를 확인할 수도 있다.
2-2. OTP 의 간단한 원리
보통 금융권에서 보안을 강화하기 위한 마지막 단계로 OTP(One time password)를 많이 사용한다. 금융 회사들은 어떻게 OTP 로 인증을 하는 것일까?
원리는 간단한다. 바로 양쪽에서 서로 같은 순서로 난수를 발생시켜 이를 확인하는 것이다. 금융 회사에 OTP 를 등록할 때 OTP 제조회사와 OTP 고유 번호 등을 저장한다. 이 정보와 OTP 가 필요한 때의 시간 정보를 이용해서 동일한 난수를 만들어낸다. OTP 로 만들어낸 번호의 유효 시간이 정해져 있으므로, 인증번호가 입력되는 시점을 기준으로 비교하면 된다. 물론 여유 있게 한다면, 앞뒤 시점까지 어느 정도 여유를 두어 비교할 수도 있다.
이런 원리 때문에 OTP 시간과 컴퓨터 시간이 동기화되지 않으면 OTP 에 보이는 인증 번호를 아무리 입력해도 잘못된 번호라고 인증이 거부되는 일이 종종 생긴다.
이렇게 적절하게 암호화된 seed 값을 정하고, 그 결과 나타나는 난수를 이용하여 인증에 이용할 수도 있다.
3. rand() 와 srand()의 한계
3-1. 쓰레드-안정성(thread-safety)
rand() 와 srand() 는 간단하면서도 꽤 분포가 고른 난수를 만들어낸다. 하지만, 이 함수들이 만들어진지 오래 되다 보니 쓰레드-안정성(thread-safety)은 보장되지 않는다. 다시 말해서, 멀티-쓰레드 환경에서는 이 함수들을 사용하지 않는 것이 좋다. 대신에 rand_r() 을 이용하자.
3-2. 난수의 크기
앞서 말했듯이, rand() 는 [0, RAND_MAX] 범위의 난수를 만들고, 이 때 RAND_MAX 최소 15비트 정수(0x7FFF = 32767)로 정의된다. 따라서 더 큰 범위의 난수가 필요할 때는 rand() 를 두 번 호출하여 비트 연산을 해야 한다. 이런 불편함 대신에 31비트 정수 난수를 만들어 주는 함수가 있다. 바로 random(), srandom(), initstate(), setstate() 이다. 하지만, 이 함수들 역시 쓰레드-안정성이 보장되지 않기 때문에 필요하다면 random_r()을 쓰자.
3-3. 난수의 범위
난수의 범위는 난수의 크기와도 관련 있는 부분이다. RAND_MAX 의 크기가 충분히 보장되지 않기 때문에, 부동 소숫점 형태의 난수를 만든다든지, 음수 범위의 난수를 포함한다든지 할 때 난수의 질이 떨어질 수 있다. 다행히 이런 문제를 해결해 주는 새로운 함수들이 있다.
drand48(), erand48(): [0.0, 1.0) 범위의 double 형 부동 소숫점 난수 생성
lrand48(), nrand48(): [0, 2^31) 범위의 long 형 정수 난수 생성
mrand48(), jrand48(): [-2^31, 2^31) 범위의 long 형 정수 난수 생성
srand48(), seed48(), lcong48(): drand48(), lrand48(), mrand48() 함수의 난수 생성 초기화
주의할 점은 drand48(), lrand48(), mrand48() 은 쓰레드-안정성이 보장되지 않는다는 것이다. 멀티-쓰레드 환경에서는 erand48(), nrand48(), jrand48() 을 사용하자.
보다 자세한 것은 다음 문서를 참고하자.
https://pubs.opengroup.org/onlinepubs/9699919799/functions/drand48.html
4. 결론
rand() 와 srand() 를 이용하면 비교적 간단하게 품질 좋은 난수를 얻을 수 있다. 또한 간단한 방법을 통해 원하는 형태로 난수를 변환할 수도 있다. 하지만, 오래된 함수이니만큼 그 한계가 명확하다. 이 한계를 알고 있어야 적절한 곳에 올바르게 쓸 수 있다. 그리고 세월이 흐른 만큼 이를 대체할 수 있는 함수들도 알아 보았다.
이를 바탕으로, 앞으로는 너무 rand() 와 srand() 에만 얽매이지 말고, 새로운 함수도 적극 활용하여 그 잇점들을 누려보자.
댓글
댓글 쓰기