Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 29 additions & 17 deletions src/main/java/com/back/global/websocket/config/WebSocketConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<String, Object> 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) {
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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")
Expand All @@ -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 메시지 처리
Expand All @@ -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 메시지 처리
Expand All @@ -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);
Expand All @@ -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
);
}
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
public record WebRTCAnswerRequest(
@NotNull Long roomId,
@NotNull Long targetUserId,
@NotNull SdpData sdp,
@NotNull String sdp,
@NotNull WebRTCMediaType mediaType
) {
}
Loading