diff --git a/src/main/java/com/back/domain/studyroom/dto/RoleChangedNotification.java b/src/main/java/com/back/domain/studyroom/dto/RoleChangedNotification.java new file mode 100644 index 00000000..10204b1b --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/dto/RoleChangedNotification.java @@ -0,0 +1,62 @@ +package com.back.domain.studyroom.dto; + +import com.back.domain.studyroom.entity.RoomRole; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +/** + * 역할 변경 WebSocket 알림 DTO + * - 방 멤버의 역할이 변경되었을 때 실시간 브로드캐스트 + */ +@Getter +@Builder +public class RoleChangedNotification { + + private Long roomId; + private Long userId; + private String nickname; + private String profileImageUrl; + private RoomRole oldRole; + private RoomRole newRole; + private String message; + private LocalDateTime timestamp; + + public static RoleChangedNotification of( + Long roomId, + Long userId, + String nickname, + String profileImageUrl, + RoomRole oldRole, + RoomRole newRole) { + + String message = buildMessage(nickname, oldRole, newRole); + + return RoleChangedNotification.builder() + .roomId(roomId) + .userId(userId) + .nickname(nickname) + .profileImageUrl(profileImageUrl) + .oldRole(oldRole) + .newRole(newRole) + .message(message) + .timestamp(LocalDateTime.now()) + .build(); + } + + private static String buildMessage(String nickname, RoomRole oldRole, RoomRole newRole) { + if (newRole == RoomRole.HOST) { + return String.format("%s님이 방장으로 임명되었습니다.", nickname); + } else if (oldRole == RoomRole.HOST) { + return String.format("%s님이 일반 멤버로 변경되었습니다.", nickname); + } else if (newRole == RoomRole.SUB_HOST) { + return String.format("%s님이 부방장으로 승격되었습니다.", nickname); + } else if (newRole == RoomRole.MEMBER && oldRole == RoomRole.VISITOR) { + return String.format("%s님이 정식 멤버로 승격되었습니다.", nickname); + } else if (newRole == RoomRole.MEMBER) { + return String.format("%s님이 일반 멤버로 변경되었습니다.", nickname); + } + return String.format("%s님의 역할이 변경되었습니다.", nickname); + } +} diff --git a/src/main/java/com/back/domain/studyroom/service/RoomRedisService.java b/src/main/java/com/back/domain/studyroom/service/RoomRedisService.java index ae32807b..f08f1d11 100644 --- a/src/main/java/com/back/domain/studyroom/service/RoomRedisService.java +++ b/src/main/java/com/back/domain/studyroom/service/RoomRedisService.java @@ -9,19 +9,16 @@ import java.util.Set; /** - * 방 상태 관리를 위한 Redis 전용 서비스 - * 방의 온라인 사용자 관리 (입장/퇴장) - * 실시간 참가자 수 조회 - * 온라인 사용자 목록 조회 - * Redis: 실시간 온라인 상태만 관리 (휘발성 데이터) - * DB: 영구 멤버십 + 역할 정보 (MEMBER 이상만 저장) - * 역할(Role)은 Redis에 저장하지 않음! - * 이유 1: DB가 Single Source of Truth (데이터 일관성) - * 이유 2: Redis-DB 동기화 복잡도 제거 - * 이유 3: 멤버 목록 조회 시 IN 절로 효율적 조회 가능 + * 방 상태 관리를 위한 Redis 전용 서비스 (곧 사라질 예정인 파일) + * (현재는 일단 유지 시킨 상황, 에러 방지용) + * @deprecated RoomParticipantService를 사용. + * 현재는 WebSocketSessionManager의 Wrapper일 뿐이며, + * RoomParticipantService에 원래 로직이 옮겨졋습니다. + * + * @see com.back.global.websocket.service.RoomParticipantService 실제 사용 서비스 * @see com.back.global.websocket.service.WebSocketSessionManager WebSocket 세션 관리 - * @see com.back.domain.studyroom.repository.RoomMemberRepository DB 멤버십 조회 */ +@Deprecated @Slf4j @Service @RequiredArgsConstructor diff --git a/src/main/java/com/back/domain/studyroom/service/RoomService.java b/src/main/java/com/back/domain/studyroom/service/RoomService.java index 3b68152f..fd2281d7 100644 --- a/src/main/java/com/back/domain/studyroom/service/RoomService.java +++ b/src/main/java/com/back/domain/studyroom/service/RoomService.java @@ -7,6 +7,7 @@ import com.back.domain.user.repository.UserRepository; import com.back.global.exception.CustomException; import com.back.global.exception.ErrorCode; +import com.back.global.websocket.service.RoomParticipantService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; @@ -41,7 +42,8 @@ public class RoomService { private final RoomMemberRepository roomMemberRepository; private final UserRepository userRepository; private final StudyRoomProperties properties; - private final RoomRedisService roomRedisService; + private final RoomParticipantService roomParticipantService; + private final org.springframework.messaging.simp.SimpMessagingTemplate messagingTemplate; /** * 방 생성 메서드 @@ -109,7 +111,7 @@ public RoomMember joinRoom(Long roomId, String password, Long userId) { } // Redis에서 현재 온라인 사용자 수 조회 - long currentOnlineCount = roomRedisService.getRoomUserCount(roomId); + long currentOnlineCount = roomParticipantService.getParticipantCount(roomId); // 정원 확인 (Redis 기반) if (currentOnlineCount >= room.getMaxParticipants()) { @@ -133,7 +135,7 @@ public RoomMember joinRoom(Long roomId, String password, Long userId) { RoomMember member = existingMember.get(); // Redis에 온라인 등록 - roomRedisService.enterRoom(userId, roomId); + roomParticipantService.enterRoom(userId, roomId); log.info("기존 멤버 재입장 - RoomId: {}, UserId: {}, Role: {}", roomId, userId, member.getRole()); @@ -145,7 +147,7 @@ public RoomMember joinRoom(Long roomId, String password, Long userId) { RoomMember visitorMember = RoomMember.createVisitor(room, user); // Redis에만 온라인 등록 - roomRedisService.enterRoom(userId, roomId); + roomParticipantService.enterRoom(userId, roomId); log.info("신규 입장 (VISITOR) - RoomId: {}, UserId: {}, DB 저장 안함", roomId, userId); @@ -172,7 +174,7 @@ public void leaveRoom(Long roomId, Long userId) { .orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND)); // Redis에서 퇴장 처리 (모든 사용자) - roomRedisService.exitRoom(userId, roomId); + roomParticipantService.exitRoom(userId, roomId); log.info("방 퇴장 완료 - RoomId: {}, UserId: {}", roomId, userId); } @@ -234,9 +236,9 @@ public void terminateRoom(Long roomId, Long userId) { room.terminate(); // Redis에서 모든 온라인 사용자 제거 - Set onlineUserIds = roomRedisService.getRoomUsers(roomId); + Set onlineUserIds = roomParticipantService.getParticipants(roomId); for (Long onlineUserId : onlineUserIds) { - roomRedisService.exitRoom(onlineUserId, roomId); + roomParticipantService.exitRoom(onlineUserId, roomId); } log.info("방 종료 완료 - RoomId: {}, UserId: {}, 퇴장 처리: {}명", @@ -275,7 +277,10 @@ public void changeUserRole(Long roomId, Long targetUserId, RoomRole newRole, Lon // 3. 대상자 확인 (DB 조회 - VISITOR는 DB에 없을 수 있음) Optional targetMemberOpt = roomMemberRepository.findByRoomIdAndUserId(roomId, targetUserId); - + + // 변경 전 역할 저장 (알림용) + RoomRole oldRole = targetMemberOpt.map(RoomMember::getRole).orElse(RoomRole.VISITOR); + // 4. HOST로 변경하는 경우 - 기존 방장 강등 if (newRole == RoomRole.HOST) { // 기존 방장을 MEMBER로 강등 @@ -306,6 +311,28 @@ public void changeUserRole(Long roomId, Long targetUserId, RoomRole newRole, Lon log.info("VISITOR 승격 (DB 저장) - RoomId: {}, UserId: {}, NewRole: {}", roomId, targetUserId, newRole); } + + // 6. WebSocket으로 역할 변경 알림 브로드캐스트 + User targetUser = userRepository.findById(targetUserId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + com.back.domain.studyroom.dto.RoleChangedNotification notification = + com.back.domain.studyroom.dto.RoleChangedNotification.of( + roomId, + targetUserId, + targetUser.getNickname(), + targetUser.getProfileImageUrl(), + oldRole, + newRole + ); + + messagingTemplate.convertAndSend( + "/topic/room/" + roomId + "/role-changed", + notification + ); + + log.info("역할 변경 알림 전송 완료 - RoomId: {}, UserId: {}, {} → {}", + roomId, targetUserId, oldRole, newRole); } /** @@ -332,7 +359,7 @@ public List getRoomMembers(Long roomId, Long userId) { } // 1. Redis에서 온라인 사용자 ID 조회 - Set onlineUserIds = roomRedisService.getRoomUsers(roomId); + Set onlineUserIds = roomParticipantService.getParticipants(roomId); if (onlineUserIds.isEmpty()) { return List.of(); @@ -412,7 +439,7 @@ public void kickMember(Long roomId, Long targetUserId, Long requesterId) { } // Redis에서 제거 (강제 퇴장) - roomRedisService.exitRoom(targetUserId, roomId); + roomParticipantService.exitRoom(targetUserId, roomId); log.info("멤버 추방 완료 - RoomId: {}, TargetUserId: {}, RequesterId: {}", roomId, targetUserId, requesterId); @@ -424,7 +451,7 @@ public void kickMember(Long roomId, Long targetUserId, Long requesterId) { * RoomResponse 생성 (Redis에서 실시간 참가자 수 조회) */ public com.back.domain.studyroom.dto.RoomResponse toRoomResponse(Room room) { - long onlineCount = roomRedisService.getRoomUserCount(room.getId()); + long onlineCount = roomParticipantService.getParticipantCount(room.getId()); return com.back.domain.studyroom.dto.RoomResponse.from(room, onlineCount); } @@ -435,8 +462,12 @@ public java.util.List toRoomResponse java.util.List roomIds = rooms.stream() .map(Room::getId) .collect(java.util.stream.Collectors.toList()); - - java.util.Map participantCounts = roomRedisService.getBulkRoomOnlineUserCounts(roomIds); + + java.util.Map participantCounts = roomIds.stream() + .collect(java.util.stream.Collectors.toMap( + roomId -> roomId, + roomId -> roomParticipantService.getParticipantCount(roomId) + )); return rooms.stream() .map(room -> com.back.domain.studyroom.dto.RoomResponse.from( @@ -452,7 +483,7 @@ public java.util.List toRoomResponse public com.back.domain.studyroom.dto.RoomDetailResponse toRoomDetailResponse( Room room, java.util.List members) { - long onlineCount = roomRedisService.getRoomUserCount(room.getId()); + long onlineCount = roomParticipantService.getParticipantCount(room.getId()); java.util.List memberResponses = members.stream() .map(com.back.domain.studyroom.dto.RoomMemberResponse::from) @@ -465,7 +496,7 @@ public com.back.domain.studyroom.dto.RoomDetailResponse toRoomDetailResponse( * MyRoomResponse 생성 (Redis에서 실시간 참가자 수 조회) */ public com.back.domain.studyroom.dto.MyRoomResponse toMyRoomResponse(Room room, RoomRole myRole) { - long onlineCount = roomRedisService.getRoomUserCount(room.getId()); + long onlineCount = roomParticipantService.getParticipantCount(room.getId()); return com.back.domain.studyroom.dto.MyRoomResponse.of(room, onlineCount, myRole); } @@ -478,8 +509,12 @@ public java.util.List toMyRoomResp java.util.List roomIds = rooms.stream() .map(Room::getId) .collect(java.util.stream.Collectors.toList()); - - java.util.Map participantCounts = roomRedisService.getBulkRoomOnlineUserCounts(roomIds); + + java.util.Map participantCounts = roomIds.stream() + .collect(java.util.stream.Collectors.toMap( + roomId -> roomId, + roomId -> roomParticipantService.getParticipantCount(roomId) + )); return rooms.stream() .map(room -> { diff --git a/src/test/java/com/back/domain/studyroom/service/RoomServiceTest.java b/src/test/java/com/back/domain/studyroom/service/RoomServiceTest.java index 0d15e458..b0f9cb7e 100644 --- a/src/test/java/com/back/domain/studyroom/service/RoomServiceTest.java +++ b/src/test/java/com/back/domain/studyroom/service/RoomServiceTest.java @@ -11,6 +11,7 @@ import com.back.domain.user.repository.UserRepository; import com.back.global.exception.CustomException; import com.back.global.exception.ErrorCode; +import com.back.global.websocket.service.RoomParticipantService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -48,7 +49,7 @@ class RoomServiceTest { private StudyRoomProperties properties; @Mock - private RoomRedisService roomRedisService; + private RoomParticipantService roomParticipantService; @InjectMocks private RoomService roomService; @@ -144,7 +145,7 @@ void joinRoom_Success() { given(roomRepository.findByIdWithLock(1L)).willReturn(Optional.of(testRoom)); given(userRepository.findById(2L)).willReturn(Optional.of(testUser)); given(roomMemberRepository.findByRoomIdAndUserId(1L, 2L)).willReturn(Optional.empty()); - given(roomRedisService.getRoomUserCount(1L)).willReturn(0L); // Redis 카운트 + given(roomParticipantService.getParticipantCount(1L)).willReturn(0L); // Redis 카운트 // when RoomMember joinedMember = roomService.joinRoom(1L, null, 2L); @@ -152,7 +153,7 @@ void joinRoom_Success() { // then assertThat(joinedMember).isNotNull(); assertThat(joinedMember.getRole()).isEqualTo(RoomRole.VISITOR); - verify(roomRedisService, times(1)).enterRoom(2L, 1L); // Redis 입장 확인 + verify(roomParticipantService, times(1)).enterRoom(2L, 1L); // Redis 입장 확인 verify(roomMemberRepository, never()).save(any(RoomMember.class)); // DB 저장 안됨! } @@ -183,7 +184,7 @@ void joinRoom_WrongPassword() { true // useWebRTC ); given(roomRepository.findByIdWithLock(1L)).willReturn(Optional.of(privateRoom)); - given(roomRedisService.getRoomUserCount(1L)).willReturn(0L); // Redis 카운트 + given(roomParticipantService.getParticipantCount(1L)).willReturn(0L); // Redis 카운트 // when & then assertThatThrownBy(() -> roomService.joinRoom(1L, "wrong", 1L)) @@ -201,7 +202,7 @@ void leaveRoom_Success() { roomService.leaveRoom(1L, 1L); // then - verify(roomRedisService, times(1)).exitRoom(1L, 1L); // Redis 퇴장 확인 + verify(roomParticipantService, times(1)).exitRoom(1L, 1L); // Redis 퇴장 확인 } @Test @@ -312,7 +313,7 @@ void updateRoomSettings_NotOwner() { void terminateRoom_Success() { // given given(roomRepository.findById(1L)).willReturn(Optional.of(testRoom)); - given(roomRedisService.getRoomUsers(1L)).willReturn(java.util.Set.of()); // 온라인 사용자 없음 + given(roomParticipantService.getParticipants(1L)).willReturn(java.util.Set.of()); // 온라인 사용자 없음 // when roomService.terminateRoom(1L, 1L); @@ -378,7 +379,7 @@ void kickMember_Success() { roomService.kickMember(1L, 2L, 1L); // then - verify(roomRedisService, times(1)).exitRoom(2L, 1L); // Redis 퇴장 확인 + verify(roomParticipantService, times(1)).exitRoom(2L, 1L); // Redis 퇴장 확인 } @Test