Spring boot/Spring boot websocket

Spring Boot - STOMP 프로토콜 채팅

whs5758 2025. 9. 5. 11:20

학습 목표

  • STOMP(Simple Text Oriented Messaging Protocol) 프로토콜의 개념과 동작 원리 이해
  • 순수 WebSocket의 복잡성을 해결하는 구조화된 메시징 시스템 구현
  • 스프링 부트의 WebSocket + STOMP 지원 기능 활용
  • 메시지 브로커구독/발행 패턴 이해
  • 채널 기반 메시지 라우팅사용자별 개별 메시지 전송 구현
  • 실시간 통신의 진화 과정 완성 (폴링 → SSE → WebSocket → STOMP 로드맵의 최종 단계)

핵심 개념 이해

1. STOMP(Simple Text Oriented Messaging Protocol)란?

STOMP는 메시징 중간웨어와 통신하기 위한 간단한 텍스트 기반 프로토콜입니다. WebSocket 위에서 동작하며, 표준화된 메시지 형식구독/발행 패턴을 제공합니다.

2. 통신 방식의 변화

  • 순수 WebSocket 방식 (3단계) - 복잡함
    • 클라이언트: socket.send("CHAT:안녕하세요")
    • 서버: 메시지 파싱, 형식 검증, 브로드캐스트 로직 직접 구현
    • 문제점: 메시지 형식 정의, 세션 관리, 에러 처리 등 모든 것을 수동 구현
  • STOMP 방식 (4단계) - 표준화
    • 클라이언트: stompClient.send("/app/chat", {}, "안녕하세요")
    • 서버: @MessageMapping으로 자동 라우팅, 메시지 브로커가 배포 처리
    • 장점: 표준 프로토콜, 자동 세션 관리, 구조화된 메시지 형식

3. STOMP vs 순수 WebSocket 비교

구분  순수 WebSocket STOMP
메시지 형식 개발자 정의 ("CHAT:", "ERROR:") 표준 프레임 구조
라우팅 수동 if-else 분기 처리 @MessageMapping 자동 라우팅
세션 관리 ConcurrentHashMap 수동 관리 스프링이 자동 관리
브로드캐스트 직접 구현 (removeIf 등) @SendTo로 자동 배포
개별 전송 복잡한 사용자-세션 매핑 필요 @SendToUser로 간단 처리
에러 처리 모든 예외 상황 수동 처리 프레임워크가 기본 제공
확장성 서버별 독립적 세션 관리 메시지 브로커로 확장 가능

4. 메시지 브로커와 구독/발행 패턴

메시지 브로커는 메시지를 받아서 적절한 구독자들에게 배포하는 중간 관리자 역할을 합니다.

  • 구독/발행 패턴의 장점:
    • 느슨한 결합: 발행자와 구독자가 서로를 직접 알 필요가 없습니다.
    • 확장성: 새로운 구독자 추가가 쉽습니다.
    • 채널 분리: 주제별로 메시지를 분리하여 관리합니다.

개발자 vs 스프링 역할 분담

  • 개발자가 직접 작성하는 코드

js 코드

// 클라이언트: 구독 등록만 직접 작성
stompClient.subscribe('/topic/messages', onMessageReceived);

스프링 부트 측 코드

// 서버: 메시지 받고 주제만 지정
@MessageMapping("/chat")           // 메시지 받기
@SendTo("/topic/messages")         // 주제 지정하기
public ChatMessageDto sendMessage(ChatMessageDto msg) {
    return msg;
}

스프링이 내부적으로 자동 처리

  • 구독자 목록 관리 (누가 /topic/messages 구독 중인지)
  • 메시지 라우팅 (어느 주제로 보낼지 판단)
  • 자동 배달 (구독자들에게 실제 전송)
  • 연결 관리 (끊어진 클라이언트 정리)
  • JSON 변환 (객체 ↔ JSON 자동 변환)

5. STOMP의 URL 패턴

