Skip to content

Commit a89df69

Browse files
authored
Refactor/146 (#146) (#164)
* refactor: 스더티룸 권한에 대한 로직 개선 * fix: ci에서 통과 못한 테스트코드 수정
1 parent f4a59a6 commit a89df69

File tree

10 files changed

+442
-104
lines changed

10 files changed

+442
-104
lines changed

src/main/java/com/back/domain/studyroom/controller/RoomController.java

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
import com.back.domain.studyroom.dto.*;
44
import com.back.domain.studyroom.entity.Room;
55
import com.back.domain.studyroom.entity.RoomMember;
6+
import com.back.domain.studyroom.entity.RoomRole;
67
import com.back.domain.studyroom.service.RoomService;
8+
import com.back.domain.user.entity.User;
79
import com.back.global.common.dto.RsData;
810
import com.back.global.security.user.CurrentUser;
911
import io.swagger.v3.oas.annotations.Operation;
@@ -317,4 +319,44 @@ public ResponseEntity<RsData<Map<String, Object>>> getPopularRooms(
317319
.status(HttpStatus.OK)
318320
.body(RsData.success("인기 방 목록 조회 완료", response));
319321
}
322+
323+
@PutMapping("/{roomId}/members/{userId}/role")
324+
@Operation(
325+
summary = "멤버 역할 변경",
326+
description = "방 멤버의 역할을 변경합니다. 방장만 실행 가능합니다. VISITOR를 포함한 모든 사용자의 역할을 변경할 수 있으며, HOST로 변경 시 기존 방장은 자동으로 MEMBER로 강등됩니다."
327+
)
328+
@ApiResponses({
329+
@ApiResponse(responseCode = "200", description = "역할 변경 성공"),
330+
@ApiResponse(responseCode = "400", description = "자신의 역할은 변경 불가"),
331+
@ApiResponse(responseCode = "403", description = "방장 권한 없음"),
332+
@ApiResponse(responseCode = "404", description = "존재하지 않는 방 또는 사용자"),
333+
@ApiResponse(responseCode = "401", description = "인증 실패")
334+
})
335+
public ResponseEntity<RsData<ChangeRoleResponse>> changeUserRole(
336+
@Parameter(description = "방 ID", required = true) @PathVariable Long roomId,
337+
@Parameter(description = "대상 사용자 ID", required = true) @PathVariable Long userId,
338+
@Valid @RequestBody ChangeRoleRequest request) {
339+
340+
Long currentUserId = currentUser.getUserId();
341+
342+
// 변경 전 역할 조회
343+
RoomRole oldRole = roomService.getUserRoomRole(roomId, userId);
344+
345+
// 역할 변경
346+
roomService.changeUserRole(roomId, userId, request.getNewRole(), currentUserId);
347+
348+
// 사용자 정보 조회
349+
User targetUser = roomService.getUserById(userId);
350+
351+
ChangeRoleResponse response = ChangeRoleResponse.of(
352+
userId,
353+
targetUser.getNickname(),
354+
oldRole,
355+
request.getNewRole()
356+
);
357+
358+
return ResponseEntity
359+
.status(HttpStatus.OK)
360+
.body(RsData.success("역할 변경 완료", response));
361+
}
320362
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package com.back.domain.studyroom.dto;
2+
3+
import com.back.domain.studyroom.entity.RoomRole;
4+
import jakarta.validation.constraints.NotNull;
5+
import lombok.AllArgsConstructor;
6+
import lombok.Getter;
7+
import lombok.NoArgsConstructor;
8+
9+
/**
10+
* 멤버 역할 변경 요청 DTO
11+
* - VISITOR → MEMBER/SUB_HOST/HOST 모두 가능
12+
* - HOST로 변경 시 기존 방장은 자동으로 MEMBER로 강등
13+
*/
14+
@Getter
15+
@NoArgsConstructor
16+
@AllArgsConstructor
17+
public class ChangeRoleRequest {
18+
19+
@NotNull(message = "역할은 필수입니다")
20+
private RoomRole newRole;
21+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package com.back.domain.studyroom.dto;
2+
3+
import com.back.domain.studyroom.entity.RoomRole;
4+
import lombok.Builder;
5+
import lombok.Getter;
6+
7+
/**
8+
* 역할 변경 응답 DTO
9+
*/
10+
@Getter
11+
@Builder
12+
public class ChangeRoleResponse {
13+
14+
private Long userId;
15+
private String nickname;
16+
private RoomRole oldRole;
17+
private RoomRole newRole;
18+
private String message;
19+
20+
public static ChangeRoleResponse of(Long userId, String nickname,
21+
RoomRole oldRole, RoomRole newRole) {
22+
String message = buildMessage(oldRole, newRole);
23+
24+
return ChangeRoleResponse.builder()
25+
.userId(userId)
26+
.nickname(nickname)
27+
.oldRole(oldRole)
28+
.newRole(newRole)
29+
.message(message)
30+
.build();
31+
}
32+
33+
private static String buildMessage(RoomRole oldRole, RoomRole newRole) {
34+
if (newRole == RoomRole.HOST) {
35+
return "방장으로 임명되었습니다.";
36+
} else if (oldRole == RoomRole.HOST) {
37+
return "방장 권한이 해제되었습니다.";
38+
} else if (newRole == RoomRole.SUB_HOST) {
39+
return "부방장으로 승격되었습니다.";
40+
} else if (newRole == RoomRole.MEMBER && oldRole == RoomRole.VISITOR) {
41+
return "정식 멤버로 승격되었습니다.";
42+
} else if (newRole == RoomRole.MEMBER) {
43+
return "일반 멤버로 강등되었습니다.";
44+
}
45+
return "역할이 변경되었습니다.";
46+
}
47+
}

src/main/java/com/back/domain/studyroom/repository/RoomMemberRepositoryCustom.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,15 @@ public interface RoomMemberRepositoryCustom {
6464
*/
6565
boolean existsByRoomIdAndUserId(Long roomId, Long userId);
6666

67+
/**
68+
* 여러 사용자의 멤버십 일괄 조회 (IN 절)
69+
* Redis에서 온라인 사용자 목록을 받아서 DB 멤버십 조회 시 사용
70+
* @param roomId 방 ID
71+
* @param userIds 사용자 ID 목록
72+
* @return 멤버십 목록 (MEMBER 이상만 DB에 있음)
73+
*/
74+
List<RoomMember> findByRoomIdAndUserIdIn(Long roomId, java.util.Set<Long> userIds);
75+
6776
/**
6877
* 특정 역할의 멤버 수 조회
6978
* TODO: Redis 기반으로 변경 예정

src/main/java/com/back/domain/studyroom/repository/RoomMemberRepositoryImpl.java

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,35 @@ public boolean existsByRoomIdAndUserId(Long roomId, Long userId) {
255255
return count != null && count > 0;
256256
}
257257

258+
/**
259+
* 여러 사용자의 멤버십 일괄 조회 (IN 절)
260+
* - Redis 온라인 목록으로 DB 멤버십 조회
261+
* - N+1 문제 해결
262+
* - VISITOR는 DB에 없으므로 결과에 포함 안됨
263+
* @param roomId 방 ID
264+
* @param userIds 사용자 ID Set
265+
* @return DB에 저장된 멤버 목록 (MEMBER 이상)
266+
*/
267+
@Override
268+
public List<RoomMember> findByRoomIdAndUserIdIn(Long roomId, java.util.Set<Long> userIds) {
269+
if (userIds == null || userIds.isEmpty()) {
270+
return List.of();
271+
}
272+
273+
return queryFactory
274+
.selectFrom(roomMember)
275+
.leftJoin(roomMember.user, user).fetchJoin() // N+1 방지
276+
.where(
277+
roomMember.room.id.eq(roomId),
278+
roomMember.user.id.in(userIds)
279+
)
280+
.orderBy(
281+
roomMember.role.asc(), // 역할순
282+
roomMember.joinedAt.asc() // 입장 시간순
283+
)
284+
.fetch();
285+
}
286+
258287
/**
259288
* 특정 역할의 멤버 수 조회
260289
* TODO: Redis 기반으로 변경 예정
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package com.back.domain.studyroom.service;
2+
3+
import com.back.global.websocket.service.WebSocketSessionManager;
4+
import lombok.RequiredArgsConstructor;
5+
import lombok.extern.slf4j.Slf4j;
6+
import org.springframework.stereotype.Service;
7+
8+
import java.util.Map;
9+
import java.util.Set;
10+
11+
/**
12+
* 방 상태 관리를 위한 Redis 전용 서비스
13+
* 방의 온라인 사용자 관리 (입장/퇴장)
14+
* 실시간 참가자 수 조회
15+
* 온라인 사용자 목록 조회
16+
* Redis: 실시간 온라인 상태만 관리 (휘발성 데이터)
17+
* DB: 영구 멤버십 + 역할 정보 (MEMBER 이상만 저장)
18+
* 역할(Role)은 Redis에 저장하지 않음!
19+
* 이유 1: DB가 Single Source of Truth (데이터 일관성)
20+
* 이유 2: Redis-DB 동기화 복잡도 제거
21+
* 이유 3: 멤버 목록 조회 시 IN 절로 효율적 조회 가능
22+
* @see com.back.global.websocket.service.WebSocketSessionManager WebSocket 세션 관리
23+
* @see com.back.domain.studyroom.repository.RoomMemberRepository DB 멤버십 조회
24+
*/
25+
@Slf4j
26+
@Service
27+
@RequiredArgsConstructor
28+
public class RoomRedisService {
29+
30+
private final WebSocketSessionManager sessionManager;
31+
32+
// ==================== 방 입장/퇴장 ====================
33+
34+
/**
35+
* 사용자가 방에 입장 (Redis 온라인 상태 업데이트)
36+
* - Redis Set에 userId 추가
37+
* - 역할(Role)은 DB에서만 관리
38+
*
39+
* @param userId 사용자 ID
40+
* @param roomId 방 ID
41+
*/
42+
public void enterRoom(Long userId, Long roomId) {
43+
sessionManager.joinRoom(userId, roomId);
44+
log.info("방 입장 완료 (Redis) - 사용자: {}, 방: {}", userId, roomId);
45+
}
46+
47+
/**
48+
* 사용자가 방에서 퇴장 (Redis 온라인 상태 업데이트)
49+
* - Redis Set에서 userId 제거
50+
* - DB 멤버십은 유지됨 (재입장 시 역할 유지)
51+
*
52+
* @param userId 사용자 ID
53+
* @param roomId 방 ID
54+
*/
55+
public void exitRoom(Long userId, Long roomId) {
56+
sessionManager.leaveRoom(userId, roomId);
57+
log.info("방 퇴장 완료 (Redis) - 사용자: {}, 방: {}", userId, roomId);
58+
}
59+
60+
// ==================== 조회 ====================
61+
62+
/**
63+
* 방의 현재 온라인 사용자 수 조회
64+
* - 실시간 참가자 수 (DB currentParticipants와 무관)
65+
*
66+
* @param roomId 방 ID
67+
* @return 온라인 사용자 수
68+
*/
69+
public long getRoomUserCount(Long roomId) {
70+
return sessionManager.getRoomOnlineUserCount(roomId);
71+
}
72+
73+
/**
74+
* 방의 온라인 사용자 ID 목록 조회
75+
* - 멤버 목록 조회 시 이 ID로 DB 조회
76+
* - DB에 없는 ID = VISITOR
77+
*
78+
* @param roomId 방 ID
79+
* @return 온라인 사용자 ID Set
80+
*/
81+
public Set<Long> getRoomUsers(Long roomId) {
82+
return sessionManager.getOnlineUsersInRoom(roomId);
83+
}
84+
85+
/**
86+
* 사용자가 현재 특정 방에 있는지 확인
87+
*
88+
* @param userId 사용자 ID
89+
* @param roomId 방 ID
90+
* @return 온라인 여부
91+
*/
92+
public boolean isUserInRoom(Long userId, Long roomId) {
93+
return sessionManager.isUserInRoom(userId, roomId);
94+
}
95+
96+
/**
97+
* 사용자의 현재 방 ID 조회
98+
*
99+
* @param userId 사용자 ID
100+
* @return 방 ID (없으면 null)
101+
*/
102+
public Long getCurrentRoomId(Long userId) {
103+
return sessionManager.getUserCurrentRoomId(userId);
104+
}
105+
106+
/**
107+
* 여러 방의 온라인 사용자 수 일괄 조회 (N+1 방지)
108+
* - 방 목록 조회 시 사용
109+
*
110+
* @param roomIds 방 ID 목록
111+
* @return Map<RoomId, OnlineCount>
112+
*/
113+
public Map<Long, Long> getBulkRoomOnlineUserCounts(java.util.List<Long> roomIds) {
114+
return sessionManager.getBulkRoomOnlineUserCounts(roomIds);
115+
}
116+
}

0 commit comments

Comments
 (0)