Skip to content

Commit b230602

Browse files
committed
✨ 하트비트 구현
1 parent 15bb20c commit b230602

File tree

11 files changed

+166
-48
lines changed

11 files changed

+166
-48
lines changed

backend/src/main/java/io/f1/backend/BackendApplication.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
import org.springframework.boot.autoconfigure.SpringBootApplication;
77
import org.springframework.boot.context.properties.EnableConfigurationProperties;
88
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
9+
import org.springframework.scheduling.annotation.EnableScheduling;
910

11+
@EnableScheduling
1012
@EnableJpaAuditing
1113
@SpringBootApplication
1214
@EnableConfigurationProperties(OAuthRedirectProperties.class)

backend/src/main/java/io/f1/backend/domain/game/app/RoomService.java

Lines changed: 28 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import static io.f1.backend.domain.game.mapper.RoomMapper.toRoomSetting;
1010
import static io.f1.backend.domain.game.mapper.RoomMapper.toRoomSettingResponse;
1111
import static io.f1.backend.domain.game.websocket.WebSocketUtils.getDestination;
12+
import static io.f1.backend.domain.game.websocket.WebSocketUtils.getUserDestination;
1213
import static io.f1.backend.domain.quiz.mapper.QuizMapper.toGameStartResponse;
1314
import static io.f1.backend.global.security.util.SecurityUtils.getCurrentUserId;
1415
import static io.f1.backend.global.security.util.SecurityUtils.getCurrentUserNickname;
@@ -44,18 +45,15 @@
4445
import io.f1.backend.global.exception.CustomException;
4546
import io.f1.backend.global.exception.errorcode.RoomErrorCode;
4647
import io.f1.backend.global.exception.errorcode.UserErrorCode;
47-
48-
import lombok.RequiredArgsConstructor;
49-
import lombok.extern.slf4j.Slf4j;
50-
51-
import org.springframework.context.ApplicationEventPublisher;
52-
import org.springframework.stereotype.Service;
53-
5448
import java.util.List;
5549
import java.util.Map;
5650
import java.util.Optional;
5751
import java.util.concurrent.ConcurrentHashMap;
5852
import java.util.concurrent.atomic.AtomicLong;
53+
import lombok.RequiredArgsConstructor;
54+
import lombok.extern.slf4j.Slf4j;
55+
import org.springframework.context.ApplicationEventPublisher;
56+
import org.springframework.stereotype.Service;
5957

