본문 바로가기

Web Socket

Spirnb WebSocket 서버 구현

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();
})
  • 스크립트를 통하여 웹 소켓 통신을 이루었다.
  •  

 

 

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

 

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

 

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

'Web Socket' 카테고리의 다른 글

WebSocket - SockJs  (1) 2024.03.24
WebSocket  (0) 2024.03.24
Spring STOMP 프로토콜 사용  (1) 2024.03.03
Spring Web Socket 서버 생성  (0) 2024.03.02