Springboot- websocket을 이용한 채팅

2023. 4. 3. 17:36Springboot/chatting

WebSocket

기존의 단방향 HTTP 프로토콜과 호환되어 양방향 통신을 제공하기 위해 개발된 프로토콜.
일반 Socket통신과 달리 HTTP 80 Port를 사용하므로 방화벽에 제약이 없으며 통상 WebSocket으로 불린다.
접속까지는 HTTP 프로토콜을 이용하고, 그 이후 통신은 자체적인 WebSocket 프로토콜로 통신하게 된다.

HTTP와 달리 한번연결 후 데이터를 송수신하기 때문에 실시간채팅이나 스트리밍서비스에 사용된다.

 

 

 

Spring에서 websocket 서버 구현하기

build.gradle

	//starter
	implementation 'org.springframework.boot:spring-boot-starter'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
	implementation 'org.springframework.boot:spring-boot-starter-websocket'

	// json
	implementation group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: '2.14.2'

	compileOnly 'org.projectlombok:lombok'
	developmentOnly 'org.springframework.boot:spring-boot-devtools'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testImplementation 'org.springframework.security:spring-security-test'

 

 

 

WebsocketHandler

socket통신은 서버와 클라이언트가 1:N으로 관계를 맺습니다. 따라서 한 서버에 여러 클라이언트가 접속할 수 있으며,

서버에는 여러 클라이언트가 발송한 메시지를 받아 처리해줄 Handler의 작성이 필요합니다.

다음과 같이 TextWebSocketHandler를 상속받아 Handler를 작성해 줍니다. 


@Slf4j
@RequiredArgsConstructor
@Component
public class WebSockChatHandler extends TextWebSocketHandler {
    private final ObjectMapper objectMapper;
    private final ChatService chatService;


    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {

    }


    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        String payload = message.getPayload();
        ChatMessage chatMessage = objectMapper.readValue(payload, ChatMessage.class);
        ChatRoom room = chatService.findRoomById(chatMessage.getRoomId());
        Set<WebSocketSession> sessions=room.getSessions();   //방에 있는 현재 사용자 한명이 WebsocketSession
        if (chatMessage.getType().equals(ChatMessage.MessageType.ENTER)) {
            //사용자가 방에 입장하면  Enter메세지를 보내도록 해놓음.  이건 새로운사용자가 socket 연결한 것이랑은 다름.
            //socket연결은 이 메세지 보내기전에 이미 되어있는 상태
            sessions.add(session);
            chatMessage.setMessage(chatMessage.getSender() + "님이 입장했습니다.");  //TALK일 경우 msg가 있을 거고, ENTER일 경우 메세지 없으니까 message set
            sendToEachSocket(sessions,new TextMessage(objectMapper.writeValueAsString(chatMessage)) );
        }else if (chatMessage.getType().equals(ChatMessage.MessageType.QUIT)) {
            sessions.remove(session);
            chatMessage.setMessage(chatMessage.getSender() + "님이 퇴장했습니다..");
            sendToEachSocket(sessions,new TextMessage(objectMapper.writeValueAsString(chatMessage)) );
        }else {
            sendToEachSocket(sessions,message ); //입장,퇴장 아닐 때는 클라이언트로부터 온 메세지 그대로 전달.
        }
    }
    private  void sendToEachSocket(Set<WebSocketSession> sessions, TextMessage message){
        sessions.parallelStream().forEach( roomSession -> {
            try {
                roomSession.sendMessage(message);
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        });
    }



    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
       //javascript에서  session.close해서 연결 끊음. 그리고 이 메소드 실행.
        //session은 연결 끊긴 session을 매개변수로 이거갖고 뭐 하세요.... 하고 제공해주는 것 뿐
    }



}

일반적으로 채팅동작하는 원리는 다음과 같다.

handletextMessage 는  사용자(javascript에서 websocket 객체)가   send("메세지")를 실행하면 호출된다.서버는 한 사용자가 보낸 메세지(TextMessage message)를  다른사용자'들' 에게 보낸다. 

 

