โš™๏ธ backend/๐ŸŒฑ spring

[Spring] WebSocket์„ ์‚ฌ์šฉํ•˜์—ฌ ๋Œ€ํ™”ํ˜• ์›น ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ๊ตฌ์ถ•ํ•˜๊ธฐ

dev!n 2023. 2. 11. 18:51

 

์›น์†Œ์ผ“์€ TCP ์œ„์— ์žˆ๋Š” ์–‡๊ณ  ๊ฐ€๋ฒผ์šด ๊ณ„์ธต์ž…๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ ๋ฉ”์‹œ์ง€๋ฅผ ํฌํ•จํ•˜๊ธฐ ์œ„ํ•ด "ํ•˜์œ„ ํ”„๋กœํ† ์ฝœ"์„ ์‚ฌ์šฉํ•˜๋Š” ๋ฐ ์ ํ•ฉํ•ฉ๋‹ˆ๋‹ค. ์ด ํฌ์ŠคํŠธ์—์„œ๋Š” Spring๊ณผ ํ•จ๊ป˜ STOMP ๋ฉ”์‹œ์ง•์„ ์‚ฌ์šฉํ•˜์—ฌ ๋Œ€ํ™”ํ˜• ์›น ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์„ ๋งŒ๋“ญ๋‹ˆ๋‹ค. STOMP๋Š” ํ•˜์œ„ ์ˆ˜์ค€ WebSocket ์œ„์—์„œ ์ž‘๋™ํ•˜๋Š” ํ•˜์œ„ ํ”„๋กœํ† ์ฝœ์ž…๋‹ˆ๋‹ค.

 

spring.io์˜ ํฌ์ŠคํŠธ๋ฅผ ์ฐธ๊ณ ํ•˜์—ฌ ์ž‘์„ฑํ•˜์˜€์Šต๋‹ˆ๋‹ค.

 

Spring | Home

Cloud Your code, any cloud—we’ve got you covered. Connect and scale your services, whatever your platform.

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 ํ–ˆ์Šต๋‹ˆ๋‹ค.

 

GitHub - spring-guides/gs-messaging-stomp-websocket: Using WebSocket to build an interactive web application :: Learn how to the

Using WebSocket to build an interactive web application :: Learn how to the send and receive messages between a browser and the server over a WebSocket - GitHub - spring-guides/gs-messaging-stomp-w...

github.com

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