본문 바로가기
Backend/Spring Boot

[Spring/Java] 웹소켓 이용하여 간단하게 빠칭코(게임) 구현하기

by persi0815 2024. 11. 14.

1. 실시간 동기화가 필요한 빠칭코 게임

빠칭코 게임은 사용자가 6x6의 36개 칸 중 3개의 칸을 선택하는 방식으로 진행된다. 하나의 게임 라운드에서는 모든 참가자가 동시에 게임에 참여하며, 특정 칸을 선택하면 다른 모든 사용자에게 즉시 반영되어야 한다. 또한, 게임이 종료되면 보상이 즉시 지급되며, 10초 후 새로운 게임이 자동으로 시작되어야 한다.

이러한 요구사항을 만족시키기 위해 서버와 클라이언트 간의 실시간 양방향 통신이 필수적이었다. 기존의 HTTP 기반의 요청-응답 방식으로 구현할 경우, 사용자의 선택이 실시간으로 반영되지 않고, 주기적으로 서버에 요청을 보내야 하는 비효율적인 구조가 될 수 있다. 이를 해결하기 위해 웹소켓(WebSocket)을 사용하여 지속적인 연결을 유지하면서 실시간으로 데이터를 주고받는 방식을 선택하였다.

 

2. 웹소켓(WebSocket) 개념 및 선택 이유

웹소켓이란?

웹소켓(WebSocket)은 브라우저와 서버 간의 양방향 통신을 가능하게 하는 프로토콜이다. HTTP는 클라이언트가 요청을 보낼 때만 서버가 응답을 보내는 방식으로 동작하지만, 웹소켓은 한 번 연결된 후 지속적인 데이터 교환이 가능하다. 이를 통해 서버가 필요할 때 즉시 데이터를 클라이언트로 전송할 수 있으며, 클라이언트 또한 실시간으로 서버에 데이터를 보낼 수 있다.

왜 웹소켓을 선택했는가?

빠칭코 게임의 요구사항은 다음과 같다.

  1. 사용자가 특정 칸을 선택하면 다른 모든 사용자에게 즉시 반영되어야 한다.
  2. 게임이 끝난 후 일정 시간이 지나면 새로운 판이 시작되어야 한다.
  3. 여러 사용자가 동시에 접속해도 성능 저하 없이 동작해야 한다.

이러한 실시간 기능을 구현하기 위해 몇 가지 대안을 고려했다.

  • Polling (주기적인 HTTP 요청)
    사용자가 일정 간격으로 서버에 상태를 요청하는 방식 → 불필요한 요청이 많아지고, 실시간성이 떨어짐
  • Server-Sent Events (SSE)
    서버에서 클라이언트에게만 데이터를 push하는 방식 → 양방향 통신이 불가능
  • WebSocket
    양방향 통신이 가능하며, 최소한의 네트워크 리소스를 사용하면서 실시간 반영이 가능함 → 적합한 선택

 

3. 빠칭코 게임에서 양방향 통신이 필요한 이유

빠칭코 게임은 여러 사용자가 동시에 참여하며, 특정 칸을 선택할 때 즉시 다른 사용자에게 반영되어야 하는 특성을 가진다. 단방향 통신 방식(예: HTTP, SSE)으로는 사용자의 선택이 즉각적으로 반영되지 않으며, 다른 사용자가 변경한 게임 상태를 주기적으로 조회해야 하는 문제가 발생한다. 반면, 웹소켓을 활용하면 클라이언트와 서버가 지속적으로 연결되어 실시간으로 이벤트를 주고받을 수 있다.

양방향 통신이 필요한 주요 이유는 다음과 같다.

  1. 사용자의 칸 선택이 다른 모든 사용자에게 즉시 반영되어야 한다.
    → 사용자가 특정 칸을 선택하면, 서버는 이를 저장하고 즉시 모든 사용자에게 브로드캐스트하여 게임 화면을 동기화해야 한다.
  2. 게임이 끝나면 모든 사용자에게 즉시 알림을 보내야 한다.
    → 36개의 칸이 모두 선택되면, 즉시 "게임 종료" 메시지를 모든 사용자에게 전송해야 한다.
  3. 10초 후 새로운 게임이 시작될 때 자동으로 참가자들에게 알림을 전달해야 한다.
    → 게임이 끝난 후 10초 동안 카운트다운을 진행하며 매초마다 모든 사용자에게 남은 시간을 전송해야 한다. 이후 자동으로 새 게임이 시작되었음을 알리는 메시지를 전송한다.

 

4. 프로젝트의 전체적인 구조

