반응형
04-29 06:41
Today
Total
«   2024/04   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30
관리 메뉴

개발하는 고라니

[Spring Boot] WebSocket과 채팅 (1) 본문

Framework/Spring Boot

[Spring Boot] WebSocket과 채팅 (1)

조용한고라니 2021. 3. 30. 00:56
반응형

일전에 WebSocket(웹소켓)과 SockJS를 사용해 Spring 프레임워크 환경에서 간단한 하나의 채팅방을 구현해본 적이 있다.

 

[Spring MVC] Web Socket(웹 소켓)과 Chatting(채팅)

 기존 공부 용도의 게시판(?)에 여러 기능을 추가하던 차, 관리자와 멤버 간 채팅 기능을 구현하고 싶었다. 채팅을 하려면 웹 소켓이 필요하다고 한다. 간단하게 구현하는 것은 어렵지 않으므로

dev-gorany.tistory.com

이때는 무작정 여러 블로그를 참고하면서 채팅이라는 기능을 구현하고 다뤄보는 것에 의의를 두었다. 이번에는 Spring Boot 환경에서 여러개의 채팅방을 구현하고, 채팅이 저장될 수 있게 하기까지를 우선 목표로 설정하고 좀 더 공부하며 진행해보고자 한다.


WebSocket

기존의 단방향 HTTP 프로토콜과 호환되어 양방향 통신을 제공하기 위해 개발된 프로토콜.

일반 Socket통신과 달리 HTTP 80 Port를 사용하므로 방화벽에 제약이 없으며 통상 WebSocket으로 불린다.

접속까지는 HTTP 프로토콜을 이용하고, 그 이후 통신은 자체적인 WebSocket 프로토콜로 통신하게 된다.

웹 소켓은 HTTP(Hyper Text Transfer Protocol)를 사용하는 네트워크 데이터 통신의 단점을 보완하는데 그 목적이 있다. HTTP를 다뤄본 적이 있다면, HTTP는 HTML이라는 문서를 운반하기 위한 프로토콜로 다음과 같이 동작한다.

출처: 솔내시스템 ezTCP님의 블로그

모든 HTTP를 사용한 통신은 클라이언트가 먼저 요청을 보내고, 그 요청에 따라 웹 서버가 응답하는 형태이며 웹 서버는 응답을 보낸 후 웹 브라우저와의 연결을 끊는다. 양쪽이 데이터를 동시에 보내는 것이 아니기 때문에 이러한 통신 방식을 반이중 통신(Half Duplex)라고 한다.

 

사실 HTTP만으로도 원하는 정보를 송수신할 수 있었지만, 인간의 욕심은 끝이 없기에 인터넷이 발전함에 따라 원하는 것이 더욱 다양해졌다. 예를 들어 클라이언트가 먼저 요청하지 않아도 서버가 먼저 데이터를 보내거나, 표준 TCP/IP 통신을 사용해 특정 서버와 통신을 하는 등 원하는 것이 늘어가자 그것을 이루고자 많은 플러그인 및 웹 기술이 개발되었다.

 

WebSocket이 존재하기 전에는 Polling이나 Long Polling, Streaming등의 방식으로 해결했었다.

 

* polling

 

출처:https://rubberduck-debug.tistory.com/123

클라이언트가 평범한 HTTP Request를 서버로 계속 요청해 이벤트 내용을 전달받는 방식. 가장 쉬운 방법이지만 클라이언트가 지속적으로 Request를 요청하기 때문에 클라이언트의 수가 많아지면 서버의 부담이 급증한다. HTTP Request Connection을 맺고 끊는 것 자체가 부담이 많은 방식이고, 클라이언트에서 실시간 정도의 빠른 응답을 기대하기 어렵다.

 

* Long polling

 

출처:https://rubberduck-debug.tistory.com/123

클라이언트에서 서버로 일단 HTTP Request를 요청한다. 이 상태로 계속 기다리다가 서버에서 해당 클라이언트로 전달할 이벤트가 있다면 그 순간 Response 메세지를 전달하며 연결이 종료된다. 곧이어 클라이언트가 다시 HTTP Request를 요청해 서버의 다음 이벤트를 기다리는 방식. polling보다 서버의 부담이 줄겠으나, 클라이언트로 보내는 이벤트들의 시간간격이 좁다면 polling과 별 차이 없게 되며, 다수의 클라이언트에게 동시에 이벤트가 발생될 경우 서버의 부담이 급증한다.

 

