Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import com.back.global.exception.CustomException;
import com.back.global.security.user.CustomUserDetails;
import com.back.domain.chat.room.service.RoomChatService;
import com.back.global.websocket.util.WebSocketAuthHelper;
import com.back.global.websocket.util.WebSocketErrorHelper;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
Expand All @@ -29,6 +30,7 @@ public class RoomChatWebSocketController {

private final RoomChatService roomChatService;
private final SimpMessagingTemplate messagingTemplate;
private final WebSocketAuthHelper authHelper;
private final WebSocketErrorHelper errorHelper;

/**
Expand All @@ -43,7 +45,8 @@ public void handleRoomChat(@DestinationVariable Long roomId,

try {
// WebSocket에서 인증된 사용자 정보 추출
CustomUserDetails userDetails = extractUserDetails(principal);
CustomUserDetails userDetails = authHelper.extractUserDetails(principal);

if (userDetails == null) {
errorHelper.sendUnauthorizedError(headerAccessor.getSessionId());
return;
Expand Down Expand Up @@ -101,7 +104,8 @@ public void clearRoomChat(@DestinationVariable Long roomId,
log.info("WebSocket 채팅 일괄 삭제 요청 - roomId: {}", roomId);

// 사용자 인증 확인
CustomUserDetails userDetails = extractUserDetails(principal);
CustomUserDetails userDetails = authHelper.extractUserDetails(principal);

if (userDetails == null) {
errorHelper.sendUnauthorizedError(headerAccessor.getSessionId());
return;
Expand Down Expand Up @@ -148,15 +152,4 @@ public void clearRoomChat(@DestinationVariable Long roomId,
}
}

// WebSocket Principal에서 CustomUserDetails 추출
private CustomUserDetails extractUserDetails(Principal principal) {
if (principal instanceof Authentication auth) {
Object principalObj = auth.getPrincipal();
if (principalObj instanceof CustomUserDetails userDetails) {
return userDetails;
}
}
return null;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ public class WebSocketSessionManager {
private static final String SESSION_USER_KEY = "ws:session:{}";
private static final String ROOM_USERS_KEY = "ws:room:{}:users";

// TTL 설정 (10분)
private static final int SESSION_TTL_MINUTES = 10;
// TTL 설정
private static final int SESSION_TTL_MINUTES = 6;

// 사용자 세션 추가 (연결 시 호출)
public void addSession(Long userId, String sessionId) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.back.global.websocket.util;

import com.back.global.security.user.CustomUserDetails;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;

import java.security.Principal;

@Component
public class WebSocketAuthHelper {

// WebSocket에서 인증된 사용자 정보 추출
public static CustomUserDetails extractUserDetails(Principal principal) {
if (principal instanceof Authentication auth) {
Object principalObj = auth.getPrincipal();
if (principalObj instanceof CustomUserDetails userDetails) {
return userDetails;
}
}
return null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package com.back.global.websocket.webrtc.controller;

import com.back.global.common.dto.RsData;
import com.back.global.websocket.webrtc.dto.ice.IceServerConfig;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@Slf4j
@RestController
@RequestMapping("/api/webrtc")
@RequiredArgsConstructor
@Tag(name = "WebRTC API", description = "WebRTC 시그널링 및 ICE 서버 관련 REST API")
public class WebRTCApiController {

// ICE 서버 설정 조회
@GetMapping("/ice-servers")
@Operation(
summary = "ICE 서버 설정 조회",
description = "WebRTC 연결에 필요한 STUN/TURN 서버 정보를 조회합니다."
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "조회 성공"),
@ApiResponse(responseCode = "500", description = "서버 오류")
})
public ResponseEntity<RsData<IceServerConfig>> getIceServers(
@Parameter(description = "사용자 ID (선택)") @RequestParam(required = false) Long userId,
@Parameter(description = "방 ID (선택)") @RequestParam(required = false) Long roomId) {

log.info("ICE 서버 설정 요청 - userId: {}, roomId: {}", userId, roomId);

// 기본 Google STUN 서버 사용
IceServerConfig config = IceServerConfig.withDefaultStunServers();

log.info("ICE 서버 설정 제공 완료 - STUN 서버 {}개", config.iceServers().size());

return ResponseEntity
.status(HttpStatus.OK)
.body(RsData.success("ICE 서버 설정 조회 성공", config));
}

// WebRTC 서비스 상태 확인
@GetMapping("/health")
@Operation(
summary = "WebRTC 서비스 상태 확인",
description = "WebRTC 시그널링 서버의 상태를 확인합니다."
)
@ApiResponse(responseCode = "200", description = "정상 작동 중")
public ResponseEntity<RsData<String>> healthCheck() {
return ResponseEntity
.status(HttpStatus.OK)
.body(RsData.success("WebRTC 서비스 정상 작동 중"));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
package com.back.global.websocket.webrtc.controller;

import com.back.global.security.user.CustomUserDetails;
import com.back.global.websocket.webrtc.dto.media.WebRTCMediaToggleRequest;
import com.back.global.websocket.webrtc.dto.media.WebRTCMediaStateResponse;
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.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Controller;
import org.springframework.validation.annotation.Validated;

import java.security.Principal;

@Controller
@RequiredArgsConstructor
@Slf4j
public class WebRTCSignalingController {

private final SimpMessagingTemplate messagingTemplate;
private final WebSocketErrorHelper errorHelper;
private final WebRTCSignalValidator validator;

// WebRTC Offer 메시지 처리
@MessageMapping("/webrtc/offer")
public void handleOffer(@Validated @Payload WebRTCOfferRequest request,
SimpMessageHeaderAccessor headerAccessor,
Principal principal) {
try {
// WebSocket에서 인증된 사용자 정보 추출
CustomUserDetails userDetails = extractUserDetails(principal);
if (userDetails == null) {
errorHelper.sendUnauthorizedError(headerAccessor.getSessionId());
return;
}

Long fromUserId = userDetails.getUserId();

// 시그널 검증
validator.validateSignal(request.roomId(), fromUserId, request.targetUserId());

log.info("WebRTC Offer received - Room: {}, From: {}, To: {}, MediaType: {}",
request.roomId(), fromUserId, request.targetUserId(), request.mediaType());

// Offer 메시지 생성
WebRTCSignalResponse response = WebRTCSignalResponse.offerOrAnswer(
WebRTCSignalType.OFFER,
fromUserId,
request.targetUserId(),
request.roomId(),
request.sdp(),
request.mediaType()
);

// 방 전체에 브로드캐스트 (P2P Mesh 연결)
messagingTemplate.convertAndSend(
"/topic/room/" + request.roomId() + "/webrtc",
response
);

} catch (Exception e) {
log.error("WebRTC Offer 처리 중 오류 발생 - roomId: {}", request.roomId(), e);
errorHelper.sendGenericErrorToUser(headerAccessor.getSessionId(), e, "Offer 전송 중 오류가 발생했습니다");
}
}

// WebRTC Answer 메시지 처리
@MessageMapping("/webrtc/answer")
public void handleAnswer(@Validated @Payload WebRTCAnswerRequest request,
SimpMessageHeaderAccessor headerAccessor,
Principal principal) {
try {
// WebSocket에서 인증된 사용자 정보 추출
CustomUserDetails userDetails = extractUserDetails(principal);
if (userDetails == null) {
errorHelper.sendUnauthorizedError(headerAccessor.getSessionId());
return;
}

Long fromUserId = userDetails.getUserId();

// 시그널 검증
validator.validateSignal(request.roomId(), fromUserId, request.targetUserId());

log.info("WebRTC Answer received - Room: {}, From: {}, To: {}, MediaType: {}",
request.roomId(), fromUserId, request.targetUserId(), request.mediaType());

// Answer 메시지 생성
WebRTCSignalResponse response = WebRTCSignalResponse.offerOrAnswer(
WebRTCSignalType.ANSWER,
fromUserId,
request.targetUserId(),
request.roomId(),
request.sdp(),
request.mediaType()
);

// 방 전체에 브로드캐스트 (P2P Mesh 연결)
messagingTemplate.convertAndSend(
"/topic/room/" + request.roomId() + "/webrtc",
response
);

} catch (Exception e) {
log.error("WebRTC Answer 처리 중 오류 발생 - roomId: {}", request.roomId(), e);
errorHelper.sendGenericErrorToUser(headerAccessor.getSessionId(), e, "Answer 전송 중 오류가 발생했습니다");
}
}

// ICE Candidate 메시지 처리
@MessageMapping("/webrtc/ice-candidate")
public void handleIceCandidate(@Validated @Payload WebRTCIceCandidateRequest request,
SimpMessageHeaderAccessor headerAccessor,
Principal principal) {
try {
// WebSocket에서 인증된 사용자 정보 추출
CustomUserDetails userDetails = extractUserDetails(principal);
if (userDetails == null) {
errorHelper.sendUnauthorizedError(headerAccessor.getSessionId());
return;
}

Long fromUserId = userDetails.getUserId();

// 시그널 검증
validator.validateSignal(request.roomId(), fromUserId, request.targetUserId());

log.info("ICE Candidate received - Room: {}, From: {}, To: {}",
request.roomId(), fromUserId, request.targetUserId());

// ICE Candidate 메시지 생성
WebRTCSignalResponse response = WebRTCSignalResponse.iceCandidate(
fromUserId,
request.targetUserId(),
request.roomId(),
request.candidate(),
request.sdpMid(),
request.sdpMLineIndex()
);

// 방 전체에 브로드캐스트 (P2P Mesh 연결)
messagingTemplate.convertAndSend(
"/topic/room/" + request.roomId() + "/webrtc",
response
);

} catch (Exception e) {
log.error("ICE Candidate 처리 중 오류 발생 - roomId: {}", request.roomId(), e);
errorHelper.sendGenericErrorToUser(headerAccessor.getSessionId(), e, "ICE Candidate 전송 중 오류가 발생했습니다");
}
}

// 미디어 상태 토글 처리
@MessageMapping("/webrtc/media/toggle")
public void handleMediaToggle(@Validated @Payload WebRTCMediaToggleRequest request,
SimpMessageHeaderAccessor headerAccessor,
Principal principal) {
try {
// 인증된 사용자 정보 추출
CustomUserDetails userDetails = extractUserDetails(principal);
if (userDetails == null) {
errorHelper.sendUnauthorizedError(headerAccessor.getSessionId());
return;
}

Long userId = userDetails.getUserId();
String nickname = userDetails.getUsername();

// 미디어 상태 변경 검증
validator.validateMediaStateChange(request.roomId(), userId);

log.info("미디어 상태 변경 - Room: {}, User: {}, MediaType: {}, Enabled: {}",
request.roomId(), userId, request.mediaType(), request.enabled());

// 미디어 상태 응답 생성
WebRTCMediaStateResponse response = WebRTCMediaStateResponse.of(
userId,
nickname,
request.mediaType(),
request.enabled()
);

// 방 전체에 브로드캐스트
messagingTemplate.convertAndSend(
"/topic/room/" + request.roomId() + "/media-status",
response
);

} catch (Exception e) {
log.error("미디어 상태 변경 중 오류 발생 - roomId: {}", request.roomId(), e);
errorHelper.sendGenericErrorToUser(headerAccessor.getSessionId(), e, "미디어 상태 변경 중 오류가 발생했습니다");
}
}

// Principal에서 CustomUserDetails 추출 헬퍼 메서드
private CustomUserDetails extractUserDetails(Principal principal) {
if (principal instanceof Authentication auth) {
Object principalObj = auth.getPrincipal();
if (principalObj instanceof CustomUserDetails userDetails) {
return userDetails;
}
}
return null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.back.global.websocket.webrtc.dto.ice;

import com.fasterxml.jackson.annotation.JsonInclude;

@JsonInclude(JsonInclude.Include.NON_NULL)
public record IceServer(
String urls,
String username,
String credential
) {
// STUN 서버 (인증 불필요)
public static IceServer stun(String url) {
return new IceServer(url, null, null);
}

// TURN 서버 (인증 필요)
public static IceServer turn(String url, String username, String credential) {
return new IceServer(url, username, credential);
}
}
Loading