프로그래밍/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 코드

 

시나리오

  1. 채팅방 생성
    1. RedisMessageListenerContainer에 MessageListener를 Add 한다. [RedisMessageListener]
    2. Topic은 채팅방의 id ( 채팅방이 여러 개 생길 수 있으니 id로 구분한다 )
  2. 채팅방 입장
    1. 채팅방 입장 시 stompClient 연결 [socket.js]
    2. 채팅방 구독 ( 메시지를 수신하기 위해 Topic을 채팅방 id로 구분하였으니 /sub/chatting/rooms/{채팅방 id} 를 구독한다. [socket.js]
  3. 메시지 보내기
    1. stompClient.send('/pub/chatting/rooms/chat', {}, JSON.stringify(chatDto) 로 ChatDto를 보낸다. [chat.js]
    2. '/chattings/rooms/chat'로 메시지가 들어오면 메시지 브로커에게 전달한다. [MessageController]
    3. 메시지 브로커에 메시지가 들어오면 직렬화 하여 채팅방을 구독하고 있는 클라이언트에게 보낸다. [RedisSubscriber]
  4. 메시지 수신
    1. 메시지 브로커로 부터 메시지를 받은 클라이언트는 화면에 받은 메시지를 출력한다. [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));
}