📚FOS Study
홈카테고리
홈카테고리

카테고리

  • AI 페이지로 이동
    • RAG 페이지로 이동
    • agents 페이지로 이동
    • custom-agents 페이지로 이동
    • Claude Code의 Skill 시스템 - 개발자를 위한 AI 자동화의 새로운 차원
    • 멀티모달 LLM (Multimodal Large Language Model)
  • architecture 페이지로 이동
    • 디자인 패턴
    • 분산 트랜잭션
    • 슬롯 게임 엔진 고도화 — 2025년 회고
  • css 페이지로 이동
    • FlexBox 페이지로 이동
  • database 페이지로 이동
    • mysql 페이지로 이동
    • opensearch 페이지로 이동
    • 김영한의-실전-데이터베이스-설계 페이지로 이동
    • 커넥션 풀 크기는 얼마나 조정해야할까?
    • 인덱스 - DB 성능 최적화의 핵심
  • devops 페이지로 이동
    • docker 페이지로 이동
    • k8s 페이지로 이동
    • k8s-in-action 페이지로 이동
    • monitoring 페이지로 이동
  • go 페이지로 이동
    • Go 언어 기본 학습
  • http 페이지로 이동
    • HTTP Connection Pool
  • interview 페이지로 이동
    • 210812 페이지로 이동
    • 뱅크샐러드 AI Native Server Engineer
    • CJ 올리브영 지원 문항
    • CJ 올리브영 커머스플랫폼유닛 Back-End 개발 지원 자료
    • 마이리얼트립 - Platform Solutions실 회원주문개발 Product Engineer
    • NHN 서비스개발센터 AI서비스개발팀
    • nhn gameenvil console backend 직무 인터뷰 준비
    • 면접을 대비해봅시다
    • Tossplace Node.js Developer
    • 토스플레이스 Node.js 백엔드 컬처핏
  • java 페이지로 이동
    • jdbc 페이지로 이동
    • opentelemetry 페이지로 이동
    • spring 페이지로 이동
    • spring-batch 페이지로 이동
    • Java의 로깅 환경
    • MDC (Mapped Diagnostic Context)
    • OpenTelemetry 란 무엇인가?
    • Virtual Thread와 Project Loom
  • javascript 페이지로 이동
    • Data_Structures_and_Algorithms 페이지로 이동
    • Heap 페이지로 이동
    • typescript 페이지로 이동
    • AbortController
    • Async Iterator와 제너레이터
    • CommonJS와 ECMAScript Modules
    • 제너레이터(Generator)
    • Http Client
    • Node.js
    • npm vs pnpm 선택기준은 무엇인가요?
    • `setImmediate()`
  • kafka 페이지로 이동
    • Kafka 기본
    • Kafka를 사용하여 **데이터 정합성**은 어떻게 유지해야 할까?
    • 메시지 전송 신뢰성
  • network 페이지로 이동
    • L2(스위치)와 L3(라우터)의 역할 차이
    • L4와 VIP(Virtual IP Address)
    • IP Subnet
  • react 페이지로 이동
    • JSX 페이지로 이동
    • VirtualDOM 페이지로 이동
    • v16 페이지로 이동
  • redis 페이지로 이동
    • Redis
    • Redis Hash와 Lua 스크립트로 잭팟 누적 구현하기
  • task 페이지로 이동
    • ai-service-team 페이지로 이동
    • nsc-slot 페이지로 이동
📚FOS Study

개발 학습 기록을 정리하는 블로그입니다.

바로가기

  • 홈
  • 카테고리

소셜

  • GitHub
  • Source Repository

© 2025 FOS Study. Built with Next.js & Tailwind CSS

목록으로 돌아가기
📁task/ nsc-slot

Slot 21 (Magical Fortune) — 텀블링 슬롯 구현기

약 5분
2026년 3월 22일
2026년 3월 22일 수정
GitHub에서 보기

Slot 21 (Magical Fortune) — 텀블링 슬롯 구현기

진행 기간: 2024.06 ~ 2024.12


어떤 슬롯인가

Magical Fortune은 텀블링(Tumbling/Cascading) 메커니즘을 기반으로 한 슬롯이다.

일반 슬롯은 스핀 한 번에 결과가 결정된다. 텀블링 슬롯은 다르다. 당첨 심볼이 제거되고 빈 자리를 위에서 새 심볼이 채운 뒤, 다시 당첨 여부를 검사한다. 연속 당첨이 가능하고, 이 과정이 반복된다.

여기에 와일드 스프레드와 보너스 랜덤 트리거가 더해진다.