* Streaming

 

출처:https://rubberduck-debug.tistory.com/123

Long Polling과 마찬가지로 클라이언트 -> 서버로 HTTP Request를 요청한다. 서버 -> 클라이언트로 이벤트를 전달할 때 해당 요청을 해제하지 않고 필요한 메세지만 보내기(Flush)를 반복하는 방식. Long Polling과 비교하여 서버에 메세지를 보내지 않고도 다시 HTTP Request 연결을 하지 않아도 되어 부담이 경감된다고 한다.

 

* WebSocket

 

이처럼 HTTP 통신의 특징인 (연결 -> 연결 해제) 때문에 효율이 많이 떨어지게 되고, 웹 브라우저 말고 외부 플러그인이 항상 필요하게 되었다. 그래서 이런 상황을 극복하고자 2014년 HTML5에 웹 소켓을 포함하게 되었다. 웹소켓은 클라이언트가 접속 요청을 하고 웹 서버가 응답한 후 연결을 끊는 것이 아닌 Connection을 그대로 유지하고 클라이언트의 요청 없이도 데이터를 전송할 수 있는 프로토콜이다. 프로토콜의 요청은 [ws://~]로 시작한다.

 

웹소켓은 HTTP환경에서 전이중 통신(Full Duplex, 2-way communication)을 지원하기 위한 프로토콜이며 RFC6455에 정의되어 있다. HTTP 프로토콜에서 HandShaking을 완료한 후, HTTP로 동작하지만, HTTP와는 다른 방식으로 통신을 한다.

 

WebSocket이 기존의 TCP Socket과 다른 점은 최초 접속이 일반 HTTP Request를 통해 HandShaking 과정을 통해 이뤄진다는 점이다.

HTTP Request를 그대로 사용하기 때문에 기존의 80, 443 포트로 접속을 하므로 추가 방화벽을 열지 않고도 양방향 통신이 가능하고, HTTP 규격인 CORS 적용이나 인증 등 과정을 기존과 동일하게 가져갈 수 있는 것이 장점이다.

 

웹소켓은 서비스를 동적으로 만들어 주지만, Ajax, Streaming, Long polling 기술이 더 효과적일 경우도 있다. 예를 들어, 변경 사항의 빈도가 자주 일어나지 않고, 데이터의 크기가 작은 경우 Ajax, Streaming, Long polling 기술이 더 효과적일 수 있다. 즉, 실시간성을 보장해야 하고, 변경 사항의 빈도가 잦다면, 또는 짧은 대기 시간, 고주파수, 대용량의 조합인 경우 WebSocket이 좋은 해결책이 될 수 있다.

 

 뉴스나 메일, SNS 피드는 동적으로 업데이트 하는 것은 맞지만 몇 분마다 업데이트 하는 것이 좋다. 반면 협업, 게임, 금융 앱은 훨씬 더 실시간에 근접해야한다.

 

* Browser별 지원 현황

Browser WebSocket Streaming Polling
IE 6, 7 no no jsonp-polling
IE 8, 9 (cookies=no) no xdr-streaming † xdr-polling †
IE 8, 9 (cookies=yes) no iframe-htmlfile iframe-xhr-polling
IE 10 rfc6455 xhr-streaming xhr-polling
Chrome 6-13 hixie-76 xhr-streaming xhr-polling
Chrome 14+ hybi-10 / rfc6455 xhr-streaming xhr-polling
Firefox <10 no ‡ xhr-streaming xhr-polling
Firefox 10+ hybi-10 / rfc6455 xhr-streaming xhr-polling
Safari 5.x hixie-76 xhr-streaming xhr-polling
Safari 6+ rfc6455 xhr-streaming xhr-polling
Opera 10.70+ no ‡ iframe-eventsource iframe-xhr-polling
Opera 12.10+ rfc6455 xhr-streaming xhr-polling
Konqueror no no jsonp-polling

(출처 : github.com/sockjs/sockjs-client#supported-transports-by-browser-html-served-from-http-or-https)


WebSocket 접속 과정

출처: https://blog.naver.com/eztcpcom/220070508655

웹소켓을 이용하여 서버와 클라이언트가 통신을 하려면 먼저 웹소켓 접속 과정을 거쳐야 한다. 웹소켓 접속 과정은 TCP/IP 접속 그리고 웹소켓 열기 HandShake 과정으로 나눌 수 있다. 웹소켓도 TCP/IP위에서 동작하므로, 서버와 클라이언트는 웹소켓을 사용하기 전에 서로 TCP/IP 접속이 되어있어야 한다. TCP/IP 접속이 완료된 후 서버와 클라이언트는 웹소켓 열기 HandShake 과정을 시작한다.

 

* 웹소켓 열기 HandShake

 

웹소켓 열기 핸드셰이크는 클라이언트가 먼저 핸드셰이크 요청을 보내고 이에 대한 응답을 서버가 클라이언트로 보내는 구조이다. 서버와 클라이언트는 HTTP 1.1 프로토콜을 사용하여 요청과 응답을 보낸다. 다음은 Request와 Response의 예시이다.

 

HandShake Request

GET /chat HTTP/1.1
Host: server.gorany.org
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Origin: http://localhost:8080
Sec-WebSocket-Protocol: v10.stomp, v11.stomp, my-team-custom
Sec-WebSocket-Version: 13
 Header Name   div   Description 
GET Require 요청 명령어는 GET을 사용해야 하며, HTTP 버전은 1.1 이상이어야 한다.
Host Require 웹소켓 서버의 주소
Upgrade Require WebSocket이라는 단어를 사용해야 한다. 대소문자는 구분 X
Connection Require Upgrade라는 단어를 사용해야 한다. 대소문자는 구분 X
Sec-WebSocket-Key Require 길이가 16Byte인 임의로 선태괸 숫자를 base64 인코딩한 값 이다.
Origin Require 클라이언트로 웹 브라우저를 사용하는 경우 필수항목으로, 클라이언트의 주소
Sec-WebSocket-Version Require 13을 사용한다.
Sec-WebSocket-Protocol Option 클라이언트가 사용하고 싶은 하위 프로토콜 이름을 명시한다.
Sec-WebSocket-Extensions Option 클라이언트가 사용하고 싶은 추가 옵션을 기술한다.

 

HandShake Response

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
 Header Name   div   Description 
HTTP Require HTTP 버전은 1.1이며, 클라이언트로부터의 요청이 이상 없는 경우 101을 상태코드로 사용한다.
Upgrade Require WebSocket이라는 단어를 사용해야 한다. 대소문자는 구분 X
Connection Require Upgrade라는 단어를 사용해야 한다. 대소문자는 구분 X
Sec-WebSocket-Accept Require 클라이언트로부터 받은 Sec-WebSocket-Key를 사용하여 계산된 값이다.
Sec-WebSocket-Protocol Option 서버에서 서비스하는 하위 프로토콜을 명시한다. 클라이언트가 요청하지 않는 하위 프로토콜을 명시하면 HandShake는 실패한다.
Sec-WebSocket-Extensions Option 서버가 사용하는 추가 옵션을 기술한다. 클라이언트가 요청하지 않는 추가 옵션을 명시하면 HandShake는 실패한다.

위 테이블에 명시된 헤더 중 필수는 반드시 사용해야 하며, 특정한 값이 명시된 헤더는 그 값만 사용해야 한다.


WebSocket 구현 테스트

1. 라이브러리 추가

 

나는 gradle을 사용하고 있기 때문에(전에는 Maven을 사용했지만) build.gradle에 spring websocket을 추가해준다.

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    
    ///////////////////////////////////////////////////////////////////////
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-websocket'
    ///////////////////////////////////////////////////////////////////////
    
    implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5'
    compileOnly 'org.projectlombok:lombok'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    runtimeOnly 'org.mariadb.jdbc:mariadb-java-client'
    annotationProcessor 'org.projectlombok:lombok'
    providedRuntime 'org.springframework.boot:spring-boot-starter-tomcat'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'

    compile group: 'org.thymeleaf.extras', name: 'thymeleaf-extras-java8time'
    implementation 'com.querydsl:querydsl-jpa'
    // https://mvnrepository.com/artifact/net.coobird/thumbnailator
    implementation group: 'net.coobird', name: 'thumbnailator', version: '0.4.8'

}

   

2. WebSocket Handler

 

소켓통신은 서버와 클라이언트가 1:N의 관계를 맺는다. 즉, 하나의 서버에 다수 클라이언트가 접속할 수 있다. 따라서 서버는 다수의 클라이언트가 보낸 메세지를 처리할 핸들러가 필요하다. 텍스트 기반의 채팅을 구현해볼 것 이므로 'TextWebSocketHandler'를 상속받아서 작성한다. Client로부터 받은 메세지를 Log출력하고 클라이언트에게 환영하는 메세지를 보내는 역할을 한다.

 

package org.gorany.community.handler;

import lombok.extern.log4j.Log4j2;
import org.springframework.stereotype.Component;
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.util.ArrayList;
import java.util.List;

@Component
@Log4j2
public class ChatHandler extends TextWebSocketHandler {

    private static List<WebSocketSession> list = new ArrayList<>();

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        String payload = message.getPayload();
        log.info("payload : " + payload);

        for(WebSocketSession sess: list) {
            sess.sendMessage(message);
        }
    }

    /* Client가 접속 시 호출되는 메서드 */
    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {

        list.add(session);

        log.info(session + " 클라이언트 접속");
    }

    /* Client가 접속 해제 시 호출되는 메서드드 */

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {

        log.info(session + " 클라이언트 접속 해제");
        list.remove(session);
    }
}

 

