프로그래밍/Spring
[Spring] 실시간 채팅 서버 구현 (STOMP, Redis Pub/Sub), 상세 시나리오
jeongseop
2024. 1. 17. 14:10
STOMP (Simple Text Oriented Messaging Protocol)
WebSocket을 기반으로 한 통신 프레임워크 입니다.
서버와 클라이언트간 양방향 통신을 지원하고, Pub/Sub 구조로 되어있어 실시간 채팅을 구현하기 용이합니다.
STOMP 메시지는 메시지 브로커를 통해 전송 됩니다. (Publisher -> Message Broker -> Subscriber)
Publisher가 특정 Topic에 메시지를 보내면 Message Broker는 그 Topic을 구독중인 Subscriber에게 메시지를 전달합니다.
메시지 브로커는 다양한데 Redis를 사용하도록 하겠습니다.
세팅
먼저 websocket과 redis를 사용하기 위해 build.gradle 프로퍼티를 정의하고
redis host와 port를 정의합니다.
build.gradle
implementation 'org.springframework.boot:spring-boot-starter-websocket'
implementation 'org.springframework.boot:spring-boot-starter-data-redis
application.properties
spring.redis.host=localhost
spring.redis.port=6379
구현 할 클래스 및 JavaScript
- WebSocketConfig : 메시지 브로커 설정
- RedisConfig : 레디스 설정
- ChatDto : 주고 받을 메시지 DTO
- RedisMessageListener : 메시지 리스너 컨테이너에 메시지 리스너를 채팅방 별로 add / remove
- JsonParser : Json 직렬화
- RedisSubscriber : 메시지 브로커에서 메시지를 수신하고 클라이언트에게 전달
- MessageController : 클라이언트가 메시지를 발행하면, 메시지 브로커에 발송
- socket.js : websocket 관련 js 코드
- chat.js : 채팅 메시지를 보내는 js 코드
시나리오
- 채팅방 생성
- RedisMessageListenerContainer에 MessageListener를 Add 한다. [RedisMessageListener]
- Topic은 채팅방의 id ( 채팅방이 여러 개 생길 수 있으니 id로 구분한다 )
- 채팅방 입장
- 채팅방 입장 시 stompClient 연결 [socket.js]
- 채팅방 구독 ( 메시지를 수신하기 위해 Topic을 채팅방 id로 구분하였으니 /sub/chatting/rooms/{채팅방 id} 를 구독한다. [socket.js]
- 메시지 보내기
- stompClient.send('/pub/chatting/rooms/chat', {}, JSON.stringify(chatDto) 로 ChatDto를 보낸다. [chat.js]
- '/chattings/rooms/chat'로 메시지가 들어오면 메시지 브로커에게 전달한다. [MessageController]
- 메시지 브로커에 메시지가 들어오면 직렬화 하여 채팅방을 구독하고 있는 클라이언트에게 보낸다. [RedisSubscriber]
- 메시지 수신
- 메시지 브로커로 부터 메시지를 받은 클라이언트는 화면에 받은 메시지를 출력한다. [socket.js]
ChatDto
@Getter
@NoArgsConstructor
public class ChatDto {
private String type;
private Long debateRoomId;
private String username;
private String message;
@Builder
public ChatDto(String type, Long debateRoomId, String username, String message){
this.type = type;
this.debateRoomId = debateRoomId;
this.username = username;
this.message = message;
}
}
RedisConfig
@Configuration
public class RedisConfig {
@Value("${spring.redis.host}")
private String host;
@Value("${spring.redis.port}")
private int port;
@Bean
public RedisMessageListenerContainer redisMessageListenerContainer(RedisConnectionFactory redisConnectionFactory,
MessageListenerAdapter messageListenerAdapter,
ChannelTopic channelTopic){
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(redisConnectionFactory);
container.addMessageListener(messageListenerAdapter, channelTopic);
return container;
}
@Bean
public MessageListenerAdapter messageListenerAdapter(RedisSubscriber redisSubscriber){
return new MessageListenerAdapter(redisSubscriber, "onMessage");
}
@Bean
public RedisConnectionFactory redisConnectionFactory(){
return new LettuceConnectionFactory(host, port);
}
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory){
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(String.class));
return redisTemplate;
}
@Bean
public ChannelTopic channelTopic() {
return new ChannelTopic("chatting");
}
}
RedisMessageListener
@RequiredArgsConstructor
@Component
public class RedisMessageListener {
private static final Map<Long, ChannelTopic> TOPICS = new HashMap<>();
private final RedisMessageListenerContainer redisMessageListenerContainer;
private final RedisSubscriber redisSubscriber;
public void enterChatRoom(Long debateRoomId){
ChannelTopic topic = getTopic(debateRoomId);
if (topic == null){
topic = new ChannelTopic(String.valueOf(debateRoomId));
redisMessageListenerContainer.addMessageListener(redisSubscriber, topic);
TOPICS.put(debateRoomId, topic);
}
}
public void deleteChatRoom(Long debateRoomId) {
redisMessageListenerContainer.removeMessageListener(redisSubscriber, getTopic(debateRoomId));
TOPICS.remove(debateRoomId);
}
public ChannelTopic getTopic(Long debateRoomId){
return TOPICS.get(debateRoomId);
}
}
JsonParser
@RequiredArgsConstructor
@Component
public class JsonParser {
private final ObjectMapper objectMapper;
public ChatDto toChatDto(String chattingMessage) {
try {
return objectMapper.readValue(chattingMessage, ChatDto.class);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
}
RedisSubscriber
@RequiredArgsConstructor
@Service
public class RedisSubscriber implements MessageListener {
private final SimpMessagingTemplate messagingTemplate;
private final RedisTemplate<String, Object> redisTemplate;
private final JsonParser jsonParser;
@Override
public void onMessage(Message message, byte[] pattern) {
String type = jsonParser.getType((String) redisTemplate.getStringSerializer().deserialize(message.getBody()));
if (type.equals("chat")) {
ChatDto chatDto = jsonParser.toChatDto((String) redisTemplate.getStringSerializer().deserialize(message.getBody()));
messagingTemplate.convertAndSend("/sub/chatting/rooms/" + chatDto.getDebateRoomId(), chatDto);
}
}
}
MessageController
@RequiredArgsConstructor
@Controller
public class MessageController {
private final RedisService redisService;
@MessageMapping("/chattings/rooms/chat")
public void chat(ChatDto chatDto){
redisService.chat(chatDto);
}
}
socket.js
let socket = new SockJS("/chattings");
let stompClient = Stomp.over(socket);
//socket 연결
stompClient.connect({}, function(frame) {
const debateRoomId = document.getElementById("debateroom-id").value;
// 채팅방 구독
stompClient.subscribe('/sub/chatting/rooms/' + debateRoomId, function (msg){
const body = JSON.parse(msg.body);
const type = body.type;
if (type == 'chat'){
onChatMessage(body.username, body.message);
}
});
});
// 메시지 받았을 때 화면에 출력
function onChatMessage(username, message) {
$("<div>").text(username + ' : ' + message).addClass('mb-2 debateroom__chat').appendTo("#chat-list");
$("#chat-list").scrollTop($("#chat-list")[0].scrollHeight);
}
chat.js
function chat(debateRoomId, username){
const form = document.getElementById("chat-form");
const formData = new FormData(form);
const chatDto = {
type: 'chat',
debateRoomId: debateRoomId,
username: username,
message: formData.get('message')
};
stompClient.send('/pub/chattings/rooms/chat', {}, JSON.stringify(chatDto));
}