지난 시간에는 Jenkins + AWS + Github Webhook을 통한 자동화 배포 인프라를 구축했다.
오늘은 Spring 공식 홈페이지에 올라와있는 웹소켓 튜토리얼을 해볼 것이다.(React & Flutter는 다음 시간에)
웹소켓은 HTML5 표준 기술로, 사용자의 브라우저와 서버 사이의 동적인 양방향 연결 채널을 구성한다. Websocket API를 통해 서버로 메세지를 보내고, 요청 없이 응답을 받아오는 것이 가능하다. 웹소켓은 별도의 포트를 사용하지 않고 HTTP와 같은 80번 포트를 사용하고 있는데, 이 때문에 클라이언트인 웹 브라우저뿐만 아니라 웹 서버도 기능을 지원하고 있어야만 한다.
웹소켓은 초기 연결에 HTTP를 사용한다. 따라서 3-hand-shake 방식으로 연결한다. 이때 차이점은 HTTP는 데이터 교환이 끝나면 연결이 끊긴다. 반면 웹소켓은 연결이 끊기지 않고 지속적으로 유지된다.
웹소켓은 HTTP와 다른 프로토콜을 사용하기 때문에 Header부분이 더 가볍다. 따라서 패킷 사이즈가 더 작고 빠르기 때문에 실시간 통신과 빠른 데이터 주고 받기에 유용하다.(EX 게임, 채팅, Realtime)
웹소켓을 이해하기 위해선 Pub - Sub 패턴을 알아야 한다. 위키피디아에서는 다음과 같이 정의한다.
Pub Sub 패턴은 비동기 메시징 패러다임이다. 발행-구독 모델에서 발신자의 메시지는 특별한 수신자가 정해져 있지 않다. 대신 발행된 메시지는 정해진 범주에 따라, 각 범주에 대한 구독을 신청한 수신자에게 전달된다. 수신자는 발행자에 대한 지식이 없어도 원하는 메시지만을 수신할 수 있다. 이러한 발행자와 구독자의 디커플링은 더 다이나믹한 네트워크 토폴로지와 높은 확장성을 허용한다.
즉 Pub Sub 패턴은 데이터의 변화(객체의 변화)가 발생하면 이와 관련있는 객체, 서비스에 변화된 데이터에 대해 알려주는 것이다!
Observer 패턴과 유사한데 Pub Sub 패턴은 Broker라는 이름의 객체를 중간에 둬서 객체 간의 종속성, 의존성을 낮춰준다(유지보수가 쉬워진다.)
이제 본격적으로 튜토리얼을 진행해본다.
튜토리얼에서는 Front도 같이 구현해야하기 때문에 Stater-websocket 말고도 추가적인 라이브러리들이 필요하다.
implementation 'org.webjars:webjars-locator-core'
implementation 'org.webjars:sockjs-client:1.0.2'
implementation 'org.webjars:stomp-websocket:2.3.3'
implementation 'org.webjars:bootstrap:3.3.7'
implementation 'org.webjars:jquery:3.1.1-1'
우선 데이터를 주고 받을 때 필요한 DTO를 구현하고자 한다.
HelloMessage 모델은 Client에서 데이터를 받아올 때 사용하는 DTO이고 Greeting 모델은 Client에 데이터를 전달할 때 사용하는 DTO다. 다만 Greeting 모델은 VO로 구성하여 Read only 속성으로 구현한다.
public class HelloMessage {
private String name;
public HelloMessage() {
}
public HelloMessage(String name) {
this.name = name;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
public class Greeting {
private String content;
public Greeting() {
}
public Greeting(String content) {
this.content = content;
}
public String getContent() {
return content;
}
}
이제 웹소켓 요청을 Handling 하는 Conroller를 구현해본다.
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.stereotype.Controller;
import org.springframework.web.util.HtmlUtils;
@Controller
public class GreetingController {
@MessageMapping("/hello")
@SendTo("/topic/greetings")
public Greeting greeting(HelloMessage message) throws Exception {
Thread.sleep(1000); // simulated delay
return new Greeting("Hello, " + HtmlUtils.htmlEscape(message.getName()) + "!");
}
}
이 코드에서 우리가 살펴봐야 할 것은 두 곳이다.
@MessageMapping("/hello")
MessageMapping은 Client가 Server에게 요청할 때 사용하는 URI를 등록하는 어노테이션이다.
@SendTo("/topic/greetings")
SendTo는 입력한 URI를 구독하고 있는 Client들에게 데이터를 전달하겠다는 어노테이션이다.
SendTo는 1 : 다(브로드캐스팅)에 사용하고 SendToUser는 1:1로 통신할 때 사용한다.
Spring boot에서 기능을 사용하기 위해선 구현한 클래스를 컨테이너에 올려야 한다. 이를 위해서 WebSocketMessageBrokerConfigurer를 상속받은 WebSocketConfig를 구현하여 등록하도록 하자.
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/topic");
config.setApplicationDestinationPrefixes("/app");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/gs-guide-websocket").withSockJS();
}
}
여기서 확인해야하는 코드는 총 4가지다.
@EnableWebSocketMessageBroker
이 어노테이션은 스프링 컨테이너에 Websocket을 사용하겠다고 등록하는 어노테이션이다.
config.enableSimpleBroker("/topic");
이 코드는 websocket에서 구독 관련 URI는 /topic으로 시작한다고 선언하는 코드입니다.
config.setApplicationDestinationPrefixes("/app");
이 코드는 websocket에 클라이언트가 요청을 보내는 URI, 즉 pub 관련 URI의 시작은 /app이라고 선언하는 코드입니다.
registry.addEndpoint("/gs-guide-websocket").withSockJS();
마지막으로 이 코드는 websocket의 엔드포인트로 websocket을 받아오기 위해선 이 URI를 통해서만 받아올 수 있다고 선언한 코드입니다.
마지막으로 프론트 엔드를 구현해본다
이번 시간에는 Springboot Websocket 튜토리얼이기 때문에 React, flutter 말고 순수 html css javascript로 구현했다.
(추가로 html css는 생략하고 javascript 코드만 살펴볼 것이다.)
var stompClient = null;
function setConnected(connected) {
$("#connect").prop("disabled", connected);
$("#disconnect").prop("disabled", !connected);
if (connected) {
$("#conversation").show();
}
else {
$("#conversation").hide();
}
$("#greetings").html("");
}
function connect() {
var socket = new SockJS('/gs-guide-websocket');
stompClient = Stomp.over(socket);
stompClient.connect({}, function (frame) {
setConnected(true);
console.log('Connected: ' + frame);
stompClient.subscribe('/topic/greetings', function (greeting) {
showGreeting(JSON.parse(greeting.body).content);
});
});
}
function disconnect() {
if (stompClient !== null) {
stompClient.disconnect();
}
setConnected(false);
console.log("Disconnected");
}
function sendName() {
stompClient.send("/app/hello", {}, JSON.stringify({'name': $("#name").val()}));
}
function showGreeting(message) {
$("#greetings").append("<tr><td>" + message + "</td></tr>");
}
$(function () {
$("form").on('submit', function (e) {
e.preventDefault();
});
$( "#connect" ).click(function() { connect(); });
$( "#disconnect" ).click(function() { disconnect(); });
$( "#send" ).click(function() { sendName(); });
});
여기서 우리가 확인해야하는 코드는 총 4가지다.
var socket = new SockJS('/gs-guide-websocket');
stompClient = Stomp.over(socket);
stompClient.connect({}, function (frame) {
setConnected(true);
console.log('Connected: ' + frame);
});
이 코드는 서버와 웹소켓 연결을 위해 사용되는 코드이다. Spring은 STOMP를 사용하기 때문에 Stomp로 감싸줘야하며 connect 메소드로 연결한 후 콜백으로 넘어오는 frame에는 연결 정보가 담겨있다.
stompClient.subscribe('/topic/greetings', function (greeting) {
showGreeting(JSON.parse(greeting.body).content);
});
이 코드는 서버에 내가 어떤 URI를 구독할 것이다 라고 알려주는 코드다. 이 코드를 통해 이 URI로 날라오는 데이터를 실시간으로 받을 수 있다.
stompClient.send("/app/hello", {}, JSON.stringify({'name': $("#name").val()}));
이 코드는 Client가 서버에게 메시지를 보내는 것으로 URI, header, 그리고 메시지로 구성되어 있다.
stompClient.disconnect();
보통 웹소켓은 클라이언트가 종료되면 자동으로 종료되지만 명시적으로 연결을 끊어줘야할 때는 이 코드를 사용한다.
잘 동작하는 것을 확인할 수 있다!