* payload ?

 

페이로드란 전송되는 데이터를 의미한다.. 데이터를 전송할 때, Header와 META 데이터, 에러 체크 비트 등과 같은 다양한 요소들을 함께 보내 데이터 전송 효율과 안정성을 높히게 된다. 이때, 보내고자 하는 데이터 자체를 의미하는 것이 페이로드이다. 예를 들어 택배 배송을 보내고 받을 때 택배 물건이 페이로드고 송장이나 박스 등은 부가적은 것이기 때문에 페이로드가 아니다.

 

다음 JSON에서 페이로드는 "data"이다. 나머지는 통신을 하는데 있어 용이하게 해주는 부가적 정보들이다.

{
"status":
"from":"localhost",
"to":"http://melonicedlatte.com/chatroom/1",
"method":"GET",
"data":{"message":"There is a cutty dog!"}
}

 

3. WebSocket Config

 

핸들러를 이용해 WebSocket을 활성화하기 위한 Config를 작성할 것이다.

@EnableWebSocket 어노테이션을 사용해 WebSocket을 활성화 하도록 한다.

WebSocket에 접속하기 위한 Endpoint는 /chat으로 설정하고, 

도메인이 다른 서버에서도 접속 가능하도록 CORS : setAllowedOrigins("*"); 를 추가해준다. 이제 클라이언트가 ws://localhost:8080/chat으로 커넥션을 연결하고 메세지 통신을 할 수 있는 준비를 마쳤다.

