From e5081578ebe308099bd4af521eb7b56a6333f5f5 Mon Sep 17 00:00:00 2001 From: loseminho Date: Thu, 2 Oct 2025 21:02:51 +0900 Subject: [PATCH] =?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", "존재하지 않는 학습 계획입니다."),