Skip to content

Commit

Permalink
Merge pull request #142 from My-Own-Weapon/develop
Browse files Browse the repository at this point in the history
채팅 관련 WebSocket 시그널 처리 구현
  • Loading branch information
HyeokJoon authored Aug 8, 2024
2 parents 92dd45e + 8d8586e commit f322687
Show file tree
Hide file tree
Showing 7 changed files with 375 additions and 0 deletions.
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ dependencies {
implementation 'com.drewnoakes:metadata-extractor:2.18.0'

implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.12.5'

// webRTC
implementation 'org.springframework.boot:spring-boot-starter-websocket'
}

tasks.named('test') {
Expand Down
19 changes: 19 additions & 0 deletions src/main/java/com/chimaera/wagubook/config/WebSocketConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.chimaera.wagubook.config;
import com.chimaera.wagubook.controller.ChatSignalHandler;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor;

@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {

@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(new ChatSignalHandler(), "/chat")
.addInterceptors(new HttpSessionHandshakeInterceptor()) //웹 소켓 세션에서 httpSession정보를 가져오기 위해 HttpSession에 있던 정보들을 copy
.setAllowedOrigins("*");
}
}
190 changes: 190 additions & 0 deletions src/main/java/com/chimaera/wagubook/controller/ChatSignalHandler.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
package com.chimaera.wagubook.controller;
import com.chimaera.wagubook.dto.request.WebSocketRequest;
import com.chimaera.wagubook.dto.response.WebSocketResponse;
import com.chimaera.wagubook.entity.Member;
import com.chimaera.wagubook.exception.CustomException;
import com.chimaera.wagubook.exception.ErrorCode;
import com.chimaera.wagubook.repository.webRTC.SessionRepository;
import com.chimaera.wagubook.service.MemberService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;

import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import static com.chimaera.wagubook.exception.ErrorCode.SESSION_ROOM_NOT_FOUND;

