From c1c0e5b4014f7eae107bcd1deeb35d9bf7088bb1 Mon Sep 17 00:00:00 2001 From: jueunk617 Date: Tue, 14 Oct 2025 10:43:04 +0900 Subject: [PATCH 1/3] =?UTF-8?q?Refactor:=20WebRTC=20=EC=8B=9C=EA=B7=B8?= =?UTF-8?q?=EB=84=90=EB=A7=81=20=EB=B0=8F=20=EC=97=90=EB=9F=AC=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0=20-=20WebRT?= =?UTF-8?q?C=20=EC=8B=9C=EA=B7=B8=EB=84=90=EB=A7=81=EC=9D=84=20=EB=B0=A9?= =?UTF-8?q?=20=EC=A0=84=EC=B2=B4=20=ED=86=A0=ED=94=BD=20=EB=B8=8C=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=EC=BA=90=EC=8A=A4=ED=8A=B8=20=EB=B0=A9=EC=8B=9D?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=ED=86=B5=EC=9D=BC=ED=95=98=EC=97=AC=20?= =?UTF-8?q?=EC=95=88=EC=A0=95=EC=84=B1=20=EA=B0=95=ED=99=94=20-=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=20=EB=B0=9C=EC=83=9D=20=EC=8B=9C=20=EA=B0=9C?= =?UTF-8?q?=EC=9D=B8=20=EC=97=90=EB=9F=AC=20=ED=81=90(`/user/queue/errors`?= =?UTF-8?q?)=EB=A1=9C=20=EC=A0=84=EC=86=A1=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20-=20SDP=20=ED=98=95=EC=8B=9D=20=EB=8B=A4?= =?UTF-8?q?=EC=8B=9C=20String=EC=9C=BC=EB=A1=9C=20=EC=88=98=EC=A0=95=20-?= =?UTF-8?q?=20WebSocket=20=EB=B0=A9=20=EC=9E=85=EC=9E=A5=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8=20=EB=A9=94=EC=8B=9C=EC=A7=80=20API=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../websocket/config/WebSocketConfig.java | 46 ++++--- .../WebSocketMessageController.java | 26 ++++ .../controller/WebRTCSignalingController.java | 124 +++++++++++------- .../dto/signal/WebRTCAnswerRequest.java | 2 +- .../webrtc/dto/signal/WebRTCOfferRequest.java | 4 +- .../dto/signal/WebRTCSignalResponse.java | 4 +- 6 files changed, 141 insertions(+), 65 deletions(-) diff --git a/src/main/java/com/back/global/websocket/config/WebSocketConfig.java b/src/main/java/com/back/global/websocket/config/WebSocketConfig.java index 81e0da2d..11db0967 100644 --- a/src/main/java/com/back/global/websocket/config/WebSocketConfig.java +++ b/src/main/java/com/back/global/websocket/config/WebSocketConfig.java @@ -7,7 +7,6 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Configuration; import org.springframework.core.Ordered; -import org.springframework.context.annotation.Bean; import org.springframework.core.annotation.Order; import org.springframework.messaging.Message; import org.springframework.messaging.MessageChannel; @@ -17,8 +16,6 @@ import org.springframework.messaging.simp.stomp.StompHeaderAccessor; import org.springframework.messaging.support.ChannelInterceptor; import org.springframework.messaging.support.MessageHeaderAccessor; -import org.springframework.scheduling.TaskScheduler; -import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; import org.springframework.security.core.Authentication; import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; import org.springframework.web.socket.config.annotation.StompEndpointRegistry; @@ -87,30 +84,45 @@ public void registerStompEndpoints(StompEndpointRegistry registry) { */ @Override public void configureClientInboundChannel(ChannelRegistration registration) { - // JWT 인증 인터셉터 등록 registration.interceptors(new ChannelInterceptor() { @Override public Message preSend(Message message, MessageChannel channel) { StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); if (accessor != null) { - log.debug("WebSocket 메시지 처리 - Command: {}, Destination: {}, SessionId: {}", - accessor.getCommand(), accessor.getDestination(), accessor.getSessionId()); - // CONNECT 시점에서 JWT 토큰 인증 - if (StompCommand.CONNECT.equals(accessor.getCommand())) { - authenticateUser(accessor); + log.info("🔥 [INTERCEPT] Command: {}, Dest: {}, SessionId: {}", + accessor.getCommand(), + accessor.getDestination(), + accessor.getSessionId()); + + try { + // CONNECT 시점에서 JWT 토큰 인증 + if (StompCommand.CONNECT.equals(accessor.getCommand())) { + authenticateUser(accessor); + } + + // SEND 시점에서 인증 확인 및 활동 시간 업데이트 + else if (StompCommand.SEND.equals(accessor.getCommand())) { + log.info("🔥 [SEND] Dest: {}, User: {}", + accessor.getDestination(), + accessor.getUser() != null ? accessor.getUser().getName() : "null"); + + validateAuthenticationAndUpdateActivity(accessor); + } + } catch (Exception e) { + + log.error("🔥 [INTERCEPT ERROR] Command: {}, Dest: {}, Error: {}", + accessor.getCommand(), + accessor.getDestination(), + e.getMessage(), e); + + // 예외를 다시 던져서 메시지 차단 + throw e; } - - // SEND 시점에서 인증 확인 및 활동 시간 업데이트 - else if (StompCommand.SEND.equals(accessor.getCommand())) { - validateAuthenticationAndUpdateActivity(accessor); - } - - // SUBSCRIBE/UNSUBSCRIBE는 단순히 채팅 구독일 뿐 - // 실제 방 입장/퇴장은 RoomController에서 비즈니스 로직으로 처리 } + log.info("🔥 [INTERCEPT] Message passing through"); return message; } }); 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 5459eae4..24984778 100644 --- a/src/main/java/com/back/global/websocket/controller/WebSocketMessageController.java +++ b/src/main/java/com/back/global/websocket/controller/WebSocketMessageController.java @@ -3,16 +3,20 @@ import com.back.global.exception.CustomException; import com.back.global.security.user.CustomUserDetails; import com.back.global.websocket.service.WebSocketSessionManager; +import com.back.global.websocket.util.WebSocketAuthHelper; import com.back.global.websocket.util.WebSocketErrorHelper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.handler.annotation.DestinationVariable; 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; +import java.util.Map; @Slf4j @Controller @@ -22,6 +26,28 @@ public class WebSocketMessageController { private final WebSocketSessionManager sessionManager; private final WebSocketErrorHelper errorHelper; + // WebSocket 방 입장 확인 메시지 + // 클라이언트가 REST API로 입장 후 WebSocket 세션 동기화 대기를 위해 전송 + @MessageMapping("/rooms/{roomId}/join") + public void handleWebSocketJoinRoom(@DestinationVariable Long roomId, + @Payload Map payload, + Principal principal) { + CustomUserDetails userDetails = WebSocketAuthHelper.extractUserDetails(principal); + if (userDetails == null) { + log.warn("📥 [WebSocket] 방 입장 실패 - 인증 정보 없음"); + return; + } + + Long userId = userDetails.getUserId(); + log.info("📥 [WebSocket] 방 입장 확인 - roomId: {}, userId: {}", roomId, userId); + + // 활동 시간 업데이트 + sessionManager.updateLastActivity(userId); + + // 실제 방 입장 로직은 REST API에서 이미 처리했으므로 + // 여기서는 단순히 WebSocket 세션이 준비되었음을 확인하는 용도 + } + // Heartbeat 처리 @MessageMapping("/heartbeat") public void handleHeartbeat(Principal principal, SimpMessageHeaderAccessor headerAccessor) { diff --git a/src/main/java/com/back/global/websocket/webrtc/controller/WebRTCSignalingController.java b/src/main/java/com/back/global/websocket/webrtc/controller/WebRTCSignalingController.java index c5602e1a..9b3b811d 100644 --- a/src/main/java/com/back/global/websocket/webrtc/controller/WebRTCSignalingController.java +++ b/src/main/java/com/back/global/websocket/webrtc/controller/WebRTCSignalingController.java @@ -3,15 +3,12 @@ import com.back.global.exception.ErrorCode; import com.back.global.exception.CustomException; import com.back.global.security.user.CustomUserDetails; -import com.back.global.websocket.dto.WebSocketSessionInfo; -import com.back.global.websocket.service.WebSocketSessionManager; import com.back.global.websocket.util.WebSocketAuthHelper; import com.back.global.websocket.webrtc.dto.WebRTCErrorResponse; import com.back.global.websocket.webrtc.dto.media.WebRTCMediaStateResponse; import com.back.global.websocket.webrtc.dto.media.WebRTCMediaToggleRequest; import com.back.global.websocket.webrtc.dto.signal.*; import com.back.global.websocket.webrtc.service.WebRTCSignalValidator; -import com.back.global.websocket.util.WebSocketErrorHelper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.messaging.handler.annotation.MessageExceptionHandler; @@ -29,9 +26,7 @@ public class WebRTCSignalingController { private final SimpMessagingTemplate messagingTemplate; - private final WebSocketErrorHelper errorHelper; private final WebRTCSignalValidator validator; - private final WebSocketSessionManager sessionManager; // WebRTC Offer 메시지 처리 @MessageMapping("/webrtc/offer") @@ -44,21 +39,28 @@ public void handleOffer(@Validated @Payload WebRTCOfferRequest request, Principa Long fromUserId = userDetails.getUserId(); Long targetUserId = request.targetUserId(); - log.info("WebRTC Offer 처리 시작 - Room: {}, From: {}, To: {}", request.roomId(), fromUserId, targetUserId); + log.info("[WebRTC] Offer 처리 - Room: {}, From: {}, To: {}", request.roomId(), fromUserId, targetUserId); + // 같은 방에 있는지, 자기 자신에게 보내는 건 아닌지 검증 validator.validateSignal(request.roomId(), fromUserId, targetUserId); - WebSocketSessionInfo targetSessionInfo = sessionManager.getSessionInfo(targetUserId); - if (targetSessionInfo == null) { - log.warn("WebRTC Offer 전송 실패 - 대상이 오프라인 상태입니다. User ID: {}", targetUserId); - throw new CustomException(ErrorCode.WS_TARGET_OFFLINE); - } - + // Offer 응답 생성 WebRTCSignalResponse response = WebRTCSignalResponse.offerOrAnswer( - WebRTCSignalType.OFFER, fromUserId, targetUserId, request.roomId(), request.sdp(), request.mediaType() + WebRTCSignalType.OFFER, + fromUserId, + targetUserId, + request.roomId(), + request.sdp(), + request.mediaType() ); - messagingTemplate.convertAndSendToUser(targetSessionInfo.username(), "/queue/webrtc", response); + // 방 토픽으로 브로드캐스트 + messagingTemplate.convertAndSend( + "/topic/room/" + request.roomId() + "/webrtc", + response + ); + + log.debug("[WebRTC] Offer 전송 완료 - Room: {}, From: {}, To: {}", request.roomId(), fromUserId, targetUserId); } // WebRTC Answer 메시지 처리 @@ -72,21 +74,28 @@ public void handleAnswer(@Validated @Payload WebRTCAnswerRequest request, Princi Long fromUserId = userDetails.getUserId(); Long targetUserId = request.targetUserId(); - log.info("WebRTC Answer 처리 시작 - Room: {}, From: {}, To: {}", request.roomId(), fromUserId, targetUserId); + log.info("[WebRTC] Answer 처리 - Room: {}, From: {}, To: {}", request.roomId(), fromUserId, targetUserId); + // 같은 방에 있는지, 자기 자신에게 보내는 건 아닌지 검증 validator.validateSignal(request.roomId(), fromUserId, targetUserId); - WebSocketSessionInfo targetSessionInfo = sessionManager.getSessionInfo(targetUserId); - if (targetSessionInfo == null) { - log.warn("WebRTC Answer 전송 실패 - 대상이 오프라인 상태입니다. User ID: {}", targetUserId); - throw new CustomException(ErrorCode.WS_TARGET_OFFLINE); - } - + // Answer 응답 생성 (targetUserId 포함) WebRTCSignalResponse response = WebRTCSignalResponse.offerOrAnswer( - WebRTCSignalType.ANSWER, fromUserId, targetUserId, request.roomId(), request.sdp(), request.mediaType() + WebRTCSignalType.ANSWER, + fromUserId, + targetUserId, + request.roomId(), + request.sdp(), + request.mediaType() + ); + + // 방 토픽으로 브로드캐스트 + messagingTemplate.convertAndSend( + "/topic/room/" + request.roomId() + "/webrtc", + response ); - messagingTemplate.convertAndSendToUser(targetSessionInfo.username(), "/queue/webrtc", response); + log.debug("[WebRTC] Answer 전송 완료 - Room: {}, From: {}, To: {}", request.roomId(), fromUserId, targetUserId); } // ICE Candidate 메시지 처리 @@ -100,23 +109,32 @@ public void handleIceCandidate(@Validated @Payload WebRTCIceCandidateRequest req Long fromUserId = userDetails.getUserId(); Long targetUserId = request.targetUserId(); - log.debug("WebRTC ICE Candidate 처리 시작 - Room: {}, From: {}, To: {}", request.roomId(), fromUserId, targetUserId); + log.debug("[WebRTC] ICE Candidate 처리 - Room: {}, From: {}, To: {}", request.roomId(), fromUserId, targetUserId); + // 같은 방에 있는지, 자기 자신에게 보내는 건 아닌지 검증 validator.validateSignal(request.roomId(), fromUserId, targetUserId); - WebSocketSessionInfo targetSessionInfo = sessionManager.getSessionInfo(targetUserId); - if (targetSessionInfo == null) { - return; // ICE Candidate는 실패해도 에러를 보내지 않고 조용히 무시 - } - + // ICE Candidate 응답 생성 WebRTCSignalResponse response = WebRTCSignalResponse.iceCandidate( - fromUserId, targetUserId, request.roomId(), request.candidate(), request.sdpMid(), request.sdpMLineIndex() + fromUserId, + targetUserId, + request.roomId(), + request.candidate(), + request.sdpMid(), + request.sdpMLineIndex() ); - messagingTemplate.convertAndSendToUser(targetSessionInfo.username(), "/queue/webrtc", response); + // 방 토픽으로 브로드캐스트 + messagingTemplate.convertAndSend( + "/topic/room/" + request.roomId() + "/webrtc", + response + ); } - // 미디어 상태 토글 처리 (방 전체에 상태 공유) + /** + * 미디어 상태 토글 처리 + * 방 전체에 미디어 상태 변경 브로드캐스트 + */ @MessageMapping("/webrtc/media/toggle") public void handleMediaToggle(@Validated @Payload WebRTCMediaToggleRequest request, Principal principal) { CustomUserDetails userDetails = WebSocketAuthHelper.extractUserDetails(principal); @@ -125,53 +143,71 @@ public void handleMediaToggle(@Validated @Payload WebRTCMediaToggleRequest reque } Long userId = userDetails.getUserId(); - log.info("미디어 상태 변경 처리 시작 - Room: {}, User: {}", request.roomId(), userId); + log.info("[WebRTC] 미디어 상태 변경 - Room: {}, User: {}, Type: {}, Enabled: {}", + request.roomId(), userId, request.mediaType(), request.enabled()); + + // 방 멤버인지 검증 validator.validateMediaStateChange(request.roomId(), userId); + // 미디어 상태 응답 생성 WebRTCMediaStateResponse response = WebRTCMediaStateResponse.of( - userId, userDetails.getUsername(), request.mediaType(), request.enabled() + userId, + userDetails.getUsername(), + request.mediaType(), + request.enabled() ); - messagingTemplate.convertAndSend("/topic/room/" + request.roomId() + "/media-status", response); + // 방 전체에 미디어 상태 브로드캐스트 + messagingTemplate.convertAndSend( + "/topic/room/" + request.roomId() + "/media-status", + response + ); } // WebRTC 시그널링 처리 중 발생하는 CustomException 처리 @MessageExceptionHandler(CustomException.class) public void handleCustomException(CustomException e, Principal principal) { if (principal == null) { - log.warn("인증 정보 없는 사용자의 WebRTC 오류: {}", e.getMessage()); + log.warn("[WebRTC] 인증 정보 없는 사용자의 오류: {}", e.getMessage()); return; } - log.warn("WebRTC 시그널링 오류 발생 (to {}): {}", principal.getName(), e.getMessage()); + CustomUserDetails userDetails = WebSocketAuthHelper.extractUserDetails(principal); + if (userDetails == null) { + log.warn("[WebRTC] Principal에서 사용자 정보 추출 실패"); + return; + } + log.warn("[WebRTC] 시그널링 오류 - User: {} (ID: {}), Error: {}", + principal.getName(), userDetails.getUserId(), e.getMessage()); + + // 에러는 개인 큐로 전송 (방 전체에 보낼 필요 없음) WebRTCErrorResponse errorResponse = WebRTCErrorResponse.from(e); messagingTemplate.convertAndSendToUser( - principal.getName(), // 에러를 발생시킨 사람의 username - "/queue/webrtc", + principal.getName(), + "/queue/errors", // 에러 전용 큐 errorResponse ); } // 예상치 못한 모든 Exception 처리 @MessageExceptionHandler(Exception.class) - public void handleGeneralException(Exception e, Principal principal) { // headerAccessor -> Principal + public void handleGeneralException(Exception e, Principal principal) { if (principal == null) { - log.error("WebRTC 처리 중 인증 정보 없는 사용자의 예외 발생", e); + log.error("[WebRTC] 인증 정보 없는 사용자의 예외 발생", e); return; } - log.error("WebRTC 시그널링 처리 중 예상치 못한 오류 발생 (to {})", principal.getName(), e); + log.error("[WebRTC] 예상치 못한 오류 발생 - User: {}", principal.getName(), e); - // CustomException으로 감싸서 일관된 형식의 에러 DTO를 생성 CustomException customException = new CustomException(ErrorCode.WS_INTERNAL_ERROR); WebRTCErrorResponse errorResponse = WebRTCErrorResponse.from(customException); messagingTemplate.convertAndSendToUser( principal.getName(), - "/queue/webrtc", + "/queue/errors", errorResponse ); } diff --git a/src/main/java/com/back/global/websocket/webrtc/dto/signal/WebRTCAnswerRequest.java b/src/main/java/com/back/global/websocket/webrtc/dto/signal/WebRTCAnswerRequest.java index f8499727..ca3d7cfe 100644 --- a/src/main/java/com/back/global/websocket/webrtc/dto/signal/WebRTCAnswerRequest.java +++ b/src/main/java/com/back/global/websocket/webrtc/dto/signal/WebRTCAnswerRequest.java @@ -6,7 +6,7 @@ public record WebRTCAnswerRequest( @NotNull Long roomId, @NotNull Long targetUserId, - @NotNull SdpData sdp, + @NotNull String sdp, @NotNull WebRTCMediaType mediaType ) { } \ No newline at end of file diff --git a/src/main/java/com/back/global/websocket/webrtc/dto/signal/WebRTCOfferRequest.java b/src/main/java/com/back/global/websocket/webrtc/dto/signal/WebRTCOfferRequest.java index 1bfe6ddc..6d07d0c0 100644 --- a/src/main/java/com/back/global/websocket/webrtc/dto/signal/WebRTCOfferRequest.java +++ b/src/main/java/com/back/global/websocket/webrtc/dto/signal/WebRTCOfferRequest.java @@ -1,12 +1,14 @@ package com.back.global.websocket.webrtc.dto.signal; import com.back.global.websocket.webrtc.dto.media.WebRTCMediaType; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import jakarta.validation.constraints.NotNull; +@JsonIgnoreProperties(ignoreUnknown = true) public record WebRTCOfferRequest( @NotNull Long roomId, @NotNull Long targetUserId, - @NotNull SdpData sdp, + @NotNull String sdp, @NotNull WebRTCMediaType mediaType ) { } \ No newline at end of file diff --git a/src/main/java/com/back/global/websocket/webrtc/dto/signal/WebRTCSignalResponse.java b/src/main/java/com/back/global/websocket/webrtc/dto/signal/WebRTCSignalResponse.java index f077ca24..59f8d2b4 100644 --- a/src/main/java/com/back/global/websocket/webrtc/dto/signal/WebRTCSignalResponse.java +++ b/src/main/java/com/back/global/websocket/webrtc/dto/signal/WebRTCSignalResponse.java @@ -9,7 +9,7 @@ public record WebRTCSignalResponse( Long fromUserId, Long targetUserId, Long roomId, - SdpData sdp, + String sdp, WebRTCMediaType mediaType, String candidate, String sdpMid, @@ -23,7 +23,7 @@ public static WebRTCSignalResponse offerOrAnswer( Long fromUserId, Long targetUserId, Long roomId, - SdpData sdp, + String sdp, WebRTCMediaType mediaType ) { return new WebRTCSignalResponse( From 0694cce0408d9b2ac57bfd2f55727aba20f4c68a Mon Sep 17 00:00:00 2001 From: jueunk617 Date: Tue, 14 Oct 2025 11:12:05 +0900 Subject: [PATCH 2/3] =?UTF-8?q?Fix:=20=EB=8D=94=20=EC=9D=B4=EC=83=81=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20DTO?= =?UTF-8?q?=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/WebSocketStatusResponse.java | 22 ------------------- .../websocket/webrtc/dto/signal/SdpData.java | 3 --- 2 files changed, 25 deletions(-) delete mode 100644 src/main/java/com/back/global/websocket/dto/WebSocketStatusResponse.java delete mode 100644 src/main/java/com/back/global/websocket/webrtc/dto/signal/SdpData.java diff --git a/src/main/java/com/back/global/websocket/dto/WebSocketStatusResponse.java b/src/main/java/com/back/global/websocket/dto/WebSocketStatusResponse.java deleted file mode 100644 index 1377d57d..00000000 --- a/src/main/java/com/back/global/websocket/dto/WebSocketStatusResponse.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.back.global.websocket.dto; - -import java.time.LocalDateTime; - -public record WebSocketStatusResponse( - boolean isConnected, - LocalDateTime connectedAt, - String sessionId, - Long currentRoomId, - LocalDateTime lastActiveAt -) { - - // 연결된 상태 응답 생성 - public static WebSocketStatusResponse connected(String sessionId, Long currentRoomId, LocalDateTime connectedAt, LocalDateTime lastActiveAt) { - return new WebSocketStatusResponse(true, connectedAt, sessionId, currentRoomId, lastActiveAt); - } - - // 연결 끊긴 상태 응답 생성 - public static WebSocketStatusResponse disconnected() { - return new WebSocketStatusResponse(false, null, null, null, null); - } -} diff --git a/src/main/java/com/back/global/websocket/webrtc/dto/signal/SdpData.java b/src/main/java/com/back/global/websocket/webrtc/dto/signal/SdpData.java deleted file mode 100644 index a7543e45..00000000 --- a/src/main/java/com/back/global/websocket/webrtc/dto/signal/SdpData.java +++ /dev/null @@ -1,3 +0,0 @@ -package com.back.global.websocket.webrtc.dto.signal; - -public record SdpData(String type, String sdp) {} \ No newline at end of file From ef9ff43bce231015a1a416e9cdef0262e5f7a655 Mon Sep 17 00:00:00 2001 From: jueunk617 Date: Tue, 14 Oct 2025 11:31:55 +0900 Subject: [PATCH 3/3] =?UTF-8?q?Test:=20=EB=B3=80=EA=B2=BD=EC=82=AC?= =?UTF-8?q?=ED=95=AD=EC=97=90=20=EB=94=B0=EB=9D=BC=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WebSocketMessageControllerTest.java | 93 +++++- .../WebRTCSignalingControllerTest.java | 292 +++++++++++++++--- 2 files changed, 345 insertions(+), 40 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 80a1d442..ae55026d 100644 --- a/src/test/java/com/back/global/websocket/controller/WebSocketMessageControllerTest.java +++ b/src/test/java/com/back/global/websocket/controller/WebSocketMessageControllerTest.java @@ -17,6 +17,8 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import java.security.Principal; +import java.util.HashMap; +import java.util.Map; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.*; @@ -56,6 +58,60 @@ private Principal createMockPrincipal(Long userId) { return new UsernamePasswordAuthenticationToken(mockUserDetails, null, null); } + @Nested + @DisplayName("WebSocket 방 입장 처리") + class HandleWebSocketJoinRoomTest { + + @Test + @DisplayName("성공 - 인증된 사용자의 방 입장 확인") + void t1() { + // given + Long roomId = 1L; + Map payload = new HashMap<>(); + Principal mockPrincipal = createMockPrincipal(userId); + doNothing().when(sessionManager).updateLastActivity(userId); + + // when + controller.handleWebSocketJoinRoom(roomId, payload, mockPrincipal); + + // then + verify(sessionManager).updateLastActivity(userId); + } + + @Test + @DisplayName("실패 - 인증 정보가 없는 경우 아무 동작도 하지 않음") + void t2() { + // given + Long roomId = 1L; + Map payload = new HashMap<>(); + Principal principal = null; + + // when + controller.handleWebSocketJoinRoom(roomId, payload, principal); + + // then + verify(sessionManager, never()).updateLastActivity(any(Long.class)); + } + + @Test + @DisplayName("실패 - CustomException 발생 시 예외를 그대로 던짐") + void t3() { + // given + Long roomId = 1L; + Map payload = new HashMap<>(); + Principal mockPrincipal = createMockPrincipal(userId); + CustomException expectedException = new CustomException(ErrorCode.BAD_REQUEST); + doThrow(expectedException).when(sessionManager).updateLastActivity(userId); + + // when & then + assertThrows(CustomException.class, () -> { + controller.handleWebSocketJoinRoom(roomId, payload, mockPrincipal); + }); + + verify(sessionManager).updateLastActivity(userId); + } + } + @Nested @DisplayName("Heartbeat 처리") class HandleHeartbeatTest { @@ -85,7 +141,7 @@ void t2() { controller.handleHeartbeat(principal, headerAccessor); // then - verify(sessionManager, never()).updateLastActivity(any()); + verify(sessionManager, never()).updateLastActivity(any(Long.class)); verify(errorHelper).sendUnauthorizedError(sessionId); } @@ -191,4 +247,39 @@ void t4() { verifyNoInteractions(errorHelper); } } + + @Nested + @DisplayName("예외 처리") + class ExceptionHandlerTest { + + @Test + @DisplayName("CustomException 처리 - ErrorHelper를 통해 에러 전송") + void t1() { + // given + CustomException exception = new CustomException(ErrorCode.ROOM_NOT_FOUND); + + // when + controller.handleCustomException(exception, headerAccessor); + + // then + verify(errorHelper).sendCustomExceptionToUser(sessionId, exception); + } + + @Test + @DisplayName("일반 Exception 처리 - ErrorHelper를 통해 일반 에러 전송") + void t2() { + // given + Exception exception = new RuntimeException("예상치 못한 오류"); + + // when + controller.handleGeneralException(exception, headerAccessor); + + // then + verify(errorHelper).sendGenericErrorToUser( + eq(sessionId), + eq(exception), + eq("요청 처리 중 서버 오류가 발생했습니다.") + ); + } + } } \ No newline at end of file diff --git a/src/test/java/com/back/global/websocket/webrtc/controller/WebRTCSignalingControllerTest.java b/src/test/java/com/back/global/websocket/webrtc/controller/WebRTCSignalingControllerTest.java index d006c4f1..58604275 100644 --- a/src/test/java/com/back/global/websocket/webrtc/controller/WebRTCSignalingControllerTest.java +++ b/src/test/java/com/back/global/websocket/webrtc/controller/WebRTCSignalingControllerTest.java @@ -3,8 +3,7 @@ import com.back.global.exception.CustomException; import com.back.global.exception.ErrorCode; import com.back.global.security.user.CustomUserDetails; -import com.back.global.websocket.dto.WebSocketSessionInfo; -import com.back.global.websocket.service.WebSocketSessionManager; +import com.back.global.websocket.webrtc.dto.WebRTCErrorResponse; import com.back.global.websocket.webrtc.dto.media.WebRTCMediaStateResponse; import com.back.global.websocket.webrtc.dto.media.WebRTCMediaToggleRequest; import com.back.global.websocket.webrtc.dto.media.WebRTCMediaType; @@ -22,8 +21,6 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; -import java.time.LocalDateTime; - import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.*; @@ -35,10 +32,9 @@ class WebRTCSignalingControllerTest { @Mock private SimpMessagingTemplate messagingTemplate; + @Mock private WebRTCSignalValidator validator; - @Mock - private WebSocketSessionManager sessionManager; @InjectMocks private WebRTCSignalingController controller; @@ -49,6 +45,7 @@ class WebRTCSignalingControllerTest { private String fromUsername; private Long targetUserId; private String targetUsername; + private String sdpContent; @BeforeEach void setUp() { @@ -57,6 +54,7 @@ void setUp() { fromUsername = "userA"; targetUserId = 20L; targetUsername = "userB"; + sdpContent = "v=0\r\no=- 123456789 2 IN IP4 127.0.0.1\r\n..."; CustomUserDetails userDetails = mock(CustomUserDetails.class); lenient().when(userDetails.getUserId()).thenReturn(fromUserId); @@ -69,110 +67,326 @@ void setUp() { class HandleOfferTest { @Test - @DisplayName("성공 - Offer 메시지를 특정 사용자에게 전송") + @DisplayName("성공 - Offer 메시지를 방 전체에 브로드캐스트") void t1() { // given - SdpData sdpData = new SdpData("offer", "sdp-content"); - WebRTCOfferRequest request = new WebRTCOfferRequest(roomId, targetUserId, sdpData, WebRTCMediaType.AUDIO); - WebSocketSessionInfo targetSession = new WebSocketSessionInfo(targetUserId, targetUsername, "session-id", LocalDateTime.now(), LocalDateTime.now(), null); - when(sessionManager.getSessionInfo(targetUserId)).thenReturn(targetSession); + WebRTCOfferRequest request = new WebRTCOfferRequest( + roomId, + targetUserId, + sdpContent, + WebRTCMediaType.AUDIO + ); // when controller.handleOffer(request, authentication); // then verify(validator).validateSignal(roomId, fromUserId, targetUserId); - verify(messagingTemplate).convertAndSendToUser(eq(targetUsername), eq("/queue/webrtc"), any(WebRTCSignalResponse.class)); + verify(messagingTemplate).convertAndSend( + eq("/topic/room/" + roomId + "/webrtc"), + (WebRTCSignalResponse) argThat((WebRTCSignalResponse response) -> + response.type() == WebRTCSignalType.OFFER && + response.fromUserId().equals(fromUserId) && + response.targetUserId().equals(targetUserId) && + response.roomId().equals(roomId) && + response.sdp().equals(sdpContent) && + response.mediaType() == WebRTCMediaType.AUDIO + ) + ); } @Test - @DisplayName("실패 - 대상 사용자가 오프라인일 때 예외를 던짐") + @DisplayName("실패 - 인증 정보가 없을 때 Unauthorized 예외를 던짐") void t2() { // given - SdpData sdpData = new SdpData("offer", "sdp-content"); - WebRTCOfferRequest request = new WebRTCOfferRequest(roomId, targetUserId, sdpData, WebRTCMediaType.AUDIO); - when(sessionManager.getSessionInfo(targetUserId)).thenReturn(null); + WebRTCOfferRequest request = new WebRTCOfferRequest( + roomId, + targetUserId, + sdpContent, + WebRTCMediaType.AUDIO + ); // when & then CustomException exception = assertThrows(CustomException.class, () -> - controller.handleOffer(request, authentication) + controller.handleOffer(request, null) ); - assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.WS_TARGET_OFFLINE); - verify(messagingTemplate, never()).convertAndSendToUser(anyString(), anyString(), any()); + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.UNAUTHORIZED); + verify(validator, never()).validateSignal(any(), any(), any()); + verify(messagingTemplate, never()).convertAndSend(anyString(), any(Object.class)); } @Test - @DisplayName("실패 - 인증 정보가 없을 때 Unauthorized 예외를 던짐") + @DisplayName("실패 - Validator에서 예외 발생 시 메시지를 전송하지 않음") void t3() { // given - SdpData sdpData = new SdpData("offer", "sdp-content"); - WebRTCOfferRequest request = new WebRTCOfferRequest(roomId, targetUserId, sdpData, WebRTCMediaType.AUDIO); + WebRTCOfferRequest request = new WebRTCOfferRequest( + roomId, + targetUserId, + sdpContent, + WebRTCMediaType.AUDIO + ); + doThrow(new CustomException(ErrorCode.ROOM_NOT_FOUND)) + .when(validator).validateSignal(roomId, fromUserId, targetUserId); // when & then - CustomException exception = assertThrows(CustomException.class, () -> - controller.handleOffer(request, null) // principal = null + assertThrows(CustomException.class, () -> + controller.handleOffer(request, authentication) ); - assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.UNAUTHORIZED); - verify(validator, never()).validateSignal(any(), any(), any()); + verify(messagingTemplate, never()).convertAndSend(anyString(), any(Object.class)); } } @Nested @DisplayName("Answer 처리") class HandleAnswerTest { + @Test - @DisplayName("성공 - Answer 메시지를 특정 사용자에게 전송") + @DisplayName("성공 - Answer 메시지를 방 전체에 브로드캐스트") void t1() { // given - SdpData sdpData = new SdpData("answer", "sdp-content"); - WebRTCAnswerRequest request = new WebRTCAnswerRequest(roomId, targetUserId, sdpData, WebRTCMediaType.AUDIO); - WebSocketSessionInfo targetSession = new WebSocketSessionInfo(targetUserId, targetUsername, "session-id", LocalDateTime.now(), LocalDateTime.now(), null); - when(sessionManager.getSessionInfo(targetUserId)).thenReturn(targetSession); + WebRTCAnswerRequest request = new WebRTCAnswerRequest( + roomId, + targetUserId, + sdpContent, + WebRTCMediaType.AUDIO + ); // when controller.handleAnswer(request, authentication); // then verify(validator).validateSignal(roomId, fromUserId, targetUserId); - verify(messagingTemplate).convertAndSendToUser(eq(targetUsername), eq("/queue/webrtc"), any(WebRTCSignalResponse.class)); + verify(messagingTemplate).convertAndSend( + eq("/topic/room/" + roomId + "/webrtc"), + (WebRTCSignalResponse) argThat((WebRTCSignalResponse response) -> + response.type() == WebRTCSignalType.ANSWER && + response.fromUserId().equals(fromUserId) && + response.targetUserId().equals(targetUserId) && + response.roomId().equals(roomId) && + response.sdp().equals(sdpContent) && + response.mediaType() == WebRTCMediaType.AUDIO + ) + ); + } + + @Test + @DisplayName("실패 - 인증 정보가 없을 때 Unauthorized 예외를 던짐") + void t2() { + // given + WebRTCAnswerRequest request = new WebRTCAnswerRequest( + roomId, + targetUserId, + sdpContent, + WebRTCMediaType.AUDIO + ); + + // when & then + CustomException exception = assertThrows(CustomException.class, () -> + controller.handleAnswer(request, null) + ); + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.UNAUTHORIZED); + verify(validator, never()).validateSignal(any(), any(), any()); + verify(messagingTemplate, never()).convertAndSend(anyString(), any(Object.class)); } } @Nested @DisplayName("ICE Candidate 처리") class HandleIceCandidateTest { + @Test - @DisplayName("성공 - ICE Candidate를 특정 사용자에게 전송") + @DisplayName("성공 - ICE Candidate를 방 전체에 브로드캐스트") void t1() { // given - WebRTCIceCandidateRequest request = new WebRTCIceCandidateRequest(roomId, targetUserId, "candidate", "audio", 0); - WebSocketSessionInfo targetSession = new WebSocketSessionInfo(targetUserId, targetUsername, "session-id", LocalDateTime.now(), LocalDateTime.now(), null); - when(sessionManager.getSessionInfo(targetUserId)).thenReturn(targetSession); + String candidateValue = "candidate:1 1 UDP 2130706431 192.168.1.1 54321 typ host"; + WebRTCIceCandidateRequest request = new WebRTCIceCandidateRequest( + roomId, + targetUserId, + candidateValue, + "audio", + 0 + ); // when controller.handleIceCandidate(request, authentication); // then verify(validator).validateSignal(roomId, fromUserId, targetUserId); - verify(messagingTemplate).convertAndSendToUser(eq(targetUsername), eq("/queue/webrtc"), any(WebRTCSignalResponse.class)); + verify(messagingTemplate).convertAndSend( + eq("/topic/room/" + roomId + "/webrtc"), + (WebRTCSignalResponse) argThat((WebRTCSignalResponse response) -> + response.type() == WebRTCSignalType.ICE_CANDIDATE && + response.fromUserId().equals(fromUserId) && + response.targetUserId().equals(targetUserId) && + response.roomId().equals(roomId) && + response.candidate().equals(candidateValue) && + response.sdpMid().equals("audio") && + response.sdpMLineIndex() == 0 + ) + ); + } + + @Test + @DisplayName("실패 - 인증 정보가 없을 때 Unauthorized 예외를 던짐") + void t2() { + // given + WebRTCIceCandidateRequest request = new WebRTCIceCandidateRequest( + roomId, + targetUserId, + "candidate", + "audio", + 0 + ); + + // when & then + CustomException exception = assertThrows(CustomException.class, () -> + controller.handleIceCandidate(request, null) + ); + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.UNAUTHORIZED); + verify(validator, never()).validateSignal(any(), any(), any()); + verify(messagingTemplate, never()).convertAndSend(anyString(), any(Object.class)); } } @Nested @DisplayName("미디어 상태 토글") class HandleMediaToggleTest { + @Test @DisplayName("성공 - 미디어 상태를 방 전체에 브로드캐스트") void t1() { // given - WebRTCMediaToggleRequest request = new WebRTCMediaToggleRequest(roomId, WebRTCMediaType.AUDIO, true); + WebRTCMediaToggleRequest request = new WebRTCMediaToggleRequest( + roomId, + WebRTCMediaType.AUDIO, + true + ); // when controller.handleMediaToggle(request, authentication); // then verify(validator).validateMediaStateChange(roomId, fromUserId); - verify(messagingTemplate).convertAndSend(eq("/topic/room/" + roomId + "/media-status"), any(WebRTCMediaStateResponse.class)); + verify(messagingTemplate).convertAndSend( + eq("/topic/room/" + roomId + "/media-status"), + (WebRTCMediaStateResponse) argThat((WebRTCMediaStateResponse response) -> + response.userId().equals(fromUserId) && + response.nickname().equals(fromUsername) && + response.mediaType() == WebRTCMediaType.AUDIO && + response.enabled() + ) + ); + } + + @Test + @DisplayName("실패 - 인증 정보가 없을 때 Unauthorized 예외를 던짐") + void t2() { + // given + WebRTCMediaToggleRequest request = new WebRTCMediaToggleRequest( + roomId, + WebRTCMediaType.AUDIO, + true + ); + + // when & then + CustomException exception = assertThrows(CustomException.class, () -> + controller.handleMediaToggle(request, null) + ); + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.UNAUTHORIZED); + verify(validator, never()).validateMediaStateChange(any(), any()); + verify(messagingTemplate, never()).convertAndSend(anyString(), any(Object.class)); + } + + @Test + @DisplayName("실패 - Validator에서 예외 발생 시 메시지를 전송하지 않음") + void t3() { + // given + WebRTCMediaToggleRequest request = new WebRTCMediaToggleRequest( + roomId, + WebRTCMediaType.AUDIO, + true + ); + doThrow(new CustomException(ErrorCode.ROOM_NOT_FOUND)) + .when(validator).validateMediaStateChange(roomId, fromUserId); + + // when & then + assertThrows(CustomException.class, () -> + controller.handleMediaToggle(request, authentication) + ); + verify(messagingTemplate, never()).convertAndSend(anyString(), any(Object.class)); + } + } + + @Nested + @DisplayName("예외 처리") + class ExceptionHandlerTest { + + @Test + @DisplayName("CustomException 처리 - 사용자 큐로 에러 전송") + void t1() { + // given + CustomException exception = new CustomException(ErrorCode.ROOM_NOT_FOUND); + + // when + controller.handleCustomException(exception, authentication); + + // then + verify(messagingTemplate).convertAndSendToUser( + eq(fromUsername), + eq("/queue/errors"), + (WebRTCErrorResponse) argThat((WebRTCErrorResponse response) -> + response.type().equals("ERROR") && + response.error().code().equals(ErrorCode.ROOM_NOT_FOUND.getCode()) && + response.timestamp() != null + ) + ); + } + + @Test + @DisplayName("CustomException 처리 - Principal이 null인 경우 메시지 전송 안 함") + void t2() { + // given + CustomException exception = new CustomException(ErrorCode.ROOM_NOT_FOUND); + + // when + controller.handleCustomException(exception, null); + + // then + verify(messagingTemplate, never()).convertAndSendToUser(anyString(), anyString(), any(Object.class)); + } + + @Test + @DisplayName("일반 Exception 처리 - 사용자 큐로 내부 에러 전송") + void t3() { + // given + Exception exception = new RuntimeException("예상치 못한 오류"); + + // when + controller.handleGeneralException(exception, authentication); + + // then + verify(messagingTemplate).convertAndSendToUser( + eq(fromUsername), + eq("/queue/errors"), + (WebRTCErrorResponse) argThat((WebRTCErrorResponse response) -> + response.type().equals("ERROR") && + response.error().code().equals(ErrorCode.WS_INTERNAL_ERROR.getCode()) && + response.timestamp() != null + ) + ); + } + + @Test + @DisplayName("일반 Exception 처리 - Principal이 null인 경우 메시지 전송 안 함") + void t4() { + // given + Exception exception = new RuntimeException("예상치 못한 오류"); + + // when + controller.handleGeneralException(exception, null); + + // then + verify(messagingTemplate, never()).convertAndSendToUser(anyString(), anyString(), any(Object.class)); } } } \ No newline at end of file