Skip to content

Commit 24c15a0

Browse files
committed
feat: 세션 자동 비활성화 및 상태 브로드캐스팅 로직 개선
1 parent dd010c5 commit 24c15a0

File tree

3 files changed

+128
-103
lines changed

3 files changed

+128
-103
lines changed

src/main/java/com/dmu/debug_visual/collab/service/RoomService.java

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,27 @@
55
import com.dmu.debug_visual.collab.domain.entity.SessionParticipant;
66
import com.dmu.debug_visual.collab.domain.repository.CodeSessionRepository;
77
import com.dmu.debug_visual.collab.domain.repository.SessionParticipantRepository;
8+
import com.dmu.debug_visual.collab.rest.dto.*;
89
import com.dmu.debug_visual.user.User;
910
import com.dmu.debug_visual.user.UserRepository;
1011
import com.dmu.debug_visual.collab.domain.repository.RoomParticipantRepository;
1112
import com.dmu.debug_visual.collab.domain.repository.RoomRepository;
12-
import com.dmu.debug_visual.collab.rest.dto.CreateRoomRequest;
13-
import com.dmu.debug_visual.collab.rest.dto.CreateSessionRequest;
14-
import com.dmu.debug_visual.collab.rest.dto.RoomResponse;
15-
import com.dmu.debug_visual.collab.rest.dto.SessionResponse;
1613
import com.dmu.debug_visual.collab.domain.entity.Room;
1714
import com.dmu.debug_visual.collab.domain.entity.RoomParticipant;
1815
import jakarta.persistence.EntityNotFoundException;
16+
import lombok.extern.slf4j.Slf4j;
1917
import lombok.RequiredArgsConstructor;
18+
import org.springframework.messaging.simp.SimpMessageSendingOperations;
2019
import org.springframework.stereotype.Service;
2120
import org.springframework.transaction.annotation.Transactional;
2221

