저번 편에서 웹 소켓을 이용하여서 채팅을 구현해보았었다. 이번에는 Spring Stomp를 이용하여서 구현을 해보려고 한다.
웹 소켓으로 구현했을 때와 달라진 점을 위주로 글을 적어보려고 하며,
먼저 Spring Stomp를 이야기 하기 전에 pub, sub 구조에 대해서 이야기를 해봐야 할 것 같다.
pub/sub 아키텍쳐는 발행/구독 아키텍쳐라고도 불리우며, 위키피디아에서는 아래와 같이 정의를 하였다.
발행-구독 모델은 비동기 메시징 패러다임이다. 발행-구독 모델에서 발신자의 메시지는 특별한 수신자가 정해져 있지 않다.
대신 발행된 메시지는 정해진 범주에 따라, 각 범주에 대한 구독을 신청한 수신자에게 전달된다.
수신자는 발행자에 대한 지식이 없어도 원하는 메시지만을 수신할 수 있다.
이러한 발행자와 구독자의 디커플링은 더 다이나믹한 네트워크 토폴로지와 높은 확장성을 허용한다.
위의 말들이 잘 이해가 안가는 분들을 위해 나를 포함해서 그림을 통한 예시들을 들어보고자 한다.
위의 그림을 토대로 설명을 해보겠다.
pub/sub 아키텍쳐에서는 이름 뜻과 같이 발행자와 구독자가 존재한다.
발행자가 메시지와 메시지를 보낼 채널(채팅방)을 서버에 보내면, 서버는 해당 채널을 구독한 모든 구독자에게 메시지를 전달하게 된다.
그러면 만약 위 그림과 같이 다른 채널을 구독한 구독자에게는 메시지가 갈까?
당연하겠지만, 발행자가 메시지를 보낸 채널을 구독하지 않은 유저에게는 메시지가 전달되지 않는다.
다들 카카오톡 채팅방들을 보통 생각하게 될텐데, pub/sub 아키텍쳐는 카카오톡과 같은 채팅방같은 서비스에 적합한 아키텍쳐이다.
또한 여타 채팅 서비스와 마찬가지로, 발행자가 반드시 발행만 하는 것이 아니라 구독도 가능하고, 구독자도 발행이 가능하다.
핵심은 반드시 발행자는 채널 id를 지정해서 메시지를 전달해야 한다는 것이고, 그래야만 해당 id의 채널을 구독한 구독자들에게 메시지가 전달 될 것이다.
또한 위에서는 이해를 위해 단순히 2번방 발행, 구독으로 적어두었지만 실제로는 아래와 같은 url을 통해서 발행, 구독한다.
{
"message" : "반갑꼬리~!",
"toChannel" : 2
}
위 그림을 보면서 이야기 해보자.
각 채널에 맞는 큐가 서버에 존재하고, 발행자가 특정 채널에 메시지를 발행을 하면 서버는 해당 채널에 맞는 큐에 메시지를 전달하고, 최종적으로 해당 채널을 구독중인 구독자에게 메시지가 전달되게 된다.
자 길고 길었다. 이제 Stomp 프로토콜에 대해서 알아보자.
Stomp 프로토콜은 Simple-Text-Oriented-Messaging-Protocol의 약자이며,
pub/sub 아키텍쳐에서 메시지 브로커를 활용해서 쉽게 메시지를 주고 받을 수 있는 프로토콜이다.
또한 웹 소켓 위에 얹어서 함께 사용할 수 있는 하위(서브) 프로토콜이며, 이 방식은 스프링에서 사용하는 방식이다.
그러면 궁금한 것은 왜 웹 소켓 위에 얹어서 사용할까? 그냥 이전 게시글처럼 웹소켓만 사용해서 채팅을 구현하면 안되는걸까?
우리가 저번에 웹 소켓을 이용해서 개발을 했을 때는 서로 메시지를 주고 받을 때 날 것의 메시지가 전달되게 되어서, 주는 입장에서도 보내는 입장에서도 적절하게 파싱을 하는 과정이 반드시 필요했었다.
@Override // 메시지 전달
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
String payload = message.getPayload();
JSONObject jsonObject = new JSONObject(payload);
for (WebSocketSession s : sessions) {
s.sendMessage(new TextMessage(jsonObject.getString("data")));
}
}
그 이유로는 Stomp 프로토콜은 커맨드, 헤더, 바디로 이루어진 프레임 단위를 정의해두었기 때문에, 이와 같은 일들이 가능한 것이다.
아래는 Stomp 프로토콜의 프레임이다.
COMMAND
header1:value1
header2:value2
Body^@
또한 Stomp는 헤더값을 통해서 통신시 인증 처리를 할 수 있는데, 클라이언트는 SEND, SUBSCRIBE 명령을 통해서 메시지의 내용과 수신 대상을 설명하는 “destination” 헤더와 함께 메시지에 대한 전송이나 채널 구독을 할 수 있고, 브로커를 통해 연결된 다른 클라이언트로 메시지를 보내는 등을 통해서 pub/sub 아키텍쳐를 가능케 한다.
아래는 Stomp 프로토콜이다.
SEND
destination:/queue/a
receipt:message-12345
hello queue a^@
보시면 위의 설명처럼 destination 헤더에서 수신 대상을 설명하는 것을 볼 수 있다.
먼저 핵심적인 Stomp 설정 코드들 부터 알아보자.
package com.example.demo.socket;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.*;
@Configuration
@EnableWebSocketMessageBroker // Stomp 프로토콜을 사용하도록 정의
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) { // STOMP에서 사용하는 메시지 브로커를 설정하는 메소드이다.
registry.enableSimpleBroker("/queue", "/topic"); // 메세지를 받을 때, 경로를 설정해주는 함수이다.
registry.setApplicationDestinationPrefixes("/app"); // 메시지 보낼 때 관련 경로 설정이다.
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws/chat")
.setAllowedOriginPatterns("*")
.withSockJS();
}
}
위 코드들을 먼저 하나씩 살펴보자
configureMessageBroker()
→ 해당 메소드는 Stomp 사용을 위한 Message Broker 설정을 해주는 메소드이다.
.enableSimpleBroker()
→ 메시지 받을 때 관련 경로 설정, prefix(api 경로 맨 앞)에 붙은 경우, messageBroker가 잡아서 해당 채팅방을 구독하고 있는 클라이언트에게 메시지를 전달해줌registerStompEndpoints()
→ 메시지 보낼 때 관련 경로 설정registerStompEndpoints()
→ Client에서 websocket연결할 때 사용할 API 경로를 설정해주는 메서드.
아래 사진은 스프링 내부에서 실제로 어떤 객체를 거쳐서 메시지가 발행자에서 구독자에게 전달되는지에 대한 사진이다.
@MessageMapping
등 client의 SEND
를 받아서 처리한다.WebSocketMessageBrokerConfigurer
를 통해 interceptor, taskExecutor를 설정할 수 있다.WebSocketMessageBrokerConfigurer
를 통해 interceptor, taskExecutor를 설정할 수 있다.SimpAnnotationMetho
는 SimpleBroker
의 존재를 직접 알지 못해도 메시지를 전달할 수 있다(결합도를 낮춤)채팅에 관한 코드를 보며, 실제 해당 코드가 어떤 Flow로 실행이 되는지 살펴보자.
@RestController
@RequiredArgsConstructor
public class MessageController {
private final SimpMessageSendingOperations sendingOperations;
@MessageMapping("/chat/message")
public void enter(ChatMessage message) {
// 만약 message의 Type이 Enter가 아니면 메시지를 받은대로 매핑시켜준다.
if (ChatMessage.MessageType.ENTER.equals(message.getType())) {
message.setMessage(message.getSender()+"님이 입장하였습니다.");
}
sendingOperations.convertAndSend("/topic/chat/room/" + message.getRoomId() ,message);
}
}
Flow는 아래와 같다.
유저가 채팅방(채널)을 생성 → 이 때까지는 아직 채팅방 채널을 구독한 것은 아님
유저가 채팅방에서 처음으로 메시지를 입력하면, 프론트엔드는 해당 메세지를 백엔드에 전달하는데, 메세지 타입을 ENTER, Sender로 본인, roomId로 해당 채팅방 번호를 전달한다. → 이 때 채팅방 채널을 구독한다.
function connect() {
// pub/sub event
ws.connect({}, function(frame) {
ws.subscribe("/topic/chat/room/"+vm.$data.roomId, function(message) {
var recv = JSON.parse(message.body);
vm.recvMessage(recv);
});
ws.send("/app/chat/message", {}, JSON.stringify({type:'ENTER', roomId:vm.$data.roomId, sender:vm.$data.sender}));
}, function(error) {
if(reconnect++ <= 5) {
setTimeout(function() {
console.log("connection reconnect");
sock = new SockJS("/ws/chat");
ws = Stomp.over(sock);
connect();
},10*1000);
}
});
}
해당 메시지에 존재하는 RoomId 즉 채널로 topic/chat/room/roomId 로 요청을 함으로써 브로커를 통해 해당 채널을 구독 중인 User들에게 입장 메시지를 보낸다.
@MessageMapping()
의 경로가 "/chat/message"이지만 ChatConfig.
setApplicationDestinationPrefixes()
를 통해 prefix를 "/app"으로 해줬기 때문에
실질 경로는 "/app/chat/message"가 됨
클라이언트에서 "/app/chat/message"의 경로로 메시지를 보내는 요청을 하면,
메시지 Controller가 받아서 "topic/chat/room/{roomId}"를 구독하고 있는 클라이언트에게 메시지를 전달하게 됨.
@MessageMapping()
→ 클라이언트가 보낸 메시지를 서버가 매핑할 때 url에 적절한 메서드를 매핑시키기 위한 깃발