From e5081578ebe308099bd4af521eb7b56a6333f5f5 Mon Sep 17 00:00:00 2001 From: loseminho Date: Thu, 2 Oct 2025 21:02:51 +0900 Subject: [PATCH 01/17] =?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 02/17] =?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 03/17] =?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 04/17] =?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 05/17] =?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 06/17] =?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 07/17] =?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 08/17] =?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 From 483a0fcab999c89f35c5c53f050d531d33d7547f Mon Sep 17 00:00:00 2001 From: loseminho Date: Thu, 9 Oct 2025 17:30:24 +0900 Subject: [PATCH 09/17] =?UTF-8?q?feat:=20=EC=8A=A4=ED=84=B0=EB=94=94?= =?UTF-8?q?=EB=A3=B8=20=EB=B0=A9=20=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20=EC=82=AD=EC=A0=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../studyroom/controller/RoomController.java | 53 +++++++++++++++++++ .../dto/UpdateRoomPasswordRequest.java | 19 +++++++ .../domain/studyroom/service/RoomService.java | 52 ++++++++++++++++++ .../com/back/global/exception/ErrorCode.java | 2 + 4 files changed, 126 insertions(+) create mode 100644 src/main/java/com/back/domain/studyroom/dto/UpdateRoomPasswordRequest.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 eeac3049..a0bf6179 100644 --- a/src/main/java/com/back/domain/studyroom/controller/RoomController.java +++ b/src/main/java/com/back/domain/studyroom/controller/RoomController.java @@ -370,6 +370,59 @@ public ResponseEntity> updateRoom( .body(RsData.success("방 설정 변경 완료", null)); } + @PutMapping("/{roomId}/password") + @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> updateRoomPassword( + @Parameter(description = "방 ID", required = true) @PathVariable Long roomId, + @Valid @RequestBody UpdateRoomPasswordRequest request) { + + Long currentUserId = currentUser.getUserId(); + + roomService.updateRoomPassword( + roomId, + request.getCurrentPassword(), + request.getNewPassword(), + currentUserId + ); + + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success("방 비밀번호 변경 완료", null)); + } + + @DeleteMapping("/{roomId}/password") + @Operation( + summary = "방 비밀번호 제거", + description = "방의 비밀번호를 제거합니다. 비밀번호가 제거되면 누구나 자유롭게 입장할 수 있습니다. 방장만 실행 가능합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "비밀번호 제거 성공"), + @ApiResponse(responseCode = "403", description = "방장 권한 없음"), + @ApiResponse(responseCode = "404", description = "존재하지 않는 방"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + public ResponseEntity> removeRoomPassword( + @Parameter(description = "방 ID", required = true) @PathVariable Long roomId) { + + Long currentUserId = currentUser.getUserId(); + + roomService.removeRoomPassword(roomId, currentUserId); + + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success("방 비밀번호 제거 완료", null)); + } + @DeleteMapping("/{roomId}") @Operation( summary = "방 종료", diff --git a/src/main/java/com/back/domain/studyroom/dto/UpdateRoomPasswordRequest.java b/src/main/java/com/back/domain/studyroom/dto/UpdateRoomPasswordRequest.java new file mode 100644 index 00000000..9d1145bb --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/dto/UpdateRoomPasswordRequest.java @@ -0,0 +1,19 @@ +package com.back.domain.studyroom.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class UpdateRoomPasswordRequest { + @NotBlank(message = "현재 비밀번호는 필수입니다") + private String currentPassword; + + @NotBlank(message = "새 비밀번호는 필수입니다") + @Size(min = 4, max = 20, message = "비밀번호는 4~20자여야 합니다") + private String newPassword; +} 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 3466ce9c..8760993c 100644 --- a/src/main/java/com/back/domain/studyroom/service/RoomService.java +++ b/src/main/java/com/back/domain/studyroom/service/RoomService.java @@ -291,6 +291,58 @@ public void updateRoomSettings(Long roomId, String title, String description, log.info("방 설정 변경 완료 - RoomId: {}, UserId: {}", roomId, userId); } + /** + * 방 비밀번호 변경 + * - 방장만 변경 가능 + * - 현재 비밀번호 검증 후 변경 + * @param roomId 방 ID + * @param currentPassword 현재 비밀번호 + * @param newPassword 새 비밀번호 + * @param userId 요청자 ID (방장) + */ + @Transactional + public void updateRoomPassword(Long roomId, String currentPassword, String newPassword, Long userId) { + Room room = roomRepository.findById(roomId) + .orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND)); + + // 방장 권한 확인 + if (!room.isOwner(userId)) { + throw new CustomException(ErrorCode.NOT_ROOM_HOST); + } + + // 현재 비밀번호 검증 + if (!currentPassword.equals(room.getPassword())) { + throw new CustomException(ErrorCode.ROOM_PASSWORD_MISMATCH); + } + + // 새 비밀번호 설정 + room.updatePassword(newPassword); + + log.info("방 비밀번호 변경 완료 - RoomId: {}, UserId: {}", roomId, userId); + } + + /** + * 방 비밀번호 제거 (공개방으로 전환) + * - 방장만 제거 가능 + * @param roomId 방 ID + * @param userId 요청자 ID (방장) + */ + @Transactional + public void removeRoomPassword(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_HOST); + } + + // 비밀번호 제거 + room.updatePassword(null); + + log.info("방 비밀번호 제거 완료 - RoomId: {}, UserId: {}", roomId, userId); + } + @Transactional public void terminateRoom(Long roomId, Long userId) { diff --git a/src/main/java/com/back/global/exception/ErrorCode.java b/src/main/java/com/back/global/exception/ErrorCode.java index b01ca8f6..ffc8330c 100644 --- a/src/main/java/com/back/global/exception/ErrorCode.java +++ b/src/main/java/com/back/global/exception/ErrorCode.java @@ -37,6 +37,8 @@ public enum ErrorCode { CHAT_DELETE_FORBIDDEN(HttpStatus.FORBIDDEN, "ROOM_014", "채팅 삭제 권한이 없습니다. 방장 또는 부방장만 가능합니다."), INVALID_DELETE_CONFIRMATION(HttpStatus.BAD_REQUEST, "ROOM_015", "삭제 확인 메시지가 일치하지 않습니다."), CHAT_DELETE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "ROOM_016", "채팅 삭제 중 오류가 발생했습니다."), + ROOM_PASSWORD_MISMATCH(HttpStatus.BAD_REQUEST, "ROOM_017", "현재 비밀번호가 일치하지 않습니다."), + NOT_ROOM_HOST(HttpStatus.FORBIDDEN, "ROOM_018", "방장 권한이 필요합니다."), // ======================== 스터디 플래너 관련 ======================== PLAN_NOT_FOUND(HttpStatus.NOT_FOUND, "PLAN_001", "존재하지 않는 학습 계획입니다."), From 77ab976dbf5d507adf42ab6e110b1d797de34c28 Mon Sep 17 00:00:00 2001 From: loseminho Date: Thu, 9 Oct 2025 17:59:23 +0900 Subject: [PATCH 10/17] =?UTF-8?q?fix:app-dev=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-dev.yml | 144 ------------------------- 1 file changed, 144 deletions(-) delete mode 100644 src/main/resources/application-dev.yml diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml deleted file mode 100644 index a7fc7cf1..00000000 --- a/src/main/resources/application-dev.yml +++ /dev/null @@ -1,144 +0,0 @@ -spring: - config: - import: optional:file:.env[.properties] - - datasource: - url: jdbc:h2:./db_dev;MODE=MySQL - driver-class-name: org.h2.Driver - username: sa - password: - - data: - redis: - host: localhost - port: 6379 - - jpa: - database-platform: org.hibernate.dialect.H2Dialect - hibernate: - ddl-auto: update # [none | validate | update | create | create-drop] - show-sql: true - properties: - hibernate: - format_sql: true - highlight_sql: true - use_sql_comments: true - defer-datasource-initialization: true - - sql: - init: - mode: always - - security: - oauth2: - client: - registration: - kakao: - client-id: ${KAKAO_CLIENT_ID} - authorization-grant-type: authorization_code - client-name: Kakao - redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" - scope: profile_nickname, profile_image, account_email - naver: - client-id: ${NAVER_CLIENT_ID} - client-secret: ${NAVER_CLIENT_SECRET} - client-name: Naver - authorization-grant-type: authorization_code - redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" - scope: email, nickname, profile_image - google: - client-id: ${GOOGLE_CLIENT_ID} - client-secret: ${GOOGLE_CLIENT_SECRET} - client-name: Google - authorization-grant-type: authorization_code - redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" - scope: - - email - - profile - github: - client-id: ${GITHUB_CLIENT_ID} - client-secret: ${GITHUB_CLIENT_SECRET} - client-name: GitHub - authorization-grant-type: authorization_code - redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" - scope: user:email - provider: - kakao: - authorization-uri: https://kauth.kakao.com/oauth/authorize - token-uri: https://kauth.kakao.com/oauth/token - user-info-uri: https://kapi.kakao.com/v2/user/me - user-name-attribute: id - naver: - authorization-uri: https://nid.naver.com/oauth2.0/authorize - token-uri: https://nid.naver.com/oauth2.0/token - user-info-uri: https://openapi.naver.com/v1/nid/me - user-name-attribute: response - google: - authorization-uri: https://accounts.google.com/o/oauth2/v2/auth - token-uri: https://oauth2.googleapis.com/token - user-info-uri: https://www.googleapis.com/oauth2/v3/userinfo - user-name-attribute: sub - github: - authorization-uri: https://github.com/login/oauth/authorize - token-uri: https://github.com/login/oauth/access_token - user-info-uri: https://api.github.com/user - user-name-attribute: id - servlet: - multipart: - max-file-size: 10MB # 업로드할 수 있는 개별 파일의 최대 크기 - max-request-size: 10MB # 한 요청의 최대 허용 크기 - - mail: - host: smtp.gmail.com - port: 587 - username: ${EMAIL_USERNAME} - password: ${EMAIL_PASSWORD} - properties: - mail: - smtp: - auth: true - starttls: - enable: true - -springdoc: - default-produces-media-type: application/json;charset=UTF-8 - -logging: - level: - org.hibernate.orm.jdbc.bind: trace - org.springframework.web.socket: DEBUG - org.springframework.messaging: DEBUG - -jwt: - secret: ${JWT_SECRET} - access-token-expiration: ${JWT_ACCESS_TOKEN_EXPIRATION:1800} # 30분 (초 단위) - refresh-token-expiration: ${JWT_REFRESH_TOKEN_EXPIRATION:604800} # 7일 (초 단위) - -# 스터디룸 설정 -studyroom: - heartbeat: - timeout-minutes: 5 # Heartbeat 타임아웃 (분) - default: - max-participants: 10 # 기본 최대 참가자 수 - allow-camera: true - allow-audio: true - allow-screen-share: true - -frontend: - base-url: http://localhost:3000 - -# AWS S3 -cloud: - aws: - credentials: - access-key: ${ACCESS_KEY} - secret-key: ${SECRET_KEY} - - region: - static: ${REGION} - - s3: - bucket: ${BUCKET_NAME} - - stack: - auto: false From 7a171673b10b33491de30da0b8dec7e61774921d Mon Sep 17 00:00:00 2001 From: loseminho Date: Fri, 10 Oct 2025 07:01:46 +0900 Subject: [PATCH 11/17] =?UTF-8?q?feat:=20=EC=9B=B9=EC=86=8C=EC=BC=93=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EC=86=8C=EA=B7=B9=EC=A0=81=20=ED=95=98?= =?UTF-8?q?=ED=8A=B8=EB=B9=84=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../websocket/config/WebSocketConfig.java | 33 ++++++++++++++++--- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/back/global/websocket/config/WebSocketConfig.java b/src/main/java/com/back/global/websocket/config/WebSocketConfig.java index ba2ba7f3..2e226c05 100644 --- a/src/main/java/com/back/global/websocket/config/WebSocketConfig.java +++ b/src/main/java/com/back/global/websocket/config/WebSocketConfig.java @@ -7,6 +7,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Configuration; import org.springframework.core.Ordered; +import org.springframework.context.annotation.Bean; import org.springframework.core.annotation.Order; import org.springframework.messaging.Message; import org.springframework.messaging.MessageChannel; @@ -16,6 +17,8 @@ import org.springframework.messaging.simp.stomp.StompHeaderAccessor; import org.springframework.messaging.support.ChannelInterceptor; import org.springframework.messaging.support.MessageHeaderAccessor; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; import org.springframework.security.core.Authentication; import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; import org.springframework.web.socket.config.annotation.StompEndpointRegistry; @@ -36,14 +39,36 @@ public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { * - /topic: 1:N 브로드캐스트 (방 채팅) * - /queue: 1:1 메시지 (개인 DM) * - /app: 클라이언트에서 서버로 메시지 전송 시 prefix + * + * STOMP 하트비트 설정: + * - 25초마다 자동 하트비트 전송 (쓰기 비활성 시) + * - 25초 이상 응답 없으면 연결 종료 (읽기 비활성 시) */ @Override public void configureMessageBroker(MessageBrokerRegistry config) { - config.enableSimpleBroker("/topic", "/queue"); + config.enableSimpleBroker("/topic", "/queue") + .setHeartbeatValue(new long[]{25000, 25000}) // [서버→클라이언트, 클라이언트→서버] + .setTaskScheduler(heartBeatScheduler()); + config.setApplicationDestinationPrefixes("/app"); config.setUserDestinationPrefix("/user"); } + /** + * STOMP 하트비트 전용 스케줄러!! + * - 별도 스레드 풀로 하트비트 처리 + * - 메인 비즈니스 로직에 영향 없음 + */ + @Bean + public TaskScheduler heartBeatScheduler() { + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + scheduler.setPoolSize(1); + scheduler.setThreadNamePrefix("wss-heartbeat-"); + scheduler.initialize(); + log.info("STOMP 하트비트 스케줄러 초기화 완료 - 주기: 25초"); + return scheduler; + } + /** * STOMP 엔드포인트 등록 * 클라이언트가 WebSocket 연결을 위해 사용할 엔드포인트 @@ -124,24 +149,22 @@ private void authenticateUser(StompHeaderAccessor accessor) { } /** - * 메시지 전송 시 인증 상태 확인 및 활동 시간 업데이트 + * 메시지 전송 시 인증 상태 확인 */ private void validateAuthenticationAndUpdateActivity(StompHeaderAccessor accessor) { if (accessor.getUser() == null) { throw new RuntimeException("인증이 필요합니다"); } - // 인증된 사용자 정보 추출 및 활동 시간 업데이트 Authentication auth = (Authentication) accessor.getUser(); if (auth.getPrincipal() instanceof CustomUserDetails userDetails) { Long userId = userDetails.getUserId(); - // 사용자 활동 시간 업데이트 (Heartbeat 효과) + // 전역 세션 활동 시간 업데이트 try { sessionManager.updateLastActivity(userId); } catch (Exception e) { log.warn("활동 시간 업데이트 실패 - 사용자: {}, 오류: {}", userId, e.getMessage()); - // 활동 시간 업데이트 실패해도 메시지 전송은 계속 진행 } log.debug("인증된 사용자 메시지 전송 - 사용자: {} (ID: {}), 목적지: {}", From cf322300a6f616059eb8b8f7cd673249c9afcf67 Mon Sep 17 00:00:00 2001 From: loseminho Date: Fri, 10 Oct 2025 13:40:28 +0900 Subject: [PATCH 12/17] =?UTF-8?q?feat:=20=EC=8A=A4=ED=84=B0=EB=94=94?= =?UTF-8?q?=EB=A3=B8=20=EC=8D=B8=EB=84=A4=EC=9D=BC=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20webrtc=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=EC=97=90=EC=84=9C=20=EC=A3=BC=EC=84=9D?= =?UTF-8?q?=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../studyroom/controller/RoomController.java | 9 ++-- .../studyroom/dto/CreateRoomRequest.java | 4 ++ .../domain/studyroom/dto/RoomResponse.java | 3 ++ .../dto/UpdateRoomSettingsRequest.java | 14 ++++-- .../back/domain/studyroom/entity/Room.java | 45 ++++++++++++++++--- .../domain/studyroom/service/RoomService.java | 17 ++++--- ...tudyRoomNotificationEventListenerTest.java | 3 +- .../controller/StudyRecordControllerTest.java | 3 +- .../controller/RoomControllerTest.java | 38 +++++++++------- .../RoomCreateIntegrationTest.java | 6 ++- .../studyroom/service/RoomServiceTest.java | 36 +++++++++------ 11 files changed, 123 insertions(+), 55 deletions(-) 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 a0bf6179..39b30bde 100644 --- a/src/main/java/com/back/domain/studyroom/controller/RoomController.java +++ b/src/main/java/com/back/domain/studyroom/controller/RoomController.java @@ -64,7 +64,8 @@ public ResponseEntity> createRoom( request.getPassword(), request.getMaxParticipants() != null ? request.getMaxParticipants() : 10, currentUserId, - request.getUseWebRTC() != null ? request.getUseWebRTC() : true // 디폴트: true + request.getUseWebRTC() != null ? request.getUseWebRTC() : true, // 디폴트: true + request.getThumbnailUrl() // 썸네일 URL ); RoomResponse response = roomService.toRoomResponse(room); @@ -339,7 +340,7 @@ public ResponseEntity>> getMyRooms() { @PutMapping("/{roomId}") @Operation( summary = "방 설정 수정", - description = "방의 제목, 설명, 정원, RTC 설정 등을 수정합니다. 방장만 수정 가능합니다." + description = "방의 제목, 설명, 정원, 썸네일을 수정합니다. 방장만 수정 가능합니다. WebRTC 설정은 현재 수정 불가합니다." ) @ApiResponses({ @ApiResponse(responseCode = "200", description = "수정 성공"), @@ -359,9 +360,7 @@ public ResponseEntity> updateRoom( request.getTitle(), request.getDescription(), request.getMaxParticipants(), - request.getAllowCamera() != null ? request.getAllowCamera() : true, - request.getAllowAudio() != null ? request.getAllowAudio() : true, - request.getAllowScreenShare() != null ? request.getAllowScreenShare() : true, + request.getThumbnailUrl(), currentUserId ); diff --git a/src/main/java/com/back/domain/studyroom/dto/CreateRoomRequest.java b/src/main/java/com/back/domain/studyroom/dto/CreateRoomRequest.java index 0dd78295..8c1adaeb 100644 --- a/src/main/java/com/back/domain/studyroom/dto/CreateRoomRequest.java +++ b/src/main/java/com/back/domain/studyroom/dto/CreateRoomRequest.java @@ -16,6 +16,10 @@ public class CreateRoomRequest { @Size(max = 500, message = "방 설명은 500자를 초과할 수 없습니다") private String description; + // 방 썸네일 이미지 URL (선택) + @Size(max = 500, message = "썸네일 URL은 500자를 초과할 수 없습니다") + private String thumbnailUrl; + private Boolean isPrivate = false; private String password; 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 e35968aa..96f51c31 100644 --- a/src/main/java/com/back/domain/studyroom/dto/RoomResponse.java +++ b/src/main/java/com/back/domain/studyroom/dto/RoomResponse.java @@ -14,6 +14,7 @@ public class RoomResponse { private String title; private String description; private Boolean isPrivate; // 비공개 방 여부 (UI에서 🔒 아이콘 표시용) + private String thumbnailUrl; // 썸네일 이미지 URL private int currentParticipants; private int maxParticipants; private RoomStatus status; @@ -31,6 +32,7 @@ public static RoomResponse from(Room room, long currentParticipants) { .title(room.getTitle()) .description(room.getDescription() != null ? room.getDescription() : "") .isPrivate(room.isPrivate()) // 비공개 방 여부 + .thumbnailUrl(room.getThumbnailUrl()) // 썸네일 URL .currentParticipants((int) currentParticipants) // Redis에서 조회한 실시간 값 .maxParticipants(room.getMaxParticipants()) .status(room.getStatus()) @@ -52,6 +54,7 @@ public static RoomResponse fromMasked(Room room) { .title("🔒 비공개 방") // 제목 마스킹 .description("비공개 방입니다") // 설명 마스킹 .isPrivate(true) + .thumbnailUrl(null) // 썸네일 숨김 .currentParticipants(0) // 참가자 수 숨김 .maxParticipants(0) // 정원 숨김 .status(room.getStatus()) diff --git a/src/main/java/com/back/domain/studyroom/dto/UpdateRoomSettingsRequest.java b/src/main/java/com/back/domain/studyroom/dto/UpdateRoomSettingsRequest.java index d3cc8b95..974ce652 100644 --- a/src/main/java/com/back/domain/studyroom/dto/UpdateRoomSettingsRequest.java +++ b/src/main/java/com/back/domain/studyroom/dto/UpdateRoomSettingsRequest.java @@ -20,7 +20,15 @@ public class UpdateRoomSettingsRequest { @Max(value = 100, message = "최대 100명까지 가능합니다") private Integer maxParticipants; - private Boolean allowCamera = true; - private Boolean allowAudio = true; - private Boolean allowScreenShare = true; + // 방 썸네일 이미지 URL (선택) + @Size(max = 500, message = "썸네일 URL은 500자를 초과할 수 없습니다") + private String thumbnailUrl; + + // ===== WebRTC 설정 (추후 팀원 구현 시 주석 해제) ===== + // WebRTC 기능은 방 생성 이후 별도 API로 관리 예정 + // 현재는 방 생성 시의 useWebRTC 값으로만 초기 설정됨 + + // private Boolean allowCamera = true; + // private Boolean allowAudio = true; + // private Boolean allowScreenShare = true; } 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 21880398..474932c5 100644 --- a/src/main/java/com/back/domain/studyroom/entity/Room.java +++ b/src/main/java/com/back/domain/studyroom/entity/Room.java @@ -25,6 +25,31 @@ public class Room extends BaseEntity { private String password; private int maxParticipants; private boolean isActive; + + // 방 썸네일 이미지 URL + private String thumbnailUrl; + + // 디폴트 썸네일 URL + private static final String DEFAULT_THUMBNAIL_URL = "/images/default-room-thumbnail.png"; + + /** + * 썸네일 URL 조회 (디폴트 처리 포함) + * null인 경우 디폴트 이미지 반환 + */ + public String getThumbnailUrl() { + return (thumbnailUrl != null && !thumbnailUrl.trim().isEmpty()) + ? thumbnailUrl + : DEFAULT_THUMBNAIL_URL; + } + + /** + * 원본 썸네일 URL 조회 (디폴트 처리 없음) + * DB에 실제 저장된 값 그대로 반환 + */ + public String getRawThumbnailUrl() { + return thumbnailUrl; + } + private boolean allowCamera; private boolean allowAudio; private boolean allowScreenShare; @@ -153,7 +178,7 @@ public boolean isOwner(Long userId) { */ public static Room create(String title, String description, boolean isPrivate, String password, int maxParticipants, User creator, RoomTheme theme, - boolean useWebRTC) { + boolean useWebRTC, String thumbnailUrl) { Room room = new Room(); room.title = title; room.description = description; @@ -161,6 +186,7 @@ public static Room create(String title, String description, boolean isPrivate, room.password = password; room.maxParticipants = maxParticipants; room.isActive = true; // 생성 시 기본적으로 활성화 + room.thumbnailUrl = thumbnailUrl; // 썸네일 URL room.allowCamera = useWebRTC; // WebRTC 사용 여부에 따라 설정 room.allowAudio = useWebRTC; // WebRTC 사용 여부에 따라 설정 room.allowScreenShare = useWebRTC; // WebRTC 사용 여부에 따라 설정 @@ -179,15 +205,22 @@ public static Room create(String title, String description, boolean isPrivate, } /** - * 방 설정 일괄 업데이트 메서드 - 방장이 방 설정을 변경할 때 여러 필드를 한 번에 업데이트 - 주된 생성 이유.. rtc 단체 제어를 위해 잡아놓았음. 잡아준 필드 변경 가능성 농후!! + * 방 설정 일괄 업데이트 메서드 (썸네일 포함) + * 방장이 방 설정을 변경할 때 여러 필드를 한 번에 업데이트 + * WebRTC 설정은 제외 (확장성을 위해 제거가 아닌 주석으로 현재 놔둠..) */ - public void updateSettings(String title, String description, int maxParticipants, - boolean allowCamera, boolean allowAudio, boolean allowScreenShare) { + public void updateSettings(String title, String description, int maxParticipants, String thumbnailUrl) { this.title = title; this.description = description; this.maxParticipants = maxParticipants; + this.thumbnailUrl = thumbnailUrl; + } + + /** + * WebRTC 설정 업데이트 메서드 (추후 사용 가능!) + * 현재는 미사용 - 추후 팀원이 만약 구현 시 활성화 되도록 + */ + public void updateWebRTCSettings(boolean allowCamera, boolean allowAudio, boolean allowScreenShare) { this.allowCamera = allowCamera; this.allowAudio = allowAudio; this.allowScreenShare = allowScreenShare; 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 8760993c..8aee49b0 100644 --- a/src/main/java/com/back/domain/studyroom/service/RoomService.java +++ b/src/main/java/com/back/domain/studyroom/service/RoomService.java @@ -67,12 +67,12 @@ public class RoomService { */ @Transactional public Room createRoom(String title, String description, boolean isPrivate, - String password, int maxParticipants, Long creatorId, boolean useWebRTC) { + String password, int maxParticipants, Long creatorId, boolean useWebRTC, String thumbnailUrl) { User creator = userRepository.findById(creatorId) .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); - Room room = Room.create(title, description, isPrivate, password, maxParticipants, creator, null, useWebRTC); + Room room = Room.create(title, description, isPrivate, password, maxParticipants, creator, null, useWebRTC, thumbnailUrl); Room savedRoom = roomRepository.save(room); RoomMember hostMember = RoomMember.createHost(savedRoom, creator); @@ -80,8 +80,8 @@ public Room createRoom(String title, String description, boolean isPrivate, // savedRoom.incrementParticipant(); // Redis로 이관 - DB 업데이트 제거 - log.info("방 생성 완료 - RoomId: {}, Title: {}, CreatorId: {}, WebRTC: {}", - savedRoom.getId(), title, creatorId, useWebRTC); + log.info("방 생성 완료 - RoomId: {}, Title: {}, CreatorId: {}, WebRTC: {}, Thumbnail: {}", + savedRoom.getId(), title, creatorId, useWebRTC, thumbnailUrl != null ? "설정됨" : "없음"); return savedRoom; } @@ -268,8 +268,7 @@ public List getUserRooms(Long userId) { @Transactional public void updateRoomSettings(Long roomId, String title, String description, - int maxParticipants, boolean allowCamera, - boolean allowAudio, boolean allowScreenShare, Long userId) { + int maxParticipants, String thumbnailUrl, Long userId) { Room room = roomRepository.findById(roomId) .orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND)); @@ -285,10 +284,10 @@ public void updateRoomSettings(Long roomId, String title, String description, throw new CustomException(ErrorCode.BAD_REQUEST); } - room.updateSettings(title, description, maxParticipants, - allowCamera, allowAudio, allowScreenShare); + room.updateSettings(title, description, maxParticipants, thumbnailUrl); - log.info("방 설정 변경 완료 - RoomId: {}, UserId: {}", roomId, userId); + log.info("방 설정 변경 완료 - RoomId: {}, UserId: {}, Thumbnail: {}", + roomId, userId, thumbnailUrl != null ? "변경됨" : "없음"); } /** diff --git a/src/test/java/com/back/domain/notification/event/studyroom/StudyRoomNotificationEventListenerTest.java b/src/test/java/com/back/domain/notification/event/studyroom/StudyRoomNotificationEventListenerTest.java index 69a74f5b..75f36eeb 100644 --- a/src/test/java/com/back/domain/notification/event/studyroom/StudyRoomNotificationEventListenerTest.java +++ b/src/test/java/com/back/domain/notification/event/studyroom/StudyRoomNotificationEventListenerTest.java @@ -66,7 +66,8 @@ void setUp() { 10, actor, null, - true + true, // useWebRTC + null // thumbnailUrl ); } diff --git a/src/test/java/com/back/domain/study/record/controller/StudyRecordControllerTest.java b/src/test/java/com/back/domain/study/record/controller/StudyRecordControllerTest.java index bbdf08ce..82f5ecf4 100644 --- a/src/test/java/com/back/domain/study/record/controller/StudyRecordControllerTest.java +++ b/src/test/java/com/back/domain/study/record/controller/StudyRecordControllerTest.java @@ -133,7 +133,8 @@ private Room createRoom(User owner) { 20, // 최대 20명 owner, null, // 테마 없음 - true // WebRTC 활성화 + true, // WebRTC 활성화 + null // thumbnailUrl ); testRoom = roomRepository.save(testRoom); diff --git a/src/test/java/com/back/domain/studyroom/controller/RoomControllerTest.java b/src/test/java/com/back/domain/studyroom/controller/RoomControllerTest.java index ad9c2d25..326beb13 100644 --- a/src/test/java/com/back/domain/studyroom/controller/RoomControllerTest.java +++ b/src/test/java/com/back/domain/studyroom/controller/RoomControllerTest.java @@ -75,7 +75,8 @@ void setUp() { 10, testUser, null, - true // useWebRTC + true, // useWebRTC + null // thumbnailUrl ); // 테스트 멤버 생성 @@ -93,6 +94,7 @@ void createRoom() { CreateRoomRequest request = new CreateRoomRequest( "테스트 방", "테스트 설명", + null, // thumbnailUrl false, null, 10, @@ -106,7 +108,8 @@ void createRoom() { any(), anyInt(), eq(1L), - anyBoolean() // useWebRTC 파라미터 추가 + anyBoolean(), // useWebRTC + any() // thumbnailUrl )).willReturn(testRoom); RoomResponse roomResponse = RoomResponse.from(testRoom, 1); @@ -129,7 +132,8 @@ void createRoom() { any(), anyInt(), eq(1L), - anyBoolean() // useWebRTC 파라미터 추가 + anyBoolean(), // useWebRTC + any() // thumbnailUrl ); verify(roomService, times(1)).toRoomResponse(any(Room.class)); } @@ -278,9 +282,7 @@ void updateRoom() { "변경된 제목", "변경된 설명", 15, - true, - true, - false + "https://example.com/new-thumbnail.jpg" // thumbnailUrl ); // when @@ -297,9 +299,7 @@ void updateRoom() { anyString(), anyString(), anyInt(), - anyBoolean(), - anyBoolean(), - anyBoolean(), + anyString(), // thumbnailUrl eq(1L) ); } @@ -381,6 +381,7 @@ void createRoom_WithWebRTC() { CreateRoomRequest request = new CreateRoomRequest( "WebRTC 방", "화상 채팅 가능", + "https://example.com/webrtc.jpg", // thumbnailUrl false, null, 10, @@ -395,7 +396,8 @@ void createRoom_WithWebRTC() { 10, testUser, null, - true + true, // useWebRTC + "https://example.com/webrtc.jpg" // thumbnailUrl ); given(roomService.createRoom( @@ -405,7 +407,8 @@ void createRoom_WithWebRTC() { any(), anyInt(), eq(1L), - eq(true) // WebRTC true 검증 + eq(true), // WebRTC true 검증 + anyString() // thumbnailUrl )).willReturn(webRTCRoom); RoomResponse roomResponse = RoomResponse.from(webRTCRoom, 1); @@ -428,7 +431,8 @@ void createRoom_WithWebRTC() { any(), anyInt(), eq(1L), - eq(true) + eq(true), // WebRTC + anyString() // thumbnailUrl ); } @@ -441,6 +445,7 @@ void createRoom_WithoutWebRTC() { CreateRoomRequest request = new CreateRoomRequest( "채팅 전용 방", "텍스트만 가능", + null, // thumbnailUrl 없음 false, null, 50, @@ -455,7 +460,8 @@ void createRoom_WithoutWebRTC() { 50, testUser, null, - false + false, // useWebRTC + null // thumbnailUrl ); given(roomService.createRoom( @@ -465,7 +471,8 @@ void createRoom_WithoutWebRTC() { any(), anyInt(), eq(1L), - eq(false) // WebRTC false 검증 + eq(false), // WebRTC false 검증 + any() // thumbnailUrl )).willReturn(chatOnlyRoom); RoomResponse roomResponse = RoomResponse.from(chatOnlyRoom, 1); @@ -488,7 +495,8 @@ void createRoom_WithoutWebRTC() { any(), anyInt(), eq(1L), - eq(false) + eq(false), // WebRTC + any() // thumbnailUrl ); } } diff --git a/src/test/java/com/back/domain/studyroom/integration/RoomCreateIntegrationTest.java b/src/test/java/com/back/domain/studyroom/integration/RoomCreateIntegrationTest.java index 26f69541..fd32427b 100644 --- a/src/test/java/com/back/domain/studyroom/integration/RoomCreateIntegrationTest.java +++ b/src/test/java/com/back/domain/studyroom/integration/RoomCreateIntegrationTest.java @@ -89,7 +89,8 @@ void createRoom_RealSave() { password, maxParticipants, testUser.getId(), - useWebRTC + useWebRTC, + null // thumbnailUrl ); // then @@ -138,7 +139,8 @@ void createRoom_CheckCollections() { null, 10, testUser.getId(), - true + true, // useWebRTC + null // thumbnailUrl ); // then - 컬렉션 필드들이 null이 아니어야 함 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 b376bca0..6e81d16f 100644 --- a/src/test/java/com/back/domain/studyroom/service/RoomServiceTest.java +++ b/src/test/java/com/back/domain/studyroom/service/RoomServiceTest.java @@ -92,7 +92,8 @@ void setUp() { 10, testUser, null, - true // useWebRTC + true, // useWebRTC + null // thumbnailUrl ); // 테스트 멤버 생성 @@ -115,7 +116,8 @@ void createRoom_Success() { null, 10, 1L, - true // useWebRTC + true, // useWebRTC + null // thumbnailUrl ); // then @@ -140,7 +142,8 @@ void createRoom_UserNotFound() { null, 10, 999L, - true // useWebRTC + true, // useWebRTC + null // thumbnailUrl )) .isInstanceOf(CustomException.class) .hasFieldOrPropertyWithValue("errorCode", ErrorCode.USER_NOT_FOUND); @@ -189,7 +192,8 @@ void joinRoom_WrongPassword() { 10, testUser, null, - true // useWebRTC + true, // useWebRTC + null // thumbnailUrl ); given(roomRepository.findByIdWithLock(1L)).willReturn(Optional.of(privateRoom)); given(roomParticipantService.getParticipantCount(1L)).willReturn(0L); // Redis 카운트 @@ -260,7 +264,8 @@ void getRoomDetail_PrivateRoomForbidden() { 10, testUser, null, - true // useWebRTC + true, // useWebRTC + null // thumbnailUrl ); given(roomRepository.findById(1L)).willReturn(Optional.of(privateRoom)); given(roomMemberRepository.existsByRoomIdAndUserId(1L, 2L)).willReturn(false); @@ -276,6 +281,7 @@ void getRoomDetail_PrivateRoomForbidden() { void updateRoomSettings_Success() { // given given(roomRepository.findById(1L)).willReturn(Optional.of(testRoom)); + given(roomParticipantService.getParticipantCount(1L)).willReturn(0L); // when roomService.updateRoomSettings( @@ -283,9 +289,7 @@ void updateRoomSettings_Success() { "변경된 제목", "변경된 설명", 15, - true, - true, - false, + "https://example.com/new-thumbnail.jpg", // thumbnailUrl 1L ); @@ -293,6 +297,7 @@ void updateRoomSettings_Success() { assertThat(testRoom.getTitle()).isEqualTo("변경된 제목"); assertThat(testRoom.getDescription()).isEqualTo("변경된 설명"); assertThat(testRoom.getMaxParticipants()).isEqualTo(15); + assertThat(testRoom.getThumbnailUrl()).isEqualTo("https://example.com/new-thumbnail.jpg"); } @Test @@ -307,9 +312,7 @@ void updateRoomSettings_NotOwner() { "변경된 제목", "변경된 설명", 15, - true, - true, - false, + null, // thumbnailUrl 999L // 다른 사용자 )) .isInstanceOf(CustomException.class) @@ -421,7 +424,8 @@ void createRoom_WithWebRTC() { null, 10, 1L, - true // WebRTC 사용 + true, // WebRTC 사용 + "https://example.com/webrtc-room.jpg" // thumbnailUrl ); // then @@ -429,6 +433,7 @@ void createRoom_WithWebRTC() { assertThat(createdRoom.isAllowCamera()).isTrue(); assertThat(createdRoom.isAllowAudio()).isTrue(); assertThat(createdRoom.isAllowScreenShare()).isTrue(); + assertThat(createdRoom.getThumbnailUrl()).isEqualTo("https://example.com/webrtc-room.jpg"); } @Test @@ -447,7 +452,8 @@ void createRoom_WithoutWebRTC() { null, 50, // WebRTC 없으면 더 많은 인원 가능 1L, - false // WebRTC 미사용 + false, // WebRTC 미사용 + null // thumbnailUrl 없음 ); // then @@ -455,5 +461,9 @@ void createRoom_WithoutWebRTC() { assertThat(createdRoom.isAllowCamera()).isFalse(); assertThat(createdRoom.isAllowAudio()).isFalse(); assertThat(createdRoom.isAllowScreenShare()).isFalse(); + // thumbnailUrl이 null이면 디폴트 이미지가 반환됨 + assertThat(createdRoom.getThumbnailUrl()).isEqualTo("/images/default-room-thumbnail.png"); + // 원본 값(DB 저장값)은 null이어야 함 + assertThat(createdRoom.getRawThumbnailUrl()).isNull(); } } From da453f61afee69482f948b8a744db060bb790f17 Mon Sep 17 00:00:00 2001 From: loseminho Date: Fri, 10 Oct 2025 13:52:56 +0900 Subject: [PATCH 13/17] =?UTF-8?q?fix:=EC=86=8C=EA=B7=B9=EC=A0=81=20?= =?UTF-8?q?=ED=95=98=ED=8A=B8=EB=B9=84=ED=8A=B8=20=EC=82=AC=EC=9A=A9=20?= =?UTF-8?q?=EC=A3=BC=EC=84=9D=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/websocket/config/WebSocketConfig.java | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/back/global/websocket/config/WebSocketConfig.java b/src/main/java/com/back/global/websocket/config/WebSocketConfig.java index 2e226c05..81e0da2d 100644 --- a/src/main/java/com/back/global/websocket/config/WebSocketConfig.java +++ b/src/main/java/com/back/global/websocket/config/WebSocketConfig.java @@ -40,25 +40,25 @@ public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { * - /queue: 1:1 메시지 (개인 DM) * - /app: 클라이언트에서 서버로 메시지 전송 시 prefix * - * STOMP 하트비트 설정: + * STOMP 하트비트 설정(임시 주석 상태): * - 25초마다 자동 하트비트 전송 (쓰기 비활성 시) * - 25초 이상 응답 없으면 연결 종료 (읽기 비활성 시) */ @Override public void configureMessageBroker(MessageBrokerRegistry config) { - config.enableSimpleBroker("/topic", "/queue") - .setHeartbeatValue(new long[]{25000, 25000}) // [서버→클라이언트, 클라이언트→서버] - .setTaskScheduler(heartBeatScheduler()); + config.enableSimpleBroker("/topic", "/queue"); + //.setHeartbeatValue(new long[]{25000, 25000}) // [서버→클라이언트, 클라이언트→서버] + //.setTaskScheduler(heartBeatScheduler()); config.setApplicationDestinationPrefixes("/app"); config.setUserDestinationPrefix("/user"); } - /** + /**(임시 주석 상태) * STOMP 하트비트 전용 스케줄러!! * - 별도 스레드 풀로 하트비트 처리 * - 메인 비즈니스 로직에 영향 없음 - */ + @Bean public TaskScheduler heartBeatScheduler() { ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); @@ -68,6 +68,7 @@ public TaskScheduler heartBeatScheduler() { log.info("STOMP 하트비트 스케줄러 초기화 완료 - 주기: 25초"); return scheduler; } + */ /** * STOMP 엔드포인트 등록 From 2de5447b621b796c5e8593c20487b150116300a6 Mon Sep 17 00:00:00 2001 From: loseminho Date: Sat, 11 Oct 2025 19:00:52 +0900 Subject: [PATCH 14/17] =?UTF-8?q?Feat:=20=EC=8A=A4=ED=84=B0=EB=94=94=20?= =?UTF-8?q?=EB=A3=B8=20=EB=82=B4=EC=97=90=20=EA=B3=A0=EC=96=91=EC=9D=B4=20?= =?UTF-8?q?=EC=95=84=EB=B0=94=ED=83=80=20=EC=8B=9C=EC=8A=A4=ED=85=9C?= =?UTF-8?q?=EA=B3=BC=20=ED=94=84=EB=A1=9C=ED=95=84=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20url=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/RoomAvatarController.java | 87 ++++++++ .../studyroom/controller/RoomController.java | 7 +- .../domain/studyroom/dto/AvatarResponse.java | 30 +++ .../studyroom/dto/RoomMemberResponse.java | 27 ++- .../studyroom/dto/UpdateAvatarRequest.java | 20 ++ .../back/domain/studyroom/entity/Avatar.java | 36 ++++ .../studyroom/entity/RoomMemberAvatar.java | 50 +++++ .../repository/AvatarRepository.java | 26 +++ .../RoomMemberAvatarRepository.java | 30 +++ .../studyroom/service/AvatarService.java | 191 ++++++++++++++++++ .../domain/studyroom/service/RoomService.java | 66 +++++- .../com/back/global/exception/ErrorCode.java | 3 + .../service/RoomParticipantService.java | 104 +++++++++- .../websocket/store/RedisSessionStore.java | 46 +++++ src/main/resources/data.sql | 9 + 15 files changed, 716 insertions(+), 16 deletions(-) create mode 100644 src/main/java/com/back/domain/studyroom/controller/RoomAvatarController.java create mode 100644 src/main/java/com/back/domain/studyroom/dto/AvatarResponse.java create mode 100644 src/main/java/com/back/domain/studyroom/dto/UpdateAvatarRequest.java create mode 100644 src/main/java/com/back/domain/studyroom/entity/Avatar.java create mode 100644 src/main/java/com/back/domain/studyroom/entity/RoomMemberAvatar.java create mode 100644 src/main/java/com/back/domain/studyroom/repository/AvatarRepository.java create mode 100644 src/main/java/com/back/domain/studyroom/repository/RoomMemberAvatarRepository.java create mode 100644 src/main/java/com/back/domain/studyroom/service/AvatarService.java diff --git a/src/main/java/com/back/domain/studyroom/controller/RoomAvatarController.java b/src/main/java/com/back/domain/studyroom/controller/RoomAvatarController.java new file mode 100644 index 00000000..a0fa9f56 --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/controller/RoomAvatarController.java @@ -0,0 +1,87 @@ +package com.back.domain.studyroom.controller; + +import com.back.domain.studyroom.dto.AvatarResponse; +import com.back.domain.studyroom.dto.UpdateAvatarRequest; +import com.back.domain.studyroom.service.AvatarService; +import com.back.global.common.dto.RsData; +import com.back.global.security.user.CurrentUser; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 방 아바타 관리 API 컨트롤러 + * - JWT 인증 필수 + * - MEMBER 등급 이상만 아바타 변경 가능 + */ +@RestController +@RequestMapping("/api/rooms/{roomId}/avatars") +@RequiredArgsConstructor +@Tag(name = "Room Avatar API", description = "방 아바타 관련 API") +@SecurityRequirement(name = "Bearer Authentication") +public class RoomAvatarController { + + private final AvatarService avatarService; + private final CurrentUser currentUser; + + /** + * 사용 가능한 아바타 목록 조회 + */ + @GetMapping + @Operation( + summary = "아바타 목록 조회", + description = "선택 가능한 아바타 목록을 조회합니다. 고양이, 강아지 등 다양한 아바타를 제공합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + public ResponseEntity>> getAvatars( + @Parameter(description = "방 ID", required = true) @PathVariable Long roomId) { + + List avatars = avatarService.getAvailableAvatars(); + + return ResponseEntity.ok( + RsData.success("아바타 목록 조회 완료", avatars) + ); + } + + /** + * 내 아바타 변경 (모든 사용자 가능) + */ + @PutMapping("/me") + @Operation( + summary = "아바타 변경", + description = "방에서 사용할 아바타를 변경합니다.\n\n" + + "- VISITOR: Redis에만 저장 (퇴장 시 삭제, 재입장 시 랜덤 배정)\n" + + "- MEMBER 이상: DB에 저장 (재입장 시에도 유지)" + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "변경 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 아바타 ID"), + @ApiResponse(responseCode = "404", description = "존재하지 않는 방 또는 아바타"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + public ResponseEntity> updateMyAvatar( + @Parameter(description = "방 ID", required = true) @PathVariable Long roomId, + @Valid @RequestBody UpdateAvatarRequest request) { + + Long userId = currentUser.getUserId(); + + avatarService.updateRoomAvatar(roomId, userId, request.getAvatarId()); + + return ResponseEntity.ok( + RsData.success("아바타가 변경되었습니다", null) + ); + } +} 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 39b30bde..99389c7f 100644 --- a/src/main/java/com/back/domain/studyroom/controller/RoomController.java +++ b/src/main/java/com/back/domain/studyroom/controller/RoomController.java @@ -496,7 +496,7 @@ public ResponseEntity> activateRoom( @GetMapping("/{roomId}/members") @Operation( summary = "방 멤버 목록 조회", - description = "방의 현재 온라인 멤버 목록을 조회합니다. 역할별로 정렬됩니다(방장>부방장>멤버>방문객)." + description = "방의 현재 온라인 멤버 목록을 조회합니다. 프로필 이미지와 아바타 정보를 포함. 역할별로 정렬됩니다(방장>부방장>멤버>방문객)." ) @ApiResponses({ @ApiResponse(responseCode = "200", description = "조회 성공"), @@ -511,9 +511,8 @@ public ResponseEntity>> getRoomMembers( List members = roomService.getRoomMembers(roomId, currentUserId); - List memberList = members.stream() - .map(RoomMemberResponse::from) - .collect(Collectors.toList()); + // 아바타 정보 포함하여 변환 (N+1 방지) + List memberList = roomService.toRoomMemberResponseList(roomId, members); return ResponseEntity .status(HttpStatus.OK) diff --git a/src/main/java/com/back/domain/studyroom/dto/AvatarResponse.java b/src/main/java/com/back/domain/studyroom/dto/AvatarResponse.java new file mode 100644 index 00000000..7a95c5bf --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/dto/AvatarResponse.java @@ -0,0 +1,30 @@ +package com.back.domain.studyroom.dto; + +import com.back.domain.studyroom.entity.Avatar; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 아바타 정보 응답 DTO + */ +@Getter +@AllArgsConstructor +public class AvatarResponse { + private Long id; + private String name; + private String imageUrl; + private String description; + private boolean isDefault; + private String category; + + public static AvatarResponse from(Avatar avatar) { + return new AvatarResponse( + avatar.getId(), + avatar.getName(), + avatar.getImageUrl(), + avatar.getDescription(), + avatar.isDefault(), + avatar.getCategory() + ); + } +} diff --git a/src/main/java/com/back/domain/studyroom/dto/RoomMemberResponse.java b/src/main/java/com/back/domain/studyroom/dto/RoomMemberResponse.java index a6f0aa21..8da01d09 100644 --- a/src/main/java/com/back/domain/studyroom/dto/RoomMemberResponse.java +++ b/src/main/java/com/back/domain/studyroom/dto/RoomMemberResponse.java @@ -12,16 +12,39 @@ public class RoomMemberResponse { private Long userId; private String nickname; + private String profileImageUrl; + private Long avatarId; + private String avatarImageUrl; private RoomRole role; private LocalDateTime joinedAt; private LocalDateTime promotedAt; + - // TODO: isOnline은 Redis에서 조회하여 추가 예정 - + /** + * RoomMember만으로 생성 (아바타 정보 없음) + * 기존 호환성과 간단한 조회용 + */ public static RoomMemberResponse from(RoomMember member) { return RoomMemberResponse.builder() .userId(member.getUser().getId()) .nickname(member.getUser().getNickname()) + .profileImageUrl(member.getUser().getProfileImageUrl()) + .role(member.getRole()) + .joinedAt(member.getJoinedAt()) + .promotedAt(member.getPromotedAt()) + .build(); + } + + /** + * 아바타 정보를 포함된 내용으로 생성 + */ + public static RoomMemberResponse of(RoomMember member, Long avatarId, String avatarImageUrl) { + return RoomMemberResponse.builder() + .userId(member.getUser().getId()) + .nickname(member.getUser().getNickname()) + .profileImageUrl(member.getUser().getProfileImageUrl()) + .avatarId(avatarId) + .avatarImageUrl(avatarImageUrl) .role(member.getRole()) .joinedAt(member.getJoinedAt()) .promotedAt(member.getPromotedAt()) diff --git a/src/main/java/com/back/domain/studyroom/dto/UpdateAvatarRequest.java b/src/main/java/com/back/domain/studyroom/dto/UpdateAvatarRequest.java new file mode 100644 index 00000000..f890d8bd --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/dto/UpdateAvatarRequest.java @@ -0,0 +1,20 @@ +package com.back.domain.studyroom.dto; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 아바타 변경 요청 DTO + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class UpdateAvatarRequest { + + @NotNull(message = "아바타 ID는 필수입니다") + @Positive(message = "올바른 아바타 ID를 입력해주세요") + private Long avatarId; +} diff --git a/src/main/java/com/back/domain/studyroom/entity/Avatar.java b/src/main/java/com/back/domain/studyroom/entity/Avatar.java new file mode 100644 index 00000000..08dc1891 --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/entity/Avatar.java @@ -0,0 +1,36 @@ +package com.back.domain.studyroom.entity; + +import com.back.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +/** + * 아바타 마스터 테이블 + * - 선택 가능한 모든 아바타 정보를 관리 + * - 고양이, 강아지 등 다양한 아바타로 확장 가능 + * - isDefault=true인 아바타(1,2,3)는 VISITOR 랜덤 배정용 + */ +@Entity +@Getter +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "avatars") +public class Avatar extends BaseEntity { + + @Column(nullable = false, length = 50) + private String name; // "검은 고양이", "하얀 고양이", "골든 리트리버" 등등등 + @Column(nullable = false, length = 500) + private String imageUrl; // CDN URL + @Column(length = 200) + private String description; // "귀여운 검은 고양이" + @Column(nullable = false) + private boolean isDefault; // 기본(랜덤) 아바타 여부 + @Column(nullable = false) + private int sortOrder; // 표시 순서 (1, 2, 3...) + @Column(length = 50) + private String category; // "CAT", "DOG", "ETC" 등 (추후 확장 가능을 위해 카테고리를 나눴드아) +} diff --git a/src/main/java/com/back/domain/studyroom/entity/RoomMemberAvatar.java b/src/main/java/com/back/domain/studyroom/entity/RoomMemberAvatar.java new file mode 100644 index 00000000..a28e7f5f --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/entity/RoomMemberAvatar.java @@ -0,0 +1,50 @@ +package com.back.domain.studyroom.entity; + +import com.back.domain.user.entity.User; +import com.back.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.time.LocalDateTime; + +/** + * 방별 아바타 설정 테이블 + * - MEMBER 등급 이상만 저장됨 (VISITOR는 저장 안함) + * - 사용자가 아바타를 변경하면 이 테이블에 기록 + * - 재입장 시 저장된 아바타 자동 로드 + */ +@Entity +@Getter +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "room_member_avatars", + uniqueConstraints = @UniqueConstraint(columnNames = {"room_id", "user_id"})) +public class RoomMemberAvatar extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "room_id", nullable = false) + private Room room; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "avatar_id", nullable = false) + private Avatar selectedAvatar; + + private LocalDateTime updatedAt; + + /** + * 선택한 아바타 변경 + */ + public void setSelectedAvatar(Avatar newAvatar) { + this.selectedAvatar = newAvatar; + this.updatedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/back/domain/studyroom/repository/AvatarRepository.java b/src/main/java/com/back/domain/studyroom/repository/AvatarRepository.java new file mode 100644 index 00000000..7a655e10 --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/repository/AvatarRepository.java @@ -0,0 +1,26 @@ +package com.back.domain.studyroom.repository; + +import com.back.domain.studyroom.entity.Avatar; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface AvatarRepository extends JpaRepository { + + /** + * 정렬 순서대로 모든 아바타 조회 + */ + List findAllByOrderBySortOrderAsc(); + + /** + * 기본 아바타만 조회 (랜덤 배정용) + */ + List findByIsDefaultTrueOrderBySortOrderAsc(); + + /** + * 카테고리별 아바타 조회하도록 하는 (고양이 말고도 다른 귀여운 애들을 대비해서 추후 확장용) + */ + List findByCategoryOrderBySortOrderAsc(String category); +} diff --git a/src/main/java/com/back/domain/studyroom/repository/RoomMemberAvatarRepository.java b/src/main/java/com/back/domain/studyroom/repository/RoomMemberAvatarRepository.java new file mode 100644 index 00000000..ecacb7e8 --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/repository/RoomMemberAvatarRepository.java @@ -0,0 +1,30 @@ +package com.back.domain.studyroom.repository; + +import com.back.domain.studyroom.entity.RoomMemberAvatar; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +@Repository +public interface RoomMemberAvatarRepository extends JpaRepository { + + /** + * 특정 방에서 특정 사용자의 아바타 설정 조회 + */ + Optional findByRoomIdAndUserId(Long roomId, Long userId); + + /** + * 특정 방의 모든 아바타 설정 조회 (일괄 조회용) + */ + @Query("SELECT rma FROM RoomMemberAvatar rma " + + "JOIN FETCH rma.selectedAvatar " + + "WHERE rma.room.id = :roomId AND rma.user.id IN :userIds") + List findByRoomIdAndUserIdIn(@Param("roomId") Long roomId, + @Param("userIds") Set userIds); +} diff --git a/src/main/java/com/back/domain/studyroom/service/AvatarService.java b/src/main/java/com/back/domain/studyroom/service/AvatarService.java new file mode 100644 index 00000000..1a84b74d --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/service/AvatarService.java @@ -0,0 +1,191 @@ +package com.back.domain.studyroom.service; + +import com.back.domain.studyroom.dto.AvatarResponse; +import com.back.domain.studyroom.entity.Avatar; +import com.back.domain.studyroom.entity.RoomMember; +import com.back.domain.studyroom.entity.RoomMemberAvatar; +import com.back.domain.studyroom.entity.RoomRole; +import com.back.domain.studyroom.repository.AvatarRepository; +import com.back.domain.studyroom.repository.RoomMemberAvatarRepository; +import com.back.domain.studyroom.repository.RoomMemberRepository; +import com.back.domain.user.entity.User; +import com.back.domain.user.repository.UserRepository; +import com.back.global.exception.CustomException; +import com.back.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 아바타 관리 서비스 + * - VISITOR: 랜덤 아바타 배정 (DB 저장 안함) + * - MEMBER 이상: 아바타 변경 시 DB 저장 + */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Slf4j +public class AvatarService { + + private final AvatarRepository avatarRepository; + private final RoomMemberAvatarRepository roomMemberAvatarRepository; + private final RoomMemberRepository roomMemberRepository; + private final UserRepository userRepository; + private final com.back.global.websocket.service.RoomParticipantService roomParticipantService; // ⭐ 추가 + + // 기본 아바타 ID 캐시 (애플리케이션 시작 시 로드) + private List defaultAvatarIds = null; + + /** + * 방 입장 시 아바타 로드 또는 생성 + * @param roomId 방 ID + * @param userId 사용자 ID + * @return 사용할 아바타 ID + */ + public Long loadOrCreateAvatar(Long roomId, Long userId) { + // 1. MEMBER 이상인지 확인 + Optional memberOpt = + roomMemberRepository.findByRoomIdAndUserId(roomId, userId); + + if (memberOpt.isEmpty()) { + // VISITOR → 랜덤 아바타 배정 + log.debug("VISITOR 입장 - RoomId: {}, UserId: {}, 랜덤 아바타 배정", roomId, userId); + return assignRandomAvatar(); + } + + RoomMember member = memberOpt.get(); + + // 2. MEMBER 이상 → DB에서 저장된 아바타 조회 + Optional savedAvatar = + roomMemberAvatarRepository.findByRoomIdAndUserId(roomId, userId); + + if (savedAvatar.isPresent()) { + // 이전에 설정한 아바타 있음 + Long avatarId = savedAvatar.get().getSelectedAvatar().getId(); + log.debug("MEMBER 재입장 - RoomId: {}, UserId: {}, 저장된 아바타: {}", + roomId, userId, avatarId); + return avatarId; + } + + // 3. MEMBER 이상이지만 아직 아바타 미설정 → 랜덤 배정 (DB 저장 안함) + log.debug("MEMBER 첫 입장 - RoomId: {}, UserId: {}, 랜덤 아바타 배정", roomId, userId); + return assignRandomAvatar(); + } + + /** + * 랜덤 아바타 배정 (기본 아바타 중 랜덤 선택) + * @return 아바타 ID + */ + public Long assignRandomAvatar() { + // 기본 아바타 목록 캐싱 + if (defaultAvatarIds == null || defaultAvatarIds.isEmpty()) { + loadDefaultAvatars(); + } + + if (defaultAvatarIds.isEmpty()) { + // 기본 아바타가 없으면 첫 번째 아바타 반환 + log.warn("기본 아바타가 없습니다. 첫 번째 아바타를 사용합니다."); + Avatar firstAvatar = avatarRepository.findAll().stream() + .findFirst() + .orElseThrow(() -> new CustomException(ErrorCode.AVATAR_NOT_FOUND)); + return firstAvatar.getId(); + } + + Random random = new Random(); + int index = random.nextInt(defaultAvatarIds.size()); + Long selectedId = defaultAvatarIds.get(index); + + log.debug("랜덤 아바타 선택: {} (총 {}개 중)", selectedId, defaultAvatarIds.size()); + return selectedId; + } + + /** + * 기본 아바타 목록 로드 + */ + private void loadDefaultAvatars() { + List defaultAvatars = avatarRepository.findByIsDefaultTrueOrderBySortOrderAsc(); + defaultAvatarIds = defaultAvatars.stream() + .map(Avatar::getId) + .collect(Collectors.toList()); + + log.info("기본 아바타 로드 완료: {}개", defaultAvatarIds.size()); + } + + /** + * 아바타 변경 + * - VISITOR: Redis에만 저장 (퇴장 시 삭제) + * - MEMBER 이상: Redis + DB 저장 (재입장 시 유지) + * @param roomId 방 ID + * @param userId 사용자 ID + * @param newAvatarId 새 아바타 ID + */ + @Transactional + public void updateRoomAvatar(Long roomId, Long userId, Long newAvatarId) { + // 1. 선택한 아바타 존재 확인 + Avatar newAvatar = avatarRepository.findById(newAvatarId) + .orElseThrow(() -> new CustomException(ErrorCode.AVATAR_NOT_FOUND)); + + // 2. 방 멤버 여부 확인 (VISITOR도 가능하도록 Optional 사용) + Optional memberOpt = roomMemberRepository + .findByRoomIdAndUserId(roomId, userId); + + // 3-1. MEMBER 이상인 경우: DB에 저장 + if (memberOpt.isPresent()) { + RoomMember member = memberOpt.get(); + + // DB에 저장 (최초 또는 업데이트) + RoomMemberAvatar roomAvatar = roomMemberAvatarRepository + .findByRoomIdAndUserId(roomId, userId) + .orElse(RoomMemberAvatar.builder() + .room(member.getRoom()) + .user(member.getUser()) + .build()); + + roomAvatar.setSelectedAvatar(newAvatar); + roomMemberAvatarRepository.save(roomAvatar); + + log.info("아바타 변경 완료 (DB 저장) - RoomId: {}, UserId: {}, Role: {}, AvatarId: {}", + roomId, userId, member.getRole(), newAvatarId); + } + // 3-2. VISITOR인 경우: Redis에만 저장 (DB 저장 안함) + else { + log.info("아바타 변경 완료 (Redis만 저장) - RoomId: {}, UserId: {}, Role: VISITOR, AvatarId: {}", + roomId, userId, newAvatarId); + } + + // 4. Redis에 아바타 업데이트 (모든 사용자 공통) + roomParticipantService.updateUserAvatar(roomId, userId, newAvatarId); + } + + /** + * 사용 가능한 아바타 목록 조회 + */ + public List getAvailableAvatars() { + return avatarRepository.findAllByOrderBySortOrderAsc() + .stream() + .map(AvatarResponse::from) + .collect(Collectors.toList()); + } + + /** + * 특정 아바타 조회 + */ + public Avatar getAvatarById(Long avatarId) { + return avatarRepository.findById(avatarId) + .orElse(null); + } + + /** + * 여러 아바타 일괄 조회 (N+1 방지) + */ + public Map getAvatarsByIds(Set avatarIds) { + return avatarRepository.findAllById(avatarIds) + .stream() + .collect(Collectors.toMap(Avatar::getId, avatar -> avatar)); + } +} 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 8aee49b0..0c389550 100644 --- a/src/main/java/com/back/domain/studyroom/service/RoomService.java +++ b/src/main/java/com/back/domain/studyroom/service/RoomService.java @@ -51,6 +51,7 @@ public class RoomService { private final RoomParticipantService roomParticipantService; private final SimpMessagingTemplate messagingTemplate; private final ApplicationEventPublisher eventPublisher; + private final AvatarService avatarService; /** * 방 생성 메서드 @@ -135,16 +136,19 @@ public RoomMember joinRoom(Long roomId, String password, Long userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + // 아바타 로드/생성 + Long avatarId = avatarService.loadOrCreateAvatar(roomId, userId); + Optional existingMember = roomMemberRepository.findByRoomIdAndUserId(roomId, userId); if (existingMember.isPresent()) { // 기존 멤버 재입장: DB에 있는 역할 그대로 사용 RoomMember member = existingMember.get(); - // Redis에 온라인 등록 - roomParticipantService.enterRoom(userId, roomId); + // Redis에 온라인 등록 (아바타 포함) + roomParticipantService.enterRoom(userId, roomId, avatarId); - log.info("기존 멤버 재입장 - RoomId: {}, UserId: {}, Role: {}", - roomId, userId, member.getRole()); + log.info("기존 멤버 재입장 - RoomId: {}, UserId: {}, Role: {}, AvatarId: {}", + roomId, userId, member.getRole(), avatarId); return member; } @@ -152,10 +156,11 @@ public RoomMember joinRoom(Long roomId, String password, Long userId) { // 신규 입장자: VISITOR로 입장 (DB 저장 안함!) RoomMember visitorMember = RoomMember.createVisitor(room, user); - // Redis에만 온라인 등록 - roomParticipantService.enterRoom(userId, roomId); + // Redis에만 온라인 등록 (아바타 포함) + roomParticipantService.enterRoom(userId, roomId, avatarId); - log.info("신규 입장 (VISITOR) - RoomId: {}, UserId: {}, DB 저장 안함", roomId, userId); + log.info("신규 입장 (VISITOR) - RoomId: {}, UserId: {}, DB 저장 안함, AvatarId: {}", + roomId, userId, avatarId); // 메모리상 객체 반환 (DB에 저장되지 않음) return visitorMember; @@ -709,4 +714,51 @@ public java.util.List toMyRoomResp }) .collect(java.util.stream.Collectors.toList()); } + + /** + * RoomMemberResponse 리스트 생성 (아바타 정보 포함, N+1 방지) + * @param roomId 방 ID + * @param members 멤버 목록 + * @return 아바타 정보가 포함된 RoomMemberResponse 리스트 + */ + public java.util.List toRoomMemberResponseList( + Long roomId, + java.util.List members) { + + if (members.isEmpty()) { + return java.util.List.of(); + } + + // 1. 모든 사용자 ID 추출 + Set userIds = members.stream() + .map(m -> m.getUser().getId()) + .collect(java.util.stream.Collectors.toSet()); + + // 2. Redis에서 아바타 ID 일괄 조회 + java.util.Map avatarMap = roomParticipantService.getUserAvatars(roomId, userIds); + + // 3. 아바타 ID Set 생성 + Set avatarIds = new java.util.HashSet<>(avatarMap.values()); + + // 4. Avatar 엔티티 일괄 조회 + java.util.Map avatarEntityMap = + avatarService.getAvatarsByIds(avatarIds); + + // 5. RoomMemberResponse 생성 + return members.stream() + .map(member -> { + Long userId = member.getUser().getId(); + Long avatarId = avatarMap.get(userId); + + String avatarImageUrl = null; + if (avatarId != null) { + com.back.domain.studyroom.entity.Avatar avatar = avatarEntityMap.get(avatarId); + avatarImageUrl = avatar != null ? avatar.getImageUrl() : null; + } + + return com.back.domain.studyroom.dto.RoomMemberResponse.of( + member, avatarId, avatarImageUrl); + }) + .collect(java.util.stream.Collectors.toList()); + } } diff --git a/src/main/java/com/back/global/exception/ErrorCode.java b/src/main/java/com/back/global/exception/ErrorCode.java index 20f543c4..9c6daf1e 100644 --- a/src/main/java/com/back/global/exception/ErrorCode.java +++ b/src/main/java/com/back/global/exception/ErrorCode.java @@ -40,6 +40,9 @@ public enum ErrorCode { ROOM_PASSWORD_MISMATCH(HttpStatus.BAD_REQUEST, "ROOM_017", "현재 비밀번호가 일치하지 않습니다."), NOT_ROOM_HOST(HttpStatus.FORBIDDEN, "ROOM_018", "방장 권한이 필요합니다."), + // ======================== 아바타 관련 ======================== + AVATAR_NOT_FOUND(HttpStatus.NOT_FOUND, "AVATAR_001", "존재하지 않는 아바타입니다."), + // ======================== 스터디 플래너 관련 ======================== PLAN_NOT_FOUND(HttpStatus.NOT_FOUND, "PLAN_001", "존재하지 않는 학습 계획입니다."), 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 14804939..b8ef6d64 100644 --- a/src/main/java/com/back/global/websocket/service/RoomParticipantService.java +++ b/src/main/java/com/back/global/websocket/service/RoomParticipantService.java @@ -23,8 +23,8 @@ public class RoomParticipantService { private final RedisSessionStore redisSessionStore; - // 사용자 방 입장 - public void enterRoom(Long userId, Long roomId) { + // 사용자 방 입장 (아바타 정보 포함) + public void enterRoom(Long userId, Long roomId, Long avatarId) { WebSocketSessionInfo sessionInfo = redisSessionStore.getUserSession(userId); if (sessionInfo == null) { @@ -41,8 +41,16 @@ public void enterRoom(Long userId, Long roomId) { WebSocketSessionInfo updatedSession = sessionInfo.withRoomId(roomId); redisSessionStore.saveUserSession(userId, updatedSession); redisSessionStore.addUserToRoom(roomId, userId); + + // 아바타 정보 저장 + saveUserAvatar(roomId, userId, avatarId); - log.info("방 입장 완료 - 사용자: {}, 방: {}", userId, roomId); + log.info("방 입장 완료 - 사용자: {}, 방: {}, 아바타: {}", userId, roomId, avatarId); + } + + // 기존 메서드 호환성 유지 (아바타 없이 입장) + public void enterRoom(Long userId, Long roomId) { + enterRoom(userId, roomId, null); } // 사용자 방 퇴장 @@ -107,4 +115,94 @@ public void exitAllRooms(Long userId) { public java.util.Map getParticipantCounts(java.util.List roomIds) { return redisSessionStore.getRoomUserCounts(roomIds); } + + // ==================== 아바타 관련 메서드 ==================== + + /** + * 사용자의 아바타 정보 저장 (Redis) + * @param roomId 방 ID + * @param userId 사용자 ID + * @param avatarId 아바타 ID + */ + private void saveUserAvatar(Long roomId, Long userId, Long avatarId) { + if (avatarId == null) { + return; // 아바타 정보가 없으면 저장하지 않음 + } + + String avatarKey = buildAvatarKey(roomId, userId); + redisSessionStore.saveValue(avatarKey, avatarId.toString(), + java.time.Duration.ofMinutes(6)); + + log.debug("아바타 정보 저장 - RoomId: {}, UserId: {}, AvatarId: {}", + roomId, userId, avatarId); + } + + /** + * 사용자의 아바타 ID 조회 + * @param roomId 방 ID + * @param userId 사용자 ID + * @return 아바타 ID (없으면 null) + */ + public Long getUserAvatar(Long roomId, Long userId) { + String avatarKey = buildAvatarKey(roomId, userId); + String avatarIdStr = redisSessionStore.getValue(avatarKey); + + if (avatarIdStr == null) { + return null; + } + + try { + return Long.parseLong(avatarIdStr); + } catch (NumberFormatException e) { + log.warn("아바타 ID 파싱 실패 - RoomId: {}, UserId: {}, Value: {}", + roomId, userId, avatarIdStr); + return null; + } + } + + /** + * 여러 사용자의 아바타 ID 일괄 조회 (N+1 방지) + * @param roomId 방 ID + * @param userIds 사용자 ID 목록 + * @return 사용자 ID → 아바타 ID 맵 + */ + public java.util.Map getUserAvatars(Long roomId, Set userIds) { + java.util.Map result = new java.util.HashMap<>(); + + for (Long userId : userIds) { + Long avatarId = getUserAvatar(roomId, userId); + if (avatarId != null) { + result.put(userId, avatarId); + } + } + + return result; + } + + /** + * 아바타 Redis Key 생성 + */ + private String buildAvatarKey(Long roomId, Long userId) { + return "ws:room:" + roomId + ":user:" + userId + ":avatar"; + } + + /** + * 아바타 정보 업데이트 (외부에서 호출 가능) + * VISITOR가 아바타를 변경할 때 사용 + * @param roomId 방 ID + * @param userId 사용자 ID + * @param avatarId 새 아바타 ID + */ + public void updateUserAvatar(Long roomId, Long userId, Long avatarId) { + if (avatarId == null) { + return; + } + + String avatarKey = buildAvatarKey(roomId, userId); + redisSessionStore.saveValue(avatarKey, avatarId.toString(), + java.time.Duration.ofMinutes(6)); + + log.info("아바타 업데이트 (Redis) - RoomId: {}, UserId: {}, AvatarId: {}", + roomId, userId, avatarId); + } } 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 0f4e34d8..1b37d85f 100644 --- a/src/main/java/com/back/global/websocket/store/RedisSessionStore.java +++ b/src/main/java/com/back/global/websocket/store/RedisSessionStore.java @@ -267,4 +267,50 @@ private Long convertToLong(Object obj) { throw new IllegalArgumentException("Cannot convert " + obj.getClass() + " to Long"); } } + + // ==================== 범용 Key-Value 저장/조회 메서드 ==================== + + /** + * 범용 값 저장 (TTL 포함) + * @param key Redis Key + * @param value 저장할 값 + * @param ttl TTL (Duration) + */ + public void saveValue(String key, String value, java.time.Duration ttl) { + try { + redisTemplate.opsForValue().set(key, value, ttl); + log.debug("값 저장 완료 - Key: {}, TTL: {}분", key, ttl.toMinutes()); + } catch (Exception e) { + log.error("값 저장 실패 - Key: {}", key, e); + throw new CustomException(ErrorCode.WS_REDIS_ERROR); + } + } + + /** + * 범용 값 조회 + * @param key Redis Key + * @return 저장된 값 (없으면 null) + */ + public String getValue(String key) { + try { + Object value = redisTemplate.opsForValue().get(key); + return value != null ? value.toString() : null; + } catch (Exception e) { + log.error("값 조회 실패 - Key: {}", key, e); + return null; // 에러 시 null 반환 (예외 던지지 않음) + } + } + + /** + * 범용 값 삭제 + * @param key Redis Key + */ + public void deleteValue(String key) { + try { + redisTemplate.delete(key); + log.debug("값 삭제 완료 - Key: {}", key); + } catch (Exception e) { + log.error("값 삭제 실패 - Key: {}", key, e); + } + } } \ No newline at end of file diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql index 52bf31d4..5cb3e989 100644 --- a/src/main/resources/data.sql +++ b/src/main/resources/data.sql @@ -94,3 +94,12 @@ VALUES ('2~4명', 'GROUP_SIZE', NOW(), NOW()), ('5~10명', 'GROUP_SIZE', NOW(), NOW()), ('10~20명', 'GROUP_SIZE', NOW(), NOW()); + +-- ========================= +-- AVATAR 초기 데이터 (고양이 아바타 3개 - 기본 랜덤하기 위해 배정) +-- ========================= +INSERT INTO avatars (id, name, image_url, description, is_default, sort_order, category, created_at, updated_at) +VALUES + (1, '검은 고양이', '/images/avatars/cat-black.png', '귀여운 검은 고양이', true, 1, 'CAT', NOW(), NOW()), + (2, '하얀 고양이', '/images/avatars/cat-white.png', '우아한 하얀 고양이', true, 2, 'CAT', NOW(), NOW()), + (3, '노란 고양이', '/images/avatars/cat-orange.png', '발랄한 노란 고양이', true, 3, 'CAT', NOW(), NOW()); From 7d4237caf66e26b519be89c867db26d9bcb2dd02 Mon Sep 17 00:00:00 2001 From: loseminho Date: Sat, 11 Oct 2025 19:34:39 +0900 Subject: [PATCH 15/17] =?UTF-8?q?fix:=20=EA=B8=B0=EC=A1=B4=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=EB=90=98=EC=96=B4=EC=9E=88=EB=8D=98=20test=20?= =?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 --- src/main/java/com/back/global/initData/DevInitData.java | 7 ++++--- .../domain/studyroom/controller/RoomControllerTest.java | 5 +++++ .../com/back/domain/studyroom/service/RoomServiceTest.java | 7 ++++++- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/back/global/initData/DevInitData.java b/src/main/java/com/back/global/initData/DevInitData.java index 43e2a411..d4658a03 100644 --- a/src/main/java/com/back/global/initData/DevInitData.java +++ b/src/main/java/com/back/global/initData/DevInitData.java @@ -39,10 +39,11 @@ public class DevInitData { @Bean ApplicationRunner DevInitDataApplicationRunner() { - return args -> initialize(); + return args -> { + initialize(); + }; } - @Transactional public void initialize() { runDataSql(); initUsersAndPostsAndComments(); @@ -102,7 +103,7 @@ public void initUsersAndPostsAndComments() { } } - private void createSamplePosts(User user1, User user2, User user3) { + private void createSamplePosts(User user1, User user2, User user3) { // ⭐ @Transactional 제거 Post post1 = new Post(user1, "[백엔드] 같이 스프링 공부하실 분 구해요!", "매주 토요일 오후 2시에 온라인으로 스터디 진행합니다.\n교재는 '스프링 완전정복'을 사용할 예정입니다.", diff --git a/src/test/java/com/back/domain/studyroom/controller/RoomControllerTest.java b/src/test/java/com/back/domain/studyroom/controller/RoomControllerTest.java index 326beb13..26ab1961 100644 --- a/src/test/java/com/back/domain/studyroom/controller/RoomControllerTest.java +++ b/src/test/java/com/back/domain/studyroom/controller/RoomControllerTest.java @@ -329,6 +329,10 @@ void getRoomMembers() { given(currentUser.getUserId()).willReturn(1L); given(roomService.getRoomMembers(eq(1L), eq(1L))).willReturn(Arrays.asList(testMember)); + + // toRoomMemberResponseList 호출 추가 + List memberResponses = Arrays.asList(RoomMemberResponse.from(testMember)); + given(roomService.toRoomMemberResponseList(eq(1L), anyList())).willReturn(memberResponses); // when ResponseEntity>> response = roomController.getRoomMembers(1L); @@ -342,6 +346,7 @@ void getRoomMembers() { verify(currentUser, times(1)).getUserId(); verify(roomService, times(1)).getRoomMembers(eq(1L), eq(1L)); + verify(roomService, times(1)).toRoomMemberResponseList(eq(1L), anyList()); } @Test 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 6e81d16f..815ac8e1 100644 --- a/src/test/java/com/back/domain/studyroom/service/RoomServiceTest.java +++ b/src/test/java/com/back/domain/studyroom/service/RoomServiceTest.java @@ -58,6 +58,9 @@ class RoomServiceTest { @Mock private NotificationService notificationService; + + @Mock + private AvatarService avatarService; @InjectMocks private RoomService roomService; @@ -157,6 +160,7 @@ void joinRoom_Success() { given(userRepository.findById(2L)).willReturn(Optional.of(testUser)); given(roomMemberRepository.findByRoomIdAndUserId(1L, 2L)).willReturn(Optional.empty()); given(roomParticipantService.getParticipantCount(1L)).willReturn(0L); // Redis 카운트 + given(avatarService.loadOrCreateAvatar(1L, 2L)).willReturn(1L); // 아바타 Mock 추가 // when RoomMember joinedMember = roomService.joinRoom(1L, null, 2L); @@ -164,7 +168,8 @@ void joinRoom_Success() { // then assertThat(joinedMember).isNotNull(); assertThat(joinedMember.getRole()).isEqualTo(RoomRole.VISITOR); - verify(roomParticipantService, times(1)).enterRoom(2L, 1L); // Redis 입장 확인 + verify(avatarService, times(1)).loadOrCreateAvatar(1L, 2L); + verify(roomParticipantService, times(1)).enterRoom(eq(2L), eq(1L), any()); // avatarId 파라미터 추가 verify(roomMemberRepository, never()).save(any(RoomMember.class)); // DB 저장 안됨! } From 08a976f92a14678c275e8246448af5835d822833 Mon Sep 17 00:00:00 2001 From: loseminho Date: Sun, 12 Oct 2025 19:51:21 +0900 Subject: [PATCH 16/17] =?UTF-8?q?test:=20=EC=95=84=EB=B0=94=ED=83=80=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/RoomAvatarControllerTest.java | 239 +++++++++++++ .../repository/AvatarRepositoryTest.java | 183 ++++++++++ .../RoomMemberAvatarRepositoryTest.java | 323 ++++++++++++++++++ 3 files changed, 745 insertions(+) create mode 100644 src/test/java/com/back/domain/studyroom/controller/RoomAvatarControllerTest.java create mode 100644 src/test/java/com/back/domain/studyroom/repository/AvatarRepositoryTest.java create mode 100644 src/test/java/com/back/domain/studyroom/repository/RoomMemberAvatarRepositoryTest.java diff --git a/src/test/java/com/back/domain/studyroom/controller/RoomAvatarControllerTest.java b/src/test/java/com/back/domain/studyroom/controller/RoomAvatarControllerTest.java new file mode 100644 index 00000000..a69549b1 --- /dev/null +++ b/src/test/java/com/back/domain/studyroom/controller/RoomAvatarControllerTest.java @@ -0,0 +1,239 @@ +package com.back.domain.studyroom.controller; + +import com.back.domain.studyroom.dto.AvatarResponse; +import com.back.domain.studyroom.dto.UpdateAvatarRequest; +import com.back.domain.studyroom.entity.Avatar; +import com.back.domain.studyroom.service.AvatarService; +import com.back.global.common.dto.RsData; +import com.back.global.security.user.CurrentUser; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("RoomAvatarController 테스트") +class RoomAvatarControllerTest { + + @Mock + private AvatarService avatarService; + + @Mock + private CurrentUser currentUser; + + @InjectMocks + private RoomAvatarController roomAvatarController; + + private List avatarResponses; + + @BeforeEach + void setUp() { + avatarResponses = List.of( + new AvatarResponse(1L, "검은 고양이", "/images/avatars/cat-black.png", + "귀여운 검은 고양이", true, "CAT"), + new AvatarResponse(2L, "하얀 고양이", "/images/avatars/cat-white.png", + "우아한 하얀 고양이", true, "CAT"), + new AvatarResponse(3L, "노란 고양이", "/images/avatars/cat-orange.png", + "발랄한 노란 고양이", true, "CAT") + ); + } + + @Test + @DisplayName("아바타 목록 조회 API - 성공") + void getAvatars_Success() { + // given + given(avatarService.getAvailableAvatars()).willReturn(avatarResponses); + + // when + ResponseEntity>> response = + roomAvatarController.getAvatars(1L); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().isSuccess()).isTrue(); + assertThat(response.getBody().getMessage()).isEqualTo("아바타 목록 조회 완료"); + assertThat(response.getBody().getData()).hasSize(3); + assertThat(response.getBody().getData()) + .extracting(AvatarResponse::getName) + .containsExactly("검은 고양이", "하얀 고양이", "노란 고양이"); + + verify(avatarService, times(1)).getAvailableAvatars(); + } + + @Test + @DisplayName("아바타 목록 조회 API - 빈 리스트 반환") + void getAvatars_EmptyList() { + // given + given(avatarService.getAvailableAvatars()).willReturn(List.of()); + + // when + ResponseEntity>> response = + roomAvatarController.getAvatars(1L); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getData()).isEmpty(); + } + + @Test + @DisplayName("아바타 변경 API - 성공 (VISITOR)") + void updateMyAvatar_Visitor_Success() { + // given + given(currentUser.getUserId()).willReturn(100L); + + UpdateAvatarRequest request = new UpdateAvatarRequest(2L); + + doNothing().when(avatarService).updateRoomAvatar(1L, 100L, 2L); + + // when + ResponseEntity> response = + roomAvatarController.updateMyAvatar(1L, request); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().isSuccess()).isTrue(); + assertThat(response.getBody().getMessage()).isEqualTo("아바타가 변경되었습니다"); + + verify(currentUser, times(1)).getUserId(); + verify(avatarService, times(1)).updateRoomAvatar(1L, 100L, 2L); + } + + @Test + @DisplayName("아바타 변경 API - 성공 (MEMBER)") + void updateMyAvatar_Member_Success() { + // given + given(currentUser.getUserId()).willReturn(100L); + + UpdateAvatarRequest request = new UpdateAvatarRequest(3L); + + doNothing().when(avatarService).updateRoomAvatar(1L, 100L, 3L); + + // when + ResponseEntity> response = + roomAvatarController.updateMyAvatar(1L, request); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().isSuccess()).isTrue(); + + verify(avatarService, times(1)).updateRoomAvatar(1L, 100L, 3L); + } + + @Test + @DisplayName("아바타 변경 API - 다른 방에서는 다른 아바타 설정 가능") + void updateMyAvatar_DifferentRooms() { + // given + given(currentUser.getUserId()).willReturn(100L); + + UpdateAvatarRequest request1 = new UpdateAvatarRequest(1L); + UpdateAvatarRequest request2 = new UpdateAvatarRequest(3L); + + // when + roomAvatarController.updateMyAvatar(1L, request1); // 방1에서 아바타 1 + roomAvatarController.updateMyAvatar(2L, request2); // 방2에서 아바타 3 + + // then + verify(avatarService, times(1)).updateRoomAvatar(1L, 100L, 1L); + verify(avatarService, times(1)).updateRoomAvatar(2L, 100L, 3L); + } + + @Test + @DisplayName("아바타 변경 API - 같은 아바타로 여러 번 변경 가능") + void updateMyAvatar_SameAvatarMultipleTimes() { + // given + given(currentUser.getUserId()).willReturn(100L); + + UpdateAvatarRequest request = new UpdateAvatarRequest(2L); + + // when + roomAvatarController.updateMyAvatar(1L, request); + roomAvatarController.updateMyAvatar(1L, request); + + // then + verify(avatarService, times(2)).updateRoomAvatar(1L, 100L, 2L); + } + + @Test + @DisplayName("아바타 변경 API - 여러 사용자가 동시에 변경 가능") + void updateMyAvatar_MultipleUsers() { + // given + UpdateAvatarRequest request1 = new UpdateAvatarRequest(1L); + UpdateAvatarRequest request2 = new UpdateAvatarRequest(2L); + + // when + given(currentUser.getUserId()).willReturn(100L); + roomAvatarController.updateMyAvatar(1L, request1); + + given(currentUser.getUserId()).willReturn(200L); + roomAvatarController.updateMyAvatar(1L, request2); + + // then + verify(avatarService, times(1)).updateRoomAvatar(1L, 100L, 1L); + verify(avatarService, times(1)).updateRoomAvatar(1L, 200L, 2L); + } + + @Test + @DisplayName("아바타 목록 조회 - 카테고리 정보 포함") + void getAvatars_IncludesCategory() { + // given + given(avatarService.getAvailableAvatars()).willReturn(avatarResponses); + + // when + ResponseEntity>> response = + roomAvatarController.getAvatars(1L); + + // then + assertThat(response.getBody().getData()) + .allMatch(avatar -> avatar.getCategory() != null); + assertThat(response.getBody().getData()) + .allMatch(avatar -> avatar.getCategory().equals("CAT")); + } + + @Test + @DisplayName("아바타 목록 조회 - isDefault 정보 포함") + void getAvatars_IncludesIsDefault() { + // given + given(avatarService.getAvailableAvatars()).willReturn(avatarResponses); + + // when + ResponseEntity>> response = + roomAvatarController.getAvatars(1L); + + // then + assertThat(response.getBody().getData()) + .allMatch(AvatarResponse::isDefault); + } + + @Test + @DisplayName("아바타 변경 API - 요청 검증 (avatarId 필수)") + void updateMyAvatar_RequestValidation() { + // given + given(currentUser.getUserId()).willReturn(100L); + + // avatarId가 있는 정상 요청 + UpdateAvatarRequest validRequest = new UpdateAvatarRequest(1L); + + // when + ResponseEntity> response = + roomAvatarController.updateMyAvatar(1L, validRequest); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + verify(avatarService, times(1)).updateRoomAvatar(1L, 100L, 1L); + } +} diff --git a/src/test/java/com/back/domain/studyroom/repository/AvatarRepositoryTest.java b/src/test/java/com/back/domain/studyroom/repository/AvatarRepositoryTest.java new file mode 100644 index 00000000..5a76b8a3 --- /dev/null +++ b/src/test/java/com/back/domain/studyroom/repository/AvatarRepositoryTest.java @@ -0,0 +1,183 @@ +package com.back.domain.studyroom.repository; + +import com.back.domain.studyroom.entity.Avatar; +import com.back.global.config.QueryDslTestConfig; +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.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase.Replace; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@ActiveProfiles("test") +@AutoConfigureTestDatabase(replace = Replace.NONE) +@Import(QueryDslTestConfig.class) +@DisplayName("AvatarRepository 테스트") +class AvatarRepositoryTest { + + @Autowired + private TestEntityManager testEntityManager; + + @Autowired + private AvatarRepository avatarRepository; + + private Avatar avatar1; + private Avatar avatar2; + private Avatar avatar3; + private Avatar avatar4; + + @BeforeEach + void setUp() { + // 기본 아바타 (랜덤 배정용) + avatar1 = Avatar.builder() + .name("검은 고양이") + .imageUrl("/images/avatars/cat-black.png") + .description("귀여운 검은 고양이") + .isDefault(true) + .sortOrder(1) + .category("CAT") + .build(); + testEntityManager.persist(avatar1); + + avatar2 = Avatar.builder() + .name("하얀 고양이") + .imageUrl("/images/avatars/cat-white.png") + .description("우아한 하얀 고양이") + .isDefault(true) + .sortOrder(2) + .category("CAT") + .build(); + testEntityManager.persist(avatar2); + + avatar3 = Avatar.builder() + .name("노란 고양이") + .imageUrl("/images/avatars/cat-orange.png") + .description("발랄한 노란 고양이") + .isDefault(true) + .sortOrder(3) + .category("CAT") + .build(); + testEntityManager.persist(avatar3); + + // 특별 아바타 (구매 필요 등) + avatar4 = Avatar.builder() + .name("골든 리트리버") + .imageUrl("/images/avatars/dog-golden.png") + .description("친근한 골든 리트리버") + .isDefault(false) // 기본 아바타 아님 + .sortOrder(4) + .category("DOG") + .build(); + testEntityManager.persist(avatar4); + + testEntityManager.flush(); + testEntityManager.clear(); + } + + @Test + @DisplayName("정렬 순서대로 모든 아바타 조회") + void findAllByOrderBySortOrderAsc() { + // when + List avatars = avatarRepository.findAllByOrderBySortOrderAsc(); + + // then + assertThat(avatars).hasSize(4); + assertThat(avatars.get(0).getName()).isEqualTo("검은 고양이"); + assertThat(avatars.get(1).getName()).isEqualTo("하얀 고양이"); + assertThat(avatars.get(2).getName()).isEqualTo("노란 고양이"); + assertThat(avatars.get(3).getName()).isEqualTo("골든 리트리버"); + } + + @Test + @DisplayName("기본 아바타만 조회 (랜덤 배정용)") + void findByIsDefaultTrueOrderBySortOrderAsc() { + // when + List defaultAvatars = avatarRepository.findByIsDefaultTrueOrderBySortOrderAsc(); + + // then + assertThat(defaultAvatars).hasSize(3); + assertThat(defaultAvatars) + .extracting(Avatar::getName) + .containsExactly("검은 고양이", "하얀 고양이", "노란 고양이"); + + assertThat(defaultAvatars) + .allMatch(Avatar::isDefault); + } + + @Test + @DisplayName("카테고리별 아바타 조회") + void findByCategoryOrderBySortOrderAsc() { + // when + List catAvatars = avatarRepository.findByCategoryOrderBySortOrderAsc("CAT"); + List dogAvatars = avatarRepository.findByCategoryOrderBySortOrderAsc("DOG"); + + // then + assertThat(catAvatars).hasSize(3); + assertThat(catAvatars) + .extracting(Avatar::getCategory) + .containsOnly("CAT"); + + assertThat(dogAvatars).hasSize(1); + assertThat(dogAvatars.get(0).getName()).isEqualTo("골든 리트리버"); + } + + @Test + @DisplayName("존재하지 않는 카테고리 조회 - 빈 리스트 반환") + void findByCategory_NotFound() { + // when + List result = avatarRepository.findByCategoryOrderBySortOrderAsc("BIRD"); + + // then + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("아바타 저장 및 조회") + void saveAndFindById() { + // given + Avatar newAvatar = Avatar.builder() + .name("회색 늑대") + .imageUrl("/images/avatars/wolf-grey.png") + .description("멋진 회색 늑대") + .isDefault(false) + .sortOrder(5) + .category("WOLF") + .build(); + + // when + testEntityManager.persist(newAvatar); + testEntityManager.flush(); + testEntityManager.clear(); + + Avatar found = avatarRepository.findById(newAvatar.getId()).orElse(null); + + // then + assertThat(found).isNotNull(); + assertThat(found.getName()).isEqualTo("회색 늑대"); + assertThat(found.getImageUrl()).isEqualTo("/images/avatars/wolf-grey.png"); + assertThat(found.getCategory()).isEqualTo("WOLF"); + assertThat(found.isDefault()).isFalse(); + } + + @Test + @DisplayName("정렬 순서 확인") + void checkSortOrder() { + // when + List avatars = avatarRepository.findAllByOrderBySortOrderAsc(); + + // then + for (int i = 0; i < avatars.size() - 1; i++) { + assertThat(avatars.get(i).getSortOrder()) + .isLessThan(avatars.get(i + 1).getSortOrder()); + } + } +} diff --git a/src/test/java/com/back/domain/studyroom/repository/RoomMemberAvatarRepositoryTest.java b/src/test/java/com/back/domain/studyroom/repository/RoomMemberAvatarRepositoryTest.java new file mode 100644 index 00000000..eef04396 --- /dev/null +++ b/src/test/java/com/back/domain/studyroom/repository/RoomMemberAvatarRepositoryTest.java @@ -0,0 +1,323 @@ +package com.back.domain.studyroom.repository; + +import com.back.domain.studyroom.entity.*; +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.global.config.QueryDslTestConfig; +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.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase.Replace; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@ActiveProfiles("test") +@AutoConfigureTestDatabase(replace = Replace.NONE) +@Import(QueryDslTestConfig.class) +@DisplayName("RoomMemberAvatarRepository 테스트") +class RoomMemberAvatarRepositoryTest { + + @Autowired + private TestEntityManager testEntityManager; + + @Autowired + private RoomMemberAvatarRepository roomMemberAvatarRepository; + + @Autowired + private AvatarRepository avatarRepository; + + private Room testRoom1; + private Room testRoom2; + private User user1; + private User user2; + private User user3; + private Avatar avatar1; + private Avatar avatar2; + private Avatar avatar3; + + @BeforeEach + void setUp() { + // 사용자 생성 + user1 = createUser("user1", "user1@test.com", "유저1"); + user2 = createUser("user2", "user2@test.com", "유저2"); + user3 = createUser("user3", "user3@test.com", "유저3"); + + // 방 생성 + testRoom1 = Room.create( + "테스트 방 1", + "테스트 설명", + false, + null, + 10, + user1, + null, + true, + null + ); + testEntityManager.persist(testRoom1); + + testRoom2 = Room.create( + "테스트 방 2", + "테스트 설명", + false, + null, + 10, + user1, + null, + true, + null + ); + testEntityManager.persist(testRoom2); + + // 아바타 생성 + avatar1 = createAvatar("검은 고양이", 1); + avatar2 = createAvatar("하얀 고양이", 2); + avatar3 = createAvatar("노란 고양이", 3); + + testEntityManager.flush(); + testEntityManager.clear(); + } + + private User createUser(String username, String email, String nickname) { + User user = User.builder() + .username(username) + .email(email) + .password("password123") + .role(Role.USER) + .userStatus(UserStatus.ACTIVE) + .build(); + + UserProfile profile = new UserProfile(); + profile.setNickname(nickname); + user.setUserProfile(profile); + + testEntityManager.persist(user); + return user; + } + + private Avatar createAvatar(String name, int sortOrder) { + Avatar avatar = Avatar.builder() + .name(name) + .imageUrl("/images/avatars/" + name + ".png") + .description(name) + .isDefault(true) + .sortOrder(sortOrder) + .category("CAT") + .build(); + testEntityManager.persist(avatar); + return avatar; + } + + @Test + @DisplayName("방별 아바타 설정 저장 및 조회") + void saveAndFindByRoomIdAndUserId() { + // given + RoomMemberAvatar roomAvatar = RoomMemberAvatar.builder() + .room(testRoom1) + .user(user1) + .selectedAvatar(avatar1) + .build(); + + // when + testEntityManager.persist(roomAvatar); + testEntityManager.flush(); + testEntityManager.clear(); + + Optional found = roomMemberAvatarRepository + .findByRoomIdAndUserId(testRoom1.getId(), user1.getId()); + + // then + assertThat(found).isPresent(); + assertThat(found.get().getSelectedAvatar().getName()).isEqualTo("검은 고양이"); + assertThat(found.get().getRoom().getId()).isEqualTo(testRoom1.getId()); + assertThat(found.get().getUser().getId()).isEqualTo(user1.getId()); + } + + @Test + @DisplayName("같은 방에서 여러 사용자의 아바타 일괄 조회 (N+1 방지)") + void findByRoomIdAndUserIdIn() { + // given + RoomMemberAvatar avatar1Setting = RoomMemberAvatar.builder() + .room(testRoom1) + .user(user1) + .selectedAvatar(avatar1) + .build(); + testEntityManager.persist(avatar1Setting); + + RoomMemberAvatar avatar2Setting = RoomMemberAvatar.builder() + .room(testRoom1) + .user(user2) + .selectedAvatar(avatar2) + .build(); + testEntityManager.persist(avatar2Setting); + + RoomMemberAvatar avatar3Setting = RoomMemberAvatar.builder() + .room(testRoom1) + .user(user3) + .selectedAvatar(avatar3) + .build(); + testEntityManager.persist(avatar3Setting); + + testEntityManager.flush(); + testEntityManager.clear(); + + // when + Set userIds = Set.of(user1.getId(), user2.getId(), user3.getId()); + List results = roomMemberAvatarRepository + .findByRoomIdAndUserIdIn(testRoom1.getId(), userIds); + + // then + assertThat(results).hasSize(3); + assertThat(results) + .extracting(rma -> rma.getSelectedAvatar().getName()) + .containsExactlyInAnyOrder("검은 고양이", "하얀 고양이", "노란 고양이"); + } + + @Test + @DisplayName("존재하지 않는 사용자 조회 - 빈 리스트 반환") + void findByRoomIdAndUserIdIn_NotFound() { + // when + Set userIds = Set.of(999L, 1000L); + List results = roomMemberAvatarRepository + .findByRoomIdAndUserIdIn(testRoom1.getId(), userIds); + + // then + assertThat(results).isEmpty(); + } + + @Test + @DisplayName("아바타 변경 (업데이트)") + void updateSelectedAvatar() { + // given + RoomMemberAvatar roomAvatar = RoomMemberAvatar.builder() + .room(testRoom1) + .user(user1) + .selectedAvatar(avatar1) + .build(); + testEntityManager.persist(roomAvatar); + testEntityManager.flush(); + testEntityManager.clear(); + + // when + RoomMemberAvatar found = roomMemberAvatarRepository + .findByRoomIdAndUserId(testRoom1.getId(), user1.getId()) + .orElseThrow(); + found.setSelectedAvatar(avatar2); + testEntityManager.persist(found); + testEntityManager.flush(); + testEntityManager.clear(); + + // then + RoomMemberAvatar updated = roomMemberAvatarRepository + .findByRoomIdAndUserId(testRoom1.getId(), user1.getId()) + .orElseThrow(); + + assertThat(updated.getSelectedAvatar().getName()).isEqualTo("하얀 고양이"); + } + + @Test + @DisplayName("사용자가 여러 방에서 각기 다른 아바타 설정") + void userCanHaveDifferentAvatarsInDifferentRooms() { + // given + RoomMemberAvatar room1Avatar = RoomMemberAvatar.builder() + .room(testRoom1) + .user(user1) + .selectedAvatar(avatar1) + .build(); + testEntityManager.persist(room1Avatar); + + RoomMemberAvatar room2Avatar = RoomMemberAvatar.builder() + .room(testRoom2) + .user(user1) + .selectedAvatar(avatar3) + .build(); + testEntityManager.persist(room2Avatar); + + testEntityManager.flush(); + testEntityManager.clear(); + + // when + RoomMemberAvatar found1 = roomMemberAvatarRepository + .findByRoomIdAndUserId(testRoom1.getId(), user1.getId()) + .orElseThrow(); + + RoomMemberAvatar found2 = roomMemberAvatarRepository + .findByRoomIdAndUserId(testRoom2.getId(), user1.getId()) + .orElseThrow(); + + // then + assertThat(found1.getSelectedAvatar().getName()).isEqualTo("검은 고양이"); + assertThat(found2.getSelectedAvatar().getName()).isEqualTo("노란 고양이"); + } + + @Test + @DisplayName("아바타 설정 삭제") + void deleteRoomMemberAvatar() { + // given + RoomMemberAvatar roomAvatar = RoomMemberAvatar.builder() + .room(testRoom1) + .user(user1) + .selectedAvatar(avatar1) + .build(); + testEntityManager.persist(roomAvatar); + testEntityManager.flush(); + + // when + roomMemberAvatarRepository.delete(roomAvatar); + testEntityManager.flush(); + testEntityManager.clear(); + + // then + Optional found = roomMemberAvatarRepository + .findByRoomIdAndUserId(testRoom1.getId(), user1.getId()); + + assertThat(found).isEmpty(); + } + + @Test + @DisplayName("Fetch Join으로 아바타 정보 한 번에 조회 (N+1 방지)") + void fetchJoinTest() { + // given + RoomMemberAvatar avatar1Setting = RoomMemberAvatar.builder() + .room(testRoom1) + .user(user1) + .selectedAvatar(avatar1) + .build(); + testEntityManager.persist(avatar1Setting); + + RoomMemberAvatar avatar2Setting = RoomMemberAvatar.builder() + .room(testRoom1) + .user(user2) + .selectedAvatar(avatar2) + .build(); + testEntityManager.persist(avatar2Setting); + + testEntityManager.flush(); + testEntityManager.clear(); + + // when + Set userIds = Set.of(user1.getId(), user2.getId()); + List results = roomMemberAvatarRepository + .findByRoomIdAndUserIdIn(testRoom1.getId(), userIds); + + // then + assertThat(results).hasSize(2); + results.forEach(rma -> { + assertThat(rma.getSelectedAvatar()).isNotNull(); + assertThat(rma.getSelectedAvatar().getName()).isNotBlank(); + }); + } +} From 8761b03697ebd518c67347142179287995f6078f Mon Sep 17 00:00:00 2001 From: loseminho Date: Mon, 13 Oct 2025 12:55:24 +0900 Subject: [PATCH 17/17] =?UTF-8?q?refactor:=20=ED=94=84=EB=A1=A0=ED=8A=B8?= =?UTF-8?q?=EC=97=94=EB=93=9C=20=EC=9A=94=EC=B2=AD=20=EC=82=AC=ED=95=AD?= =?UTF-8?q?=EC=97=90=20=EB=94=B0=EB=A5=B8=20=EC=8A=A4=ED=84=B0=EB=94=94?= =?UTF-8?q?=EB=A3=B8=20=EC=A1=B0=ED=9A=8C=20=EB=A7=88=EC=8A=A4=ED=82=B9=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../studyroom/controller/RoomController.java | 33 ++++++++----------- .../repository/RoomRepositoryImpl.java | 6 ++-- .../domain/studyroom/service/RoomService.java | 15 ++------- .../back/global/security/SecurityConfig.java | 6 ++++ .../global/security/user/CurrentUser.java | 15 +++++++++ .../controller/RoomControllerTest.java | 4 +-- .../studyroom/service/RoomServiceTest.java | 18 ++++++---- 7 files changed, 53 insertions(+), 44 deletions(-) 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 99389c7f..6d43d576 100644 --- a/src/main/java/com/back/domain/studyroom/controller/RoomController.java +++ b/src/main/java/com/back/domain/studyroom/controller/RoomController.java @@ -130,11 +130,10 @@ public ResponseEntity> leaveRoom( @GetMapping("/all") @Operation( summary = "모든 방 목록 조회", - description = "공개 방과 비공개 방 전체를 조회합니다. 비공개 방은 제목과 방장 정보가 마스킹됩니다. 열린 방(WAITING, ACTIVE)이 우선 표시되고, 닫힌 방(PAUSED, TERMINATED)은 뒤로 밀립니다." + description = "공개 방과 비공개 방 전체를 조회합니다. 열린 방(WAITING, ACTIVE)이 우선 표시되고, 닫힌 방(PAUSED, TERMINATED)은 뒤로 밀립니다. 비로그인 사용자도 조회 가능합니다." ) @ApiResponses({ - @ApiResponse(responseCode = "200", description = "조회 성공"), - @ApiResponse(responseCode = "401", description = "인증 실패") + @ApiResponse(responseCode = "200", description = "조회 성공") }) public ResponseEntity>> getAllRooms( @Parameter(description = "페이지 번호 (0부터 시작)") @RequestParam(defaultValue = "0") int page, @@ -143,8 +142,8 @@ public ResponseEntity>> getAllRooms( Pageable pageable = PageRequest.of(page, size); Page rooms = roomService.getAllRooms(pageable); - // 비공개 방 마스킹 포함한 변환 - List roomList = roomService.toRoomResponseListWithMasking(rooms.getContent()); + // 모든 정보 공개 + List roomList = roomService.toRoomResponseList(rooms.getContent()); Map response = new HashMap<>(); response.put("rooms", roomList); @@ -162,11 +161,10 @@ public ResponseEntity>> getAllRooms( @GetMapping("/public") @Operation( summary = "공개 방 목록 조회", - description = "공개 방 전체를 조회합니다. includeInactive=true로 설정하면 닫힌 방도 포함됩니다 (기본값: true). 열린 방이 우선 표시됩니다." + description = "공개 방 전체를 조회합니다. includeInactive=true로 설정하면 닫힌 방도 포함됩니다 (기본값: true). 열린 방이 우선 표시됩니다. 비로그인 사용자도 조회 가능합니다." ) @ApiResponses({ - @ApiResponse(responseCode = "200", description = "조회 성공"), - @ApiResponse(responseCode = "401", description = "인증 실패") + @ApiResponse(responseCode = "200", description = "조회 성공") }) public ResponseEntity>> getPublicRooms( @Parameter(description = "페이지 번호 (0부터 시작)") @RequestParam(defaultValue = "0") int page, @@ -261,11 +259,10 @@ public ResponseEntity>> getMyHostingRooms( @GetMapping @Operation( summary = "입장 가능한 공개 방 목록 조회 (기존)", - description = "입장 가능한 공개 스터디 룸 목록을 페이징하여 조회합니다. 최신 생성 순으로 정렬됩니다." + description = "입장 가능한 공개 스터디 룸 목록을 페이징하여 조회합니다. 최신 생성 순으로 정렬됩니다. 비로그인 사용자도 조회 가능합니다." ) @ApiResponses({ - @ApiResponse(responseCode = "200", description = "조회 성공"), - @ApiResponse(responseCode = "401", description = "인증 실패") + @ApiResponse(responseCode = "200", description = "조회 성공") }) public ResponseEntity>> getRooms( @Parameter(description = "페이지 번호 (0부터 시작)") @RequestParam(defaultValue = "0") int page, @@ -292,18 +289,17 @@ public ResponseEntity>> getRooms( @GetMapping("/{roomId}") @Operation( summary = "방 상세 정보 조회", - description = "특정 방의 상세 정보와 현재 온라인 멤버 목록을 조회합니다. 비공개 방은 멤버만 조회 가능합니다." + description = "특정 방의 상세 정보와 현재 온라인 멤버 목록을 조회합니다. 비로그인 사용자도 조회 가능합니다." ) @ApiResponses({ @ApiResponse(responseCode = "200", description = "조회 성공"), - @ApiResponse(responseCode = "403", description = "비공개 방에 대한 접근 권한 없음"), - @ApiResponse(responseCode = "404", description = "존재하지 않는 방"), - @ApiResponse(responseCode = "401", description = "인증 실패") + @ApiResponse(responseCode = "404", description = "존재하지 않는 방") }) public ResponseEntity> getRoomDetail( @Parameter(description = "방 ID", required = true) @PathVariable Long roomId) { - Long currentUserId = currentUser.getUserId(); + // 비로그인 사용자는 userId = null로 처리 + Long currentUserId = currentUser.getUserIdOrNull(); Room room = roomService.getRoomDetail(roomId, currentUserId); List members = roomService.getRoomMembers(roomId, currentUserId); @@ -522,11 +518,10 @@ public ResponseEntity>> getRoomMembers( @GetMapping("/popular") @Operation( summary = "인기 방 목록 조회", - description = "참가자 수가 많은 인기 방 목록을 페이징하여 조회합니다. 참가자 수 내림차순에서 최신순으로 정렬됩니다." + description = "참가자 수가 많은 인기 방 목록을 페이징하여 조회합니다. 공개방과 비공개방 모두 포함됩니다. 참가자 수 내림차순에서 최신순으로 정렬됩니다. 비로그인 사용자도 조회 가능합니다." ) @ApiResponses({ - @ApiResponse(responseCode = "200", description = "조회 성공"), - @ApiResponse(responseCode = "401", description = "인증 실패") + @ApiResponse(responseCode = "200", description = "조회 성공") }) public ResponseEntity>> getPopularRooms( @Parameter(description = "페이지 번호 (0부터 시작)") @RequestParam(defaultValue = "0") int page, 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 26e76819..5a67ac3c 100644 --- a/src/main/java/com/back/domain/studyroom/repository/RoomRepositoryImpl.java +++ b/src/main/java/com/back/domain/studyroom/repository/RoomRepositoryImpl.java @@ -157,13 +157,13 @@ public Page findRoomsWithFilters(String title, RoomStatus status, Boolean } /** - * 인기 방 조회 (참가자 수 기준) + * 인기 방 조회 (참가자 수 기준) - 공개+비공개 포함 * * 참고: 참가자 수는 Redis에서 조회하므로 DB에서는 정렬 불가 * 서비스 레이어에서 Redis 데이터로 정렬 필요 * * 조회 조건: - * - 공개 방만 (isPrivate = false) + * - 공개 방 + 비공개 방 모두 포함 * - 활성화된 방만 (isActive = true) * @param pageable 페이징 정보 * @return 페이징된 방 목록 (최신순 정렬) @@ -174,7 +174,6 @@ public Page findPopularRooms(Pageable pageable) { .selectFrom(room) .leftJoin(room.createdBy, user).fetchJoin() // N+1 방지 .where( - room.isPrivate.eq(false), room.isActive.eq(true) ) .orderBy(room.createdAt.desc()) // 최신순 (서비스에서 Redis 기반으로 재정렬) @@ -187,7 +186,6 @@ public Page findPopularRooms(Pageable pageable) { .select(room.count()) .from(room) .where( - room.isPrivate.eq(false), room.isActive.eq(true) ) .fetchOne(); 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 0c389550..460b64e7 100644 --- a/src/main/java/com/back/domain/studyroom/service/RoomService.java +++ b/src/main/java/com/back/domain/studyroom/service/RoomService.java @@ -257,12 +257,8 @@ public Room getRoomDetail(Long roomId, Long userId) { Room room = roomRepository.findById(roomId) .orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND)); - if (room.isPrivate()) { - boolean isMember = roomMemberRepository.existsByRoomIdAndUserId(roomId, userId); - if (!isMember) { - throw new CustomException(ErrorCode.ROOM_FORBIDDEN); - } - } + // ⭐ 비공개 방 접근 제한 제거 - 모든 사용자가 조회 가능 + // (프론트엔드에서 입장 시 로그인 체크) return room; } @@ -534,12 +530,7 @@ public List getRoomMembers(Long roomId, Long userId) { Room room = roomRepository.findById(roomId) .orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND)); - if (room.isPrivate()) { - boolean isMember = roomMemberRepository.existsByRoomIdAndUserId(roomId, userId); - if (!isMember) { - throw new CustomException(ErrorCode.ROOM_FORBIDDEN); - } - } + // ⭐ 비공개 방 접근 제한 제거 - 모든 사용자가 조회 가능 // 1. Redis에서 온라인 사용자 ID 조회 Set onlineUserIds = roomParticipantService.getParticipants(roomId); diff --git a/src/main/java/com/back/global/security/SecurityConfig.java b/src/main/java/com/back/global/security/SecurityConfig.java index fdd13498..da5b6915 100644 --- a/src/main/java/com/back/global/security/SecurityConfig.java +++ b/src/main/java/com/back/global/security/SecurityConfig.java @@ -42,6 +42,12 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers("api/ws/**", "/ws/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/posts/**").permitAll() .requestMatchers("/api/rooms/*/messages/**").permitAll() //스터디 룸 내에 잡혀있어 있는 채팅 관련 전체 허용 + // 방 목록 조회 API 비로그인 허용 + .requestMatchers(HttpMethod.GET, "/api/rooms").permitAll() + .requestMatchers(HttpMethod.GET, "/api/rooms/all").permitAll() + .requestMatchers(HttpMethod.GET, "/api/rooms/public").permitAll() + .requestMatchers(HttpMethod.GET, "/api/rooms/popular").permitAll() + .requestMatchers(HttpMethod.GET, "/api/rooms/*").permitAll() // 방 상세 조회 //.requestMatchers("/api/rooms/RoomChatApiControllerTest").permitAll() // 테스트용 임시 허용 .requestMatchers("/","/swagger-ui/**", "/v3/api-docs/**").permitAll() // Swagger 허용 .requestMatchers("/h2-console/**").permitAll() // H2 Console 허용 diff --git a/src/main/java/com/back/global/security/user/CurrentUser.java b/src/main/java/com/back/global/security/user/CurrentUser.java index c97deab8..0fd00942 100644 --- a/src/main/java/com/back/global/security/user/CurrentUser.java +++ b/src/main/java/com/back/global/security/user/CurrentUser.java @@ -30,6 +30,21 @@ public boolean isAuthenticated() { } public Long getUserId() { return getDetails().getUserId(); } + + /** + * 현재 사용자 ID 조회 (비로그인 시 null 반환) + * 비로그인 접근이 허용되는 API에서 사용 + */ + public Long getUserIdOrNull() { + try { + return getDetails().getUserId(); + } catch (CustomException e) { + if (e.getErrorCode() == ErrorCode.UNAUTHORIZED) { + return null; + } + throw e; + } + } public String getUsername() { return getDetails().getUsername(); } diff --git a/src/test/java/com/back/domain/studyroom/controller/RoomControllerTest.java b/src/test/java/com/back/domain/studyroom/controller/RoomControllerTest.java index 26ab1961..edb98059 100644 --- a/src/test/java/com/back/domain/studyroom/controller/RoomControllerTest.java +++ b/src/test/java/com/back/domain/studyroom/controller/RoomControllerTest.java @@ -208,7 +208,7 @@ void getRooms() { @DisplayName("방 상세 정보 조회 API 테스트 - JWT 인증") void getRoomDetail() { // given - given(currentUser.getUserId()).willReturn(1L); + given(currentUser.getUserIdOrNull()).willReturn(1L); given(roomService.getRoomDetail(eq(1L), eq(1L))).willReturn(testRoom); given(roomService.getRoomMembers(eq(1L), eq(1L))).willReturn(Arrays.asList(testMember)); @@ -229,7 +229,7 @@ void getRoomDetail() { assertThat(response.getBody().isSuccess()).isTrue(); assertThat(response.getBody().getData().getTitle()).isEqualTo("테스트 방"); - verify(currentUser, times(1)).getUserId(); + verify(currentUser, times(1)).getUserIdOrNull(); verify(roomService, times(1)).getRoomDetail(eq(1L), eq(1L)); verify(roomService, times(1)).getRoomMembers(eq(1L), eq(1L)); verify(roomService, times(1)).toRoomDetailResponse(any(Room.class), anyList()); 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 815ac8e1..d9cf5b83 100644 --- a/src/test/java/com/back/domain/studyroom/service/RoomServiceTest.java +++ b/src/test/java/com/back/domain/studyroom/service/RoomServiceTest.java @@ -258,8 +258,8 @@ void getRoomDetail_Success() { } @Test - @DisplayName("방 상세 정보 조회 - 비공개 방 권한 없음") - void getRoomDetail_PrivateRoomForbidden() { + @DisplayName("방 상세 정보 조회 - 비공개 방도 조회 가능") + void getRoomDetail_PrivateRoomAllowed() { // given Room privateRoom = Room.create( "비공개 방", @@ -273,12 +273,16 @@ void getRoomDetail_PrivateRoomForbidden() { null // thumbnailUrl ); given(roomRepository.findById(1L)).willReturn(Optional.of(privateRoom)); - given(roomMemberRepository.existsByRoomIdAndUserId(1L, 2L)).willReturn(false); + // ⭐ 비공개 방 접근 제한 제거되었으므로 권한 체크 안 함 - // when & then - assertThatThrownBy(() -> roomService.getRoomDetail(1L, 2L)) - .isInstanceOf(CustomException.class) - .hasFieldOrPropertyWithValue("errorCode", ErrorCode.ROOM_FORBIDDEN); + // when + Room result = roomService.getRoomDetail(1L, 2L); + + // then + assertThat(result).isNotNull(); + assertThat(result.getTitle()).isEqualTo("비공개 방"); + assertThat(result.isPrivate()).isTrue(); + verify(roomMemberRepository, never()).existsByRoomIdAndUserId(any(), any()); } @Test