여기서는 입장과 퇴장, 그 외를 나눠서 서로다른메세지를 세팅했지만, sendToEachSocket() 메소드를 통해 같은방에 있는 모든 사용자들에게 메세지를 보낸다.

 

 

 

WebsocketConfig

@RequiredArgsConstructor
@Configuration
@EnableWebSocket   //이게 websocket 서버로서 동작하겠다는 어노테이션
public class WebSockConfig implements WebSocketConfigurer { 
    private final WebSocketHandler webSocketHandler;

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(webSocketHandler, "/ws/chat").setAllowedOrigins("*");
        // handler 등록,   js에서 new Websocket할 때 경로 지정
        //다른 url에서도 접속할 수있게(CORS방지)
    }
}

위에서 만든 handler를 이용하여 Websocket을 활성화하기 위한 Config 파일을 작성합니다.

@EnableWebSocket을 선언하여 Websocket을 활성화합니다. Websocket에 접속하기 위한 endpoint는 /ws/chat으로

설정하고 도메인이 다른 서버에서도 접속 가능하도록 CORS : setAllowedOrigins(“*”)를 설정을 추가해 줍니다.

이제 클라이언트가 ws://localhost:8080/ws/chat으로 커넥션을 연결하고 메시지 통신을 할 수 있는 기본적인 준비가

끝났습니다. 나머지는 채팅메세지를 위한 DTO,  채팅 방을 위한 ChatRoom 및 Chatroom 관련 service

그리고 화면을 위한 Controller, html 등만 작성하면 됩니다.

 

 

ChatRoom

@Getter
public class ChatRoom {
    private String roomId;
    private String name;
    private Set<WebSocketSession> sessions = new HashSet<>();
    @Builder
    public ChatRoom(String roomId, String name) {
        this.roomId = roomId;
        this.name = name;
    }
}

특별할 건 없지만 방 한개마다 여러사용자들을 Set형태로 가지고 있습니다.

 

ChatMessage

@Getter
@Setter
public class ChatMessage {
    // 메시지 타입 : 입장, 채팅, 나감
    public enum MessageType {
        ENTER, TALK,QUIT
    }
    private MessageType type; // 메시지 타입
    private String roomId; // 방번호
    private String sender; // 메시지 보낸사람
    private String message; // 메시지
}

Enum인 MessageType은 서버가 메세지를 처리할 때 입장, 채팅, 퇴장을 구별하는데 사용됩니다.

 

 

ChatService

@Slf4j
@RequiredArgsConstructor
@Service
public class ChatService {

    private final ObjectMapper objectMapper;
    private Map<String, ChatRoom> chatRooms;

    @PostConstruct
    private void init() {
        chatRooms = new LinkedHashMap<>();
    }

    public List<ChatRoom> findAllRoom() {
        return new ArrayList<>(chatRooms.values());
    }

    public ChatRoom findRoomById(String roomId) {
        return chatRooms.get(roomId);
    }

    public ChatRoom createRoom(String name) {
        String randomId = UUID.randomUUID().toString();
        ChatRoom chatRoom = ChatRoom.builder()
                .roomId(randomId)
                .name(name)
                .build();
        chatRooms.put(randomId, chatRoom);
        return chatRoom;
    }
}

방을 만드는것과  방을 찾아주는 메소드가 있습니다.

방을 만들 때는 random한 방 이름으로 만듭니다. 

DB와 연동된다면 방을 DB에 저장하겠지만, 아직은 DB연결이 없기때문에 Map형태로 방을 저장합니다.

 

 

ChatController

@Controller
@RequiredArgsConstructor
public class ChatController {
    private final ChatService chatService;


    @RequestMapping("/chat/chatList")
    public String chatList(Model model){
        List<ChatRoom> roomList = chatService.findAllRoom();
        model.addAttribute("roomList",roomList);
        return "chat/chatList";
    }


