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

#algorithm#visualization#d3#random
당신의 점심 룰렛이 공정하지 않은 이유

"이상하게 점심 룰렛 돌리면 A랑 B가 맨날 같이 걸리는 것 같은데..."

회사에서 점심 메뉴 룰렛을 몇 번 돌리다 보니, 슬랙에 이런 의문이 올라왔다. 처음엔 "그냥 운이겠지"라고 넘겼는데, 몇 번 더 돌려보니 정말 특정 사람들끼리 자주 뭉쳐 나온다. 셔플 알고리즘 자체에 문제가 있는 건 아닐까?

궁금해서 코드를 뜯어봤다. 범인은 array.sort(() => Math.random() - 0.5). 간단해 보이지만, 이 한 줄이 편향의 원인이었다.

Mike Bostock의 Will It Shuffle?에서 영감을 받아, 본 글에서는 D3.js와 React로 셔플 알고리즘의 편향을 직접 시각화해본다. 잘못된 셔플이 왜 문제인지, 그리고 Fisher-Yates가 왜 공정한지 눈으로 확인해보자.

셔플의 공정성

완벽한 셔플이라면 모든 요소가 모든 위치에 동일한 확률로 배치되어야 한다. NN개 요소가 있다면 특정 요소가 특정 위치에 올 확률은 정확히 1N\frac{1}{N}이다.

이 조건을 만족하지 못하면 편향(Bias)이 있다고 한다. 예를 들어 N=3N=3이면, A가 0/1/2번째에 있을 확률이 모두 13\frac{1}{3}이어야 한다. Matrix Diagram에서는 대각선이 진할수록 '원래 자리 근처에 남는 경향'이 강하다는 뜻이다. 반대로 전체가 고르게 회색이면 각 위치 확률이 거의 균일하다는 뜻이다. 특정 행/열이 진하면 특정 원소/위치로 쏠림이 있는 것이다. 이 가이드만 기억하고 아래 시각화를 보면 패턴을 훨씬 빨리 읽을 수 있다.

시각화: Matrix Diagram

아래 시각화는 셔플을 수천 번 돌리고 결과를 집계한다. 행은 원래 위치, 열은 셔플 후 위치다. 균일한 회색이면 공정하고, 패턴이 보이면 편향이 있다.

드롭다운에서 알고리즘을 바꿔가며 비교해보자. 어떤 알고리즘이 공정하고, 어떤 알고리즘이 편향되어 있는지 눈으로 확인할 수 있다.

알고리즘

Knuth Shuffle이라고도 불리며, 편향이 없는 완벽한 셔플 알고리즘입니다.

시뮬레이션을 시작하면 매트릭스가 채워집니다.
자주 등장 (Positive Bias)기대치 (Unbiased)드물게 등장 (Negative Bias)
총 시도 횟수: 0대기 중

1. Random Comparator (나쁜 예)

시뮬레이션을 시작하면 매트릭스가 채워집니다.
자주 등장 (Positive Bias)기대치 (Unbiased)드물게 등장 (Negative Bias)

가장 흔한 실수는 array.sort(() => Math.random() - 0.5) 코드다.

// ❌ 쓰지 말자
function randomComparatorShuffle(array) {
  return array.sort(() => Math.random() - 0.5);
}

시각화에서 Random Comparator를 선택해보자. 가장자리에 얼룩이 묻은 듯한 불규칙한 패턴이 나타난다. 특히 배열의 앞부분과 끝부분 요소들이 특정 위치로 쏠리는 경향을 볼 수 있다.

왜 실패하는가

sort일관된 비교 함수(특히 추이성)를 전제로 동작한다. 그런데 () => Math.random() - 0.5는 비교할 때마다 결과가 바뀌어, a<ba \lt b, b<cb \lt c인데도 c<ac \lt a 같은 모순(비추이성)이 생길 수 있다. 추이성이 깨지면 정렬 과정이 구현(엔진/알고리즘)에 따라 달라져서 편향된 분포가 나온다.

주장 (3원소만으로도 추이성 위반이 발생한다)

