JavaScript에서 Math.random()과 MAX_SAFE_INTEGER 곱셈 시 홀수만 나오는 원인과 해결 방법
Problem
JavaScript에서 매우 큰 무작위 정수를 생성하기 위해 Math.floor(Math.random() * Number.MAX_SAFE_INTEGER)를 사용하는 경우가 있습니다. 하지만 이 코드를 실행해보면 결과값이 100% 확률로 홀수만 나오는 기이한 현상이 발생합니다.
또한 Number.MAX_SAFE_INTEGER - 1을 곱하면 75% 확률로 홀수가 나오고, Math.ceil을 사용하면 정상적으로 50:50 비율이 나오는 등 일관성 없는 동작을 보입니다. 이러한 문제는 개발자가 의도한 균등한 난수 분포를 망가뜨리며, 특히 확률 기반의 로직이나 암호화폐, 게임 등의 도메인에서 치명적인 버그를 유발할 수 있습니다.
Background
이 현상을 이해하려면 JavaScript의 부동소수점(Floating-point) 처리 방식과 V8 엔진(Chrome, Node.js 등)의 Math.random() 구현 방식을 알아야 합니다. JavaScript의 모든 숫자는 IEEE 754 배정밀도(64비트) 부동소수점으로 저장되며, 안전하게 표현할 수 있는 최대 정수(Number.MAX_SAFE_INTEGER)는 $2^{53} - 1$입니다.
과거 V8 엔진은 성능 최적화를 위해 Math.random()을 생성할 때 53비트가 아닌 52비트의 난수만 사용했습니다. 부동소수점의 지수(exponent) 부분을 조작하여 곱셈 연산 없이 빠르게 $[1, 2)$ 범위의 난수를 만든 뒤 1을 빼는 방식을 취했기 때문입니다.
수학적으로 $(2^{53}-1)x = 2^{53}x - x$ 가 됩니다. 여기서 $2^{53}x$는 항상 짝수입니다. 이 값이 정확한 부동소수점 값으로 반올림되어 짝수가 되려면 $x$가 $2^{53}x$의 ULP(Unit in the Last Place, 최소 정밀도 단위)보다 작아야 하는데, 52비트 난수 체계에서는 이 조건이 절대 충족되지 않습니다. 따라서 이를 Math.floor로 내림 처리하면 항상 홀수가 도출되는 수학적 필연성이 발생하게 됩니다.
Solution
이 문제를 해결하고 균등한 짝수/홀수 분포를 가진 큰 난수를 생성하는 방법은 크게 세 가지가 있습니다.
1. Math.random()에 2를 먼저 곱하는 우회 방법
가장 간단한 해결책은 Math.random()의 결과에 먼저 2를 곱하여 부동소수점의 스케일을 조정하는 것입니다. 이렇게 하면 경계값이 확장되어 짝수와 홀수의 비율이 50:50으로 맞춰집니다.
// 기존 문제 코드: 항상 홀수 반환 (과거 V8 엔진 기준)
// const badRandom = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);
// 해결 코드: Math.random()에 2를 먼저 곱함
const goodRandom = Math.floor(Number.MAX_SAFE_INTEGER * (2 * Math.random()));
console.log(goodRandom);
2. 안전한 최대 정수 한도 낮추기
Math.floor를 사용하면서 50:50의 비율을 보장받으려면, 곱하는 최대값을 $2^{53}-1$이 아닌 $2^{52}$ 이하로 낮춰야 합니다. 52비트 난수를 사용하는 엔진에서도 정밀도 손실 없이 안전하게 계산할 수 있는 범위입니다.
// 2^52 (4503599627370496) 이하의 값을 곱합니다.
const MAX_SAFE_RANDOM_LIMIT = Math.pow(2, 52);
// 짝수와 홀수가 균등하게 50:50으로 발생합니다.
const balancedRandom = Math.floor(Math.random() * MAX_SAFE_RANDOM_LIMIT);
console.log(balancedRandom);
3. Web Crypto API 사용하기 (권장)
사실 Math.random()은 ECMAScript 스펙상 난수의 품질이나 비트 수를 엄격하게 규정하지 않으므로, 엔진마다(Firefox, Safari, Chrome 등) 동작이 다를 수 있습니다. 크고 안전한 난수가 필요하다면 반드시 crypto.getRandomValues()를 사용해야 합니다.
function getSecureRandomBigInt() {
// 64비트(8바이트) 크기의 배열 생성
const randomBuffer = new BigUint64Array(1);
// 암호학적으로 안전한 난수로 배열을 채움
crypto.getRandomValues(randomBuffer);
// MAX_SAFE_INTEGER 범위 내로 값을 조정 (Modulo 연산)
const maxSafe = BigInt(Number.MAX_SAFE_INTEGER);
const secureRandom = randomBuffer[0] % maxSafe;
return Number(secureRandom);
}
console.log(getSecureRandomBigInt());
Deep Dive
흥미롭게도 최근 버전의 크롬(Chrome 137 이상 등 최신 V8 엔진)에서는 Math.random()의 구현이 개선되어 53비트의 난수를 사용하도록 패치되었습니다. 따라서 최신 브라우저에서는 원본 질문의 ‘100% 홀수’ 버그가 더 이상 재현되지 않을 수 있습니다.
하지만 이는 JavaScript의 Math.random()이 엔진의 구현에 철저히 의존한다는 사실을 방증합니다. 프로덕션 환경에서는 사용자가 어떤 브라우저나 구버전의 Node.js를 사용할지 알 수 없으므로, Math.random()을 사용하여 32비트($2^{32}$) 이상의 큰 정수 난수를 생성하는 것은 지양해야 합니다. 보안이나 균등한 분포가 중요한 로직(예: 토큰 생성, 확률형 아이템 추첨)에서는 반드시 Web Crypto API를 도입하는 것이 베스트 프랙티스입니다.
Conclusion
Math.random() * Number.MAX_SAFE_INTEGER가 홀수만 반환하던 현상은 V8 엔진의 52비트 난수 최적화와 부동소수점 정밀도 한계가 맞물려 발생한 문제입니다. 이 문제를 우회하려면 난수에 2를 먼저 곱하거나 한계값을 $2^{52}$로 낮출 수 있지만, 가장 근본적이고 안전한 해결책은 crypto.getRandomValues()를 활용하여 암호학적으로 안전한 난수를 생성하는 것입니다.