채팅 서버 만들기 - WebSocket

김규연·2022년 12월 28일
4

🧐WebSocket 이란?

HTTP 환경에서 클라이언트와 서버 사이에 하나의 TCP연결을 통해 실시간으로 전이중 통신을 가능하게 하는 프로토콜이다. 여기서 전이중 통신이란, 일방적인 송신 또는 수신만이 가능한 단방향 통신과는 달리 가정에서의 전화와 같이 양방향으로 송신과 수신이 가능한 것을 말한다. 클라이언트가 접속 요청을 하고 웹서버가 응답한 후 연결을 끊는 것이 아니라 연결을 그대로 유지하고 클라이언트의 요청 없이도 데이터를 전송할 수 있는 프로토콜이다.

WebSocket의 특징

  1. 양방향 통신 가능 (Full-Duplex)
    http 통신은 Client가 요청을 보내는 경우에만 Server가 응답하는 단반향 통신이다. 하지만 웹소켓을 사용하면 데이터 송수신을 동시에 처리할 수 있다.
  2. 실시간 네트워킹 가능 (Real Time Networking)
    연속된 데이터를 빠르게 노출할 수 있다.

WebSocket이 나오기 전 통신 방식

  1. Polling
    Client가 평범한 HTTP Request를 서버로 계속텍스트 요청해 이벤트 내용을 전달받는 방식. setTimeout, setInterval 등으로 일정 주기마다 서버에 request를 보내면 된다.

<단점>
(1) 불필요한 request와 Connection을 생성하여 서버에 부담을 주게 된다.
(2) 요청 주기가 짧을 수록 부하가 커진다.
(3) 일정 주기마다 요청을 보내는 것이기 때문에 실시간이라고 보기에 애매하다.
(4) HTTP 통신을 하기 때문에 Request, Response 헤더가 불필요하게 크다.

<Polling을 사용하는 경우>
(1) 응답을 실시간으로 받지 않아도 되는 경우
(2) 다수의 사용자가 동시에 사용하는 경우

  1. Long Polling
    polling과 비슷하게 일정 주기마다 요청을 보내지만 서버가 응답을 바로 전달하지 않는 방식, 즉 요청을 보냈을 때 서버가 응답을 바로 보내지 않고 특정 이벤트나 타입아웃이 발생했을 때 응답을 전달하는 방식

<단점>
(1) 불필요한 요청을 보내지 않아 Polling 보다 좋아보이지만, Long Polling도 동시 다발적인 요청과 응답이 생기면 부하가 발생할 수 있다.
(2) HTTP 통신을 하기 때문에 Request, Response 헤더가 불필요하게 크다.

<Long Polling을 사용하는 경우>
(1) 응답을 실시간으로 받아야하는 경우
(2) 적은 수의 사용자가 동시에 사용하는 경우

  1. Streaming
    이벤트가 발생했을 때 응답을 내려주되, 응답을 완료시키지 않고 계속 연결을 유지하는 방식

<단점>
(1) Long Polling에 비해 응답마다 다시 요청을 하지 않아도 되므로 효율적이지만, 연결 시간이 길어질수록 연결 유효성 관리의 부담이 발생한다.
(2) HTTP 통신을 하기 때문에 Request, Response 헤더가 불필요하게 크다.

WebSocket의 동작 방식

