당신의 점심 룰렛이 공정하지 않은 이유

"이상하게 점심 룰렛 돌리면 A랑 B가 맨날 같이 걸리는 것 같은데..."
회사에서 점심 메뉴 룰렛을 몇 번 돌리다 보니, 슬랙에 이런 의문이 올라왔다. 처음엔 "그냥 운이겠지"라고 넘겼는데, 몇 번 더 돌려보니 정말 특정 사람들끼리 자주 뭉쳐 나온다. 셔플 알고리즘 자체에 문제가 있는 건 아닐까?
궁금해서 코드를 뜯어봤다. 범인은 array.sort(() => Math.random() - 0.5). 간단해 보이지만, 이 한 줄이 편향의 원인이었다.
Mike Bostock의 Will It Shuffle?에서 영감을 받아, 본 글에서는 D3.js와 React로 셔플 알고리즘의 편향을 직접 시각화해본다. 잘못된 셔플이 왜 문제인지, 그리고 Fisher-Yates가 왜 공정한지 눈으로 확인해보자.
셔플의 공정성
완벽한 셔플이라면 모든 요소가 모든 위치에 동일한 확률로 배치되어야 한다. 개 요소가 있다면 특정 요소가 특정 위치에 올 확률은 정확히 이다.
이 조건을 만족하지 못하면 편향(Bias)이 있다고 한다. 예를 들어 이면, A가 0/1/2번째에 있을 확률이 모두 이어야 한다. Matrix Diagram에서는 대각선이 진할수록 '원래 자리 근처에 남는 경향'이 강하다는 뜻이다. 반대로 전체가 고르게 회색이면 각 위치 확률이 거의 균일하다는 뜻이다. 특정 행/열이 진하면 특정 원소/위치로 쏠림이 있는 것이다. 이 가이드만 기억하고 아래 시각화를 보면 패턴을 훨씬 빨리 읽을 수 있다.
시각화: Matrix Diagram
아래 시각화는 셔플을 수천 번 돌리고 결과를 집계한다. 행은 원래 위치, 열은 셔플 후 위치다. 균일한 회색이면 공정하고, 패턴이 보이면 편향이 있다.
드롭다운에서 알고리즘을 바꿔가며 비교해보자. 어떤 알고리즘이 공정하고, 어떤 알고리즘이 편향되어 있는지 눈으로 확인할 수 있다.
알고리즘
Knuth Shuffle이라고도 불리며, 편향이 없는 완벽한 셔플 알고리즘입니다.
1. Random Comparator (나쁜 예)
가장 흔한 실수는 array.sort(() => Math.random() - 0.5) 코드다.
// ❌ 쓰지 말자
function randomComparatorShuffle(array) {
return array.sort(() => Math.random() - 0.5);
}시각화에서 Random Comparator를 선택해보자. 가장자리에 얼룩이 묻은 듯한 불규칙한 패턴이 나타난다. 특히 배열의 앞부분과 끝부분 요소들이 특정 위치로 쏠리는 경향을 볼 수 있다.
왜 실패하는가
sort는 일관된 비교 함수(특히 추이성)를 전제로 동작한다.
그런데 () => Math.random() - 0.5는 비교할 때마다 결과가 바뀌어, , 인데도 같은 모순(비추이성)이 생길 수 있다.
추이성이 깨지면 정렬 과정이 구현(엔진/알고리즘)에 따라 달라져서 편향된 분포가 나온다.
서로 다른 세 원소 에 대해, 비교 결과가 매번 독립적으로(각각 확률 ) 정해지는 랜덤 comparator를 쓰면 추이성이 깨지는(3-사이클이 생기는) 확률이 이다.
- 모델링:
세 쌍 , , 에 대해 비교 결과를 각각 하나의 방향으로 본다. 예를 들어 이면 로 둔다.
Math.random()을 비교할 때마다 호출하면(정렬 과정에서 비교는 여러 번 발생) 이 세 결과를 단순화하여 서로 독립인 동전 던지기처럼 볼 수 있다. - 경우의 수:
세 쌍의 방향은 각 쌍마다 2가지이므로 전체 경우는 가지다.
- 추이성 ↔ 사이클 부재:
추이성은 “이고 이면 반드시 ”를 뜻한다. 이는 곧 방향 그래프에 3-사이클이 없어야 한다는 것과 동치다.
- 사이클인 경우는 2가지:
8가지 중 3-사이클이 생기는 경우는 다음 두 가지뿐이다:
- , , 2) , , . 따라서 사이클 확률은 이다.
- 결론:
위 확률이 0이 아니므로, 이 비교 함수는 “항상 추이적”일 수 없다. 즉 정렬이 전제하는 수학적 조건(추이성)을 구조적으로 위반한다.
(보충) 8가지 경우를 표로 한 번에 보기
| 경우 | vs | vs | vs | 판정 |
|---|---|---|---|---|
| 1 | 추이적 | |||
| 2 | 사이클 () | |||
| 3 | 추이적 | |||
| 4 | 추이적 | |||
| 5 | 추이적 | |||
| 6 | 추이적 | |||
| 7 | 사이클 () | |||
| 8 | 추이적 |
직관: 추이적이면 '한 방향', 비추이적이면 '사이클'
직접 실험: 랜덤 토너먼트에서 사이클 비율(≈ 0.25) 확인
V8(Chrome/Node.js)은 TimSort를 쓴다. TimSort는 배열을 스캔하며 이미 정렬된 구간(run)을 찾고, 이 run들을 병합(merge)하는 방식으로 동작한다. 랜덤 comparator를 쓰면 이 과정이 교란된다:
- 앞부분 요소들: 첫 run에 묶여 비교 횟수가 적음 → 이동 기회가 적어 원래 위치 근처에 남기 쉬움
- 뒷부분 요소들: 마지막 merge에서 상대적으로 덜 영향받음 → 역시 원래 위치에 가까이 머무름
- 중간 요소들: 여러 번의 merge를 거치며 더 많이 섞임 → 상대적으로 더 균일하게 분산
요약하면, 비교 횟수가 적으면 이동 확률이 낮아지고, 원래 위치 근처에 남는다. 그래서 누적하면 가장자리에 얼룩처럼 보이는 패턴이 나타난다.
결국 sort(() => Math.random() - 0.5)는 겉보기엔 섞이는 것 같아도, 공정한 셔플이 아니다.
2. Naive Swap (나쁜 예)
또 다른 실수는 모든 요소를 임의의 위치와 교환하는 방식이다.
// ❌ 미묘하게 편향된다
function naiveSwapShuffle(array) {
for (let i = 0; i < array.length; i++) {
const j = randomInt(0, array.length - 1); // 전체 배열에서 아무 위치나
[array[i], array[j]] = [array[j], array[i]]; // swap
}
}겉보기에는 Fisher–Yates와 비슷하다. "모든 i에 대해 임의의 j를 골라 swap한다"는 말이 그럴싸해 보여서 더 위험하다.
핵심 문제는 무작위로 뽑는 단위(선택 경로)가 원하는 결과(순열)와 1:1 대응이 아니라는 점이다. 여러 경로가 같은 순열로 압축되는데, 그 압축 비율이 순열마다 다르기 때문에 분포가 찌그러진다.
이런 편향이 누적되어 행렬 다이어그램에서 대각선(원래 위치 근처)이 상대적으로 두드러지게 된다.
1) ‘경로 수’로 보는 1줄짜리 증명
이 알고리즘은 총 번의 선택을 한다. 매번 를 0부터 중 하나로 고르니 가능한 선택 경로의 수는 정확히 개다. 그리고 각 경로는 확률이 동일하게 이다.
따라서 어떤 순열 의 확률은 다음처럼 쓸 수 있다:
즉 이다.
여기서 는 “Naive Swap의 번 선택이 로 끝나는 경우의 수(경로 수)”다.
만약 완벽하게 공정(균일)하려면 모든 순열에 대해 이어야 한다.
즉 모든 순열이 가져야 하는 경로 수는
로 “동일”해야 한다.
그런데 이 로 나누어떨어지지 않는다면(예: , ), 위 값은 정수가 될 수 없고,
정수여야 하는 ‘경로 수’가 모두 같아질 수 없으므로 균일 분포는 불가능하다.
Naive Swap은 “경로(선택 시퀀스) → 순열”이 1:1이 아니라 여러 경로가 같은 순열로 압축된다.
그런데 그 압축되는 “경로 개수”가 순열마다 같지 않아서 확률이 달라진다.
2) 직관: ‘원래 자리로 돌아올 기회’가 너무 많다
Naive Swap은 매 단계에서 아무 j나 고르기 때문에, 어떤 원소는
- 자기 자신과 swap해서 그대로 남기도 하고(확률 ),
- 다른 원소와 바뀌었다가도 다음 단계들에서 다시 되돌아올 기회가 계속 생긴다.
이 “되돌아오는 기회”가 전반적으로 많아져서, 누적하면 대각선(원래 위치 근처)이 상대적으로 더 진해지는 경향이 나타난다.
3) 직접 확인: 작은 N에서 ‘정확한 확률’ 전수조사
아래는 작은 에 대해 Naive Swap의 모든 선택 경로(개)를 전부 열거해,
각 순열이 나올 확률을 “정확히” 계산한 결과다. (기대값은 균일 분포일 때의 )
| 순열 | ratio |
|---|---|
| [1 2 0 4 3] | 1.805 |
| [1 0 3 4 2] | 1.805 |
| [1 2 3 4 0] | 1.613 |
| [1 2 3 0 4] | 1.613 |
| [0 2 3 4 1] | 1.613 |
| [2 0 1 4 3] | 1.421 |
| [1 0 4 2 3] | 1.421 |
| [1 3 4 0 2] | 1.344 |
| [2 3 0 4 1] | 1.344 |
| [2 0 3 4 1] | 1.267 |
| 순열 | ratio |
|---|---|
| [4 0 1 2 3] | 0.614 |
| [4 3 2 1 0] | 0.691 |
| [4 1 2 0 3] | 0.691 |
| [4 1 0 3 2] | 0.691 |
| [4 0 2 3 1] | 0.691 |
| [4 0 2 1 3] | 0.691 |
| [3 4 2 0 1] | 0.730 |
| [3 4 1 2 0] | 0.730 |
| [4 1 2 3 0] | 0.768 |
| [4 1 3 2 0] | 0.768 |
(보충) ‘나눠떨어지면 괜찮나?’
위 논증은 “이 로 나누어떨어지지 않으면 반드시 편향”임을 보인다.
하지만 반대로 “나눠떨어지면 항상 공정”을 뜻하는 건 아니다.
공정하려면 각 순열로 가는 경로 수가 정확히 같아야 하는데, Naive Swap은 그 구조 자체가 대칭을 충분히 보장하지 못한다. 그래서 실제로 대부분의 N에서 분포가 균일해지지 않는다.
3. Fisher-Yates Shuffle (좋은 예)
올바른 방법은 Fisher-Yates(또는 Knuth) 셔플이다.
// ✅ 공정한 셔플
function fisherYatesShuffle(array) {
for (let i = array.length - 1; i > 0; i--) {
const j = randomInt(0, i); // 아직 섞지 않은 구간에서만 선택
[array[i], array[j]] = [array[j], array[i]]; // swap
}
}이 알고리즘은 수학적으로 완벽하게 공정하다. 시각화에서 Fisher-Yates를 선택하면 전체가 균일한 회색 노이즈로 채워진다.
Fisher-Yates의 수학적 증명
Fisher-Yates가 공정한 이유를 귀납법으로 증명해보자.
개 요소에 Fisher-Yates를 적용하면 개 순열이 각각 확률로 나온다.
알고리즘
- 번째 단계 ():
- 인덱스 부터 중 하나를 무작위로 선택 (확률 )
- 선택한 인덱스와 위치의 요소를 교환
- 기저: 이면 순열은 1개뿐이고 확률은 이다.
- 귀납 가정: 일 때 모든 개 순열이 각각 확률로 나온다고 가정하자.
- 귀납 단계: 일 때, 마지막 위치에 올 요소를 개 중 하나로 고르며 각 요소가 선택될 확률은 다. 나머지 개는 귀납 가정에 의해 각 순열이 확률로 나온다.
따라서 특정 순열이 나올 확률은:
즉 개 순열 모두 같은 확률로 나온다.
개별 위치의 확률
특정 요소 가 위치 에 올 확률은 이다.
가 에 고정되면 나머지 개의 배치는 가지다. 각 순열 확률이 이므로:
Naive Swap이 실패하는 이유
Naive Swap은 번의 선택에서 매번 개 중 하나를 고른다.
- 선택 경로의 수:
- 가능한 순열의 수:
이 로 나누어떨어지지 않는다. 예를 들어 이고 인데, 27은 6으로 나누어떨어지지 않는다. 그래서 어떤 순열은 , 어떤 순열은 확률을 갖게 된다.
Fisher-Yates는 각 단계에서 아직 배치되지 않은 요소들 중에서만 고르기 때문에 정확히 개 경로가 생기고, 각 경로가 하나의 순열에 1:1로 대응한다. 그래서 공정하다.
결론
셔플 알고리즘의 작은 차이가 결과 분포에 큰 영향을 준다. 게임, 암호화, 샘플링처럼 무작위성이 중요한 곳에서는 반드시 검증된 알고리즘을 써야 한다.
핵심 규칙:
-
array.sort(() => Math.random() - 0.5)쓰지 말자. 편향이 생긴다. - Fisher-Yates를 쓰자. 대부분의 언어와 라이브러리에 이미 구현되어 있다.
실전에서 쓰기
직접 구현할 필요 없이, 검증된 라이브러리를 사용하면 된다.
// JavaScript: Lodash 사용
import { shuffle } from 'lodash';
const shuffled = shuffle([1, 2, 3, 4, 5]);# Python: 내장 random 모듈 (in-place Fisher-Yates)
import random
arr = [1, 2, 3, 4, 5]
random.shuffle(arr)// Go: math/rand 패키지
import "math/rand"
rand.Shuffle(len(arr), func(i, j int) {
arr[i], arr[j] = arr[j], arr[i]
})