배경
커머스 플랫폼 개발에서 랜덤 쿠폰 지급 이벤트는 빈번히 요구되는 기능이다. 이러한 이벤트에서는 일반적으로 할인율이 낮은 쿠폰일수록 당첨 확률이 높고, 할인율이 높은 쿠폰일수록 당첨 확률이 낮다. 이를 구현하기 위해 각 쿠폰에 가중치를 부여하고, 랜덤하게 쿠폰을 발행하는 가중치 랜덤 알고리즘(Weighted Random Algorithm)이 사용된다. 이번 포스트에서는 이 알고리즘을 알아보고 실제 구현, 테스트까지 진행해보자.
가중치 랜덤 알고리즘(Weighted Random Algorithm)
가중치 랜덤 알고리즘은 각 요소에 가중치를 할당해서 선택 확률을 조절하여 선택하는 알고리즘이다.
가중치가 높을수록 선택될 확률이 높고 가중치가 낮을수록 선택될 확률이 낮다.
사용 사례
- 게임 개발: 디아블로 시리즈를 참 좋아했는데, '폐지 줍기 게임'으로 유명하다. 이 게임의 매력은 더 강력한 아이템을 얻기 위해 끊임없이 몬스터를 사냥하고 아이템을 수집하는 데 있다. 하지만 대부분 쓰레기 아이템들만 드랍되고 강력한 아이템은 정말 운이 좋아야 볼 수 있었다. 한국 게임에서는 대표적으로 메이플 스토리의 아이템 강화가 있다. 강화 단계가 높아질수록 강화에 성공할 확률이 낮아지고 최악의 상황에서는 아이템이 파괴될 수 있다. 이러한 아이템 드랍 시스템이나 강화 시스템을 구현하는 데 가중치 랜덤 알고리즘이 효과적으로 활용될 수 있다. 이런 확률을 잘 조절해서 게임의 재미와 밸런스를 동시에 유지할 수 있다.(간만에 게임 이야기를 하다보니 말이 길어졌다ㅋㅋ)
- 로드 밸런싱: 여러 서버로 들어오는 요청을 분배할 때 사용될 수 있다. 각 서버의 성능, 용량, 부하 상태에 따라 요청을 분배할 수 있다. 예를 들어, 특정 서버의 CPU 사용률이 급격히 증가하면 해당 서버로의 요청 분배 가중치를 낮추고, 다른 서버의 가중치를 높여서 시스템을 안정적으로 운영할 수 있다.
- 추천 시스템: 음악, 영화, 상품 추천 등 널리 사용된다. 예를 들어, 넷플릭스에서 사용자가 최근 시청한 컨텐츠와 유사한 장르의 영화에 높은 가중치를 주어 추천할 확률을 높인다. 또는, 크리스마스와 같은 특정 시즌에 맞는 컨텐츠에도 일시적으로 가중치를 높여 추천에 반영할 수 있다.
- 온라인 광고 시스템: 광고비, 클릭률, 전환율, 사용자의 관심사와 광고 내용 연관성 등 다양한 요소를 종합적으로 계산하여 가중치를 할당하고, 이를 기반으로 알고리즘이 실시간으로 표시할 광고를 선택할 수 있다.
- 랜덤 쿠폰 이벤트: 할인율이 다른 쿠폰들에 가중치를 다르게 주어 가중치에 따라서 랜덤하게 발행할 수 있다.
구현
구현은 생각보다 간단하다. 알고리즘 흐름은 다음과 같다.
흐름
- 가중치를 기준으로 내림차순 정렬
- 각 요소의 가중치 총합(Total Weight)을 계산한다.
- 0부터 Total Weight 사이의 난수(rnd)를 생성한다.
- 난수(rnd)가 0보다 작거나 같은 값이 될 때까지 각 요소의 가중치를 난수에 누적 차감시킨다.
- 난수(rnd)가 0보다 작거나 같은 값이 되면 해당 요소를 선택한다.
이제 위에 정리한 흐름대로 코드로 구현해보자. 예시 코드는 Java로 구현했다.
Coupon 클래스는 쿠폰명(title), 가중치(weight)를 속성으로 가지고 있다.
public Coupon getWeightRandomCoupon(List<Coupon> couponList) {
// 가중치를 기준으로 내림차순 정렬
couponList.sort(Comparator.comparing(Coupon::getWeight).reversed());
// 1 ~ TotalWeight 사이의 랜덤값
int totalWeight = couponList.stream().mapToInt(Coupon::getWeight).sum();
double random = Math.ceil(Math.random() * totalWeight);
/**
* random에 가중치를 누적 차감하며 루프
* random 값이 0보다 작거나 같으면 해당 쿠폰 발행
*/
for (Coupon coupon : couponList) {
random -= coupon.getWeight();
if (random <= 0) {
return coupon;
}
}
return null;
}
1억개의 랜덤 쿠폰을 발행해서 실제 가중치대로 쿠폰이 발행되는지 테스트해보았다.
public class Main {
public static void main(String[] args) {
Main main = new Main();
List<Coupon> couponList = Arrays.asList(
new Coupon("1만원 쿠폰", 50),
new Coupon("2만원 쿠폰", 30),
new Coupon("5만원 쿠폰", 19),
new Coupon("100만원 쿠폰", 1)
);
// 랜덤 쿠폰 테스트 (1억회 반복)
Map<Coupon, Integer> couponCount = new HashMap<>();
int count = 100000000;
for (int i = 0; i < count; i++) {
// 쿠폰이 등장할 때마다 해당 쿠폰 등장 횟수 +1
Coupon randomCoupon = main.getWeightRandomCoupon(couponList);
couponCount.put(randomCoupon, 1 + couponCount.getOrDefault(randomCoupon, 0));
}
// 쿠폰 등장 확률 출력
for (Entry<Coupon, Integer> e : couponCount.entrySet()) {
System.out.printf("%s 등장 확률: %d%%\n", e.getKey().getTitle(), Math.round(((double) e.getValue() / (double) count) * 100));
}
}
public Coupon getWeightRandomCoupon(List<Coupon> couponList) {
// 가중치를 기준으로 내림차순 정렬
couponList.sort(Comparator.comparing(Coupon::getWeight).reversed());
// 1 ~ TotalWeight 사이의 랜덤값
int totalWeight = couponList.stream().mapToInt(Coupon::getWeight).sum();
double random = Math.ceil(Math.random() * totalWeight);
/**
* random에 가중치를 누적 차감하며 루프
* random 값이 0보다 작거나 같으면 해당 쿠폰 발행
*/
for (Coupon coupon : couponList) {
random -= coupon.getWeight();
if (random <= 0) {
return coupon;
}
}
return null;
}
@Getter
@ToString
@AllArgsConstructor
static class Coupon {
private String title; // 쿠폰명
private int weight; // 가중치
}
}
위 예시를 통해 부여된 가중치에 따라 쿠폰이 등장한 것을 확인할 수 있다. 하지만 실제 업무 환경에서는 이보다 훨씬 복잡한 요구사항이 존재할 수 있기 때문에 구현 과정이 더욱 복잡해질 수 있다. 또한 더 효율적으로 동작하는 다양한 가중치 랜덤 알고리즘들이 있다. 이에 대해 더 자세히 알고 싶다면, 아래 참조 링크들을 확인하길 바란다.
참조
https://dev.to/jacktt/understanding-the-weighted-random-algorithm-581p
https://blog.naver.com/occidere/222024179048
https://eli.thegreenplace.net/2010/01/22/weighted-random-generation-in-python
https://blog.bruce-hill.com/a-faster-weighted-random-choice