[Spring] WebSocket을 이용한 실시간 채팅

CodeKong의 기술 블로그·2023년 9월 12일
1

SPRING BOOT

목록 보기
9/24
post-thumbnail

[진행중인 포스트입니다.]

MBTI community 사이드 프로젝트 진행 중에 실시간 채팅기능을 이용하기 위해

& UMC FatMan 프로젝트에서 도입할 팀 매칭 기능을 개발하기 위해 실시간 기능을 도입하였습니다!


기존의 웹 브라우저는 request를 보내면 response를 받는 방식으로 이루어집니다.
그래서 클라이언트는 "요청을 보내야지만" 응답을 받을 수 있는 구조입니다.

하지만 요청을 보내지 않는 상황에서도 데이터를 받아야하는 상황이 있죠
실시간 채팅이나 알림이 그런 상황들 입니다.

WebSockect

WebSockect은 HandShake를 통해 다른 클라이언트들과의 connection을 생성하고 유지합니다.

바로 알아보도록 하겠습니다!


build.gradle

//웹소켓 라이브러리
implementation 'org.springframework.boot:spring-boot-starter-websocket'

implementation 'org.webjars:webjars-locator-core'
implementation 'org.webjars:sockjs-client:1.5.1'
implementation 'org.webjars:stomp-websocket:2.3.4'
implementation 'org.springframework:spring-messaging:6.0.3'
implementation 'org.springframework.security:spring-security-messaging:6.0.2'
implementation 'org.webjars:bootstrap:3.3.7'
implementation 'org.webjars:jquery:3.1.1-1'

자세한 설명은 에 정리해두었습니다!


WebSokectConfig.java

@Configuration
@EnableWebSocketMessageBroker
public class WebSokectConfig implements WebSocketMessageBrokerConfigurer{

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws")
                .setAllowedOriginPatterns("*")
                .withSockJS();
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.enableSimpleBroker("/sub");
        registry.setApplicationDestinationPrefixes("/pub");
    }
}

WebSocket을 위한 Config파일입니다.
기본적으로 WebSocketMessageBrokerConfigurer를 구현한 클래스입니다

  1. registerStompEndpoints

  2. configureMessageBroker
    공식 문서에서는 app과 topic으로 되어있지만 이해의 편의를 위해 설정하였습니다.


ChatRequestDto.java

@AllArgsConstructor
@NoArgsConstructor
@Builder
@Getter
public class ChatRequestDto {

    private String sender;

    private String content;
}

ChatController.java

@Controller
@RequiredArgsConstructor
public class ChatController {

   private final SimpMessagingTemplate messagingTemplate;

	//입장을 할 때 사용하는 루트입니다.
   @MessageMapping("/chat/enter")
   public void enter(@RequestBody ChatRequestDto dto) {
       messagingTemplate.convertAndSend("/sub/chat/room/1", dto);
   }
   
	//메세지를 전송할 때 사용하는 루트입니다.
   @MessageMapping("/chat/message")
   public void message(@RequestBody ChatRequestDto dto) {
       messagingTemplate.convertAndSend("/sub/chat/room/1", dto);
   }

}

메세지를 전송하기 위한 Controller입니다.
SimpMessagingTemplate의 설명은 에 정리해놨습니다!

@MessageMapping

기본적으로 주소로 보내면 convertAndSend를 통해 해당 엔드포인트로 dto를 보냅니다.
이후 해당 엔드포인트를 sub하고 있는 클라이언트들에게 메세지를 전송합니다.


이후 웹 부분에서는 https://ratseno.tistory.com/72 님의 포스트를 참고하여 작성하였습니다!

index.html

<!DOCTYPE html>
<html>
<head>
    <meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0">
    <title>Spring Boot WebSocket Chat Application</title>
    <link rel="stylesheet" href="/css/main.css" />
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
</head>
<body>

<div id="username-page">
    <div class="username-page-container">
        <h1 class="title">username을 입력하세요</h1>
        <form id="usernameForm" name="usernameForm">
            <div class="form-group">
                <input type="text" id="name" placeholder="Username" autocomplete="off" class="form-control" />
            </div>
            <div class="form-group">
                <button type="submit" class="accent username-submit">채팅 시작하기</button>
            </div>
        </form>
    </div>