주요 기능 및 흐름

  1. 사용자가 게임에 접속하면 웹소켓을 통해 서버와 연결된다.
  2. 각 게임 라운드가 시작되면 36개의 칸에 보상이 무작위로 배치된다.
  3. 사용자는 6x6의 36칸 중 3칸을 선택할 수 있다.
  4. 선택된 칸은 다른 모든 사용자들에게 즉시 반영된다.
  5. 모든 칸이 선택되면 게임이 종료되며, 사용자에게 보상이 지급된다.
  6. 10초 동안 카운트다운이 진행되며, 모든 사용자에게 "새로운 판이 시작됩니다."라는 메시지가 브로드캐스트된다.
  7. 10초 후 새로운 게임 라운드가 시작되며, 모든 사용자의 선택이 초기화된다.
  8. 서버가 재시작되더라도 현재 게임 상태가 유지되도록 구현된다.

 

5. 주요 로직 및 코드 분석

 

1) WebSocket 핸들러 구현 (PachinkoWebSocketHandler)

WebSocket 핸들러는 TextWebSocketHandler를 상속하여 구현하며, 클라이언트의 연결을 관리하고, 메시지를 주고받는 역할을 수행한다.

@Component
@RequiredArgsConstructor
public class PachinkoWebSocketHandler extends TextWebSocketHandler {
...
}

 

실질적으로 사용자의 연결과 관련없는 비즈니스 로직은 Service에 넣었다. 

 

(1) 연결이 설정되었을 때

@Override
public void afterConnectionEstablished(WebSocketSession session) {
    sessions.add(session);
    System.out.println("새로운 사용자 접속: " + session.getId());
}

→ 사용자가 접속하면 세션을 sessions 리스트에 추가하여 관리한다.

 

(2) 메시지 수신 및 처리

@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
    JsonNode node = objectMapper.readTree(message.getPayload());

    if (!session.getAttributes().containsKey("user") && node.has("token")) {
        String token = node.get("token").asText();
        if (jwtTokenUtils.validateToken(token)) {
            String username = jwtTokenUtils.getUsernameFromToken(token);
            User user = userService.findByUserName(username);
            session.getAttributes().put("user", user);
        } else {
            session.close();
            return;
        }
    }

    if (node.has("square")) {
        int selectedSquare = node.get("square").asInt();
        User user = (User) session.getAttributes().get("user");

        if (user != null) {
            processSquareSelection(session, user, selectedSquare);
        } else {
            sendMessage(session, "유저가 인증되지 않았습니다.");
        }
    }
}

→ 첫 메시지에서 JWT 검증 후 세션에 사용자 정보를 저장하고, 이후 메시지는 칸 선택 처리 로직을 실행한다.

 

private void processSquareSelection(WebSocketSession session, User user, long currentRound, int selectedSquare) {
    System.out.println(pachinkoService.viewSelectedSquares() + "핸들러에서 processSquareSelection 시작지점");

    String result = pachinkoService.selectSquare(user, currentRound, selectedSquare);
    System.out.println(pachinkoService.viewSelectedSquares() + "핸들러에서 processSquareSelection 시작지점");

    if (Objects.equals(result, "정상적으로 선택 완료되었습니다.")) {
        broadcastMessage(user.getNickname() + "가 " + selectedSquare + "을 선택했습니다.");
        checkGameStatusAndCloseSessionsIfNeeded();
    } else if (Objects.equals(result, "다른 사용자가 이전에 선택한 칸입니다.")) {
        sendMessage(session, selectedSquare + "번째 칸은 이미 다른 사용자에 의해 선택되었습니다.");
    } else if (Objects.equals(result, "본인이 이전에 선택한 칸입니다.")) {
        sendMessage(session, selectedSquare + "번째 칸은 본인이 이전에 선택한 칸입니다.");
    } else {
        sendMessage(session, "이미 3칸을 선택하셔서 더 이상 칸을 선택할 수 없습니다.");
    }
}

service의 selectSquare를 호출해서 관련 비즈니스 로직을 실행시키고, 결과를 message를 통해 사용자에게 알린다.

 

2) 빠칭코 게임 상태 관리 (PachinkoService)

(0) 기본 설정 - 보상 랜덤 배치

public void assignRewardsToSquares(Long currentRound) {
    List<String> rewards = new ArrayList<>();
    
    rewards.addAll(Collections.nCopies(1, "S1"));  // S급 보석 1개
    rewards.addAll(Collections.nCopies(4, "A1"));  // A급 보석 1개
    rewards.addAll(Collections.nCopies(5, "B2"));  // B급 보석 2개
    rewards.addAll(Collections.nCopies(10, "B1")); // B급 보석 1개
    rewards.addAll(Collections.nCopies(16, "F"));  // 꽝

    Collections.shuffle(rewards, new SecureRandom());

    for (int i = 0; i < 36; i++) {
        pachinkoRepository.save(new Pachinko(currentRound, i + 1, rewards.get(i)));
    }
}

→ 매판이 시작될때마다 각 칸에 보상을 배치하는데, 보상이 무작위로 배치되도록 Collections.shuffle()을 사용한다.

 

(1) 칸의 유효성 검증 한 후, 해당 칸을 선택했다는 정보를 저장

유저가 칸 선택했을 때 Handler로부터 호출된다. 