WebSocket 동작 과정은 크케 세가지로 나눌 수 있다.
Opening Handshake, Data transfer, Closing Handshake

  1. Handshake
    Opening Handshake와 Closing Handshake는 일반적인 HTTP TCP 통신의 과정 중 하나이다. 접속 요청은 HTTP로 한 뒤, 웹소켓 프로토콜로 변경된다.(WS) 웹소캣 프로토콜로 변경되기 위한 HTTP 헤더는 아래와 같이 구성되어 있다.(ws://localhost:8080/chat로 접속한다고 가정)

Request Header

GET /chat HTTP/1.1 // 웹소켓 통신 요청에서 HTTP 버전은 1.1이상이어야 하고 GET 메서드를 사용해야한다.
Host: localhost:8080 // 웹소켓 서버 주소
Upgrade: websocket // 프로토콜을 전환하기 위해 사용하는 헤더. websocket으로 전환
Connection: Upgrade // 현재의 전송이 완료된 후 접속을 유지할 것인가에 대한 정보
Sec-WebSocket-Key: PxYcOtlXHXDojXci2qkTIQ== // 유효한 요청인지 확인하기 위해 사용하는 키 값. 길이가 16Byte인 임의로 선택된 숫자를 base64 인코딩한 값이다.
Sec-WebSocket-Protocol: chat, superchat // 사용하고자 하는 하나 이상의 웹소켓, 즉 하위 프로토콜 지정
Sec-WebSocket-Version: 13 // 클라이언트가 사용하고자 하는 웹소켓 프로토콜 버전. 현재 최신 버전 13
Origin: http://localhost:8080// 클라이언트의 주소

Response Header

HTTP/1.1 101 Switching Protocols // 101은 HTTP에서 WS로 프로토콜 전환이 승인 되었다는 응답코드이다.
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: GPxAkIJN0Ss6TNM2pBI8qXYiWZ0= // 웹소켓 연결이 개시되었음을 알린다. 서로간의 신원 인증키가 클라이언트에서 계산한 값과 일치하지 않으면 연결 수립이 안된다.
Sec-WebSocket-Protocol: chat
  1. Data Transfer
    Opening HandShake에서 승인이 나고나면, 웹소켓 프로토콜로 Data Transfer이 진행된다. 여기서 데이터는 메시지라는 단위로 전달된다.

<Message>
여러 frame이 모여서 구성하는 하나의 논리적 메세지 단위

<frame>
communication에서 가장 작은 단위의 데이터. 작은 header와 payload로 구성되어 있다.

WebSocket과 HTTP의 차이

HTTP는 클라이언트인 웹 브라우저와 웹 서버 간 소통하기 위한 프로토콜이다. 예를 들어 클라이언트가 HTTP를 통해 특정 페이지, 정보를 요청하게 되면 서버는 요청에 응답하여 필요한 정보를 클라이언트에게 해당 정보를 전달하게 된다. 위의 그림에서도 알 수 있듯이 Response가 있기 전에 무조건 Request가 있어야 한다.

WebSocket은 하나의 TCP접속에 전이중 통신 채널을 제공하는 컴퓨터 통신 프로토콜이다. 이를 사용함으로써 서버와 사용자 간의 실시간 데이터 전송을 용이하게 한다. 예를 들어 코인 거래와 같은 트레이딩 시스템, SNS 애플리케이션 등이 대표적인 예이다.

즉, WebSocket과 HTTP의 차이는 양방향 통신인지, 단방향 통신인지에 대한 차이가 되겠다.
HTTP는 클라이언트가 요청을 보내고 서버가 응답하는 단방향 통신으로 연결상태가 유지되지 않는다(stateless). 하지만 WebSocket은 서버 역시 클라이언트한테 요청을 보낼 수 있는 양방향 통신으로 연결상태가 유지된다(stateful).

참고한 사이트
참고한 사이트

💻WebSocket 을 이용하여 채팅서버 만들어보기

1. 라이버러리 추가

필자는 Maven을 사용하여 구현하였기 때문에 pom.xml에 websocket을 dependecy를 추가해주었다.

<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-websocket</artifactId>
    <version>${org.springframework-version}</version>
</dependency>

2. WebSocket Handler 작성

서버는 다수의 클라이언트가 보낸 메세지를 처리할 Handler가 필요하다. 따라서 텍스트 기반의 채팅을 구현해볼 것 이므로 "TextWebSocketHandler"를 상속받아서 작성했다.

package site.workforus.forus.chat.websocket;

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 List<WebSocketSession> sessionList = new ArrayList<WebSocketSession>();

    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        log.info("#ChattingHandler, afterConnectionEstablished");
        sessionList.add(session);

        log.info(session.getPrincipal().getName() + "님이 입장하셨습니다.");
    }

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        log.info("#ChattingHandler, handleMessage");
        
        log.info(session.getId() + ": " + message);

        String payload = message.getPayload();
        log.info("payload : " + payload);

        log.info("session : " + session);
        log.info("message : " + message);

        for(WebSocketSession s : sessionList) {
            s.sendMessage(new TextMessage(session.getPrincipal().getName() + ":" + message.getPayload()));
        }
    }

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
    	log.info("#ChattingHandler, afterConnectionClosed");
    
        log.info("status : " + status);
        log.info("session : " + session);

        sessionList.remove(session);

        log.info(session.getPrincipal().getName() + "님이 퇴장하셨습니다.");
    }
}

log를 이용하여 각 매게변수를 출력해보면 다음과 같다.

INFO : site.workforus.forus.chat.websocket.ChatHandler - #ChattingHandler, afterConnectionEstablished
INFO : site.workforus.forus.chat.websocket.ChatHandler - A2022100님이 입장하셨습니다.

INFO : site.workforus.forus.chat.websocket.ChatHandler - #ChattingHandler, handleMessage
INFO : site.workforus.forus.chat.websocket.ChatHandler - a0ca11ee-a9ec-7e86-6383-dad1ad9a1072: TextMessage payload=[ffdd], byteCount=4, last=true]
INFO : site.workforus.forus.chat.websocket.ChatHandler - payload : ffdd
INFO : site.workforus.forus.chat.websocket.ChatHandler - session : StandardWebSocketSession[id=6edd6f6b-4b96-cc83-ca47-42c9373fa37b, uri=ws://localhost:8080/ws/chat]
INFO : site.workforus.forus.chat.websocket.ChatHandler - message : TextMessage payload=[dddd], byteCount=4, last=true]

