본문으로 건너뛰기
모든 글
작동 원리

돌리기 전에 이미 정해집니다

룰렛 애니메이션이 시작되기 전에 당첨자가 추첨되는 이유와 그에 따른 이점을 설명합니다.

돌리기 버튼을 누르면 룰렛이 10초 동안 회전합니다. 겉보기에는 감속 구간, 마지막 반 바퀴, 혹은 포인터가 구분선을 하나 더 넘느냐 마느냐 하는 그 10초의 순간 어딘가에서 결과가 결정되는 것처럼 보입니다.

하지만 그렇지 않습니다. 당첨자는 단 몇 마이크로초 만에 먼저 추첨되며, 애니메이션은 그 결과를 가리키도록 조준될 뿐입니다.

왜 이 방식을 사용할까요?

흔히 생각할 수 있는 대안은 룰렛을 먼저 돌린 다음 멈춘 위치를 읽어내는 것입니다. 언뜻 물리 법칙에 따라 결정되므로 아무도 개입하지 않아 더 공정해 보이지만, 공정성에 대한 모든 의문이 애니메이션 자체에 대한 의문으로 번지기 때문에 오히려 신뢰를 증명하기가 훨씬 더 어렵습니다.

더 세게 튕기면 결과가 달라질까요? 회전 중간에 클릭하여 일찍 멈추면 결과에 편향이 생길까요? 애니메이션을 전혀 보지 않는 prefers-reduced-motion(동작 줄이기) 설정 사용자는 어떻게 처리해야 할까요?

먼저 추첨하면 이 세 가지 질문에 대한 답이 모두 같아집니다. 추첨이 이미 끝났으므로 결과는 변하지 않습니다. 세게 튕기면 바퀴가 더 많이 돌 뿐이고, 일찍 클릭하면 이동 시간만 단축됩니다. 동작 줄이기 모드는 이동 과정을 완전히 건너뛰고 즉시 결과를 알려줍니다. 세 가지 경우를 각각 복잡하게 처리할 필요 없이, 단 하나의 코드로 일관되게 동작합니다.

추첨은 어떻게 이루어질까요?

난수 생성에는 Math.random 대신 브라우저 자체의 암호화 생성기인 crypto.getRandomValues를 사용합니다.

균등한 난수를 얻으려면 겉보기보다 한 단계가 더 필요합니다. 단순하게 draw % count를 사용하면 count가 2³²로 나누어떨어지지 않을 때마다 편향이 발생합니다. 약 40억 개의 경우의 수 중 앞쪽 몇 개 항목이 1회씩 더 많은 기회를 얻기 때문입니다. 미세한 차이지만 저울에 손가락을 얹는 것과 다름없으며, 공정성을 내세우는 룰렛에는 결코 있어서는 안 될 일입니다. 따라서 나머지 잉여 구간에 떨어지는 추첨값은 버리고 다시 추첨합니다.

const limit = Math.floor(2 ** 32 / max) * max;

let draw = crypto.getRandomValues(new Uint32Array(1))[0];
while (draw >= limit) {
  draw = crypto.getRandomValues(new Uint32Array(1))[0];
}

return draw % max;

이로 인해 가끔 재추첨이 발생하지만, 정확히 균등한 결과를 보장할 수 있습니다. 동일한 코드가 공정성 안내 페이지에도 공개되어 있으며, 단순한 예시가 아니라 실제로 실행되는 코드입니다.

가중치는 확률일 뿐, 편애가 아닙니다

항목에 가중치 3을 부여하면 추첨 지분을 1개가 아닌 3개를 가져갑니다. 룰렛판에서는 해당 부채꼴이 더 넓게 그려지며, 목록에는 해당 가중치로 인한 실제 백분율이 표시되므로 직접 계산할 필요가 없습니다.

동일한 텍스트의 항목이 2개 입력되면 그대로 2개 항목으로 유지됩니다. 각 항목은 고유한 당첨 확률을 유지하며, 목록은 항목을 임의로 합치는 대신 중복 횟수를 표시합니다. 사용자에게 알리지 않고 당첨 확률을 절반으로 줄여버리는 것은 불필요한 간섭이기 때문입니다.

애니메이션의 진짜 역할

결과가 미리 정해진 애니메이션이라고 해서 가짜라는 뜻은 아닙니다. 룰렛은 실제로 당첨된 부채꼴 위치까지 회전하고, 포인터 소리는 지나치는 구분선에 맞춰 정확히 발생합니다. '아슬아슬한 연출' 옵션을 켜면 무작위 칸이 아닌 당첨 항목 바로 옆 칸에서 멈칫거리다가, 회전이 시작되기 전에 이미 결정된 당첨 항목으로 이동합니다.

긴장감은 진짜입니다. 다만 결과가 보이는 것보다 더 일찍 결정되었을 뿐입니다.