Web Socket

Spirnb WebSocket 서버 구현

댕발바닥 2024. 3. 24. 21:17

WebSocket을 이용한 채팅 서버 구축

가장 일반적인 방식으로 먼저 채팅 서버를 구축해보겠다. 이전 포스팅에서 WebSocket, SockJs를 공부하고 소개했다 해당 부분을 이용하여 채팅서버를 구축하는데 목표를 가지고 진행을 했다.

 

 

WebSocket

https://daliy-dev.tistory.com/34

 

WebSocket

WebSocket이란? WebSocket이란 프로토콜의 일종으로 서버와 클라이언트간에 Socket Connection을 유지함으로써 언제든 양방향 통신 또는 데이터 전송이 가능하도록 하는 기술이다. 이는 통상적으로 Client

daliy-dev.tistory.com

 

SockJs

https://daliy-dev.tistory.com/35

 

WebSocket - SockJs

WebSocket의 한계 웹 소켓은 HTML5 이후에 나왔기에 HTML5 이전에 기술에는 적용이 어렵다. Firefox, Chrome, Edge, Whale 과 같은 브루아저에서는 동작을 하지만, 모바일 크롬, IE에서는 WebSocket이 동작하지 않

daliy-dev.tistory.com

 

 

WebSocket 설정

 

package com.kr.formdang.config;

import com.kr.formdang.handler.WebSocketChatHandler;
import lombok.RequiredArgsConstructor;
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;

@Configuration
@EnableWebSocket
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketConfigurer {

    private final WebSocketChatHandler webSocketChatHandler;

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(webSocketChatHandler, "/ws/chat")
                .setAllowedOrigins("http://localhost:8724")
                .withSockJS()
                .setClientLibraryUrl("http://localhost:8724/js/lib/sockjs-client_1.5.1_sockjs.js");
    }


}

 

  • WebSocket Config 클래스를 만들었으며 origin, socketjs, handler 등록등을 진행해주었다.

 

package com.kr.formdang.handler;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.kr.formdang.model.net.handler.SocketMessage;
import com.kr.formdang.service.ChatRoomService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.BinaryMessage;
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 org.springframework.web.util.UriComponents;
import org.springframework.web.util.UriComponentsBuilder;

import java.util.Objects;

@Component
@Slf4j
@RequiredArgsConstructor
public class WebSocketChatHandler extends TextWebSocketHandler {

    private final ObjectMapper objectMapper;

    private final ChatRoomService socketChatRoomService;

    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        try {
            log.debug("[입장]");
            super.afterConnectionEstablished(session);
            UriComponents uriComponents = UriComponentsBuilder.fromUriString(Objects.requireNonNull(session.getUri()).toString()).build();
            String channel = Objects.requireNonNull(uriComponents.getQueryParams().getFirst("channel"));
            String name = uriComponents.getQueryParams().getFirst("name");
            socketChatRoomService.entranceRoom(channel, session);
            socketChatRoomService.sendMessage(channel, new SocketMessage(channel, name, "안녕하세요. " + name + "님이 입장하셨습니다."));
        } catch (Exception e) {
            log.error("[오류 발생]", e);
            session.close();
            throw e;
        }
    }

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        SocketMessage socketMessage = objectMapper.readValue(message.getPayload(), SocketMessage.class);
        log.debug("{}", socketMessage);
        if (StringUtils.isNoneEmpty(socketMessage.getMsg())) {
            socketChatRoomService.sendMessage(socketMessage.getChannel(), socketMessage);
        }
    }



    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        super.afterConnectionClosed(session, status);
        log.debug("[종료]");
        UriComponents uriComponents = UriComponentsBuilder.fromUriString(Objects.requireNonNull(session.getUri()).toString()).build();
        String channel = uriComponents.getQueryParams().getFirst("channel");
        String name = uriComponents.getQueryParams().getFirst("name");
        socketChatRoomService.exitRoom(channel, session);
        socketChatRoomService.sendMessage(channel, new SocketMessage(channel, name, name + "님이 퇴장하셨습니다.."));
        session.close();
    }



}

 

  • SocketHandler로 최초연결, 메세지교환, 종료 3단계를 등록하였고 채팅방에 따라 session을 관리하게 처리했다.
  • 파라미터를 통하여 채널(채팅방번호)를 받아서 해당 세션을 등록하고 제거해주었다.

 