INFO : site.workforus.forus.chat.websocket.ChatHandler - #ChattingHandler, afterConnectionClosed
INFO : site.workforus.forus.chat.websocket.ChatHandler - status : CloseStatus[code=1001, reason=null]
INFO : site.workforus.forus.chat.websocket.ChatHandler - session : StandardWebSocketSession[id=6edd6f6b-4b96-cc83-ca47-42c9373fa37b, uri=ws://localhost:8080/ws/chat]
INFO : site.workforus.forus.chat.websocket.ChatHandler - A2022100님이 퇴장하셨습니다.

3. WebSocket Config 작성

@EnableWebSocket 어노테이션을 사용해 WebSocket을 활성화 하였다. WebSocket에 접속하기 위한 Endpoint는 /chat로 설정하였고, setAllowedOrigins("*
")을 추가해주어 도메인이 다른 서버에서돌 접속 가능하도록 해준다.

package site.workforus.forus.chat.config;

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;
import site.workforus.forus.chat.websocket.ChatHandler;

@Configuration
@RequiredArgsConstructor
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
    private final ChatHandler chatHandler;

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

❓Endpoint

Endpoint란 API가 서버에서 자원에 접근할 수 있도록 하는 URL이다.

4. ChatController 작성

필자는 Spring Security를 사용했기 때문에 아래와 같은 방식으로 user 정보를 가져왔다.

package site.workforus.forus.chat.controller;

import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import site.workforus.forus.employee.model.LoginVO;

@Controller
@Slf4j
@RequestMapping(value="/chat")
public class ChatController {

    @GetMapping("")
    public String chat(Model model) {
        LoginVO user = (LoginVO) SecurityContextHolder.getContext().getAuthentication().getPrincipal();

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

        model.addAttribute("userid", user.getUsername());

        return "chat/chat";
    }
}

5. chat.jsp 작성

필자는 채팅하는 부분 HTML만 가져왔다.

<div class="chat-center-layout">
	<section class="section">
		<div class="card">
			<div class="card-header">
				<div class="media d-flex align-items-center">
					<div class="avatar me-3">
						<img src="static/images/faces/1.jpg" alt="" srcset="">
						<span class="avatar-status bg-success"></span>
					</div>
                    <div class="name flex-grow-1">
						<h6 class="mb-0">Fred</h6>
						<span class="text-xs">Online</span>
                    </div>
                    <button class="btn btn-sm">
						<i data-feather="x"></i>
                    </button>
               </div>
			</div>
            <div class="card-body pt-4 bg-grey" id="id_chat">
				<div id="chat-content">
                </div>
           </div>
           <div class="card-footer">
           		<div class="message-form d-flex flex-direction-column align-items-center">
                	<a href="http://" class="black"><i data-feather="smile"></i></a>
                    <div class="d-flex flex-grow-1 ml-4">
                        <input type="text" class="form-control" id="msg" name="context" placeholder="Type your message..">
                        <button type="submit" class="submit-btn" id="button-send">전송</button>
                    </div>
                </div>
            </div>
		</div>
	</section>
</div>

7. chat.js 작성

<script type="text/javascript">
	$("#button-send").on("click", function(e) {
		sendMessage();
		$('#msg').val('');
	});

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

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

	function sendMessage() {
		ws.send($("#msg").val());
	}

	function onMessage(msg) {
		var data = msg.data;
		console.log(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 = '${userid}';
		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='chat'><div class='chat-body'><div class='chat-message' id='id_chat'>";
			str += sessionId + " : " + message;
			str += "</div></div></div>";
			$("#chat-content").append(str);
		} else {
			var str = "<div class='chat chat-left'><div class='chat'><div class='chat-body'><div class='chat-message' id='id_chat'>";
			str += sessionId + " : " + message;
			str += "</div></div></div></div>";
			$("#chat-content").append(str);
		}
	}

	function onClose(evt) {
		var str = '${userid}' + " 님이 방을 나가셨습니다.";
		$("#chat-content").append(str);
	}

	function onOpen(evt) {
		var str = '${userid}' + " 님이 입장하셨습니다.";
		$("#chat-content").append(str);
	}
</script>

input 태그에 "ㅇ"을 입력하고 전송버튼을 누르면 다음과 같이 console에 출력된다.

A2022100:ㅇ
chat:715 arr[0]: A2022100
chat:715 arr[1]: ㅇ
chat:719 cur_session : A2022100
chat:724 sessionID : A2022100
chat:725 cur_session : A2022100

동작 화면

🙋‍♀️느낀점

WebSocket Handler만을 사용해서 채팅을 구현해보았다. 이의 경우 채팅방은 하나뿐이다. 따라서 채팅방 고도화를 하기 위해서 많이 사용하는 STOMP를 이용해서 여러방의 채팅방이 만들어질 수 있도록 해볼 것이다. 또한 채팅을 데이터베이스에 저장하여 채팅을 영속화 하는 작업도 할 것이다.

참고한 사이트
참고한 사이트
참고한 사이트

profile
오늘도 뚠뚠 개미 개발자

0개의 댓글