    @PostMapping("/chat/createRoom")  //방을 만들었으면 해당 방으로 가야지.
    public String createRoom(Model model, @RequestParam String name, String username) {
        ChatRoom room = chatService.createRoom(name);
        model.addAttribute("room",room);
        model.addAttribute("username",username);
        return "chat/chatRoom";  //만든사람이 채팅방 1빠로 들어가게 됩니다
    }

    @GetMapping("/chat/chatRoom")
    public String chatRoom(Model model, @RequestParam String roomId){
        ChatRoom room = chatService.findRoomById(roomId);
        model.addAttribute("room",room);   //현재 방에 들어오기위해서 필요한데...... 접속자 수 등등은 실시간으로 보여줘야 돼서 여기서는 못함
        return "chat/chatRoom";
    }
}

 

chatList.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>

<form action="/chat/createRoom" method="post">
    <input type="text" name="name" placeholder="채팅방 이름">
    <button type="submit" >방 만들기</button>
</form>

<table>
    <tr th:each="room : ${roomList}" >
        <td>
            <a th:href="@{chatRoom(roomId=${room.roomId} )}"
               th:text="${room.name}"></a>
        </td>
    </tr>
</table>

</body>
</html>

첫 화면은 현재 만들어진 방을 전부 보여줍니다.

방을 만들면 만든 방 주소로 입장하게됩니다.

 

chatRoom.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<input type="text" placeholder="보낼 메세지를 입력하세요." class="content">
<button type="button" value="전송" class="sendBtn" onclick="sendMsg()">전송</button>
<button type="button" value="방나가기" class="quit" onclick="quit()">방 나가기 </button>
<div>
    <span>메세지</span>
    <div class="msgArea"></div>
</div>
</body>

<script th:inline="javascript">
        function enterRoom(socket){
            var enterMsg={"type" : "ENTER","roomId":[[${room.roomId}]],"sender":"chee","msg":""}; //sender는  글쓸때 수정하자.
            socket.send(JSON.stringify(enterMsg));
        }
        let socket = new WebSocket("ws://localhost:8080/ws/chat");

        socket.onopen = function (e) {
            console.log('open server!')
            enterRoom(socket);
        };
        socket.onclose=function(e){
            console.log('disconnet');
        }

        socket.onerror = function (e){
            console.log(e);
        }

        //메세지 수신했을 때 이벤트.
        socket.onmessage = function (e) {
            console.log(e.data);
            let msgArea = document.querySelector('.msgArea');
            let newMsg = document.createElement('div');
            newMsg.innerText=e.data;
            msgArea.append(newMsg);
        }


        //메세지 보내기 버튼 눌렀을 떄..
        function sendMsg() {
            let content=document.querySelector('.content').value;
             var talkMsg={"type" : "TALK","roomId":[[${room.roomId}]] ,"sender":"chee","msg":content};
           socket.send(JSON.stringify(talkMsg));
        }

        function quit(){
             var quitMsg={"type" : "QUIT","roomId":[[${room.roomId}]] ,"sender":"chee","msg":""};
           socket.send(JSON.stringify(quitMsg));
            socket.close();
            location.href="/chat/chatList";
        }

</script>

</html>

시작하자마자 webSocket을 만들고 연결합니다. 그 후   방에 입장( 입장메세지를 보냄) 합니다.

메세지를 수신하면  div에 해당 내용이 추가되고

전송메세지를 누를 때마다 서버에 메세지를 보냅니다.   

아직은 메세지를 보내는 사람 이름을 "chee"로만 설정합니다 

방 나가기를 누르면 방을 나갑니다. 

(아직은 뒤로가기나 브라우저를 끄는 등의 행위를 할 때 방 나가기 이벤트가 실행되지 않기때문에 

방 나가기 버튼을 직접 눌러 실습합니다)

 

 

 

실습

방을 만들었다면 방에 입장합니다. 실시간 채팅이므로 브라우저를 2개이상 켜놓고 실습합니다.

각 브라우저에서 방1에 입장합니다.

 

 

 

메세지를 주고 받습니다.