TECH 으로 돌아가기
TECH HACKER NEWS 오늘 8분 읽기 23 READS

난수를 10으로 나누면 왜 공평하지 않은가: random_choice가 더 나은 기본값인 이유

난수를 10으로 나누면 왜 공평하지 않은가: random_choice가 더 나은 기본값인 이유
SOURCE IMAGE · HACKER NEWS

코드 어디에나 등장하는 패턴이 있다. 여러 객체 중 하나를 무작위로 고르되, 모든 객체가 똑같은 확률로 뽑히게 하고 싶은 상황이다. 가장 직관적인 해법은 큰 난수를 하나 뽑은 뒤 선택지 개수로 나머지 연산(modulo)을 취하는 것이다. C처럼 표준 라이브러리가 rand() 정도만 제공하는 언어에서는 사실상 유일하게 떠오르는 방법이기도 하다. 그런데 이 평범한 코드에는 많은 개발자가 놓치는 함정이 숨어 있다.

모듈로가 균등 분포를 망가뜨리는 이유

문제를 단순화해 보자. [0, 9] 구간에서 정수 하나를 균등하게 뽑으면 각 숫자가 뽑힐 확률은 1/10, 즉 10%다. 이제 이 정수를 3개 객체 중 하나를 고르는 데 쓰기 위해 3으로 나머지 연산을 한다고 해보자. 우리는 은근히 각 객체가 1/3씩, 약 33.3% 확률로 뽑히기를 기대한다. 하지만 결과를 표로 적어 보면 기대가 어긋난다는 것이 금방 드러난다. 10개의 입력 중 4개가 첫 번째 선택지로, 나머지 두 선택지는 각각 3개의 입력만 매핑된다. 결국 첫 번째 객체는 의도한 33%가 아니라 40% 확률로, 나머지 둘은 각각 30%로 뽑힌다.

입력 개수가 선택지 개수로 딱 나누어떨어지지 않을 때 생기는 이 치우침은 잘 알려진 고전적 실수다. 올바른 동작은 l과 h 사이의 각 정수에 1/((h-l)+1)의 확률을 주는 random_between(l, h) 함수로 구현된다. 이름은 단순해 보여도 올바르게 만들려면 모듈로 한 줄보다 복잡해진다. 바로 이 지점이 글쓴이가 지적하는 API 설계의 문제다. 가장 유용한 변형 중 하나를 틀리기 쉽게 만들어 두고, 저수준의 균등 난수 함수 하나만 제공하는 것은 좋은 설계가 아니라는 것이다.

분포를 직접 표현하는 random_choice

글쓴이의 제안은 사고의 출발점을 바꾸는 것이다. 우리가 정말 원하는 것은 '균등한 숫자 하나'가 아니라 '무엇이 얼마나 자주 일어나는가'를 표현하는 더 일반적인 연산이다. 실무에서는 숫자 하나만 필요한 경우가 드물고, 대개 여러 선택지 중 객체를 고르며, 특정 객체에 더 높거나 낮은 가중치를 주고 싶을 때도 많다. 균등 분포는 여러 유용한 분포 중 하나일 뿐이다. 그래서 이산적인 선택지와 각각의 확률을 함께 받는 random_choice()가 더 나은 기본 도구가 된다. 확률 분포를 개발자가 직접 적어 내려가면, 앞서 본 치우친 선택 같은 오류는 눈에 확 띄게 된다.

확률을 1.0으로 합이 맞게 적는 방식은 교육적으로는 깔끔하지만, 부동소수점 불안정성과 '정확히 1.0이 되도록 비율을 계산해야 하는' 번거로움이라는 실무적 단점이 있다. 그래서 Python을 비롯한 다수의 표준 라이브러리는 상대적인 정수 가중치를 쓴다. 숫자들이 100이나 1.0으로 합쳐질 필요 없이, 그냥 합을 구한 뒤 각 선택지의 비율로 확률이 정해진다. 앞의 (0.4, 0.3, 0.3)은 (4, 3, 3)으로 표현되고, 4+3+3=10이므로 3/10=0.3이 자연스럽게 나온다. 상자 안에 파란 물건 10개, 빨간 물건 5개가 있으면 [(파랑, 10), (빨강, 5)]라고 그대로 적으면 되니, 사람이 더 직관적으로, 그래서 더 정확하게 쓰게 된다.

