Skip to content

Commit a231f6e

Browse files
authored
Merge pull request #122 from prgrms-web-devcourse-final-project/Feat/118
Feat: WebRTC 시그널링 서버 구축 (#118)
2 parents cfedf7e + 71a94bb commit a231f6e

17 files changed

+560
-21
lines changed

src/main/java/com/back/domain/chat/room/controller/RoomChatWebSocketController.java

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import com.back.global.exception.CustomException;
88
import com.back.global.security.user.CustomUserDetails;
99
import com.back.domain.chat.room.service.RoomChatService;
10+
import com.back.global.websocket.util.WebSocketAuthHelper;
1011
import com.back.global.websocket.util.WebSocketErrorHelper;
1112
import io.swagger.v3.oas.annotations.tags.Tag;
1213
import lombok.RequiredArgsConstructor;
@@ -29,6 +30,7 @@ public class RoomChatWebSocketController {
2930

3031
private final RoomChatService roomChatService;
3132
private final SimpMessagingTemplate messagingTemplate;
33+
private final WebSocketAuthHelper authHelper;
3234
private final WebSocketErrorHelper errorHelper;
3335

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

4446
try {
4547
// WebSocket에서 인증된 사용자 정보 추출
46-
CustomUserDetails userDetails = extractUserDetails(principal);
48+
CustomUserDetails userDetails = authHelper.extractUserDetails(principal);
49+
4750
if (userDetails == null) {
4851
errorHelper.sendUnauthorizedError(headerAccessor.getSessionId());
4952
return;
@@ -101,7 +104,8 @@ public void clearRoomChat(@DestinationVariable Long roomId,
101104
log.info("WebSocket 채팅 일괄 삭제 요청 - roomId: {}", roomId);
102105

103106
// 사용자 인증 확인
104-
CustomUserDetails userDetails = extractUserDetails(principal);
107+
CustomUserDetails userDetails = authHelper.extractUserDetails(principal);
108+
105109
if (userDetails == null) {
106110
errorHelper.sendUnauthorizedError(headerAccessor.getSessionId());
107111
return;
@@ -148,15 +152,4 @@ public void clearRoomChat(@DestinationVariable Long roomId,
148152
}
149153
}
150154

151-
// WebSocket Principal에서 CustomUserDetails 추출
152-
private CustomUserDetails extractUserDetails(Principal principal) {
153-
if (principal instanceof Authentication auth) {
154-
Object principalObj = auth.getPrincipal();
155-
if (principalObj instanceof CustomUserDetails userDetails) {
156-
return userDetails;
157-
}
158-
}
159-
return null;
160-
}
161-
162155
}

src/main/java/com/back/global/websocket/service/WebSocketSessionManager.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ public class WebSocketSessionManager {
2828
private static final String SESSION_USER_KEY = "ws:session:{}";
2929
private static final String ROOM_USERS_KEY = "ws:room:{}:users";
3030

31-
// TTL 설정 (10분)
32-
private static final int SESSION_TTL_MINUTES = 10;
31+
// TTL 설정
32+
private static final int SESSION_TTL_MINUTES = 6;
3333

3434
// 사용자 세션 추가 (연결 시 호출)
3535
public void addSession(Long userId, String sessionId) {
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package com.back.global.websocket.util;
2+
3+
import com.back.global.security.user.CustomUserDetails;
4+
import org.springframework.security.core.Authentication;
5+
import org.springframework.stereotype.Component;
6+
7+
import java.security.Principal;
8+
9+
@Component
10+
public class WebSocketAuthHelper {
11+
12+
// WebSocket에서 인증된 사용자 정보 추출
13+
public static CustomUserDetails extractUserDetails(Principal principal) {
14+
if (principal instanceof Authentication auth) {
15+
Object principalObj = auth.getPrincipal();
16+
if (principalObj instanceof CustomUserDetails userDetails) {
17+
return userDetails;
18+
}
19+
}
20+
return null;
21+
}
22+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package com.back.global.websocket.webrtc.controller;
2+
3+
import com.back.global.common.dto.RsData;
4+
import com.back.global.websocket.webrtc.dto.ice.IceServerConfig;
5+
import io.swagger.v3.oas.annotations.Operation;
6+
import io.swagger.v3.oas.annotations.Parameter;
7+
import io.swagger.v3.oas.annotations.responses.ApiResponse;
8+
import io.swagger.v3.oas.annotations.responses.ApiResponses;
9+
import io.swagger.v3.oas.annotations.tags.Tag;
10+
import lombok.RequiredArgsConstructor;
11+
import lombok.extern.slf4j.Slf4j;
12+
import org.springframework.http.HttpStatus;
13+
import org.springframework.http.ResponseEntity;
14+
import org.springframework.web.bind.annotation.*;
15+
16+
@Slf4j
17+
@RestController
18+
@RequestMapping("/api/webrtc")
19+
@RequiredArgsConstructor
20+
@Tag(name = "WebRTC API", description = "WebRTC 시그널링 및 ICE 서버 관련 REST API")
21+
public class WebRTCApiController {
22+
23+
// ICE 서버 설정 조회
24+
@GetMapping("/ice-servers")
25+
@Operation(
26+
summary = "ICE 서버 설정 조회",
27+
description = "WebRTC 연결에 필요한 STUN/TURN 서버 정보를 조회합니다."
28+
)
29+
@ApiResponses({
30+
@ApiResponse(responseCode = "200", description = "조회 성공"),
31+
@ApiResponse(responseCode = "500", description = "서버 오류")
32+
})
33+
public ResponseEntity<RsData<IceServerConfig>> getIceServers(
34+
@Parameter(description = "사용자 ID (선택)") @RequestParam(required = false) Long userId,
35+
@Parameter(description = "방 ID (선택)") @RequestParam(required = false) Long roomId) {
36+
37+
log.info("ICE 서버 설정 요청 - userId: {}, roomId: {}", userId, roomId);
38+
39+
// 기본 Google STUN 서버 사용
40+
IceServerConfig config = IceServerConfig.withDefaultStunServers();
41+
42+
log.info("ICE 서버 설정 제공 완료 - STUN 서버 {}개", config.iceServers().size());
43+
44+
return ResponseEntity
45+
.status(HttpStatus.OK)
46+
.body(RsData.success("ICE 서버 설정 조회 성공", config));
47+
}
48+
49+
// WebRTC 서비스 상태 확인
50+
@GetMapping("/health")
51+
@Operation(
52+
summary = "WebRTC 서비스 상태 확인",
53+
description = "WebRTC 시그널링 서버의 상태를 확인합니다."
54+
)
55+
@ApiResponse(responseCode = "200", description = "정상 작동 중")
56+
public ResponseEntity<RsData<String>> healthCheck() {
57+
return ResponseEntity
58+
.status(HttpStatus.OK)
59+
.body(RsData.success("WebRTC 서비스 정상 작동 중"));
60+
}
61+
}
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
package com.back.global.websocket.webrtc.controller;
2+
3+
import com.back.global.security.user.CustomUserDetails;
4+
import com.back.global.websocket.webrtc.dto.media.WebRTCMediaToggleRequest;
5+
import com.back.global.websocket.webrtc.dto.media.WebRTCMediaStateResponse;
6+
import com.back.global.websocket.webrtc.dto.signal.*;
7+
import com.back.global.websocket.webrtc.service.WebRTCSignalValidator;
8+
import com.back.global.websocket.util.WebSocketErrorHelper;
9+
import lombok.RequiredArgsConstructor;
10+
import lombok.extern.slf4j.Slf4j;
11+
import org.springframework.messaging.handler.annotation.MessageMapping;
12+
import org.springframework.messaging.handler.annotation.Payload;
13+
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
14+
import org.springframework.messaging.simp.SimpMessagingTemplate;
15+
import org.springframework.security.core.Authentication;
16+
import org.springframework.stereotype.Controller;
17+
import org.springframework.validation.annotation.Validated;
18+
19+
import java.security.Principal;
20+
21+
@Controller
22+
@RequiredArgsConstructor
23+
@Slf4j
24+
public class WebRTCSignalingController {
25+
26+
private final SimpMessagingTemplate messagingTemplate;
27+
private final WebSocketErrorHelper errorHelper;
28+
private final WebRTCSignalValidator validator;
29+
30+
// WebRTC Offer 메시지 처리
31+
@MessageMapping("/webrtc/offer")
32+
public void handleOffer(@Validated @Payload WebRTCOfferRequest request,
33+
SimpMessageHeaderAccessor headerAccessor,
34+
Principal principal) {
35+
try {
36+
// WebSocket에서 인증된 사용자 정보 추출
37+
CustomUserDetails userDetails = extractUserDetails(principal);
38+
if (userDetails == null) {
39+
errorHelper.sendUnauthorizedError(headerAccessor.getSessionId());
40+
return;
41+
}
42+
43+
Long fromUserId = userDetails.getUserId();
44+
45+
// 시그널 검증
46+
validator.validateSignal(request.roomId(), fromUserId, request.targetUserId());
47+
48+
log.info("WebRTC Offer received - Room: {}, From: {}, To: {}, MediaType: {}",
49+
request.roomId(), fromUserId, request.targetUserId(), request.mediaType());
50+
51+
// Offer 메시지 생성
52+
WebRTCSignalResponse response = WebRTCSignalResponse.offerOrAnswer(
53+
WebRTCSignalType.OFFER,
54+
fromUserId,
55+
request.targetUserId(),
56+
request.roomId(),
57+
request.sdp(),
58+
request.mediaType()
59+
);
60+
61+
// 방 전체에 브로드캐스트 (P2P Mesh 연결)
62+
messagingTemplate.convertAndSend(
63+
"/topic/room/" + request.roomId() + "/webrtc",
64+
response
65+
);
66+
67+
} catch (Exception e) {
68+
log.error("WebRTC Offer 처리 중 오류 발생 - roomId: {}", request.roomId(), e);
69+
errorHelper.sendGenericErrorToUser(headerAccessor.getSessionId(), e, "Offer 전송 중 오류가 발생했습니다");
70+
}
71+
}
72+
73+
// WebRTC Answer 메시지 처리
74+
@MessageMapping("/webrtc/answer")
75+
public void handleAnswer(@Validated @Payload WebRTCAnswerRequest request,
76+
SimpMessageHeaderAccessor headerAccessor,
77+
Principal principal) {
78+
try {
79+
// WebSocket에서 인증된 사용자 정보 추출
80+
CustomUserDetails userDetails = extractUserDetails(principal);
81+
if (userDetails == null) {
82+
errorHelper.sendUnauthorizedError(headerAccessor.getSessionId());
83+
return;
84+
}
85+
86+
Long fromUserId = userDetails.getUserId();
87+
88+
// 시그널 검증
89+
validator.validateSignal(request.roomId(), fromUserId, request.targetUserId());
90+
91+
log.info("WebRTC Answer received - Room: {}, From: {}, To: {}, MediaType: {}",
92+
request.roomId(), fromUserId, request.targetUserId(), request.mediaType());
93+
94+
// Answer 메시지 생성
95+
WebRTCSignalResponse response = WebRTCSignalResponse.offerOrAnswer(
96+
WebRTCSignalType.ANSWER,
97+
fromUserId,
98+
request.targetUserId(),
99+
request.roomId(),
100+
request.sdp(),
101+
request.mediaType()
102+
);
103+
104+
// 방 전체에 브로드캐스트 (P2P Mesh 연결)
105+
messagingTemplate.convertAndSend(
106+
"/topic/room/" + request.roomId() + "/webrtc",
107+
response
108+
);
109+
110+
} catch (Exception e) {
111+
log.error("WebRTC Answer 처리 중 오류 발생 - roomId: {}", request.roomId(), e);
112+
errorHelper.sendGenericErrorToUser(headerAccessor.getSessionId(), e, "Answer 전송 중 오류가 발생했습니다");
113+
}
114+
}
115+
116+
// ICE Candidate 메시지 처리
117+
@MessageMapping("/webrtc/ice-candidate")
118+
public void handleIceCandidate(@Validated @Payload WebRTCIceCandidateRequest request,
119+
SimpMessageHeaderAccessor headerAccessor,
120+
Principal principal) {
121+
try {
122+
// WebSocket에서 인증된 사용자 정보 추출
123+
CustomUserDetails userDetails = extractUserDetails(principal);
124+
if (userDetails == null) {
125+
errorHelper.sendUnauthorizedError(headerAccessor.getSessionId());
126+
return;
127+
}
128+
129+
Long fromUserId = userDetails.getUserId();
130+
131+
// 시그널 검증
132+
validator.validateSignal(request.roomId(), fromUserId, request.targetUserId());
133+
134+
log.info("ICE Candidate received - Room: {}, From: {}, To: {}",
135+
request.roomId(), fromUserId, request.targetUserId());
136+
137+
// ICE Candidate 메시지 생성
138+
WebRTCSignalResponse response = WebRTCSignalResponse.iceCandidate(
139+
fromUserId,
140+
request.targetUserId(),
141+
request.roomId(),
142+
request.candidate(),
143+
request.sdpMid(),
144+
request.sdpMLineIndex()
145+
);
146+
147+
// 방 전체에 브로드캐스트 (P2P Mesh 연결)
148+
messagingTemplate.convertAndSend(
149+
"/topic/room/" + request.roomId() + "/webrtc",
150+
response
151+
);
152+
153+
} catch (Exception e) {
154+
log.error("ICE Candidate 처리 중 오류 발생 - roomId: {}", request.roomId(), e);
155+
errorHelper.sendGenericErrorToUser(headerAccessor.getSessionId(), e, "ICE Candidate 전송 중 오류가 발생했습니다");
156+
}
157+
}
158+
159+
// 미디어 상태 토글 처리
160+
@MessageMapping("/webrtc/media/toggle")
161+
public void handleMediaToggle(@Validated @Payload WebRTCMediaToggleRequest request,
162+
SimpMessageHeaderAccessor headerAccessor,
163+
Principal principal) {
164+
try {
165+
// 인증된 사용자 정보 추출
166+
CustomUserDetails userDetails = extractUserDetails(principal);
167+
if (userDetails == null) {
168+
errorHelper.sendUnauthorizedError(headerAccessor.getSessionId());
169+
return;
170+
}
171+
172+
Long userId = userDetails.getUserId();
173+
String nickname = userDetails.getUsername();
174+
175+
// 미디어 상태 변경 검증
176+
validator.validateMediaStateChange(request.roomId(), userId);
177+
178+
log.info("미디어 상태 변경 - Room: {}, User: {}, MediaType: {}, Enabled: {}",
179+
request.roomId(), userId, request.mediaType(), request.enabled());
180+
181+
// 미디어 상태 응답 생성
182+
WebRTCMediaStateResponse response = WebRTCMediaStateResponse.of(
183+
userId,
184+
nickname,
185+
request.mediaType(),
186+
request.enabled()
187+
);
188+
189+
// 방 전체에 브로드캐스트
190+
messagingTemplate.convertAndSend(
191+
"/topic/room/" + request.roomId() + "/media-status",
192+
response
193+
);
194+
195+
} catch (Exception e) {
196+
log.error("미디어 상태 변경 중 오류 발생 - roomId: {}", request.roomId(), e);
197+
errorHelper.sendGenericErrorToUser(headerAccessor.getSessionId(), e, "미디어 상태 변경 중 오류가 발생했습니다");
198+
}
199+
}
200+
201+
// Principal에서 CustomUserDetails 추출 헬퍼 메서드
202+
private CustomUserDetails extractUserDetails(Principal principal) {
203+
if (principal instanceof Authentication auth) {
204+
Object principalObj = auth.getPrincipal();
205+
if (principalObj instanceof CustomUserDetails userDetails) {
206+
return userDetails;
207+
}
208+
}
209+
return null;
210+
}
211+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.back.global.websocket.webrtc.dto.ice;
2+
3+
import com.fasterxml.jackson.annotation.JsonInclude;
4+
5+
@JsonInclude(JsonInclude.Include.NON_NULL)
6+
public record IceServer(
7+
String urls,
8+
String username,
9+
String credential
10+
) {
11+
// STUN 서버 (인증 불필요)
12+
public static IceServer stun(String url) {
13+
return new IceServer(url, null, null);
14+
}
15+
16+
// TURN 서버 (인증 필요)
17+
public static IceServer turn(String url, String username, String credential) {
18+
return new IceServer(url, username, credential);
19+
}
20+
}

0 commit comments

Comments
 (0)