Develop/Spring

[Spring] WebSocket

hz25 2022. 8. 23.

이 글은 Spring 문서의 WebSocket 부분을 참고해 작성한 글입니다.

목차

WebSocket 이란?

하나의 TCP 접속에 전이중 통신(양방향 독립회선) 채널을 제공하는 컴퓨터 통신 프로토콜 입니다.

HTTP와 함께 OSI 모델의 7계층에 위치해 있으며 제 4계층의 TCP에 의존합니다.

웹소켓은 HTTP 포트인 80과 HTTPS 포트인 443 위에 동작하도록 설계되었으며, HTTP 프록시 및 중간 층을 지원하도록 설계되었기 때문에 HTTP와 호환이 됩니다.

호환을 달성하기 위해 웹소켓 핸드셰이크는 HTTP 업그레이드 헤더를 사용해, HTTP 프로토콜에서 웹소켓 프로토콜로 변경합니다.

 

HTTP (HyperText Transfer Protocol)

웹 상에서 정보를 주고받을 수 있는 프로토콜입니다.

HTTP는 클라이언트와 서버 사이에 이루어지는 요청/응답 프로토콜 입니다. 예를 들면, 클라이언트인 웹 브라우저가 HTTP를 통해 서버로부터 웹페이지(HTML)이나 그림 정보를 요청하면 서버는 이 요청에 응답해 필요한 정보를 해당 사용자에게 전달합니다.

HTTP를 통해 전달되는 자료는 http: 로 시작하는 URL(인터넷 주소)로 조회할 수 있습니다.

 

HTTP vs WebSocket

Restful한 HTTP에서 서버와 클라이언트는 많은 request들을 사용해 요청하고, 응답합니다.

하지만 WebSocket에는 일반적으로 초기 연결을 위한 HTTP 요청 하나만 존재하고, 모든 메시지는 그 소켓으로 교환됩니다.

 

또한 WebSocket은 HTTP와 달리 메시지 내용에 의미를 지정하지 않는 저수준 전송 프로토콜이기 때문에 클라이언트와 서버가 메시지 의미 체계를 정해두지 않는 한 메시지를 라우팅하거나 처리할 방법이 없습니다.

WebSocket 클라이언트와 서버는 HTTP 핸드셰이크 요청의 Sec-WebSocket-Protocol header를 통해 STOMP와 같은 더 높은 수준의 메시징 프로토콜을 사용할 수 있습니다.

 

WebSocket 사용 이유

HTTP는 클라이언트의 요청이 있어야만 서버가 응답할 수 있습니다. 이런 단점을 개선하기 위해 이전에는 Polling, Long Polling, Streaming 방법을 사용했습니다.

Polling은 클라이언트가 주기적으로 서버에 요청을 보내서 받을 정보가 있는지 확인하는 방법이기 때문에 서버에 부담을 줍니다.

Long Polling은 Polling을 개선한 것으로, 클라이언트가 일단 요청을 보내두면 서버는 응답하지 않고 있다가(이 때 클라이언트는 pending 상태) 이벤트가 발생했을 때 응답하고, 연결을 끊습니다. 클라이언트는 응답을 받으면(연결이 끊어지면) 다시 연결하는 것을 반복합니다.

Streaming은 클라이언트가 요청을 보내면 커넥션을 맺고, 이 커넥션을 계속 유지합니다. 하지만 클라이언트가 서버에 메시지를 보내고 싶다면 새로운 커넥션을 맺어야 합니다.

 

위의 세 가지 방법은 짧은 대기 시간과 잦은 통신(실시간 양방향 데이터 통신 등), 높은 볼륨(많은 수의 동시 접속자 등) 등의 경우에는 연결을 끊고 다시 연결하는 작업이 많아 부하가 많아질 수 밖에 없습니다. 이럴 때 WebSocket을 사용하면 효과적입니다.

 

통신 과정

웹소켓의 통신 과정은 다음과 같습니다.

웹소켓 통신 과정

 

웹소켓 핸드셰이크 (WebSocket Handshake)

핸드셰이킹 (Handshaking) : 정상적인 통신이 시작되기 전에 양쪽 간의 협상 과정입니다.

 

WebSocket Handshake의 request, response

WebSocket을 연결하기 위해 클라이언트는 HTTP request를 보내고, 서버가 이에 대한 response를 반환합니다.

 

