동시성 충돌 문제
기존 Indian Frog 웹 게임 프로젝트 코드에서는 한 게임방에서 두 명의 유저가 게임 시작을 위해 요청을 보낼 경우 클라이언트로부터 2개의 요청이 서버로 보내지게 되는데, 서버에서 요청을 처리하는 도중 다른 요청이 서버에서 같이 처리되어 게임 엔티티가 2개 생성되고 유저에게 지급된 카드가 서로 다르게 되는 문제가 발생했다.
문제 해결을 위해 원인을 파악한 결과 기존 코드에서는 동시성 충돌에 대한 고려를 전혀하지 않고 구현된 상태였다.
기존 StartRound 코드
@Transactional
public StartRoundResponse startRound(Long gameRoomId) {
return totalRoundStartTimer.record(() -> {
log.info("게임룸 ID로 라운드 시작: {}", gameRoomId);
log.info("게임룸 검증 및 검색 중.");
GameRoom gameRoom = gameValidator.validateAndRetrieveGameRoom(gameRoomId);
log.info("게임룸 검증 및 검색 완료.");
log.info("게임 초기화 또는 검색 중.");
Game game = gameValidator.initializeOrRetrieveGame(gameRoom);
log.info("게임 초기화 또는 검색 완료.");
gameRoom.updateGameState(GameState.START);
log.info("게임 상태를 START로 업데이트 함.");
int firstBet = performRoundStartTimer.record(() -> performRoundStart(game));
log.info("라운드 시작 작업 수행 완료.");
gameValidator.saveGameRoomState(gameRoom);
log.info("게임룸 상태 저장 완료.");
int round = game.getRound();
log.info("게임의 현재 턴 가져오는 중.");
Turn turn = gameTurnService.getTurn(game.getId());
log.info("현재 턴 가져옴.");
log.info("StartRoundResponse 반환 중.");
return new StartRoundResponse("ACTION", round, game.getPlayerOne(), game.getPlayerTwo(),
game.getPlayerOneCard(), game.getPlayerTwoCard(), turn, firstBet);
});
}
위 코드에서는 게임을 시작하는 기능을 수행하고만 있을 뿐 동시에 요청이 들어왔을 때에 대한 동시성 충돌 상황에 대한 대비가 전혀 되어있지 않았다.
동시성 충돌 문제를 해결하기 위해 SpringBoot에서 동시성을 제어하는 여러 기술들을 찾아보고 프로젝트에 적용할 수 있는 방법을 하나 씩 적용해서 문제를 해결하기로 했다.
Spring Boot에서 동시성 충돌 문제를 해결하는 몇 가지 방법
1. 낙관적 락(Optimistic Locking)
- 낙관적 락은 데이터베이스에 데이터를 업데이트하기 전에 해당 데이터가 마지막으로 읽힌 이후 수정되었는지 확인한다. 이는 일반적으로 버전 번호나 타임 스탬프를 사용하여 관리된다.
- 데이터를 업데이트하려 할 때, 해당 데이터의 버전이 미리 읽었던 버전과 같은지 확인하고, 그렇지 않은 경우 충돌로 간주하여 업데이트를 거부한다.
2. 비관적 락(Pessimistic Locking)
- 비관적 락은 데이터를 읽기 시작할 때부터 관련 데이터에 락을 걸어 다른 프로세스나 스레드가 해당 데이터를 동시에 수정하지 못하게 한다.
- 이 방법은 데이터의 일관성을 유지하는데 효과적이지만, 락을 오래 보유함으로써 시스템의 성능에 부정적인 영향을 줄 수 있다.
3. 트랜잭션 격리 수준(Transaction Isolation Levels)
- 데이터베이스에서는 다양한 트랜잭션 격리 수준을 제공하여 동시성을 관리한다. 격리 수준을 설정함으로써 dirty read, 논리적 일관성 문제, non-repeatable read 등을 방지할 수 있다.
- SERIALIZABLE 격리 수준은 트랜잭션이 serial하게 실행되는 것처럼 보장함으로써 동시성 문제를 최소화 한다.
4. Synchronized
- 메소드나 코드 블록을 동기화하여 여러 스레드가 객체의 상태를 동시에 수정하는 것을 방지할 수 있다. 이로 인해 데이터의 일관성과 스레드 안전성이 유지된다.
- 손 쉽게 동시성 문제를 해결할 수 있고 코드의 안정성을 높일 수 있지만, 과도한 동기화는 성능 저하를 초래하고 잘못된 사용은 데드락을 발생시킬 수 있다.
동시성 충돌 문제를 해결하기 위한 4 가지 방법 중 낙관적 락은 클라이언트에서 서버에 요청을 보낼 때 2명의 유저가 동시에 요청을 보내서 충돌이 빈번하게 발생하기 때문에 비효율적이며, 충돌 감지와 복구 로직이 필요하기 때문에 적합하지 않다고 판단했다.
트랜잭션 격리 수준은 데이터베이스 리소스에 부담을 줄 수 있고 데드락을 유발할 수 있다는 점에서 적합하지 않다고 판단했고, 비관적 락과 Synchronized를 적용해 동시성 충돌 문제를 방지하도록 구현해 보기로 했다.
Synchronized 적용
@Transactional
public synchronized StartRoundResponse startRound(Long gameRoomId, String email) {
return totalRoundStartTimer.record(() -> {
log.info("게임룸 ID로 라운드 시작: {}", gameRoomId);
log.info("게임룸 검증 및 검색 중.");
GameRoom gameRoom = gameValidator.validateAndRetrieveGameRoom(gameRoomId);
log.info("게임룸 검증 및 검색 완료.");
Game currentGame = gameRoom.getCurrentGame();
if (currentGame == null && !gameRoom.getGameState().equals(GameState.START)) {
gameRoom.updateGameState(GameState.START);
log.info("게임 상태 업데이트 : {}.", gameRoom.getGameState());
log.info("게임 초기화 또는 검색 중.");
Game game = gameValidator.initializeOrRetrieveGame(gameRoom);
log.info("게임 초기화 또는 검색 완료.");
int firstBet = performRoundStartTimer.record(() -> performRoundStart(game, email));
Card card = email.equals(game.getPlayerOne().getEmail()) ? game.getPlayerTwoCard() : game.getPlayerOneCard();
log.info("라운드 시작 작업 수행 완료.");
int round = game.getRound();
log.info("게임의 현재 턴 가져오는 중.");
Turn turn = gameTurnService.getTurn(game.getId());
log.info("현재 턴 가져옴.");
log.info("StartRoundResponse 반환 중.");
return new StartRoundResponse("ACTION", round, game.getPlayerOne(), game.getPlayerTwo(), card, turn, firstBet);
}
int firstBet = currentGame.getBetAmount();
Card card = email.equals(currentGame.getPlayerOne().getEmail()) ? currentGame.getPlayerTwoCard() : currentGame.getPlayerOneCard();
int round = currentGame.getRound();
Turn turn = gameTurnService.getTurn(currentGame.getId());
return new StartRoundResponse("ACTION", round, currentGame.getPlayerOne(), currentGame.getPlayerTwo(), card, turn, firstBet);
});
}
StartRound 메서드에 synchronized 적용 및 게임 상태 업데이트를 통해 첫 번째 요청만 게임 엔티티를 생성할 수 있도록 코드를 수정했으나 테스트 결과 문제가 해결되지 않았음
비관적 락 적용
기존 synchronized를 적용한 메서드에 추가적으로 비관적 락 기법을 적용했다.
@Transactional
@Lock(LockModeType.PESSIMISTIC_WRITE)
public synchronized StartRoundResponse startRound(Long gameRoomId, String email) {
return totalRoundStartTimer.record(() -> {
log.info("게임룸 ID로 라운드 시작: {}", gameRoomId);
log.info("게임룸 검증 및 검색 중.");
GameRoom gameRoom = gameValidator.validateAndRetrieveGameRoom(gameRoomId);
log.info("게임룸 검증 및 검색 완료.");
Game currentGame = gameRoom.getCurrentGame();
if (currentGame == null && !gameRoom.getGameState().equals(GameState.START)) {
gameRoom.updateGameState(GameState.START);
log.info("게임 상태 업데이트 : {}.", gameRoom.getGameState());
log.info("게임 초기화 또는 검색 중.");
Game game = gameValidator.initializeOrRetrieveGame(gameRoom);
log.info("게임 초기화 또는 검색 완료.");
int firstBet = performRoundStartTimer.record(() -> performRoundStart(game, email));
Card card = email.equals(game.getPlayerOne().getEmail()) ? game.getPlayerTwoCard() : game.getPlayerOneCard();
log.info("라운드 시작 작업 수행 완료.");
int round = game.getRound();
log.info("게임의 현재 턴 가져오는 중.");
Turn turn = gameTurnService.getTurn(game.getId());
log.info("현재 턴 가져옴.");
log.info("StartRoundResponse 반환 중.");
return new StartRoundResponse("ACTION", round, game.getPlayerOne(), game.getPlayerTwo(), card, turn, firstBet);
}
int firstBet = currentGame.getBetAmount();
Card card = email.equals(currentGame.getPlayerOne().getEmail()) ? currentGame.getPlayerTwoCard() : currentGame.getPlayerOneCard();
int round = currentGame.getRound();
Turn turn = gameTurnService.getTurn(currentGame.getId());
return new StartRoundResponse("ACTION", round, currentGame.getPlayerOne(), currentGame.getPlayerTwo(), card, turn, firstBet);
});
}
테스트 결과 게임 Entity가 2개가 생성되는 문제가 지속되고 synchronized와 비관적 락 동시 적용으로 인해 성능 저하가 심할 것으로 판단해 synchronized 적용을 포기하고 다른 방식의 해결 방법을 찾기로 했다.
임시 해결책
프로젝트의 마감 기한 및 유저 테스트를 위해 빠른 처리가 필요했기 때문에 게임 엔티티 생성을 StartRound 메서드가 아닌 GameReady 메서드로 변경하고 동시성 충돌이 발생할 수 있는 부분을 boolean 타입을 통해 최초 1번 만 설정되도록 코드를 수정했다.
GameReady 메서드
@Transactional
public GameStatus gameReady(Long gameRoomId, Principal principal) {
return totalGameReadyTimer.record(() -> {
User user = userRepository.findByEmail(principal.getName()).orElseThrow(() -> new RestApiException(ErrorCode.NOT_FOUND_GAME_USER.getMessage()));
GameRoom gameRoom = gameRoomRepository.findById(gameRoomId).orElseThrow(() -> new RestApiException(ErrorCode.NOT_FOUND_GAME_ROOM.getMessage()));
ValidateRoom validateRoom = validateRoomRepository.findByGameRoomAndParticipants(gameRoom, user.getNickname()).orElseThrow(() -> new RestApiException(ErrorCode.GAME_ROOM_NOW_FULL.getMessage()));
if (!checkReadyPoints(user)) {
throw new RestApiException(INSUFFICIENT_POINTS.getMessage());
}
validateRoom.revert(validateRoom.isReady());
Timer.Sample getValidateRoomTimer = Timer.start(registry);
List<ValidateRoom> validateRooms = validateRoomRepository.findAllByGameRoomAndReadyTrue(gameRoom);
getValidateRoomTimer.stop(registry.timer("readyValidate.time"));
if (validateRooms.size() == 2) {
gameValidator.gameValidate(gameRoom);
firstCardShuffle(gameRoom.getCurrentGame());
return new GameStatus(gameRoomId, user.getNickname(), GameState.ALL_READY);
}
if (validateRooms.size() == 1 && validateRoom.isReady() == true) {
return new GameStatus(gameRoomId, user.getNickname(), GameState.READY);
}
if (validateRooms.size() == 1 && validateRoom.isReady() == false) {
return new GameStatus(gameRoomId, user.getNickname(), GameState.UNREADY);
}
return new GameStatus(gameRoomId, user.getNickname(), GameState.NO_ONE_READY);
});
}
게임 엔티티 생성을 게임 방의 두 유저가 모두 Ready를 했을 때 생성되도록 변경을 통해 한 게임방에 2개의 게임 엔티티가 생성되는 문제를 해결할 수 있었으나 기존에 있던 동시성 충돌 문제를 해결한 것이 아니라 동시성 충돌을 회피한 것이므로 추가적으로 동시성 충돌을 방지할 수 있는 처리가 필요하다.
Spring Boot에서 Redis를 사용해 동시성 제어를 구현하는 방법
비관적 락과 Synchronized를 적용하는 것으로도 동시성 충돌 문제를 해결하지 못해 방법을 찾던 중 다른 프로젝트 팀에서 Redis 기능을 사용해서 동시성 제어를 구현했다는 것을 듣게 되었고, Redis를 사용해서 동시성 제어를 구현하기 위해 Redis에서 제공하는 동시성 제어 방법에 대해 알아보았다.
Redis의 동시성 제어 방법
1. 분산 락(Distributed Locking)
- Redis의 'SETNX' 명령어는 키가 존재하지 않을 때만 값을 설정하는데, 이를 활용하여 분산 락을 구현할 수 있다.
- Redisson과 같은 라이브러리는 Redis를 사용하여 보다 고급 기능의 분산 락을 제공한다. Redisson은 자동 잠금 해제, 재진입 가능한 락, 공정한 락 등을 지원한다.
2. Pub/Sub 모델
- Redis의 발행/구독 모델을 사용하면 여러 서비스 인스턴스 간에 메시지 교환할 수 있어, 이벤트 기반 동기화가 필요한 경우 유용하다.
- 하나의 인스턴스가 작업을 완료하고 다른 인스턴스에 신호를 보낼 때 사용할 수 있다.
3. 리스트와 큐
- Redis의 리스트 데이터 구조를 사용하여 작업 큐를 구현할 수 있다. 'LPUSH'와 'RPOP'을 사용하여 작업을 큐에 추가하고, 다른 인스턴에서는 작업을 가져와 처리한다.
- 작업이 여러 처리 단계를 거치는 워크 플로우에 적합할 수 있다.
4. 원자적 연산
- Redis는 'INCR', 'DECR' 같은 원자적 연산을 지원하는데, 이를 활용하여 공유 카운터와 같은 동시성에 민감한 데이터를 안전하게 조작할 수 있다.
- 제한된 자원에 대한 접근 횟수를 제어할 때 사용할 수 있다.
5. 트랜잭션
- Redis의 'MULTI'와 'EXEC' 명령을 사용하여 여러 명령을 한 묶음으로 실행할 수 있다. 이 트랜잭션은 중간에 다른 클라이언트의 요청이 끼어들지 않는 방식으로 실행된다.
- 여러 단계에 걸친데이터 변경을 원자적으로 처리해야 할 때 유용하다.
Redis와 Spring을 통해 동시성 제어 기능을 구현할 때는, 네트워크 지연, 데이터 일관성 및 복원력 등을 고려해야 한다고 한다.
기술 선택
WebSocket을 사용한 1 대 1 웹 게임 애플리케이션에서 같은 게임방에서 동시에 요청을 보낼 때 요청을 순서대로 처리해 줄 필요가 있고, 서버를 1개만 사용하고 있기 때문에 리스트와 큐, 트랜잭션을 사용해 동시성 제어 기능을 구현하기로 결정했다.
분산 락
- 단일 서버 애플리케이션에서는 오버헤드 증가, 복잡성 증가, 비용 비효율 등의 문제가 발생하기 때문에 사용하지 않기로 했다.
- 서버 확장 등으로 인해 분산 락이 필요해질 경우 도입하는 것을 고려하기로 했다.
Pub/Sub 모델
- Pub/Sub 시스템은 메시지를 비동기적으로 교환하기 위한 방법으로 사용되며, 몇 가지리 이유로 인해 동시성 제어 기능 구현에 사용하지 않기로 했다.
- 비보장된 메시지 순서, 상태 없음, 동시성 문제로 인해 추가적인 동기화 메커니즘이 필요하기 때문이다.
리스트와 큐
- 게임방 ID를 Key로 설정하고 요청 정보를 Value로 하여 요청이 들어온 순서대로 우선순위(Score)를 지정해 서버에서 요청을 순서대로 처리할 수 있게할 수 있다고 판단해 사용하기로 결정했다.
- 정확한 순서 보장, 동시성 제어, 확장성 측면의 이점이 있으며, 비교적 구현 방법이 쉽다는 점도 있었다.
원자적 연산
- 복수의 데이터 요소가 관련된 처리의 순서를 관리하거나 전체적인 흐름 제어에는 한계가 있기 때문에 사용하지 않기로 결정했다.
트랜잭션
- 리스트, 큐와 함께 사용하는 것으로 몇 가지 장점을 얻을 수 있기 때문에 사용하기로 결정했다.
- 데이터 일관성 보장, 복잡한 작업 순서의 정확성, 병렬 처리와 동시성 제어, 오류 복구와 안정성 향상, 효율적인 리소스 관리 측면의 이점이 있다.
리스트, 큐와 트랜잭션을 같이 사용할 때 이점
리스트 또는 큐와 트랜잭션을 함께 사용하는 것은 여러 이점을 제공하는데, 특히 데이터의 일관성을 유지하면서 동시에 여러 작업을 안정적으로 처리할 수 있는 환경을 구축할 때 유용하다고 한다.
- 데이터 일관성 보장
- 트랜잭션은 시작부터 종료까지의 모든 연산이 완전히 수행되거나, 아니면 아무것도 적용되지 않도록 보장하는 원자성을 제공한다.
- 이는 리스트나 큐에 데이터를 추가하거나 수정할 때 중요하며, 작업 큐에 작업을 추가하면서 관련된 데이터베이스에도 해당 작업의 상세 정보를 기록해야 할 경우, 트랜잭션을 사용하면 이 두 작업이 모두 성공적으로 수행되거나 둘 다 취소되도록 보장할 수 있다.
- 복잡한 작업 순서의 정확성
- 리스트나 큐를 사용하여 작업을 처리하는 경우, 여러 단계를 거쳐야 할 수 있다.
- 트랜잭션을 사용하면 여러 단계를 한 그룹으로 묶어 처리할 수 있어, 중간 단계에서 오류가 발생할 경우 전체 작업을 롤백하여 데이터의 일관성을 유지할 수 있다.
- 병렬 처리와 동시에 제어
- 트랜잭션은 동시에 여러 사용자나 시스템이 데이터에 접근할 대 발생할 수 있는 충돌을 관리한다.
- 여러 프로세스가 같은 큐에 동시에 접근하여 데이터를 추가하거나 수정할 경우, 트랜잭션을 통해 이러한 접근을 제어하고 데드락이나 레이스 컨디션 없이 안정적으로 처리할 수 있다.
- 오류 복구와 안정성 향상
- 트랜잭션을 사용하면 오류 발생 시 시스템을 이전 상태로 쉽게 되돌릴 수 있다.
- 이는 시스템의 안정성을 크게 향상시킬 뿐만 아니라, 오류 발생 후의 복구 작업을 간소화한다.
- 데이터의 무결성을 보장하는 동시에, 사용자 경험도 개선할 수 있다.
- 효율적인 리소스 관리
- 트랜잭션을 통해 여러 데이터 조작 작업을 한 단위로 묶어 처리함으로써, 네트워크 호출이나 디스크 I/O와 같은 비용이 많이 드는 연산의 수를 줄일 수 있다.
- 이는 시스템의 전체적인 성능을 향상시키고 리소스를 효율적으로 사용할 수 있도록 도와준다.
동시성 제어 기능 구현
Redis를 통해 동시성 제어 기능을 구현하기 위해 기존의 코드를 수정하는 작업을 진행하였고 아래는 전체적인 Redis 사용 동시성 제어 기능 구현 순서이다.
- 기존 SpringBoot 애플리케이션에는 Redis를 사용하고 있었으나 편의상 Redis 설정부터 기록
1. Redis 설정
build.gradle
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
- build.gradle을 통해 의존성을 주입
application.yml
data:
redis:
host: ${dev-redis.host}
port: ${dev-redis.port}
password: ${dev-redis.password}
- application.yml or properties 를 통해 redis 설정(주소, 포트 번호, 비밀번호)
- 보안을 위해 환경 변수를 사용해서 진행했다.
RedisConfig
@Configuration
@EnableRedisRepositories
@RequiredArgsConstructor
public class RedisConfig {
private final RedisProperties redisProperties;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration();
redisStandaloneConfiguration.setHostName(redisProperties.getHost());
redisStandaloneConfiguration.setPort(redisProperties.getPort());
redisStandaloneConfiguration.setPassword(redisProperties.getPassword());
return new LettuceConnectionFactory(redisStandaloneConfiguration);
}
@Bean
public RedisTemplate<String,String> redisTemplate() {
// redisTemplate 를 받아와서 set, get, delete 를 사용
RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
/*
* setKeySerializer, setValueSerializer 설정
* redis-cli 을 통해 직접 데이터를 조회 시 알아볼 수 없는 형태로 출력되는 것을 방지
*/
redisTemplate.setConnectionFactory(redisConnectionFactory());
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new StringRedisSerializer());
return redisTemplate;
}
}
- @Configuration을 통해 Redis 연결 및 템플릿 설정
2. 동시성 제어 - Redis Sorted Set, Transaction 사용
Sorted Set 설정
@Autowired
private RedisTemplate<String, String> redisTemplate;
public void enqueueRequest(String gameRoomId, String requestDetails) {
double score = System.currentTimeMillis(); // 요청 시간을 스코어로 사용
String key = "gameRequests:" + gameRoomId;
redisTemplate.opsForZSet().add(key, requestDetails, score);
}
- Redis Sorted Set을 사용해서 서버에 들어오는 요청을 타임 스탬프에 따라 우선 순위를 결정하고 우선 순위에 따라 요청을 서버로 보낼 수 있도록 설정
요청 처리 메서드
public void processRequests(String gameRoomId) throws JsonProcessingException {
String key = "gameRequests:" + gameRoomId;
log.info("processRequests 시작");
while (true) {
Set<String> requests = redisTemplate.opsForZSet().range(key, 0, 0);
if (requests != null && !requests.isEmpty()) {
String requestJson = requests.iterator().next();
GameRequest request = objectMapper.readValue(requestJson, GameRequest.class);
log.info("executeGameRequest 시작");
executeGameRequest(request);
log.info("executeGameRequest 종료");
redisTemplate.opsForZSet().remove(key, request);
log.info("완료된 요청 삭제");
} else break;
}
}
- Sorted Set에 저장된 요청을 우선 순위대로 꺼내서 서버에 보내는 메서드를 작성
- ObjectMapper를 사용해 직렬화 후 저장한 요청을 역직렬화해서 서버로 요청을 보내도록 처리
Transaction 적용
public void processRequests(String gameRoomId) throws JsonProcessingException {
String key = "gameRequests:" + gameRoomId;
log.info("processRequests 시작");
while (true) {
redisTemplate.watch(key);
List<Object> txResults;
Set<String> requests = redisTemplate.opsForZSet().range(key, 0, 0);
if (requests != null && !requests.isEmpty()) {
String requestJson = requests.iterator().next();
GameRequest request = objectMapper.readValue(requestJson, GameRequest.class);
redisTemplate.multi();
log.info("executeGameRequest 시작");
try {
executeGameRequest(request); // 이제 트랜잭션 내에서 처리
redisTemplate.opsForZSet().remove(key, requestJson);
} catch (Exception e) {
log.error("Request 처리 실패: " + e.getMessage());
redisTemplate.discard(); // 트랜잭션 취소
handleFailure(request, e); // 실패 처리 메소드 호출
continue; // 요청 재처리를 위해 계속 진행
}
txResults = redisTemplate.exec(); // 트랜잭션 실행
if (!txResults.isEmpty()) {
log.info("완료된 요청 삭제");
} else {
log.info("트랜잭션이 중단되었습니다. 다른 프로세스에서 요청이 변경되었을 수 있습니다.");
}
} else {
break;
}
}
redisTemplate.unwatch();
}
- 요청 처리 시 안정성을 높이기 위해 Redis 트랜잭션을 메서드에 추가로 적용하여 요청 실패 시 롤백 후 다시 요청을 보낼 수 있도록 구현
전체 코드
package com.service.indianfrog.domain.game.redis;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.service.indianfrog.domain.game.dto.*;
import com.service.indianfrog.domain.game.service.EndGameService;
import com.service.indianfrog.domain.game.service.GamePlayService;
import com.service.indianfrog.domain.game.service.GameSessionService;
import com.service.indianfrog.domain.game.service.StartGameService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.messaging.simp.SimpMessageSendingOperations;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Set;
@Slf4j
@Service
public class RedisRequestManager {
/* 동시 요청을 관리하기 위한 요청 관리 클래스
* Redis Sorted Set을 사용하여 요청에 대한 우선 순위를 설정하고 우선 순위에 따라 요청이 처리되도록 설정
* GameController에 요청을 보내면 요청을 Sorted Set에 저장 후 우선 순위에 따라 각 게임 서비스 로직 호출*/
private final RedisTemplate<String, String> redisTemplate;
private final ObjectMapper objectMapper;
private final SimpMessageSendingOperations messagingTemplate;
private final StartGameService startGameService;
private final GamePlayService gamePlayService;
private final EndGameService endGameService;
private final GameSessionService gameSessionService;
public RedisRequestManager(RedisTemplate<String, String> redisTemplate, ObjectMapper objectMapper,
SimpMessageSendingOperations messagingTemplate, StartGameService startGameService,
GamePlayService gamePlayService, EndGameService endGameService,
GameSessionService gameSessionService) {
this.redisTemplate = redisTemplate;
this.objectMapper = objectMapper;
this.messagingTemplate = messagingTemplate;
this.startGameService = startGameService;
this.gamePlayService = gamePlayService;
this.endGameService = endGameService;
this.gameSessionService = gameSessionService;
}
/* 요청을 Sorted Set에 저장*/
public void enqueueRequest(String gameRoomId, String requestDetails) {
double score = System.currentTimeMillis(); // 요청 시간을 스코어로 사용
String key = "gameRequests:" + gameRoomId;
redisTemplate.opsForZSet().add(key, requestDetails, score);
}
public void processRequests(String gameRoomId) throws JsonProcessingException {
String key = "gameRequests:" + gameRoomId;
log.info("processRequests 시작");
while (true) {
redisTemplate.watch(key);
List<Object> txResults;
Set<String> requests = redisTemplate.opsForZSet().range(key, 0, 0);
if (requests != null && !requests.isEmpty()) {
String requestJson = requests.iterator().next();
GameRequest request = objectMapper.readValue(requestJson, GameRequest.class);
redisTemplate.multi();
log.info("executeGameRequest 시작");
try {
executeGameRequest(request); // 이제 트랜잭션 내에서 처리
redisTemplate.opsForZSet().remove(key, requestJson);
} catch (Exception e) {
log.error("Request 처리 실패: " + e.getMessage());
redisTemplate.discard(); // 트랜잭션 취소
handleFailure(request, e); // 실패 처리 메소드 호출
continue; // 요청 재처리를 위해 계속 진행
}
txResults = redisTemplate.exec(); // 트랜잭션 실행
if (!txResults.isEmpty()) {
log.info("완료된 요청 삭제");
} else {
log.info("트랜잭션이 중단되었습니다. 다른 프로세스에서 요청이 변경되었을 수 있습니다.");
}
} else {
break;
}
}
redisTemplate.unwatch();
}
private void executeGameRequest(GameRequest request) {
log.info("요청 우선 순위대로 처리");
Long gameRoomId = request.getGameRoomId();
log.info("gameRoomId {}", gameRoomId);
String gameState = request.getGameState();
log.info("gameState -> {}", gameState);
switch (gameState) {
case "START" -> {
GameDto.StartRoundResponse response = startGameService.startRound(gameRoomId, request.getEmail());
sendUserGameMessage(response, request.getEmail());
}
case "ACTION", "USER_CHOICE" -> {
Object response = switch (gameState) {
case "ACTION" ->
gamePlayService.playerAction(gameRoomId, request.getGameBetting());
case "USER_CHOICE" -> gameSessionService.processUserChoices(gameRoomId, request.getUserChoices());
default -> throw new IllegalStateException("Unexpected value: " + gameState);
};
String destination = "/topic/gameRoom/" + gameRoomId;
messagingTemplate.convertAndSend(destination, response);
}
case "END" -> {
GameDto.EndRoundResponse response = endGameService.endRound(gameRoomId, request.getEmail());
sendUserEndRoundMessage(response, request.getEmail());
}
case "GAME_END" -> {
GameDto.EndGameResponse response = endGameService.endGame(gameRoomId);
sendUserEndGameMessage(response, request.getEmail());
}
default -> throw new IllegalStateException("Invalid game state: " + gameState);
}
}
private void sendUserEndRoundMessage(GameDto.EndRoundResponse response, String email) {
log.info("who are you? -> {}", email);
log.info("player's Card : {}", response.getMyCard());
try {
messagingTemplate.convertAndSendToUser(email, "/queue/endRoundInfo", new EndRoundInfo(
response.getNowState(),
response.getNextState(),
response.getRound(),
response.getRoundWinner().getNickname(),
response.getRoundLoser().getNickname(),
response.getRoundPot(),
response.getMyCard()));
log.info("Message sent successfully.");
}
catch (Exception e) {
log.error("Failed to send message", e);
}
}
private void sendUserEndGameMessage(GameDto.EndGameResponse response, String email) {
log.info("who are you? -> {}", email);
try {
messagingTemplate.convertAndSendToUser(email, "/queue/endGameInfo", new EndGameInfo(
response.getNowState(),
response.getNextState(),
response.getGameWinner().getNickname(),
response.getGameLoser().getNickname(),
response.getWinnerPot(),
response.getLoserPot()));
log.info("Message sent successfully.");
} catch (Exception e) {
log.error("Failed to send message", e);
}
}
private void sendUserGameMessage(GameDto.StartRoundResponse response, String email) {
/* 각 Player 에게 상대 카드 정보와 턴 정보를 전송*/
log.info("who are you? -> {}", email);
log.info(response.getGameState(), response.getTurn().toString());
String playerOne = response.getPlayerOne().getEmail();
String playerTwo = response.getPlayerTwo().getEmail();
try {
if (email.equals(playerOne)) {
messagingTemplate.convertAndSendToUser(playerOne, "/queue/gameInfo", new GameInfo(
response.getOtherCard(),
response.getTurn(),
response.getFirstBet(),
response.getRoundPot(),
response.getRound()));
log.info("Message sent successfully.");
}
if (email.equals(playerTwo)) {
messagingTemplate.convertAndSendToUser(playerTwo, "/queue/gameInfo", new GameInfo(
response.getOtherCard(),
response.getTurn(),
response.getFirstBet(),
response.getRoundPot(),
response.getRound()));
log.info("Message sent successfully.");
}
} catch (Exception e) {
log.error("Failed to send message", e);
}
}
private void handleFailure(GameRequest request, Exception e) {
// 로그에 예외 상황을 기록
log.error("Failed to process request: {}, error: {}", request, e.toString());
// 재시도 로직
if (shouldRetry(request)) {
log.info("Scheduling retry for the request: {}", request);
enqueueRequest(request.getGameRoomId().toString(), request.toString());
} else {
log.error("No retry will be attempted for: {}", request);
notifyAdmin(request, e); // 관리자에게 알림
}
}
private boolean shouldRetry(GameRequest request) {
// 여기에서 재시도 여부를 결정하는 로직 구현, 예를 들어 최대 재시도 횟수 확인 등
return true; // 단순 예시
}
private void notifyAdmin(GameRequest request, Exception e) {
// 실패한 요청과 예외 정보를 기반으로 관리자나 개발 팀에 알림
log.warn("Notifying admin about the failure: {}", e.getMessage());
// 여기에 이메일 보내기, 슬랙 메시지 보내기 등의 로직을 구현할 수 있습니다.
}
}
GameController
package com.service.indianfrog.domain.game.controller;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.service.indianfrog.domain.game.dto.GameBetting;
import com.service.indianfrog.domain.game.dto.GameRequest;
import com.service.indianfrog.domain.game.dto.GameStatus;
import com.service.indianfrog.domain.game.dto.UserChoices;
import com.service.indianfrog.domain.game.redis.RedisRequestManager;
import com.service.indianfrog.domain.game.service.*;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.handler.annotation.DestinationVariable;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.messaging.simp.SimpMessageSendingOperations;
import org.springframework.stereotype.Controller;
import java.security.Principal;
@Tag(name = "게임 실행 컨트롤러", description = "인디언 포커 게임 실행 및 종료 컨트롤러입니다.")
@Slf4j
@Controller
public class GameController {
private final SimpMessageSendingOperations messagingTemplate;
private final ReadyService readyService;
private final RedisRequestManager redisRequestManager;
private final ObjectMapper objectMapper;
public GameController(SimpMessageSendingOperations messagingTemplate, ReadyService readyService,
RedisRequestManager redisRequestManager, ObjectMapper objectMapper) {
this.messagingTemplate = messagingTemplate;
this.readyService = readyService;
this.redisRequestManager = redisRequestManager;
this.objectMapper = objectMapper;
}
/* pub 사용 게임 준비 */
@MessageMapping("/gameRoom/{gameRoomId}/ready")
public void gameReady(
@DestinationVariable Long gameRoomId, Principal principal) {
log.info("게임 준비 - 게임방 아이디 : {}", gameRoomId);
GameStatus gameStatus = readyService.gameReady(gameRoomId, principal);
String destination = "/topic/gameRoom/" + gameRoomId;
messagingTemplate.convertAndSend(destination, gameStatus);
}
@MessageMapping("/gameRoom/{gameRoomId}/{gameState}")
public void handleGameState(@DestinationVariable Long gameRoomId, @DestinationVariable String gameState,
@Payload(required = false) GameBetting gameBetting, @Payload(required = false) UserChoices userChoices, Principal principal) throws JsonProcessingException {
/* 요청을 Redis Sorted Set에 저장*/
String email = principal.getName();
GameRequest request = new GameRequest(gameRoomId, gameState, email, gameBetting, userChoices);
String requestJson = objectMapper.writeValueAsString(request);
redisRequestManager.enqueueRequest(gameRoomId.toString(), requestJson);
log.info("Request for gameState: {} in gameRoom: {} has been enqueued", gameState, gameRoomId);
/* 요청을 순서대로 실행*/
redisRequestManager.processRequests(gameRoomId.toString());
log.info("processRequests 완료");
}
}
테스트 및 적용
유저 테스트 및 기타 문제(버그) 해결로 인해 유저 테스트 기간 종료 후 적용 및 테스트를 실시할 예정