From d8fd6f09149ae1c78cd693ccda1e891d2b42d863 Mon Sep 17 00:00:00 2001 From: jueunk617 Date: Thu, 9 Oct 2025 12:39:43 +0900 Subject: [PATCH 1/7] =?UTF-8?q?Fix:=20=EC=95=8C=EB=A6=BC=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20API=20=EC=9D=91=EB=8B=B5=20=EB=A9=94=EC=84=B8?= =?UTF-8?q?=EC=A7=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/NotificationSettingController.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/back/domain/notification/controller/NotificationSettingController.java b/src/main/java/com/back/domain/notification/controller/NotificationSettingController.java index 391e99ce..f6ed4cba 100644 --- a/src/main/java/com/back/domain/notification/controller/NotificationSettingController.java +++ b/src/main/java/com/back/domain/notification/controller/NotificationSettingController.java @@ -38,7 +38,7 @@ public ResponseEntity> toggleSetting( settingService.toggleSetting(currentUser.getUserId(), type); - return ResponseEntity.ok(RsData.success(null)); + return ResponseEntity.ok(RsData.success("알림 설정 토글 성공")); } @PutMapping("/all") @@ -49,6 +49,6 @@ public ResponseEntity> toggleAllSettings( settingService.toggleAllSettings(currentUser.getUserId(), enable); - return ResponseEntity.ok(RsData.success(null)); + return ResponseEntity.ok(RsData.success("알림 설정 전체 변경 성공")); } } \ No newline at end of file From 686bf28b552aceac949776fd5716e0c621fea496 Mon Sep 17 00:00:00 2001 From: jueunk617 Date: Fri, 10 Oct 2025 10:10:53 +0900 Subject: [PATCH 2/7] =?UTF-8?q?Fix:=20=EB=B0=A9=20=EC=9E=85=ED=87=B4?= =?UTF-8?q?=EC=9E=A5=20@Deprecated=20=EB=A9=94=EC=84=9C=EB=93=9C=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 --- .../WebSocketMessageController.java | 63 -------- .../WebSocketMessageControllerTest.java | 144 ------------------ 2 files changed, 207 deletions(-) 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 4922948e..e133d518 100644 --- a/src/main/java/com/back/global/websocket/controller/WebSocketMessageController.java +++ b/src/main/java/com/back/global/websocket/controller/WebSocketMessageController.java @@ -7,7 +7,6 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.messaging.handler.annotation.MessageMapping; -import org.springframework.messaging.handler.annotation.DestinationVariable; import org.springframework.messaging.handler.annotation.Payload; import org.springframework.messaging.simp.SimpMessageHeaderAccessor; import org.springframework.stereotype.Controller; @@ -42,68 +41,6 @@ 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, - SimpMessageHeaderAccessor headerAccessor) { - try { - if (message.userId() != null) { - sessionManager.joinRoom(message.userId(), roomId); - log.info("STOMP 방 입장 처리 완료 - 사용자: {}, 방: {}", message.userId(), roomId); - } else { - log.warn("유효하지 않은 방 입장 요청: userId가 null"); - errorHelper.sendInvalidRequestError(headerAccessor.getSessionId(), "사용자 ID가 필요합니다"); - } - } catch (CustomException e) { - log.error("방 입장 처리 실패 - 방: {}, 에러: {}", roomId, e.getMessage()); - errorHelper.sendCustomExceptionToUser(headerAccessor.getSessionId(), e); - } catch (Exception e) { - log.error("방 입장 처리 중 예상치 못한 오류 - 방: {}", roomId, e); - errorHelper.sendGenericErrorToUser(headerAccessor.getSessionId(), e, "방 입장 중 오류가 발생했습니다"); - } - } - - /** - * 방 퇴장 처리 - * - * @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, - SimpMessageHeaderAccessor headerAccessor) { - try { - if (message.userId() != null) { - sessionManager.leaveRoom(message.userId(), roomId); - log.info("STOMP 방 퇴장 처리 완료 - 사용자: {}, 방: {}", message.userId(), roomId); - } else { - log.warn("유효하지 않은 방 퇴장 요청: userId가 null"); - errorHelper.sendInvalidRequestError(headerAccessor.getSessionId(), "사용자 ID가 필요합니다"); - } - } catch (CustomException e) { - log.error("방 퇴장 처리 실패 - 방: {}, 에러: {}", roomId, e.getMessage()); - errorHelper.sendCustomExceptionToUser(headerAccessor.getSessionId(), e); - } catch (Exception e) { - log.error("방 퇴장 처리 중 예상치 못한 오류 - 방: {}", roomId, e); - errorHelper.sendGenericErrorToUser(headerAccessor.getSessionId(), e, "방 퇴장 중 오류가 발생했습니다"); - } - } - // 사용자 활동 신호 처리 @MessageMapping("/activity") public void handleActivity(@Payload HeartbeatMessage message, diff --git a/src/test/java/com/back/global/websocket/controller/WebSocketMessageControllerTest.java b/src/test/java/com/back/global/websocket/controller/WebSocketMessageControllerTest.java index bfe021bc..8ccca987 100644 --- a/src/test/java/com/back/global/websocket/controller/WebSocketMessageControllerTest.java +++ b/src/test/java/com/back/global/websocket/controller/WebSocketMessageControllerTest.java @@ -118,150 +118,6 @@ void t4() { } } - @Nested - @DisplayName("방 입장 처리") - class HandleJoinRoomTest { - - @Test - @DisplayName("정상 - 방 입장") - void t5() { - // given - HeartbeatMessage message = new HeartbeatMessage(userId); - doNothing().when(sessionManager).joinRoom(userId, roomId); - - // when - controller.handleJoinRoom(roomId, message, headerAccessor); - - // then - verify(sessionManager).joinRoom(userId, roomId); - verify(errorHelper, never()).sendInvalidRequestError(anyString(), anyString()); - verify(errorHelper, never()).sendCustomExceptionToUser(anyString(), any()); - verify(errorHelper, never()).sendGenericErrorToUser(anyString(), any(), anyString()); - } - - @Test - @DisplayName("실패 - userId가 null") - void t6() { - // given - HeartbeatMessage message = new HeartbeatMessage(null); - - // when - controller.handleJoinRoom(roomId, message, headerAccessor); - - // then - verify(sessionManager, never()).joinRoom(any(), any()); - verify(errorHelper).sendInvalidRequestError(sessionId, "사용자 ID가 필요합니다"); - } - - @Test - @DisplayName("실패 - CustomException 발생") - void t7() { - // given - HeartbeatMessage message = new HeartbeatMessage(userId); - CustomException exception = new CustomException(ErrorCode.NOT_ROOM_MEMBER); - doThrow(exception).when(sessionManager).joinRoom(userId, roomId); - - // when - controller.handleJoinRoom(roomId, message, headerAccessor); - - // then - verify(sessionManager).joinRoom(userId, roomId); - verify(errorHelper).sendCustomExceptionToUser(sessionId, exception); - } - - @Test - @DisplayName("실패 - 일반 Exception 발생") - void t8() { - // given - HeartbeatMessage message = new HeartbeatMessage(userId); - RuntimeException exception = new RuntimeException("예상치 못한 오류"); - doThrow(exception).when(sessionManager).joinRoom(userId, roomId); - - // when - controller.handleJoinRoom(roomId, message, headerAccessor); - - // then - verify(sessionManager).joinRoom(userId, roomId); - verify(errorHelper).sendGenericErrorToUser( - eq(sessionId), - any(Exception.class), - eq("방 입장 중 오류가 발생했습니다") - ); - } - } - - @Nested - @DisplayName("방 퇴장 처리") - class HandleLeaveRoomTest { - - @Test - @DisplayName("정상 - 방 퇴장") - void t9() { - // given - HeartbeatMessage message = new HeartbeatMessage(userId); - doNothing().when(sessionManager).leaveRoom(userId, roomId); - - // when - controller.handleLeaveRoom(roomId, message, headerAccessor); - - // then - verify(sessionManager).leaveRoom(userId, roomId); - verify(errorHelper, never()).sendInvalidRequestError(anyString(), anyString()); - verify(errorHelper, never()).sendCustomExceptionToUser(anyString(), any()); - verify(errorHelper, never()).sendGenericErrorToUser(anyString(), any(), anyString()); - } - - @Test - @DisplayName("실패 - userId가 null") - void t10() { - // given - HeartbeatMessage message = new HeartbeatMessage(null); - - // when - controller.handleLeaveRoom(roomId, message, headerAccessor); - - // then - verify(sessionManager, never()).leaveRoom(any(), any()); - verify(errorHelper).sendInvalidRequestError(sessionId, "사용자 ID가 필요합니다"); - } - - @Test - @DisplayName("실패 - CustomException 발생") - void t11() { - // given - HeartbeatMessage message = new HeartbeatMessage(userId); - CustomException exception = new CustomException(ErrorCode.NOT_ROOM_MEMBER); - doThrow(exception).when(sessionManager).leaveRoom(userId, roomId); - - // when - controller.handleLeaveRoom(roomId, message, headerAccessor); - - // then - verify(sessionManager).leaveRoom(userId, roomId); - verify(errorHelper).sendCustomExceptionToUser(sessionId, exception); - } - - @Test - @DisplayName("실패 - 일반 Exception 발생") - void t12() { - // given - HeartbeatMessage message = new HeartbeatMessage(userId); - RuntimeException exception = new RuntimeException("예상치 못한 오류"); - doThrow(exception).when(sessionManager).leaveRoom(userId, roomId); - - // when - controller.handleLeaveRoom(roomId, message, headerAccessor); - - // then - verify(sessionManager).leaveRoom(userId, roomId); - verify(errorHelper).sendGenericErrorToUser( - eq(sessionId), - any(Exception.class), - eq("방 퇴장 중 오류가 발생했습니다") - ); - } - } - @Nested @DisplayName("활동 신호 처리") class HandleActivityTest { From 71234e9e548ac565360782025a7af3897f93de8a Mon Sep 17 00:00:00 2001 From: jueunk617 Date: Fri, 10 Oct 2025 10:22:03 +0900 Subject: [PATCH 3/7] =?UTF-8?q?Refactor:=20WebSocketMessageController?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=9D=B8=EC=A6=9D=EB=90=9C=20Principal=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=EB=A5=BC=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WebSocketMessageController.java | 30 +++++--- .../websocket/dto/HeartbeatMessage.java | 5 -- .../WebSocketMessageControllerTest.java | 75 ++++++++++--------- 3 files changed, 59 insertions(+), 51 deletions(-) delete mode 100644 src/main/java/com/back/global/websocket/dto/HeartbeatMessage.java 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 e133d518..59259bdb 100644 --- a/src/main/java/com/back/global/websocket/controller/WebSocketMessageController.java +++ b/src/main/java/com/back/global/websocket/controller/WebSocketMessageController.java @@ -1,7 +1,8 @@ package com.back.global.websocket.controller; import com.back.global.exception.CustomException; -import com.back.global.websocket.dto.HeartbeatMessage; +import java.security.Principal; +import com.back.global.security.user.CustomUserDetails; import com.back.global.websocket.service.WebSocketSessionManager; import com.back.global.websocket.util.WebSocketErrorHelper; import lombok.RequiredArgsConstructor; @@ -9,6 +10,7 @@ import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.handler.annotation.Payload; import org.springframework.messaging.simp.SimpMessageHeaderAccessor; +import org.springframework.security.core.Authentication; import org.springframework.stereotype.Controller; @Slf4j @@ -21,16 +23,18 @@ public class WebSocketMessageController { // Heartbeat 처리 @MessageMapping("/heartbeat") - public void handleHeartbeat(@Payload HeartbeatMessage message, + public void handleHeartbeat(Principal principal, SimpMessageHeaderAccessor headerAccessor) { try { - if (message.userId() != null) { - // TTL 10분으로 연장 - sessionManager.updateLastActivity(message.userId()); - log.debug("Heartbeat 처리 완료 - 사용자: {}", message.userId()); + // Principal에서 인증된 사용자 정보 추출 + if (principal instanceof Authentication auth && auth.getPrincipal() instanceof CustomUserDetails userDetails) { + Long userId = userDetails.getUserId(); + + sessionManager.updateLastActivity(userId); + log.debug("Heartbeat 처리 완료 - 사용자: {}", userId); } else { - log.warn("유효하지 않은 Heartbeat 메시지 수신: userId가 null"); - errorHelper.sendInvalidRequestError(headerAccessor.getSessionId(), "사용자 ID가 필요합니다"); + log.warn("인증되지 않은 Heartbeat 요청: {}", headerAccessor.getSessionId()); + errorHelper.sendUnauthorizedError(headerAccessor.getSessionId()); } } catch (CustomException e) { log.error("Heartbeat 처리 실패: {}", e.getMessage()); @@ -43,12 +47,14 @@ public void handleHeartbeat(@Payload HeartbeatMessage message, // 사용자 활동 신호 처리 @MessageMapping("/activity") - public void handleActivity(@Payload HeartbeatMessage message, + public void handleActivity(Principal principal, SimpMessageHeaderAccessor headerAccessor) { try { - if (message.userId() != null) { - sessionManager.updateLastActivity(message.userId()); - log.debug("사용자 활동 신호 처리 완료 - 사용자: {}", message.userId()); + if (principal instanceof Authentication auth && auth.getPrincipal() instanceof CustomUserDetails userDetails) { + Long userId = userDetails.getUserId(); + + sessionManager.updateLastActivity(userId); + log.debug("사용자 활동 신호 처리 완료 - 사용자: {}", userId); } else { log.warn("유효하지 않은 활동 신호: userId가 null"); errorHelper.sendInvalidRequestError(headerAccessor.getSessionId(), "사용자 ID가 필요합니다"); diff --git a/src/main/java/com/back/global/websocket/dto/HeartbeatMessage.java b/src/main/java/com/back/global/websocket/dto/HeartbeatMessage.java deleted file mode 100644 index b490a9c6..00000000 --- a/src/main/java/com/back/global/websocket/dto/HeartbeatMessage.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.back.global.websocket.dto; - -public record HeartbeatMessage( - Long userId -) {} diff --git a/src/test/java/com/back/global/websocket/controller/WebSocketMessageControllerTest.java b/src/test/java/com/back/global/websocket/controller/WebSocketMessageControllerTest.java index 8ccca987..e37f2cca 100644 --- a/src/test/java/com/back/global/websocket/controller/WebSocketMessageControllerTest.java +++ b/src/test/java/com/back/global/websocket/controller/WebSocketMessageControllerTest.java @@ -2,7 +2,7 @@ import com.back.global.exception.CustomException; import com.back.global.exception.ErrorCode; -import com.back.global.websocket.dto.HeartbeatMessage; +import com.back.global.security.user.CustomUserDetails; import com.back.global.websocket.service.WebSocketSessionManager; import com.back.global.websocket.util.WebSocketErrorHelper; import org.junit.jupiter.api.BeforeEach; @@ -14,6 +14,9 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.messaging.simp.SimpMessageHeaderAccessor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; + +import java.security.Principal; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @@ -33,64 +36,68 @@ class WebSocketMessageControllerTest { private SimpMessageHeaderAccessor headerAccessor; private Long userId; - private Long roomId; private String sessionId; @BeforeEach void setUp() { userId = 10L; - roomId = 1L; sessionId = "test-session-id"; headerAccessor = mock(SimpMessageHeaderAccessor.class); lenient().when(headerAccessor.getSessionId()).thenReturn(sessionId); } + // 테스트용 Mock Principal 객체를 생성하는 헬퍼 메소드 + private Principal createMockPrincipal(Long userId) { + CustomUserDetails mockUserDetails = mock(CustomUserDetails.class); + when(mockUserDetails.getUserId()).thenReturn(userId); + + return new UsernamePasswordAuthenticationToken(mockUserDetails, null, null); + } + @Nested @DisplayName("Heartbeat 처리") class HandleHeartbeatTest { @Test - @DisplayName("정상 - Heartbeat 처리") + @DisplayName("성공 - 인증된 사용자의 Heartbeat 처리") void t1() { // given - HeartbeatMessage message = new HeartbeatMessage(userId); + Principal mockPrincipal = createMockPrincipal(userId); doNothing().when(sessionManager).updateLastActivity(userId); // when - controller.handleHeartbeat(message, headerAccessor); + controller.handleHeartbeat(mockPrincipal, headerAccessor); // then verify(sessionManager).updateLastActivity(userId); - verify(errorHelper, never()).sendInvalidRequestError(anyString(), anyString()); - verify(errorHelper, never()).sendCustomExceptionToUser(anyString(), any()); - verify(errorHelper, never()).sendGenericErrorToUser(anyString(), any(), anyString()); + verifyNoInteractions(errorHelper); } @Test - @DisplayName("실패 - userId가 null") + @DisplayName("실패 - 인증 정보가 없는 경우") void t2() { // given - HeartbeatMessage message = new HeartbeatMessage(null); + Principal principal = null; // when - controller.handleHeartbeat(message, headerAccessor); + controller.handleHeartbeat(principal, headerAccessor); // then verify(sessionManager, never()).updateLastActivity(any()); - verify(errorHelper).sendInvalidRequestError(sessionId, "사용자 ID가 필요합니다"); + verify(errorHelper).sendUnauthorizedError(sessionId); } @Test @DisplayName("실패 - CustomException 발생") void t3() { // given - HeartbeatMessage message = new HeartbeatMessage(userId); + Principal mockPrincipal = createMockPrincipal(userId); CustomException exception = new CustomException(ErrorCode.BAD_REQUEST); doThrow(exception).when(sessionManager).updateLastActivity(userId); // when - controller.handleHeartbeat(message, headerAccessor); + controller.handleHeartbeat(mockPrincipal, headerAccessor); // then verify(sessionManager).updateLastActivity(userId); @@ -101,12 +108,12 @@ void t3() { @DisplayName("실패 - 일반 Exception 발생") void t4() { // given - HeartbeatMessage message = new HeartbeatMessage(userId); + Principal mockPrincipal = createMockPrincipal(userId); RuntimeException exception = new RuntimeException("예상치 못한 오류"); doThrow(exception).when(sessionManager).updateLastActivity(userId); // when - controller.handleHeartbeat(message, headerAccessor); + controller.handleHeartbeat(mockPrincipal, headerAccessor); // then verify(sessionManager).updateLastActivity(userId); @@ -123,46 +130,46 @@ void t4() { class HandleActivityTest { @Test - @DisplayName("정상 - 활동 신호 처리") - void t13() { + @DisplayName("성공 - 인증된 사용자의 활동 신호 처리") + void t1() { // given - HeartbeatMessage message = new HeartbeatMessage(userId); + Principal mockPrincipal = createMockPrincipal(userId); doNothing().when(sessionManager).updateLastActivity(userId); // when - controller.handleActivity(message, headerAccessor); + controller.handleActivity(mockPrincipal, headerAccessor); // then verify(sessionManager).updateLastActivity(userId); - verify(errorHelper, never()).sendInvalidRequestError(anyString(), anyString()); - verify(errorHelper, never()).sendCustomExceptionToUser(anyString(), any()); - verify(errorHelper, never()).sendGenericErrorToUser(anyString(), any(), anyString()); + verifyNoInteractions(errorHelper); } @Test - @DisplayName("실패 - userId가 null") - void t14() { + @DisplayName("실패 - 인증 정보가 없는 경우") + void t2() { // given - HeartbeatMessage message = new HeartbeatMessage(null); + Principal principal = null; // when - controller.handleActivity(message, headerAccessor); + controller.handleActivity(principal, headerAccessor); // then verify(sessionManager, never()).updateLastActivity(any()); + + // handleActivity의 else 블록에 맞춰 검증 로직 수정 verify(errorHelper).sendInvalidRequestError(sessionId, "사용자 ID가 필요합니다"); } @Test @DisplayName("실패 - CustomException 발생") - void t15() { + void t3() { // given - HeartbeatMessage message = new HeartbeatMessage(userId); + Principal mockPrincipal = createMockPrincipal(userId); CustomException exception = new CustomException(ErrorCode.BAD_REQUEST); doThrow(exception).when(sessionManager).updateLastActivity(userId); // when - controller.handleActivity(message, headerAccessor); + controller.handleActivity(mockPrincipal, headerAccessor); // then verify(sessionManager).updateLastActivity(userId); @@ -171,14 +178,14 @@ void t15() { @Test @DisplayName("실패 - 일반 Exception 발생") - void t16() { + void t4() { // given - HeartbeatMessage message = new HeartbeatMessage(userId); + Principal mockPrincipal = createMockPrincipal(userId); RuntimeException exception = new RuntimeException("예상치 못한 오류"); doThrow(exception).when(sessionManager).updateLastActivity(userId); // when - controller.handleActivity(message, headerAccessor); + controller.handleActivity(mockPrincipal, headerAccessor); // then verify(sessionManager).updateLastActivity(userId); From 3870cc5c59895e84015d25f256587f465bd2daf2 Mon Sep 17 00:00:00 2001 From: jueunk617 Date: Fri, 10 Oct 2025 10:33:13 +0900 Subject: [PATCH 4/7] =?UTF-8?q?Refactor:=20KEYS=20=EB=8C=80=EC=8B=A0=20Red?= =?UTF-8?q?is=20=EC=B9=B4=EC=9A=B4=ED=84=B0=EB=A5=BC=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=ED=95=98=EC=97=AC=20=EC=98=A8=EB=9D=BC=EC=9D=B8=20=EC=9C=A0?= =?UTF-8?q?=EC=A0=80=20=EC=88=98=20=EC=A1=B0=ED=9A=8C=20=EC=84=B1=EB=8A=A5?= =?UTF-8?q?=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../websocket/config/WebSocketConstants.java | 7 +++++ .../websocket/service/UserSessionService.java | 5 ++++ .../websocket/store/RedisSessionStore.java | 30 +++++++++++++++++-- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/back/global/websocket/config/WebSocketConstants.java b/src/main/java/com/back/global/websocket/config/WebSocketConstants.java index 102bb1d3..c57c5d6e 100644 --- a/src/main/java/com/back/global/websocket/config/WebSocketConstants.java +++ b/src/main/java/com/back/global/websocket/config/WebSocketConstants.java @@ -46,6 +46,13 @@ private WebSocketConstants() { public static final String ROOM_USERS_KEY_PREFIX = "ws:room:"; public static final String ROOM_USERS_KEY_SUFFIX = ":users"; + /** + * 전체 온라인 사용자 수 저장 Key + * - 패턴: ws:online_users:count + * - 값: Long (카운트) + */ + public static final String ONLINE_USER_COUNT_KEY = "ws:online_users:count"; + // ===== Key 빌더 헬퍼 메서드 ===== public static String buildUserSessionKey(Long userId) { diff --git a/src/main/java/com/back/global/websocket/service/UserSessionService.java b/src/main/java/com/back/global/websocket/service/UserSessionService.java index bb45a051..0a7256ce 100644 --- a/src/main/java/com/back/global/websocket/service/UserSessionService.java +++ b/src/main/java/com/back/global/websocket/service/UserSessionService.java @@ -34,6 +34,8 @@ public void registerSession(Long userId, String sessionId) { redisSessionStore.saveUserSession(userId, newSession); redisSessionStore.saveSessionUserMapping(sessionId, userId); + redisSessionStore.incrementOnlineUserCount(); + log.info("WebSocket 세션 등록 완료 - 사용자: {}, 세션: {}", userId, sessionId); } @@ -44,6 +46,9 @@ public void terminateSession(String sessionId) { if (userId != null) { redisSessionStore.deleteUserSession(userId); redisSessionStore.deleteSessionUserMapping(sessionId); + + redisSessionStore.decrementOnlineUserCount(); + log.info("WebSocket 세션 종료 완료 - 세션: {}, 사용자: {}", sessionId, userId); } else { log.warn("종료할 세션을 찾을 수 없음 - 세션: {}", sessionId); 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 b24e8866..0f4e34d8 100644 --- a/src/main/java/com/back/global/websocket/store/RedisSessionStore.java +++ b/src/main/java/com/back/global/websocket/store/RedisSessionStore.java @@ -182,11 +182,35 @@ public long getRoomUserCount(Long roomId) { public long getTotalOnlineUserCount() { try { - Set userKeys = redisTemplate.keys(WebSocketConstants.buildUserSessionKeyPattern()); - return userKeys != null ? userKeys.size() : 0; + // 카운터 키에서 직접 값을 가져옴 + Object count = redisTemplate.opsForValue().get(WebSocketConstants.ONLINE_USER_COUNT_KEY); + if (count instanceof Number) { + return ((Number) count).longValue(); + } + return 0L; } catch (Exception e) { log.error("전체 온라인 사용자 수 조회 실패", e); - return 0; + return 0; // 에러 발생 시 0 반환 + } + } + + public void incrementOnlineUserCount() { + try { + redisTemplate.opsForValue().increment(WebSocketConstants.ONLINE_USER_COUNT_KEY); + } catch (Exception e) { + log.error("온라인 사용자 수 증가 실패", e); + } + } + + public void decrementOnlineUserCount() { + try { + // 카운터가 0보다 작아지지 않도록 방지 + Long currentValue = redisTemplate.opsForValue().decrement(WebSocketConstants.ONLINE_USER_COUNT_KEY); + if (currentValue != null && currentValue < 0) { + redisTemplate.opsForValue().set(WebSocketConstants.ONLINE_USER_COUNT_KEY, 0L); + } + } catch (Exception e) { + log.error("온라인 사용자 수 감소 실패", e); } } From 6937491f107d5939c0876c3d0dd2afea55fd93a7 Mon Sep 17 00:00:00 2001 From: jueunk617 Date: Fri, 10 Oct 2025 10:36:48 +0900 Subject: [PATCH 5/7] =?UTF-8?q?Refactor:=20WebSocketMessageController=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=A4=91=EC=95=99=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WebSocketMessageController.java | 72 +++++++++---------- 1 file changed, 33 insertions(+), 39 deletions(-) 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 59259bdb..5459eae4 100644 --- a/src/main/java/com/back/global/websocket/controller/WebSocketMessageController.java +++ b/src/main/java/com/back/global/websocket/controller/WebSocketMessageController.java @@ -1,18 +1,19 @@ package com.back.global.websocket.controller; import com.back.global.exception.CustomException; -import java.security.Principal; import com.back.global.security.user.CustomUserDetails; import com.back.global.websocket.service.WebSocketSessionManager; import com.back.global.websocket.util.WebSocketErrorHelper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.handler.annotation.MessageExceptionHandler; import org.springframework.messaging.handler.annotation.MessageMapping; -import org.springframework.messaging.handler.annotation.Payload; import org.springframework.messaging.simp.SimpMessageHeaderAccessor; import org.springframework.security.core.Authentication; import org.springframework.stereotype.Controller; +import java.security.Principal; + @Slf4j @Controller @RequiredArgsConstructor @@ -23,48 +24,41 @@ public class WebSocketMessageController { // Heartbeat 처리 @MessageMapping("/heartbeat") - public void handleHeartbeat(Principal principal, - SimpMessageHeaderAccessor headerAccessor) { - try { - // Principal에서 인증된 사용자 정보 추출 - if (principal instanceof Authentication auth && auth.getPrincipal() instanceof CustomUserDetails userDetails) { - Long userId = userDetails.getUserId(); - - sessionManager.updateLastActivity(userId); - log.debug("Heartbeat 처리 완료 - 사용자: {}", userId); - } else { - log.warn("인증되지 않은 Heartbeat 요청: {}", headerAccessor.getSessionId()); - errorHelper.sendUnauthorizedError(headerAccessor.getSessionId()); - } - } catch (CustomException e) { - log.error("Heartbeat 처리 실패: {}", e.getMessage()); - errorHelper.sendCustomExceptionToUser(headerAccessor.getSessionId(), e); - } catch (Exception e) { - log.error("Heartbeat 처리 중 예상치 못한 오류", e); - errorHelper.sendGenericErrorToUser(headerAccessor.getSessionId(), e, "Heartbeat 처리 중 오류가 발생했습니다"); + public void handleHeartbeat(Principal principal, SimpMessageHeaderAccessor headerAccessor) { + if (principal instanceof Authentication auth && auth.getPrincipal() instanceof CustomUserDetails userDetails) { + Long userId = userDetails.getUserId(); + sessionManager.updateLastActivity(userId); + log.debug("Heartbeat 처리 완료 - 사용자: {}", userId); + } else { + log.warn("인증되지 않은 Heartbeat 요청: {}", headerAccessor.getSessionId()); + errorHelper.sendUnauthorizedError(headerAccessor.getSessionId()); } } // 사용자 활동 신호 처리 @MessageMapping("/activity") - public void handleActivity(Principal principal, - SimpMessageHeaderAccessor headerAccessor) { - try { - if (principal instanceof Authentication auth && auth.getPrincipal() instanceof CustomUserDetails userDetails) { - Long userId = userDetails.getUserId(); - - sessionManager.updateLastActivity(userId); - log.debug("사용자 활동 신호 처리 완료 - 사용자: {}", userId); - } else { - log.warn("유효하지 않은 활동 신호: userId가 null"); - errorHelper.sendInvalidRequestError(headerAccessor.getSessionId(), "사용자 ID가 필요합니다"); - } - } catch (CustomException e) { - log.error("활동 신호 처리 실패: {}", e.getMessage()); - errorHelper.sendCustomExceptionToUser(headerAccessor.getSessionId(), e); - } catch (Exception e) { - log.error("활동 신호 처리 중 예상치 못한 오류", e); - errorHelper.sendGenericErrorToUser(headerAccessor.getSessionId(), e, "활동 신호 처리 중 오류가 발생했습니다"); + public void handleActivity(Principal principal, SimpMessageHeaderAccessor headerAccessor) { + if (principal instanceof Authentication auth && auth.getPrincipal() instanceof CustomUserDetails userDetails) { + Long userId = userDetails.getUserId(); + sessionManager.updateLastActivity(userId); + log.debug("사용자 활동 신호 처리 완료 - 사용자: {}", userId); + } else { + log.warn("유효하지 않은 활동 신호: 인증 정보 없음"); + errorHelper.sendInvalidRequestError(headerAccessor.getSessionId(), "사용자 ID가 필요합니다"); } } + + // WebSocket 메시지 처리 중 발생하는 CustomException 처리 + @MessageExceptionHandler(CustomException.class) + public void handleCustomException(CustomException e, SimpMessageHeaderAccessor headerAccessor) { + log.error("WebSocket 처리 중 CustomException 발생: {}", e.getMessage()); + errorHelper.sendCustomExceptionToUser(headerAccessor.getSessionId(), e); + } + + // 예상치 못한 모든 Exception 처리 + @MessageExceptionHandler(Exception.class) + public void handleGeneralException(Exception e, SimpMessageHeaderAccessor headerAccessor) { + log.error("WebSocket 처리 중 예상치 못한 오류 발생", e); + errorHelper.sendGenericErrorToUser(headerAccessor.getSessionId(), e, "요청 처리 중 서버 오류가 발생했습니다."); + } } \ No newline at end of file From 387235d6f3391771151c2f3bfc55639b4aef2eee Mon Sep 17 00:00:00 2001 From: jueunk617 Date: Fri, 10 Oct 2025 10:50:57 +0900 Subject: [PATCH 6/7] =?UTF-8?q?Test:=20WebSocketMessageControllerTest=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WebSocketMessageControllerTest.java | 71 +++++++++---------- 1 file changed, 33 insertions(+), 38 deletions(-) diff --git a/src/test/java/com/back/global/websocket/controller/WebSocketMessageControllerTest.java b/src/test/java/com/back/global/websocket/controller/WebSocketMessageControllerTest.java index e37f2cca..80a1d442 100644 --- a/src/test/java/com/back/global/websocket/controller/WebSocketMessageControllerTest.java +++ b/src/test/java/com/back/global/websocket/controller/WebSocketMessageControllerTest.java @@ -18,6 +18,7 @@ import java.security.Principal; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @@ -89,39 +90,37 @@ void t2() { } @Test - @DisplayName("실패 - CustomException 발생") + @DisplayName("실패 - CustomException 발생 시 예외를 그대로 던짐") void t3() { // given Principal mockPrincipal = createMockPrincipal(userId); - CustomException exception = new CustomException(ErrorCode.BAD_REQUEST); - doThrow(exception).when(sessionManager).updateLastActivity(userId); + CustomException expectedException = new CustomException(ErrorCode.BAD_REQUEST); + doThrow(expectedException).when(sessionManager).updateLastActivity(userId); - // when - controller.handleHeartbeat(mockPrincipal, headerAccessor); + // when & then + assertThrows(CustomException.class, () -> { + controller.handleHeartbeat(mockPrincipal, headerAccessor); + }); - // then verify(sessionManager).updateLastActivity(userId); - verify(errorHelper).sendCustomExceptionToUser(sessionId, exception); + verifyNoInteractions(errorHelper); } @Test - @DisplayName("실패 - 일반 Exception 발생") + @DisplayName("실패 - 일반 Exception 발생 시 예외를 그대로 던짐") void t4() { // given Principal mockPrincipal = createMockPrincipal(userId); - RuntimeException exception = new RuntimeException("예상치 못한 오류"); - doThrow(exception).when(sessionManager).updateLastActivity(userId); + RuntimeException expectedException = new RuntimeException("예상치 못한 오류"); + doThrow(expectedException).when(sessionManager).updateLastActivity(userId); - // when - controller.handleHeartbeat(mockPrincipal, headerAccessor); + // when & then + assertThrows(RuntimeException.class, () -> { + controller.handleHeartbeat(mockPrincipal, headerAccessor); + }); - // then verify(sessionManager).updateLastActivity(userId); - verify(errorHelper).sendGenericErrorToUser( - eq(sessionId), - any(Exception.class), - eq("Heartbeat 처리 중 오류가 발생했습니다") - ); + verifyNoInteractions(errorHelper); } } @@ -155,45 +154,41 @@ void t2() { // then verify(sessionManager, never()).updateLastActivity(any()); - - // handleActivity의 else 블록에 맞춰 검증 로직 수정 verify(errorHelper).sendInvalidRequestError(sessionId, "사용자 ID가 필요합니다"); } @Test - @DisplayName("실패 - CustomException 발생") + @DisplayName("실패 - CustomException 발생 시 예외를 그대로 던짐") void t3() { // given Principal mockPrincipal = createMockPrincipal(userId); - CustomException exception = new CustomException(ErrorCode.BAD_REQUEST); - doThrow(exception).when(sessionManager).updateLastActivity(userId); + CustomException expectedException = new CustomException(ErrorCode.BAD_REQUEST); + doThrow(expectedException).when(sessionManager).updateLastActivity(userId); - // when - controller.handleActivity(mockPrincipal, headerAccessor); + // when & then + assertThrows(CustomException.class, () -> { + controller.handleActivity(mockPrincipal, headerAccessor); + }); - // then verify(sessionManager).updateLastActivity(userId); - verify(errorHelper).sendCustomExceptionToUser(sessionId, exception); + verifyNoInteractions(errorHelper); } @Test - @DisplayName("실패 - 일반 Exception 발생") + @DisplayName("실패 - 일반 Exception 발생 시 예외를 그대로 던짐") void t4() { // given Principal mockPrincipal = createMockPrincipal(userId); - RuntimeException exception = new RuntimeException("예상치 못한 오류"); - doThrow(exception).when(sessionManager).updateLastActivity(userId); + RuntimeException expectedException = new RuntimeException("예상치 못한 오류"); + doThrow(expectedException).when(sessionManager).updateLastActivity(userId); - // when - controller.handleActivity(mockPrincipal, headerAccessor); + // when & then + assertThrows(RuntimeException.class, () -> { + controller.handleActivity(mockPrincipal, headerAccessor); + }); - // then verify(sessionManager).updateLastActivity(userId); - verify(errorHelper).sendGenericErrorToUser( - eq(sessionId), - any(Exception.class), - eq("활동 신호 처리 중 오류가 발생했습니다") - ); + verifyNoInteractions(errorHelper); } } } \ No newline at end of file From 7d00bc06a3d2d09b3c50df30184e19660439b3eb Mon Sep 17 00:00:00 2001 From: jueunk617 Date: Fri, 10 Oct 2025 11:21:46 +0900 Subject: [PATCH 7/7] =?UTF-8?q?Feat:=20currentParticipants=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=B4=88=EA=B8=B0?= =?UTF-8?q?=EA=B0=92=20=EC=84=A4=EC=A0=95=20+=20=EC=A0=84=EC=B2=B4=20?= =?UTF-8?q?=EC=98=A8=EB=9D=BC=EC=9D=B8=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=88=98=20=EC=A1=B0=ED=9A=8C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=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 | 5 +++++ .../store/RedisSessionStoreTest.java | 20 +++++++------------ 2 files changed, 12 insertions(+), 13 deletions(-) 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 f47ab5d7..21880398 100644 --- a/src/main/java/com/back/domain/studyroom/entity/Room.java +++ b/src/main/java/com/back/domain/studyroom/entity/Room.java @@ -29,6 +29,10 @@ public class Room extends BaseEntity { private boolean allowAudio; private boolean allowScreenShare; + @Builder.Default + @Column(nullable = false) + private Integer currentParticipants = 0; + // 방 상태 @Builder.Default @Enumerated(EnumType.STRING) @@ -161,6 +165,7 @@ 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; room.createdBy = creator; room.theme = theme; diff --git a/src/test/java/com/back/global/websocket/store/RedisSessionStoreTest.java b/src/test/java/com/back/global/websocket/store/RedisSessionStoreTest.java index 1d3406fc..41c9c270 100644 --- a/src/test/java/com/back/global/websocket/store/RedisSessionStoreTest.java +++ b/src/test/java/com/back/global/websocket/store/RedisSessionStoreTest.java @@ -267,23 +267,17 @@ void t13() { @Test @DisplayName("전체 온라인 사용자 수 조회") void t14() { - // given - Long userId1 = 15L; - Long userId2 = 16L; - Long userId3 = 17L; - - WebSocketSessionInfo session1 = WebSocketSessionInfo.createNewSession(userId1, "session-1"); - WebSocketSessionInfo session2 = WebSocketSessionInfo.createNewSession(userId2, "session-2"); - WebSocketSessionInfo session3 = WebSocketSessionInfo.createNewSession(userId3, "session-3"); - - // when - redisSessionStore.saveUserSession(userId1, session1); - redisSessionStore.saveUserSession(userId2, session2); - redisSessionStore.saveUserSession(userId3, session3); + // given & when + // 세션 저장 대신, 카운터 증가 메서드를 직접 3번 호출 + redisSessionStore.incrementOnlineUserCount(); + redisSessionStore.incrementOnlineUserCount(); + redisSessionStore.incrementOnlineUserCount(); + // 카운터 값을 조회 long totalCount = redisSessionStore.getTotalOnlineUserCount(); // then + // 증가된 카운터 값이 3인지 확인 assertThat(totalCount).isEqualTo(3); }