</div>

<div id="chat-page" class="hidden">
    <div class="chat-container">
        <div class="chat-header">
            <h2>Spring WebSocket Chat Demo</h2>
        </div>
        <div class="connecting">
            연결중...
        </div>
        <ul id="messageArea">

        </ul>
        <form id="messageForm" name="messageForm">
            <div class="form-group">
                <div class="input-group clearfix">
                    <input type="text" id="message" placeholder="Type a message..." autocomplete="off" class="form-control"/>
                    <button type="submit" class="primary">보내기</button>
                </div>
            </div>
        </form>
    </div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/sockjs-client/1.4.0/sockjs.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/stomp.js/2.3.3/stomp.min.js"></script>
<script src="./app.js"></script>
</body>
</html>

app.js

var usernamePage = document.querySelector('#username-page');
var chatPage = document.querySelector('#chat-page');
var usernameForm = document.querySelector('#usernameForm');
var messageForm = document.querySelector('#messageForm');
var messageInput = document.querySelector('#message');
var messageArea = document.querySelector('#messageArea');
var connectingElement = document.querySelector('.connecting');

var stompClient = null;
var username = null;

var colors = [
    '#2196F3', '#32c787', '#00BCD4', '#ff5652',
    '#ffc107', '#ff85af', '#FF9800', '#39bbb0'
];

function connect(event) {
    username = document.querySelector('#name').value.trim();

    if(username) {
        usernamePage.classList.add('hidden');
        chatPage.classList.remove('hidden');

        var socket = new SockJS('/ws');
        stompClient = Stomp.over(socket);

        stompClient.connect({}, onConnected, onError);
    }
    event.preventDefault();
}


function onConnected() {
    // Subscribe to the Public Topic
    stompClient.subscribe('/sub/chat/room/1', onMessageReceived);

    // Tell your username to the server
    stompClient.send("/pub/chat/enter",
        {},
        JSON.stringify({sender: username, content: 'JOIN'})
    )

    connectingElement.classList.add('hidden');
}


function onError(error) {
    connectingElement.textContent = 'Could not connect to WebSocket server. Please refresh this page to try again!';
    connectingElement.style.color = 'red';
}


function sendMessage(event) {
    var messageContent = messageInput.value.trim();
    if(messageContent && stompClient) {
        var chatMessage = {
            sender: username,
            content: messageInput.value
        };
        stompClient.send("/pub/chat/message", {}, JSON.stringify(chatMessage));
        messageInput.value = '';
    }
    event.preventDefault();
}


function onMessageReceived(payload) {
    var message = JSON.parse(payload.body);
console.log("message",message);
    var messageElement = document.createElement('li');

    if(message.content === 'JOIN') {
        messageElement.classList.add('event-message');
        message.content = message.sender + ' joined!';
    } else if (message.content === 'LEAVE') {
        messageElement.classList.add('event-message');
        message.content = message.sender + ' left!';
    } else {
        messageElement.classList.add('chat-message');

        var avatarElement = document.createElement('i');
        var avatarText = document.createTextNode(message.sender[0]);
        avatarElement.appendChild(avatarText);
        avatarElement.style['background-color'] = getAvatarColor(message.sender);

        messageElement.appendChild(avatarElement);

        var usernameElement = document.createElement('span');
        var usernameText = document.createTextNode(message.sender);
        usernameElement.appendChild(usernameText);
        messageElement.appendChild(usernameElement);
    }

    var textElement = document.createElement('p');
    var messageText = document.createTextNode(message.content);
    textElement.appendChild(messageText);

    messageElement.appendChild(textElement);

    messageArea.appendChild(messageElement);
    messageArea.scrollTop = messageArea.scrollHeight;
}


function getAvatarColor(messageSender) {
    var hash = 0;
    for (var i = 0; i < messageSender.length; i++) {
        hash = 31 * hash + messageSender.charCodeAt(i);
    }
    var index = Math.abs(hash % colors.length);
    return colors[index];
}