import lombok.RequiredArgsConstructor;
import org.gorany.community.handler.ChatHandler;
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
@RequiredArgsConstructor
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {

    private final ChatHandler chatHandler;

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
    
        registry.addHandler(chatHandler, "ws/chat").setAllowedOrigins("*");
    }
}

 

* CORS

 

교차 출처 리소스 공유(Cross-Origin Resource Sharing, CORS)는 추가 HTTP 헤더를 사용해 한 출처에서 실행 중인 웹 어플리케이션이 다른 출처의 선택한 자원에 접근할 수 있는 권한을 부여하도록 브라우저에 알려주는 체제이다. 웹 어플리케이션은 리소스가 자신의 출처( domain, protocol, port )와 다를 때 교차 출처 HTTP 요청을 실행한다. 이에 대한 응답으로 서버는 Access-Control-Allow-Origin 헤더를 다시 보낸다.

 

출처 : https://developer.mozilla.org/ko/docs/Web/HTTP/CORS

 

* Endpoint

 

메서드는 같은 URL들에 대해서도 다른 요청을 하게끔 구별하게 해주는 항목. 각각 GET, PUT, DELETE 메서드에 따라 다른 요청을 하는 것을 알 수 있다. Endpoint란 API가 서버에서 자원(resource)에 접근할 수 있도록 하는 URL이다.

출처: https://velog.io/@kho5420/Web-API-%EA%B7%B8%EB%A6%AC%EA%B3%A0-EndPoint

 

4. ChatController

 

import lombok.extern.log4j.Log4j2;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
@Log4j2
public class ChatController {
    
    @GetMapping("/chat")
    public String chatGET(){

        log.info("@ChatController, chat GET()");
        
        return "chat";
    }
}

 

 

5. chat.html 그리고 JS

 

thymeleaf 템플릿 엔진을 적용하다보니 구조가 깨진 것 처럼 보이나, 실제 동작에선 전혀 지장이 없다.