6058
@Slf4j
6159
@Service
@@ -93,7 +91,7 @@ public RoomCreateResponse saveRoom(RoomCreateRequest request) {
9391
roomRepository.saveRoom(room);
9492

9593
/* 다른 방 접속 시 기존 방은 exit 처리 - 탭 동시 로그인 시 (disconnected 리스너 작동x) */
96-
exitIfInAnotherRoom(room, host.getId());
94+
exitIfInAnotherRoom(room, getCurrentUserPrincipal());
9795

9896
eventPublisher.publishEvent(new RoomCreatedEvent(room, quiz));
9997

@@ -112,7 +110,7 @@ public void enterRoom(RoomValidationRequest request) {
112110
Long userId = getCurrentUserId();
113111

114112
/* 다른 방 접속 시 기존 방은 exit 처리 - 탭 동시 로그인 시 (disconnected 리스너 작동x) */
115-
exitIfInAnotherRoom(room, userId);
113+
exitIfInAnotherRoom(room, getCurrentUserPrincipal() );
116114

117115
/* reconnect */
118116
if (room.hasPlayer(userId)) {
@@ -137,16 +135,12 @@ public void enterRoom(RoomValidationRequest request) {
137135
}
138136
}
139137

140-
private void exitIfInAnotherRoom(Room room, Long userId) {
141-
142-
Long joinedRoomId = userRoomRepository.getRoomId(userId);
138+
private void exitIfInAnotherRoom(Room room,UserPrincipal userPrincipal) {
139+
Long userId = userPrincipal.getUserId();
140+
Long joinedRoomId = getRoomIdByUserId(userId);
143141

144142
if (joinedRoomId != null && !room.isSameRoom(joinedRoomId)) {
145-
if (room.isPlaying()) {
146-
changeConnectedStatus(userId, ConnectionState.DISCONNECTED);
147-
} else {
148-
exitRoom(joinedRoomId, getCurrentUserPrincipal());
149-
}
143+
disconnectOrExitRoom(joinedRoomId, userPrincipal);
150144
}
151145
}
152146

@@ -161,7 +155,7 @@ public void initializeRoomSocket(Long roomId, UserPrincipal principal) {
161155

162156
/* 재연결 */
163157
if (room.isPlayerInState(userId, ConnectionState.DISCONNECTED)) {
164-
changeConnectedStatus(userId, ConnectionState.CONNECTED);
158+
changeConnectedStatus(roomId,userId, ConnectionState.CONNECTED);
165159
cancelTask(userId);
166160
reconnectSendResponse(roomId, principal);
167161
return;
@@ -187,7 +181,7 @@ public void initializeRoomSocket(Long roomId, UserPrincipal principal) {
187181
userRoomRepository.addUser(player, room);
188182

189183
messageSender.sendPersonal(
190-
getUserDestination(), MessageType.GAME_SETTING, gameSettingResponse, principal);
184+
getUserDestination(), MessageType.GAME_SETTING, gameSettingResponse, principal.getName());
191185

192186
messageSender.sendBroadcast(destination, MessageType.ROOM_SETTING, roomSettingResponse);
193187
messageSender.sendBroadcast(destination, MessageType.PLAYER_LIST, playerListResponse);
@@ -215,7 +209,7 @@ public void exitRoom(Long roomId, UserPrincipal principal) {
215209
getUserDestination(),
216210
MessageType.EXIT_SUCCESS,
217211
new ExitSuccessResponse(true),
218-
principal);
212+
principal.getName());
219213

220214
SystemNoticeResponse systemNoticeResponse =
221215
ofPlayerEvent(removePlayer.nickname, RoomEventType.EXIT);
@@ -260,17 +254,17 @@ public void reconnectSendResponse(Long roomId, UserPrincipal principal) {
260254
MessageType.SYSTEM_NOTICE,
261255
ofPlayerEvent(
262256
principal.getUserNickname(), RoomEventType.RECONNECT_PRIVATE_NOTICE),
263-
principal);
257+
principal.getName());
264258
messageSender.sendPersonal(
265259
userDestination,
266260
MessageType.RANK_UPDATE,
267261
toRankUpdateResponse(room),
268-
principal);
262+
principal.getName());
269263
messageSender.sendPersonal(
270264
userDestination,
271265
MessageType.GAME_START,
272266
toGameStartResponse(room.getQuestions()),
273-
principal);
267+
principal.getName());
274268
} else {
275269
RoomSettingResponse roomSettingResponse = toRoomSettingResponse(room);
276270

@@ -284,33 +278,30 @@ public void reconnectSendResponse(Long roomId, UserPrincipal principal) {
284278
PlayerListResponse playerListResponse = toPlayerListResponse(room);
285279

286280
messageSender.sendPersonal(
287-
userDestination, MessageType.ROOM_SETTING, roomSettingResponse, principal);
281+
userDestination, MessageType.ROOM_SETTING, roomSettingResponse, principal.getName());
288282
messageSender.sendPersonal(
289-
userDestination, MessageType.PLAYER_LIST, playerListResponse, principal);
283+
userDestination, MessageType.PLAYER_LIST, playerListResponse, principal.getName());
290284
messageSender.sendPersonal(
291-
userDestination, MessageType.GAME_SETTING, gameSettingResponse, principal);
285+
userDestination, MessageType.GAME_SETTING, gameSettingResponse, principal.getName());
292286
}
293287
}
294288

