Skip to content

Commit d25fca8

Browse files
committed
feat: WebSocket을 이용한 실시간 협업 기능 구현 (#28)
1 parent 8a949d0 commit d25fca8

File tree

13 files changed

+532
-0
lines changed

13 files changed

+532
-0
lines changed

build.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ dependencies {
4141
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
4242

4343
implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3:3.1.1'
44+
45+
implementation 'org.springframework.boot:spring-boot-starter-websocket'
4446
}
4547

4648
tasks.named('test') {

src/main/java/com/dmu/debug_visual/config/SecurityConfig.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,13 +55,15 @@ public SecurityFilterChain devSecurityFilterChain(HttpSecurity http) throws Exce
5555
.requestMatchers("/api/users/login", "/api/users/signup").permitAll()
5656
.requestMatchers("/api/code/**").permitAll()
5757
.requestMatchers(HttpMethod.GET, "/api/posts/**", "/api/comments/**").permitAll()
58+
.requestMatchers("/ws-collab/**").permitAll()
5859

5960
// 2. USER 권한이 필요한 경로
6061
.requestMatchers("/api/posts/**").hasRole("USER")
6162
.requestMatchers("/api/notifications/**").hasRole("USER")
6263
.requestMatchers("/api/report/**").hasRole("USER")
6364
.requestMatchers("/api/comments/**").hasRole("USER")
6465
.requestMatchers("/api/files/upload").hasRole("USER")
66+
.requestMatchers("/api/collab-rooms").hasRole("USER")
6567

6668
// 3. 나머지 모든 요청은 인증된 사용자만 접근 가능 (ADMIN 경로 포함)
6769
.anyRequest().authenticated()
@@ -90,6 +92,7 @@ public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws
9092
.requestMatchers("/api/users/login", "/api/users/signup").permitAll()
9193
.requestMatchers("/api/code/**").permitAll()
9294
.requestMatchers(HttpMethod.GET, "/api/posts/**", "/api/comments/**").permitAll()
95+
.requestMatchers("/ws-collab/**").permitAll()
9396

9497
// 2. ADMIN 권한이 필요한 경로
9598
.requestMatchers("/api/admin/**").hasRole("ADMIN")
@@ -100,6 +103,7 @@ public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws
100103
.requestMatchers("/api/report/**").hasRole("USER")
101104
.requestMatchers("/api/comments/**").hasRole("USER")
102105
.requestMatchers("/api/files/upload").hasRole("USER")
106+
.requestMatchers("/api/collab-rooms").hasRole("USER")
103107

104108

105109
// 4. 나머지 모든 요청은 인증된 사용자만 접근 가능
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package com.dmu.debug_visual.config;
2+
3+
import com.dmu.debug_visual.security.StompChannelInterceptor;
4+
import lombok.RequiredArgsConstructor;
5+
import org.springframework.context.annotation.Configuration;
6+
import org.springframework.messaging.simp.config.ChannelRegistration;
7+
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
8+
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
9+
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
10+
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
11+
12+
@Configuration
13+
@EnableWebSocketMessageBroker // WebSocket 메시지 브로커 기능 활성화
14+
@RequiredArgsConstructor
15+
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
16+
17+
private final StompChannelInterceptor stompChannelInterceptor; // 추가
18+
19+
@Override
20+
public void configureClientInboundChannel(ChannelRegistration registration) {
21+
// 클라이언트가 메시지를 보내는 채널에 인터셉터를 등록합니다.
22+
registration.interceptors(stompChannelInterceptor);
23+
}
24+
25+
@Override
26+
public void registerStompEndpoints(StompEndpointRegistry registry) {
27+
// 클라이언트가 WebSocket 연결을 시작할 엔드포인트를 설정합니다.
28+
// SockJS는 WebSocket을 지원하지 않는 브라우저를 위한 호환성 옵션입니다.
29+
registry.addEndpoint("/ws-collab").setAllowedOriginPatterns("*").withSockJS();
30+
}
31+
32+
@Override
33+
public void configureMessageBroker(MessageBrokerRegistry registry) {
34+
// "/topic"으로 시작하는 주소를 구독하는 클라이언트들에게 메시지를 브로드캐스팅(전파)합니다.
35+
registry.enableSimpleBroker("/topic");
36+
37+
// "/app"으로 시작하는 주소로 들어온 메시지는 @MessageMapping이 붙은 메서드로 라우팅됩니다.
38+
registry.setApplicationDestinationPrefixes("/app");
39+
}
40+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package com.dmu.debug_visual.config;
2+
3+
import com.dmu.debug_visual.websocket.dto.SystemMessage;
4+
import com.dmu.debug_visual.websocket.service.RoomService;
5+
import lombok.RequiredArgsConstructor;
6+
import lombok.extern.slf4j.Slf4j;
7+
import org.springframework.context.event.EventListener;
8+
import org.springframework.messaging.simp.SimpMessageSendingOperations;
9+
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
10+
import org.springframework.stereotype.Component;
11+
import org.springframework.web.socket.messaging.SessionDisconnectEvent;
12+
import org.springframework.web.socket.messaging.SessionSubscribeEvent;
13+
14+
import java.util.Map;
15+
import java.util.Objects;
16+
17+
@Slf4j
18+
@Component
19+
@RequiredArgsConstructor
20+
public class WebSocketEventListener {
21+
22+
private final SimpMessageSendingOperations messagingTemplate;
23+
private final RoomService roomService;
24+
25+
// 사용자가 특정 방을 구독할 때(입장) 호출되는 메서드
26+
@EventListener
27+
public void handleWebSocketSubscribeListener(SessionSubscribeEvent event) {
28+
StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());
29+
Map<String, Object> sessionAttributes = headerAccessor.getSessionAttributes();
30+
31+
String destination = headerAccessor.getDestination();
32+
if (destination != null && destination.startsWith("/topic/room/")) {
33+
String roomId = destination.split("/")[3];
34+
String userName = (String) Objects.requireNonNull(sessionAttributes).get("userName");
35+
36+
String userId = (String) sessionAttributes.get("userId");
37+
38+
// ✨ 중요: 세션에 현재 사용자가 어느 방에 들어갔는지 기록합니다.
39+
if (userId != null) {
40+
roomService.addParticipant(roomId, userId);
41+
}
42+
43+
log.info("[입장] 사용자: {}, 방: {}", userName, roomId);
44+
SystemMessage chatMessage = SystemMessage.builder()
45+
.roomId(roomId)
46+
.senderName("System")
47+
.content(userName + "님이 입장했습니다.")
48+
.build();
49+
50+
51+
messagingTemplate.convertAndSend("/topic/room/" + roomId + "/system", chatMessage);
52+
}
53+
}
54+
55+
// 사용자의 웹소켓 연결이 끊어졌을 때(퇴장) 호출되는 메서드
56+
@EventListener
57+
public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) {
58+
StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());
59+
Map<String, Object> sessionAttributes = headerAccessor.getSessionAttributes();
60+
61+
if (sessionAttributes != null) {
62+
String userName = (String) sessionAttributes.get("userName");
63+
64+
// ✨ 중요: 세션에서 아까 저장해둔 roomId를 꺼냅니다.
65+
String roomId = (String) sessionAttributes.get("roomId");
66+
67+
if (userName != null && roomId != null) {
68+
log.info("[퇴장] 사용자: {}, 방: {}", userName, roomId);
69+
70+
SystemMessage chatMessage = SystemMessage.builder()
71+
.roomId(roomId)
72+
.senderName("System")
73+
.content(userName + "님이 퇴장했습니다.")
74+
.build();
75+
76+
messagingTemplate.convertAndSend("/topic/room/" + roomId + "/system", chatMessage);
77+
}
78+
}
79+
}
80+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package com.dmu.debug_visual.security;
2+
3+
import com.dmu.debug_visual.user.UserRepository;
4+
import lombok.RequiredArgsConstructor;
5+
import lombok.extern.slf4j.Slf4j;
6+
import org.springframework.messaging.Message;
7+
import org.springframework.messaging.MessageChannel;
8+
import org.springframework.messaging.simp.stomp.StompCommand;
9+
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
10+
import org.springframework.messaging.support.ChannelInterceptor;
11+
import org.springframework.messaging.support.MessageHeaderAccessor;
12+
import org.springframework.stereotype.Component;
13+
import util.JwtTokenProvider;
14+
15+
import java.util.Map;
16+
import java.util.Objects;
17+
18+
@Slf4j
19+
@Component
20+
@RequiredArgsConstructor
21+
public class StompChannelInterceptor implements ChannelInterceptor {
22+
23+
private final JwtTokenProvider jwtTokenProvider;
24+
private final UserRepository userRepository;
25+
26+
@Override
27+
public Message<?> preSend(Message<?> message, MessageChannel channel) {
28+
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
29+
30+
// 사용자가 웹소켓에 연결을 시도할 때 (CONNECT) JWT 토큰을 검증합니다.
31+
if (StompCommand.CONNECT.equals(accessor.getCommand())) {
32+
String token = Objects.requireNonNull(accessor.getFirstNativeHeader("Authorization")).substring(7);
33+
34+
if (jwtTokenProvider.validateToken(token)) {
35+
String userId = jwtTokenProvider.getUserIdFromToken(token);
36+
37+
// 웹소켓 세션에 사용자 정보를 저장합니다.
38+
Map<String, Object> sessionAttributes = accessor.getSessionAttributes();
39+
if (sessionAttributes != null) {
40+
sessionAttributes.put("userId", userId);
41+
userRepository.findByUserId(userId).ifPresent(user -> {
42+
sessionAttributes.put("userName", user.getName());
43+
});
44+
}
45+
}
46+
}
47+
return message;
48+
}
49+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package com.dmu.debug_visual.websocket.controller;
2+
3+
import com.dmu.debug_visual.user.UserRepository; // <-- UserRepository import
4+
import com.dmu.debug_visual.websocket.dto.CodeMessage;
5+
import com.dmu.debug_visual.websocket.dto.PermissionChangeMessage;
6+
import com.dmu.debug_visual.websocket.dto.Room;
7+
import com.dmu.debug_visual.websocket.service.RoomService;
8+
import lombok.RequiredArgsConstructor;
9+
import org.springframework.messaging.handler.annotation.DestinationVariable;
10+
import org.springframework.messaging.handler.annotation.MessageMapping;
11+
import org.springframework.messaging.simp.SimpMessagingTemplate;
12+
import org.springframework.stereotype.Controller;
13+
14+
@Controller
15+
@RequiredArgsConstructor
16+
public class CodeCollabController {
17+
18+
private final RoomService roomService;
19+
private final SimpMessagingTemplate messagingTemplate;
20+
private final UserRepository userRepository; // <-- UserRepository 주입
21+
22+
// 코드 수정 메시지 처리
23+
@MessageMapping("/room/{roomId}/code-update")
24+
public void handleCodeUpdate(@DestinationVariable String roomId, CodeMessage message) {
25+
if (roomService.hasWritePermission(roomId, message.getSenderId())) {
26+
// 사용자 이름을 찾아서 메시지에 추가
27+
userRepository.findByUserId(message.getSenderId()).ifPresent(user -> {
28+
message.setSenderName(user.getName());
29+
});
30+
messagingTemplate.convertAndSend("/topic/room/" + roomId + "/code", message);
31+
}
32+
}
33+
34+
// 권한 부여 메시지 처리
35+
@MessageMapping("/room/{roomId}/grant-permission")
36+
public void handlePermissionGrant(@DestinationVariable String roomId, PermissionChangeMessage message) {
37+
Room room = roomService.findRoomById(roomId);
38+
if (room != null && room.getOwnerId().equals(message.getSenderId())) {
39+
room.grantWritePermission(message.getTargetUserId());
40+
41+
// 보내는 사람과 받는 사람의 이름을 찾아서 메시지에 추가
42+
userRepository.findByUserId(message.getSenderId()).ifPresent(sender -> message.setSenderName(sender.getName()));
43+
userRepository.findByUserId(message.getTargetUserId()).ifPresent(target -> message.setTargetUserName(target.getName()));
44+
45+
messagingTemplate.convertAndSend("/topic/room/" + roomId + "/permission", message);
46+
}
47+
}
48+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package com.dmu.debug_visual.websocket.controller;
2+
3+
4+
import com.dmu.debug_visual.security.CustomUserDetails;
5+
import com.dmu.debug_visual.websocket.dto.Room;
6+
import com.dmu.debug_visual.websocket.service.RoomService;
7+
import lombok.RequiredArgsConstructor;
8+
import org.springframework.http.ResponseEntity;
9+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
10+
import org.springframework.web.bind.annotation.PostMapping;
11+
import org.springframework.web.bind.annotation.RequestMapping;
12+
import org.springframework.web.bind.annotation.RestController;
13+
14+
@RestController
15+
@RequestMapping("/api/collab-rooms")
16+
@RequiredArgsConstructor
17+
public class RoomController {
18+
19+
private final RoomService roomService;
20+
21+
/**
22+
* 새로운 협업 방을 생성합니다.
23+
* @param userDetails 현재 로그인한 사용자의 정보
24+
* @return 생성된 방의 정보 (roomId 포함)
25+
*/
26+
@PostMapping
27+
public ResponseEntity<Room> createRoom(@AuthenticationPrincipal CustomUserDetails userDetails) {
28+
// 로그인한 사용자를 방의 소유자(owner)로 설정합니다.
29+
String ownerId = userDetails.getUsername();
30+
31+
// RoomService를 통해 새로운 방을 생성합니다.
32+
Room newRoom = roomService.createRoom(ownerId);
33+
34+
// 생성된 방의 정보를 클라이언트에게 반환합니다.
35+
return ResponseEntity.ok(newRoom);
36+
}
37+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.dmu.debug_visual.websocket.dto;
2+
3+
import lombok.Getter;
4+
import lombok.Setter;
5+
6+
@Getter
7+
@Setter
8+
public class CodeMessage {
9+
private String senderId; // 메시지를 보낸 사람의 ID
10+
private String senderName;
11+
private String content; // 전송할 코드 내용
12+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.dmu.debug_visual.websocket.dto;
2+
3+
import lombok.Getter;
4+
import lombok.Setter;
5+
6+
@Getter
7+
@Setter
8+
public class PermissionChangeMessage {
9+
private String senderId; // 권한을 부여하는 사람 (방장)
10+
private String senderName;
11+
private String targetUserId; // 권한을 받는 사람
12+
private String targetUserName;
13+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package com.dmu.debug_visual.websocket.dto;
2+
3+
import lombok.Builder;
4+
import lombok.Getter;
5+
6+
import java.util.Map;
7+
import java.util.concurrent.ConcurrentHashMap;
8+
9+
@Getter
10+
public class Room {
11+
12+
private String roomId; // 방 고유 ID
13+
private String ownerId; // 방 생성자(방장)의 ID
14+
private Map<String, Permission> participants; // 참여자 ID와 권한 목록
15+
16+
// 참여자의 권한을 정의하는 enum (열거형)
17+
public enum Permission {
18+
READ_ONLY, // 읽기 전용
19+
READ_WRITE // 읽기/쓰기
20+
}
21+
22+
@Builder
23+
public Room(String roomId, String ownerId) {
24+
this.roomId = roomId;
25+
this.ownerId = ownerId;
26+
// 여러 사용자가 동시에 접근해도 안전한 ConcurrentHashMap 사용
27+
this.participants = new ConcurrentHashMap<>();
28+
// 방 생성자는 기본적으로 읽기/쓰기 권한을 가집니다.
29+
this.participants.put(ownerId, Permission.READ_WRITE);
30+
}
31+
32+
// 새로운 참여자를 방에 추가하는 메서드 (기본 권한은 읽기 전용)
33+
public void addParticipant(String userId) {
34+
this.participants.putIfAbsent(userId, Permission.READ_ONLY);
35+
}
36+
37+
// 특정 참여자에게 쓰기 권한을 부여하는 메서드
38+
public void grantWritePermission(String userId) {
39+
if (this.participants.containsKey(userId)) {
40+
this.participants.put(userId, Permission.READ_WRITE);
41+
}
42+
}
43+
}

0 commit comments

Comments
 (0)