text박스에 Hello World를 쓰고 전송을 누르면 "gorany100:Hello World!" 이런 문자열이 전송되게 된다.

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security">

<th:block th:replace="~{/layout/basic :: setContent(~{this :: content})}">
    <th:block th:fragment="content">
    
        <div class="container">
            <div class="col-6">
                <label><b>채팅방</b></label>
            </div>
            <div>
                <div id="msgArea" class="col"></div>
                <div class="col-6">
                    <div class="input-group mb-3">
                        <input type="text" id="msg" class="form-control" aria-label="Recipient's username" aria-describedby="button-addon2">
                        <div class="input-group-append">
                            <button class="btn btn-outline-secondary" type="button" id="button-send">전송</button>
                        </div>
                    </div>
                </div>
            </div>
        </div>
        
    </th:block>
</th:block>

</html>
<script th:inline="javascript">
            $(document).ready(function(){

            const username = [[${#authentication.principal.username}]];

            $("#disconn").on("click", (e) => {
                disconnect();
            })
            
            $("#button-send").on("click", (e) => {
                send();
            });

            const websocket = new WebSocket("ws://localhost:8080/ws/chat");

            websocket.onmessage = onMessage;
            websocket.onopen = onOpen;
            websocket.onclose = onClose;

            function send(){

                let msg = document.getElementById("msg");

                console.log(username + ":" + msg.value);
                websocket.send(username + ":" + msg.value);
                msg.value = '';
            }
            
            //채팅창에서 나갔을 때
            function onClose(evt) {
                var str = username + ": 님이 방을 나가셨습니다.";
                websocket.send(str);
            }
            
            //채팅창에 들어왔을 때
            function onOpen(evt) {
                var str = username + ": 님이 입장하셨습니다.";
                websocket.send(str);
            }

            function onMessage(msg) {
                var data = msg.data;
                var sessionId = null;
                //데이터를 보낸 사람
                var message = null;
                var arr = data.split(":");

                for(var i=0; i<arr.length; i++){
                    console.log('arr[' + i + ']: ' + arr[i]);
                }

                var cur_session = username;

                //현재 세션에 로그인 한 사람
                console.log("cur_session : " + cur_session);
                sessionId = arr[0];
                message = arr[1];

                console.log("sessionID : " + sessionId);
                console.log("cur_session : " + cur_session);

                //로그인 한 클라이언트와 타 클라이언트를 분류하기 위함
                if(sessionId == cur_session){
                    var str = "<div class='col-6'>";
                    str += "<div class='alert alert-secondary'>";
                    str += "<b>" + sessionId + " : " + message + "</b>";
                    str += "</div></div>";
                    $("#msgArea").append(str);
                }
                else{
                    var str = "<div class='col-6'>";
                    str += "<div class='alert alert-warning'>";
                    str += "<b>" + sessionId + " : " + message + "</b>";
                    str += "</div></div>";
                    $("#msgArea").append(str);
                }
            }
            })
</script>

<Log는 참고 용>

(1) 장에서는 오직 Spring의 WebSocket 라이브러리를 이용해 HTML 5 웹 소켓을 사용하는 채팅을 구현해보았다. 문제점은 아주 많다. 일단 채팅방은 단 하나라는 것 이다. 따라서 채팅방 고도화를 해서 여러 방을 만들어볼 것이다.

 

그리고 웹소켓을 지원하지 않는 브라우저에서는 동작하지 않을 것이다. 따라서 SockJS를 이용해 streaming, polling같이 다른 방법으로 채팅을 동작하게 해주는 방법이 필요하다.

 

그리고 STOMP...가 무엇인지 아직 모르기에 이것에 대해 공부하고, 나아가 채팅을 어떻게 데이터베이스에 저장해 채팅을 영속화 하는 작업도 필요할 것 같다.

 

최종적으로는 메시지 암호화.. 할 수 있으면 좋겠지만 일단 하는 데 까지 해보도록 한다.

 

[Spring Boot] WebSocket과 채팅 (2) - SockJS

[Spring Boot] WebSocket과 채팅 (1) 일전에 WebSocket(웹소켓)과 SockJS를 사용해 Spring 프레임워크 환경에서 간단한 하나의 채팅방을 구현해본 적이 있다. [Spring MVC] Web Socket(웹 소켓)과 Chatting(채팅)..

dev-gorany.tistory.com

 # References 

반응형
Comments