295-
public Long changeConnectedStatus(Long userId, ConnectionState newState) {
296-
Long roomId = userRoomRepository.getRoomId(userId);
289+
public void changeConnectedStatus(Long roomId,Long userId, ConnectionState newState) {
297290
Room room = findRoom(roomId);
298-
299291
room.updatePlayerConnectionState(userId, newState);
300-
301-
return roomId;
302292
}
303293

304294
public void cancelTask(Long userId) {
305295
disconnectTasks.cancelDisconnectTask(userId);
306296
}
307297

308-
public void exitIfNotPlaying(Long roomId, UserPrincipal principal) {
298+
public void disconnectOrExitRoom(Long roomId , UserPrincipal principal) {
309299
Room room = findRoom(roomId);
310300
if (room.isPlaying()) {
301+
changeConnectedStatus(room.getId(), principal.getUserId(), ConnectionState.DISCONNECTED);
311302
removeUserRepository(principal.getUserId(), roomId);
312303
} else {
313-
exitRoom(roomId, principal);
304+
exitRoom(room.getId(), principal);
314305
}
315306
}
316307

@@ -349,10 +340,6 @@ private void changeHost(Room room, Player host) {
349340
nextHost.orElseThrow(() -> new CustomException(RoomErrorCode.PLAYER_NOT_FOUND)));
350341
}
351342

352-
private String getUserDestination() {
353-
return "/queue";
354-
}
355-
356343
public void exitRoomForDisconnectedPlayer(Long roomId, Player player) {
357344

358345
Object lock = roomLocks.computeIfAbsent(roomId, k -> new Object());
@@ -417,4 +404,8 @@ public void removeUserRepository(Long userId, Long roomId) {
417404
public boolean isUserInAnyRoom(Long userId) {
418405
return userRoomRepository.isUserInAnyRoom(userId);
419406
}
407+
408+
public Long getRoomIdByUserId(Long userId) {
409+
return userRoomRepository.getRoomId(userId);
410+
}
420411
}

backend/src/main/java/io/f1/backend/domain/game/dto/MessageType.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,6 @@ public enum MessageType {
1111
RANK_UPDATE,
1212
QUESTION_START,
1313
GAME_RESULT,
14-
EXIT_SUCCESS
14+
EXIT_SUCCESS,
15+
HEARTBEAT
1516
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package io.f1.backend.domain.game.dto.response;
2+
3+
public record HeartbeatResponse (String direction){
4+
5+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package io.f1.backend.domain.game.websocket;
2+
3+
import static io.f1.backend.domain.game.websocket.WebSocketUtils.getUserDestination;
4+
5+
import io.f1.backend.domain.game.app.RoomService;
6+
import io.f1.backend.domain.game.dto.MessageType;
7+
import io.f1.backend.domain.game.dto.response.HeartbeatResponse;
8+
import io.f1.backend.domain.user.dto.UserPrincipal;
9+
import java.security.Principal;
10+
import java.util.Map;
11+
import java.util.concurrent.ConcurrentHashMap;
12+
import lombok.RequiredArgsConstructor;
13+
import lombok.extern.slf4j.Slf4j;
14+
import org.springframework.messaging.simp.user.SimpSession;
15+
import org.springframework.messaging.simp.user.SimpUser;
16+
import org.springframework.messaging.simp.user.SimpUserRegistry;
17+
import org.springframework.scheduling.annotation.Scheduled;
18+
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
19+
import org.springframework.stereotype.Component;
20+
21+
@Slf4j
22+
@Component
23+
@RequiredArgsConstructor
24+
public class HeartbeatMonitor {
25+
26+
private static final String DIRECTION = "serverToClient";
27+
private static final int MAX_MISSED_HEARTBEATS = 3;
28+
private static final long HEARTBEAT_CHECK_INTERVAL_MS = 15000L;
29+
30+
private final Map<String, Integer> missedPongCounter = new ConcurrentHashMap<>();
31+
32+
private final MessageSender messageSender;
33+
private final RoomService roomService;
34+
private final SimpUserRegistry simpUserRegistry;
35+
36+
@Scheduled(fixedDelay = HEARTBEAT_CHECK_INTERVAL_MS)
37+
public void monitorClientHeartbeat() {
38+
/* user 없으면 skip */
39+
if (simpUserRegistry.getUserCount() == 0) {
40+
return;
41+
}
42+
43+
simpUserRegistry.getUsers().forEach(user ->
44+
user.getSessions().forEach(session -> handleSessionHeartbeat(user, session)));
45+
46+
}
47+
48+
private void handleSessionHeartbeat(SimpUser user, SimpSession session) {
49+
String sessionId = session.getId();
50+
51+
/* pong */
52+
messageSender.sendPersonal(getUserDestination(),
53+
MessageType.HEARTBEAT, new HeartbeatResponse(DIRECTION), user.getName());
54+
55+
missedPongCounter.merge(sessionId, 1, Integer::sum);
56+
int missedCnt = missedPongCounter.get(sessionId);
57+
58+
/* max_missed_heartbeats 이상 pong 이 안왔을때 - disconnect 처리 */
59+
if (missedCnt >= MAX_MISSED_HEARTBEATS) {
60+
61+
Principal principal = user.getPrincipal();
62+
63+
if (principal instanceof UsernamePasswordAuthenticationToken token &&
64+
token.getPrincipal() instanceof UserPrincipal userPrincipal) {
65+
66+
Long userId = userPrincipal.getUserId();
67+
Long roomId = roomService.getRoomIdByUserId(userId);
68+
69+
roomService.disconnectOrExitRoom(roomId, userPrincipal);
70+
}
71+
missedPongCounter.remove(sessionId);
72+
}
73+
}
74+
75+
public void resetMissedPongCount(String sessionId) {
76+
missedPongCounter.put(sessionId, 0);
77+
}
78+
79+
public void cleanSession(String sessionId) {
80+
missedPongCounter.remove(sessionId);
81+
}
82+
83+
}

backend/src/main/java/io/f1/backend/domain/game/websocket/MessageSender.java

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,7 @@
22

33
import io.f1.backend.domain.game.dto.MessageType;
44
import io.f1.backend.domain.game.dto.response.DefaultWebSocketResponse;
5-
import io.f1.backend.domain.user.dto.UserPrincipal;
6-
75
import lombok.RequiredArgsConstructor;
8-
96
import org.springframework.messaging.simp.SimpMessagingTemplate;
107
import org.springframework.stereotype.Component;
118

@@ -21,8 +18,8 @@ public <T> void sendBroadcast(String destination, MessageType type, T message) {
2118
}
2219

2320
public <T> void sendPersonal(
24-
String destination, MessageType type, T message, UserPrincipal principal) {
21+
String destination, MessageType type, T message, String principalName) {
2522
messagingTemplate.convertAndSendToUser(
26-
principal.getName(), destination, new DefaultWebSocketResponse<>(type, message));
23+
principalName, destination, new DefaultWebSocketResponse<>(type, message));
2724
}
2825
}

backend/src/main/java/io/f1/backend/domain/game/websocket/WebSocketUtils.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,16 @@ public static UserPrincipal getSessionUser(Message<?> message) {
1414
return (UserPrincipal) auth.getPrincipal();
1515
}
1616

17+
public static String getSessionId(Message<?> message) {
18+
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
19+
return accessor.getSessionId();
20+
}
21+
1722
public static String getDestination(Long roomId) {
1823
return "/sub/room/" + roomId;
1924
}
25+
26+
public static String getUserDestination() {
27+
return "/queue";
28+
}
2029
}

backend/src/main/java/io/f1/backend/domain/game/websocket/controller/GameSocketController.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ public void initializeRoomSocket(@DestinationVariable Long roomId, Message<?> me
3939
public void reconnect(@DestinationVariable Long roomId, Message<?> message) {
4040

4141
UserPrincipal principal = getSessionUser(message);
42-
roomService.changeConnectedStatus(principal.getUserId(), ConnectionState.CONNECTED);
42+
roomService.changeConnectedStatus(roomId,principal.getUserId(), ConnectionState.CONNECTED);
4343
roomService.reconnectSendResponse(roomId, principal);
4444
}
4545

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package io.f1.backend.domain.game.websocket.controller;
2+
3+
import static io.f1.backend.domain.game.websocket.WebSocketUtils.getSessionId;
4+
5+
import io.f1.backend.domain.game.websocket.HeartbeatMonitor;
6+
import lombok.RequiredArgsConstructor;
7+
import org.springframework.messaging.Message;
8+
import org.springframework.messaging.handler.annotation.MessageMapping;
9+
import org.springframework.stereotype.Controller;
10+
11+
@Controller
12+
@RequiredArgsConstructor
13+
public class HeartbeatController {
14+
15+
private final HeartbeatMonitor heartbeatMonitor;
16+
17+
@MessageMapping("/heartbeat/pong")
18+
public void handlePong(Message<?> message) {
19+
String sessionId = getSessionId(message);
20+
21+
heartbeatMonitor.resetMissedPongCount(sessionId);
22+
}
23+
24+
}

0 commit comments

Comments
 (0)