서로 다른 세 원소 a,b,ca,b,c에 대해, 비교 결과가 매번 독립적으로(각각 확률 12\frac{1}{2}) 정해지는 랜덤 comparator를 쓰면 추이성이 깨지는(3-사이클이 생기는) 확률이 14\frac{1}{4}이다.

증명
  1. 모델링:

    세 쌍 (a,b)(a,b), (b,c)(b,c), (a,c)(a,c)에 대해 비교 결과를 각각 하나의 방향으로 본다. 예를 들어 a<ba \lt b이면 aba \rightarrow b로 둔다. Math.random()을 비교할 때마다 호출하면(정렬 과정에서 비교는 여러 번 발생) 이 세 결과를 단순화하여 서로 독립인 동전 던지기처럼 볼 수 있다.

  2. 경우의 수:

    세 쌍의 방향은 각 쌍마다 2가지이므로 전체 경우는 23=82^3 = 8가지다.

  3. 추이성 ↔ 사이클 부재:

    추이성은 “aba \rightarrow b이고 bcb \rightarrow c이면 반드시 aca \rightarrow c”를 뜻한다. 이는 곧 방향 그래프에 3-사이클이 없어야 한다는 것과 동치다.

  4. 사이클인 경우는 2가지:

    8가지 중 3-사이클이 생기는 경우는 다음 두 가지뿐이다:

    1. aba \rightarrow b, bcb \rightarrow c, cac \rightarrow a 2) aca \rightarrow c, cbc \rightarrow b, bab \rightarrow a. 따라서 사이클 확률은 28=14\frac{2}{8}=\frac{1}{4}이다.
  5. 결론:

    위 확률이 0이 아니므로, 이 비교 함수는 “항상 추이적”일 수 없다. 즉 정렬이 전제하는 수학적 조건(추이성)을 구조적으로 위반한다.

(보충) 8가지 경우를 표로 한 번에 보기

경우aa vs bbbb vs ccaa vs cc판정
1a<ba \lt bb<cb \lt ca<ca \lt c추이적
2a<ba \lt bb<cb \lt ca>ca \gt c사이클 (a<b<c<aa \lt b \lt c \lt a)
3a<ba \lt bb>cb \gt ca<ca \lt c추이적
4a<ba \lt bb>cb \gt ca>ca \gt c추이적
5a>ba \gt bb<cb \lt ca<ca \lt c추이적
6a>ba \gt bb<cb \lt ca>ca \gt c추이적
7a>ba \gt bb>cb \gt ca<ca \lt c사이클 (a>b>c>aa \gt b \gt c \gt a)
8a>ba \gt bb>cb \gt ca>ca \gt c추이적

직관: 추이적이면 '한 방향', 비추이적이면 '사이클'

직접 실험: 랜덤 토너먼트에서 사이클 비율(≈ 0.25) 확인

a<ba \lt b , b<cb \lt c , a<ca \lt c
→ 사이클이 없어 전순서로 일관되게 정렬 가능
누적 통계
총 샘플: 1
사이클: 0
비율: 0.000 (이론값 0.250)
각 버튼은 $(a,b)$, $(b,c)$, $(a,c)$ 비교 방향을 무작위로 정해 토너먼트를 만든다. 세 비교는 독립인 동전 던지기처럼 동작하므로, 8가지 중 2가지만 사이클 → 이론적으로 $\frac14$가 된다.

V8(Chrome/Node.js)은 TimSort를 쓴다. TimSort는 배열을 스캔하며 이미 정렬된 구간(run)을 찾고, 이 run들을 병합(merge)하는 방식으로 동작한다. 랜덤 comparator를 쓰면 이 과정이 교란된다:

  • 앞부분 요소들: 첫 run에 묶여 비교 횟수가 적음 → 이동 기회가 적어 원래 위치 근처에 남기 쉬움
  • 뒷부분 요소들: 마지막 merge에서 상대적으로 덜 영향받음 → 역시 원래 위치에 가까이 머무름
  • 중간 요소들: 여러 번의 merge를 거치며 더 많이 섞임 → 상대적으로 더 균일하게 분산

