From ceb3b94c41d6bb3e062900062358464dc233fe99 Mon Sep 17 00:00:00 2001 From: loseminho Date: Wed, 1 Oct 2025 19:51:43 +0900 Subject: [PATCH 1/3] =?UTF-8?q?refactor:=20RoomMember=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=EC=97=90=EC=84=9C=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=ED=95=84=EB=93=9C=20=EC=A0=9C=EA=B1=B0,?= =?UTF-8?q?=20redis=EB=A1=9C=20=EC=9D=B4=EA=B4=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../studyroom/dto/RoomMemberResponse.java | 8 +- .../domain/studyroom/entity/RoomMember.java | 88 ++++-------- .../repository/RoomMemberRepository.java | 5 +- .../RoomMemberRepositoryCustom.java | 12 ++ .../repository/RoomMemberRepositoryImpl.java | 125 ++++-------------- .../studyroom/repository/RoomRepository.java | 4 +- .../repository/RoomRepositoryImpl.java | 11 +- .../domain/studyroom/service/RoomService.java | 22 ++- .../webrtc/service/WebRTCSignalValidator.java | 9 +- .../studyroom/service/RoomServiceTest.java | 6 +- .../service/WebRTCSignalValidatorTest.java | 87 ++---------- 11 files changed, 103 insertions(+), 274 deletions(-) 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 2d12c824..a6f0aa21 100644 --- a/src/main/java/com/back/domain/studyroom/dto/RoomMemberResponse.java +++ b/src/main/java/com/back/domain/studyroom/dto/RoomMemberResponse.java @@ -13,18 +13,18 @@ public class RoomMemberResponse { private Long userId; private String nickname; private RoomRole role; - private boolean isOnline; private LocalDateTime joinedAt; - private LocalDateTime lastActiveAt; + private LocalDateTime promotedAt; + + // TODO: isOnline은 Redis에서 조회하여 추가 예정 public static RoomMemberResponse from(RoomMember member) { return RoomMemberResponse.builder() .userId(member.getUser().getId()) .nickname(member.getUser().getNickname()) .role(member.getRole()) - .isOnline(member.isOnline()) .joinedAt(member.getJoinedAt()) - .lastActiveAt(member.getLastActiveAt() != null ? member.getLastActiveAt() : member.getJoinedAt()) + .promotedAt(member.getPromotedAt()) .build(); } } diff --git a/src/main/java/com/back/domain/studyroom/entity/RoomMember.java b/src/main/java/com/back/domain/studyroom/entity/RoomMember.java index e442c692..929425e9 100644 --- a/src/main/java/com/back/domain/studyroom/entity/RoomMember.java +++ b/src/main/java/com/back/domain/studyroom/entity/RoomMember.java @@ -41,16 +41,9 @@ public class RoomMember extends BaseEntity { // 멤버십 기본 정보 @Column(nullable = false) - private LocalDateTime joinedAt; // 방에 처음 입장한 시간 - private LocalDateTime lastActiveAt; // 마지막으로 활동한 시간 - - // 실시간 상태 관리 필드들 - @Column(nullable = false) - private boolean isOnline = false; // 현재 방에 온라인 상태인지 - - private String connectionId; // WebSocket 연결 ID (실시간 통신용) - - private LocalDateTime lastHeartbeat; // 마지막 heartbeat 시간 (연결 상태 확인용) + private LocalDateTime joinedAt; // MEMBER 이상으로 승격된 시간 + + private LocalDateTime promotedAt; // 권한이 변경된 시간 // 💡 권한 확인 메서드들 (RoomRole enum의 메서드를 위임) @@ -95,18 +88,20 @@ public boolean isMember() { } /** - * 현재 활성 상태인지 확인 - 온라인 멤버 목록 표시, 비활성 사용자 정리 등 - 온라인 상태이고 최근 설정된 시간 이내에 heartbeat가 있었던 경우 + * 현재 활성 상태인지 확인 (Redis 기반으로 변경 예정) + * 임시로 항상 true 반환 + * TODO: Redis에서 실시간 상태 확인하도록 변경 */ + @Deprecated public boolean isActive(int timeoutMinutes) { - return isOnline && lastHeartbeat != null && - lastHeartbeat.isAfter(LocalDateTime.now().minusMinutes(timeoutMinutes)); + // 실시간 상태는 Redis에서 관리 + return true; } /** - 기본 멤버 생성 메서드, 처음 입장 시 사용 + 기본 멤버 생성 메서드 + MEMBER 이상 등급 생성 시 사용 (DB 저장용) */ public static RoomMember create(Room room, User user, RoomRole role) { RoomMember member = new RoomMember(); @@ -114,9 +109,7 @@ public static RoomMember create(Room room, User user, RoomRole role) { member.user = user; member.role = role; member.joinedAt = LocalDateTime.now(); - member.lastActiveAt = LocalDateTime.now(); - member.isOnline = true; // 생성 시 온라인 상태 - member.lastHeartbeat = LocalDateTime.now(); + member.promotedAt = LocalDateTime.now(); return member; } @@ -128,18 +121,24 @@ public static RoomMember createHost(Room room, User user) { /** * 일반 멤버 생성, 권한 자동 변경 - - 비공개 방에서 초대받은 사용자를 정식 멤버로 등록할 때 (로직 검토 중) + * 비공개 방에서 초대받은 사용자를 정식 멤버로 등록할 때 */ public static RoomMember createMember(Room room, User user) { return create(room, user, RoomRole.MEMBER); } /** - * 방문객 생성 - * 사용 상황: 공개 방에 처음 입장하는 사용자를 임시 방문객으로 등록 + * 방문객 생성 (메모리상으로만 존재, DB 저장 안함) + * 공개 방에 처음 입장하는 사용자용 + * Redis에서 실시간 상태 관리 */ public static RoomMember createVisitor(Room room, User user) { - return create(room, user, RoomRole.VISITOR); + RoomMember member = new RoomMember(); + member.room = room; + member.user = user; + member.role = RoomRole.VISITOR; + member.joinedAt = LocalDateTime.now(); + return member; } /** @@ -148,47 +147,6 @@ public static RoomMember createVisitor(Room room, User user) { */ public void updateRole(RoomRole newRole) { this.role = newRole; - } - - /** - * 온라인 상태 변경 - * 사용 상황: 멤버가 방에 입장하거나 퇴장할 때 - 활동 시간도 함께 업데이트, 온라인이 되면 heartbeat도 갱신 - */ - public void updateOnlineStatus(boolean online) { - this.isOnline = online; - this.lastActiveAt = LocalDateTime.now(); - if (online) { - this.lastHeartbeat = LocalDateTime.now(); - } - } - - /** - * WebSocket 연결 ID 업데이트 - * 사용 상황: 멤버가 웹소켓으로 방에 연결될 때 - + heartbeat도 함께 갱신 - */ - public void updateConnectionId(String connectionId) { - this.connectionId = connectionId; - this.lastHeartbeat = LocalDateTime.now(); - } - - /** - * 사용 : 클라이언트에서 주기적으로 서버에 연결 상태를 알릴 때 - * 목적: 연결이 끊어진 멤버를 자동으로 감지하기 위해 사용, 별도의 다른 것으로 변경 가능 - */ - public void heartbeat() { - this.lastHeartbeat = LocalDateTime.now(); - this.lastActiveAt = LocalDateTime.now(); - this.isOnline = true; - } - - /** - * 방 퇴장 처리 (명시적 퇴장과 연결 끊김 상태 로직 분할 예정임.. 일단은 임시로 통합 상태) - 멤버가 방을 나가거나 연결이 끊어졌을 때, 오프라인 상태로 변경하고 연결 ID 제거 - */ - public void leave() { - this.isOnline = false; - this.connectionId = null; + this.promotedAt = LocalDateTime.now(); } } diff --git a/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepository.java b/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepository.java index 213f3890..367d8d9d 100644 --- a/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepository.java +++ b/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepository.java @@ -8,8 +8,5 @@ @Repository public interface RoomMemberRepository extends JpaRepository, RoomMemberRepositoryCustom { - /** - * WebSocket 연결 ID로 멤버 조회 - */ - Optional findByConnectionId(String connectionId); + // 모든 메서드는 RoomMemberRepositoryCustom 인터페이스로 이동 } 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 7740e0d1..a6732eb6 100644 --- a/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepositoryCustom.java +++ b/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepositoryCustom.java @@ -20,16 +20,22 @@ public interface RoomMemberRepositoryCustom { /** * 방의 온라인 멤버 조회 + * TODO: Redis 기반으로 변경 예정 + * 현재는 DB에 저장된 모든 멤버 반환 (임시) */ + @Deprecated List findOnlineMembersByRoomId(Long roomId); /** * 방의 활성 멤버 수 조회 + * TODO: Redis 기반으로 변경 예정 */ + @Deprecated int countActiveMembersByRoomId(Long roomId); /** * 사용자가 참여 중인 모든 방의 멤버십 조회 + * DB에 저장된 멤버십만 조회 (MEMBER 이상) */ List findActiveByUserId(Long userId); @@ -60,16 +66,22 @@ public interface RoomMemberRepositoryCustom { /** * 특정 역할의 멤버 수 조회 + * 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 81bd17a7..8c8761e4 100644 --- a/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepositoryImpl.java +++ b/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepositoryImpl.java @@ -74,48 +74,39 @@ public List findByRoomIdOrderByRole(Long roomId) { /** * 방의 온라인 멤버 조회 - * - 현재 온라인 상태인 멤버만 (isOnline = true) - * - 1순위: 역할 (HOST > SUB_HOST > MEMBER > VISITOR) - * - 2순위: 마지막 활동 시간 (최근 활동 순) - * - 방 상세 페이지에서 현재 접속 중인 멤버 표시 - * - 실시간 멤버 목록 업데이트 + * TODO: Redis 기반으로 변경 예정 + * 현재는 방의 모든 멤버 반환 (임시) * @param roomId 방 ID - * @return 온라인 멤버 목록 + * @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), - roomMember.isOnline.eq(true) - ) + .where(roomMember.room.id.eq(roomId)) .orderBy( - roomMember.role.asc(), // 역할순 - roomMember.lastActiveAt.desc() // 최근 활동순 + roomMember.role.asc(), // 역할순 + roomMember.joinedAt.asc() // 입장 시간순 ) .fetch(); } /** * 방의 활성 멤버 수 조회 - * - 현재 온라인 상태인 멤버 (isOnline = true) - * - 방 목록에서 현재 참가자 수 표시 - * - 정원 체크 (현재 참가자 vs 최대 참가자) - * - 통계 데이터 수집 로직 구현 시 연결 해야함.. + * TODO: Redis 기반으로 변경 예정 + * 현재는 방의 모든 멤버 수 반환 (임시) * @param roomId 방 ID - * @return 활성 멤버 수 + * @return 멤버 수 */ @Override + @Deprecated public int countActiveMembersByRoomId(Long roomId) { Long count = queryFactory .select(roomMember.count()) .from(roomMember) - .where( - roomMember.room.id.eq(roomId), - roomMember.isOnline.eq(true) - ) + .where(roomMember.room.id.eq(roomId)) .fetchOne(); return count != null ? count.intValue() : 0; @@ -123,17 +114,15 @@ public int countActiveMembersByRoomId(Long roomId) { /** * 사용자가 참여 중인 모든 방의 멤버십 조회 + * DB에 저장된 멤버십만 조회 (MEMBER 이상) * @param userId 사용자 ID - * @return 참여 중인 방의 멤버십 목록 + * @return 멤버십 목록 */ @Override public List findActiveByUserId(Long userId) { return queryFactory .selectFrom(roomMember) - .where( - roomMember.user.id.eq(userId), - roomMember.isOnline.eq(true) - ) + .where(roomMember.user.id.eq(userId)) .fetch(); } @@ -267,29 +256,21 @@ public boolean existsByRoomIdAndUserId(Long roomId, Long userId) { } /** - * 특정 역할의 온라인 멤버 수 조회 - * - 특정 역할의 멤버 - * - 현재 온라인 상태만 - * 예시: - * ```java - * int hostCount = countByRoomIdAndRole(roomId, RoomRole.HOST); - * if (hostCount == 0) { - * - * } - * ``` + * 특정 역할의 멤버 수 조회 + * TODO: Redis 기반으로 변경 예정 * @param roomId 방 ID * @param role 역할 - * @return 해당 역할의 온라인 멤버 수 + * @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), - roomMember.isOnline.eq(true) + roomMember.role.eq(role) ) .fetchOne(); @@ -298,75 +279,27 @@ public int countByRoomIdAndRole(Long roomId, RoomRole role) { /** * 방 퇴장 처리 (벌크 업데이트) - * - isOnline을 false로 변경 - * - connectionId를 null로 초기화 - * - * ai 코드 리뷰 결과 : - * - 한 번의 쿼리로 처리하여 성능 최적화 상태 - * - 벌크 연산은 영속성 컨텍스트를 무시 - * - 이후 해당 엔티티를 조회하면 DB와 불일치 가능 - * - 필요시 em.clear() 또는 em.refresh() 사용 - * ( 추후 기초 기능 개발 완료 후 개선 예정) - * - * - 사용자가 명시적으로 방을 나갈 때 - * - WebSocket 연결 끊김 감지 시 - * - 타임아웃으로 자동 퇴장 처리 시 + * TODO: Redis로 이관 예정 + * 현재는 아무 동작 안함 (DB에는 멤버십 유지) * @param roomId 방 ID * @param userId 사용자 ID */ @Override + @Deprecated public void leaveRoom(Long roomId, Long userId) { - queryFactory - .update(roomMember) - .set(roomMember.isOnline, false) - .setNull(roomMember.connectionId) - .where( - roomMember.room.id.eq(roomId), - roomMember.user.id.eq(userId) - ) - .execute(); + // Redis로 이관 예정 - 현재는 아무 동작 안함 + // DB의 멤버십은 유지됨 } /** * 방의 모든 멤버를 오프라인 처리 (방 종료 시) - * - 해당 방의 모든 멤버를 오프라인으로 변경 - * - 모든 멤버의 connectionId 제거 - * - * - 방장이 방을 종료할 때 - * - 방이 자동으로 종료될 때 (참가자 0명 + 일정 시간 경과) - * - 긴급 상황으로 방을 강제 종료할 때 - * - * ai 코드 리뷰 결과 : - * 해당 부분도 쿼리 한번으로 동작되는 내용이기 때문에, - * 그렇게 동작 시에는 웹소켓에 미리 종료 알림을 주는 형식으로 변경하라고 함. - * 이 작업 후 방 상태를 TERMINATED로 변경해야 함 - * - * 사용 예시: - * ```java - * @Transactional - * public void terminateRoom(Long roomId) { - * Room room = roomRepository.findById(roomId)...; - * - * // 모든 멤버 오프라인 처리 - * roomMemberRepository.disconnectAllMembers(roomId); - * - * // 방 종료 - * room.terminate(); - * - * // WebSocket으로 종료 알림 - * notifyRoomTermination(roomId); - * } - * ``` - * + * TODO: Redis로 이관 예정 + * 현재는 아무 동작 안함 * @param roomId 방 ID */ @Override + @Deprecated public void disconnectAllMembers(Long roomId) { - queryFactory - .update(roomMember) - .set(roomMember.isOnline, false) - .setNull(roomMember.connectionId) - .where(roomMember.room.id.eq(roomId)) - .execute(); + // 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 dea97567..9c11e14f 100644 --- a/src/main/java/com/back/domain/studyroom/repository/RoomRepository.java +++ b/src/main/java/com/back/domain/studyroom/repository/RoomRepository.java @@ -39,9 +39,11 @@ public interface RoomRepository extends JpaRepository, RoomRepositor 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 AND rm.isOnline = true) " + + "(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/repository/RoomRepositoryImpl.java b/src/main/java/com/back/domain/studyroom/repository/RoomRepositoryImpl.java index bf0e81c2..d9ab9ac0 100644 --- a/src/main/java/com/back/domain/studyroom/repository/RoomRepositoryImpl.java +++ b/src/main/java/com/back/domain/studyroom/repository/RoomRepositoryImpl.java @@ -76,9 +76,9 @@ public Page findJoinablePublicRooms(Pageable pageable) { /** * 사용자가 참여 중인 방 조회 - * 조회 조건: - * - 특정 사용자가 멤버로 등록된 방 - * - 현재 온라인 상태인 방만 + * 조회 조건: + * - 특정 사용자가 멤버로 등록된 방 (DB에 저장된 멤버십) + * TODO: Redis에서 온라인 상태 확인하도록 변경 * @param userId 사용자 ID * @return 참여 중인 방 목록 */ @@ -88,10 +88,7 @@ public List findRoomsByUserId(Long userId) { .selectFrom(room) .leftJoin(room.createdBy, user).fetchJoin() // N+1 방지 .join(room.roomMembers, roomMember) // 멤버 조인 - .where( - roomMember.user.id.eq(userId), - roomMember.isOnline.eq(true) - ) + .where(roomMember.user.id.eq(userId)) .fetch(); } 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 c7d021e4..b6cfb956 100644 --- a/src/main/java/com/back/domain/studyroom/service/RoomService.java +++ b/src/main/java/com/back/domain/studyroom/service/RoomService.java @@ -123,10 +123,8 @@ public RoomMember joinRoom(Long roomId, String password, Long userId) { Optional existingMember = roomMemberRepository.findByRoomIdAndUserId(roomId, userId); if (existingMember.isPresent()) { RoomMember member = existingMember.get(); - if (member.isOnline()) { - throw new CustomException(ErrorCode.ALREADY_JOINED_ROOM); - } - member.updateOnlineStatus(true); + // TODO: Redis에서 온라인 여부 확인하도록 변경 + // 현재는 기존 멤버 재입장 허용 room.incrementParticipant(); return member; } @@ -162,14 +160,12 @@ public void leaveRoom(Long roomId, Long userId) { RoomMember member = roomMemberRepository.findByRoomIdAndUserId(roomId, userId) .orElseThrow(() -> new CustomException(ErrorCode.NOT_ROOM_MEMBER)); - if (!member.isOnline()) { - return; - } + // TODO: Redis에서 온라인 상태 확인하도록 변경 if (member.isHost()) { handleHostLeaving(room, member); } else { - member.leave(); + // TODO: Redis에서 제거하도록 변경 room.decrementParticipant(); } @@ -177,6 +173,7 @@ public void leaveRoom(Long roomId, Long userId) { } private void handleHostLeaving(Room room, RoomMember hostMember) { + // TODO: Redis에서 온라인 멤버 조회하도록 변경 List onlineMembers = roomMemberRepository.findOnlineMembersByRoomId(room.getId()); List otherOnlineMembers = onlineMembers.stream() @@ -185,7 +182,7 @@ private void handleHostLeaving(Room room, RoomMember hostMember) { if (otherOnlineMembers.isEmpty()) { room.terminate(); - hostMember.leave(); + // TODO: Redis에서 제거하도록 변경 room.decrementParticipant(); } else { RoomMember newHost = otherOnlineMembers.stream() @@ -197,7 +194,7 @@ private void handleHostLeaving(Room room, RoomMember hostMember) { if (newHost != null) { newHost.updateRole(RoomRole.HOST); - hostMember.leave(); + // TODO: Redis에서 제거하도록 변경 room.decrementParticipant(); log.info("새 방장 지정 - RoomId: {}, NewHostId: {}", @@ -261,7 +258,8 @@ public void terminateRoom(Long roomId, Long userId) { } room.terminate(); - roomMemberRepository.disconnectAllMembers(roomId); + // TODO: Redis에서 모든 멤버 제거하도록 변경 + // roomMemberRepository.disconnectAllMembers(roomId); log.info("방 종료 완료 - RoomId: {}, UserId: {}", roomId, userId); } @@ -337,7 +335,7 @@ public void kickMember(Long roomId, Long targetUserId, Long requesterId) { throw new CustomException(ErrorCode.CANNOT_KICK_HOST); } - targetMember.leave(); + // TODO: Redis에서 제거하도록 변경 Room room = roomRepository.findById(roomId) .orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND)); diff --git a/src/main/java/com/back/global/websocket/webrtc/service/WebRTCSignalValidator.java b/src/main/java/com/back/global/websocket/webrtc/service/WebRTCSignalValidator.java index 6a8dfd96..e150cdc0 100644 --- a/src/main/java/com/back/global/websocket/webrtc/service/WebRTCSignalValidator.java +++ b/src/main/java/com/back/global/websocket/webrtc/service/WebRTCSignalValidator.java @@ -32,15 +32,17 @@ public void validateSignal(Long roomId, Long fromUserId, Long targetUserId) { } // 2. 발신자가 방에 속해있는지 확인 + // TODO: Redis에서 온라인 상태 확인하도록 변경 Optional fromMember = roomMemberRepository.findByRoomIdAndUserId(roomId, fromUserId); - if (fromMember.isEmpty() || !fromMember.get().isOnline()) { + if (fromMember.isEmpty()) { log.warn("방에 속하지 않은 사용자의 시그널 전송 시도 - roomId: {}, userId: {}", roomId, fromUserId); throw new CustomException(ErrorCode.NOT_ROOM_MEMBER); } // 3. 수신자가 같은 방에 속해있는지 확인 + // TODO: Redis에서 온라인 상태 확인하도록 변경 Optional targetMember = roomMemberRepository.findByRoomIdAndUserId(roomId, targetUserId); - if (targetMember.isEmpty() || !targetMember.get().isOnline()) { + if (targetMember.isEmpty()) { log.warn("수신자가 방에 없거나 오프라인 상태 - roomId: {}, targetUserId: {}", roomId, targetUserId); throw new CustomException(ErrorCode.NOT_ROOM_MEMBER); } @@ -50,9 +52,10 @@ public void validateSignal(Long roomId, Long fromUserId, Long targetUserId) { // 미디어 상태 변경 검증 public void validateMediaStateChange(Long roomId, Long userId) { + // TODO: Redis에서 온라인 상태 확인하도록 변경 Optional member = roomMemberRepository.findByRoomIdAndUserId(roomId, userId); - if (member.isEmpty() || !member.get().isOnline()) { + if (member.isEmpty()) { log.warn("방에 속하지 않은 사용자의 미디어 상태 변경 시도 - roomId: {}, userId: {}", roomId, userId); throw new CustomException(ErrorCode.NOT_ROOM_MEMBER); } 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 4ccd73dd..d7262668 100644 --- a/src/test/java/com/back/domain/studyroom/service/RoomServiceTest.java +++ b/src/test/java/com/back/domain/studyroom/service/RoomServiceTest.java @@ -185,7 +185,7 @@ void joinRoom_WrongPassword() { @DisplayName("방 나가기 - 성공") void leaveRoom_Success() { // given - testMember.updateOnlineStatus(true); + // TODO: Redis 통합 후 온라인 상태 체크 추가 예정 given(roomRepository.findById(1L)).willReturn(Optional.of(testRoom)); given(roomMemberRepository.findByRoomIdAndUserId(1L, 1L)).willReturn(Optional.of(testMember)); @@ -303,7 +303,7 @@ void updateRoomSettings_NotOwner() { void terminateRoom_Success() { // given given(roomRepository.findById(1L)).willReturn(Optional.of(testRoom)); - willDoNothing().given(roomMemberRepository).disconnectAllMembers(1L); + // disconnectAllMembers는 더 이상 호출되지 않음 (Redis로 이관 예정) // when roomService.terminateRoom(1L, 1L); @@ -311,7 +311,7 @@ void terminateRoom_Success() { // then assertThat(testRoom.getStatus()).isEqualTo(RoomStatus.TERMINATED); assertThat(testRoom.isActive()).isFalse(); - verify(roomMemberRepository, times(1)).disconnectAllMembers(1L); + // verify 제거: disconnectAllMembers는 더 이상 호출되지 않음 } @Test diff --git a/src/test/java/com/back/global/websocket/webrtc/service/WebRTCSignalValidatorTest.java b/src/test/java/com/back/global/websocket/webrtc/service/WebRTCSignalValidatorTest.java index fef7bda8..f14b02e8 100644 --- a/src/test/java/com/back/global/websocket/webrtc/service/WebRTCSignalValidatorTest.java +++ b/src/test/java/com/back/global/websocket/webrtc/service/WebRTCSignalValidatorTest.java @@ -51,19 +51,12 @@ void setUp() { User fromUser = mock(User.class); User targetUser = mock(User.class); - // 온라인 멤버들 + // 온라인/오프라인 구분은 Redis로 이관 예정 + // 현재는 멤버 존재 여부만 체크 onlineFromMember = RoomMember.createMember(mockRoom, fromUser); - onlineFromMember.updateOnlineStatus(true); - onlineTargetMember = RoomMember.createMember(mockRoom, targetUser); - onlineTargetMember.updateOnlineStatus(true); - - // 오프라인 멤버들 offlineFromMember = RoomMember.createMember(mockRoom, fromUser); - offlineFromMember.updateOnlineStatus(false); - offlineTargetMember = RoomMember.createMember(mockRoom, targetUser); - offlineTargetMember.updateOnlineStatus(false); } @Nested @@ -114,24 +107,9 @@ void t3() { verify(roomMemberRepository).findByRoomIdAndUserId(roomId, fromUserId); } - @Test - @DisplayName("실패 - 발신자가 오프라인") - void t4() { - // given - given(roomMemberRepository.findByRoomIdAndUserId(roomId, fromUserId)) - .willReturn(Optional.of(offlineFromMember)); - - // when & then - assertThatThrownBy(() -> validator.validateSignal(roomId, fromUserId, targetUserId)) - .isInstanceOf(CustomException.class) - .hasFieldOrPropertyWithValue("errorCode", ErrorCode.NOT_ROOM_MEMBER); - - verify(roomMemberRepository).findByRoomIdAndUserId(roomId, fromUserId); - } - @Test @DisplayName("실패 - 수신자가 방에 없음") - void t5() { + void t4() { // given given(roomMemberRepository.findByRoomIdAndUserId(roomId, fromUserId)) .willReturn(Optional.of(onlineFromMember)); @@ -146,39 +124,6 @@ void t5() { verify(roomMemberRepository).findByRoomIdAndUserId(roomId, fromUserId); verify(roomMemberRepository).findByRoomIdAndUserId(roomId, targetUserId); } - - @Test - @DisplayName("실패 - 수신자가 오프라인") - void t6() { - // given - given(roomMemberRepository.findByRoomIdAndUserId(roomId, fromUserId)) - .willReturn(Optional.of(onlineFromMember)); - given(roomMemberRepository.findByRoomIdAndUserId(roomId, targetUserId)) - .willReturn(Optional.of(offlineTargetMember)); - - // when & then - assertThatThrownBy(() -> validator.validateSignal(roomId, fromUserId, targetUserId)) - .isInstanceOf(CustomException.class) - .hasFieldOrPropertyWithValue("errorCode", ErrorCode.NOT_ROOM_MEMBER); - - verify(roomMemberRepository).findByRoomIdAndUserId(roomId, fromUserId); - verify(roomMemberRepository).findByRoomIdAndUserId(roomId, targetUserId); - } - - @Test - @DisplayName("실패 - 발신자와 수신자 모두 오프라인") - void t7() { - // given - given(roomMemberRepository.findByRoomIdAndUserId(roomId, fromUserId)) - .willReturn(Optional.of(offlineFromMember)); - - // when & then - assertThatThrownBy(() -> validator.validateSignal(roomId, fromUserId, targetUserId)) - .isInstanceOf(CustomException.class) - .hasFieldOrPropertyWithValue("errorCode", ErrorCode.NOT_ROOM_MEMBER); - - verify(roomMemberRepository).findByRoomIdAndUserId(roomId, fromUserId); - } } @Nested @@ -186,8 +131,8 @@ void t7() { class ValidateMediaStateChangeTest { @Test - @DisplayName("정상 - 온라인 멤버") - void t8() { + @DisplayName("정상 - 멤버 존재") + void t5() { // given given(roomMemberRepository.findByRoomIdAndUserId(roomId, fromUserId)) .willReturn(Optional.of(onlineFromMember)); @@ -201,7 +146,7 @@ void t8() { @Test @DisplayName("실패 - 방에 없는 사용자") - void t9() { + void t6() { // given given(roomMemberRepository.findByRoomIdAndUserId(roomId, fromUserId)) .willReturn(Optional.empty()); @@ -215,30 +160,14 @@ void t9() { } @Test - @DisplayName("실패 - 오프라인 사용자") - void t10() { - // given - given(roomMemberRepository.findByRoomIdAndUserId(roomId, fromUserId)) - .willReturn(Optional.of(offlineFromMember)); - - // when & then - assertThatThrownBy(() -> validator.validateMediaStateChange(roomId, fromUserId)) - .isInstanceOf(CustomException.class) - .hasFieldOrPropertyWithValue("errorCode", ErrorCode.NOT_ROOM_MEMBER); - - verify(roomMemberRepository).findByRoomIdAndUserId(roomId, fromUserId); - } - - @Test - @DisplayName("정상 - 다른 방의 온라인 멤버") - void t11() { + @DisplayName("정상 - 다른 방의 멤버") + void t7() { // given Long differentRoomId = 999L; Room differentRoom = mock(Room.class); User user = mock(User.class); RoomMember memberInDifferentRoom = RoomMember.createMember(differentRoom, user); - memberInDifferentRoom.updateOnlineStatus(true); given(roomMemberRepository.findByRoomIdAndUserId(differentRoomId, fromUserId)) .willReturn(Optional.of(memberInDifferentRoom)); From 3c2986f546defa66da29840ba74b7a54755d5bd8 Mon Sep 17 00:00:00 2001 From: loseminho Date: Wed, 1 Oct 2025 23:49:47 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=EC=A0=9C=EA=B1=B0=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20redis=20=EC=97=B0=EB=8F=99=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../studyroom/controller/RoomController.java | 23 ++--- .../domain/studyroom/dto/MyRoomResponse.java | 4 +- .../studyroom/dto/RoomDetailResponse.java | 4 +- .../domain/studyroom/dto/RoomResponse.java | 4 +- .../domain/studyroom/service/RoomService.java | 87 +++++++++++++++++-- .../service/WebSocketSessionManager.java | 9 ++ .../controller/RoomControllerTest.java | 28 +++++- 7 files changed, 127 insertions(+), 32 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 d14a70ea..fcf58e7e 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,7 @@ public ResponseEntity> createRoom( currentUserId ); - RoomResponse response = RoomResponse.from(room); + RoomResponse response = roomService.toRoomResponse(room); return ResponseEntity .status(HttpStatus.CREATED) @@ -139,9 +139,7 @@ public ResponseEntity>> getRooms( Pageable pageable = PageRequest.of(page, size); Page rooms = roomService.getJoinableRooms(pageable); - List roomList = rooms.getContent().stream() - .map(RoomResponse::from) - .collect(Collectors.toList()); + List roomList = roomService.toRoomResponseList(rooms.getContent()); Map response = new HashMap<>(); response.put("rooms", roomList); @@ -175,11 +173,7 @@ public ResponseEntity> getRoomDetail( Room room = roomService.getRoomDetail(roomId, currentUserId); List members = roomService.getRoomMembers(roomId, currentUserId); - List memberResponses = members.stream() - .map(RoomMemberResponse::from) - .collect(Collectors.toList()); - - RoomDetailResponse response = RoomDetailResponse.of(room, memberResponses); + RoomDetailResponse response = roomService.toRoomDetailResponse(room, members); return ResponseEntity .status(HttpStatus.OK) @@ -201,12 +195,7 @@ public ResponseEntity>> getMyRooms() { List rooms = roomService.getUserRooms(currentUserId); - List roomList = rooms.stream() - .map(room -> MyRoomResponse.of( - room, - roomService.getUserRoomRole(room.getId(), currentUserId) - )) - .collect(Collectors.toList()); + List roomList = roomService.toMyRoomResponseList(rooms, currentUserId); return ResponseEntity .status(HttpStatus.OK) @@ -313,9 +302,7 @@ public ResponseEntity>> getPopularRooms( Pageable pageable = PageRequest.of(page, size); Page rooms = roomService.getPopularRooms(pageable); - List roomList = rooms.getContent().stream() - .map(RoomResponse::from) - .collect(Collectors.toList()); + List roomList = roomService.toRoomResponseList(rooms.getContent()); Map response = new HashMap<>(); response.put("rooms", roomList); 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 78b90d55..7a5c710d 100644 --- a/src/main/java/com/back/domain/studyroom/dto/MyRoomResponse.java +++ b/src/main/java/com/back/domain/studyroom/dto/MyRoomResponse.java @@ -20,12 +20,12 @@ public class MyRoomResponse { private RoomRole myRole; private LocalDateTime createdAt; - public static MyRoomResponse of(Room room, RoomRole myRole) { + public static MyRoomResponse of(Room room, long currentParticipants, RoomRole myRole) { return MyRoomResponse.builder() .roomId(room.getId()) .title(room.getTitle()) .description(room.getDescription() != null ? room.getDescription() : "") - .currentParticipants(room.getCurrentParticipants()) + .currentParticipants((int) currentParticipants) // Redis에서 조회한 실시간 값 .maxParticipants(room.getMaxParticipants()) .status(room.getStatus()) .myRole(myRole) diff --git a/src/main/java/com/back/domain/studyroom/dto/RoomDetailResponse.java b/src/main/java/com/back/domain/studyroom/dto/RoomDetailResponse.java index f282d46e..803d99f8 100644 --- a/src/main/java/com/back/domain/studyroom/dto/RoomDetailResponse.java +++ b/src/main/java/com/back/domain/studyroom/dto/RoomDetailResponse.java @@ -25,14 +25,14 @@ public class RoomDetailResponse { private LocalDateTime createdAt; private List members; - public static RoomDetailResponse of(Room room, List members) { + public static RoomDetailResponse of(Room room, long currentParticipants, List members) { return RoomDetailResponse.builder() .roomId(room.getId()) .title(room.getTitle()) .description(room.getDescription() != null ? room.getDescription() : "") .isPrivate(room.isPrivate()) .maxParticipants(room.getMaxParticipants()) - .currentParticipants(room.getCurrentParticipants()) + .currentParticipants((int) currentParticipants) // Redis에서 조회한 실시간 값 .status(room.getStatus()) .allowCamera(room.isAllowCamera()) .allowAudio(room.isAllowAudio()) 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 905a35ad..bd3960fb 100644 --- a/src/main/java/com/back/domain/studyroom/dto/RoomResponse.java +++ b/src/main/java/com/back/domain/studyroom/dto/RoomResponse.java @@ -19,12 +19,12 @@ public class RoomResponse { private String createdBy; private LocalDateTime createdAt; - public static RoomResponse from(Room room) { + public static RoomResponse from(Room room, long currentParticipants) { return RoomResponse.builder() .roomId(room.getId()) .title(room.getTitle()) .description(room.getDescription() != null ? room.getDescription() : "") - .currentParticipants(room.getCurrentParticipants()) + .currentParticipants((int) currentParticipants) // Redis에서 조회한 실시간 값 .maxParticipants(room.getMaxParticipants()) .status(room.getStatus()) .createdBy(room.getCreatedBy().getNickname()) 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 b6cfb956..988aeee7 100644 --- a/src/main/java/com/back/domain/studyroom/service/RoomService.java +++ b/src/main/java/com/back/domain/studyroom/service/RoomService.java @@ -40,6 +40,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; /** * 방 생성 메서드 @@ -67,7 +68,7 @@ public Room createRoom(String title, String description, boolean isPrivate, RoomMember hostMember = RoomMember.createHost(savedRoom, creator); roomMemberRepository.save(hostMember); - savedRoom.incrementParticipant(); + // savedRoom.incrementParticipant(); // Redis로 이관 - DB 업데이트 제거 log.info("방 생성 완료 - RoomId: {}, Title: {}, CreatorId: {}", savedRoom.getId(), title, creatorId); @@ -125,14 +126,14 @@ public RoomMember joinRoom(Long roomId, String password, Long userId) { RoomMember member = existingMember.get(); // TODO: Redis에서 온라인 여부 확인하도록 변경 // 현재는 기존 멤버 재입장 허용 - room.incrementParticipant(); + // room.incrementParticipant(); // Redis로 이관 - DB 업데이트 제거 return member; } RoomMember newMember = RoomMember.createVisitor(room, user); RoomMember savedMember = roomMemberRepository.save(newMember); - room.incrementParticipant(); + // room.incrementParticipant(); // Redis로 이관 - DB 업데이트 제거 log.info("방 입장 완료 - RoomId: {}, UserId: {}, Role: {}", roomId, userId, newMember.getRole()); @@ -166,7 +167,7 @@ public void leaveRoom(Long roomId, Long userId) { handleHostLeaving(room, member); } else { // TODO: Redis에서 제거하도록 변경 - room.decrementParticipant(); + // room.decrementParticipant(); // Redis로 이관 - DB 업데이트 제거 } log.info("방 퇴장 완료 - RoomId: {}, UserId: {}", roomId, userId); @@ -183,7 +184,7 @@ private void handleHostLeaving(Room room, RoomMember hostMember) { if (otherOnlineMembers.isEmpty()) { room.terminate(); // TODO: Redis에서 제거하도록 변경 - room.decrementParticipant(); + // room.decrementParticipant(); // Redis로 이관 - DB 업데이트 제거 } else { RoomMember newHost = otherOnlineMembers.stream() .filter(m -> m.getRole() == RoomRole.SUB_HOST) @@ -195,7 +196,7 @@ private void handleHostLeaving(Room room, RoomMember hostMember) { if (newHost != null) { newHost.updateRole(RoomRole.HOST); // TODO: Redis에서 제거하도록 변경 - room.decrementParticipant(); + // room.decrementParticipant(); // Redis로 이관 - DB 업데이트 제거 log.info("새 방장 지정 - RoomId: {}, NewHostId: {}", room.getId(), newHost.getUser().getId()); @@ -339,9 +340,81 @@ public void kickMember(Long roomId, Long targetUserId, Long requesterId) { Room room = roomRepository.findById(roomId) .orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND)); - room.decrementParticipant(); + // room.decrementParticipant(); // Redis로 이관 - DB 업데이트 제거 log.info("멤버 추방 완료 - RoomId: {}, TargetUserId: {}, RequesterId: {}", roomId, targetUserId, requesterId); } + + // ==================== DTO 생성 헬퍼 메서드 ==================== + + /** + * RoomResponse 생성 (Redis에서 실시간 참가자 수 조회) + */ + public com.back.domain.studyroom.dto.RoomResponse toRoomResponse(Room room) { + long onlineCount = sessionManager.getRoomOnlineUserCount(room.getId()); + return com.back.domain.studyroom.dto.RoomResponse.from(room, onlineCount); + } + + /** + * RoomResponse 리스트 생성 (일괄 조회로 N+1 방지) + */ + public java.util.List toRoomResponseList(java.util.List rooms) { + java.util.List roomIds = rooms.stream() + .map(Room::getId) + .collect(java.util.stream.Collectors.toList()); + + java.util.Map participantCounts = sessionManager.getBulkRoomOnlineUserCounts(roomIds); + + return rooms.stream() + .map(room -> com.back.domain.studyroom.dto.RoomResponse.from( + room, + participantCounts.getOrDefault(room.getId(), 0L) + )) + .collect(java.util.stream.Collectors.toList()); + } + + /** + * RoomDetailResponse 생성 (Redis에서 실시간 참가자 수 조회) + */ + public com.back.domain.studyroom.dto.RoomDetailResponse toRoomDetailResponse( + Room room, + java.util.List members) { + long onlineCount = sessionManager.getRoomOnlineUserCount(room.getId()); + + java.util.List memberResponses = members.stream() + .map(com.back.domain.studyroom.dto.RoomMemberResponse::from) + .collect(java.util.stream.Collectors.toList()); + + return com.back.domain.studyroom.dto.RoomDetailResponse.of(room, onlineCount, memberResponses); + } + + /** + * MyRoomResponse 생성 (Redis에서 실시간 참가자 수 조회) + */ + public com.back.domain.studyroom.dto.MyRoomResponse toMyRoomResponse(Room room, RoomRole myRole) { + long onlineCount = sessionManager.getRoomOnlineUserCount(room.getId()); + return com.back.domain.studyroom.dto.MyRoomResponse.of(room, onlineCount, myRole); + } + + /** + * MyRoomResponse 리스트 생성 (일괄 조회로 N+1 방지) + */ + public java.util.List toMyRoomResponseList( + java.util.List rooms, + Long userId) { + java.util.List roomIds = rooms.stream() + .map(Room::getId) + .collect(java.util.stream.Collectors.toList()); + + java.util.Map participantCounts = sessionManager.getBulkRoomOnlineUserCounts(roomIds); + + return rooms.stream() + .map(room -> { + RoomRole role = getUserRoomRole(room.getId(), userId); + long count = participantCounts.getOrDefault(room.getId(), 0L); + return com.back.domain.studyroom.dto.MyRoomResponse.of(room, count, role); + }) + .collect(java.util.stream.Collectors.toList()); + } } diff --git a/src/main/java/com/back/global/websocket/service/WebSocketSessionManager.java b/src/main/java/com/back/global/websocket/service/WebSocketSessionManager.java index 9a29f2fb..92cd4818 100644 --- a/src/main/java/com/back/global/websocket/service/WebSocketSessionManager.java +++ b/src/main/java/com/back/global/websocket/service/WebSocketSessionManager.java @@ -84,4 +84,13 @@ public Long getUserCurrentRoomId(Long userId) { public boolean isUserInRoom(Long userId, Long roomId) { return roomParticipantService.isUserInRoom(userId, roomId); } + + // 여러 방의 온라인 사용자 수 일괄 조회 (N+1 방지) + public java.util.Map getBulkRoomOnlineUserCounts(java.util.List roomIds) { + return roomIds.stream() + .collect(java.util.stream.Collectors.toMap( + roomId -> roomId, + this::getRoomOnlineUserCount + )); + } } \ No newline at end of file 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 e1be5d07..63fc1475 100644 --- a/src/test/java/com/back/domain/studyroom/controller/RoomControllerTest.java +++ b/src/test/java/com/back/domain/studyroom/controller/RoomControllerTest.java @@ -105,9 +105,13 @@ void createRoom() { anyInt(), eq(1L) )).willReturn(testRoom); + + RoomResponse roomResponse = RoomResponse.from(testRoom, 1); + given(roomService.toRoomResponse(any(Room.class))).willReturn(roomResponse); // when ResponseEntity> response = roomController.createRoom(request); + // then assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); assertThat(response.getBody()).isNotNull(); @@ -123,6 +127,7 @@ void createRoom() { anyInt(), eq(1L) ); + verify(roomService, times(1)).toRoomResponse(any(Room.class)); } @Test @@ -174,6 +179,9 @@ void getRooms() { 1 ); given(roomService.getJoinableRooms(any())).willReturn(roomPage); + + List roomResponses = Arrays.asList(RoomResponse.from(testRoom, 1)); + given(roomService.toRoomResponseList(anyList())).willReturn(roomResponses); // when ResponseEntity>> response = roomController.getRooms(0, 20); @@ -185,6 +193,7 @@ void getRooms() { assertThat(response.getBody().getData().get("rooms")).isNotNull(); verify(roomService, times(1)).getJoinableRooms(any()); + verify(roomService, times(1)).toRoomResponseList(anyList()); } @Test @@ -195,6 +204,13 @@ void getRoomDetail() { given(roomService.getRoomDetail(eq(1L), eq(1L))).willReturn(testRoom); given(roomService.getRoomMembers(eq(1L), eq(1L))).willReturn(Arrays.asList(testMember)); + + RoomDetailResponse roomDetailResponse = RoomDetailResponse.of( + testRoom, + 1, + Arrays.asList(RoomMemberResponse.from(testMember)) + ); + given(roomService.toRoomDetailResponse(any(Room.class), anyList())).willReturn(roomDetailResponse); // when ResponseEntity> response = roomController.getRoomDetail(1L); @@ -208,6 +224,7 @@ void getRoomDetail() { verify(currentUser, times(1)).getUserId(); 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()); } @Test @@ -226,7 +243,11 @@ void getMyRooms() { } given(roomService.getUserRooms(eq(1L))).willReturn(Arrays.asList(testRoom)); - given(roomService.getUserRoomRole(eq(1L), eq(1L))).willReturn(RoomRole.HOST); + + List myRoomResponses = Arrays.asList( + MyRoomResponse.of(testRoom, 1, RoomRole.HOST) + ); + given(roomService.toMyRoomResponseList(anyList(), eq(1L))).willReturn(myRoomResponses); // when ResponseEntity>> response = roomController.getMyRooms(); @@ -240,6 +261,7 @@ void getMyRooms() { verify(currentUser, times(1)).getUserId(); verify(roomService, times(1)).getUserRooms(eq(1L)); + verify(roomService, times(1)).toMyRoomResponseList(anyList(), eq(1L)); } @Test @@ -328,6 +350,9 @@ void getPopularRooms() { 1 ); given(roomService.getPopularRooms(any())).willReturn(roomPage); + + List roomResponses = Arrays.asList(RoomResponse.from(testRoom, 1)); + given(roomService.toRoomResponseList(anyList())).willReturn(roomResponses); // when ResponseEntity>> response = roomController.getPopularRooms(0, 20); @@ -339,5 +364,6 @@ void getPopularRooms() { assertThat(response.getBody().getData().get("rooms")).isNotNull(); verify(roomService, times(1)).getPopularRooms(any()); + verify(roomService, times(1)).toRoomResponseList(anyList()); } } From 80e3eb0a5d1e5cae1061f79d8b177b07387e72eb Mon Sep 17 00:00:00 2001 From: loseminho Date: Thu, 2 Oct 2025 15:02:59 +0900 Subject: [PATCH 3/3] =?UTF-8?q?refactor:=20=EB=B0=A9=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=20=EC=8B=9C=20webRTC=20=EC=A0=9C=EC=96=B4=20=EA=B0=80=EB=8A=A5?= =?UTF-8?q?=ED=95=98=EA=B2=8C=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 | 5 +- .../studyroom/dto/CreateRoomRequest.java | 6 + .../domain/studyroom/dto/RoomResponse.java | 8 ++ .../back/domain/studyroom/entity/Room.java | 10 +- .../domain/studyroom/service/RoomService.java | 10 +- .../controller/RoomControllerTest.java | 134 +++++++++++++++++- .../studyroom/service/RoomServiceTest.java | 69 ++++++++- 7 files changed, 220 insertions(+), 22 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 fcf58e7e..0d8c8e8c 100644 --- a/src/main/java/com/back/domain/studyroom/controller/RoomController.java +++ b/src/main/java/com/back/domain/studyroom/controller/RoomController.java @@ -43,7 +43,7 @@ public class RoomController { @PostMapping @Operation( summary = "방 생성", - description = "새로운 스터디 룸을 생성합니다. 방 생성자는 자동으로 방장(HOST)이 됩니다." + description = "새로운 스터디 룸을 생성합니다. 방 생성자는 자동으로 방장(HOST)이 됩니다. useWebRTC로 화상/음성/화면공유 기능을 한 번에 제어할 수 있습니다." ) @ApiResponses({ @ApiResponse(responseCode = "201", description = "방 생성 성공"), @@ -61,7 +61,8 @@ public ResponseEntity> createRoom( request.getIsPrivate() != null ? request.getIsPrivate() : false, request.getPassword(), request.getMaxParticipants() != null ? request.getMaxParticipants() : 10, - currentUserId + currentUserId, + request.getUseWebRTC() != null ? request.getUseWebRTC() : true // 디폴트: true ); RoomResponse response = roomService.toRoomResponse(room); 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 46a4672d..0dd78295 100644 --- a/src/main/java/com/back/domain/studyroom/dto/CreateRoomRequest.java +++ b/src/main/java/com/back/domain/studyroom/dto/CreateRoomRequest.java @@ -23,4 +23,10 @@ public class CreateRoomRequest { @Min(value = 2, message = "최소 2명 이상이어야 합니다") @Max(value = 100, message = "최대 100명까지 가능합니다") private Integer maxParticipants = 10; + + // WebRTC 통합 제어 필드 (카메라, 오디오, 화면공유를 한 번에 제어) + // true: WebRTC 기능 전체 활성화 + // false: WebRTC 기능 전체 비활성화 + // null: 디폴트 true로 처리 + private Boolean useWebRTC = true; } 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 bd3960fb..1f22a5aa 100644 --- a/src/main/java/com/back/domain/studyroom/dto/RoomResponse.java +++ b/src/main/java/com/back/domain/studyroom/dto/RoomResponse.java @@ -19,6 +19,11 @@ public class RoomResponse { private String createdBy; private LocalDateTime createdAt; + // WebRTC 설정 정보 (프론트엔드에서 UI 제어용) + private Boolean allowCamera; + private Boolean allowAudio; + private Boolean allowScreenShare; + public static RoomResponse from(Room room, long currentParticipants) { return RoomResponse.builder() .roomId(room.getId()) @@ -29,6 +34,9 @@ public static RoomResponse from(Room room, long currentParticipants) { .status(room.getStatus()) .createdBy(room.getCreatedBy().getNickname()) .createdAt(room.getCreatedAt()) + .allowCamera(room.isAllowCamera()) + .allowAudio(room.isAllowAudio()) + .allowScreenShare(room.isAllowScreenShare()) .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 98d681cf..dea501d0 100644 --- a/src/main/java/com/back/domain/studyroom/entity/Room.java +++ b/src/main/java/com/back/domain/studyroom/entity/Room.java @@ -168,9 +168,11 @@ public boolean isOwner(Long userId) { * 방 생성을 위한 정적 팩토리 메서드 새로운 방을 생성할 때 모든 기본값을 설정 해주는 초기 메서드 기본 상태에서 방장이 임의로 변형하고 싶은 부분만 변경해서 사용 가능 + * @param useWebRTC WebRTC 사용 여부 (true: 카메라/오디오/화면공유 전체 활성화, false: 전체 비활성화) */ public static Room create(String title, String description, boolean isPrivate, - String password, int maxParticipants, User creator, RoomTheme theme) { + String password, int maxParticipants, User creator, RoomTheme theme, + boolean useWebRTC) { Room room = new Room(); room.title = title; room.description = description; @@ -178,9 +180,9 @@ public static Room create(String title, String description, boolean isPrivate, room.password = password; room.maxParticipants = maxParticipants; room.isActive = true; // 생성 시 기본적으로 활성화 - room.allowCamera = true; // 기본적으로 카메라 허용 - room.allowAudio = true; // 기본적으로 오디오 허용 - room.allowScreenShare = true; // 기본적으로 화면 공유 허용 + room.allowCamera = useWebRTC; // WebRTC 사용 여부에 따라 설정 + room.allowAudio = useWebRTC; // WebRTC 사용 여부에 따라 설정 + room.allowScreenShare = useWebRTC; // WebRTC 사용 여부에 따라 설정 room.status = RoomStatus.WAITING; // 생성 시 대기 상태 room.currentParticipants = 0; // 생성 시 참가자 0명 room.createdBy = creator; 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 988aeee7..b6a8b12f 100644 --- a/src/main/java/com/back/domain/studyroom/service/RoomService.java +++ b/src/main/java/com/back/domain/studyroom/service/RoomService.java @@ -52,17 +52,17 @@ public class RoomService { * 기본 설정: - 상태: WAITING (대기 중) - - 카메라/오디오/화면공유: application.yml의 설정값 사용 + - WebRTC: useWebRTC 파라미터에 따라 카메라/오디오/화면공유 통합 제어 - 참가자 수: 0명에서 시작 후 방장 추가로 1명 */ @Transactional public Room createRoom(String title, String description, boolean isPrivate, - String password, int maxParticipants, Long creatorId) { + String password, int maxParticipants, Long creatorId, boolean useWebRTC) { User creator = userRepository.findById(creatorId) .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); - Room room = Room.create(title, description, isPrivate, password, maxParticipants, creator, null); + Room room = Room.create(title, description, isPrivate, password, maxParticipants, creator, null, useWebRTC); Room savedRoom = roomRepository.save(room); RoomMember hostMember = RoomMember.createHost(savedRoom, creator); @@ -70,8 +70,8 @@ public Room createRoom(String title, String description, boolean isPrivate, // savedRoom.incrementParticipant(); // Redis로 이관 - DB 업데이트 제거 - log.info("방 생성 완료 - RoomId: {}, Title: {}, CreatorId: {}", - savedRoom.getId(), title, creatorId); + log.info("방 생성 완료 - RoomId: {}, Title: {}, CreatorId: {}, WebRTC: {}", + savedRoom.getId(), title, creatorId, useWebRTC); return savedRoom; } 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 63fc1475..e80206de 100644 --- a/src/test/java/com/back/domain/studyroom/controller/RoomControllerTest.java +++ b/src/test/java/com/back/domain/studyroom/controller/RoomControllerTest.java @@ -66,7 +66,7 @@ void setUp() { userProfile.setNickname("테스트유저"); testUser.setUserProfile(userProfile); - // 테스트 방 생성 + // 테스트 방 생성 (WebRTC 사용) testRoom = Room.create( "테스트 방", "테스트 설명", @@ -74,7 +74,8 @@ void setUp() { null, 10, testUser, - null + null, + true // useWebRTC ); // 테스트 멤버 생성 @@ -94,7 +95,8 @@ void createRoom() { "테스트 설명", false, null, - 10 + 10, + true // useWebRTC ); given(roomService.createRoom( @@ -103,7 +105,8 @@ void createRoom() { anyBoolean(), any(), anyInt(), - eq(1L) + eq(1L), + anyBoolean() // useWebRTC 파라미터 추가 )).willReturn(testRoom); RoomResponse roomResponse = RoomResponse.from(testRoom, 1); @@ -125,7 +128,8 @@ void createRoom() { anyBoolean(), any(), anyInt(), - eq(1L) + eq(1L), + anyBoolean() // useWebRTC 파라미터 추가 ); verify(roomService, times(1)).toRoomResponse(any(Room.class)); } @@ -366,4 +370,124 @@ void getPopularRooms() { verify(roomService, times(1)).getPopularRooms(any()); verify(roomService, times(1)).toRoomResponseList(anyList()); } + + @Test + @DisplayName("방 생성 API - WebRTC 활성화 테스트") + void createRoom_WithWebRTC() { + // given + given(currentUser.getUserId()).willReturn(1L); + + CreateRoomRequest request = new CreateRoomRequest( + "WebRTC 방", + "화상 채팅 가능", + false, + null, + 10, + true // WebRTC 활성화 + ); + + Room webRTCRoom = Room.create( + "WebRTC 방", + "화상 채팅 가능", + false, + null, + 10, + testUser, + null, + true + ); + + given(roomService.createRoom( + anyString(), + anyString(), + anyBoolean(), + any(), + anyInt(), + eq(1L), + eq(true) // WebRTC true 검증 + )).willReturn(webRTCRoom); + + RoomResponse roomResponse = RoomResponse.from(webRTCRoom, 1); + given(roomService.toRoomResponse(any(Room.class))).willReturn(roomResponse); + + // when + ResponseEntity> response = roomController.createRoom(request); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getData().getAllowCamera()).isTrue(); + assertThat(response.getBody().getData().getAllowAudio()).isTrue(); + assertThat(response.getBody().getData().getAllowScreenShare()).isTrue(); + + verify(roomService, times(1)).createRoom( + anyString(), + anyString(), + anyBoolean(), + any(), + anyInt(), + eq(1L), + eq(true) + ); + } + + @Test + @DisplayName("방 생성 API - WebRTC 비활성화 테스트") + void createRoom_WithoutWebRTC() { + // given + given(currentUser.getUserId()).willReturn(1L); + + CreateRoomRequest request = new CreateRoomRequest( + "채팅 전용 방", + "텍스트만 가능", + false, + null, + 50, + false // WebRTC 비활성화 + ); + + Room chatOnlyRoom = Room.create( + "채팅 전용 방", + "텍스트만 가능", + false, + null, + 50, + testUser, + null, + false + ); + + given(roomService.createRoom( + anyString(), + anyString(), + anyBoolean(), + any(), + anyInt(), + eq(1L), + eq(false) // WebRTC false 검증 + )).willReturn(chatOnlyRoom); + + RoomResponse roomResponse = RoomResponse.from(chatOnlyRoom, 1); + given(roomService.toRoomResponse(any(Room.class))).willReturn(roomResponse); + + // when + ResponseEntity> response = roomController.createRoom(request); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getData().getAllowCamera()).isFalse(); + assertThat(response.getBody().getData().getAllowAudio()).isFalse(); + assertThat(response.getBody().getData().getAllowScreenShare()).isFalse(); + + verify(roomService, times(1)).createRoom( + anyString(), + anyString(), + anyBoolean(), + any(), + anyInt(), + eq(1L), + eq(false) + ); + } } 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 d7262668..d9f1c7a7 100644 --- a/src/test/java/com/back/domain/studyroom/service/RoomServiceTest.java +++ b/src/test/java/com/back/domain/studyroom/service/RoomServiceTest.java @@ -71,7 +71,7 @@ void setUp() { userProfile.setNickname("테스트유저"); testUser.setUserProfile(userProfile); - // 테스트 방 생성 + // 테스트 방 생성 (WebRTC 사용) testRoom = Room.create( "테스트 방", "테스트 설명", @@ -79,7 +79,8 @@ void setUp() { null, 10, testUser, - null + null, + true // useWebRTC ); // 테스트 멤버 생성 @@ -101,7 +102,8 @@ void createRoom_Success() { false, null, 10, - 1L + 1L, + true // useWebRTC ); // then @@ -125,7 +127,8 @@ void createRoom_UserNotFound() { false, null, 10, - 999L + 999L, + true // useWebRTC )) .isInstanceOf(CustomException.class) .hasFieldOrPropertyWithValue("errorCode", ErrorCode.USER_NOT_FOUND); @@ -171,7 +174,8 @@ void joinRoom_WrongPassword() { "1234", 10, testUser, - null + null, + true // useWebRTC ); given(roomRepository.findByIdWithLock(1L)).willReturn(Optional.of(privateRoom)); @@ -242,7 +246,8 @@ void getRoomDetail_PrivateRoomForbidden() { "1234", 10, testUser, - null + null, + true // useWebRTC ); given(roomRepository.findById(1L)).willReturn(Optional.of(privateRoom)); given(roomMemberRepository.existsByRoomIdAndUserId(1L, 2L)).willReturn(false); @@ -387,4 +392,56 @@ void kickMember_NoPermission() { .isInstanceOf(CustomException.class) .hasFieldOrPropertyWithValue("errorCode", ErrorCode.NOT_ROOM_MANAGER); } + + @Test + @DisplayName("방 생성 - WebRTC 활성화") + void createRoom_WithWebRTC() { + // given + given(userRepository.findById(1L)).willReturn(Optional.of(testUser)); + given(roomRepository.save(any(Room.class))).willAnswer(invocation -> invocation.getArgument(0)); + given(roomMemberRepository.save(any(RoomMember.class))).willReturn(testMember); + + // when + Room createdRoom = roomService.createRoom( + "WebRTC 방", + "화상 채팅 가능", + false, + null, + 10, + 1L, + true // WebRTC 사용 + ); + + // then + assertThat(createdRoom).isNotNull(); + assertThat(createdRoom.isAllowCamera()).isTrue(); + assertThat(createdRoom.isAllowAudio()).isTrue(); + assertThat(createdRoom.isAllowScreenShare()).isTrue(); + } + + @Test + @DisplayName("방 생성 - WebRTC 비활성화") + void createRoom_WithoutWebRTC() { + // given + given(userRepository.findById(1L)).willReturn(Optional.of(testUser)); + given(roomRepository.save(any(Room.class))).willAnswer(invocation -> invocation.getArgument(0)); + given(roomMemberRepository.save(any(RoomMember.class))).willReturn(testMember); + + // when + Room createdRoom = roomService.createRoom( + "채팅 전용 방", + "텍스트만 가능", + false, + null, + 50, // WebRTC 없으면 더 많은 인원 가능 + 1L, + false // WebRTC 미사용 + ); + + // then + assertThat(createdRoom).isNotNull(); + assertThat(createdRoom.isAllowCamera()).isFalse(); + assertThat(createdRoom.isAllowAudio()).isFalse(); + assertThat(createdRoom.isAllowScreenShare()).isFalse(); + } }