22+
import java.util.List;
23+
import java.util.stream.Collectors;
24+
2325
/**
2426
* 협업 방과 세션의 생성, 관리, 권한 부여 등 핵심 비즈니스 로직을 처리하는 서비스
2527
*/
28+
@Slf4j
2629
@Service
2730
@RequiredArgsConstructor
2831
public class RoomService {
@@ -32,6 +35,7 @@ public class RoomService {
3235
private final RoomParticipantRepository roomParticipantRepository;
3336
private final SessionParticipantRepository sessionParticipantRepository;
3437
private final CodeSessionRepository codeSessionRepository;
38+
private final SimpMessageSendingOperations messagingTemplate;
3539

3640
// 1. 방 관리 (Room Management)
3741
/**
@@ -83,13 +87,13 @@ public void kickParticipant(String roomId, String ownerId, String targetUserId)
8387
throw new IllegalArgumentException("Owner cannot kick themselves.");
8488
}
8589

86-
// 1. 방 참여자 목록에서 삭제
8790
RoomParticipant participantToRemove = roomParticipantRepository.findByRoomAndUser_UserId(room, targetUserId)
8891
.orElseThrow(() -> new EntityNotFoundException("Participant not found in this room."));
8992
roomParticipantRepository.delete(participantToRemove);
9093

91-
// 2. 해당 방의 모든 세션 참여자 목록에서도 삭제
9294
sessionParticipantRepository.deleteAllByRoomIdAndUserId(roomId, targetUserId);
95+
96+
broadcastRoomState(roomId); // ✨ 강퇴 후 방송!
9397
}
9498

9599
// 2. 세션 관리 (Session Management)
@@ -233,19 +237,52 @@ public void joinRoom(String roomId, String userId) {
233237
User user = userRepository.findByUserId(userId)
234238
.orElseThrow(() -> new EntityNotFoundException("User not found: " + userId));
235239

236-
// 💡 이미 참여자인지 확인하여 중복 등록을 방지합니다.
237240
boolean isAlreadyParticipant = roomParticipantRepository.existsByRoomAndUser(room, user);
238241
if (isAlreadyParticipant) {
239-
// 이미 참여자이면 아무것도 하지 않고 성공으로 간주
242+
broadcastRoomState(roomId); // 이미 참여자여도 최신 상태를 한번 보내줌
240243
return;
241244
}
242245

243-
// 새로운 참여자로 등록 (기본 권한은 READ_ONLY)
244246
RoomParticipant newParticipant = RoomParticipant.builder()
245247
.room(room)
246248
.user(user)
247249
.permission(RoomParticipant.Permission.READ_ONLY)
248250
.build();
249251
roomParticipantRepository.save(newParticipant);
252+
253+
broadcastRoomState(roomId); // ✨ 참여자 추가 후 방송!
254+
}
255+
256+
/**
257+
* 특정 방의 최신 상태(방 이름, 방장, 참여자 목록)를 조회하여
258+
* 해당 방의 시스템 채널로 브로드캐스팅합니다.
259+
* @param roomId 상태를 방송할 방의 ID
260+
*/
261+
@Transactional(readOnly = true)
262+
public void broadcastRoomState(String roomId) {
263+
Room dbRoom = roomRepository.findByRoomId(roomId)
264+
.orElseThrow(() -> new RuntimeException("Room not found during state broadcast: " + roomId));
265+
266+
ParticipantInfo ownerInfo = ParticipantInfo.builder()
267+
.userId(dbRoom.getOwner().getUserId())
268+
.userName(dbRoom.getOwner().getName())
269+
.build();
270+
271+
List<ParticipantInfo> participantInfos = dbRoom.getParticipants().stream()
272+
.filter(p -> !p.getUser().getUserId().equals(dbRoom.getOwner().getUserId()))
273+
.map(p -> ParticipantInfo.builder()
274+
.userId(p.getUser().getUserId())
275+
.userName(p.getUser().getName())
276+
.build())
277+
.collect(Collectors.toList());
278+
279+
RoomStateUpdate roomStateUpdate = RoomStateUpdate.builder()
280+
.roomName(dbRoom.getName())
281+
.owner(ownerInfo)
282+
.participants(participantInfos)
283+
.build();
284+
285+
messagingTemplate.convertAndSend("/topic/room/" + roomId + "/system", roomStateUpdate);
286+
log.info("Broadcasted room state update for room: {}", roomId);
250287
}
251288
}

src/main/java/com/dmu/debug_visual/collab/service/WebSocketRoomService.java

Lines changed: 48 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -4,72 +4,77 @@
44
import org.springframework.stereotype.Service;
55

66
import java.util.Map;
7-
import java.util.UUID;
7+
import java.util.Set;
88
import java.util.concurrent.ConcurrentHashMap;
99

1010
@Service
1111
public class WebSocketRoomService {
1212

13-
// 현재 생성된 모든 방의 정보를 서버 메모리에 저장합니다.
13+
// key: roomId, value: WebSocketRoom (방의 기본 정보)
1414
private final Map<String, WebSocketRoom> activeRooms = new ConcurrentHashMap<>();
1515

16-
/**
17-
* 새로운 협업 방을 생성합니다.
18-
* @param ownerId 방을 생성하는 사용자의 ID
19-
* @return 생성된 방의 정보
20-
*/
16+
// key: sessionId, value: Set of userIds
17+
private final Map<String, Set<String>> sessionParticipants = new ConcurrentHashMap<>();
18+
19+
// --- 방(Room) 관련 메소드 ---
20+
2121
public WebSocketRoom activateRoom(String roomId, String ownerId) {
22-
if(activeRooms.containsKey(roomId)) {
23-
return activeRooms.get(roomId);
24-
}
25-
WebSocketRoom webSocketRoom = WebSocketRoom.builder()
26-
.roomId(roomId)
22+
// computeIfAbsent를 사용하면 if문 없이 더 간결하게 코드를 작성할 수 있습니다.
23+
return activeRooms.computeIfAbsent(roomId, k -> WebSocketRoom.builder()
24+
.roomId(k)
2725
.ownerId(ownerId)
28-
.build();
29-
activeRooms.put(roomId, webSocketRoom);
30-
return webSocketRoom;
26+
.build());
3127
}
3228

33-
/**
34-
* ID로 활성화된 방을 찾습니다.
35-
* @param roomId 찾으려는 방의 ID
36-
* @return 찾아낸 방의 정보 (없으면 null)
37-
*/
3829
public WebSocketRoom findActiveRoomById(String roomId) {
3930
return activeRooms.get(roomId);
4031
}
4132

42-
/**
43-
* 특정 사용자가 특정 방에서 쓰기 권한을 가지고 있는지 확인합니다.
44-
* @param roomId 확인할 방의 ID
45-
* @param userId 확인할 사용자의 ID
46-
* @return 쓰기 권한이 있으면 true
47-
*/
48-
public boolean hasWritePermission(String roomId, String userId) {
49-
WebSocketRoom webSocketRoom = findActiveRoomById(roomId);
50-
if (webSocketRoom == null) {
51-
return false;
52-
}
53-
WebSocketRoom.Permission permission = webSocketRoom.getParticipants().get(userId);
54-
return WebSocketRoom.Permission.READ_WRITE.equals(permission);
55-
}
56-
57-
/**
58-
* 특정 방에 새로운 참여자를 추가합니다.
59-
* @param roomId 참여할 방의 ID
60-
* @param userId 새로운 참여자의 ID
61-
*/
6233
public void addParticipant(String roomId, String userId) {
63-
WebSocketRoom webSocketRoom = findActiveRoomById(roomId);
64-
if (webSocketRoom != null) {
65-
webSocketRoom.addParticipant(userId);
34+
WebSocketRoom activeRoom = findActiveRoomById(roomId);
35+
if (activeRoom != null) {
36+
activeRoom.addParticipant(userId);
6637
}
6738
}
6839

6940
public void removeParticipant(String roomId, String userId) {
7041
WebSocketRoom activeRoom = findActiveRoomById(roomId);
7142
if (activeRoom != null) {
43+
// Map에서 참여자를 제거합니다.
7244
activeRoom.getParticipants().remove(userId);
7345
}
7446
}
47+
48+
// --- 세션(Session) 관련 메소드 ---
49+
50+
/**
51+
* 특정 세션에 실시간 참여자를 추가합니다.
52+
* @param sessionId 참여할 세션 ID
53+
* @param userId 참여하는 사용자 ID
54+
*/
55+
public void addSessionParticipant(String sessionId, String userId) {
56+
// computeIfAbsent를 사용하여 sessionId가 없으면 새로 Set을 만들고, 있으면 기존 Set에 userId를 추가합니다.
57+
sessionParticipants.computeIfAbsent(sessionId, k -> ConcurrentHashMap.newKeySet()).add(userId);
58+
}
59+
60+
/**
61+
* 특정 세션에서 실시간 참여자를 제거합니다.
62+
* @param sessionId 나가는 세션 ID
63+
* @param userId 나가는 사용자 ID
64+
*/
65+
public void removeSessionParticipant(String sessionId, String userId) {
66+
if (sessionParticipants.containsKey(sessionId)) {
67+
sessionParticipants.get(sessionId).remove(userId);
68+
}
69+
}
70+
71+
/**
72+
* 특정 세션이 비어있는지 (아무도 접속해있지 않은지) 확인합니다.
73+
* @param sessionId 확인할 세션 ID
74+
* @return 세션이 비어있거나 존재하지 않으면 true
75+
*/
76+
public boolean isSessionEmpty(String sessionId) {
77+
// sessionId에 해당하는 참여자 목록이 없거나, 있더라도 비어있으면 true를 반환합니다.
78+
return !sessionParticipants.containsKey(sessionId) || sessionParticipants.get(sessionId).isEmpty();
79+
}
7580
}
Lines changed: 34 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
11
package com.dmu.debug_visual.config;
22

3-
import com.dmu.debug_visual.collab.domain.entity.Room;
4-
import com.dmu.debug_visual.collab.domain.repository.RoomRepository;
5-
import com.dmu.debug_visual.collab.rest.dto.ParticipantInfo;
6-
import com.dmu.debug_visual.collab.rest.dto.RoomStateUpdate;
3+
import com.dmu.debug_visual.collab.domain.entity.CodeSession;
4+
import com.dmu.debug_visual.collab.domain.repository.CodeSessionRepository;
5+
import com.dmu.debug_visual.collab.service.RoomService;
76
import com.dmu.debug_visual.collab.service.WebSocketRoomService;
87
import com.dmu.debug_visual.security.CustomUserDetails;
98
import lombok.RequiredArgsConstructor;
109
import lombok.extern.slf4j.Slf4j;
1110
import org.springframework.context.event.EventListener;
12-
import org.springframework.messaging.simp.SimpMessageSendingOperations;
1311
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
1412
import org.springframework.security.core.Authentication;
1513
import org.springframework.stereotype.Component;
@@ -18,19 +16,17 @@
1816
import org.springframework.web.socket.messaging.SessionSubscribeEvent;
1917

2018
import java.security.Principal;
21-
import java.util.List;
2219
import java.util.Map;
2320
import java.util.Objects;
24-
import java.util.stream.Collectors;
2521

2622
@Slf4j
2723
@Component
2824
@RequiredArgsConstructor
2925
public class WebSocketEventListener {
3026

31-
private final SimpMessageSendingOperations messagingTemplate;
3227
private final WebSocketRoomService webSocketRoomService;
33-
private final RoomRepository roomRepository;
28+
private final RoomService roomService;
29+
private final CodeSessionRepository codeSessionRepository;
3430

3531
@EventListener
3632
@Transactional
@@ -44,19 +40,25 @@ public void handleWebSocketSubscribeListener(SessionSubscribeEvent event) {
4440
try {
4541
String roomId = destination.split("/")[3];
4642

47-
// --- 사용자 정보 가져오기 및 메모리에 사용자 추가 ---
4843
Authentication authentication = (Authentication) userPrincipal;
4944
CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal();
5045
String userId = userDetails.getUsername();
46+
5147
webSocketRoomService.addParticipant(roomId, userId);
5248

53-
// --- 퇴장 이벤트를 위해 세션에 정보 저장 ---
5449
Map<String, Object> sessionAttributes = Objects.requireNonNull(headerAccessor.getSessionAttributes());
5550
sessionAttributes.put("roomId", roomId);
5651
sessionAttributes.put("userId", userId);
5752

58-
// --- 방 전체에 최신 상태 브로드캐스팅 ---
59-
broadcastRoomState(roomId);
53+
// 만약 구독 주소에 "/session/"이 포함되어 있다면, 세션 참여자로도 등록합니다.
54+
if (destination.contains("/session/")) {
55+
String sessionId = destination.split("/")[5];
56+
sessionAttributes.put("sessionId", sessionId); // 퇴장 시 사용하기 위해 세션 ID 저장
57+
webSocketRoomService.addSessionParticipant(sessionId, userId);
58+
log.info("User {} joined session {}", userId, sessionId);
59+
}
60+
61+
roomService.broadcastRoomState(roomId);
6062

6163
} catch (Exception e) {
6264
log.error("Error handling subscribe event: ", e);
@@ -73,51 +75,32 @@ public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) {
7375
if (sessionAttributes != null) {
7476
String roomId = (String) sessionAttributes.get("roomId");
7577
String userId = (String) sessionAttributes.get("userId");
78+
String sessionId = (String) sessionAttributes.get("sessionId");
7679

7780
if (roomId != null && userId != null) {
7881
log.info("[퇴장] 사용자: {}, 방: {}", userId, roomId);
7982

80-
// 메모리에서 사용자 제거 (WebSocketRoomService에 removeParticipant 메소드 필요)
8183
webSocketRoomService.removeParticipant(roomId, userId);
8284

83-
// --- 방 전체에 최신 상태 브로드캐스팅 ---
84-
broadcastRoomState(roomId);
85+
// --- 세션 자동 비활성화 로직 ---
86+
if (sessionId != null) {
87+
webSocketRoomService.removeSessionParticipant(sessionId, userId);
88+
89+
// 방금 나간 사람이 마지막 참여자였는지 확인
90+
if (webSocketRoomService.isSessionEmpty(sessionId)) {
91+
log.info("Last user left session {}. Deactivating session.", sessionId);
92+
93+
// DB에서 세션을 찾아 상태를 INACTIVE로 변경
94+
codeSessionRepository.findBySessionId(sessionId).ifPresent(session -> {
95+
session.updateStatus(CodeSession.SessionStatus.INACTIVE);
96+
log.info("Session {} status updated to INACTIVE in DB.", sessionId);
97+
});
98+
}
99+
}
100+
101+
// 변경된 방 상태(참여자 감소)를 모두에게 알림
102+
roomService.broadcastRoomState(roomId);
85103
}
86104
}
87105
}
88-
89-
/**
90-
* 특정 방의 최신 상태(방 이름, 방장, 참여자 목록)를 조회하여
91-
* 해당 방의 시스템 채널로 브로드캐스팅하는 헬퍼 메소드
92-
*/
93-
private void broadcastRoomState(String roomId) {
94-
Room dbRoom = roomRepository.findByRoomId(roomId)
95-
.orElseThrow(() -> new RuntimeException("Room not found during state broadcast: " + roomId));
96-
97-
// 1. 방장 정보 DTO 생성
98-
ParticipantInfo ownerInfo = ParticipantInfo.builder()
99-
.userId(dbRoom.getOwner().getUserId())
100-
.userName(dbRoom.getOwner().getName())
101-
.build();
102-
103-
// 2. 참여자(방장 제외) 목록 DTO 생성
104-
List<ParticipantInfo> participantInfos = dbRoom.getParticipants().stream()
105-
.filter(p -> !p.getUser().getUserId().equals(dbRoom.getOwner().getUserId()))
106-
.map(p -> ParticipantInfo.builder()
107-
.userId(p.getUser().getUserId())
108-
.userName(p.getUser().getName())
109-
.build())
110-
.collect(Collectors.toList());
111-
112-
// 3. 최종 업데이트 DTO 생성
113-
RoomStateUpdate roomStateUpdate = RoomStateUpdate.builder()
114-
.roomName(dbRoom.getName())
115-
.owner(ownerInfo)
116-
.participants(participantInfos)
117-
.build();
118-
119-
// 4. 시스템 채널로 브로드캐스팅
120-
messagingTemplate.convertAndSend("/topic/room/" + roomId + "/system", roomStateUpdate);
121-
log.info("Broadcasted room state update for room: {}", roomId);
122-
}
123106
}

0 commit comments

Comments
 (0)