@Transactional
@Retryable(
        value = DataIntegrityViolationException.class,
        maxAttempts = 3,
        backoff = @Backoff(delay = 100, multiplier = 2)
)
public String selectSquare(User user, long currentRound, int squareNumber) {
    // 1. 칸 번호 유효성 검증
    validateSquareNumber(squareNumber);

    // 2. 이미 선택된 칸인지 확인
    if (selectedSquares.contains(squareNumber)) {
        log.info("{} 이미 {}가 존재합니다.", selectedSquares, squareNumber);
        if (isUserSelected(user, currentRound, squareNumber)) {
            log.info("본인이 이전에 선택한 칸입니다.");
            return "본인이 이전에 선택한 칸입니다.";
        } else {
            log.info("다른 사용자가 이전에 선택한 칸입니다.");
            return "다른 사용자가 이전에 선택한 칸입니다.";
        }
    }

    // 3. 사용자 Pachinko 상태 조회 및 초기화
    UserPachinko userPachinko = userpachinkoRepository.findByUserAndRoundForUpdate(user, currentRound)
            .orElseGet(() -> initializeUserPachinko(user, currentRound));

    // 4. 칸 추가 로직 & 더 이상 선택할 수 없는 경우 처리
    if (!userPachinko.addSquare(squareNumber)) {
        log.info("이미 세 칸을 선택하셨습니다.");
        return "이미 세 개의 칸을 선택하셨습니다.";
    }

    // 5. 사용자 Pachinko 상태 저장
    userpachinkoRepository.save(userPachinko);
    log.info("user pachinko에 선택한 칸인 {}을 저장했습니다.", squareNumber);

    // 6. 보석 차감 로직
    deductUserJewel(user);
    log.info("빠칭코 칸 선택을 위해 B급 보석 하나를 지불하여 DB에서 보석을 차감했습니다.");

    // 7. 선택한 칸을 set에 추가
    addSelectedSquare(squareNumber);
    log.info("선택한 칸을 set에 삽입했습니다. 변경된 set: {}", selectedSquares);

    return "정상적으로 선택 완료되었습니다.";
}

 

(2) 게임 끝남을 감지, 카운트다운 & 보상 전달

private void checkGameStatusAndCloseSessionsIfNeeded() {
    if (pachinkoService.isGameOver()) {
        System.out.println("게임 끝남 확인. 보상 전달 시작");
        broadcastMessage("해당 판이 종료되었습니다. 10초 후 새로운 판이 시작됩니다.");

        Thread rewardThread = new Thread(() -> {
            try {
                pachinkoService.giveRewards();
                broadcastMessage("보상 전달이 완료되었습니다.");
            } catch (Exception e) {
                e.printStackTrace();
            }
        });

        Thread countdownThread = new Thread(() -> {
            try {
                countdownAndNotifyPlayers(10);
                startNewRoundAndNotifyPlayers();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

        // 두 작업을 비동기로 병렬 실행
        rewardThread.start();
        countdownThread.start();
    }
}

→ 유저들이 칸을 선택할때마다 모든 칸이 다 선택되었나 확인한 후, 다 선택되었을때, 게임을 끝낸다. 이때, 해당 판의 보상을 유저들에게 전달하며, 다음판 시작을 알리는 카운트다운을 실행한다. 

 

(2-1) 게임 끝남 감지 

public boolean isGameOver() {
    System.out.println("모든 칸 선택 되었나 확인중");
    return (selectedSquares.size() == TOTAL_PACHINKO_SQUARE_COUNT);
}

 

(2-2) 카운트다운

private void countdownAndNotifyPlayers(int seconds) throws InterruptedException {
    for (int i = seconds; i > 0; i--) {
        broadcastMessage(i + "초 후에 새로운 게임이 시작됩니다.");
        Thread.sleep(1000);
    }
}

 

(2-3) 보상 지급

@Transactional
public void giveRewards() {
    List<UserPachinko> userPachinkoList = userpachinkoRepository.findByRound(currentRound);
    
    for (UserPachinko userPachinko : userPachinkoList) {
        User user = userPachinko.getUser();
        
        int square = userPachinko.getSquare1();
        Pachinko pachinko = pachinkoRepository.findByRoundAndSquare(currentRound, square);
        if (!pachinko.getJewelType().equals("F")) {
            userJewelRepository.updateUserJewel(user, pachinko.getJewelType(), pachinko.getJewelNum());
        }
    }
}

사용자가 선택한 칸의 보상을 조회 후 지급한다.

 

(3) 카운트 다운 후 새로운 게임 시작

private void countdownAndNotifyPlayers(int seconds) throws InterruptedException {
    for (int i = seconds; i > 0; i--) {
        broadcastMessage(i + "초 후에 새로운 게임이 시작됩니다.");
        Thread.sleep(1000);
    }
    startNewRoundAndNotifyPlayers();
}

게임 종료 후 10초 동안 카운트다운을 진행한 뒤 자동으로 새 판을 시작한다.

 

 

이처럼 WebSocket을 활용하여 실시간 게임 상태 동기화, 보상 지급, 카운트다운 진행을 원활하게 구현할 수 있었다!