1. 텀블링 메커니즘 구현

상태 기계(State Machine)로 표현

텀블링은 단순히 "심볼을 지우고 다시 넣는" 게 아니다. 각 텀블 사이클마다 상태를 추적해야 한다.

스핀 시작
  │
  ▼
릴 결과 생성
  │
  ▼
당첨 계산 ──── 당첨 없음 ──────────────────────────────▶ 스핀 종료
  │
당첨 있음
  │
  ▼
당첨 심볼 제거 + 보상 누적
  │
  ▼
새 심볼 낙하 (텀블)
  │
  ▼
다시 당첨 계산 ──▶ (반복)

여기서 중요한 것이 "이미 처리된 심볼을 다음 사이클에서 다시 처리하지 않는 것" 이다.

텀블 이후 새로 채워진 심볼이 다시 당첨 조건을 만들 수 있다. 그런데 이전 텀블에서 살아남은 심볼(당첨에 기여했지만 제거되지 않은 심볼)을 잘못 처리하면 같은 심볼이 중복 계산된다.

해결 방법은 각 텀블 사이클마다 "새로 생성된 심볼" 과 "이전부터 있던 심볼" 을 구분하는 것이다.

// 각 텀블 사이클에서 처리할 심볼 범위를 명시적으로 추적
Set<Position> processedPositions = new HashSet<>();

while (hasWin(window)) {
    List<Position> winPositions = calculateWinPositions(window);

    removeSymbols(window, winPositions);
    fillNewSymbols(window); // 위에서 새 심볼 낙하

    // 다음 사이클에서는 새로 채워진 심볼만 당첨 계산 대상
    processedPositions.addAll(winPositions);
}

캐스캐이딩 횟수 응답

클라이언트는 "몇 번 텀블이 일어났는지"를 알아야 애니메이션을 처리할 수 있다. 처음에는 캐스캐이딩된 심볼 카운트를 내려줬는데, 클라이언트가 실제로 필요한 건 횟수(count) 였다.

// 변경 전: 제거된 심볼 개수
response.setCascadeCount(removedSymbols.size());

// 변경 후: 텀블 발생 횟수
response.setCascadeCount(tumbleCount); // 1, 2, 3...

작은 차이처럼 보이지만, 클라이언트가 "3연속 텀블"을 표현하려면 횟수가 필요하다. 심볼 개수로는 연속 횟수를 알 수 없다.


2. 와일드 스프레드 (Wild Spread)

와일드 심볼이 나왔을 때 인접 셀로 퍼지는 기능이다.

문제: 스프레드된 와일드의 중복 처리

와일드 스프레드에서 가장 신경 써야 할 것은 스프레드로 생성된 와일드가 또 다른 스프레드를 트리거하지 않도록 하는 것 이다.

와일드 A가 퍼져서 와일드 B, C를 만들고, 그 B, C가 다시 퍼진다면 연쇄 폭발이 일어난다. 이건 의도된 스펙이 아니다.

// 원본 와일드와 스프레드된 와일드를 구분
Set<Position> originalWilds = findOriginalWilds(window);
Set<Position> spreadPositions = new HashSet<>();

for (Position wildPos : originalWilds) {
    // 원본 와일드 기준으로만 스프레드 계산
    spreadPositions.addAll(calculateSpreadArea(wildPos, spreadConfig));
}

// 스프레드된 위치에 와일드 배치
applyWilds(window, spreadPositions);
// 이후 spreadPositions에 있는 와일드는 추가 스프레드 대상에서 제외

원본 와일드 집합을 먼저 확정한 뒤, 그 집합을 기준으로만 스프레드를 계산한다. 스프레드로 생성된 와일드는 스프레드 기준에서 제외한다.


3. 보너스 랜덤 트리거

일반 스핀 중 특정 확률로 보너스 피처가 발동된다. 유저가 요청한 스핀 결과와는 별개로, 추가 이벤트가 발생하는 구조다.

응답 구조 설계

클라이언트가 처리해야 할 정보가 두 가지다.

  1. 원래 스핀 결과: 릴 심볼, 당첨 정보
  2. 랜덤 트리거 이벤트: 보너스 발동 여부, 보너스 결과

처음 설계는 랜덤 트리거가 발동되면 원래 스핀 결과를 덮어쓰는 방식이었다. 클라이언트에서 문제가 생겼다. 스핀 결과가 사라지면 유저는 어떤 스핀이 나왔는지 볼 수 없다.