usernameForm.addEventListener('submit', connect, true)
messageForm.addEventListener('submit', sendMessage, true)

중요 파트이기 때문에 부분부분 알아보겠습니다

function connect(event) {
    username = document.querySelector('#name').value.trim();

    if(username) {
        usernamePage.classList.add('hidden');
        chatPage.classList.remove('hidden');

		//SockJS를 초기화하고 WebSocket 연결을 생성합니다.
        var socket = new SockJS('/ws');

        //앞서 생성한 SockJS 연결을 이용하여 Stomp 클라이언트를 초기화합니다.
        stompClient = Stomp.over(socket);

  		//stompClient 객체를 사용하여 서버에 연결을 시도합니다.
        stompClient.connect({}, onConnected, onError);
    }
    event.preventDefault();
}

SockJS는 WebSocket을 사용하지 못하는 환경에서도 대체 수단을 제공하는 라이브러리입니다.
WebSocket을 지원하지 못하는 브라우저도 유사하게 작동하게 도와줍니다.

connect 함수는 세 가지 인자를 받습니다.

  1. 첫 번째 인자 {} : 연결 옵션으로, 여기서는 추가 옵션을 설정하지 않고 빈 객체를 사용합니다.
  2. 두 번째 인자 onConnected : 연결이 성공하면 호출할 콜백 함수로, 서버와의 연결이 성공적으로 설정되면 이 함수가 호출됩니다.
  3. 세 번째 인자 onError : 연결이 실패하면 호출할 콜백 함수로, 연결 오류가 발생하면 이 함수가 호출됩니다.
function onConnected() {

    //특정 채팅 방에 구독을 요청합니다
    //onMessageReceived 함수는 새로운 메시지가 도착했을 때 호출되는 콜백 함수입니다
    stompClient.subscribe('/sub/chat/room/1', onMessageReceived);

    //클라이언트는 /pub/chat/enter 엔드포인트로 메시지를 보냅니다
    stompClient.send("/pub/chat/enter",
        {},
        JSON.stringify({sender: username, content: 'JOIN'})
    )

    connectingElement.classList.add('hidden');
}

SockJS는 WebSocket을 사용하지 못하는 환경에서도 대체 수단을 제공하는 라이브러리입니다.
WebSocket을 지원하지 못하는 브라우저도 유사하게 작동하게 도와줍니다.

send 함수는 두 가지 인자를 받습니다.

  1. 첫 번째 인자 {} : 헤더 옵션입니다.
  2. 두 번째 인자 JSON.stringify({sender: username, content: 'JOIN'} : 메세지의 내용을 JSON 형식으로 전송합니다.

css/main.css

* {
    -webkit-box-sizing: border-box;
    -moz-box-sizing: border-box;
    box-sizing: border-box;
}

html,body {
    height: 100%;
    overflow: hidden;
}

body {
    margin: 0;
    padding: 0;
    font-weight: 400;
    font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
    font-size: 1rem;
    line-height: 1.58;
    color: #333;
    background-color: #f4f4f4;
    height: 100%;
}

body:before {
    height: 50%;
    width: 100%;
    position: absolute;
    top: 0;
    left: 0;
    background: #128ff2;
    content: "";
    z-index: 0;
}

.clearfix:after {
    display: block;
    content: "";
    clear: both;
}

.hidden {
    display: none;
}

.form-control {
    width: 100%;
    min-height: 38px;
    font-size: 15px;
    border: 1px solid #c8c8c8;
}

.form-group {
    margin-bottom: 15px;
}

input {
    padding-left: 10px;
    outline: none;
}

h1, h2, h3, h4, h5, h6 {
    margin-top: 20px;
    margin-bottom: 20px;
}

h1 {
    font-size: 1.7em;
}

a {
    color: #128ff2;
}

button {
    box-shadow: none;
    border: 1px solid transparent;
    font-size: 14px;
    outline: none;
    line-height: 100%;
    white-space: nowrap;
    vertical-align: middle;
    padding: 0.6rem 1rem;
    border-radius: 2px;
    transition: all 0.2s ease-in-out;
    cursor: pointer;
    min-height: 38px;
}

button.default {
    background-color: #e8e8e8;
    color: #333;
    box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.12);
}