package com.kr.formdang.service.impl;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.kr.formdang.model.dto.SocketChatRoom;
import com.kr.formdang.service.ChatRoomService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;

import java.io.IOException;
import java.util.*;

@Service
@Slf4j
@RequiredArgsConstructor
public class SocketChatRoomService implements ChatRoomService {

    private Map<String, SocketChatRoom> chatRoomMap = Collections.synchronizedMap(new HashMap<>());
    private final ObjectMapper objectMapper;

    @Override
    public <T> void sendMessage(String channel, T message) {
        if (!chatRoomMap.containsKey(channel)) return;
        SocketChatRoom chatRoom = chatRoomMap.get(channel);
        chatRoom.getSessions().forEach(it -> {
            try {
                if (it.isOpen()) {
                    it.sendMessage(new TextMessage(objectMapper.writeValueAsString(message)));
                } else {
                    chatRoom.getSessions().remove(it);
                }
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        });
    }

    @Override
    public void createRoom(String channel) {
        if (chatRoomMap.containsKey(channel)) return;
        chatRoomMap.put(channel, new SocketChatRoom(channel));
    }

    @Override
    public void deleteRoom(String channel) {
        if (!chatRoomMap.containsKey(channel)) return;
        chatRoomMap.remove(channel);
    }

    @Override
    public List<String> findRooms() {
        return new LinkedList<>(chatRoomMap.keySet());
    }

    @Override
    public void entranceRoom(String channel, WebSocketSession session) {
        if (!chatRoomMap.containsKey(channel)) return;
        SocketChatRoom chatRoom = chatRoomMap.get(channel);
        chatRoom.entranceRoom(session);
    }

    @Override
    public void exitRoom(String channel, WebSocketSession session) {
        if (!chatRoomMap.containsKey(channel)) return;
        SocketChatRoom chatRoom = chatRoomMap.get(channel);
        chatRoom.exitRoom(session);
    }

}
  • 채널 키를통하여 채팅방 객체를 찾아 해당 세션들의 행동을 구현하는 구현체 부분이다.

 

package com.kr.formdang.model.dto;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.socket.WebSocketSession;

import java.util.concurrent.CopyOnWriteArrayList;

@Getter
@Slf4j
public class SocketChatRoom {

    private String channel;

    private CopyOnWriteArrayList<WebSocketSession> sessions = new CopyOnWriteArrayList<>();

    public SocketChatRoom(String channel) {
        this.channel = channel;
    }

    public void entranceRoom(WebSocketSession session) {
        sessions.addIfAbsent(session);
        log.debug("세션 수: {}", sessions.size());
    }

    public void exitRoom(WebSocketSession session) {
        sessions.remove(session);
        log.debug("세션 수: {}", sessions.size());
    }
}
  • 실제 세션이 관리되고있는 채팅방 객체이다.
  • CopyOnWriteArrayList 리스트를 사용하여 스레드 safe를 유지하고, 해당 List를 쓴 이유 실제 서비스에서 세션의 read작업이 write 작업보다 많이 발생 될것이라고 예상하고 썻다 

 

위 처럼 서버를 설정해주고 화면을 구성했다

 

package com.kr.formdang.controller;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.servlet.ModelAndView;

@Controller
@Slf4j
@RequiredArgsConstructor
public class ViewController {

    @GetMapping("/view/socket/chat/{channel}")
    public ModelAndView viewSocketChat(@PathVariable(value = "channel") String channel) {
        ModelAndView modelAndView = new ModelAndView();
        modelAndView.setViewName("/socket/chat.html");
        modelAndView.addObject("channel", channel);
        return modelAndView;
    }

