[Spring] WebSocket을 사용하여 대화형 웹 애플리케이션 구축하기
웹소켓은 TCP 위에 있는 얇고 가벼운 계층입니다. 따라서 메시지를 포함하기 위해 "하위 프로토콜"을 사용하는 데 적합합니다. 이 포스트에서는 Spring과 함께 STOMP 메시징을 사용하여 대화형 웹 애플리케이션을 만듭니다. STOMP는 하위 수준 WebSocket 위에서 작동하는 하위 프로토콜입니다.
spring.io의 포스트를 참고하여 작성하였습니다.
만들 것
사용자 이름이 포함된 메시지를 수락하는 서버를 구축합니다. 이에 대한 응답으로 서버는 클라이언트가 가입한 대기열에 인사말을 푸시합니다.
요구 환경
Java 17 or later
Gradle 7.5+ or Maven 3.5+
빌드 환경
$ ./gradlew -v
------------------------------------------------------------
Gradle 7.6
------------------------------------------------------------
Build time: 2022-11-25 13:35:10 UTC
Revision: daece9dbc5b79370cc8e4fd6fe4b2cd400e150a8
Kotlin: 1.7.10
Groovy: 3.0.13
Ant: Apache Ant(TM) version 1.10.11 compiled on July 10 2021
JVM: 17.0.6 (Eclipse Adoptium 17.0.6+10)
OS: Mac OS X 13.0 aarch64
Gradle Dependency
build.gradle.kts에 다음을 추가
implementation("org.springframework.boot:spring-boot-starter-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")
STOMP 메시지 서비스 필수 구성 요소
리소스 표현 클래스 생성
이 서비스는 본문이 JSON 객체인 STOMP 메시지에 이름이 포함된 메시지를 받습니다.
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
@NoArgsConstructor
@AllArgsConstructor
@Getter
public class HelloMessage {
private String name;
}
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
@NoArgsConstructor
@AllArgsConstructor
@Getter
public class Greeting {
private String content;
}
메시지 처리 컨트롤러 생성
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 1s.
return new Greeting("Hello, " + HtmlUtils.htmlEscape(message.getName()) + "!");
}
}
STOMP 메시징을 위한 스프링 설정
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) {
// 간단한 메모리 기반 메시지 브로커가 /topic 접두사가 붙은 대상에서 인사말 메시지를 클라이언트로 다시 전달할 수 있도록 설정
config.enableSimpleBroker("/topic");
// @MessageMapping이 달린 메서드에 바인딩된 메시지에 대해 /app 접두사 지정
// 이 접두사는 모든 메시지 매핑을 정의하는데 사용된다
config.setApplicationDestinationPrefixes("/app");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// /gs-guide-websocket라는 엔드포인트를 등록하여 웹소켓을 사용할 수 없는 경우
// 대체 전송을 할 수 있도록 SockJS 폴백 옵션을 활성화
// 사용 가능한 최상의 전송(웹소켓, xhr-streaming, xhr-polling 등)
registry.addEndpoint("/gs-guide-websocket").withSockJS();
}
}
클라이언트
메시지를 주고 받을 자바스크립트 클라이언트
index.html과 main.css는 이 포스트의 관심사가 아니므로 공식 깃헙 레포에서 Copy&Paste 했습니다.
index.html
을 잠깐 보면, 웹소켓을 통해 STOMP를 통해 서버와 통신하는 데 사용되는 SockJS 및 STOMP 자바스크립트 라이브러리를 가져옵니다. 또한 클라이언트 애플리케이션의 로직이 포함된 app.js도 가져옵니다.
app.js
var stompClient = null;
function setConnected(connected) {
$("#connect").prop("disabled", connected);
$("#disconnect").prop("disabled", !connected);
if (connected) {
$("#conversation").show();
} else {
$("#conversation").hide();
}
$("#greetings").html("");
}
/**
* SockJS와 stomp.js를 사용하여 SockJS 서버가 연결을 기다리는 /gs-guide-websocket에 대한 연결을 엽니다.
* 연결에 성공하면 클라이언트는 서버가 인사말 메시지를 게시하는 /topic/greetings 대상에 가입합니다.
* 해당 대상에서 인사말이 수신되면 DOM에 단락 요소를 추가하여 인사말 메시지를 표시합니다.
*/
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");
}
/**
* 사용자가 입력한 이름을 검색하고 STOMP 클라이언트를 사용하여 /app/hello 대상(GreetingController.greeting()이 수신하는 곳)으로 보냅니다.
*/
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();
});
});
실행
application 클래스
이 애플리케이션을 실행하는데 사용합니다. Spring Boot가 애플리케이션 클래스를 자동으로 생성했을 것 입니다.
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
실행 가능한 JAR 빌드
// 실행
$ ./gradlew bootrun
// 빌드 후 JAR 실행
$ ./gradlew build
$ java -jar {패키지 이름}-{버전}.jar
실행 결과
서비스가 실행되면 브라우저에서 http://localhost:8080 에 접속하여 Connect 버튼을 클릭합니다.
연결이 열리면 이름을 입력하라는 메시지가 표시됩니다. 이름을 입력하고 보내기를 클릭합니다. 사용자의 이름이 STOMP를 통해 서버에 JSON 메시지로 전송됩니다. 1초의 시뮬레이션 지연 후 서버가 페이지에 표시될 "Hello" 인사말과 함께 메시지를 다시 보냅니다. 이때 다른 이름을 보낼 수도 있고 연결 끊기 버튼을 클릭하여 연결을 닫을 수도 있습니다.
command line ; ctrl + c 로 종료.
> Task :bootRun
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v3.0.2)
~~~생략
2023-02-11T18:31:57.151+09:00 INFO 13732 --- [ main] o.s.b.a.w.s.WelcomePageHandlerMapping : Adding welcome page: class path resource [static/index.html]
~~~
2023-02-11T18:31:57.996+09:00 INFO 13732 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port(s): 8080 (http) with context path ''
2023-02-11T18:31:57.997+09:00 INFO 13732 --- [ main] o.s.m.s.b.SimpleBrokerMessageHandler : Starting...
2023-02-11T18:31:57.997+09:00 INFO 13732 --- [ main] o.s.m.s.b.SimpleBrokerMessageHandler : BrokerAvailabilityEvent[available=true, SimpleBrokerMessageHandler [org.springframework.messaging.simp.broker.DefaultSubscriptionRegistry@d082916]]
2023-02-11T18:31:57.997+09:00 INFO 13732 --- [ main] o.s.m.s.b.SimpleBrokerMessageHandler : Started.
2023-02-11T18:31:58.010+09:00 INFO 13732 --- [ main] io.sookmyung.type.Application : Started Application in 2.713 seconds (process running for 2.901)
2023-02-11T18:32:29.089+09:00 INFO 13732 --- [nio-8080-exec-1] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring DispatcherServlet 'dispatcherServlet'
2023-02-11T18:32:29.090+09:00 INFO 13732 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet : Initializing Servlet 'dispatcherServlet'
2023-02-11T18:32:29.092+09:00 INFO 13732 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet : Completed initialization in 2 ms
2023-02-11T18:32:43.601+09:00 INFO 13732 --- [nboundChannel-7] i.s.type.example.GreetingController : HelloMessage(name=Jennie)
2023-02-11T18:32:46.048+09:00 INFO 13732 --- [boundChannel-10] i.s.type.example.GreetingController : HelloMessage(name=Jennie)
2023-02-11T18:32:52.323+09:00 INFO 13732 --- [boundChannel-13] i.s.type.example.GreetingController : HelloMessage(name=Bennie)
2023-02-11T18:32:57.039+09:00 INFO 13732 --- [MessageBroker-4] o.s.w.s.c.WebSocketMessageBrokerStats : WebSocketSession[1 current WS(1)-HttpStream(0)-HttpPoll(0), 1 total, 0 closed abnormally (0 connect failure, 0 send limit, 0 transport error)], stompSubProtocol[processed CONNECT(1)-CONNECTED(1)-DISCONNECT(0)], stompBrokerRelay[null], inboundChannel[pool size = 15, active threads = 0, queued tasks = 0, completed tasks = 15], outboundChannel[pool size = 4, active threads = 0, queued tasks = 0, completed tasks = 4], sockJsScheduler[pool size = 8, active threads = 1, queued tasks = 2, completed tasks = 5]
<==========---> 80% EXECUTING [1m 47s]
> :bootRun