button.primary {
    background-color: #128ff2;
    box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.12);
    color: #fff;
}

button.accent {
    background-color: #ff4743;
    box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.12);
    color: #fff;
}

#username-page {
    text-align: center;
}

.username-page-container {
    background: #fff;
    box-shadow: 0 1px 11px rgba(0, 0, 0, 0.27);
    border-radius: 2px;
    width: 100%;
    max-width: 500px;
    display: inline-block;
    margin-top: 42px;
    vertical-align: middle;
    position: relative;
    padding: 35px 55px 35px;
    min-height: 250px;
    position: absolute;
    top: 50%;
    left: 0;
    right: 0;
    margin: 0 auto;
    margin-top: -160px;
}

.username-page-container .username-submit {
    margin-top: 10px;
}


#chat-page {
    position: relative;
    height: 100%;
}

.chat-container {
    max-width: 700px;
    margin-left: auto;
    margin-right: auto;
    background-color: #fff;
    box-shadow: 0 1px 11px rgba(0, 0, 0, 0.27);
    margin-top: 30px;
    height: calc(100% - 60px);
    max-height: 600px;
    position: relative;
}

#chat-page ul {
    list-style-type: none;
    background-color: #FFF;
    margin: 0;
    overflow: auto;
    overflow-y: scroll;
    padding: 0 20px 0px 20px;
    height: calc(100% - 150px);
}

#chat-page #messageForm {
    padding: 20px;
}

#chat-page ul li {
    line-height: 1.5rem;
    padding: 10px 20px;
    margin: 0;
    border-bottom: 1px solid #f4f4f4;
}

#chat-page ul li p {
    margin: 0;
}

#chat-page .event-message {
    width: 100%;
    text-align: center;
    clear: both;
}

#chat-page .event-message p {
    color: #777;
    font-size: 14px;
    word-wrap: break-word;
}

#chat-page .chat-message {
    padding-left: 68px;
    position: relative;
}

#chat-page .chat-message i {
    position: absolute;
    width: 42px;
    height: 42px;
    overflow: hidden;
    left: 10px;
    display: inline-block;
    vertical-align: middle;
    font-size: 18px;
    line-height: 42px;
    color: #fff;
    text-align: center;
    border-radius: 50%;
    font-style: normal;
    text-transform: uppercase;
}

#chat-page .chat-message span {
    color: #333;
    font-weight: 600;
}

#chat-page .chat-message p {
    color: #43464b;
}

#messageForm .input-group input {
    float: left;
    width: calc(100% - 85px);
}

#messageForm .input-group button {
    float: left;
    width: 80px;
    height: 38px;
    margin-left: 5px;
}

.chat-header {
    text-align: center;
    padding: 15px;
    border-bottom: 1px solid #ececec;
}

.chat-header h2 {
    margin: 0;
    font-weight: 500;
}

.connecting {
    padding-top: 5px;
    text-align: center;
    color: #777;
    position: absolute;
    top: 65px;
    width: 100%;
}


@media screen and (max-width: 730px) {

    .chat-container {
        margin-left: 10px;
        margin-right: 10px;
        margin-top: 10px;
    }
}

@media screen and (max-width: 480px) {
    .chat-container {
        height: calc(100% - 30px);
    }

    .username-page-container {
        width: auto;
        margin-left: 15px;
        margin-right: 15px;
        padding: 25px;
    }

    #chat-page ul {
        height: calc(100% - 120px);
    }

    #messageForm .input-group button {
        width: 65px;
    }

    #messageForm .input-group input {
        width: calc(100% - 70px);
    }

    .chat-header {
        padding: 10px;
    }

    .connecting {
        top: 60px;
    }

    .chat-header h2 {
        font-size: 1.1em;
    }
}

결과

codeKong를 입력하고 접속합니다.

join 메세지가 나타납니다.

채팅 시 화면에 표시 합니다

다른 브라우저에서 접속시 또 다른 브라우저에 join 메세지가 나타납니다

동작완료!!

0개의 댓글