요약하면, 비교 횟수가 적으면 이동 확률이 낮아지고, 원래 위치 근처에 남는다. 그래서 누적하면 가장자리에 얼룩처럼 보이는 패턴이 나타난다.

결국 sort(() => Math.random() - 0.5)는 겉보기엔 섞이는 것 같아도, 공정한 셔플이 아니다.

2. Naive Swap (나쁜 예)

시뮬레이션을 시작하면 매트릭스가 채워집니다.
자주 등장 (Positive Bias)기대치 (Unbiased)드물게 등장 (Negative Bias)

또 다른 실수는 모든 요소를 임의의 위치와 교환하는 방식이다.

// ❌ 미묘하게 편향된다
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줄짜리 증명

이 알고리즘은 총 NN번의 선택을 한다. 매번 jj를 0부터 N1N-1 중 하나로 고르니 가능한 선택 경로의 수는 정확히 NNN^N개다. 그리고 각 경로는 확률이 동일하게 1NN\frac{1}{N^N}이다.

따라서 어떤 순열 π\pi의 확률은 다음처럼 쓸 수 있다:

P(π)=c(π)NNP(\pi) = \frac{c(\pi)}{N^N} 이다.

여기서 c(π)c(\pi)는 “Naive Swap의 NN번 선택이 π\pi로 끝나는 경우의 수(경로 수)”다.

만약 완벽하게 공정(균일)하려면 모든 순열에 대해 P(π)=1N!P(\pi) = \frac{1}{N!}이어야 한다.
즉 모든 순열이 가져야 하는 경로 수는

NNN!\frac{N^N}{N!}

로 “동일”해야 한다.

그런데 NNN^NN!N!로 나누어떨어지지 않는다면(예: 33=273^3=27, 3!=63!=6), 위 값은 정수가 될 수 없고,
정수여야 하는 ‘경로 수’가 모두 같아질 수 없으므로 균일 분포는 불가능하다.

핵심

Naive Swap은 “경로(선택 시퀀스) → 순열”이 1:1이 아니라 여러 경로가 같은 순열로 압축된다.
그런데 그 압축되는 “경로 개수”가 순열마다 같지 않아서 확률이 달라진다.

2) 직관: ‘원래 자리로 돌아올 기회’가 너무 많다

Naive Swap은 매 단계에서 아무 j나 고르기 때문에, 어떤 원소는

  • 자기 자신과 swap해서 그대로 남기도 하고(확률 1N\frac{1}{N}),
  • 다른 원소와 바뀌었다가도 다음 단계들에서 다시 되돌아올 기회가 계속 생긴다.

이 “되돌아오는 기회”가 전반적으로 많아져서, 누적하면 대각선(원래 위치 근처)이 상대적으로 더 진해지는 경향이 나타난다.

3) 직접 확인: 작은 N에서 ‘정확한 확률’ 전수조사

아래는 작은 NN에 대해 Naive Swap의 모든 선택 경로(NNN^N개)를 전부 열거해,
각 순열이 나올 확률을 “정확히” 계산한 결과다. (기대값은 균일 분포일 때의 1N!\frac{1}{N!})

Naive Swap: 작은 N에서 “정확한” 순열 확률(경로 전수조사)
확률 비율 히스토그램 (기대값 대비 \(P(\pi) / (1/N!)\))
min≈0.614 (lo≈0.55)max≈1.805 (hi≈1.86)
요약
- 경로 수: 5^5 = 3125
- 순열 수: 5! = 120
- 기대(균일) 확률: 1/5! ≈ 0.008333
- (참고) 기대 경로수/순열: 5^5/5! ≈ 26.042
- χ²(균일 대비): 155.2
가장 “자주” 나오는 순열 Top 10
순열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
가장 “드물게” 나오는 순열 Bottom 10
순열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
각 비교/교환 선택(경로)은 확률이 정확히 \(1/N^N\)로 동일합니다. 따라서 각 순열의 확률은 “그 순열로 가는 경로 수”에 의해 결정되고, 이 경로 수가 순열마다 다르면(대부분의 N에서) 편향이 생깁니다.

