[ 롤모임 운영일지 ] - 21. 꽝 82%까지 가는 길 — 복권 확률 튜닝 일지
5월 중순, 배팅 포인트 상점에 스크래치 복권을 넣었다. 50P를 내면 황금 카드가 나오고, 손가락으로 긁으면 당첨금이 드러나는 단순한 장치다. 그런데 커밋 로그를 다시 보니 재미있는 기록이 남아 있었다 — 출시하고 40여 분 동안 확률표를 네 번 고쳤다. 처음엔 당첨 확률을 두 번이나 올렸고, 30분 뒤엔 정반대로 꽝을 82%까지 끌어올렸다. 이번 편은 그 짧고 굵었던 밸런싱 시행착오의 기록이다.
시작 — 50P짜리 스크래치 카드
구현 자체는 소박하다. 서버는 누적 확률 테이블에서 가중 랜덤으로 당첨금을 뽑고, 프론트는 canvas의 destination-out 합성으로 긁는 모션을 만든다. 50% 이상 긁으면 자동으로 전부 공개된다.
// packages/api/src/routes/bet-store.ts (출시 당시)
const LOTTERY_TIERS: { prize: number; cumProb: number }[] = [
{ prize: 10000, cumProb: 0.0001 },
{ prize: 5000, cumProb: 0.0011 },
{ prize: 1000, cumProb: 0.0111 },
{ prize: 500, cumProb: 0.0611 },
{ prize: 100, cumProb: 0.1611 },
{ prize: 50, cumProb: 0.3111 },
{ prize: 0, cumProb: 1.0000 },
];
출시 스펙은 최고 10,000P(0.01%)부터 본전 50P(15%)까지, 꽝이 약 69%였다. 확률표는 카드 옆에 그대로 노출했다 — 가챠 확률 공시처럼, 유저가 기대값을 스스로 판단할 수 있어야 한다고 봤다.
40여 분의 확률 변천사
그날 오후의 커밋 타임라인을 표로 옮기면 이렇다. 마지막 열만 다음날 밤의 커밋이다.
| 당첨금 | 출시 | +3분 | +5분 | +37분 | +41분 | 다음날 (현재) |
|---|---|---|---|---|---|---|
| 10,000P | 0.01% | 0.01% | 0.01% | 0.01% | 0.001% | 0.005% |
| 5,000P | 0.1% | 0.1% | 0.1% | 0.05% | 0.005% | 0.02% |
| 1,000P | 1% | 1% | 1% | 0.2% | 0.02% | 0.1% |
| 500P | 5% | 5% | 5% | 0.8% | 0.1% | 1% |
| 100P | 10% | 10% | 10% | 2% | 2% | 3% |
| 본전 50P | 15% | 25% | 50% | 15% | 15% | 45% |
| 꽝 | ~69% | ~59% | ~34% | ~82% | ~83% | ~51% |
| 기대 회수율 | 117% | 127% | 152% | 38% | 21% | 66% |
출시 3분 뒤 본전 확률을 15%에서 25%로 올렸고, 다시 2분 뒤 50%까지 올렸다. 왜 그랬는지 커밋 메시지에는 안 남아 있지만, 긁어보면 이유를 짐작하기 어렵지 않다 — 꽝 69%는 체감상 꽤 박하다. 세 장 긁어서 세 장 다 꽝이면 재미가 없으니, “그래도 뭔가는 자주 나오게” 본전 확률을 계속 올린 것이다.
문제는 마지막 행이다. 본전이 50%가 된 시점의 기대 회수율은 152% — 50P를 내면 평균 76P가 돌아온다. 긁는 맛을 살리려던 조정이, 수학적으로는 포인트 발행기를 만들어버렸다. 이 서비스의 포인트는 배팅·상점·경매 스토어 등 경제 전체에서 쓰이는 재화라, 무한정 긁기만 하면 잔액이 우상향하는 장치를 상점에 걸어둔 셈이었다.
37분 뒤 유턴했다. 본전을 다시 15%로 내리고 중간 당첨(100~500P)을 대폭 깎아 꽝을 82%까지 올렸고(기대 회수율 38%), 4분 뒤엔 500P 이상 고액 구간을 한 번 더 조여 21%까지 내렸다. 152%에서 21%로, 한 시간도 안 되는 사이에 정반대 극단으로 이동한 것이다.
다음날 — “복권은 수익 장치가 아니라 재미 장치”
하루 자고 나서 최종안이 나왔다. 지금도 운영 중인 확률표다.
// packages/api/src/routes/bet-store.ts (현재)
// 확률 분포 (cumProb는 누적):
// 10000원 0.005% · 5000원 0.02% · 1000원 0.1%
// 500원 1% · 100원 3% · 본전(50) 45% · 꽝(0) 나머지(≈50.875%)
const LOTTERY_TIERS: { prize: number; cumProb: number }[] = [
{ prize: 10000, cumProb: 0.00005 },
{ prize: 5000, cumProb: 0.00025 },
{ prize: 1000, cumProb: 0.00125 },
{ prize: 500, cumProb: 0.01125 },
{ prize: 100, cumProb: 0.04125 },
{ prize: 50, cumProb: 0.49125 },
{ prize: 0, cumProb: 1.00000 },
];
핵심은 본전 45%다. 두 번에 한 번은 “50P 당첨”이 뜬다. 돌려받는 건 낸 돈 그대로지만, 긁는 순간의 경험은 꽝과 완전히 다르다. 대신 실제 이득 구간(100P 이상)은 다 합쳐 4% 남짓으로 묶어서, 기대 회수율은 66% — 긁을수록 평균적으로는 조금씩 잃는 구조를 유지했다. 극단을 오간 끝에 도착한 답이 “자주 웃게 하되, 경제에서는 꾸준히 포인트를 회수하는 장치“였던 셈이다.
전체 경제 관점에서 이 방향이 맞았다는 건 나중에 숫자로도 확인했다. 대시보드 스냅샷에서 봤듯 주간 포인트 발행과 소비가 비슷한 수준에서 순환하고 있는데, 복권이 152% 시절 그대로였다면 이 균형은 진작 무너졌을 것이다.
연출의 함정 — 결과를 먼저 말해버리는 UI
확률 못지않게 손이 간 게 “결과를 스포일러하지 않기”였다. 같은 실수를 세 번 반복했다.
첫째, 잔액이 먼저 움직였다. 서버는 구매 요청 시점에 이미 당첨금을 정산해서 새 잔액을 돌려주는데, 프론트가 그걸 받자마자 화면 상단 잔액에 반영해버렸다. 카드를 긁기도 전에 잔액이 +950P 되어 있으면 긁을 이유가 없다. 서버 응답은 ref에 담아두고, 긁기가 끝난 순간에만 반영하도록 고쳤다.
// packages/frontend/src/pages/BetStorePage.tsx
const pendingBalanceRef = useRef<number | null>(null);
// 구매 응답: setBalance(...) 대신 보류
pendingBalanceRef.current = d.balance ?? balance - 50;
// 긁기 완료(onReveal) 시점에만 반영
onReveal={() => {
if (pendingBalanceRef.current !== null) {
setBalance(pendingBalanceRef.current);
pendingBalanceRef.current = null;
}
}}
둘째, 확률표가 먼저 말했다. 긁는 중에 옆에 확률표를 세로로 보여주면서 당첨된 행을 하이라이트하는 연출을 넣었는데 — 하이라이트 조건이 lotteryPrize === value라서, 긁기 시작 전부터 정답 행에 불이 들어와 있었다. revealed && prize === value로, 다 긁은 뒤에만 켜지게 바꿨다.
셋째, 사소하지만 idle 상태에서도 확률표가 떠 있던 것을 정리했고, 다음날엔 모바일에서 카드를 긁을 때 페이지가 같이 스크롤되는 문제를 touchmove를 passive: false로 잡아 막았다.
셋 다 원인은 같다. 스크래치 복권의 가치는 당첨금이 아니라 “모르는 상태로 긁는 3초”인데, 상태를 정직하게 즉시 렌더링하는 React의 기본 습관이 그 3초를 자꾸 깨뜨렸다. 연출이 있는 UI에서는 “데이터가 도착한 시점”과 “보여줘야 하는 시점”을 의식적으로 분리해야 한다는 걸 배웠다.
정리
- 기대값 계산은 출시 전에 했어야 했다. 본전 확률을 “체감”으로 두 번 올리는 동안 기대 회수율이 152%가 됐다는 걸, 표를 놓고 곱셈만 해봤어도 바로 알았을 것이다.
- “자주 당첨”과 “많이 당첨”은 분리할 수 있다. 최종안의 본전 45%는 경제적으로는 거의 아무 일도 안 일어나는 결과지만, 체감상으로는 복권을 계속 긁게 만드는 핵심 장치다. 재미는 빈도에서, 회수는 금액에서 가져오면 된다.
- 연출이 있는 UI는 상태 반영 시점이 곧 스포일러 방어선이다. 잔액, 하이라이트, 확률표 — 전부 “이미 알고 있는 것”을 너무 일찍 보여줘서 생긴 문제였다.
- 확률표는 그날 이후 두 달 넘게 그대로다. 한 시간도 안 되는 사이 극단을 오갔던 것치고는, 하루 자고 나서 정한 값이 꽤 오래 살아남았다.