public class ChatSignalHandler extends TextWebSocketHandler {
@Autowired
private MemberService memberService;
private final SessionRepository sessionRepository = SessionRepository.getInstance();
private final ObjectMapper objectMapper = new ObjectMapper();
private List<WebSocketSession> sessionList = new ArrayList<>();

// 데이터 타입
private static final String MSG_TYPE_JOIN_ROOM = "join_room";
private static final String MSG_TYPE_CHAT = "chat";

/**
* 웹 소켓이 연결되면 실행되는 메서드
* 어떤 session인지 출력
*/
@Override
public void afterConnectionEstablished(WebSocketSession session){
System.out.println("웹 소켓 연결 성공 session : " + session);
Long memberId = (Long)session.getAttributes().get("memberId");
System.out.println("member Id : " + memberId);
if (memberId == null) {
throw new CustomException(ErrorCode.REQUEST_LOGIN);
}
// sessionList.add(session);
}

@Override
protected void handleTextMessage(final WebSocketSession session, final TextMessage message) throws Exception {
Long memberId = (Long)(session.getAttributes().get("memberId"));
if (memberId == null) {
throw new CustomException(ErrorCode.REQUEST_LOGIN);
}

// 수신된 메시지를 JSON 객체로 파싱
String payload = message.getPayload();
WebSocketRequest data = new ObjectMapper().readValue(payload, WebSocketRequest.class);
String type = data.getType();
String roomURL = data.getRoomURL();

// 타입에 따라 다른 로직을 적용할 수 있다 (P2P연결을 위한 기능)
//SDP 오퍼(SDP Offer): 피어 A가 연결을 시작하기 위해 자신의 미디어 세션 설정을 피어 B에게 보내는 메시지
//SDP 응답(SDP Answer): 피어 B가 SDP 오퍼를 수신하고 자신의 설정을 포함하여 응답하는 메시지
//ICE 후보자(ICE Candidates): P2P 연결을 설정하기 위해 피어들이 교환하는 네트워크 경로 정보
//React에서 P2P연결을 하기 위해 필요하다 특정 누군가에 대한게 아니라 브로드케스드


switch (type){
/**
* MSG_TYPE_JOIN_ROOM : 처음 방에 입장했을 때
* 방을 시작하는 경우 (스트리머) - 새로운 방을 생성하고 Client(방장)의 세션 정보를 저장
* 방에 참여하는 경우 (시청자) - sessionList에 Client 세션 정보를 저장
*/
case MSG_TYPE_JOIN_ROOM:
//방이 존재할 때
if(sessionRepository.isExistRoom(roomURL)){
sessionRepository.addClient(roomURL, session);
}
//방을 새로 만들 때
else{
sessionRepository.addClientInNewRoom(roomURL, session);
}
//이 세션이 어느 방에 들어가 있는지 저장
sessionRepository.saveRoomIdHashMapBySession(session, roomURL);

//방 안에 유저 이름 저장
sessionRepository.addUsernameInRoom(session.getId(), data.getSenderName());

Map<String, WebSocketSession> joinClientList = sessionRepository.getClientList(roomURL);

//방 안 참가자에게 접속한 사람 정보 전달
List<String> participantSessionList = new ArrayList<>();
for (Map.Entry<String, WebSocketSession> entry : joinClientList.entrySet()) {
if(entry.getValue() != session){
participantSessionList.add(entry.getKey());
sendMessage(entry.getValue(),new WebSocketResponse().builder()
.type("join")
.senderId(data.getSenderId())
.senderName(data.getSenderName())
.data(data.getData())
.build());
}
}

//방에 참여하고 있던 참가자 리스트(username, sessionId)
List<String> participantNameList = new ArrayList<>();
for (Map.Entry<String, WebSocketSession> entry : joinClientList.entrySet()) {
if(entry.getValue() != session)
participantNameList.add(sessionRepository.getUsernameInRoom(entry.getKey()));
}

//접속한 사람에게 방 안 참가자들 정보를 반환
sendMessage(session, new WebSocketResponse().builder()
.type("all_users")
.senderId(data.getSenderId())
.senderName(data.getSenderName())
.data(data.getData())
.allUsersNickNames(participantNameList)
.build());
break;

case MSG_TYPE_CHAT:

if (sessionRepository.isExistRoom(roomURL)) {
Map<String, WebSocketSession> ClientList = sessionRepository.getClientList(roomURL);

//방에 존재하는 모든 사람들에게 메시지를 보냄
for (WebSocketSession ws : ClientList.values()) {
sendMessage(ws,
new WebSocketResponse().builder()
.type(data.getType())
.senderId(data.getSenderId())
.senderName(data.getSenderName())
.data(data.getData())
.build());
}
} else {
throw new CustomException(SESSION_ROOM_NOT_FOUND);
}
break;


default:
}
}

private void sendMessage(WebSocketSession session, WebSocketResponse message) {
try {
String json = objectMapper.writeValueAsString(message);
session.sendMessage(new TextMessage(json));
} catch (IOException e) {
System.out.println("============== 발생한 에러 메세지: {}" + e.getMessage());
}
}

// 웹소켓 연결이 끊어지면 실행되는 메소드
@Override
@Transactional
public void afterConnectionClosed(final WebSocketSession session, final CloseStatus status) {
Long memberId = (Long)session.getAttributes().get("memberId");
if (memberId == null) {
throw new CustomException(ErrorCode.REQUEST_LOGIN);
}
Member findMember = memberService.findById(memberId);

// 끊어진 세션이 어느방에 있었는지 조회
String roomUrl = sessionRepository.getRoomUrlToSession(session);

// 2) 방 참가자들 세션 정보들 사이에서 삭제
sessionRepository.deleteClient(roomUrl, session);

// 3) 별도 해당 참가자 세션 정보도 삭제
sessionRepository.deleteRoomIdToSession(session);

// 4) 별도 해당 닉네임 리스트에서도 삭제
sessionRepository.deleteUsernameInRoom(session.getId());
// 본인 제외 모두에게 전달
for(Map.Entry<String, WebSocketSession> client : sessionRepository.getClientList(roomUrl).entrySet()){
sendMessage(client.getValue(),
new WebSocketResponse().builder()
.type("leave")
.senderId(findMember.getUsername())
.senderName(findMember.getName())
.build());
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.chimaera.wagubook.dto.request;

import lombok.Data;

@Data
public class WebSocketRequest {
private String senderId;
private String senderName;
private String type;
private String data;
private String roomURL;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.chimaera.wagubook.dto.response;

import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.util.List;
import java.util.Map;

@Builder
@Getter
@JsonInclude(JsonInclude.Include.NON_NULL)
@NoArgsConstructor
@AllArgsConstructor
public class WebSocketResponse {
private String senderId; // sender 로그인 아이디
private String senderName; // sender 이름
private String type; // [""]
private String data; // 메시지
private String roomURL; // 방 url
private List<String> allUsersNickNames; // 방에 참여하고 있는 사람들 리스트(처음 입장 시)
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ public enum ErrorCode {
NOT_FOUND_STORE(NOT_FOUND, "해당 스토어를 찾을 수 없습니다."),
NOT_FOUND_URL(NOT_FOUND, "해당 url이 존재하지 않습니다."),
NOT_FOUND_LIVE_ROOM(NOT_FOUND, "해당 라이브룸을 찾을 수 없습니다."),
SESSION_ROOM_NOT_FOUND(NOT_FOUND, "해당 세션룸을 찾을 수 없습니다."),



Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package com.chimaera.wagubook.repository.webRTC;

import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.WebSocketSession;

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

// 기능 : 웹소켓에 필요한 세션 정보를 저장, 관리 (싱글톤)
@Slf4j
@Component
@NoArgsConstructor
public class SessionRepository {
private static SessionRepository sessionRepository;
/**
* 방 번호를 사용해서 세션 리스트를 찾을 수 있다.
* <roomId, <sessionId, session>>
*/
private final Map<String, Map<String, WebSocketSession>> sessionListInRoom = new ConcurrentHashMap<>();


/**
* 세션 아이디를 사용해서 방 번호를 찾을 수 있다.
* <session, roomId>
*/
private final HashMap<WebSocketSession, String> roomIdHashMapBySessionId = new HashMap<>();


/**
* 세션 아이디와 사용자 이름 정보를 매핑
* <sessionId, username>
*/
private final Map<String, String> usernamesBySessionId = new HashMap<>();


/**
* Session 데이터를 공통으로 사용하기 위해 싱글톤으로 구현
*/
public static SessionRepository getInstance(){
if(sessionRepository == null){
synchronized (SessionRepository.class){
sessionRepository = new SessionRepository();
}
}
return sessionRepository;
}

/**
* 방이 존재하는지 확인하는 로직
*/
public boolean isExistRoom(String roomURL) {
return sessionListInRoom.containsKey(roomURL);
}

/**
* 방에 클라이언트가 참여했을 때, 세션 리스트에 추가하는 로직
*/
public void addClient(String roomURL, WebSocketSession session) {
sessionListInRoom.get(roomURL).put(session.getId(), session);
}

/**
* 방이 처음 생성될 때, 생성 후 세션 리스트에 추가
*/
public void addClientInNewRoom(String roomURL, WebSocketSession session) {
Map<String, WebSocketSession> newClient = new HashMap<>();
newClient.put(session.getId(), session);
sessionListInRoom.put(roomURL, newClient);
}

/**
* 방에 클라이언트가 참여하면 session으로 방을 찾을 수 있도록 session과 room을 매핑한다.
*/
public void saveRoomIdHashMapBySession(WebSocketSession session, String roomURL) {
roomIdHashMapBySessionId.put(session, roomURL);
}

public Map<String, WebSocketSession> getClientList(String roomURL) {
if(sessionListInRoom.isEmpty())
return null;
return sessionListInRoom.get(roomURL);
}

public void addUsernameInRoom(String sessionId, String username) {
this.usernamesBySessionId.put(sessionId, username);
}

/**
* sessionId로 사용자 이름을 조회
*/
public String getUsernameInRoom(String sessionId) {
return this.usernamesBySessionId.get(sessionId);
}

public String getRoomUrlToSession(WebSocketSession session) {
if(roomIdHashMapBySessionId.isEmpty())
return null;
return roomIdHashMapBySessionId.get(session);
}

public void deleteClient(String roomUrl, WebSocketSession session) {
Map<String, WebSocketSession> clientList = sessionListInRoom.get(roomUrl);
String removeSessionId = "";
for (Map.Entry<String, WebSocketSession> client : clientList.entrySet()) {
if(client.getKey().equals(session.getId())){
removeSessionId = client.getKey();
break;
}
}
clientList.remove(removeSessionId);
sessionListInRoom.remove(removeSessionId);

}

public void deleteRoomIdToSession(WebSocketSession session) {
roomIdHashMapBySessionId.remove(session);
}


public void deleteUsernameInRoom(String sessionId) {
usernamesBySessionId.remove(sessionId);
}
}

0 comments on commit f322687

Please sign in to comment.