1. 실시간 동기화가 필요한 빠칭코 게임
빠칭코 게임은 사용자가 6x6의 36개 칸 중 3개의 칸을 선택하는 방식으로 진행된다. 하나의 게임 라운드에서는 모든 참가자가 동시에 게임에 참여하며, 특정 칸을 선택하면 다른 모든 사용자에게 즉시 반영되어야 한다. 또한, 게임이 종료되면 보상이 즉시 지급되며, 10초 후 새로운 게임이 자동으로 시작되어야 한다.
이러한 요구사항을 만족시키기 위해 서버와 클라이언트 간의 실시간 양방향 통신이 필수적이었다. 기존의 HTTP 기반의 요청-응답 방식으로 구현할 경우, 사용자의 선택이 실시간으로 반영되지 않고, 주기적으로 서버에 요청을 보내야 하는 비효율적인 구조가 될 수 있다. 이를 해결하기 위해 웹소켓(WebSocket)을 사용하여 지속적인 연결을 유지하면서 실시간으로 데이터를 주고받는 방식을 선택하였다.
2. 웹소켓(WebSocket) 개념 및 선택 이유
웹소켓이란?
웹소켓(WebSocket)은 브라우저와 서버 간의 양방향 통신을 가능하게 하는 프로토콜이다. HTTP는 클라이언트가 요청을 보낼 때만 서버가 응답을 보내는 방식으로 동작하지만, 웹소켓은 한 번 연결된 후 지속적인 데이터 교환이 가능하다. 이를 통해 서버가 필요할 때 즉시 데이터를 클라이언트로 전송할 수 있으며, 클라이언트 또한 실시간으로 서버에 데이터를 보낼 수 있다.
왜 웹소켓을 선택했는가?
빠칭코 게임의 요구사항은 다음과 같다.
- 사용자가 특정 칸을 선택하면 다른 모든 사용자에게 즉시 반영되어야 한다.
- 게임이 끝난 후 일정 시간이 지나면 새로운 판이 시작되어야 한다.
- 여러 사용자가 동시에 접속해도 성능 저하 없이 동작해야 한다.
이러한 실시간 기능을 구현하기 위해 몇 가지 대안을 고려했다.
- Polling (주기적인 HTTP 요청)
사용자가 일정 간격으로 서버에 상태를 요청하는 방식 → 불필요한 요청이 많아지고, 실시간성이 떨어짐 - Server-Sent Events (SSE)
서버에서 클라이언트에게만 데이터를 push하는 방식 → 양방향 통신이 불가능 - WebSocket
양방향 통신이 가능하며, 최소한의 네트워크 리소스를 사용하면서 실시간 반영이 가능함 → 적합한 선택
3. 빠칭코 게임에서 양방향 통신이 필요한 이유
빠칭코 게임은 여러 사용자가 동시에 참여하며, 특정 칸을 선택할 때 즉시 다른 사용자에게 반영되어야 하는 특성을 가진다. 단방향 통신 방식(예: HTTP, SSE)으로는 사용자의 선택이 즉각적으로 반영되지 않으며, 다른 사용자가 변경한 게임 상태를 주기적으로 조회해야 하는 문제가 발생한다. 반면, 웹소켓을 활용하면 클라이언트와 서버가 지속적으로 연결되어 실시간으로 이벤트를 주고받을 수 있다.
양방향 통신이 필요한 주요 이유는 다음과 같다.
- 사용자의 칸 선택이 다른 모든 사용자에게 즉시 반영되어야 한다.
→ 사용자가 특정 칸을 선택하면, 서버는 이를 저장하고 즉시 모든 사용자에게 브로드캐스트하여 게임 화면을 동기화해야 한다. - 게임이 끝나면 모든 사용자에게 즉시 알림을 보내야 한다.
→ 36개의 칸이 모두 선택되면, 즉시 "게임 종료" 메시지를 모든 사용자에게 전송해야 한다. - 10초 후 새로운 게임이 시작될 때 자동으로 참가자들에게 알림을 전달해야 한다.
→ 게임이 끝난 후 10초 동안 카운트다운을 진행하며 매초마다 모든 사용자에게 남은 시간을 전송해야 한다. 이후 자동으로 새 게임이 시작되었음을 알리는 메시지를 전송한다.
4. 프로젝트의 전체적인 구조
주요 기능 및 흐름
- 사용자가 게임에 접속하면 웹소켓을 통해 서버와 연결된다.
- 각 게임 라운드가 시작되면 36개의 칸에 보상이 무작위로 배치된다.
- 사용자는 6x6의 36칸 중 3칸을 선택할 수 있다.
- 선택된 칸은 다른 모든 사용자들에게 즉시 반영된다.
- 모든 칸이 선택되면 게임이 종료되며, 사용자에게 보상이 지급된다.
- 10초 동안 카운트다운이 진행되며, 모든 사용자에게 "새로운 판이 시작됩니다."라는 메시지가 브로드캐스트된다.
- 10초 후 새로운 게임 라운드가 시작되며, 모든 사용자의 선택이 초기화된다.
- 서버가 재시작되더라도 현재 게임 상태가 유지되도록 구현된다.
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을 활용하여 실시간 게임 상태 동기화, 보상 지급, 카운트다운 진행을 원활하게 구현할 수 있었다!
'Backend > Spring Boot' 카테고리의 다른 글
[Spring Data Jpa] 3가지 방법으로 N+1 문제 해결해보기 (Fetch Join, Batch Fetching, EntityGraph) (1) | 2025.01.16 |
---|---|
FCM 프로젝트 만들기 (Firebase 2024 최신버전) (0) | 2024.11.26 |
[Spring/Java] 민감한 개인정보 AES 알고리즘으로 암호화/복호화하기 (1) | 2024.11.14 |
[Spring Boot] 로깅이란? 3가지 Logging 방식 소개 (0) | 2024.11.11 |
[Spring/Java] FCM을 통해 Push 알림 보내기 (0) | 2024.07.08 |