스프링 부트 STOMP에서는 세 가지 주요 URL 패턴을 사용합니다:

  • /app/*: 클라이언트가 서버로 메시지를 보낼 때 사용
  • /topic/*: 서버가 클라이언트들에게 브로드캐스트할 때 사용
  • /user/*: 특정 사용자에게만 개별 메시지를 보낼 때 사용

코드 구현 하기

WebSocketConfig
package com.example.class_step_websocket.config;

import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.*;

@RequiredArgsConstructor
@Configuration
// @EnableWebSocket // WebSocket 기능 활성화
@EnableWebSocketMessageBroker // STOMP 메세지 브로커 기능 활성화
// WebSocketMessageBrokerConfigurer 수정
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    /**
     * 메세지 브로커 설정
     * - 브로커는 메세지를 받아서 구독자들에게 배포하는 중간 관리자
     */
    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        // /topic <-- 다수의 클라이언트에게 브로드캐스트 하는 채널
        // /user <-- 특정 사용자에게만 개별 메세지를 보내는 채널
        registry.enableSimpleBroker("/topic", "/user");

        // 클라이언트가 우리쪽으로 보내는 형식 설정
        registry.setApplicationDestinationPrefixes("/app");
    }


     // STOMP 엔드 포인트 등록
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        /**
         * /ws(websocket) - STOMP에 연결을 위한 엔드 포인트
         * withSockJS - 브라우저 호환성을 위해 설정 함
         * 폴링, 이벤트 소스, JSONP 등으로 대체
         */
        registry.addEndpoint("/ws")
                .setAllowedOriginPatterns("*")
                .withSockJS();
    }
}

 

ChatController
package com.example.class_step_websocket.chat;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;

@Slf4j
@RequiredArgsConstructor
@Controller // 뷰 반환
public class ChatController {

    private final ChatService chatService;

    // 메세지 작성 폼 페이지
    @GetMapping("/save-form")
    public String saveForm() {
        return "save-form";
    }

    // 채팅 목록 페이지
    @GetMapping("/")
    public String index(Model model) {
        model.addAttribute("models", chatService.findAll());
        return "index";
    }

    // 채팅 메세지 저장 요청
    @PostMapping("/chat")
    public String save(String msg) {
        chatService.save(msg);
        // POST 맵핑 (웹에서 주의할 점) - 중복 등록 방지
        return "save-form";
        // PRG(POST-REDIRECT-GET) 패턴
        // return "redirect:/";
    }

    /**
     * @MessageMapping - 내부 동작 원리 (AOP 관점 지향 프로그램)
     * /app/chat --> xxx --> 브로드 캐스트 처리
     * 순수 웹 소켓 구현 시 만들었던 웹 소켓 핸들러를 대체 한다
     * MessageMapping -> handleMessage() 로직을 대체 함  if --> CHAT:
     *
     * @SendTo("/topic/message") - 기존 로직인 broadcastMessage()를 대체 한다
     */
    @MessageMapping("/chat")
    @SendTo("/topic/message")
    public String sendMessage(String message, SimpMessageHeaderAccessor headerAccessor) {
        log.info("STOMP 메세지 수신 : {}", message);
        try {
            if (message == null || message.trim().isEmpty()) {
                log.warn("빈 메세지 수신, 브로드 캐스트 하지 않음");
                return null;
            }
            Chat savedChat = chatService.save(message.trim());
            return savedChat.getMsg();
        } catch (Exception e) {
            log.error("메세지 저장 실패 : ", e);
            return "ERROR: 메세지 저장에 실패했습니다";
        }
    }


}

 

@MessageMapping("/chat") —> 메서드 가로 채기

@SendTo("/topic/message") —> 리턴타입 String 이라면 보고 있다가 (메세지 브로커가 가져 간다)

실제 스프링이 사용하는 컴포넌트들

  • @SendTo 어노테이션 스캐너: 어노테이션 발견하고 설정 저장
  • Method Interceptor: 메서드 실행 후 return 값 가로채기
  • SimpMessagingTemplate: 실제 메시지 브로커에게 전송

 

