제목: "[Spring Boot] WebSocket과 채팅 (3) - STOMP"
작성자: tistory(조용한고라니)
작성자 수정일: 2021년 4월 9일
링크: https://dev-gorany.tistory.com/235
작성일: 2022년 2월 22일
앞선 포스팅에서 SockJS를 적용해 WebSocket을 지원하지 않는 브라우저에서 서버와 클라이언트 간 통신이 끊기지 않고 채팅 기능을 수행하는 것 까지 확인하였다. 다만 채팅방이 여전히 하나만 존재한다는 점이 아쉽게 느껴진다.
그리고 메시징 방식만 잘 정의한다면 WebSocket만으로 좋은 Server/Client 소켓 서버를 완성할 수 있으나, 단순한 통신 구조로 인해 WebSocket만을 이용해 채팅을 구현하면 해당 메세지가 어떤 요청인지, 어떻게 처리해야 하는지에 따라 채팅방과 세션을 일일이 구현하고 메세지 발송 부분을 관리하는 추가 코드를 구현해주어야 한다.
WebSocket 프로토콜은 두 가지 유형의 메세지를 정의하고 있지만 그 메세지의 내용까지는 정의하고 있지 않는다.
STOMP (Simple Text Oriented Messaging Protocol) 은 메세징 전송을 효율적으로 하기 위해 탄생한 프로토콜이고, 기본적으로 pub/sub구조로 되어있어 메세지를 전송하고 메세지를 받아 처리하는 부분이 확실히 정해져 있기 때문에 개발자 입장에서 명확하게 인지하고 개발할 수 있는 이점이 있다.
한 줄로 정의하자면, STOMP
프로토콜은 WebSocket 위에서 동작하는 프로토콜로써 클라이언트와 서버가 전송할 메세지의 유형,형식,내용들을 정의하는 메커니즘이다.
또한 STOMP를 이용하면 메세지의 헤더에 값을 줄 수 있어 헤더 값을 기반으로 통신 시 인증 처리를 구현하는 것도 가능하면 STOMP 스펙에 정의한 규칙만 잘 지키면 여러 언어 및 플랫폼 간 메세지를 상호 운영할 수 있다.
STOMP는 TCP 또는 WebSocket 같은 양방향 네트워크 프로토콜 기반으로 동작한다.
이름에서 알 수 있듯, STOMP는 TEXT 지향 프로토콜이나, Message Payload에는 Text or Binary 데이터를 포함할 수 있다.
위에서 언급한 pub/sub
란 메세지를 공급하는 주체와 소비하는 주체를 분리해 제공하는 메세징 방법이다.
채팅방 생성: pub/sub 구현을 위한 Topic이 생성됨
채팅방 입장: Topic 구독
채팅방에서 메세지를 송수신: 해당 Topic으로 메세지를 송신(pub),메세지를 수신(sub)
클라이언트는 메세지를 전송하기 위해 SEND, SUBSCRIBE COMMAND를 사용할 수 있다.
또한 SEND, SUBSCRIBE COMMAND 요청 Frame에는 메세지가 무엇이고, 누가 받아서 처리할지에 대한 Header 정보가 포함되어있다.
이런 명령어들은 destination
헤더를 요구하는데 이것을 어디에 전송할지, 혹은 어디에서 메세지를 구독할 것인지를 나타낸다.
위와 같은 과정을 통해 STOMP는 Publish-Subscribe 메커니즘을 제공한다.
즉, Broker를 통해 타 사용자들에게 메세지를 보내거나 서버가 특정 작업을 수행하도록 메세지를 보낼 수 있게 된다.
- 만약 Spring에서 지원하는 STOMP를 사용하면 Spring WebSocket 어플리케이션은 Broker로 동작하게 된다.
Spring에서 지원하는 STOMP는 많은 기능을 하는데 예를 들어 Simple In-Memory Broker
를 이용해 Subscribe 중인 다른 클라이언트들에게 메세지를 보내준다.
- Simple In Memory Broker
는 클라이언트의 SUBSCRIBE 정보를 자체적으로 메모리에 유지한다.
- 또한 RabbitMQ
, ActiveMQ
같은 외부 메세징 시스템을 STOMP Broker로 사용할 수 있도록 지원한다.
구조적인 면을 보자면,
이와 같은 구조 덕분에 HTTP 기반의 보안 설정과 공통된 검증 등을 적용할 수 있게 된다.
STOMP는 HTTP에서 모델링되는 Frame 기반 프로토콜이다. Frame은 몇개의 Text Line으로 지정된 구조인데 첫 번째 라인은 Text이고 이후 Key:Value 형태로 Header의 정보를 포함한다. 다음 빈 라인을 추가하고 Payload가 존재 한다. 이 구조를 보면 HTTP 요청과 왜 유사한지 알 수 있다.
Frame 기반 프로토콜 참고
https://brunch.co.kr/@sangjinkang/3
COMMAND
header1:value1
header2:value2
Body^@
COMMAND: SEND,SUBSCRIBE를 지시할 수 있다.
header: 기존의 WebSocket으로는 표현이 불가능한 header를 작성할 수 있다.
"topic/.." --> publish-subscribe (1:N)
"queue/" --> point-to-point (1:1)
SUBSCRIBE
destination: /topic/chat/room/5
id: sub-1
^@
SEND
destination: /pub/chat
content-type: application/json
{"chatRoomId": 5, "type": "MESSAGE", "writer": "clientB"} ^@
MESSAGE
destination: /topic/chat/room/5
message-id: d4c0d7f6-1
subscription: sub-1
{"chatRoomId": 5, "type": "MESSAGE", "writer": "clientB"} ^@
출처: https://supawer0728.github.io/2018/03/30/spring-websocket/
서버는 불분명한 메세지를 전송할 수 없다. 그러므로 서버의 모든 메세지는 특정 클라이언트 구독에 응답하여야 하고, 서버 메세지의 **subscription-id
헤더는 클라이언트 구독의 id
** 헤더와 일치해야 한다.
SEND
destination: /queue/a
content-type: text/plain
hello queue a
^@
content-length
와 content-type
헤더를 반드시 가져야만 한다.SUBSCRIBE frame
은 주어진 destination에 등록하기 위해 사용된다.SEND frame
과 마찬가지로 Subscribe는 client가 구독하기 원하는 목적지를 가리키는 destination
헤더를 필요로 한다.Message frame
로서 서버에서 클라이언트에게 전달된다.SUBSCRIBE
id: 0
destination: /queue/foo
ack: client
^@
단일 연결은 여러 개의 구독을 할 수 있으므로 구독 ID를 고유하게 식별하기 위해 id
헤더가 프레임에 포함되어야 한다.
이외에도 아래 내용이 있지만 필요하면 찾아보자
출처: https://stomp.github.io/stomp-specification-1.2.html#Frames_and_Headers
Spring framework 및 Spring Security는 STOMP
를 사용하여, WebSocket
만 사용할 때보다 더 다채로운 모델링을 할 수 있다.
Messaging Protocol을 만들고 메세지 형식을 커스터마이징 할 필요가 없다.
RabbitMQ,ActiveMQ 같은 Message Broker를 이용해, Subscription(구독)을 관리하고 메세지를 브로드캐스팅할 수 있다.
WebSocket 기반으로 각 Connect(연결)마다 WebSocketHandler
를 구현하는 것보다 @Controller
된 객체를 이용해 조직적으로 관리할 수 있다.
destination
헤더를 기반으로 @Controller
객체의 @MethodMapping
메서드로 라우팅 된다.STOMP의 destination
및 message type을 기반으로 메세지를 보호하기 위해 스프링 시큐리티를 사용할 수 있다.
Spring은 WebSocket / SockJS를 기반으로 STOMP를 위해 spring-messaging
과 spring-websocket
모듈을 제공한다.
아래 예시와 같이 STOMP 설정을 할 수 있는데 기본적으로 커넥션을 위한 STOMP Endpoint를 설정해야만 한다.
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/example").withSockJS();
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.setApplicationDestinationPrefixes("/test");
registry.enableSimpleBroker("/topic", "/queue");
}
}
/example
/test
/test
경로로 시작하는 STOMP 메시지의 destination
헤더는 Controller 객체의 @MessageMapping
메서드로 라우팅된다.내장된 메세지 브로커를 사용해 Client에게 Subscriptions,Broadscasting 기능을 제공한다.
/topic
, /queue
로 시작하는 destination
헤더를 가진 메세지를 브로커로 라우팅 한다.
Simple Message Broker
는 /topic
, /queue
prefix에 대해 특별한 의미를 부여하지 않는다.SockJS로 브라우저에 연결하기 위해 sockjs-client
를 이용할 수 있다.
STOMP에 있어 많은 어플리케이션들은 jmesnil/stomp-websocket
( stomp.js
로 알려진) 라이브러리를 사용해왔지만, 더이상 유지되지 않는다.
최근에는 JSteunou/webstomp-client
를 많이 사용한다.
var sock = new SockJS("/ws/chat");
var stomp = webstomp.over(sock);
stomp.connect({}, function(frame) {
}
/* WebSocket만 이용할 경우 */
var websocket = new WebSocket("/ws/chat");
var stomp = webstomp.over(websocket);
stomp.connect({}, function(frame) {
}
참고 링크:
STOMP Endpoint가 노출되고 나면, Spring 어플리케이션은 연결되어있는 Client들에 대해 STOMP 브로커가 된다.
(/app
== /pub
, /topic
== /sub
) 아래 그림은 내장 메세지 브로커를 사용한 경우 컴포넌트 구성을 보여준다.
출처: https://docs.spring.io/spring-framework/docs/current/reference/html/web.html#websocket-stomp
spring-message 모듈은 Spring framework의 통합된 Messaging 어플리케이션을 위한 지원을 한다.
Message
MessageHandler
SimpleAnnotationMethod
@MessageMapping
등 Client의 Send
를 받아서 처리한다.SimpleBroker
Channel
clientInBoundChannel
WebSocketMessageBrokerConfigurer
를 통해 intercept,taskExecutor를 설정할 수 있다.clientOutboundChannel
WebSocketMessageBrokerConfigurer
를 통해 intercept,taskExecutor를 설정할 수 있다.brokerChannel
SimpleAnnotationMethod
는 SimpleBroker
의 존재를 직접 알지 못해도 메세지를 전달할 수 있다.이것들 말고도 더 많은 내용이 있지만, 지금 다 보기에는 힘들 것 같다. 추후 Spring docs를 더 참고하도록 하고, 실제 코드를 짜서 구현해보도록 하자.
의존성 추가
// https://mvnrepository.com/artifact/org.webjars/stomp-websocket
implementation 'org.webjars:stomp-websocket:2.3.4'
WebSocketConfig -> StompWebSocketConfig
기존에 WebSocketConfigurer
를 구현한 WebSocketConfig
설정파일을 WebSocketMessageBrokerConfigurer
를 구현한 StompWebSocketConfig
로 변경한다.
@Configuration
@EnableWebSocketMessageBroker
public class StompWebSocketConfig implements WebSocketMessageBrokerConfigurer {
/*
endpoint 를 /stomp 로 하고, allowedOrigins 를 "*"로 하면 페이지에서
Get /info 4040 Error 가 발생한다.
그래서 아래와 같이 2개의 계층으로 분리하고 origins 를 개발 도메인으로 변경하지 잘 동작한다.
*/
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/stomp/chat")
.setAllowedOriginPatterns("http://localhost:8080")
.withSockJS();
}
// 어플리케이션 내부에서 사용할 path 를 지정할 수 있음
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.setApplicationDestinationPrefixes("/pub");
registry.enableSimpleBroker("/sub");
}
}
@EnableWebSocketMessageBroker
setApplicationDestinationPrefixes
enableSimpleBroker
enableStompBrokerRelay
ChatMessageDTO
@Data
public class ChatMessageDto {
private String roomId;
private String writer;
private String message;
}
ChatRoomDTO
더 이상 하나의 채팅방에 옹기종기 모이는 것이 아닌 private한 공간에서 채팅할 수 있도록 채팅방을 만들어주자.
@Data
public class ChatRoomDto {
private String roomId;
private String name;
private Set<WebSocketSession> socketSessions = new HashSet<>();
// WebSocketSession 은 Spring 에서 Websocket Connection 이 맺어진 세션
public static ChatRoomDto create(String name) {
ChatRoomDto room = new ChatRoomDto();
room.setRoomId(UUID.randomUUID().toString());
room.setName(name);
return room;
}
}
ChatRoomRepository
@Repository
public class ChatRoomRepository {
private Map<String, ChatRoomDto> charRoomDtoMap;
@PostConstruct
private void init() {
charRoomDtoMap = new LinkedHashMap<>();
}
public List<ChatRoomDto> findAllRooms() {
// 채팅방 생성 순서 최근 순으로 반환
ArrayList<ChatRoomDto> list = new ArrayList<>(charRoomDtoMap.values());
Collections.reverse(list);
return list;
}
public ChatRoomDto findRoomById(String id) {
return charRoomDtoMap.get(id);
}
public ChatRoomDto createChatRoomDto(String name) {
ChatRoomDto room = ChatRoomDto.create(name);
charRoomDtoMap.put(room.getRoomId(), room);
return room;
}
}
StompChatController
@Controller
@RequiredArgsConstructor
public class StompChatController {
// 특정 Broker 로 메세지를 전달
private final SimpMessagingTemplate template;
// Client 가 Send 할 수 있는 경로
// stompConfig 에서 설정한 applicationDestinationPrefixes 와 @MessageMapping 경로가 병합됨
// /pub/chat/enter
@MessageMapping(value = "/chat/enter")
public void enter(ChatMessageDto dto) {
dto.setMessage(dto.getWriter() + "님이 채팅방에 참여하였습니다.");
template.convertAndSend("/sub/chat/room/" + dto.getRoomId(), dto);
}
@MessageMapping(value = "/chat/message")
public void message(ChatMessageDto dto) {
template.convertAndSend("/sub/chat/room/" + dto.getRoomId(), dto);
}
}
@MessageMapping
을 통해 WebSocket
으로 들어오는 메시지 발행을 처리한다.
/pub/chat/enter
로 발행 요청을 하면 컨트롤러가 해당 메세지를 받아 처리하는데, 메세지가 발행되면 "/sub/chat/room/{roomId}"
로 메세지가 전송되는 것을 볼 수 있다.클라이언트에서는 해당 주소를 SUBSCRIBE
하고 있다가 메세지가 전달되면 화면에 출력한다.
이 때 /sub/chat/room/{roomId}
는 채팅방을 구분하는 값이다.
기존의 핸들러 ChatHandler
의 역할을 StompChatController
가 대신 해주므로 핸들러는 없어도 된다.
RoomController
@Controller
@RequiredArgsConstructor
@RequestMapping(value = "/chat")
@Slf4j
public class RoomController {
private final ChatRoomRepository chatRoomRepository;
// 채팅방 목록 조회
@GetMapping("/rooms")
public String rooms(Model model) {
log.info("# All Chat Rooms");
model.addAttribute("list", chatRoomRepository.findAllRooms());
return "chat/rooms";
}
// 채팅방 개설
@PostMapping(value = "/room")
public String create(@RequestParam String name, RedirectAttributes redirectAttributes) {
log.info("# Create Chat Room, name: [{}]", name);
redirectAttributes.addFlashAttribute("roomName", chatRoomRepository.createChatRoomDto(name));
return "redirect:/chat/rooms";
}
// 채팅방 조회
@GetMapping("/room")
public String getRoom(String roomId, Model model) {
log.info("# get Char Room, roomId = [{}]", roomId);
model.addAttribute("room", chatRoomRepository.findRoomById(roomId));
model.addAttribute("username", UUID.randomUUID().toString());
return "chat/room";
}
}```
**View**
```html
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<title>hello</title>
</head>
<body>
<div class="container">
<div class="col-6">
<h1>[[${room.name}]]</h1>
</div>
<div>
<div id="msgArea" class="col"></div>
<div class="col-6">
<div class="input-group mb-3">
<input type="text" id="msg" class="form-control">
<div class="input-group-append">
<button class="btn btn-outline-secondary" type="button" id="button-send">전송</button>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/stomp.js/2.3.3/stomp.min.js"></script>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.12.4/jquery.min.js"></script>
<script th:inline="javascript">
$(document).ready(function(){
var roomName = [[${room.name}]];
var roomId = [[${room.roomId}]];
var username = [[${username}]];
console.log(roomName + ", " + roomId + ", " + username);
//1. SockJS를 내부에 들고있는 stomp를 내어줌
var sockJs = new SockJS("/stomp/chat");
var stomp = Stomp.over(sockJs);
//2. connection이 맺어지면 실행
stomp.connect({}, function (){
console.log("STOMP Connection")
//4. subscribe(path, callback)으로 메세지를 받을 수 있음
stomp.subscribe("/sub/chat/room/" + roomId, function (chat) {
var content = JSON.parse(chat.body);
var writer = content.writer;
var message = content.message
var str = '';
console.log("writer === username")
str = "<div class='col-6'>";
str += "<div class='alert alert-secondary'>";
str += "<b>" + writer + " : " + message + "</b>";
str += "</div></div>";
$("#msgArea").append(str);
});
//3. send(path, header, message)로 메세지를 보낼 수 있음
stomp.send('/pub/chat/enter', {}, JSON.stringify({roomId: roomId, writer: username}))
});
$("#button-send").on("click", function(e){
var msg = document.getElementById("msg");
console.log(username + ":" + msg.value);
stomp.send('/pub/chat/message', {}, JSON.stringify({roomId: roomId, message: msg.value, writer: username}));
msg.value = '';
});
});
</script>
</body>
</html>
WebSocket -> SockJS -> STOMP 순으로 진행해보았다