제목: "[Spring Boot] WebSocket과 채팅 (1)"
작성자: tistory(조용한고라니)
작성자 수정일: 2021년3월30일
링크: https://dev-gorany.tistory.com/212
작성일: 2022년2월1일
제목: "Spring WebSocket 소개"
작성자: tistory(supawer0728)
작성자 수정일: 2018년3월30일
링크: https://supawer0728.github.io/2018/03/30/spring-websocket
작성일: 2022년2월1일
소켓통신이란? https://helloworld-88.tistory.com/215
HTTP 통신과 소켓 통신의 차이점
자주 데이터를 주고 받아야 하는 환경에서는 소켓 통신, 아니라면 HTTP 통신을 주로 사용한다.
HTTP 통신은 사용자가 서버에 요청을 보내야 응답을 받을 수 있는 단방향 통신인 반면, 소켓 통신은 양방향 통신이다.
소켓 통신은 계속해서 Connection을 들고 있기 때문에 HTTP 통신에 비해 많은 리소스가 소모된다.
기존 단방향의 HTTP 프로토콜과 호환되어 양방향 통신을 제공하기 위해 개발된 프로토콜
일반 Socket 통신 달리 HTTP 포트(80) 을 사용하므로 방화벽에 제약이 없으며, 통상 WebSocket
으로 불린다.
접속까지는 HTTP 프로토콜을 이용하고, 그 이후 통신은 자체적인 WebSocket
프로토콜로 통신하게 된다.
웹 소켓은 HTTP(hyper text transfer protocol)를 사용하는 네트워크 데이터 통신의 단점을 보완하는데 그 목적이 있다.
모든 HTTP를 사용한 통신은 클라이언트가 먼저 요청을 보내고, 그 요청에 따라 웹 서버가 응답하는 형태이며 웹 서버는 응답을 보낸 후 브라우저와의 연결을 끊는다.
양쪽이 데이터를 동시에 보내는 것이 아니기 때문에 이러한 통신을 반이중 통신(Half Duplex
)라고 한다.
사실 HTTP만으로도 원하는 정보를 송수신할 수 있었지만, 인터넷이 발전함에 따라 더욱 더 원하는 것들이 다양해졌다.
웹소켓이 존재하기 전에는 Polling이나 Long Polling,Streaming 등의 방식으로 해결했었다.
클라이언트가 평범한 HTTP Request를 서버로 계속 요청해 이벤트 내용을 전달 받는 방식
가장 쉬운 방법이지만 클라이언트가 지속적으로 Request를 요청하기 때문에 클라이언트의 수가 많아지면 서버의 부담이 급증한다.
HTTP Request Connection을 맺고 끊는 것 자체가 부담이 많은 방식이고, 클라이언트에서 실시간 정도의 빠른 응답을 기대하기 어렵다.
클라이언트에서 서버로 일단 HTTP Request를 요청한다. 이 상태로 계속 기다리다가 서버에서 해당 클라이언트로 전달할 이벤트가 있다면 그 순간 Response 메시지를 전달하여 연결이 종료된다.
곧이어 클라이언트가 다시 HTTP Request를 요청해 서버의 다음 이벤트를 기다리는 방식이다
Polling보다 서버의 부담이 줄겠으나, 클라이언트로 보내는 이벤드들의 시간간격이 좁다면 polling과 별차이 없게 되며, 다수의 클라이언트에게 동시에 이벤트가 발생될 경우 서버의 부담이 급증한다.
Long Polling과 마찬가지로 클라이언트-> 서버로 HTTP Request를 요청한다
서버->클라이언트로 이벤트를 전달할 때 해당 요청을 해제하지 않고 필요한 메세지만 보내기(Flush
)를 반복하는 방식
Long Polling과 비교하여 서버에 메세지를 보내지 않고도 다시 HTTP Request 연결을 하지 않아도 되어 부담이 경감된다고 한다.
이처럼 HTTP 통신의 특징인 ( 연결 -> 연결 해제) 때문에 효율이 많이 떨어지게 되고, 웹 브라우저 말고 외부 플러그인이 항상 필요하게 되었다.
웹소켓은 클라이언트가 접속 요청을 하고 웹 서버가 응답한 후 연결을 끊는 것이 아닌 Connection을 그대로 유지하고 클라이언트의 요청없이도 데이터를 전송할 수 있는 프로토콜이다.
웹소켓은 HTTP 환경에서 전이중 통신(Full Duplex, 2-way communication)을 지원하기 위한 프로토콜이며 RFC6455에 정의되어 있다.
HTTP 프로토콜에서 HandShaking을 완료한 후 ,HTTP로 동작하지만, HTTP와는 다른 방식으로 통신한다.
기존의 TCP Socket과 다른 점은 최초 접속이 일반 HTTP Request를 통해 HandShaking 과정을 통해 이뤄진다는 점이다.
HTTP Request를 그대로 사용하기 때문에 기존의 80,443 포트로 접속을 하므로 추가 방화벽을 열지 않고도 양방향 통신이 가능하고, HTTP 규격인 CORS 적용이나 인증 등 과정을 기존과 동일하게 가져갈 수 있는 것이 장점이다.
웹소켓은 서비스를 동적으로 만들어주지만, Ajax,Streaming,Long polling 기술이 더 효과적일 수 도있다.
- 예를 들어, 변경 사항의 빈도가 자주 일어나지 않고, 데이터의 크기가 작은 경우 Ajax, Streaming, Long polling 기술이 더 효과적일 수 있다.
실시간성을 보장해야 하고, 변경 사항의 빈도가 잦다면, 또는 짧은 대기 시간,고주파수,대용량의 조합인 경우 WebSocket이 좋은 해결책이 될 수 있다.
뉴스나 메일,SNS 피드는 동적으로 업데이트 하는 것은 맞지만 몇 분마다 업데이트 하는 것이 좋다.
반면 협업,게임,금융 앱은 훨씬 더 실시간에 근접해야 한다.
Browser별 지원 현황
출처: https://github.com/sockjs/sockjs-client#supported-transports-by-browser-html-served-from-http-or-https
출처: https://blog.naver.com/eztcpcom/220070508655
웹소켓을 이용하여 서버와 클라이언트가 통신을 하려면 먼저 웹소켓 접속 과정을 거쳐야 한다.
웹소켓 접속 과정은 TCP/IP 접속, 웹소켓 열기 HandShake 과정으로 나눌 수 있다.
웹소켓도 TCP/IP 위에서 동작하므로, 서버와 클라이언트는 웹소켓을 사용하기 전에 서로 TCP/IP 접속이 되어있어야 한다.
TCP/IP 접속이 완료된 후 서버와 클라이언트는 웹 소켓 열기 HandShake 과정을 시작한다.
웹소켓 열기 핸드쉐이크는 클라이언트가 먼저 핸드쉐이크 요청을 보내고 이에 대한 응답을 서버가 클라이언트로 보내는 구조이다.
서버와 클라이언트는 HTTP 1.1 프로토콜을 사용하여 요청과 응답을 보낸다.
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 | Desc |
---|---|---|
GET | Required | 요청 명령어는 GET을 사용해야 하며, HTTP 버전은 1.1 이상이어야 한다. |
Host | Required | 웹소켓 서버의 주소 |
Upgrade | Required | WebSokcet이라는 단어를 사용해야 한다. 대소문자는 구분X |
Connection | Required | Upgrade라는 단어를 사용해야 한다. 대소문자는 구분X |
Sec-WebSocket-Key | Required | 길이가 16바이트인 임의의 선택된 숫자를 base64 인코딩한 값이다. |
Origin | Required | 클라이언트로 웹 브라우저를 사용하는 경우 필수항목으로, 클라이언트의 주소 |
Sec-WebSocket-Version | Required | 13을 사용한다. |
Sec-WebSocket-Protocol | Option | 클라이언트가 사용하고 싶은 하위 프로토콜 이름을 명시한다. |
Sec-WebSocket-Extensions | Option | 클라이언트가 사용하고 싶은 추가 옵션을 기술한다. |
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Header Name | div | Desc |
---|---|---|
HTTP | Required | HTTP 버전은 1.1이며, 클라이언트로부터의 요청이 이상 없는 경우 101을 상태 코드로 사용한다. |
Upgrade | Required | WebSocket이라는 단어를 사용해야 한다. 대소문자는 구분 X |
Connection | Required | Upgrade라는 단어를 사용해야 한다. 대소문자는 구분 X |
Sec-WebSocket-Accept | Required | 클라이언트로부터 받은 Sec-WebSocket-Key를 사용하여 계산된 값이다. |
Sec-WebSocket-Protocol | Option | 서버에서 서비스하는 하위 프로토콜을 명시한다. 클라이언트가 요청하지 않는 하위 프로토콜을 명시하면 HandShake는 실패한다. |
Sec-WebSocket-Extensions | Option | 서버가 사용하는 추가 옵션을 기술한다. 클라이언트가 요청하지 않는 추가 옵션을 명시하면 HandShake는 실패한다. |
주의
위 테이블에 명시된 헤더 중 필수는 반드시 사용해야 하며, 특정한 값이 명시된 헤더는 그 값만 사용해야 한다.
implementation 'org.springframework.boot:spring-boot-starter-websocket'
TextWebSocketHandler
을 상속받는다@Component
@Slf4j
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 webSocketSession : list) {
webSocketSession.sendMessage(message);
}
}
/**
* 클라이언트가 접속 시 호출되는 메서드
* @param session
* @throws Exception
*/
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
list.add(session);
log.info("[{}] 클라이언트 접속", session);
}
/**
* 클라이언트가 접속 해제 시 호출되는 메서드
* @param session
* @param status
* @throws Exception
*/
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
log.info("[{}] 클라이언트 접속 해제", session);
list.remove(session);
}
}
}
payload
페이로드란 전송되는 데이터를 의미한다. 데이터를 전송할때 헤더와 META 데이터, 에러 체크 비트 등과 같은 다양한 요소들을 함께 보내 데이터 전송 효율과 안정성을 높히게 된다. 이 때, 보내고자 하는 데이터 자체를 의미하는 것이 페이로드이다. 예를 들어 택배 배송을 보내고 받을 때 택배 물건이 페이로드이고 송장이나 박스 등은 페이로드가 아니다.
다음 JSON에서 페이로드는 "data"이다. 나머지는 통신을 하는데 있어 용이하게 해주는 부가적 정보들이다.
{
"status":
"from":"localhost",
"to":"http://melonicedlatte.com/chatroom/1",
"method":"GET",
"data":{"message":"There is a cutty dog!"}
}
핸들러를 이용해 WebSocket을 활성화하기 위한 Config를 작성하자
@EnableWebSocket
어노테이션을 사용해 WebSocket
을 활성화 해야 한다.
WebSocket
에 접속하기 위한 EndPoint
는 /chat
으로 설정하고, 도메인이 다른 서버에서도 접속 가능하도록 CORS : setAllowedOrigins("*");
를 추가해준다.
이제 클라이언트가 ws://localhost:8080/chat
으로 커넥션을 연결하고 메세지 통신을 할 수 있는 준비를 마쳤다.
@EnableWebSocket
@RequiredArgsConstructor
@Configuration
public class WebSocketConfig implements WebSocketConfigurer {
private final ChatHandler chatHandler;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(chatHandler, "ws/chat")
.setAllowedOriginPatterns("*");
}
}
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
@Controller
@Slf4j
public class ChatController {
@GetMapping("/chat")
public String chatGet(Model model) {
log.info("me.dragonappear.websocket.chat.ChatController.chatGet");
model.addAttribute("name", UUID.randomUUID().toString());
return "chat";
}
}
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<title>hello</title>
</head>
<body>
<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>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.12.4/jquery.min.js"></script>
<script th:inline="javascript">
$(document).ready(function(){
const username = [[${name}]];
$("#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>
</body>
</html>
채팅방이 단 하나이다
웹소켓을 지원하지 않는 브라우저에서는 동작하지 않는다.
DB에 저장해야 한다.
메시지 암호화