HTTP의 Upgrade header를 사용하면 이미 만들어진 connection을 다른 프로토콜로 업그레이드 하거나 전환할 수 있는데, 여기서 request는 WebSocket 프로토콜로 전환하기 위해 Upgrade header를 websocket으로 설정해 보냅니다.

Upgrade header를 사용하면 Connection: upgrade 도 함께 사용해야 하기 때문에 request headers에서 함께 확인할 수 있습니다.

 

WebSocket을 지원하는 서버는 일반적인 성공 상태 코드인 200 대신 101 Switching Protocols 라는 상태 코드를 반환해 프로토콜을 전환하고 성공적으로 handshake를 완료합니다.

 

handshake 후에 TCP 소켓은 클라이언트와 서버 모두가 계속해서 메시지를 보내고 받을 수 있도록 열려 있습니다.

 

구현 코드

Backend (Spring Boot)

설정

build.gradle 파일의 dependencies 항목 안에 아래처럼 WebSocket에 필요한 라이브러리가 추가되어있어야 합니다.

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

 

WebsocketConfig.java 파일로 WebSocket 설정을 추가합니다.

  • @EnableWebSocket 어노테이션을 통해 WebSocket을 활성화합니다.
  • WebSocket에 접속하기 위한 Endpoint는 /chat 으로 설정합니다.
  • 도메인이 달라도 접속 가능하도록 하기 위해 setAllowedOrigins("*")를 추가해줍니다.
package com.project.backend.config;

import com.project.backend.api.handler.ChatHandler;
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
@RequiredArgsConstructor
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
    private final ChatHandler chatHandler;

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

 

Handler

Text 기반의 채팅을 구현하기 위해 TextWebSocketHandler를 상속받아서 사용합니다.

package com.project.backend.api.handler;

import lombok.extern.slf4j.Slf4j;
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
@Slf4j
public class ChatHandler extends TextWebSocketHandler {
    private static List<WebSocketSession> webSocketSessionList = new ArrayList<>();

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

        log.debug("payload : " + payload);
        log.debug(session);

        sendMessageToAll(message);
    }

    /* Client가 접속 시 호출되는 메서드 */
    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        log.info("클라이언트 접속 " + session);
        webSocketSessionList.add(session);
    }

    /* Client가 접속 해제 시 호출되는 메서드 */
    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        log.info("클라이언트 접속 해제 " + session);
        webSocketSessionList.remove(session);
    }

    /* 모든 접속자에게 메시지를 보내는 메서드 */
    public void sendMessageToAll(TextMessage message) throws Exception {
        for (WebSocketSession session : webSocketSessionList) {
            session.sendMessage(message);
        }
    }
}

 

 

Frontend

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta http-equiv="X-UA-Compatible" content="IE=edge" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>Document</title>
    </head>

    <script>
        var websocket, sendMsg;

        document.addEventListener("DOMContentLoaded", function () {
            websocket = new WebSocket("ws://localhost:80/ws/chat");

            sendMsg = {
                writer: "member1",
                message: "",
            };

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

        // 메세지를 보낸다
        function send() {
            alert("send 실행");

            let inputMsg = document.getElementById("inputMsg").value;
            sendMsg.message = inputMsg;
            inputMsg.value = "";

            console.log(JSON.stringify(sendMsg));
            websocket.send(JSON.stringify(sendMsg));
        }

        //채팅창에서 나갔을 때
        function onClose(event) {
            websocket.send(": 님이 방을 나가셨습니다.");
            websocket.close();
        }

        // 채팅창에 들어왔을 때
        function onOpen(event) {
            // 웹소켓 연결되면 전송 버튼 기능 활성화
            let sendBtn = document.getElementById("button-send");
            sendBtn.addEventListener("click", send());

            websocket.send(": 님이 입장하셨습니다.");
        }

        // 메세지를 받았을 때
        function onMessage(receiveMsg) {
            let msgArea = document.getElementById("msgArea");

            msgArea.append(receiveMsg.message);
        }
    </script>

    <body>
        <div class="container">
            <label><b>채팅방</b></label>
            <div id="msgArea" class="col"></div>
            <div class="col-6">
                <input type="text" id="inputMsg" />
                <button type="button" id="button-send">전송</button>
            </div>
        </div>
    </body>
</html>

 

참고자료

https://docs.spring.io/spring-framework/docs/current/reference/html/web.html#websocket

 

댓글