8인 실시간 마피아 게임을 위한 완전 비동기(Fully Reactive) 백엔드 서버
WebFlux + R2DBC + Redis로 구현한 논블로킹 게임 서버. 동시성 제어, 실시간 통신, 상태 기반 게임 로직을 직접 설계하고 구현했습니다.
마피아 게임은 단순해 보이지만, 서버 관점에서는 까다로운 문제들이 있습니다:
- 동시성: 8명이 동시에 투표하면 race condition 발생
- 실시간성: 밤/낮/투표 페이즈가 시간 제한 내에 자동 전환되어야 함
- 상태 일관성: 분산 환경에서도 게임 상태가 정확해야 함
이 프로젝트는 이런 문제들을 Redis 분산 락, 이벤트 기반 스케줄링, 리액티브 스트림으로 해결합니다.
8명이 동시에 투표하면 집계가 꼬일 수 있습니다. Redis SETNX 기반 분산 락으로 해결:
// RedisLockService.java
public Mono<String> acquireLock(String lockKey) {
return redisTemplate.opsForValue()
.setIfAbsent(fullKey, lockToken, LOCK_TTL) // atomic operation
.retryWhen(Retry.backoff(MAX_RETRY_ATTEMPTS, RETRY_DELAY));
}- TTL 10초: 데드락 방지
- Lock Token: 소유권 검증으로 다른 프로세스의 락 해제 방지
- Exponential Backoff: 재시도 시 부하 분산
밤(30초) → 낮(30초) → 투표(10초) → 변론(10초) → 결과(10초) 순환:
// GameSchedulerService - Spring Event 기반
@EventListener
public void onPhaseChanged(PhaseChangedEvent event) {
int duration = event.getPhaseDurationSeconds();
Mono.delay(Duration.ofSeconds(duration))
.then(gameService.nextPhase(gameId))
.subscribe(); // non-blocking
}스케줄러가 블로킹하지 않고, 이벤트 발행으로 다음 페이즈를 예약합니다.
DB부터 WebSocket까지 전 구간 리액티브:
WebSocket Request
↓ (Reactive)
GameService (Mono/Flux)
↓ (R2DBC - non-blocking)
MySQL / Redis
↓ (Reactive)
Redis Pub/Sub → WebSocket Broadcast
단일 스레드에서도 높은 동시 처리량을 보장합니다.
| Layer | Technology |
|---|---|
| Runtime | Java 21, Spring Boot 3.5, WebFlux |
| Database | MySQL 8 (R2DBC), AWS RDS |
| Cache & Lock | Redis (AWS ElastiCache Valkey) |
| Realtime | WebSocket, Redis Pub/Sub |
| Infra | Docker, AWS ECR, EC2, Watchtower |
NIGHT (30s) DAY (30s) VOTE (10s)
├─ 마피아: 시민 지목 ├─ 토론 ├─ 투표 (과반수)
├─ 의사: 치료 대상 지목 └─ 경찰 조사 결과 공유 └─ 최다 득표자 → 재판
└─ 경찰: 조사 대상 지목
↓
RESULT (10s) DEFENSE (10s)
├─ 찬/반 투표 └─ 최후 변론
└─ 과반 찬성 시 처형
승리 조건
- 시민팀: 마피아 전원 처형
- 마피아팀: 마피아 수 >= 시민 수
┌─────────────────────────────────────────────────────────────┐
│ Client │
│ (WebSocket + REST API) │
└──────────────────────────┬──────────────────────────────────┘
│
┌──────────────────────────▼──────────────────────────────────┐
│ Spring WebFlux │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ REST API │ │ WebSocket │ │ Event Publisher │ │
│ │ Controller │ │ Handler │ │ (Phase Scheduler) │ │
│ └──────┬──────┘ └──────┬──────┘ └──────────┬──────────┘ │
│ │ │ │ │
│ ┌──────▼────────────────▼────────────────────▼──────────┐ │
│ │ Service Layer │ │
│ │ GameService │ VoteCacheService │ RedisLockService │ │
│ └──────────────────────┬────────────────────────────────┘ │
└─────────────────────────┼───────────────────────────────────┘
│
┌─────────────────┼─────────────────┐
│ │ │
┌───────▼───────┐ ┌───────▼───────┐ ┌───────▼───────┐
│ MySQL 8.x │ │ Redis │ │ Redis Pub/Sub │
│ (R2DBC) │ │ Cache + Lock │ │ Broadcast │
│ │ │ │ │ │
│ - users │ │ - game state │ │ - room:{id} │
│ - games │ │ - vote cache │ │ - game:{id} │
│ - actions │ │ - dist lock │ │ │
└───────────────┘ └───────────────┘ └───────────────┘
graph TB
subgraph "CI/CD"
DEV[Developer] -->|docker push| ECR[ECR]
DEV -->|git push| GHA[GitHub Actions]
end
subgraph "AWS"
ECR -->|pull| WT[Watchtower]
WT -->|restart| APP[Spring Boot Container]
APP -->|R2DBC| RDS[(RDS MySQL)]
APP -->|Redis| CACHE[(ElastiCache)]
TG[Health Check] -->|unhealthy| CW[CloudWatch]
CW -->|trigger| LAMBDA[Lambda Auto-Recovery]
end
자동화 포인트
- Watchtower가 5분마다 ECR 폴링 → 새 이미지 감지 시 자동 배포
- Health check 실패 시 Lambda가 EC2 자동 재부팅
src/main/java/com/jingwook/mafia_server/
├── config/ # WebSocket, Redis, R2DBC 설정
├── controllers/ # REST API 엔드포인트
├── handlers/ # WebSocket 메시지 핸들러
├── services/ # 비즈니스 로직 (GameService, RedisLockService)
├── domains/ # 도메인 모델 (순수 Java 로직)
├── entities/ # R2DBC 엔티티
├── events/ # Spring Event (PhaseChangedEvent 등)
└── enums/ # GamePhase, PlayerRole 등
# 1. 의존성 실행
docker-compose up -d
# 2. DB 초기화
mysql -h localhost -P 3307 -u root -p < init_database.sql
# 3. 실행
./gradlew bootRunAPI 문서: http://localhost:8080/swagger-ui.html