    @GetMapping("/view/socket/room")
    public ModelAndView viewSocketRoom() {
        ModelAndView modelAndView = new ModelAndView();
        modelAndView.setViewName("/socket/room.html");
        return modelAndView;
    }
}
  • 뷰 페이지로 이동해주는 컨트롤러로 room.html에서 방을선택하여 chat.html로 이동한다 이때 채널 값을 모델에 넣어주는 처리를 진행했다.

 

<!DOCTYPE html>
<html>
<head>
    <meta http-equiv="Content-Type" content="text/event-stream; charset=utf-8"/>
    <title>채팅방</title>
    <script th:src="@{/js/lib/jquery-3.7.1.min.js}"></script> <!-- jquery -->
    <script th:src="@{/js/socket-chat.js}"></script>
    <script th:src="@{/js/lib/sockjs-client_1.5.1_sockjs.js}"></script> <!-- socket js -->
    <script th:inline="javascript">
        const channel = [[${channel}]]
    </script>
</head>
<body>
<div id="main-content" class="container">
    <div class="row">
        <div class="col-md-6">
            <div class="form-inline">
                <div class="form-group">
                    <label for="connect">채팅방 연결하기 :</label>
                    <button id="connect" class="btn btn-default" type="button" onclick="connect()">Connect</button>
                </div>
            </div>
        </div>
        <div class="col-md-6">
            <div class="form-inline">
                <div class="form-group">
                    <label for="content">채팅 내용입력</label>
                    <input type="text" id="content" class="form-control" placeholder="Your name here..." onkeyup="if(window.event.keyCode==13) { send(); }">
                    <button class="btn btn-default" type="button" onclick="send()">전송</button>
                </div>
            </div>
        </div>
    </div>
    <div class="row">
        <div class="col-md-12">
            <table id="conversation" class="table table-striped">
                <thead>
                <tr>
                    <th>안녕하세요.</th>
                </tr>
                </thead>
                <tbody id="greetings">
                </tbody>
            </table>
        </div>
    </div>
</div>
</body>
</html>
  • HTML 페이지며 다른 설정없이 connection 수행 및 메세지 발송 정도 등록했다.

 

const name = generateRandomString()

let websocket;

class Msg {
    channel
    name
    msg
    constructor(channel, name, msg) {
        this.channel = channel;
        this.name = name;
        this.msg = msg;
    }
}

function connect() {
    websocket = new SockJS(`/ws/chat?channel=${channel}&name=${name}`);

    websocket.onmessage = onmessage;
    websocket.onerror = onerror;
    websocket.onclose = function () {
        window.history.back();
    };

    setConnected(true)
}

function onmessage(msg) {
    const data = JSON.parse(msg.data)
    $("#greetings").append("<tr><td>" + data.msg + "</td></tr>");
}

function onerror(e) {
    console.log(e)
    setConnected(false)
}

function send() {
    console.log(name)
    websocket.send(JSON.stringify(new Msg(channel, name, document.getElementById('content').value)));
    document.getElementById('content').value = '';
}

function setConnected(connected) {
    $("#connect").prop("disabled", connected);
    $("#disconnect").prop("disabled", !connected);
    if (connected) $("#conversation").show();
    else $("#conversation").hide();
    $("#greetings").html("");
}


function generateRandomString(){
    const num = Math.ceil(Math.random() * 1000);
    return "유저" + num;
}

$(document).ready(function () {
    connect();
})
  • 스크립트를 통하여 웹 소켓 통신을 이루었다.
  •  

 

 

위처럼 두개의 소켓 연결을 통하여 유저 대화 테스트를 진행했다.

 

소켓을 통하여 채팅 서버 구축을 해보면서 새롭게 알아가는 지식들이 많아서 도움이 많이 되고있는 것같다.

 

다음에는 다중화 서버 가능한 구조로 개선 해볼예정이다.