Spring Boot에서 웹소켓(WebSocket) 사용하기

1. 웹소켓(WebSocket)이란?

 

웹소켓은 클라이언트와 서버 간의 양방향 통신을 가능하게 하는 프로토콜이다.

기존 HTTP 요청-응답 방식은 클라이언트에서 요청을 해야지만 서버에서 응답을 해서 값을 가져오는 반면,

웹소켓은 한 번 연결하면 지속적으로 데이터를 주고받을 수 있어 실시간성이 중요한 서비스에 적합하다.

 

 

특징

 

1. Persistent Connection

  •  HTTP는 요청-응답 후 연결이 끊기지만, 웹소켓은 한 번 연결되면 유지됨

2. 양방향 통신

  • 클라이언트와 서버가 서로 데이터를 계속 주고받을 수 있음

3. 낮은 오버헤드

  • HTTP보다 헤더 크기가 작아 네트워크 비용 절감

4. 빠른 데이터 전송

  • 연결이 유지되고 있으므로 응답 속도가 빠름

 

그리고 STOMP(Simple Text Oriented Messaging Protocol)를 사용하면 좀 더 구조화된 메시지 교환이 가능하다.

 

2. STOMP

웹소켓 위에서 동작하는 텍스트 기반 프로토콜이다.

메시지를 구독(Subscribe)와 발행(Publish) 개념으로 처리하고 헤더 기반의 메시지 전송 방식을 지원한다.

Redis와 같은 메시지 브로커와도 연동이 가능하다.

 

웹소켓을 편리하게 사용할 수 있는 프로토콜이라고 이해하면 편할 것 같다.

 

 

3. 실습

그래서 웹소켓으로 뭘 해볼까 생각해보다가 양방향 통신과 지속성이라는 특징을 이용해서 온라인 히터를 만들어서 접속한 유저들이 온도를 공유하고 설정도 할 수 있는 간단한 프로젝트를 만들어보았다.

 

코드

 

Spring Boot 웹소켓 기본 설정

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
    
    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.enableSimpleBroker("/topic");  // 구독하는 경로
        registry.setApplicationDestinationPrefixes("/app"); // 클라이언트가 메시지를 보낼 때 사용하는 prefix
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws") // 웹소켓 연결 엔드포인트
                .setAllowedOrigins("*") // CORS 설정
                .withSockJS(); // SockJS 지원
    }
}

 

 

  • /ws: 클라이언트가 웹소켓을 연결하는 엔드포인트
  • /app: 클라이언트가 메시지를 보낼 때 사용
  • /topic: 서버에서 발행한 메시지를 클라이언트가 구독하는 경로
  • SockJS: 브라우저에서 웹소켓을 지원하지 않을 경우 대체 가능

 

 

웹소켓 컨트롤러 (STOMP 메시지 처리)

 

package com.example.onlineheater.controller;

import com.example.onlineheater.service.CounterService;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.stereotype.Controller;

@Controller
public class CounterWebSocketController {
    private final CounterService counterService;

    public CounterWebSocketController(CounterService counterService) {
        this.counterService = counterService;
    }

    @MessageMapping("/increment") // 클라이언트가 /app/increment로 메시지를 보내면 실행됨
    @SendTo("/topic/counter")     // 메시지를 /topic/public 을 구독한 클라이언트에게 전송
    public int incrementCounter() {
        return counterService.incrementCounter();
    }

    @MessageMapping("/decrement")
    @SendTo("/topic/counter")
    public int decrementCounter() {
        return counterService.decrementCounter();
    }
}

 

 

HTTP 요청 컨트롤러

package com.example.onlineheater.controller;

import com.example.onlineheater.service.CounterService;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/counter")
public class CounterController {
    private final CounterService counterService;

    public CounterController(CounterService counterService) {
        this.counterService = counterService;
    }

    @PostMapping("/increment")
    public int increment() {
        return counterService.incrementCounter();
    }

    @PostMapping("/decrement")
    public int decrement() {
        return counterService.decrementCounter();
    }

    @GetMapping
    public int getCounter() {
        return counterService.getCounter();
    }
}

 

 

CounterService.java

package com.example.onlineheater.service;

import lombok.Getter;
import org.springframework.stereotype.Service;
import org.springframework.messaging.simp.SimpMessagingTemplate;

@Service
public class CounterService {
    @Getter
    private int counter = 0;
    private final SimpMessagingTemplate messagingTemplate;

    public CounterService(SimpMessagingTemplate messagingTemplate) {
        this.messagingTemplate = messagingTemplate;
    }

    public synchronized int incrementCounter() {
        counter++;
        messagingTemplate.convertAndSend("/topic/counter", counter);
        return counter;
    }

    public synchronized int decrementCounter() {
        counter--;
        messagingTemplate.convertAndSend("/topic/counter", counter);
        return counter;
    }

}

 

 

Vue로 만든 프론트엔드

<template>
  <div style="justify-content: center; display: flex;">
    <div style="width: 500px; height: 350px; overflow: hidden;">
      <img src="https://gi.esmplus.com/buy7942rpo/elec_heater/unimax/ump-2100fg_v2/02_ump-2100fg_v2_08.gif"
        style="object-fit: cover; width: 100%; height: 100%;" />
    </div>

  </div>
  <div style="display: flex; justify-content: center; margin-top: 30px;">
  <h1>현재 온도: <span>{{ counter }} <v-icon>mdi-temperature-celsius</v-icon></span></h1>
    <v-button style="margin-left: 50px;" @click="increment"><v-icon>mdi-arrow-up-bold</v-icon></v-button>
    <v-button style="margin-left: 50px;" @click="decrement"><v-icon>mdi-arrow-down-bold</v-icon></v-button>
  </div>
</template>
<script>
import { ref, onMounted } from "vue";
import SockJS from "sockjs-client";
import Stomp from "stompjs";
import axios from "axios";

export default {
  setup() {
    const counter = ref(0);
    let stompClient = null;

    const connectWebSocket = () => {
      const socket = new SockJS("http://요청보낼주소/ws");
      stompClient = Stomp.over(socket);

      stompClient.connect({}, () => {
        console.log("Connected to WebSocket");
        stompClient.subscribe("/topic/counter", (message) => {
          counter.value = Number(message.body);
        });
      });
    };

    const increment = async () => {
      await axios.post("http://요청보낼주소/counter/increment");
    };
    const decrement = async () => {
      await axios.post("http://요청보낼주소/counter/decrement");
    };

    const fetchCounter = async () => {
      const response = await axios.get("http://요청보낼주소/counter");
      counter.value = response.data;
    };

    onMounted(() => {
      fetchCounter();
      connectWebSocket();
    });

    return {
      counter,
      increment,
      decrement,
    };
  },
};
</script>


<style scoped>
h1 {
  font-size: 24px;
}

button {
  padding: 10px;
  font-size: 16px;
  cursor: pointer;
}
</style>

 

 

실행화면