index.mustache
<!doctype html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <title>채팅 (WebSocket)</title>
    <style>
        body { margin: 20px; font-family: Arial, sans-serif; }
        nav ul { list-style: none; padding: 0; display: flex; gap: 10px; }
        nav a { padding: 8px 15px; background: #666; color: white; text-decoration: none; }

        .chat-area {
            border: 1px solid #ccc;
            height: 300px;
            padding: 10px;
            overflow-y: scroll;
            background: #f9f9f9;
        }

        .message {
            padding: 5px 10px;
            margin: 3px 0;
            background: white;
            border-radius: 8px;
            max-width: 80%;
        }

        .message:nth-child(odd) {
            background: #e1f5fe;
            margin-left: 20%;
        }

        /* 연결 상태 표시용 스타일 */
        .status {
            padding: 5px 10px;
            border-radius: 3px;
            font-size: 12px;
            margin-bottom: 10px;
        }
        .connected { background: #d4edda; color: #155724; }
        .disconnected { background: #f8d7da; color: #721c24; }

        /* 메시지 입력 폼 스타일 */
        .message-form {
            margin-top: 15px;
            padding: 15px;
            border: 1px solid #ddd;
            border-radius: 5px;
            background: #f8f9fa;
        }

        .message-form input {
            padding: 8px;
            width: 70%;
            margin-right: 10px;
            border: 1px solid #ccc;
            border-radius: 3px;
        }

        .message-form button {
            padding: 8px 15px;
            background: #007bff;
            color: white;
            border: none;
            border-radius: 3px;
            cursor: pointer;
        }

        .message-form button:disabled {
            background: #6c757d;
            cursor: not-allowed;
        }
    </style>
</head>
<body>
<nav>
    <ul>
        <li><a href="/">목록</a></li>
        <li><a href="/save-form">작성</a></li>
    </ul>
</nav>

<h3>채팅 (WebSocket 방식)</h3>
<p style="color: #666; font-size: 13px;">실시간 업데이트</p>

<!-- 연결 상태 표시 영역 -->
<div id="status" class="status disconnected">연결 중...</div>

<!-- 채팅 메시지 표시 영역 -->
<div class="chat-area" id="chatArea">
    {{#models}}
        <div class="message">{{msg}}</div>
    {{/models}}
</div>

<!-- 메시지 입력 폼 -->
<div class="message-form">
    <form id="chatForm">
        <input type="text" id="msgInput" placeholder="메시지를 입력하세요..." required>
        <button type="submit" id="sendButton" disabled>연결 중...</button>
    </form>
</div>


<!-- STOMP 라이브러리 로드 -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/sockjs-client/1.6.1/sockjs.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/stomp.js/2.3.3/stomp.min.js"></script>

<script>
    <!--  이 페이지에 전역 변수 선언    -->
    // DOM 요소 참조
    const chatArea = document.getElementById('chatArea');
    const status = document.getElementById('status');
    const msgInput = document.getElementById('msgInput');
    const chatForm = document.getElementById('chatForm');
    const sendButton = document.getElementById('sendButton');

    <!--  이 페이지에 전역 변수 선언    -->
    let stompClient = null;

<!--    스톰프 연결 시작 -->
    function connect() {
        const socket = new SockJS('/ws');
        stompClient = Stomp.over(socket);

        // 스톰프 연결 시도
        stompClient.connect({}, onConnected, onError);
    }

<!--    연결 요청 함수 실행-->
    connect();

<!--    연결 성공 콜백 메서드 선언-->
    function onConnected() {
        status.textContent = "실시간 연결됨(STOMP)";
        status.className = 'status connected';
        sendButton.disabled = false;
        sendButton.textContent = '전송';
        msgInput.focus();
       console.log('11111111111');
        // 메시지 채널 구독 설정
        stompClient.subscribe('/topic/message', onMessageReceived);

        console.log('STOMP 연결 및 구독 처리 완료');
    }
<!--    연결 실패시 콜백 -->
    function onError(error) {
        status.textContent = "연결 에러 발생";
        status.className = 'status disconnected';
        sendButton.disabled = true;
        console.error('STOMP 연결 에러', error);
    }
<!-- 메세지 수신 처리 -->
    function onMessageReceived(message) {
      console.log('message', message);
      const chatMessage = message.body;
      // 에러 메세지 처리
      if(chatMessage.startsWith('ERROR:')) {
        // HTML 조작 ... 생략...
        console.error('ERROR 발생');
        return;
      }

      // 일반 채팅 메세지 정상 수신
      const messageDiv = document.createElement('div');
      messageDiv.className = 'message';
      messageDiv.textContent = chatMessage;
      chatArea.appendChild(messageDiv);

      // 스크롤 맨 아래로 이동 처리
      chatArea.scrollTop = chatArea.scrollHeight;
    }

<!--        메세지 전송 로직 처리-->
    chatForm.addEventListener('submit', function(e) {
        e.preventDefault();
        sendMessage();
    });

<!--    메세지 전송 로직 처리 -->
    function sendMessage() {
        // 사용자가 작성한 글자 가져오기
        const sendMessage = msgInput.value.trim();
        // 가져온 글자를 서버로 보내는 처리 - (검증...방어적 코드 작성)
        if(sendMessage != null && stompClient != null && stompClient.connected) {
        // {'content-Type': 'application/json'} <-- 헤더 설정
            stompClient.send('/app/chat', {}, sendMessage);

            msgInput.value = '';
            msgInput.focus();
        } else {
            alert('Stomp 연결이 끊어져 있습니다');
        }
    }

<!--     엔터키 이벤트 등록 및 처리   -->
        msgInput.addEventListener('keypress', function(e) {
            if(e.key === 'Enter') {
                sendMessage();
            }
        });

        // 페이지 종료 시 연결 종료 처리
        window.addEventListener('beforeunload', () => {
            if(stompClient) {
                stompClient.disconnect();
                console.log('stomp연결 정리 완료');
            }
        });


</script>


</body>
</html>