개요
몇가지 조건을 가진 작은 프로젝트를 생각하였다.
- n명이 같은 페이지에 접속해 있을 때 어느 유저가 값을 보낸다면 n명의 화면에 반영되어야한다.
- 따로 DB는 사용하지않는다.
구상
몇개의 키워드를 생각하였다.
- 양방향통신
- websocket
- sse
웹소켓과 sse 중 웹소켓을 선택했다. sse는 서버에서 클라이언트로 보낼 수 있어 클라이언트는 보내지는 못하고 받기만 한다. 이 경우 나는 유저가 값을 보내고 그 값을 받아야하기때문에 단방향인 sse보다는 웹소켓이 더 적합하다고 생각했다.
WebSocket
웹소켓은 HTML5에서 새롭게 추가된 것으로 실시간 양방향 데이터 전송을 한다. 일반 웹 서버가 http 프로토콜을 이용해 통신하였다면 웹소켓은 ws프로토콜을 사용한다.
http 프로토콜은 stateless 통신이다. 그와는 반대로 websocket 프로토콜은 stateful하기때문에 한 번 연결이된다면 종료될 떄 까지 계속 유지가 된다. 그래서 프로토콜의 요청을 [ws://~] 로 구성된다.
변경 사항의 빈도가 자주 일어나지 않고 데이터 크기가 작을 때는 ajax, long polling 기술이 더 효과적일 수 있다. 하지만 실시간이 보장되어야하고 변경사항의 빈도가 잦거나 짧은 대기 시간을 필요로 하는 경우 websocket이 더 효과적은 방법이 된다.
접속
웹소켓 접속은 크게 TCP/IP 접속과 Opening HandShake 로 이루어져있다. 웹소켓도 TCP/IP 위에서 작동하기때문에 서버와 클라이언트는 웹소켓을 사용하기 전에 서로 TCP/IP 접속이 되어 있어야한다. 그리고 웹소켓 클라이언트에서 핸드쉐이크 요청을 전송하고 이에 대한 응답으로 핸드쉐이크 응답을 받는데, 이 때 101의 응답코드는 받게된다.
구현
https://dev-gorany.tistory.com/212
위 포스팅을 기반으로 기본 웹소켓의 기능을 가진 프로젝트를 구현했다. 나는 단순히 누르면 카운트되는 버튼과 카운트된 숫자를 표시하는 것으로 간략화했다.
문제와 해결방안
구상했던 모든 유저에 의해서 누적되는 카운트횟수가 각각의 유저에게 동일하게 나타는 것이 아닌 유저마다 개별적으로 0부터 카운트가 되었다. 그래서 모든 유저에서 보여지는 값은 같지만 누적되는 총 합의 값은 보여지지않았다.
그래서 처음에는 H2와 같은 외부 DBMS를 사용하거나 값을 세션에 저장하기위해서 redis를 적용시켜볼까 생각을 했었다. 그러면 간단한 방법을 목적으로 했지만 내 생각보다 오버엔지니어링이 되는 것 같았다.
본래의 문제점이 생긴 이유를 보다가 문제점이 프론트쪽에서 개별적으로 카운트가 이루어지고 백엔드에서 카운트값에 대한 메모리를 전혀 사용하지못하고 있다는 것이였다. 그래서 백엔드에 static으로 카운트 값이 저장되는 변수를 생성하고 그 변수를 이용해서 프론트와 백엔드를 구성해보기로 했다.
// static으로 메모리에 할당
public static int count = 0;
private static final 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);
}
}
웹소켓 핸들러를 통해서 보면 웹소켓에서 받은 값은 TextMessage로 공유되어 진다. 하지만 공유할려는 값은 int인데 TextMessage는 바이트값이나 char만 받아서 원하는 타입을 사용하기에 제약이 생겨 한 번의 난관을 봉착하게 됐다. 그러다가 STOMP를 알게 되었고 찾아봤다.
STOMP
- raw websocket보다 더 많은 프로그래밍 모델을 지원
- 여러 브로커(카프카, 등등)을 사용가능
- spring framework를 사용하면 사용가능
- 메시지 포맷을 정할 필요가 없다.
- 애플리케이션 로직은 여러 @Controller 인스턴스로 구성될 수 있으며 주어진 연결에 대해 단일 WebSocketHandler를 사용하여 원시 WebSocket 메시지를 처리하는 대신 STOMP 대상 헤더를 기반으로 메시지를 라우팅할 수 있다.
위와 같이 여러가지의 장점이 있지만 텍스트와 바이너리 형태의 메세지만 지원하는 웹소켓과는 달리 자유롭게 정의를 할 수 있는 것이 내가 찾는 점이였다.
통신과정
- 클라이언트로 부터 header와 payload 담은 메시지를 전달받으면
- request channel (InboundChannel)에서 이를 알맞은 MessageHandler에 전달한다.
- 애노테이션 기반의 로직 처리가 포함되는 경우, SimpAnnotationMethodMessageHandler를 통해 @Controller를 호출한다.
- 로직 처리 후, 반환된 값을 기반으로 메시지를 만들어 broker channel에 전달한다.
- broker channel은 SimpleBrokerMessageHandler를 통해 구독자들을 가져온다.
- 그리고 각각의 구독자들에게 response channel (OutboundChannel)로 메시지를 전달한다.
리팩토링
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Message {
private int count;
}
지금은 카운트값만 필요하기때문에 dto에는 count만 넣었다.
import static rissins.liveconnection.config.WebSocketConfig.count;
@RestController
@RequiredArgsConstructor
@Slf4j
public class MessageController {
private final SimpMessageSendingOperations sendingOperations;
@MessageMapping("/chat/message")
public void send() {
count++;
sendingOperations.convertAndSend("/topic" , count);
}
}
@MessageMaaping을 통해서 static 메모리에 있는 count 변수를 할당해서 SimpMessageSendingOperations 를 통해 정한 곳으로 count값을 보내게 된다. 여기서도 지금보니 바로 count를 보내지말고 dto에 담아서 보내는게 더 좋다고 생각된다.
<body>
<p id="msgArea" class="col" th:value="${count}" th:text="${count}"></p>
<div class="col-6">
<div class="input-group mb-3">
<div class="input-group-append">
<button class="btn btn-outline-secondary" type="button" id="button-send" th:onclick="send()">전송</button>
</div>
</div>
</div>
</body>
var sock = new SockJS("/ws/chat");
var ws = Stomp.over(sock);
function send() {
ws.send("/app/chat/message");
ws.subscribe("/topic", function (message) {
console.log("message" + message.body);
document.getElementById('msgArea').innerText = message.body;
});
}
$(document).ready(function () {
ws.connect();
console.log("연결됨")
})
프론트쪽은 간단히 html과 javascript로 구성하였다. 페이지에 로딩되면 SockJs로 웹소켓에 연결하고 전송버튼을 누르면 백엔드로 send하고 최신화된 값을 바로 html에 반영한다.
그 결과 여러 개의 탭을 띄워놓고 각각의 클라이언트에서 눌려도 모두에게 같은 화면을 표시해주게 되었다.