(보충) ‘나눠떨어지면 괜찮나?’

위 논증은 “NNN^NN!N!로 나누어떨어지지 않으면 반드시 편향”임을 보인다.
하지만 반대로 “나눠떨어지면 항상 공정”을 뜻하는 건 아니다.

공정하려면 각 순열로 가는 경로 수가 정확히 같아야 하는데, Naive Swap은 그 구조 자체가 대칭을 충분히 보장하지 못한다. 그래서 실제로 대부분의 N에서 분포가 균일해지지 않는다.

3. Fisher-Yates Shuffle (좋은 예)

시뮬레이션을 시작하면 매트릭스가 채워집니다.
자주 등장 (Positive Bias)기대치 (Unbiased)드물게 등장 (Negative Bias)

올바른 방법은 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의 공정성)

NN개 요소에 Fisher-Yates를 적용하면 N!N!개 순열이 각각 1N!\frac{1}{N!} 확률로 나온다.

알고리즘

  • kk번째 단계 (k=N,N1,,1k = N, N-1, \ldots, 1):
    • 인덱스 00부터 k1k-1 중 하나를 무작위로 선택 (확률 1k\frac{1}{k})
    • 선택한 인덱스와 k1k-1 위치의 요소를 교환
증명
  1. 기저: N=1N=1이면 순열은 1개뿐이고 확률은 11!=1\frac{1}{1!}=1이다.
  2. 귀납 가정: N=k1N=k-1일 때 모든 (k1)!(k-1)!개 순열이 각각 1(k1)!\frac{1}{(k-1)!} 확률로 나온다고 가정하자.
  3. 귀납 단계: N=kN=k일 때, 마지막 위치에 올 요소를 kk개 중 하나로 고르며 각 요소가 선택될 확률은 1k\frac{1}{k}다. 나머지 k1k-1개는 귀납 가정에 의해 각 순열이 1(k1)!\frac{1}{(k-1)!} 확률로 나온다.

따라서 특정 순열이 나올 확률은:

1k×1(k1)!=1k!\frac{1}{k} \times \frac{1}{(k-1)!} = \frac{1}{k!}

k!k!개 순열 모두 같은 확률로 나온다.

개별 위치의 확률

특정 요소 ee가 위치 jj에 올 확률은 1N\frac{1}{N}이다.

(보충) 위치별 확률 계산

eejj에 고정되면 나머지 N1N-1개의 배치는 (N1)!(N-1)!가지다. 각 순열 확률이 1N!\frac{1}{N!}이므로:

(N1)!×1N!=1N(N-1)! \times \frac{1}{N!} = \frac{1}{N}

Naive Swap이 실패하는 이유

Naive Swap은 NN번의 선택에서 매번 NN개 중 하나를 고른다.

  • 선택 경로의 수: NNN^N
  • 가능한 순열의 수: N!N!

NNN^NN!N!로 나누어떨어지지 않는다. 예를 들어 33=273^3 = 27이고 3!=63! = 6인데, 27은 6으로 나누어떨어지지 않는다. 그래서 어떤 순열은 527\frac{5}{27}, 어떤 순열은 427\frac{4}{27} 확률을 갖게 된다.

핵심

Fisher-Yates는 각 단계에서 아직 배치되지 않은 요소들 중에서만 고르기 때문에 정확히 N!N!개 경로가 생기고, 각 경로가 하나의 순열에 1:1로 대응한다. 그래서 공정하다.

결론

셔플 알고리즘의 작은 차이가 결과 분포에 큰 영향을 준다. 게임, 암호화, 샘플링처럼 무작위성이 중요한 곳에서는 반드시 검증된 알고리즘을 써야 한다.

핵심 규칙:

  1. array.sort(() => Math.random() - 0.5) 쓰지 말자. 편향이 생긴다.
  2. 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]
})
이 글이 도움이 되었나요?
다음 콘텐츠에 대한 피드백을 남겨주세요.
당신의 점심 룰렛이 공정하지 않은 이유 | PWNZ INTERACTIVES