안녕하세요. 서버 개발자로 현업에서 일하고 있는 유저입니다. 이번 추옵/어빌 사태와 관련하여, 어떻게 비직관적인 뽑기 알고리즘이 구현되었는지 개발자 관점에서 분석해보려고 합니다.

내용이 다소 전문적이기 때문에 이해하시기 어려울 수도 있습니다. 이해바랍니다.

아래에 작성된 모든 코드는 C++의 형태를 띄는 의사코드입니다. 즉 디테일이 틀려도 그러려니 해주시길 바란다는 의미입니다.

1. 태초에 "하나 뽑기" 알고리즘이 있었다.
환불이나 어빌 이전에도 큐브도 있고, 피그미, 아이템 드롭 등 랜덤 뽑기는 자주 사용되었기 때문에 널리 사용되는 뽑기 함수가 있었을 것입니다.
그 함수의 형태는 일반적으로 다음과 같은 형태를 띌 것입니다.
// weight : 가중치가 담긴 배열이며 길이는 length 입니다.
// weight에 기반하여 0~(length-1) 사이의 정수를 반환합니다.
int select_one(int* weight, int length) {
    // Implement
}

위 기능은 수학적으로 너무나 범용적인 알고리즘으로 Python의 Random 모듈에도 동일한 기능이 구현되어 있습니다.
https://docs.python.org/ko/3/library/random.html#random.choices

2. 기획 요구사항은 간단 명료했을 것이다.
일반적인 관점에서 기획 단계에서는 그 목적과 의도를 개발팀에 전달하지, 알고리즘을 전달하는 경우는 많지 않습니다. 알고리즘은 주로 개발자의 영역이기 때문입니다.
따라서 추옵/어빌의 기획 요구사항은 다음과 같은 단순한 형태일 것이라 예상됩니다. "N개의 옵션을 가중치에 맞게 뽑아주세요. 단, 나왔던 항목이 또 나오면 안됩니다."

3. 개발자는 `select_one` 함수를 써서 추옵/어빌을 구현했다.
이제 이 기능을 구현하게된 개발자는 가능하면 `select_one` 함수를 응용하여 기능을 구현하고자 했을 것입니다. 왜냐하면 이미 구현된 기능은 최대한 재사용하는 것이 더 신속한 개발로 이뤄지기 때문입니다.

4. 개발자는 오류를 발생시켰다.
개발 지식을 조금 가진 분이라면 위 `select_one`을 응용하여 N개의 항목을 중복없이 뽑는 기능을 구현하실 수 있을 것입니다. 정말 여러 방법이 있을 수는 있으나 여기서는 2가지 방법만 소개해보겠습니다.

첫번째 방법은 이미 뽑은 항목을 제외한 새로운 `weight` 배열을 생성하고 이를 인자로 `select_one` 함수를 호출하는 것입니다. 이렇게 구현하는 경우 이번 사태와 같은 일은 일어나지 않습니다. 다만 새로운 배열을 생성하는 것 자체가 다소 기피될 수 있는 작업입니다. 왜냐하면 배열을 생성하기 위해서는 메모리 할당이 요구되기 때문입니다.
현대(2020년대)에 와서는 이정도의 메모리 할당을 크게 기피하지는 않으나, 해당 기능이 출시된지 오랜 시간이 지났기도 하고, 서버의 구성 방법에 따라서 이런 최적화가 필요했을 수 있습니다.

두번째 방법은 새로운 배열 할당을 지양하는 대신, `select_one`을 여러번 호출하여 동일한 효과를 거두는 방법입니다. 이미 선택된 옵션을 기준으로 배열을 2개로 나누어, 각각에 대해 `select_one`을 호출하고 그 중 하나를 최종적으로 선택하는 방식입니다. 아시겠지만, 이게 지금까지 사용된 알고리즘입니다.

0.
`weights` 배열의 길이가 100이라고 가정함.
int length = 100;

1.
`select_one(weights, 100)`을 호출합니다. 이렇게 선택된 항목을 `first` 라고 하겠습니다.
int first = select_on(weights, length);

2.
`first`를 기준으로 배열을 2개로 분할합니다. 이 과정은 단순 참조(Reference) 변경만으로 이뤄질 수 있기에 메모리 할당이 발생하지 않습니다. 또한 `first`를 제외하고 배열을 나누기 때문에 `first` 가 다시 뽑히는 것도 예방할 수 있습니다.
int* w1 = weights;
int len1 = first;
int* w2 = &(weights[first+1]);
int len2 = length-first+1;

3.
각각에 대하여 select_one 을 호출합니다. 이 함수의 반환값을 `second1` `second2`라고 하겠습니다.
int second1 = select_one(w1, len1);
int second2 = select_one(w2, len2);

4.
이 결과중 하나를 취사선택합니다. 현재 본섭에 배포된 코드는 second1과 second2 중에 균등한 확률로 하나를 선택합니다.

여기서 4번에서 문제가 발생하였습니다. 이 부분을 기획 의도대로 수정하면 다음과 같이 수정되어야 합니다.
"첫번째 방법"과 동일한 결과를 얻으려면, 확률론적으로는 균등확률이 아닌, `w1` 의 합과 `w2`의 합을 가중치로 두고 뽑아야합니다.

안타깝게도 이러한 형태의 버그는 사전에 탐지하기가 어렵습니다. 의도한 동작이 확률적으로 발생하기 때문입니다. 검증 과정에서는 각 옵션이 가중치에 맞게 등장하는지만 확인했을 것이며, 그 옵션의 선택간에 종속적인 관계가 있는지는 검증하지 않았을 것입니다. 유저들이 문제가 있단 걸 밝히는데 오랜 시간이 걸린 것과 마찬가지입니다.

또한 현업에서 이런 실수는 생각보다 자주 발생합니다. 모든 개발자가 통계와 확률에 대해서 충분한 지식을 갖추고 있는 것은 아니기 때문입니다.

5. 결론
개발자 관점에서 볼때는 이런 과정과 의도로 개발이 이루어졌다고 보고 있습니다. 왜 이렇게 복잡하게 랜덤 알고리즘을 짰는지, 이해가 안되고 답답하신 분들께 도움이 되었길 바랍니다.

글 전반적으로 실드를 치는 느낌이 나는데, 알려진 정보를 제 경험에 비추어 종합한 소설이라고 생각해주시면 감사하겠습니다.