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 상태에서도 확률표가 떠 있던 것을 정리했고, 다음날엔 모바일에서 카드를 긁을 때 페이지가 같이 스크롤되는 문제를 touchmovepassive: false로 잡아 막았다.

셋 다 원인은 같다. 스크래치 복권의 가치는 당첨금이 아니라 “모르는 상태로 긁는 3초”인데, 상태를 정직하게 즉시 렌더링하는 React의 기본 습관이 그 3초를 자꾸 깨뜨렸다. 연출이 있는 UI에서는 “데이터가 도착한 시점”과 “보여줘야 하는 시점”을 의식적으로 분리해야 한다는 걸 배웠다.


정리

  • 기대값 계산은 출시 전에 했어야 했다. 본전 확률을 “체감”으로 두 번 올리는 동안 기대 회수율이 152%가 됐다는 걸, 표를 놓고 곱셈만 해봤어도 바로 알았을 것이다.
  • “자주 당첨”과 “많이 당첨”은 분리할 수 있다. 최종안의 본전 45%는 경제적으로는 거의 아무 일도 안 일어나는 결과지만, 체감상으로는 복권을 계속 긁게 만드는 핵심 장치다. 재미는 빈도에서, 회수는 금액에서 가져오면 된다.
  • 연출이 있는 UI는 상태 반영 시점이 곧 스포일러 방어선이다. 잔액, 하이라이트, 확률표 — 전부 “이미 알고 있는 것”을 너무 일찍 보여줘서 생긴 문제였다.
  • 확률표는 그날 이후 두 달 넘게 그대로다. 한 시간도 안 되는 사이 극단을 오갔던 것치고는, 하루 자고 나서 정한 값이 꽤 오래 살아남았다.