// 변경 전: 랜덤 트리거 결과만 반환
return SpinResult.ofRandomTrigger(bonusResult);

// 변경 후: 원래 스핀 결과 + 트리거 이벤트를 함께 반환
return SpinResult.builder()
    .reelResult(originalSpinResult)     // 원래 스핀은 유지
    .randomTriggerEvent(bonusResult)    // 트리거는 추가 필드로
    .build();

원래 윈도우를 유지하면서 랜덤 트리거 이벤트를 별도 필드로 담는 방식으로 바꿨다. 클라이언트는 트리거 이벤트가 있으면 기존 스핀 결과 위에 트리거 애니메이션을 덧씌우는 방식으로 처리할 수 있다.


4. 시뮬레이터에서 RTP를 정확하게 측정하려면

텀블링 슬롯의 시뮬레이터는 일반 슬롯보다 복잡하다.

InitSpin과 EndSpin

텀블링 슬롯에는 세 종류의 스핀이 있다.

종류설명
BASE일반 스핀
INIT_SPIN프리스핀 진입 시 첫 스핀
END_SPIN프리스핀 종료 스핀

처음 시뮬레이터를 만들 때 BASE 스핀만 집계했다. RTP를 계산하면 이론값과 차이가 났다.

원인은 INIT_SPIN과 END_SPIN의 보상이 집계에서 빠진 것이었다. 프리스핀 구간에서 발생하는 보상이 포함되지 않으니 실제보다 낮은 RTP가 나왔다.

// 수정 전: BASE 스핀만 집계
if (spinType == BASE) {
    accumulate(spinResult);
}

// 수정 후: 모든 스핀 타입 집계
accumulate(spinResult); // 타입에 관계없이 항상 집계

RTP 집계는 스핀 타입에 관계없이 유저가 받은 모든 보상을 포함해야 한다.

프리스핀 결과가 윈 레인지에 합산되지 않는 문제

RTP만이 아니라 윈 레인지(win range) 도 중요한 시뮬레이터 항목이다. "베팅 대비 몇 배 당첨이 몇 번 발생했는가"를 구간별로 집계한다.

0 ~ 1x  : 30%
1x ~ 5x : 40%
5x ~ 20x: 20%
20x ~   :  5%
프리스핀 :  5%  ← 이게 집계에서 빠졌다

프리스핀은 별도 루프로 진행되는데, 윈 레인지 집계 로직이 BASE 스핀 루프 안에만 있었다. 프리스핀 루프를 돌면서 발생한 보상이 윈 레인지에 합산되지 않았다.

프리스핀 결과를 BASE 스핀 결과와 합산한 뒤 윈 레인지를 계산하도록 순서를 바꿨다.


배운 것

텀블링 슬롯은 "처리 순서"가 핵심이다. 각 텀블 사이클에서 무엇을 처리하고 무엇을 보존할지, 어떤 심볼이 새로 생성된 것인지 구분하는 로직이 버그의 대부분을 만든다. 상태를 명시적으로 추적하는 코드가 훨씬 안전하다.

클라이언트와의 계약을 먼저 정의해야 한다. 랜덤 트리거 응답 설계처럼, 서버에서 "편한 방식"으로 응답을 만들면 클라이언트가 처리하기 어렵다. 클라이언트가 어떤 정보를 어떤 순서로 필요로 하는지부터 생각해야 한다.

시뮬레이터는 모든 경로를 커버해야 한다. RTP 시뮬레이터에서 일부 스핀 타입이 빠지면 결과가 조용히 틀려진다. 시뮬레이터 집계 로직을 짤 때는 "유저가 받을 수 있는 모든 보상 경로"를 체크리스트로 만들어두는 게 효과적이다.


사용 기술

  • Java 17, Spring Boot 3.x
  • JUnit 5
task 카테고리의 다른 글 보기수정 제안하기

댓글

댓글을 불러오는 중...
목차
  • Slot 21 (Magical Fortune) — 텀블링 슬롯 구현기
  • 어떤 슬롯인가
  • 1. 텀블링 메커니즘 구현
  • 상태 기계(State Machine)로 표현
  • 캐스캐이딩 횟수 응답
  • 2. 와일드 스프레드 (Wild Spread)
  • 문제: 스프레드된 와일드의 중복 처리
  • 3. 보너스 랜덤 트리거
  • 응답 구조 설계
  • 4. 시뮬레이터에서 RTP를 정확하게 측정하려면
  • InitSpin과 EndSpin
  • 프리스핀 결과가 윈 레인지에 합산되지 않는 문제
  • 배운 것
  • 사용 기술