학습 목표
- 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>
'Spring boot > Spring boot websocket' 카테고리의 다른 글
| Spring Boot - STOMP(DTO) 변환 (0) | 2025.09.09 |
|---|---|
| Spring Boot WebSocket 기본 채팅 시스템 (0) | 2025.09.04 |
| Spring Boot SSE(Server-Sent Events) 채팅 시스템 (0) | 2025.09.04 |
| Spring Boot 폴링(Polling) 채팅 시스템 (0) | 2025.09.02 |