From e5081578ebe308099bd4af521eb7b56a6333f5f5 Mon Sep 17 00:00:00 2001 From: loseminho Date: Thu, 2 Oct 2025 21:02:51 +0900 Subject: [PATCH 1/8] =?UTF-8?q?refactor:=20=EC=8A=A4=EB=8D=94=ED=8B=B0?= =?UTF-8?q?=EB=A3=B8=20=EA=B6=8C=ED=95=9C=EC=97=90=20=EB=8C=80=ED=95=9C=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../studyroom/controller/RoomController.java | 42 +++ .../studyroom/dto/ChangeRoleRequest.java | 21 ++ .../studyroom/dto/ChangeRoleResponse.java | 47 ++++ .../RoomMemberRepositoryCustom.java | 9 + .../repository/RoomMemberRepositoryImpl.java | 29 ++ .../studyroom/service/RoomRedisService.java | 116 ++++++++ .../domain/studyroom/service/RoomService.java | 251 +++++++++++------- .../com/back/global/exception/ErrorCode.java | 7 +- 8 files changed, 429 insertions(+), 93 deletions(-) create mode 100644 src/main/java/com/back/domain/studyroom/dto/ChangeRoleRequest.java create mode 100644 src/main/java/com/back/domain/studyroom/dto/ChangeRoleResponse.java create mode 100644 src/main/java/com/back/domain/studyroom/service/RoomRedisService.java diff --git a/src/main/java/com/back/domain/studyroom/controller/RoomController.java b/src/main/java/com/back/domain/studyroom/controller/RoomController.java index 0d8c8e8c..2b7b4b7c 100644 --- a/src/main/java/com/back/domain/studyroom/controller/RoomController.java +++ b/src/main/java/com/back/domain/studyroom/controller/RoomController.java @@ -3,7 +3,9 @@ import com.back.domain.studyroom.dto.*; import com.back.domain.studyroom.entity.Room; import com.back.domain.studyroom.entity.RoomMember; +import com.back.domain.studyroom.entity.RoomRole; import com.back.domain.studyroom.service.RoomService; +import com.back.domain.user.entity.User; import com.back.global.common.dto.RsData; import com.back.global.security.user.CurrentUser; import io.swagger.v3.oas.annotations.Operation; @@ -317,4 +319,44 @@ public ResponseEntity>> getPopularRooms( .status(HttpStatus.OK) .body(RsData.success("인기 방 목록 조회 완료", response)); } + + @PutMapping("/{roomId}/members/{userId}/role") + @Operation( + summary = "멤버 역할 변경", + description = "방 멤버의 역할을 변경합니다. 방장만 실행 가능합니다. VISITOR를 포함한 모든 사용자의 역할을 변경할 수 있으며, HOST로 변경 시 기존 방장은 자동으로 MEMBER로 강등됩니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "역할 변경 성공"), + @ApiResponse(responseCode = "400", description = "자신의 역할은 변경 불가"), + @ApiResponse(responseCode = "403", description = "방장 권한 없음"), + @ApiResponse(responseCode = "404", description = "존재하지 않는 방 또는 사용자"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + public ResponseEntity> changeUserRole( + @Parameter(description = "방 ID", required = true) @PathVariable Long roomId, + @Parameter(description = "대상 사용자 ID", required = true) @PathVariable Long userId, + @Valid @RequestBody ChangeRoleRequest request) { + + Long currentUserId = currentUser.getUserId(); + + // 변경 전 역할 조회 + RoomRole oldRole = roomService.getUserRoomRole(roomId, userId); + + // 역할 변경 + roomService.changeUserRole(roomId, userId, request.getNewRole(), currentUserId); + + // 사용자 정보 조회 + User targetUser = roomService.getUserById(userId); + + ChangeRoleResponse response = ChangeRoleResponse.of( + userId, + targetUser.getNickname(), + oldRole, + request.getNewRole() + ); + + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success("역할 변경 완료", response)); + } } diff --git a/src/main/java/com/back/domain/studyroom/dto/ChangeRoleRequest.java b/src/main/java/com/back/domain/studyroom/dto/ChangeRoleRequest.java new file mode 100644 index 00000000..753ffb89 --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/dto/ChangeRoleRequest.java @@ -0,0 +1,21 @@ +package com.back.domain.studyroom.dto; + +import com.back.domain.studyroom.entity.RoomRole; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 멤버 역할 변경 요청 DTO + * - VISITOR → MEMBER/SUB_HOST/HOST 모두 가능 + * - HOST로 변경 시 기존 방장은 자동으로 MEMBER로 강등 + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class ChangeRoleRequest { + + @NotNull(message = "역할은 필수입니다") + private RoomRole newRole; +} diff --git a/src/main/java/com/back/domain/studyroom/dto/ChangeRoleResponse.java b/src/main/java/com/back/domain/studyroom/dto/ChangeRoleResponse.java new file mode 100644 index 00000000..a2c493ef --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/dto/ChangeRoleResponse.java @@ -0,0 +1,47 @@ +package com.back.domain.studyroom.dto; + +import com.back.domain.studyroom.entity.RoomRole; +import lombok.Builder; +import lombok.Getter; + +/** + * 역할 변경 응답 DTO + */ +@Getter +@Builder +public class ChangeRoleResponse { + + private Long userId; + private String nickname; + private RoomRole oldRole; + private RoomRole newRole; + private String message; + + public static ChangeRoleResponse of(Long userId, String nickname, + RoomRole oldRole, RoomRole newRole) { + String message = buildMessage(oldRole, newRole); + + return ChangeRoleResponse.builder() + .userId(userId) + .nickname(nickname) + .oldRole(oldRole) + .newRole(newRole) + .message(message) + .build(); + } + + private static String buildMessage(RoomRole oldRole, RoomRole newRole) { + if (newRole == RoomRole.HOST) { + return "방장으로 임명되었습니다."; + } else if (oldRole == RoomRole.HOST) { + return "방장 권한이 해제되었습니다."; + } else if (newRole == RoomRole.SUB_HOST) { + return "부방장으로 승격되었습니다."; + } else if (newRole == RoomRole.MEMBER && oldRole == RoomRole.VISITOR) { + return "정식 멤버로 승격되었습니다."; + } else if (newRole == RoomRole.MEMBER) { + return "일반 멤버로 강등되었습니다."; + } + return "역할이 변경되었습니다."; + } +} diff --git a/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepositoryCustom.java b/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepositoryCustom.java index a6732eb6..399c3254 100644 --- a/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepositoryCustom.java +++ b/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepositoryCustom.java @@ -64,6 +64,15 @@ public interface RoomMemberRepositoryCustom { */ boolean existsByRoomIdAndUserId(Long roomId, Long userId); + /** + * 여러 사용자의 멤버십 일괄 조회 (IN 절) + * Redis에서 온라인 사용자 목록을 받아서 DB 멤버십 조회 시 사용 + * @param roomId 방 ID + * @param userIds 사용자 ID 목록 + * @return 멤버십 목록 (MEMBER 이상만 DB에 있음) + */ + List findByRoomIdAndUserIdIn(Long roomId, java.util.Set userIds); + /** * 특정 역할의 멤버 수 조회 * TODO: Redis 기반으로 변경 예정 diff --git a/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepositoryImpl.java b/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepositoryImpl.java index 8c8761e4..c48f2aa6 100644 --- a/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepositoryImpl.java +++ b/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepositoryImpl.java @@ -255,6 +255,35 @@ public boolean existsByRoomIdAndUserId(Long roomId, Long userId) { return count != null && count > 0; } + /** + * 여러 사용자의 멤버십 일괄 조회 (IN 절) + * - Redis 온라인 목록으로 DB 멤버십 조회 + * - N+1 문제 해결 + * - VISITOR는 DB에 없으므로 결과에 포함 안됨 + * @param roomId 방 ID + * @param userIds 사용자 ID Set + * @return DB에 저장된 멤버 목록 (MEMBER 이상) + */ + @Override + public List findByRoomIdAndUserIdIn(Long roomId, java.util.Set userIds) { + if (userIds == null || userIds.isEmpty()) { + return List.of(); + } + + return queryFactory + .selectFrom(roomMember) + .leftJoin(roomMember.user, user).fetchJoin() // N+1 방지 + .where( + roomMember.room.id.eq(roomId), + roomMember.user.id.in(userIds) + ) + .orderBy( + roomMember.role.asc(), // 역할순 + roomMember.joinedAt.asc() // 입장 시간순 + ) + .fetch(); + } + /** * 특정 역할의 멤버 수 조회 * TODO: Redis 기반으로 변경 예정 diff --git a/src/main/java/com/back/domain/studyroom/service/RoomRedisService.java b/src/main/java/com/back/domain/studyroom/service/RoomRedisService.java new file mode 100644 index 00000000..ae32807b --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/service/RoomRedisService.java @@ -0,0 +1,116 @@ +package com.back.domain.studyroom.service; + +import com.back.global.websocket.service.WebSocketSessionManager; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.Map; +import java.util.Set; + +/** + * 방 상태 관리를 위한 Redis 전용 서비스 + * 방의 온라인 사용자 관리 (입장/퇴장) + * 실시간 참가자 수 조회 + * 온라인 사용자 목록 조회 + * Redis: 실시간 온라인 상태만 관리 (휘발성 데이터) + * DB: 영구 멤버십 + 역할 정보 (MEMBER 이상만 저장) + * 역할(Role)은 Redis에 저장하지 않음! + * 이유 1: DB가 Single Source of Truth (데이터 일관성) + * 이유 2: Redis-DB 동기화 복잡도 제거 + * 이유 3: 멤버 목록 조회 시 IN 절로 효율적 조회 가능 + * @see com.back.global.websocket.service.WebSocketSessionManager WebSocket 세션 관리 + * @see com.back.domain.studyroom.repository.RoomMemberRepository DB 멤버십 조회 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class RoomRedisService { + + private final WebSocketSessionManager sessionManager; + + // ==================== 방 입장/퇴장 ==================== + + /** + * 사용자가 방에 입장 (Redis 온라인 상태 업데이트) + * - Redis Set에 userId 추가 + * - 역할(Role)은 DB에서만 관리 + * + * @param userId 사용자 ID + * @param roomId 방 ID + */ + public void enterRoom(Long userId, Long roomId) { + sessionManager.joinRoom(userId, roomId); + log.info("방 입장 완료 (Redis) - 사용자: {}, 방: {}", userId, roomId); + } + + /** + * 사용자가 방에서 퇴장 (Redis 온라인 상태 업데이트) + * - Redis Set에서 userId 제거 + * - DB 멤버십은 유지됨 (재입장 시 역할 유지) + * + * @param userId 사용자 ID + * @param roomId 방 ID + */ + public void exitRoom(Long userId, Long roomId) { + sessionManager.leaveRoom(userId, roomId); + log.info("방 퇴장 완료 (Redis) - 사용자: {}, 방: {}", userId, roomId); + } + + // ==================== 조회 ==================== + + /** + * 방의 현재 온라인 사용자 수 조회 + * - 실시간 참가자 수 (DB currentParticipants와 무관) + * + * @param roomId 방 ID + * @return 온라인 사용자 수 + */ + public long getRoomUserCount(Long roomId) { + return sessionManager.getRoomOnlineUserCount(roomId); + } + + /** + * 방의 온라인 사용자 ID 목록 조회 + * - 멤버 목록 조회 시 이 ID로 DB 조회 + * - DB에 없는 ID = VISITOR + * + * @param roomId 방 ID + * @return 온라인 사용자 ID Set + */ + public Set getRoomUsers(Long roomId) { + return sessionManager.getOnlineUsersInRoom(roomId); + } + + /** + * 사용자가 현재 특정 방에 있는지 확인 + * + * @param userId 사용자 ID + * @param roomId 방 ID + * @return 온라인 여부 + */ + public boolean isUserInRoom(Long userId, Long roomId) { + return sessionManager.isUserInRoom(userId, roomId); + } + + /** + * 사용자의 현재 방 ID 조회 + * + * @param userId 사용자 ID + * @return 방 ID (없으면 null) + */ + public Long getCurrentRoomId(Long userId) { + return sessionManager.getUserCurrentRoomId(userId); + } + + /** + * 여러 방의 온라인 사용자 수 일괄 조회 (N+1 방지) + * - 방 목록 조회 시 사용 + * + * @param roomIds 방 ID 목록 + * @return Map + */ + public Map getBulkRoomOnlineUserCounts(java.util.List roomIds) { + return sessionManager.getBulkRoomOnlineUserCounts(roomIds); + } +} 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 71f0f57c..3b68152f 100644 --- a/src/main/java/com/back/domain/studyroom/service/RoomService.java +++ b/src/main/java/com/back/domain/studyroom/service/RoomService.java @@ -16,6 +16,7 @@ import java.util.List; import java.util.Optional; +import java.util.Set; /** - 방 생성, 입장, 퇴장 로직 처리 @@ -40,7 +41,7 @@ public class RoomService { private final RoomMemberRepository roomMemberRepository; private final UserRepository userRepository; private final StudyRoomProperties properties; - private final com.back.global.websocket.service.WebSocketSessionManager sessionManager; + private final RoomRedisService roomRedisService; /** * 방 생성 메서드 @@ -82,13 +83,13 @@ public Room createRoom(String title, String description, boolean isPrivate, * 입장 검증 과정: * 1. 방 존재 및 활성 상태 확인 (비관적 락으로 동시성 제어) * 2. 방 상태가 입장 가능한지 확인 (WAITING, ACTIVE) - * 3. 정원 초과 여부 확인 + * 3. 정원 초과 여부 확인 (Redis 기반) * 4. 비공개 방인 경우 비밀번호 확인 * 5. 이미 참여 중인지 확인 (재입장 처리) - * 멤버 등록: (현재는 visitor로 등록이지만 추후 역할 부여가 안된 인원을 visitor로 띄우는 식으로 저장 데이터 줄일 예정) - * - 신규 사용자: VISITOR 역할로 등록 - * - 기존 사용자: 온라인 상태로 변경 + * 멤버 등록: + * - 신규 사용자 (DB에 없음): VISITOR로 입장 → DB 저장 안함, Redis에만 등록 + * - 기존 멤버 (DB에 있음): 저장된 역할로 재입장 → Redis에만 등록 * * 동시성 제어: 비관적 락(PESSIMISTIC_WRITE)으로 정원 초과 방지 */ @@ -107,10 +108,15 @@ public RoomMember joinRoom(Long roomId, String password, Long userId) { throw new CustomException(ErrorCode.ROOM_TERMINATED); } + // Redis에서 현재 온라인 사용자 수 조회 + long currentOnlineCount = roomRedisService.getRoomUserCount(roomId); + + // 정원 확인 (Redis 기반) + if (currentOnlineCount >= room.getMaxParticipants()) { + throw new CustomException(ErrorCode.ROOM_FULL); + } + if (!room.canJoin()) { - if (room.isFull()) { - throw new CustomException(ErrorCode.ROOM_FULL); - } throw new CustomException(ErrorCode.ROOM_INACTIVE); } @@ -123,35 +129,41 @@ public RoomMember joinRoom(Long roomId, String password, Long userId) { Optional existingMember = roomMemberRepository.findByRoomIdAndUserId(roomId, userId); if (existingMember.isPresent()) { + // 기존 멤버 재입장: DB에 있는 역할 그대로 사용 RoomMember member = existingMember.get(); - // TODO: Redis에서 온라인 여부 확인하도록 변경 - // 현재는 기존 멤버 재입장 허용 - // room.incrementParticipant(); // Redis로 이관 - DB 업데이트 제거 - + + // Redis에 온라인 등록 + roomRedisService.enterRoom(userId, roomId); + + log.info("기존 멤버 재입장 - RoomId: {}, UserId: {}, Role: {}", + roomId, userId, member.getRole()); + return member; } - RoomMember newMember = RoomMember.createVisitor(room, user); - RoomMember savedMember = roomMemberRepository.save(newMember); - - // room.incrementParticipant(); // Redis로 이관 - DB 업데이트 제거 + // 신규 입장자: VISITOR로 입장 (DB 저장 안함!) + RoomMember visitorMember = RoomMember.createVisitor(room, user); + + // Redis에만 온라인 등록 + roomRedisService.enterRoom(userId, roomId); - log.info("방 입장 완료 - RoomId: {}, UserId: {}, Role: {}", - roomId, userId, newMember.getRole()); + log.info("신규 입장 (VISITOR) - RoomId: {}, UserId: {}, DB 저장 안함", roomId, userId); - return savedMember; + // 메모리상 객체 반환 (DB에 저장되지 않음) + return visitorMember; } /** * 방 나가기 메서드 * - * 🚪 퇴장 처리: - * - 일반 멤버: 단순 오프라인 처리 및 참가자 수 감소 - * - 방장: 특별 처리 로직 실행 (handleHostLeaving) + * 퇴장 처리: + * - VISITOR: Redis에서만 제거 (DB에 없음) + * - MEMBER 이상: Redis에서 제거 + DB 멤버십은 유지 (재입장 시 역할 유지) + * - 방장: Redis에서 제거 + DB 멤버십 유지 + 방은 계속 존재 * - * 🔄 방장 퇴장 시 처리: - * - 다른 멤버가 없으면 → 방 자동 종료 - * - 다른 멤버가 있으면 → 새 방장 자동 위임 + * 방은 참가자 0명이어도 유지: + * - 방장이 오프라인이어도 다른 사람들이 입장 가능 + * - 방 종료는 오직 방장만 명시적으로 가능 */ @Transactional public void leaveRoom(Long roomId, Long userId) { @@ -159,52 +171,12 @@ public void leaveRoom(Long roomId, Long userId) { Room room = roomRepository.findById(roomId) .orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND)); - RoomMember member = roomMemberRepository.findByRoomIdAndUserId(roomId, userId) - .orElseThrow(() -> new CustomException(ErrorCode.NOT_ROOM_MEMBER)); - - // TODO: Redis에서 온라인 상태 확인하도록 변경 - - if (member.isHost()) { - handleHostLeaving(room, member); - } else { - // TODO: Redis에서 제거하도록 변경 - // room.decrementParticipant(); // Redis로 이관 - DB 업데이트 제거 - } + // Redis에서 퇴장 처리 (모든 사용자) + roomRedisService.exitRoom(userId, roomId); log.info("방 퇴장 완료 - RoomId: {}, UserId: {}", roomId, userId); } - private void handleHostLeaving(Room room, RoomMember hostMember) { - // TODO: Redis에서 온라인 멤버 조회하도록 변경 - List onlineMembers = roomMemberRepository.findOnlineMembersByRoomId(room.getId()); - - List otherOnlineMembers = onlineMembers.stream() - .filter(m -> !m.getId().equals(hostMember.getId())) - .toList(); - - if (otherOnlineMembers.isEmpty()) { - room.terminate(); - // TODO: Redis에서 제거하도록 변경 - // room.decrementParticipant(); // Redis로 이관 - DB 업데이트 제거 - } else { - RoomMember newHost = otherOnlineMembers.stream() - .filter(m -> m.getRole() == RoomRole.SUB_HOST) - .findFirst() - .orElse(otherOnlineMembers.stream() - .min((m1, m2) -> m1.getJoinedAt().compareTo(m2.getJoinedAt())) - .orElse(null)); - - if (newHost != null) { - newHost.updateRole(RoomRole.HOST); - // TODO: Redis에서 제거하도록 변경 - // room.decrementParticipant(); // Redis로 이관 - DB 업데이트 제거 - - log.info("새 방장 지정 - RoomId: {}, NewHostId: {}", - room.getId(), newHost.getUser().getId()); - } - } - } - public Page getJoinableRooms(Pageable pageable) { return roomRepository.findJoinablePublicRooms(pageable); } @@ -260,35 +232,93 @@ public void terminateRoom(Long roomId, Long userId) { } room.terminate(); - // TODO: Redis에서 모든 멤버 제거하도록 변경 - // roomMemberRepository.disconnectAllMembers(roomId); - log.info("방 종료 완료 - RoomId: {}, UserId: {}", roomId, userId); + // Redis에서 모든 온라인 사용자 제거 + Set onlineUserIds = roomRedisService.getRoomUsers(roomId); + for (Long onlineUserId : onlineUserIds) { + roomRedisService.exitRoom(onlineUserId, roomId); + } + + log.info("방 종료 완료 - RoomId: {}, UserId: {}, 퇴장 처리: {}명", + roomId, userId, onlineUserIds.size()); } + /** + * 멤버 역할 변경 + * 1. 방장만 역할 변경 가능 + * 2. VISITOR → 모든 역할 승격 가능 (HOST 포함) + * 3. HOST로 변경 시: + * - 대상자가 DB에 없으면 DB에 저장 + * - 기존 방장은 자동으로 MEMBER로 강등 + * - 본인은 방장으로 변경 불가 + * 4. 방장 자신의 역할은 변경 불가 + * @param roomId 방 ID + * @param targetUserId 대상 사용자 ID + * @param newRole 새 역할 + * @param requesterId 요청자 ID (방장) + */ @Transactional public void changeUserRole(Long roomId, Long targetUserId, RoomRole newRole, Long requesterId) { + // 1. 요청자가 방장인지 확인 RoomMember requester = roomMemberRepository.findByRoomIdAndUserId(roomId, requesterId) .orElseThrow(() -> new CustomException(ErrorCode.NOT_ROOM_MEMBER)); - if (!requester.canManageRoom()) { + if (!requester.isHost()) { throw new CustomException(ErrorCode.NOT_ROOM_MANAGER); } - RoomMember targetMember = roomMemberRepository.findByRoomIdAndUserId(roomId, targetUserId) - .orElseThrow(() -> new CustomException(ErrorCode.NOT_ROOM_MEMBER)); - - if (targetMember.isHost()) { - throw new CustomException(ErrorCode.CANNOT_CHANGE_HOST_ROLE); + // 2. 본인을 변경하려는 경우 (방장 → 다른 역할 불가) + if (targetUserId.equals(requesterId)) { + throw new CustomException(ErrorCode.CANNOT_CHANGE_OWN_ROLE); } - targetMember.updateRole(newRole); + // 3. 대상자 확인 (DB 조회 - VISITOR는 DB에 없을 수 있음) + Optional targetMemberOpt = roomMemberRepository.findByRoomIdAndUserId(roomId, targetUserId); - log.info("멤버 권한 변경 완료 - RoomId: {}, TargetUserId: {}, NewRole: {}, RequesterId: {}", - roomId, targetUserId, newRole, requesterId); + // 4. HOST로 변경하는 경우 - 기존 방장 강등 + if (newRole == RoomRole.HOST) { + // 기존 방장을 MEMBER로 강등 + requester.updateRole(RoomRole.MEMBER); + log.info("기존 방장 강등 - RoomId: {}, UserId: {}, MEMBER로 변경", roomId, requesterId); + } + + // 5. 대상자 처리 + if (targetMemberOpt.isPresent()) { + // 기존 멤버 - 역할만 업데이트 + RoomMember targetMember = targetMemberOpt.get(); + targetMember.updateRole(newRole); + + log.info("멤버 권한 변경 - RoomId: {}, TargetUserId: {}, NewRole: {}", + roomId, targetUserId, newRole); + } else { + // VISITOR → 승격 시 DB에 저장 + Room room = roomRepository.findById(roomId) + .orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND)); + + User targetUser = userRepository.findById(targetUserId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + // DB에 저장 (처음으로!) + RoomMember newMember = RoomMember.create(room, targetUser, newRole); + roomMemberRepository.save(newMember); + + log.info("VISITOR 승격 (DB 저장) - RoomId: {}, UserId: {}, NewRole: {}", + roomId, targetUserId, newRole); + } } + /** + * 방 멤버 목록 조회 (Redis + DB 조합) + * 1. Redis에서 온라인 사용자 ID 조회 + * 2. DB에서 해당 사용자들의 멤버십 조회 (IN 절) + * 3. DB에 없는 사용자 = VISITOR + * 4. User 정보와 조합하여 반환 + * + * @param roomId 방 ID + * @param userId 요청자 ID (권한 체크용) + * @return 온라인 멤버 목록 (VISITOR 포함) + */ public List getRoomMembers(Long roomId, Long userId) { Room room = roomRepository.findById(roomId) @@ -301,7 +331,43 @@ public List getRoomMembers(Long roomId, Long userId) { } } - return roomMemberRepository.findOnlineMembersByRoomId(roomId); + // 1. Redis에서 온라인 사용자 ID 조회 + Set onlineUserIds = roomRedisService.getRoomUsers(roomId); + + if (onlineUserIds.isEmpty()) { + return List.of(); + } + + // 2. DB에서 멤버십 조회 (MEMBER 이상만 DB에 있음) + List dbMembers = roomMemberRepository.findByRoomIdAndUserIdIn(roomId, onlineUserIds); + + // 3. DB에 있는 userId Set 생성 + Set dbUserIds = dbMembers.stream() + .map(m -> m.getUser().getId()) + .collect(java.util.stream.Collectors.toSet()); + + // 4. DB에 없는 userId = VISITOR들 + Set visitorUserIds = onlineUserIds.stream() + .filter(id -> !dbUserIds.contains(id)) + .collect(java.util.stream.Collectors.toSet()); + + // 5. VISITOR User 정보 조회 (일괄 조회) + if (!visitorUserIds.isEmpty()) { + List visitorUsers = userRepository.findAllById(visitorUserIds); + + // 6. VISITOR RoomMember 객체 생성 (메모리상) + List visitorMembers = visitorUsers.stream() + .map(user -> RoomMember.createVisitor(room, user)) + .collect(java.util.stream.Collectors.toList()); + + // 7. DB 멤버 + VISITOR 합치기 + List allMembers = new java.util.ArrayList<>(dbMembers); + allMembers.addAll(visitorMembers); + + return allMembers; + } + + return dbMembers; } public RoomRole getUserRoomRole(Long roomId, Long userId) { @@ -310,6 +376,14 @@ public RoomRole getUserRoomRole(Long roomId, Long userId) { .orElse(RoomRole.VISITOR); } + /** + * 사용자 정보 조회 (역할 변경 응답용) + */ + public User getUserById(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + } + /** * 인기 방 목록 조회 (참가자 수 기준) */ @@ -337,11 +411,8 @@ public void kickMember(Long roomId, Long targetUserId, Long requesterId) { throw new CustomException(ErrorCode.CANNOT_KICK_HOST); } - // TODO: Redis에서 제거하도록 변경 - - Room room = roomRepository.findById(roomId) - .orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND)); - // room.decrementParticipant(); // Redis로 이관 - DB 업데이트 제거 + // Redis에서 제거 (강제 퇴장) + roomRedisService.exitRoom(targetUserId, roomId); log.info("멤버 추방 완료 - RoomId: {}, TargetUserId: {}, RequesterId: {}", roomId, targetUserId, requesterId); @@ -353,7 +424,7 @@ public void kickMember(Long roomId, Long targetUserId, Long requesterId) { * RoomResponse 생성 (Redis에서 실시간 참가자 수 조회) */ public com.back.domain.studyroom.dto.RoomResponse toRoomResponse(Room room) { - long onlineCount = sessionManager.getRoomOnlineUserCount(room.getId()); + long onlineCount = roomRedisService.getRoomUserCount(room.getId()); return com.back.domain.studyroom.dto.RoomResponse.from(room, onlineCount); } @@ -365,7 +436,7 @@ public java.util.List toRoomResponse .map(Room::getId) .collect(java.util.stream.Collectors.toList()); - java.util.Map participantCounts = sessionManager.getBulkRoomOnlineUserCounts(roomIds); + java.util.Map participantCounts = roomRedisService.getBulkRoomOnlineUserCounts(roomIds); return rooms.stream() .map(room -> com.back.domain.studyroom.dto.RoomResponse.from( @@ -381,7 +452,7 @@ public java.util.List toRoomResponse public com.back.domain.studyroom.dto.RoomDetailResponse toRoomDetailResponse( Room room, java.util.List members) { - long onlineCount = sessionManager.getRoomOnlineUserCount(room.getId()); + long onlineCount = roomRedisService.getRoomUserCount(room.getId()); java.util.List memberResponses = members.stream() .map(com.back.domain.studyroom.dto.RoomMemberResponse::from) @@ -394,7 +465,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 = sessionManager.getRoomOnlineUserCount(room.getId()); + long onlineCount = roomRedisService.getRoomUserCount(room.getId()); return com.back.domain.studyroom.dto.MyRoomResponse.of(room, onlineCount, myRole); } @@ -408,7 +479,7 @@ public java.util.List toMyRoomResp .map(Room::getId) .collect(java.util.stream.Collectors.toList()); - java.util.Map participantCounts = sessionManager.getBulkRoomOnlineUserCounts(roomIds); + java.util.Map participantCounts = roomRedisService.getBulkRoomOnlineUserCounts(roomIds); return rooms.stream() .map(room -> { diff --git a/src/main/java/com/back/global/exception/ErrorCode.java b/src/main/java/com/back/global/exception/ErrorCode.java index 03b96782..917651fe 100644 --- a/src/main/java/com/back/global/exception/ErrorCode.java +++ b/src/main/java/com/back/global/exception/ErrorCode.java @@ -32,9 +32,10 @@ public enum ErrorCode { NOT_ROOM_MANAGER(HttpStatus.FORBIDDEN, "ROOM_009", "방 관리자 권한이 필요합니다."), CANNOT_KICK_HOST(HttpStatus.BAD_REQUEST, "ROOM_010", "방장은 추방할 수 없습니다."), CANNOT_CHANGE_HOST_ROLE(HttpStatus.BAD_REQUEST, "ROOM_011", "방장의 권한은 변경할 수 없습니다."), - CHAT_DELETE_FORBIDDEN(HttpStatus.FORBIDDEN, "ROOM_012", "채팅 삭제 권한이 없습니다. 방장 또는 부방장만 가능합니다."), - INVALID_DELETE_CONFIRMATION(HttpStatus.BAD_REQUEST, "ROOM_013", "삭제 확인 메시지가 일치하지 않습니다."), - CHAT_DELETE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "ROOM_014", "채팅 삭제 중 오류가 발생했습니다."), + CANNOT_CHANGE_OWN_ROLE(HttpStatus.BAD_REQUEST, "ROOM_012", "자신의 역할은 변경할 수 없습니다."), + CHAT_DELETE_FORBIDDEN(HttpStatus.FORBIDDEN, "ROOM_013", "채팅 삭제 권한이 없습니다. 방장 또는 부방장만 가능합니다."), + INVALID_DELETE_CONFIRMATION(HttpStatus.BAD_REQUEST, "ROOM_014", "삭제 확인 메시지가 일치하지 않습니다."), + CHAT_DELETE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "ROOM_015", "채팅 삭제 중 오류가 발생했습니다."), // ======================== 스터디 플래너 관련 ======================== PLAN_NOT_FOUND(HttpStatus.NOT_FOUND, "PLAN_001", "존재하지 않는 학습 계획입니다."), From ef46ed03959a360b46638871fa55ab47ff88db00 Mon Sep 17 00:00:00 2001 From: loseminho Date: Fri, 3 Oct 2025 20:39:05 +0900 Subject: [PATCH 2/8] =?UTF-8?q?fix:=20ci=EC=97=90=EC=84=9C=20=ED=86=B5?= =?UTF-8?q?=EA=B3=BC=20=EB=AA=BB=ED=95=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/RoomChatApiControllerTest.java | 4 ++-- .../studyroom/service/RoomServiceTest.java | 20 ++++++++++--------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/test/java/com/back/domain/chat/room/controller/RoomChatApiControllerTest.java b/src/test/java/com/back/domain/chat/room/controller/RoomChatApiControllerTest.java index 0d307141..71a43ee6 100644 --- a/src/test/java/com/back/domain/chat/room/controller/RoomChatApiControllerTest.java +++ b/src/test/java/com/back/domain/chat/room/controller/RoomChatApiControllerTest.java @@ -290,7 +290,7 @@ void t8() throws Exception { .contentType(MediaType.APPLICATION_JSON) .content(requestBody)) .andExpect(status().isForbidden()) - .andExpect(jsonPath("$.code").value("ROOM_012")) + .andExpect(jsonPath("$.code").value("ROOM_013")) .andExpect(jsonPath("$.message").value("채팅 삭제 권한이 없습니다. 방장 또는 부방장만 가능합니다.")); } @@ -364,7 +364,7 @@ void t10() throws Exception { .contentType(MediaType.APPLICATION_JSON) .content(requestBody)) .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value("ROOM_013")) + .andExpect(jsonPath("$.code").value("ROOM_014")) .andExpect(jsonPath("$.message").value("삭제 확인 메시지가 일치하지 않습니다.")); } 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 d9f1c7a7..0d15e458 100644 --- a/src/test/java/com/back/domain/studyroom/service/RoomServiceTest.java +++ b/src/test/java/com/back/domain/studyroom/service/RoomServiceTest.java @@ -47,6 +47,9 @@ class RoomServiceTest { @Mock private StudyRoomProperties properties; + @Mock + private RoomRedisService roomRedisService; + @InjectMocks private RoomService roomService; @@ -141,14 +144,16 @@ 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(roomMemberRepository.save(any(RoomMember.class))).willReturn(testMember); + given(roomRedisService.getRoomUserCount(1L)).willReturn(0L); // Redis 카운트 // when RoomMember joinedMember = roomService.joinRoom(1L, null, 2L); // then assertThat(joinedMember).isNotNull(); - verify(roomMemberRepository, times(1)).save(any(RoomMember.class)); + assertThat(joinedMember.getRole()).isEqualTo(RoomRole.VISITOR); + verify(roomRedisService, times(1)).enterRoom(2L, 1L); // Redis 입장 확인 + verify(roomMemberRepository, never()).save(any(RoomMember.class)); // DB 저장 안됨! } @Test @@ -178,6 +183,7 @@ void joinRoom_WrongPassword() { true // useWebRTC ); given(roomRepository.findByIdWithLock(1L)).willReturn(Optional.of(privateRoom)); + given(roomRedisService.getRoomUserCount(1L)).willReturn(0L); // Redis 카운트 // when & then assertThatThrownBy(() -> roomService.joinRoom(1L, "wrong", 1L)) @@ -189,15 +195,13 @@ void joinRoom_WrongPassword() { @DisplayName("방 나가기 - 성공") void leaveRoom_Success() { // given - // TODO: Redis 통합 후 온라인 상태 체크 추가 예정 given(roomRepository.findById(1L)).willReturn(Optional.of(testRoom)); - given(roomMemberRepository.findByRoomIdAndUserId(1L, 1L)).willReturn(Optional.of(testMember)); // when roomService.leaveRoom(1L, 1L); // then - verify(roomMemberRepository, times(1)).findByRoomIdAndUserId(1L, 1L); + verify(roomRedisService, times(1)).exitRoom(1L, 1L); // Redis 퇴장 확인 } @Test @@ -308,7 +312,7 @@ void updateRoomSettings_NotOwner() { void terminateRoom_Success() { // given given(roomRepository.findById(1L)).willReturn(Optional.of(testRoom)); - // disconnectAllMembers는 더 이상 호출되지 않음 (Redis로 이관 예정) + given(roomRedisService.getRoomUsers(1L)).willReturn(java.util.Set.of()); // 온라인 사용자 없음 // when roomService.terminateRoom(1L, 1L); @@ -316,7 +320,6 @@ void terminateRoom_Success() { // then assertThat(testRoom.getStatus()).isEqualTo(RoomStatus.TERMINATED); assertThat(testRoom.isActive()).isFalse(); - // verify 제거: disconnectAllMembers는 더 이상 호출되지 않음 } @Test @@ -370,13 +373,12 @@ void kickMember_Success() { given(roomMemberRepository.findByRoomIdAndUserId(1L, 1L)).willReturn(Optional.of(hostMember)); given(roomMemberRepository.findByRoomIdAndUserId(1L, 2L)).willReturn(Optional.of(targetMember)); - given(roomRepository.findById(1L)).willReturn(Optional.of(testRoom)); // when roomService.kickMember(1L, 2L, 1L); // then - verify(roomMemberRepository, times(1)).findByRoomIdAndUserId(1L, 2L); + verify(roomRedisService, times(1)).exitRoom(2L, 1L); // Redis 퇴장 확인 } @Test From 23e55ea977ab7dcf01685b75786ec227edf0e677 Mon Sep 17 00:00:00 2001 From: loseminho Date: Sat, 4 Oct 2025 15:10:09 +0900 Subject: [PATCH 3/8] =?UTF-8?q?fix:rest=20api=EC=99=80=20=EC=9B=B9?= =?UTF-8?q?=EC=86=8C=EC=BC=93=20=EC=A4=91=EA=B0=84=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/RoleChangedNotification.java | 62 ++++++++++++++++++ .../studyroom/service/RoomRedisService.java | 17 +++-- .../domain/studyroom/service/RoomService.java | 63 ++++++++++++++----- .../studyroom/service/RoomServiceTest.java | 14 ++--- 4 files changed, 125 insertions(+), 31 deletions(-) create mode 100644 src/main/java/com/back/domain/studyroom/dto/RoleChangedNotification.java 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..9bb91621 100644 --- a/src/main/java/com/back/domain/studyroom/service/RoomRedisService.java +++ b/src/main/java/com/back/domain/studyroom/service/RoomRedisService.java @@ -10,18 +10,15 @@ /** * 방 상태 관리를 위한 Redis 전용 서비스 - * 방의 온라인 사용자 관리 (입장/퇴장) - * 실시간 참가자 수 조회 - * 온라인 사용자 목록 조회 - * Redis: 실시간 온라인 상태만 관리 (휘발성 데이터) - * DB: 영구 멤버십 + 역할 정보 (MEMBER 이상만 저장) - * 역할(Role)은 Redis에 저장하지 않음! - * 이유 1: DB가 Single Source of Truth (데이터 일관성) - * 이유 2: Redis-DB 동기화 복잡도 제거 - * 이유 3: 멤버 목록 조회 시 IN 절로 효율적 조회 가능 + * + * @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..d92229de 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: {}, 퇴장 처리: {}명", @@ -276,6 +278,9 @@ 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); } @@ -436,7 +463,11 @@ public java.util.List toRoomResponse .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); } @@ -479,7 +510,11 @@ public java.util.List toMyRoomResp .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..72163788 100644 --- a/src/test/java/com/back/domain/studyroom/service/RoomServiceTest.java +++ b/src/test/java/com/back/domain/studyroom/service/RoomServiceTest.java @@ -48,7 +48,7 @@ class RoomServiceTest { private StudyRoomProperties properties; @Mock - private RoomRedisService roomRedisService; + private RoomParticipantService roomParticipantService; @InjectMocks private RoomService roomService; @@ -144,7 +144,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 +152,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 +183,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 +201,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 +312,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 +378,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 From 2979e6ef87d7387dcf188b90e48ceb1a38694d9a Mon Sep 17 00:00:00 2001 From: loseminho Date: Sat, 4 Oct 2025 15:11:40 +0900 Subject: [PATCH 4/8] =?UTF-8?q?fix:rest=20api=EC=99=80=20=EC=9B=B9?= =?UTF-8?q?=EC=86=8C=EC=BC=93=20=EC=A4=91=EA=B0=84=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/studyroom/service/RoomRedisService.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 9bb91621..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,11 +9,11 @@ import java.util.Set; /** - * 방 상태 관리를 위한 Redis 전용 서비스 - * - * @deprecated RoomParticipantService를 사용하세요. - * 이 서비스는 WebSocketSessionManager의 Wrapper일 뿐이며, - * RoomParticipantService가 더 직접적이고 명확합니다. + * 방 상태 관리를 위한 Redis 전용 서비스 (곧 사라질 예정인 파일) + * (현재는 일단 유지 시킨 상황, 에러 방지용) + * @deprecated RoomParticipantService를 사용. + * 현재는 WebSocketSessionManager의 Wrapper일 뿐이며, + * RoomParticipantService에 원래 로직이 옮겨졋습니다. * * @see com.back.global.websocket.service.RoomParticipantService 실제 사용 서비스 * @see com.back.global.websocket.service.WebSocketSessionManager WebSocket 세션 관리 From 98c9e4c7c2693bc89069552cb97d07de64fe33b6 Mon Sep 17 00:00:00 2001 From: loseminho Date: Sun, 5 Oct 2025 19:33:45 +0900 Subject: [PATCH 5/8] =?UTF-8?q?fix:=20=EC=97=90=EB=9F=AC=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8=EC=9D=84=20=EC=9C=84=ED=95=9C=20=ED=86=B5=ED=95=A9?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80,=20Room.create?= =?UTF-8?q?()=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../back/domain/studyroom/entity/Room.java | 6 + .../RoomCreateIntegrationTest.java | 156 ++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 src/test/java/com/back/domain/studyroom/integration/RoomCreateIntegrationTest.java diff --git a/src/main/java/com/back/domain/studyroom/entity/Room.java b/src/main/java/com/back/domain/studyroom/entity/Room.java index dea501d0..3399007b 100644 --- a/src/main/java/com/back/domain/studyroom/entity/Room.java +++ b/src/main/java/com/back/domain/studyroom/entity/Room.java @@ -188,6 +188,12 @@ public static Room create(String title, String description, boolean isPrivate, room.createdBy = creator; room.theme = theme; + // 컬렉션 필드 명시적 초기화 (null 방지) + room.roomMembers = new ArrayList<>(); + room.roomChatMessages = new ArrayList<>(); + room.roomParticipantHistories = new ArrayList<>(); + room.studyRecords = new ArrayList<>(); + return room; } diff --git a/src/test/java/com/back/domain/studyroom/integration/RoomCreateIntegrationTest.java b/src/test/java/com/back/domain/studyroom/integration/RoomCreateIntegrationTest.java new file mode 100644 index 00000000..26f69541 --- /dev/null +++ b/src/test/java/com/back/domain/studyroom/integration/RoomCreateIntegrationTest.java @@ -0,0 +1,156 @@ +package com.back.domain.studyroom.integration; + +import com.back.domain.studyroom.dto.CreateRoomRequest; +import com.back.domain.studyroom.entity.Room; +import com.back.domain.studyroom.repository.RoomMemberRepository; +import com.back.domain.studyroom.repository.RoomRepository; +import com.back.domain.studyroom.service.RoomService; +import com.back.domain.user.entity.Role; +import com.back.domain.user.entity.User; +import com.back.domain.user.entity.UserProfile; +import com.back.domain.user.entity.UserStatus; +import com.back.domain.user.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +@DisplayName("방 생성 통합 테스트 - 실제 DB 저장") +public class RoomCreateIntegrationTest { + + @Autowired + private RoomService roomService; + + @Autowired + private UserRepository userRepository; + + @Autowired + private RoomRepository roomRepository; + + @Autowired + private RoomMemberRepository roomMemberRepository; + + private User testUser; + + @BeforeEach + void setUp() { + // 실제 사용자 생성 및 저장 + UserProfile userProfile = new UserProfile(); + userProfile.setNickname("테스트유저"); + userProfile.setProfileImageUrl("https://example.com/profile.jpg"); + + testUser = User.builder() + .username("testuser@test.com") + .email("testuser@test.com") + .password("password123") + .role(Role.USER) + .userStatus(UserStatus.ACTIVE) + .build(); + + testUser.setUserProfile(userProfile); + testUser = userRepository.save(testUser); + + System.out.println("=== 테스트 사용자 생성 완료: ID = " + testUser.getId()); + } + + @Test + @DisplayName("방 생성 실제 저장 테스트 - 프론트엔드 요청과 동일한 조건") + void createRoom_RealSave() { + // given - 프론트엔드가 보낸 것과 동일한 요청 + String title = "테스트1"; + String description = "테스트 제발 제발 제"; + boolean isPrivate = true; + String password = "123123123"; + int maxParticipants = 9; + boolean useWebRTC = false; + + System.out.println("=== 방 생성 시작 ==="); + System.out.println("Title: " + title); + System.out.println("Description: " + description); + System.out.println("IsPrivate: " + isPrivate); + System.out.println("Password: " + password); + System.out.println("MaxParticipants: " + maxParticipants); + System.out.println("UseWebRTC: " + useWebRTC); + System.out.println("CreatorId: " + testUser.getId()); + + // when - 실제 서비스 호출 + Room createdRoom = roomService.createRoom( + title, + description, + isPrivate, + password, + maxParticipants, + testUser.getId(), + useWebRTC + ); + + // then + assertThat(createdRoom).isNotNull(); + assertThat(createdRoom.getId()).isNotNull(); + assertThat(createdRoom.getTitle()).isEqualTo(title); + assertThat(createdRoom.getDescription()).isEqualTo(description); + assertThat(createdRoom.isPrivate()).isEqualTo(isPrivate); + assertThat(createdRoom.getPassword()).isEqualTo(password); + assertThat(createdRoom.getMaxParticipants()).isEqualTo(maxParticipants); + assertThat(createdRoom.isAllowCamera()).isEqualTo(useWebRTC); + assertThat(createdRoom.isAllowAudio()).isEqualTo(useWebRTC); + assertThat(createdRoom.isAllowScreenShare()).isEqualTo(useWebRTC); + + System.out.println("=== 방 생성 성공! ==="); + System.out.println("Room ID: " + createdRoom.getId()); + System.out.println("Room Members: " + createdRoom.getRoomMembers()); + System.out.println("Room Chat Messages: " + createdRoom.getRoomChatMessages()); + + // DB에 실제로 저장되었는지 확인 + Room savedRoom = roomRepository.findById(createdRoom.getId()).orElse(null); + assertThat(savedRoom).isNotNull(); + assertThat(savedRoom.getTitle()).isEqualTo(title); + + // 방장 멤버도 저장되었는지 확인 + boolean hostExists = roomMemberRepository.existsByRoomIdAndUserId( + createdRoom.getId(), + testUser.getId() + ); + assertThat(hostExists).isTrue(); + + System.out.println("=== 방 생성 통합 테스트 완료 ==="); + } + + @Test + @DisplayName("방 생성 - 컬렉션 필드 null 체크") + void createRoom_CheckCollections() { + // given + String title = "컬렉션 테스트"; + + // when + Room createdRoom = roomService.createRoom( + title, + "설명", + false, + null, + 10, + testUser.getId(), + true + ); + + // then - 컬렉션 필드들이 null이 아니어야 함 + assertThat(createdRoom.getRoomMembers()).isNotNull(); + assertThat(createdRoom.getRoomChatMessages()).isNotNull(); + assertThat(createdRoom.getRoomParticipantHistories()).isNotNull(); + assertThat(createdRoom.getStudyRecords()).isNotNull(); + + System.out.println("=== 컬렉션 필드 확인 ==="); + System.out.println("RoomMembers: " + createdRoom.getRoomMembers()); + System.out.println("RoomChatMessages: " + createdRoom.getRoomChatMessages()); + System.out.println("RoomParticipantHistories: " + createdRoom.getRoomParticipantHistories()); + System.out.println("StudyRecords: " + createdRoom.getStudyRecords()); + } +} From 8cb4561a8e675ccde6fb584f9c299385472f5c71 Mon Sep 17 00:00:00 2001 From: loseminho Date: Sun, 5 Oct 2025 23:43:59 +0900 Subject: [PATCH 6/8] =?UTF-8?q?refactor,=20feat=20:=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=20=EB=B6=84=ED=95=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../studyroom/controller/RoomController.java | 181 +++++++++++++++++- .../domain/studyroom/dto/MyRoomResponse.java | 2 + .../domain/studyroom/dto/RoomResponse.java | 23 +++ .../repository/RoomRepositoryCustom.java | 37 ++++ .../repository/RoomRepositoryImpl.java | 153 +++++++++++++++ .../domain/studyroom/service/RoomService.java | 98 ++++++++++ 6 files changed, 493 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/back/domain/studyroom/controller/RoomController.java b/src/main/java/com/back/domain/studyroom/controller/RoomController.java index 2b7b4b7c..eeac3049 100644 --- a/src/main/java/com/back/domain/studyroom/controller/RoomController.java +++ b/src/main/java/com/back/domain/studyroom/controller/RoomController.java @@ -126,9 +126,140 @@ public ResponseEntity> leaveRoom( .body(RsData.success("방 퇴장 완료", null)); } - @GetMapping + @GetMapping("/all") + @Operation( + summary = "모든 방 목록 조회", + description = "공개 방과 비공개 방 전체를 조회합니다. 비공개 방은 제목과 방장 정보가 마스킹됩니다. 열린 방(WAITING, ACTIVE)이 우선 표시되고, 닫힌 방(PAUSED, TERMINATED)은 뒤로 밀립니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + public ResponseEntity>> getAllRooms( + @Parameter(description = "페이지 번호 (0부터 시작)") @RequestParam(defaultValue = "0") int page, + @Parameter(description = "페이지 크기") @RequestParam(defaultValue = "20") int size) { + + Pageable pageable = PageRequest.of(page, size); + Page rooms = roomService.getAllRooms(pageable); + + // 비공개 방 마스킹 포함한 변환 + List roomList = roomService.toRoomResponseListWithMasking(rooms.getContent()); + + Map response = new HashMap<>(); + response.put("rooms", roomList); + response.put("page", rooms.getNumber()); + response.put("size", rooms.getSize()); + response.put("totalElements", rooms.getTotalElements()); + response.put("totalPages", rooms.getTotalPages()); + response.put("hasNext", rooms.hasNext()); + + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success("모든 방 목록 조회 완료", response)); + } + + @GetMapping("/public") @Operation( summary = "공개 방 목록 조회", + description = "공개 방 전체를 조회합니다. includeInactive=true로 설정하면 닫힌 방도 포함됩니다 (기본값: true). 열린 방이 우선 표시됩니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + public ResponseEntity>> getPublicRooms( + @Parameter(description = "페이지 번호 (0부터 시작)") @RequestParam(defaultValue = "0") int page, + @Parameter(description = "페이지 크기") @RequestParam(defaultValue = "20") int size, + @Parameter(description = "닫힌 방 포함 여부") @RequestParam(defaultValue = "true") boolean includeInactive) { + + Pageable pageable = PageRequest.of(page, size); + Page rooms = roomService.getPublicRooms(includeInactive, pageable); + + List roomList = roomService.toRoomResponseList(rooms.getContent()); + + Map response = new HashMap<>(); + response.put("rooms", roomList); + response.put("page", rooms.getNumber()); + response.put("size", rooms.getSize()); + response.put("totalElements", rooms.getTotalElements()); + response.put("totalPages", rooms.getTotalPages()); + response.put("hasNext", rooms.hasNext()); + + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success("공개 방 목록 조회 완료", response)); + } + + @GetMapping("/private") + @Operation( + summary = "내 비공개 방 목록 조회", + description = "내가 멤버로 등록된 비공개 방을 조회합니다. includeInactive=true로 설정하면 닫힌 방도 포함됩니다 (기본값: true). 열린 방이 우선 표시됩니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + public ResponseEntity>> getMyPrivateRooms( + @Parameter(description = "페이지 번호 (0부터 시작)") @RequestParam(defaultValue = "0") int page, + @Parameter(description = "페이지 크기") @RequestParam(defaultValue = "20") int size, + @Parameter(description = "닫힌 방 포함 여부") @RequestParam(defaultValue = "true") boolean includeInactive) { + + Long currentUserId = currentUser.getUserId(); + + Pageable pageable = PageRequest.of(page, size); + Page rooms = roomService.getMyPrivateRooms(currentUserId, includeInactive, pageable); + + List roomList = roomService.toRoomResponseList(rooms.getContent()); + + Map response = new HashMap<>(); + response.put("rooms", roomList); + response.put("page", rooms.getNumber()); + response.put("size", rooms.getSize()); + response.put("totalElements", rooms.getTotalElements()); + response.put("totalPages", rooms.getTotalPages()); + response.put("hasNext", rooms.hasNext()); + + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success("내 비공개 방 목록 조회 완료", response)); + } + + @GetMapping("/my/hosting") + @Operation( + summary = "내가 호스트인 방 목록 조회", + description = "내가 방장으로 있는 방을 조회합니다. 열린 방이 우선 표시되고, 닫힌 방은 뒤로 밀립니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + public ResponseEntity>> getMyHostingRooms( + @Parameter(description = "페이지 번호 (0부터 시작)") @RequestParam(defaultValue = "0") int page, + @Parameter(description = "페이지 크기") @RequestParam(defaultValue = "20") int size) { + + Long currentUserId = currentUser.getUserId(); + + Pageable pageable = PageRequest.of(page, size); + Page rooms = roomService.getMyHostingRooms(currentUserId, pageable); + + List roomList = roomService.toRoomResponseList(rooms.getContent()); + + Map response = new HashMap<>(); + response.put("rooms", roomList); + response.put("page", rooms.getNumber()); + response.put("size", rooms.getSize()); + response.put("totalElements", rooms.getTotalElements()); + response.put("totalPages", rooms.getTotalPages()); + response.put("hasNext", rooms.hasNext()); + + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success("내가 호스트인 방 목록 조회 완료", response)); + } + + @GetMapping + @Operation( + summary = "입장 가능한 공개 방 목록 조회 (기존)", description = "입장 가능한 공개 스터디 룸 목록을 페이징하여 조회합니다. 최신 생성 순으로 정렬됩니다." ) @ApiResponses({ @@ -262,6 +393,54 @@ public ResponseEntity> deleteRoom( .body(RsData.success("방 종료 완료", null)); } + @PutMapping("/{roomId}/pause") + @Operation( + summary = "방 일시정지", + description = "방을 일시정지 상태로 변경합니다. 일시정지된 방은 입장할 수 없으며, 방 목록에서 뒤로 밀립니다. 방장만 실행 가능합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "일시정지 성공"), + @ApiResponse(responseCode = "400", description = "이미 종료되었거나 일시정지 불가능한 상태"), + @ApiResponse(responseCode = "403", description = "방장 권한 없음"), + @ApiResponse(responseCode = "404", description = "존재하지 않는 방"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + public ResponseEntity> pauseRoom( + @Parameter(description = "방 ID", required = true) @PathVariable Long roomId) { + + Long currentUserId = currentUser.getUserId(); + + roomService.pauseRoom(roomId, currentUserId); + + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success("방 일시정지 완료", null)); + } + + @PutMapping("/{roomId}/activate") + @Operation( + summary = "방 활성화/재개", + description = "일시정지된 방을 다시 활성화합니다. 활성화된 방은 다시 입장 가능하며, 방 목록 앞쪽에 표시됩니다. 방장만 실행 가능합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "활성화 성공"), + @ApiResponse(responseCode = "400", description = "이미 종료되었거나 활성화 불가능한 상태"), + @ApiResponse(responseCode = "403", description = "방장 권한 없음"), + @ApiResponse(responseCode = "404", description = "존재하지 않는 방"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + public ResponseEntity> activateRoom( + @Parameter(description = "방 ID", required = true) @PathVariable Long roomId) { + + Long currentUserId = currentUser.getUserId(); + + roomService.activateRoom(roomId, currentUserId); + + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success("방 활성화 완료", null)); + } + @GetMapping("/{roomId}/members") @Operation( summary = "방 멤버 목록 조회", diff --git a/src/main/java/com/back/domain/studyroom/dto/MyRoomResponse.java b/src/main/java/com/back/domain/studyroom/dto/MyRoomResponse.java index 7a5c710d..548a6f09 100644 --- a/src/main/java/com/back/domain/studyroom/dto/MyRoomResponse.java +++ b/src/main/java/com/back/domain/studyroom/dto/MyRoomResponse.java @@ -14,6 +14,7 @@ public class MyRoomResponse { private Long roomId; private String title; private String description; + private Boolean isPrivate; // 비공개 방 여부 (UI에서 🔒 아이콘 표시용) private int currentParticipants; private int maxParticipants; private RoomStatus status; @@ -25,6 +26,7 @@ public static MyRoomResponse of(Room room, long currentParticipants, RoomRole my .roomId(room.getId()) .title(room.getTitle()) .description(room.getDescription() != null ? room.getDescription() : "") + .isPrivate(room.isPrivate()) // 비공개 방 여부 .currentParticipants((int) currentParticipants) // Redis에서 조회한 실시간 값 .maxParticipants(room.getMaxParticipants()) .status(room.getStatus()) diff --git a/src/main/java/com/back/domain/studyroom/dto/RoomResponse.java b/src/main/java/com/back/domain/studyroom/dto/RoomResponse.java index 1f22a5aa..e35968aa 100644 --- a/src/main/java/com/back/domain/studyroom/dto/RoomResponse.java +++ b/src/main/java/com/back/domain/studyroom/dto/RoomResponse.java @@ -13,6 +13,7 @@ public class RoomResponse { private Long roomId; private String title; private String description; + private Boolean isPrivate; // 비공개 방 여부 (UI에서 🔒 아이콘 표시용) private int currentParticipants; private int maxParticipants; private RoomStatus status; @@ -29,6 +30,7 @@ public static RoomResponse from(Room room, long currentParticipants) { .roomId(room.getId()) .title(room.getTitle()) .description(room.getDescription() != null ? room.getDescription() : "") + .isPrivate(room.isPrivate()) // 비공개 방 여부 .currentParticipants((int) currentParticipants) // Redis에서 조회한 실시간 값 .maxParticipants(room.getMaxParticipants()) .status(room.getStatus()) @@ -39,4 +41,25 @@ public static RoomResponse from(Room room, long currentParticipants) { .allowScreenShare(room.isAllowScreenShare()) .build(); } + + /** + * 비공개 방 정보 마스킹 버전 (전체 목록에서 볼 때 사용) + * "모든 방" 조회 시 사용 - 비공개 방의 민감한 정보를 숨김 + */ + public static RoomResponse fromMasked(Room room) { + return RoomResponse.builder() + .roomId(room.getId()) + .title("🔒 비공개 방") // 제목 마스킹 + .description("비공개 방입니다") // 설명 마스킹 + .isPrivate(true) + .currentParticipants(0) // 참가자 수 숨김 + .maxParticipants(0) // 정원 숨김 + .status(room.getStatus()) + .createdBy("익명") // 방장 정보 숨김 + .createdAt(room.getCreatedAt()) + .allowCamera(false) // RTC 정보 숨김 + .allowAudio(false) + .allowScreenShare(false) + .build(); + } } diff --git a/src/main/java/com/back/domain/studyroom/repository/RoomRepositoryCustom.java b/src/main/java/com/back/domain/studyroom/repository/RoomRepositoryCustom.java index 5673b101..31ae6f13 100644 --- a/src/main/java/com/back/domain/studyroom/repository/RoomRepositoryCustom.java +++ b/src/main/java/com/back/domain/studyroom/repository/RoomRepositoryCustom.java @@ -47,4 +47,41 @@ public interface RoomRepositoryCustom { * 비관적 락으로 방 조회 (동시성 제어용) */ Optional findByIdWithLock(Long roomId); + + /** + * 모든 방 조회 (공개 + 비공개 전체) + * 정렬: 열린 방(WAITING, ACTIVE) 우선 → 최신순 + * 비공개 방은 정보 마스킹하여 반환 + * @param pageable 페이징 정보 + * @return 페이징된 방 목록 + */ + Page findAllRooms(Pageable pageable); + + /** + * 공개 방 전체 조회 + * 정렬: 열린 방 우선 → 최신순 + * @param includeInactive 닫힌 방(PAUSED, TERMINATED) 포함 여부 + * @param pageable 페이징 정보 + * @return 페이징된 공개 방 목록 + */ + Page findPublicRoomsWithStatus(boolean includeInactive, Pageable pageable); + + /** + * 내가 멤버인 비공개 방 조회 + * 정렬: 열린 방 우선 → 최신순 + * @param userId 사용자 ID + * @param includeInactive 닫힌 방 포함 여부 + * @param pageable 페이징 정보 + * @return 페이징된 비공개 방 목록 + */ + Page findMyPrivateRooms(Long userId, boolean includeInactive, Pageable pageable); + + /** + * 내가 호스트(방장)인 방 조회 + * 정렬: 열린 방 우선 → 최신순 + * @param userId 사용자 ID + * @param pageable 페이징 정보 + * @return 페이징된 방 목록 + */ + Page findRoomsByHostId(Long userId, Pageable pageable); } diff --git a/src/main/java/com/back/domain/studyroom/repository/RoomRepositoryImpl.java b/src/main/java/com/back/domain/studyroom/repository/RoomRepositoryImpl.java index d9ab9ac0..48714b45 100644 --- a/src/main/java/com/back/domain/studyroom/repository/RoomRepositoryImpl.java +++ b/src/main/java/com/back/domain/studyroom/repository/RoomRepositoryImpl.java @@ -270,4 +270,157 @@ public Optional findByIdWithLock(Long roomId) { return Optional.ofNullable(foundRoom); } + + /** + * 모든 방 조회 (공개 + 비공개 전체) + * 조회 조건: + * - 모든 방 (공개 + 비공개) + * 정렬: + * 1. 열린 방(WAITING, ACTIVE) 우선 + * 2. 닫힌 방(PAUSED, TERMINATED) 뒤로 + * 3. 최신 생성순 + * + * 비공개 방은 컨트롤러/서비스 레이어에서 정보 마스킹 합니당 + */ + @Override + public Page findAllRooms(Pageable pageable) { + List rooms = queryFactory + .selectFrom(room) + .leftJoin(room.createdBy, user).fetchJoin() + .orderBy( + // 열린 방 우선 (0), 닫힌 방 뒤로 (1) + room.status.when(RoomStatus.WAITING).then(0) + .when(RoomStatus.ACTIVE).then(0) + .otherwise(1).asc(), + room.createdAt.desc() // 최신순 + ) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + // 전체 개수 조회 + Long totalCount = queryFactory + .select(room.count()) + .from(room) + .fetchOne(); + + return new PageImpl<>(rooms, pageable, totalCount != null ? totalCount : 0); + } + + /** + * 공개 방 전체 조회 + * 조회 조건: + * - isPrivate = false + * - includeInactive에 따라 닫힌 방 포함 여부 결정 + * 정렬: 열린 방 우선 → 최신순 + */ + @Override + public Page findPublicRoomsWithStatus(boolean includeInactive, Pageable pageable) { + BooleanExpression whereClause = room.isPrivate.eq(false); + + // 닫힌 방 제외 옵션 + if (!includeInactive) { + whereClause = whereClause.and( + room.status.in(RoomStatus.WAITING, RoomStatus.ACTIVE) + ); + } + + List rooms = queryFactory + .selectFrom(room) + .leftJoin(room.createdBy, user).fetchJoin() + .where(whereClause) + .orderBy( + room.status.when(RoomStatus.WAITING).then(0) + .when(RoomStatus.ACTIVE).then(0) + .otherwise(1).asc(), + room.createdAt.desc() + ) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + Long totalCount = queryFactory + .select(room.count()) + .from(room) + .where(whereClause) + .fetchOne(); + + return new PageImpl<>(rooms, pageable, totalCount != null ? totalCount : 0); + } + + /** + * 내가 멤버인 비공개 방 조회 + * 조회 조건: + * - isPrivate = true + * - 내가 멤버로 등록된 방 + * - includeInactive에 따라 닫힌 방 포함 여부 결정 + * 정렬: 열린 방 우선 → 최신순 + */ + @Override + public Page findMyPrivateRooms(Long userId, boolean includeInactive, Pageable pageable) { + BooleanExpression whereClause = room.isPrivate.eq(true) + .and(roomMember.user.id.eq(userId)); + + // 닫힌 방 제외 옵션 + if (!includeInactive) { + whereClause = whereClause.and( + room.status.in(RoomStatus.WAITING, RoomStatus.ACTIVE) + ); + } + + List rooms = queryFactory + .selectFrom(room) + .leftJoin(room.createdBy, user).fetchJoin() + .join(room.roomMembers, roomMember) + .where(whereClause) + .orderBy( + room.status.when(RoomStatus.WAITING).then(0) + .when(RoomStatus.ACTIVE).then(0) + .otherwise(1).asc(), + room.createdAt.desc() + ) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + Long totalCount = queryFactory + .select(room.count()) + .from(room) + .join(room.roomMembers, roomMember) + .where(whereClause) + .fetchOne(); + + return new PageImpl<>(rooms, pageable, totalCount != null ? totalCount : 0); + } + + /** + * 내가 호스트(방장)인 방 조회 + * 조회 조건: + * - room.createdBy.id = userId + * 정렬: 열린 방 우선 → 최신순 + */ + @Override + public Page findRoomsByHostId(Long userId, Pageable pageable) { + List rooms = queryFactory + .selectFrom(room) + .leftJoin(room.createdBy, user).fetchJoin() + .where(room.createdBy.id.eq(userId)) + .orderBy( + room.status.when(RoomStatus.WAITING).then(0) + .when(RoomStatus.ACTIVE).then(0) + .otherwise(1).asc(), + room.createdAt.desc() + ) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + Long totalCount = queryFactory + .select(room.count()) + .from(room) + .where(room.createdBy.id.eq(userId)) + .fetchOne(); + + return new PageImpl<>(rooms, pageable, totalCount != null ? totalCount : 0); + } } 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 fd2281d7..f6d6db64 100644 --- a/src/main/java/com/back/domain/studyroom/service/RoomService.java +++ b/src/main/java/com/back/domain/studyroom/service/RoomService.java @@ -1,6 +1,7 @@ package com.back.domain.studyroom.service; import com.back.domain.studyroom.config.StudyRoomProperties; +import com.back.domain.studyroom.dto.RoomResponse; import com.back.domain.studyroom.entity.*; import com.back.domain.studyroom.repository.*; import com.back.domain.user.entity.User; @@ -183,6 +184,69 @@ public Page getJoinableRooms(Pageable pageable) { return roomRepository.findJoinablePublicRooms(pageable); } + /** + * 모든 방 조회 (공개 + 비공개 전체) + * 비공개 방은 정보 마스킹 + */ + public Page getAllRooms(Pageable pageable) { + return roomRepository.findAllRooms(pageable); + } + + /** + * 공개 방 전체 조회 + * @param includeInactive 닫힌 방 포함 여부 (기본: true) + */ + public Page getPublicRooms(boolean includeInactive, Pageable pageable) { + return roomRepository.findPublicRoomsWithStatus(includeInactive, pageable); + } + + /** + * 내가 멤버인 비공개 방 조회 + * @param includeInactive 닫힌 방 포함 여부 (기본: true) + */ + public Page getMyPrivateRooms(Long userId, boolean includeInactive, Pageable pageable) { + return roomRepository.findMyPrivateRooms(userId, includeInactive, pageable); + } + + /** + * 내가 호스트인 방 조회 + */ + public Page getMyHostingRooms(Long userId, Pageable pageable) { + return roomRepository.findRoomsByHostId(userId, pageable); + } + + /** + * 모든 방을 RoomResponse로 변환 (비공개 방 마스킹 포함) + * @param rooms 방 목록 + * @return 마스킹된 RoomResponse 리스트 + */ + public java.util.List toRoomResponseListWithMasking(java.util.List rooms) { + java.util.List roomIds = rooms.stream() + .map(Room::getId) + .collect(java.util.stream.Collectors.toList()); + + // Redis에서 참가자 수 일괄 조회 + java.util.Map participantCounts = roomIds.stream() + .collect(java.util.stream.Collectors.toMap( + roomId -> roomId, + roomId -> roomParticipantService.getParticipantCount(roomId) + )); + + return rooms.stream() + .map(room -> { + long count = participantCounts.getOrDefault(room.getId(), 0L); + + // 비공개 방이면 마스킹된 버전 반환 + if (room.isPrivate()) { + return RoomResponse.fromMasked(room); + } + + // 공개 방은 일반 버전 반환 + return RoomResponse.from(room, count); + }) + .collect(java.util.stream.Collectors.toList()); + } + public Room getRoomDetail(Long roomId, Long userId) { Room room = roomRepository.findById(roomId) .orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND)); @@ -245,6 +309,40 @@ public void terminateRoom(Long roomId, Long userId) { roomId, userId, onlineUserIds.size()); } + /** + * 방 일시정지 (방장만 가능) + */ + @Transactional + public void pauseRoom(Long roomId, Long userId) { + Room room = roomRepository.findById(roomId) + .orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND)); + + if (!room.isOwner(userId)) { + throw new CustomException(ErrorCode.NOT_ROOM_MANAGER); + } + + room.pause(); + + log.info("방 일시정지 완료 - RoomId: {}, UserId: {}", roomId, userId); + } + + /** + * 방 재개/활성화 (방장만 가능) + */ + @Transactional + public void activateRoom(Long roomId, Long userId) { + Room room = roomRepository.findById(roomId) + .orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND)); + + if (!room.isOwner(userId)) { + throw new CustomException(ErrorCode.NOT_ROOM_MANAGER); + } + + room.activate(); + + log.info("방 활성화 완료 - RoomId: {}, UserId: {}", roomId, userId); + } + /** * 멤버 역할 변경 * 1. 방장만 역할 변경 가능 From 57c38ae1dc6cf038d0479b8daa98e9e72b281104 Mon Sep 17 00:00:00 2001 From: loseminho Date: Tue, 7 Oct 2025 22:36:19 +0900 Subject: [PATCH 7/8] =?UTF-8?q?refactor:=20redis=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B5=9C=EC=A0=81=ED=99=94=20=EB=B0=8F=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/MemberJoinedNotification.java | 34 +++ .../studyroom/dto/MemberLeftNotification.java | 28 +++ .../dto/RoomStatusChangedNotification.java | 32 +++ .../back/domain/studyroom/entity/Room.java | 42 +--- .../RoomMemberRepositoryCustom.java | 36 --- .../repository/RoomMemberRepositoryImpl.java | 231 ------------------ .../studyroom/repository/RoomRepository.java | 9 - .../studyroom/service/RoomRedisService.java | 113 --------- .../domain/studyroom/service/RoomService.java | 56 ++--- .../WebSocketMessageController.java | 22 +- .../service/RoomParticipantService.java | 10 + .../websocket/store/RedisSessionStore.java | 44 ++++ 12 files changed, 201 insertions(+), 456 deletions(-) create mode 100644 src/main/java/com/back/domain/studyroom/dto/MemberJoinedNotification.java create mode 100644 src/main/java/com/back/domain/studyroom/dto/MemberLeftNotification.java create mode 100644 src/main/java/com/back/domain/studyroom/dto/RoomStatusChangedNotification.java delete mode 100644 src/main/java/com/back/domain/studyroom/service/RoomRedisService.java diff --git a/src/main/java/com/back/domain/studyroom/dto/MemberJoinedNotification.java b/src/main/java/com/back/domain/studyroom/dto/MemberJoinedNotification.java new file mode 100644 index 00000000..877b52da --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/dto/MemberJoinedNotification.java @@ -0,0 +1,34 @@ +package com.back.domain.studyroom.dto; + +import com.back.domain.studyroom.entity.RoomRole; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +/** + * 멤버 입장 알림 DTO + * WebSocket을 통해 방 참가자들에게 브로드캐스트 + */ +@Getter +@Builder +public class MemberJoinedNotification { + private Long roomId; + private Long userId; + private String nickname; + private String profileImageUrl; + private RoomRole role; // 입장 시 역할 (보통 VISITOR) + private LocalDateTime timestamp; + + public static MemberJoinedNotification of(Long roomId, Long userId, String nickname, + String profileImageUrl, RoomRole role) { + return MemberJoinedNotification.builder() + .roomId(roomId) + .userId(userId) + .nickname(nickname) + .profileImageUrl(profileImageUrl) + .role(role) + .timestamp(LocalDateTime.now()) + .build(); + } +} diff --git a/src/main/java/com/back/domain/studyroom/dto/MemberLeftNotification.java b/src/main/java/com/back/domain/studyroom/dto/MemberLeftNotification.java new file mode 100644 index 00000000..d66dc661 --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/dto/MemberLeftNotification.java @@ -0,0 +1,28 @@ +package com.back.domain.studyroom.dto; + +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +/** + * 멤버 퇴장 알림 DTO + * WebSocket을 통해 방 참가자들에게 브로드캐스트 + */ +@Getter +@Builder +public class MemberLeftNotification { + private Long roomId; + private Long userId; + private String nickname; + private LocalDateTime timestamp; + + public static MemberLeftNotification of(Long roomId, Long userId, String nickname) { + return MemberLeftNotification.builder() + .roomId(roomId) + .userId(userId) + .nickname(nickname) + .timestamp(LocalDateTime.now()) + .build(); + } +} diff --git a/src/main/java/com/back/domain/studyroom/dto/RoomStatusChangedNotification.java b/src/main/java/com/back/domain/studyroom/dto/RoomStatusChangedNotification.java new file mode 100644 index 00000000..5f7ed956 --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/dto/RoomStatusChangedNotification.java @@ -0,0 +1,32 @@ +package com.back.domain.studyroom.dto; + +import com.back.domain.studyroom.entity.RoomStatus; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +/** + * 방 상태 변경 알림 DTO + * WebSocket을 통해 방 참가자들에게 브로드캐스트 + */ +@Getter +@Builder +public class RoomStatusChangedNotification { + private Long roomId; + private RoomStatus oldStatus; + private RoomStatus newStatus; + private String changedBy; // 변경한 사용자 닉네임 + private LocalDateTime timestamp; + + public static RoomStatusChangedNotification of(Long roomId, RoomStatus oldStatus, + RoomStatus newStatus, String changedBy) { + return RoomStatusChangedNotification.builder() + .roomId(roomId) + .oldStatus(oldStatus) + .newStatus(newStatus) + .changedBy(changedBy) + .timestamp(LocalDateTime.now()) + .build(); + } +} diff --git a/src/main/java/com/back/domain/studyroom/entity/Room.java b/src/main/java/com/back/domain/studyroom/entity/Room.java index 3399007b..f47ab5d7 100644 --- a/src/main/java/com/back/domain/studyroom/entity/Room.java +++ b/src/main/java/com/back/domain/studyroom/entity/Room.java @@ -35,11 +35,6 @@ public class Room extends BaseEntity { @Column(nullable = false) private RoomStatus status = RoomStatus.WAITING; - // 현재 참여자 - @Builder.Default - @Column(nullable = false) - private int currentParticipants = 0; - // 방장 @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "created_by") @@ -74,40 +69,22 @@ public class Room extends BaseEntity { /** * 방에 입장할 수 있는지 확인 * 사용 상황: 사용자가 방 입장을 시도할 때 입장 가능 여부를 미리 체크 - 방이 활성화되어 있고, 입장 가능한 상태이며, 정원이 초과되지 않은 경우 + * 방이 활성화되어 있고, 입장 가능한 상태인 경우 + * + * 정원 체크는 Redis에서 실시간으로 수행 (RoomService에서 처리) */ public boolean canJoin() { - return isActive && status.isJoinable() && currentParticipants < maxParticipants; + return isActive && status.isJoinable(); } /** * 방의 정원이 가득 찼는지 확인 - 방 목록에서 "만석" 표시하거나, 입장 제한할 때 - */ - public boolean isFull() { - return currentParticipants >= maxParticipants; - } - - /** - * 참가자 수 증가 (최대 정원까지만) - 누군가 방에 입장했을 때 참가자 수를 1 증가시킴 - 최대 정원을 초과하지 않도록 체크 + * + * 실제 정원 체크는 Redis 기반으로 RoomService에서 수행 + * 이 메서드는 UI 표시용으로만 사용 (최대 정원 반환) */ - public void incrementParticipant() { - if (currentParticipants < maxParticipants) { - this.currentParticipants++; - } - } - - /** - * 참가자 수 감소 (0 이하로는 감소하지 않음) - 누군가 방에서 나갔을 때 참가자 수를 1 감소시킴 - 음수가 되지 않도록 체크 - */ - public void decrementParticipant() { - if (this.currentParticipants > 0) { - this.currentParticipants--; - } + public int getMaxCapacity() { + return maxParticipants; } /** @@ -184,7 +161,6 @@ public static Room create(String title, String description, boolean isPrivate, room.allowAudio = useWebRTC; // WebRTC 사용 여부에 따라 설정 room.allowScreenShare = useWebRTC; // WebRTC 사용 여부에 따라 설정 room.status = RoomStatus.WAITING; // 생성 시 대기 상태 - room.currentParticipants = 0; // 생성 시 참가자 0명 room.createdBy = creator; room.theme = theme; diff --git a/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepositoryCustom.java b/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepositoryCustom.java index 399c3254..b5a947b8 100644 --- a/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepositoryCustom.java +++ b/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepositoryCustom.java @@ -18,21 +18,6 @@ public interface RoomMemberRepositoryCustom { */ List findByRoomIdOrderByRole(Long roomId); - /** - * 방의 온라인 멤버 조회 - * TODO: Redis 기반으로 변경 예정 - * 현재는 DB에 저장된 모든 멤버 반환 (임시) - */ - @Deprecated - List findOnlineMembersByRoomId(Long roomId); - - /** - * 방의 활성 멤버 수 조회 - * TODO: Redis 기반으로 변경 예정 - */ - @Deprecated - int countActiveMembersByRoomId(Long roomId); - /** * 사용자가 참여 중인 모든 방의 멤버십 조회 * DB에 저장된 멤버십만 조회 (MEMBER 이상) @@ -72,25 +57,4 @@ public interface RoomMemberRepositoryCustom { * @return 멤버십 목록 (MEMBER 이상만 DB에 있음) */ List findByRoomIdAndUserIdIn(Long roomId, java.util.Set userIds); - - /** - * 특정 역할의 멤버 수 조회 - * TODO: Redis 기반으로 변경 예정 - */ - @Deprecated - int countByRoomIdAndRole(Long roomId, RoomRole role); - - /** - * 방 퇴장 처리 (벌크 업데이트) - * TODO: Redis로 이관 예정, DB에는 멤버십만 유지 - */ - @Deprecated - void leaveRoom(Long roomId, Long userId); - - /** - * 방의 모든 멤버를 오프라인 처리 (방 종료 시) - * TODO: Redis로 이관 예정 - */ - @Deprecated - void disconnectAllMembers(Long roomId); } diff --git a/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepositoryImpl.java b/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepositoryImpl.java index c48f2aa6..899a347b 100644 --- a/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepositoryImpl.java +++ b/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepositoryImpl.java @@ -72,189 +72,6 @@ public List findByRoomIdOrderByRole(Long roomId) { .fetch(); } - /** - * 방의 온라인 멤버 조회 - * TODO: Redis 기반으로 변경 예정 - * 현재는 방의 모든 멤버 반환 (임시) - * @param roomId 방 ID - * @return 멤버 목록 (역할순, 입장순 정렬) - */ - @Override - @Deprecated - public List findOnlineMembersByRoomId(Long roomId) { - return queryFactory - .selectFrom(roomMember) - .leftJoin(roomMember.user, user).fetchJoin() // N+1 방지 - .where(roomMember.room.id.eq(roomId)) - .orderBy( - roomMember.role.asc(), // 역할순 - roomMember.joinedAt.asc() // 입장 시간순 - ) - .fetch(); - } - - /** - * 방의 활성 멤버 수 조회 - * TODO: Redis 기반으로 변경 예정 - * 현재는 방의 모든 멤버 수 반환 (임시) - * @param roomId 방 ID - * @return 멤버 수 - */ - @Override - @Deprecated - public int countActiveMembersByRoomId(Long roomId) { - Long count = queryFactory - .select(roomMember.count()) - .from(roomMember) - .where(roomMember.room.id.eq(roomId)) - .fetchOne(); - - return count != null ? count.intValue() : 0; - } - - /** - * 사용자가 참여 중인 모든 방의 멤버십 조회 - * DB에 저장된 멤버십만 조회 (MEMBER 이상) - * @param userId 사용자 ID - * @return 멤버십 목록 - */ - @Override - public List findActiveByUserId(Long userId) { - return queryFactory - .selectFrom(roomMember) - .where(roomMember.user.id.eq(userId)) - .fetch(); - } - - /** - * 특정 역할의 멤버 조회 - * - 방장(HOST) 찾기 - * - 부방장(SUB_HOST) 목록 조회 - * - 역할별 멤버 필터링 - * 예시: - * ```java - * // 방의 모든 부방장 조회 - * List subHosts = findByRoomIdAndRole(roomId, RoomRole.SUB_HOST); - * ``` - * @param roomId 방 ID - * @param role 역할 (HOST, SUB_HOST, MEMBER, VISITOR) - * @return 해당 역할의 멤버 목록 - */ - @Override - public List findByRoomIdAndRole(Long roomId, RoomRole role) { - return queryFactory - .selectFrom(roomMember) - .where( - roomMember.room.id.eq(roomId), - roomMember.role.eq(role) - ) - .fetch(); - } - - /** - * 방장 조회 - * - 방장 권한 확인 - * - 방 소유자 정보 표시 - * - 정상적인 방이라면 반드시 방장이 1명 존재 - * - Optional.empty()인 경우는 데이터 오류 상태 - * @param roomId 방 ID - * @return 방장 멤버십 (Optional) - */ - @Override - public Optional findHostByRoomId(Long roomId) { - RoomMember host = queryFactory - .selectFrom(roomMember) - .where( - roomMember.room.id.eq(roomId), - roomMember.role.eq(RoomRole.HOST) - ) - .fetchOne(); - - return Optional.ofNullable(host); - } - - /** - * 관리자 권한을 가진 멤버들 조회 (HOST, SUB_HOST) - * - HOST: 방장 (최고 권한) - * - SUB_HOST: 부방장 (방장이 위임한 권한) - * - 관리자 목록 표시 - * - 권한 체크 (이 목록에 있는 사용자만 특정 작업 가능) - * @param roomId 방 ID - * @return 관리자 멤버 목록 (HOST, SUB_HOST) - */ - @Override - public List findManagersByRoomId(Long roomId) { - return queryFactory - .selectFrom(roomMember) - .where( - roomMember.room.id.eq(roomId), - roomMember.role.in(RoomRole.HOST, RoomRole.SUB_HOST) - ) - .orderBy(roomMember.role.asc()) // HOST가 먼저 - .fetch(); - } - - /** - * 사용자가 특정 방에서 관리자 권한을 가지고 있는지 확인 - * - HOST 또는 SUB_HOST 역할 - * - 방 설정 변경 권한 체크 - * - 멤버 추방 권한 체크 - * - 공지사항 작성 권한 체크 - * 사용 예시: - * ```java - * if (!roomMemberRepository.isManager(roomId, userId)) { - * throw new CustomException(ErrorCode.NOT_ROOM_MANAGER); - * } - * ``` - * @param roomId 방 ID - * @param userId 사용자 ID - * @return 관리자 권한 여부 - */ - @Override - public boolean isManager(Long roomId, Long userId) { - Long count = queryFactory - .select(roomMember.count()) - .from(roomMember) - .where( - roomMember.room.id.eq(roomId), - roomMember.user.id.eq(userId), - roomMember.role.in(RoomRole.HOST, RoomRole.SUB_HOST) - ) - .fetchOne(); - - return count != null && count > 0; - } - - /** - * 사용자가 이미 해당 방의 멤버인지 확인 - * ( 해당 로직 활용해서 유저 밴 등으로 추후에 확장 가능) - * - 방 입장 전 중복 참여 체크 - * - 비공개 방 접근 권한 확인 - * - 멤버 전용 기능 접근 권한 확인 - * 사용 예시: - * ```java - * if (room.isPrivate() && !roomMemberRepository.existsByRoomIdAndUserId(roomId, userId)) { - * throw new CustomException(ErrorCode.ROOM_FORBIDDEN); - * } - * ``` - * @param roomId 방 ID - * @param userId 사용자 ID - * @return 멤버 여부 - */ - @Override - public boolean existsByRoomIdAndUserId(Long roomId, Long userId) { - Long count = queryFactory - .select(roomMember.count()) - .from(roomMember) - .where( - roomMember.room.id.eq(roomId), - roomMember.user.id.eq(userId) - ) - .fetchOne(); - - return count != null && count > 0; - } - /** * 여러 사용자의 멤버십 일괄 조회 (IN 절) * - Redis 온라인 목록으로 DB 멤버십 조회 @@ -283,52 +100,4 @@ public List findByRoomIdAndUserIdIn(Long roomId, java.util.Set ) .fetch(); } - - /** - * 특정 역할의 멤버 수 조회 - * TODO: Redis 기반으로 변경 예정 - * @param roomId 방 ID - * @param role 역할 - * @return 해당 역할의 멤버 수 - */ - @Override - @Deprecated - public int countByRoomIdAndRole(Long roomId, RoomRole role) { - Long count = queryFactory - .select(roomMember.count()) - .from(roomMember) - .where( - roomMember.room.id.eq(roomId), - roomMember.role.eq(role) - ) - .fetchOne(); - - return count != null ? count.intValue() : 0; - } - - /** - * 방 퇴장 처리 (벌크 업데이트) - * TODO: Redis로 이관 예정 - * 현재는 아무 동작 안함 (DB에는 멤버십 유지) - * @param roomId 방 ID - * @param userId 사용자 ID - */ - @Override - @Deprecated - public void leaveRoom(Long roomId, Long userId) { - // Redis로 이관 예정 - 현재는 아무 동작 안함 - // DB의 멤버십은 유지됨 - } - - /** - * 방의 모든 멤버를 오프라인 처리 (방 종료 시) - * TODO: Redis로 이관 예정 - * 현재는 아무 동작 안함 - * @param roomId 방 ID - */ - @Override - @Deprecated - public void disconnectAllMembers(Long roomId) { - // Redis로 이관 예정 - 현재는 아무 동작 안함 - } } diff --git a/src/main/java/com/back/domain/studyroom/repository/RoomRepository.java b/src/main/java/com/back/domain/studyroom/repository/RoomRepository.java index 9c11e14f..c70dfd0d 100644 --- a/src/main/java/com/back/domain/studyroom/repository/RoomRepository.java +++ b/src/main/java/com/back/domain/studyroom/repository/RoomRepository.java @@ -37,13 +37,4 @@ public interface RoomRepository extends JpaRepository, RoomRepositor // 비밀번호 검증용 (비공개 방) @Query("SELECT r FROM Room r WHERE r.id = :roomId AND r.isPrivate = true AND r.password = :password") Optional findByIdAndPassword(@Param("roomId") Long roomId, @Param("password") String password); - - // 참가자 수 업데이트 - // TODO: Redis 기반으로 변경 예정 - 현재는 사용하지 않음 - @Deprecated - @Modifying - @Query("UPDATE Room r SET r.currentParticipants = " + - "(SELECT COUNT(rm) FROM RoomMember rm WHERE rm.room.id = r.id) " + - "WHERE r.id = :roomId") - void updateCurrentParticipants(@Param("roomId") Long roomId); } diff --git a/src/main/java/com/back/domain/studyroom/service/RoomRedisService.java b/src/main/java/com/back/domain/studyroom/service/RoomRedisService.java deleted file mode 100644 index f08f1d11..00000000 --- a/src/main/java/com/back/domain/studyroom/service/RoomRedisService.java +++ /dev/null @@ -1,113 +0,0 @@ -package com.back.domain.studyroom.service; - -import com.back.global.websocket.service.WebSocketSessionManager; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -import java.util.Map; -import java.util.Set; - -/** - * 방 상태 관리를 위한 Redis 전용 서비스 (곧 사라질 예정인 파일) - * (현재는 일단 유지 시킨 상황, 에러 방지용) - * @deprecated RoomParticipantService를 사용. - * 현재는 WebSocketSessionManager의 Wrapper일 뿐이며, - * RoomParticipantService에 원래 로직이 옮겨졋습니다. - * - * @see com.back.global.websocket.service.RoomParticipantService 실제 사용 서비스 - * @see com.back.global.websocket.service.WebSocketSessionManager WebSocket 세션 관리 - */ -@Deprecated -@Slf4j -@Service -@RequiredArgsConstructor -public class RoomRedisService { - - private final WebSocketSessionManager sessionManager; - - // ==================== 방 입장/퇴장 ==================== - - /** - * 사용자가 방에 입장 (Redis 온라인 상태 업데이트) - * - Redis Set에 userId 추가 - * - 역할(Role)은 DB에서만 관리 - * - * @param userId 사용자 ID - * @param roomId 방 ID - */ - public void enterRoom(Long userId, Long roomId) { - sessionManager.joinRoom(userId, roomId); - log.info("방 입장 완료 (Redis) - 사용자: {}, 방: {}", userId, roomId); - } - - /** - * 사용자가 방에서 퇴장 (Redis 온라인 상태 업데이트) - * - Redis Set에서 userId 제거 - * - DB 멤버십은 유지됨 (재입장 시 역할 유지) - * - * @param userId 사용자 ID - * @param roomId 방 ID - */ - public void exitRoom(Long userId, Long roomId) { - sessionManager.leaveRoom(userId, roomId); - log.info("방 퇴장 완료 (Redis) - 사용자: {}, 방: {}", userId, roomId); - } - - // ==================== 조회 ==================== - - /** - * 방의 현재 온라인 사용자 수 조회 - * - 실시간 참가자 수 (DB currentParticipants와 무관) - * - * @param roomId 방 ID - * @return 온라인 사용자 수 - */ - public long getRoomUserCount(Long roomId) { - return sessionManager.getRoomOnlineUserCount(roomId); - } - - /** - * 방의 온라인 사용자 ID 목록 조회 - * - 멤버 목록 조회 시 이 ID로 DB 조회 - * - DB에 없는 ID = VISITOR - * - * @param roomId 방 ID - * @return 온라인 사용자 ID Set - */ - public Set getRoomUsers(Long roomId) { - return sessionManager.getOnlineUsersInRoom(roomId); - } - - /** - * 사용자가 현재 특정 방에 있는지 확인 - * - * @param userId 사용자 ID - * @param roomId 방 ID - * @return 온라인 여부 - */ - public boolean isUserInRoom(Long userId, Long roomId) { - return sessionManager.isUserInRoom(userId, roomId); - } - - /** - * 사용자의 현재 방 ID 조회 - * - * @param userId 사용자 ID - * @return 방 ID (없으면 null) - */ - public Long getCurrentRoomId(Long userId) { - return sessionManager.getUserCurrentRoomId(userId); - } - - /** - * 여러 방의 온라인 사용자 수 일괄 조회 (N+1 방지) - * - 방 목록 조회 시 사용 - * - * @param roomIds 방 ID 목록 - * @return Map - */ - public Map getBulkRoomOnlineUserCounts(java.util.List roomIds) { - return sessionManager.getBulkRoomOnlineUserCounts(roomIds); - } -} 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 f6d6db64..cf98f83a 100644 --- a/src/main/java/com/back/domain/studyroom/service/RoomService.java +++ b/src/main/java/com/back/domain/studyroom/service/RoomService.java @@ -84,9 +84,9 @@ public Room createRoom(String title, String description, boolean isPrivate, * 방 입장 메서드 * * 입장 검증 과정: - * 1. 방 존재 및 활성 상태 확인 (비관적 락으로 동시성 제어) - * 2. 방 상태가 입장 가능한지 확인 (WAITING, ACTIVE) - * 3. 정원 초과 여부 확인 (Redis 기반) + * 1. 방 존재 확인 (비관적 락으로 동시성 제어) + * 2. 정원 초과 여부 확인 (Redis 기반) + * 3. 방 입장 가능 여부 확인 (활성화 + 입장 가능한 상태) * 4. 비공개 방인 경우 비밀번호 확인 * 5. 이미 참여 중인지 확인 (재입장 처리) @@ -99,30 +99,29 @@ public Room createRoom(String title, String description, boolean isPrivate, @Transactional public RoomMember joinRoom(Long roomId, String password, Long userId) { - // 비관적 락으로 방 조회 - 동시 입장 시 정원 초과 방지 + // 1. 비관적 락으로 방 조회 - 동시 입장 시 정원 초과 방지 Room room = roomRepository.findByIdWithLock(roomId) .orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND)); - if (!room.isActive()) { - throw new CustomException(ErrorCode.ROOM_INACTIVE); - } - - if (room.getStatus() == RoomStatus.TERMINATED) { - throw new CustomException(ErrorCode.ROOM_TERMINATED); - } - - // Redis에서 현재 온라인 사용자 수 조회 + // 2. Redis에서 현재 온라인 사용자 수 조회 long currentOnlineCount = roomParticipantService.getParticipantCount(roomId); - // 정원 확인 (Redis 기반) + // 3. 정원 확인 (Redis 기반) if (currentOnlineCount >= room.getMaxParticipants()) { throw new CustomException(ErrorCode.ROOM_FULL); } + // 4. 방 입장 가능 여부 확인 (활성화 + 입장 가능한 상태) if (!room.canJoin()) { - throw new CustomException(ErrorCode.ROOM_INACTIVE); + if (room.getStatus() == RoomStatus.TERMINATED) { + throw new CustomException(ErrorCode.ROOM_TERMINATED); + } else if (!room.isActive()) { + throw new CustomException(ErrorCode.ROOM_INACTIVE); + } + throw new CustomException(ErrorCode.ROOM_NOT_JOINABLE); } + // 5. 비밀번호 확인 if (room.needsPassword() && !room.getPassword().equals(password)) { throw new CustomException(ErrorCode.ROOM_PASSWORD_INCORRECT); } @@ -225,12 +224,8 @@ public java.util.List toRoomResponseListWithMasking(java.util.List .map(Room::getId) .collect(java.util.stream.Collectors.toList()); - // Redis에서 참가자 수 일괄 조회 - java.util.Map participantCounts = roomIds.stream() - .collect(java.util.stream.Collectors.toMap( - roomId -> roomId, - roomId -> roomParticipantService.getParticipantCount(roomId) - )); + // Redis Pipeline으로 일괄 조회 (N+1 해결) + java.util.Map participantCounts = roomParticipantService.getParticipantCounts(roomIds); return rooms.stream() .map(room -> { @@ -277,7 +272,10 @@ public void updateRoomSettings(Long roomId, String title, String description, throw new CustomException(ErrorCode.NOT_ROOM_MANAGER); } - if (maxParticipants < room.getCurrentParticipants()) { + // Redis에서 현재 온라인 사용자 수 조회 + long currentOnlineCount = roomParticipantService.getParticipantCount(roomId); + + if (maxParticipants < currentOnlineCount) { throw new CustomException(ErrorCode.BAD_REQUEST); } @@ -561,11 +559,8 @@ public java.util.List toRoomResponse .map(Room::getId) .collect(java.util.stream.Collectors.toList()); - java.util.Map participantCounts = roomIds.stream() - .collect(java.util.stream.Collectors.toMap( - roomId -> roomId, - roomId -> roomParticipantService.getParticipantCount(roomId) - )); + // Redis Pipeline으로 일괄 조회 (N+1 해결) + java.util.Map participantCounts = roomParticipantService.getParticipantCounts(roomIds); return rooms.stream() .map(room -> com.back.domain.studyroom.dto.RoomResponse.from( @@ -608,11 +603,8 @@ public java.util.List toMyRoomResp .map(Room::getId) .collect(java.util.stream.Collectors.toList()); - java.util.Map participantCounts = roomIds.stream() - .collect(java.util.stream.Collectors.toMap( - roomId -> roomId, - roomId -> roomParticipantService.getParticipantCount(roomId) - )); + // Redis Pipeline으로 일괄 조회 (N+1 해결) + java.util.Map participantCounts = roomParticipantService.getParticipantCounts(roomIds); return rooms.stream() .map(room -> { diff --git a/src/main/java/com/back/global/websocket/controller/WebSocketMessageController.java b/src/main/java/com/back/global/websocket/controller/WebSocketMessageController.java index 6316fbbb..4922948e 100644 --- a/src/main/java/com/back/global/websocket/controller/WebSocketMessageController.java +++ b/src/main/java/com/back/global/websocket/controller/WebSocketMessageController.java @@ -42,7 +42,16 @@ public void handleHeartbeat(@Payload HeartbeatMessage message, } } - // 방 입장 처리 + /** + * 방 입장 처리 + * + * @deprecated 이 STOMP 엔드포인트는 REST API로 대체되었습니다. + * 대신 POST /api/rooms/{roomId}/join을 사용하세요. + * + * 참고: REST API 호출 시 자동으로 Redis에 입장 처리되며 WebSocket 알림도 전송됩니다. + * 이 엔드포인트는 하위 호환성을 위해 유지되지만 사용을 권장하지 않습니다. + */ + @Deprecated @MessageMapping("/rooms/{roomId}/join") public void handleJoinRoom(@DestinationVariable Long roomId, @Payload HeartbeatMessage message, @@ -64,7 +73,16 @@ public void handleJoinRoom(@DestinationVariable Long roomId, } } - // 방 퇴장 처리 + /** + * 방 퇴장 처리 + * + * @deprecated 이 STOMP 엔드포인트는 REST API로 대체되었습니다. + * 대신 POST /api/rooms/{roomId}/leave를 사용하세요. + * + * 참고: REST API 호출 시 자동으로 Redis에서 퇴장 처리되며 WebSocket 알림도 전송됩니다. + * 이 엔드포인트는 하위 호환성을 위해 유지되지만 사용을 권장하지 않습니다. + */ + @Deprecated @MessageMapping("/rooms/{roomId}/leave") public void handleLeaveRoom(@DestinationVariable Long roomId, @Payload HeartbeatMessage message, diff --git a/src/main/java/com/back/global/websocket/service/RoomParticipantService.java b/src/main/java/com/back/global/websocket/service/RoomParticipantService.java index 89f8bd1c..14804939 100644 --- a/src/main/java/com/back/global/websocket/service/RoomParticipantService.java +++ b/src/main/java/com/back/global/websocket/service/RoomParticipantService.java @@ -97,4 +97,14 @@ public void exitAllRooms(Long userId) { // 에러를 던지지 않고 로그만 남김 (세션 종료는 계속 진행되어야 함) } } + + /** + * 여러 방의 온라인 참가자 수를 일괄 조회 + * N+1 문제 해결을 위한 일괄 조회 메서드 + * @param roomIds 방 ID 목록 + * @return 방 ID → 참가자 수 맵 + */ + public java.util.Map getParticipantCounts(java.util.List roomIds) { + return redisSessionStore.getRoomUserCounts(roomIds); + } } diff --git a/src/main/java/com/back/global/websocket/store/RedisSessionStore.java b/src/main/java/com/back/global/websocket/store/RedisSessionStore.java index 6dc7d93e..b24e8866 100644 --- a/src/main/java/com/back/global/websocket/store/RedisSessionStore.java +++ b/src/main/java/com/back/global/websocket/store/RedisSessionStore.java @@ -190,6 +190,50 @@ public long getTotalOnlineUserCount() { } } + /** + * 여러 방의 사용자 수를 일괄 조회 (Redis Pipeline 사용) + * N+1 문제 해결을 위한 일괄 조회 메서드 + * @param roomIds 조회할 방 ID 목록 + * @return 방 ID → 사용자 수 맵 + */ + public java.util.Map getRoomUserCounts(java.util.List roomIds) { + if (roomIds == null || roomIds.isEmpty()) { + return java.util.Map.of(); + } + + try { + // Pipeline을 사용하여 한 번에 여러 SET 크기 조회 + java.util.List results = redisTemplate.executePipelined( + (org.springframework.data.redis.core.RedisCallback) connection -> { + for (Long roomId : roomIds) { + String roomUsersKey = WebSocketConstants.buildRoomUsersKey(roomId); + connection.setCommands().sCard(roomUsersKey.getBytes()); + } + return null; + } + ); + + // 결과를 Map으로 변환 + java.util.Map resultMap = new java.util.HashMap<>(); + for (int i = 0; i < roomIds.size(); i++) { + Long count = results.get(i) != null ? ((Number) results.get(i)).longValue() : 0L; + resultMap.put(roomIds.get(i), count); + } + + log.debug("방 사용자 수 일괄 조회 완료 - 방 개수: {}", roomIds.size()); + return resultMap; + + } catch (Exception e) { + log.error("방 사용자 수 일괄 조회 실패 - 방 개수: {}", roomIds.size(), e); + // 에러 시 개별 조회로 폴백 + return roomIds.stream() + .collect(java.util.stream.Collectors.toMap( + roomId -> roomId, + this::getRoomUserCount + )); + } + } + private Long convertToLong(Object obj) { if (obj instanceof Long) { return (Long) obj; From 1af4d1e9ca54fcfdd38f740d083248075250db99 Mon Sep 17 00:00:00 2001 From: loseminho Date: Wed, 8 Oct 2025 12:56:53 +0900 Subject: [PATCH 8/8] =?UTF-8?q?fix:=20=EC=97=90=EB=9F=AC=20=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/RoomMemberRepositoryImpl.java | 112 ++++++++++++++++++ .../repository/RoomRepositoryImpl.java | 37 +++--- .../controller/RoomChatApiControllerTest.java | 6 +- 3 files changed, 133 insertions(+), 22 deletions(-) diff --git a/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepositoryImpl.java b/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepositoryImpl.java index 899a347b..898b437c 100644 --- a/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepositoryImpl.java +++ b/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepositoryImpl.java @@ -100,4 +100,116 @@ public List findByRoomIdAndUserIdIn(Long roomId, java.util.Set ) .fetch(); } + + /** + * 사용자가 참여 중인 모든 방의 멤버십 조회 + * DB에 저장된 멤버십만 조회 (MEMBER 이상) + * @param userId 사용자 ID + * @return 멤버십 목록 + */ + @Override + public List findActiveByUserId(Long userId) { + return queryFactory + .selectFrom(roomMember) + .where(roomMember.user.id.eq(userId)) + .fetch(); + } + + /** + * 특정 역할의 멤버 조회 + * - 방장(HOST) 찾기 + * - 부방장(SUB_HOST) 목록 조회 + * - 역할별 멤버 필터링 + * @param roomId 방 ID + * @param role 역할 (HOST, SUB_HOST, MEMBER, VISITOR) + * @return 해당 역할의 멤버 목록 + */ + @Override + public List findByRoomIdAndRole(Long roomId, RoomRole role) { + return queryFactory + .selectFrom(roomMember) + .where( + roomMember.room.id.eq(roomId), + roomMember.role.eq(role) + ) + .fetch(); + } + + /** + * 방장 조회 + * - 방장 권한 확인 + * - 방 소유자 정보 표시 + * @param roomId 방 ID + * @return 방장 멤버십 (Optional) + */ + @Override + public Optional findHostByRoomId(Long roomId) { + RoomMember host = queryFactory + .selectFrom(roomMember) + .where( + roomMember.room.id.eq(roomId), + roomMember.role.eq(RoomRole.HOST) + ) + .fetchOne(); + + return Optional.ofNullable(host); + } + + /** + * 관리자 권한을 가진 멤버들 조회 (HOST, SUB_HOST) + * @param roomId 방 ID + * @return 관리자 멤버 목록 (HOST, SUB_HOST) + */ + @Override + public List findManagersByRoomId(Long roomId) { + return queryFactory + .selectFrom(roomMember) + .where( + roomMember.room.id.eq(roomId), + roomMember.role.in(RoomRole.HOST, RoomRole.SUB_HOST) + ) + .orderBy(roomMember.role.asc()) // HOST가 먼저 + .fetch(); + } + + /** + * 사용자가 특정 방에서 관리자 권한을 가지고 있는지 확인 + * @param roomId 방 ID + * @param userId 사용자 ID + * @return 관리자 권한 여부 + */ + @Override + public boolean isManager(Long roomId, Long userId) { + Long count = queryFactory + .select(roomMember.count()) + .from(roomMember) + .where( + roomMember.room.id.eq(roomId), + roomMember.user.id.eq(userId), + roomMember.role.in(RoomRole.HOST, RoomRole.SUB_HOST) + ) + .fetchOne(); + + return count != null && count > 0; + } + + /** + * 사용자가 이미 해당 방의 멤버인지 확인 + * @param roomId 방 ID + * @param userId 사용자 ID + * @return 멤버 여부 + */ + @Override + public boolean existsByRoomIdAndUserId(Long roomId, Long userId) { + Long count = queryFactory + .select(roomMember.count()) + .from(roomMember) + .where( + roomMember.room.id.eq(roomId), + roomMember.user.id.eq(userId) + ) + .fetchOne(); + + return count != null && count > 0; + } } diff --git a/src/main/java/com/back/domain/studyroom/repository/RoomRepositoryImpl.java b/src/main/java/com/back/domain/studyroom/repository/RoomRepositoryImpl.java index 48714b45..26e76819 100644 --- a/src/main/java/com/back/domain/studyroom/repository/RoomRepositoryImpl.java +++ b/src/main/java/com/back/domain/studyroom/repository/RoomRepositoryImpl.java @@ -38,7 +38,8 @@ public class RoomRepositoryImpl implements RoomRepositoryCustom { * - 비공개가 아닌 방 (isPrivate = false) * - 활성화된 방 (isActive = true) * - 입장 가능한 상태 (WAITING 또는 ACTIVE) - * - 정원이 가득 차지 않은 방 + * + * 참고: 정원 체크는 Redis 기반으로 프론트엔드/서비스에서 수행 * @param pageable 페이징 정보 * @return 페이징된 방 목록 */ @@ -51,8 +52,7 @@ public Page findJoinablePublicRooms(Pageable pageable) { .where( room.isPrivate.eq(false), room.isActive.eq(true), - room.status.in(RoomStatus.WAITING, RoomStatus.ACTIVE), - room.currentParticipants.lt(room.maxParticipants) + room.status.in(RoomStatus.WAITING, RoomStatus.ACTIVE) ) .orderBy(room.createdAt.desc()) .offset(pageable.getOffset()) @@ -66,8 +66,7 @@ public Page findJoinablePublicRooms(Pageable pageable) { .where( room.isPrivate.eq(false), room.isActive.eq(true), - room.status.in(RoomStatus.WAITING, RoomStatus.ACTIVE), - room.currentParticipants.lt(room.maxParticipants) + room.status.in(RoomStatus.WAITING, RoomStatus.ACTIVE) ) .fetchOne(); @@ -159,11 +158,15 @@ public Page findRoomsWithFilters(String title, RoomStatus status, Boolean /** * 인기 방 조회 (참가자 수 기준) + * + * 참고: 참가자 수는 Redis에서 조회하므로 DB에서는 정렬 불가 + * 서비스 레이어에서 Redis 데이터로 정렬 필요 + * * 조회 조건: * - 공개 방만 (isPrivate = false) * - 활성화된 방만 (isActive = true) * @param pageable 페이징 정보 - * @return 페이징된 인기 방 목록 + * @return 페이징된 방 목록 (최신순 정렬) */ @Override public Page findPopularRooms(Pageable pageable) { @@ -174,10 +177,7 @@ public Page findPopularRooms(Pageable pageable) { room.isPrivate.eq(false), room.isActive.eq(true) ) - .orderBy( - room.currentParticipants.desc(), // 참가자 수 많은 순 - room.createdAt.desc() // 최신순 - ) + .orderBy(room.createdAt.desc()) // 최신순 (서비스에서 Redis 기반으로 재정렬) .offset(pageable.getOffset()) .limit(pageable.getPageSize()) .fetch(); @@ -197,20 +197,20 @@ public Page findPopularRooms(Pageable pageable) { /** * 비활성 방 정리 (배치 작업용) + * + * 참고: 참가자 수는 Redis에서 관리하므로 DB에서 직접 확인 불가 + * 이 메서드는 Redis와 연동하여 사용해야 함 + * * 대상: - * - 참가자가 0명인 방 * - ACTIVE 상태인 방 * - cutoffTime 이전에 마지막으로 업데이트된 방 + * * 처리: * - 상태를 TERMINATED로 변경 * - isActive를 false로 변경 - * 사용 예시: - * ``` - * // 1시간 이상 비어있는 방 정리 - * LocalDateTime cutoff = LocalDateTime.now().minusHours(1); - * int count = terminateInactiveRooms(cutoff); - * log.info("정리된 방 개수: {}", count); - * ``` + * + * TODO: Redis에서 참가자 0명인 방 확인 후 호출 + * * @param cutoffTime 기준 시간 (이 시간 이전에 업데이트된 방 정리) * @return 정리된 방 개수 */ @@ -221,7 +221,6 @@ public int terminateInactiveRooms(LocalDateTime cutoffTime) { .set(room.status, RoomStatus.TERMINATED) .set(room.isActive, false) .where( - room.currentParticipants.eq(0), room.status.eq(RoomStatus.ACTIVE), room.updatedAt.lt(cutoffTime) ) diff --git a/src/test/java/com/back/domain/chat/room/controller/RoomChatApiControllerTest.java b/src/test/java/com/back/domain/chat/room/controller/RoomChatApiControllerTest.java index 71a43ee6..edc3e851 100644 --- a/src/test/java/com/back/domain/chat/room/controller/RoomChatApiControllerTest.java +++ b/src/test/java/com/back/domain/chat/room/controller/RoomChatApiControllerTest.java @@ -290,7 +290,7 @@ void t8() throws Exception { .contentType(MediaType.APPLICATION_JSON) .content(requestBody)) .andExpect(status().isForbidden()) - .andExpect(jsonPath("$.code").value("ROOM_013")) + .andExpect(jsonPath("$.code").value("ROOM_014")) .andExpect(jsonPath("$.message").value("채팅 삭제 권한이 없습니다. 방장 또는 부방장만 가능합니다.")); } @@ -364,7 +364,7 @@ void t10() throws Exception { .contentType(MediaType.APPLICATION_JSON) .content(requestBody)) .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value("ROOM_014")) + .andExpect(jsonPath("$.code").value("ROOM_015")) .andExpect(jsonPath("$.message").value("삭제 확인 메시지가 일치하지 않습니다.")); } @@ -434,7 +434,7 @@ void t12() throws Exception { .contentType(MediaType.APPLICATION_JSON) .content(requestBody)) .andExpect(status().isForbidden()) - .andExpect(jsonPath("$.code").value("ROOM_008")) + .andExpect(jsonPath("$.code").value("ROOM_009")) .andExpect(jsonPath("$.message").value("방 멤버가 아닙니다.")); } } \ No newline at end of file