흥미롭게도 이 API는 random_u64()만 있을 때보다 random_between(l, h)이 있을 때 구현이 훨씬 쉬워진다. 가중치가 15+12+3=30이라면 random_between(0, 29)로 숫자 하나를 뽑고, [0,14]이면 첫째, [15,26]이면 둘째, [27,29]이면 셋째로 매핑하면 끝이다. 정수 가중치를 구간의 부분집합으로 펼친 뒤 그 구간에서 균등하게 뽑는 것이다. 이렇게 보면 random_u64()가 지나치게 저수준이라는 또 하나의 방증이 된다. random_u64()와 random_between()을 없애자는 것이 아니라, random_choice()를 기본으로 두면 '성공의 구덩이'에 자연스럽게 빠지게 된다는 이야기다.

숫자가 아니라 확률 변수를 다루고 있다

더 미묘한 교훈은 자신이 어떤 영역에서 사고하고 있는지 인식하는 문제다. 위 예시에서 우리가 다루는 대상은 사실 숫자가 아니라 확률 변수(X, Y, Z로 쓰는 그것)다. 확률 변수에도 덧셈과 뺄셈, 지수 같은 대수 연산이 있지만, 이 연산들은 바탕이 되는 분포와 기댓값에 영향을 준다. 특히 비선형 연산은 기댓값을 보존하지 않는다. 즉 E[f(X)]가 f(E[X])와 같으리라는 보장이 없다. 모듈로 사례에서 벌어진 일이 정확히 이것이다. 우리는 뽑힌 값 x의 어떤 성질을 지키려 했다고 착각하기 쉽지만, 실제로 보존해야 했던 것은 random_u64() 함수 자체의 분포였다.

이 깨달음은 추상적 조언에 그치지 않는다. 글쓴이의 팀은 운영체제 전체를 대상으로 하는 결정론적 퍼저인 Antithesis 플랫폼을 사용한다. 퍼저에서는 코드 커버리지가 길잡이가 된다. 어떤 경로를 퍼저가 찾지 못했다면 그 코드는 테스트되지 않은 것이다. 예컨대 업로드 함수가 '작은' 경우와 '큰' 경우를 다르게 처리한다면 두 경로를 모두 밟게 해야 하는데, 이때 분포를 직접 표현하는 방식이 유용하다. 테스트에서 업로드의 약 7/8을 작은 데이터, 약 1/8을 큰 데이터(세 가지 크기)로 두는 식이다. 이 숫자를 조절하며 상태 공간을 탐색할 수 있고, 조건에 따라 choice.push()로 선택지를 추가해 탐색을 유도할 수도 있다.

결론적으로 이 팀은 최근 코드 리뷰에서 슬그머니 끼어든 random_u64() 호출을 모두 가중치 기반 random_choice()로 걷어냈다. 실무에서 필요했던 것은 언제나 이산 가중 선택뿐이었기 때문이다. 한국의 개발자에게 이 사례가 주는 실천적 교훈은 분명하다. 익숙한 '난수 뽑아 나머지 연산' 코드를 마주칠 때, 먼저 원하는 분포를 그대로 표현할 수 있는지 자문해 보는 것이다. 그렇게 할 수 있다면 대개 더 정확하고, 더 다루기 쉬운 도구를 손에 쥐게 된다.

SOURCE · HACKER NEWS
원문 전체 보기 → https://ersc.io/blog/when-random-isnt-random-enough
SHARE
NEXT · CHOOSE

변화를 읽었다면,
내가 만들 수익 구조를 고릅니다.

정보를 더 모으는 데서 멈추지 않고, 광고·외주·판매·중개·구독 중 내 상황에 맞는 출발점을 정해보세요.

21가지 수익 구조 살펴보기 →
처리 중...