[Spring] Redis PUB/SUB + WebSocket을 이용한 채팅 서버 구현하기 - 간단편

김강욱·2024년 5월 14일
5

Project-DoggyWalky

목록 보기
1/5
post-thumbnail

이번 포스팅에서는 DoggyWalky 프로젝트에서의 채팅 기능을 구현해보도록 하겠습니다.

😁 아키텍쳐 구성

DoggyWalky 프로젝트의 채팅 관련 아키텍처 설계는 다음과 같습니다.

Main ServerChat Server를 분리하고 중간에 Redis PUB/SUB Model을 통해 통신을 하도록 설계하였습니다.

🙌 다음과 같이 서버를 구성한 이유는?

이번 프로젝트에서의 목표 중 하나가 바로 이벤트 브로커 또는 메시지 브로커를 사용하여 MSA 환경을 간접 경험 해보기였습니다.

Redis PUB/SUB System은 분산 환경에서 메시지 기반 통신을 가능하게 하는 메시지 브로커 기능을 간단하게 구현할 수 있으므로 채택하게 되었습니다.

또한 부하 분산 및 확장성을 고려하여 Main Server와 여러 개의 Chat Server를 따로 분리하였습니다.

채팅 기능을 위해 WebSocket을 사용하였는데 WebSocket 특성상 사용자는 WebSocket 연결을 계속 유지하고 있어야합니다.

이에 하나의 서버에서 여러가지 로직과 채팅 로직을 모두 처리하지 않고 Chat Server를 따로 만들어 WebSocket 연결을 전담하도록 구현함으로써 WebSocket 연결 유지와 관련된 부하를 분산 처리하는 데 효율적이라 판단하였습니다.

WebSocket 연결 유지 뿐만 아니라 채팅 관련 작업을 Chat Server에서 처리해주기 때문에 Main Server에서는 다른 로직에 집중할 수 있어 서버의 부하를 감소시키는 효과도 고려하였습니다.

마지막으로 각 서버가 특정 역할에 집중함으로써 시스템의 복잡성 관리 및 개발의 유지 보수를 독립적으로 관리할 수 있기 때문에 시스템 안정성 및 유지보수성을 향상시킬 수 있다 판단하였습니다.


😁 채팅 기능 구현해보기

이제 채팅 기능을 구현해보도록 하겠습니다. 먼저 Main ServerChat Server가 각각 역할이 나눠져 있기 때문에 따로 살펴보도록 하겠습니다.

1. Main Server

Main Server는 채팅방 개설, 채팅방 나가기, 채팅방 안보이게 설정 3가지의 로직을 처리하고 있습니다. 해당 기능들을 Main Server에서 직접 처리하지 않고 Redis PUB/SUB 모델을 이용하여 Chat Server에게 해당 요청을 양도하는 목적으로 사용하고 있습니다.

Main ServerChat Server는 다음과 같은 흐름으로 통신하게 됩니다.

1. 사용자 요청

사용자가 게시글 작성자에게 1:1 문의하기 버튼을 눌러 채팅방 생성을 요청하게 되면 Main Server에서는 ChatController를 통해 해당 요청을 받게 됩니다.

2. Redis Publish

Main ServerchatRoom이라는 TopiccreateRoom이라는 명령을 의미하는 메시지를 Redispublish하게 됩니다.

chatRoom Topicsubscribe하고 있는 ChatServer에서는 createRoom 메시지를 수신하여 로직을 처리할 수 있게 됩니다.

3. DB INSERT

createRoom 메시지를 수신한 ChatServer는 메시지 안에 게시글 작성자, 채팅 문의한 사용자 등의 정보를 해독하고 해당 정보를 바탕으로 DB에 INSERT 쿼리를 보내게 됩니다. 실질적으로 채팅 관련 데이터베이스와의 커넥션은 이 과정에서 이루어집니다.

4. 에러 메시지 전송

만약 위의 3번 과정에서 에러가 발생한 경우 ChatServer와 사용자 간의 연결된 WebSocket 통로를 이용하여 에러 메시지를 전송하게 됩니다.

Main Server가 전송한 메시지에 1:1 문의 신청을 한 사용자의 정보를 이용하여 해당 사용자가 구독한 WebSocket 주소에 에러메시지를 전송하게 됩니다.

흐름을 정리해봤으니 이제 실제 코드를 보도록 하겠습니다.

📝 Redis 설정 파일 추가

@Configuration
public class RestConfig {

    /**
     * 단일 Topic 사용을 위한 Bean 설정
     */
    @Bean
    public ChannelTopic channelTopic() {
        return new ChannelTopic("chatRoom");
    }

    @Bean
    public RestTemplate restTemplate(RestTemplateBuilder restTemplateBuilder) {
        return restTemplateBuilder
                .requestFactory(() -> new BufferingClientHttpRequestFactory(new SimpleClientHttpRequestFactory()))
                .additionalMessageConverters(new StringHttpMessageConverter(Charset.forName("UTF-8")))
                .build();
    }

    /**
     * 어플리케이션에서 사용할 redisTemplate 설정
     */
    @Bean
    public RedisTemplate<String, Object> redisPubTemplate(RedisConnectionFactory connectionFactory) {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(connectionFactory);
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(String.class));
        return redisTemplate;
    }
}

해당 설정 파일에서 RedisPubTemplate를 스프링 빈으로 등록해주고 있습니다.

RedisConnectionFactory를 통해 RedisTemplateRedis 서버와 연결될 수 있도록 설정합니다. 이를 통해 RedisTemplate이 데이터를 Redis에 쓰고 읽을 때 사용하는 기본적인 네트워크 연결을 제공합니다.

redisTemplate.setKeySerializer(new StringRedisSerializer()) 설정은 Redis에서 키를 저장하고 검색할 때 사용되는 직렬화 방법을 정의하고 있습니다. StringRedisSerializer는 키를 UTF-8 인코딩의 문자열로 직렬화하고 역직렬화합니다.

redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(String.class)) 설정은 Redis에 저장될 값의 직렬화 방법을 정의합니다. Jackson2JsonRedisSerializer는 객체를 JSON 형태로 직렬화하여 저장하며, String.class로 지정되어 있어 문자열 데이터가 JSON 형태로 직렬화됩니다.

그리고 chatRoom 이름의ChannelTopic을 생성하여 스프링 빈으로 등록하였습니다.


📝 ChatController

@RestController
@RequiredArgsConstructor
public class ChatController {



    private final ChannelTopic topic;
    private final RedisTemplate redisPubTemplate;

    @PostMapping("/contact")
    public ResponseEntity contact(@RequestBody ContactRequestDto contactDto, Principal principal) {
        Long senderId = Long.parseLong(principal.getName());

        redisPubTemplate.convertAndSend(topic.getTopic(), ChatRoomMessage.createChatRoom(senderId,contactDto.getReceiverId(),contactDto.getJobPostId()));
        return new ResponseEntity(HttpStatus.OK);
    }

    @PostMapping("/unvisible")
    public ResponseEntity unvisible(@RequestBody UnvisibleRequestDto unvisibleDto, Principal principal) {
        Long senderId = Long.parseLong(principal.getName());

        redisPubTemplate.convertAndSend(topic.getTopic(), ChatRoomMessage.unvisibleChatRoom(senderId, unvisibleDto.getReceiverId(), unvisibleDto.getChatRoomId()));
        return new ResponseEntity(HttpStatus.OK);
    }

    @PostMapping("/quit")
    public ResponseEntity quit(@RequestBody QuitRequestDto quitDto, Principal principal) {
        Long senderId = Long.parseLong(principal.getName());

        redisPubTemplate.convertAndSend(topic.getTopic(), ChatRoomMessage.quitChatRoom(senderId, quitDto.getReceiverId(), quitDto.getChatRoomId()));
        return new ResponseEntity(HttpStatus.OK);
    }
}

채팅방 개설, 채팅방 안보이게 설정, 채팅방 나가기의 요청을 각각 /contact , /unvisible, /quit 메서드에서 처리하고 있습니다.

RedisPubTemplate을 통해 chatRoom 이름의 Topic에 메시지를 전송하게 됩니다.

📝 ChatRoomMessage(PUB 시 전달할 메시지 형식)

@Getter
@Builder
public class ChatRoomMessage {

    private enum Type {
        UNVISIBLE, QUIT, CREATE
    }

    private Long senderId;
    private Long receiverId;
    private Long chatRoomId;
    private Long jobPostId;
    private Type type;

    public static ChatRoomMessage createChatRoom(Long senderId, Long receiverId,Long jobPostId) {
        return ChatRoomMessage
                .builder()
                .chatRoomId(null)
                .senderId(senderId)
                .receiverId(receiverId)
                .jobPostId(jobPostId)
                .type(Type.CREATE)
                .build();
    }

    public static ChatRoomMessage quitChatRoom(Long senderId, Long receiverId, Long chatRoomId) {
        return ChatRoomMessage
                .builder()
                .chatRoomId(chatRoomId)
                .senderId(senderId)
                .receiverId(receiverId)
                .jobPostId(null)
                .type(Type.QUIT)
                .build();
    }

    public static ChatRoomMessage unvisibleChatRoom(Long senderId, Long receiverId, Long chatRoomId) {
        return ChatRoomMessage
                .builder()
                .chatRoomId(chatRoomId)
                .senderId(senderId)
                .receiverId(receiverId)
                .jobPostId(null)
                .type(Type.UNVISIBLE)
                .build();
    }

}

메시지 타입은 CREATE, UNVISIBLE, QUIT 세 가지 타입으로 구분되고 있으며 ChatServer에서 메시지를 수신하여 해당 세 가지 타입 구분에 따라 다른 분기 로직을 처리하게 됩니다.

그 외에 채팅방 생성에 필요한 정보(게시글 작성자, 1:1 문의하기 신청자, 게시글 ID, 채팅방 ID)를 포함하고 있습니다.

요약 정리

Main Server는 채팅방 생성, 안보이게 설정, 나가기 세 가지 타입의 요청을 받을 시 각 요청에 대해 알맞은 타입의 메시지를 생성하여 Redis에 발행하면 Chat Server에 해당 메시지를 수신하여 대신 처리하게 됩니다.


2. Chat Server

이번에는 Chat Server에 대해 알아보도록 하겠습니다. Chat Server의 역할은 크게 두가지가 있습니다.

  1. Main Server가 양도한 채팅 관련 로직 처리하기
  2. 사용자 간 실시간 채팅 로직 처리하기

Redis PUB/SUB 모델을 활용하여 위의 로직을 처리하게 되는데, 첫 번째 로직에 대해서는 Main Server에서 설명하였습니다.

이번에는 두 번째 로직에 대해서 자세히 알아보도록 하겠습니다. 채팅 전송 시의 흐름을 살펴보도록 하겠습니다.

1. 사용자 채팅 메시지 전송

사용자는 채팅 모달창을 열때 WebSocket 연결 요청을 보내게 됩니다. 이후 채팅방에 접속하여 채팅을 입력하고 전송하기를 눌렀을 때 /pub/chat/message API를 호출하게 됩니다.

ChatServer에서 요청을 수신받고 채팅 메시지를 전달받아 유효성 검사(회원 존재 여부, 상대방의 채팅방 나가기 여부, 상대방의 채팅방 접속 여부 등등), 메시지 저장, 마지막 메시지 수정 등의 로직 수행을 하게 됩니다.

2. 채팅 메시지 관련 데이터 처리

위에서의 채팅 메시지 관련 로직 수행에 필요한 데이터 처리를 하는 과정입니다.

3. Redis PUB으로 채팅 메시지 발신

채팅 메시지 관련 데이터 처리가 완료된 후 RedischatMessage 이름의 Topicpublish하는 과정입니다.

다중 채팅 서버 간의 데이터 공유를 위해 Redis PUB/SUB 모델을 사용합니다.

예를 들어, 사용자 C는 채팅 서버 A와 웹소켓 연결이 되어있는 상태이고 사용자 D는 채팅 서버 B와 웹소켓 연결이 되어있는 상태이고 사용자 C와 D가 서로 채팅을 전송한다고 가정해봅시다.

사용자 C가 채팅을 전송했을 시 채팅 서버 A에는 채팅 메시지가 전달될 것입니다. 하지만 채팅 서버 B는 이를 인지하지 못합니다. 서버가 독립적이기 때문에 채팅 서버 A가 채팅 메시지가 전달되었다는 사실을 채팅 서버 B에게 따로 알려줘야 합니다.

채팅 서버 A가 서버 B에게 해당 이벤트를 전달하는 역할을 하는 것이 바로 Redis PUB/SUB 모델입니다.

4. 다중 채팅 서버에서 Redis SUB으로 채팅 메시지 수신

chatMessage 이름의 Topic을 구독(subscribe)하고 있는 ChatServer에서 채팅 메시지를 수신하게 됩니다.

이번 프로젝트에서는 다중 채팅 서버가 모두 chatMessage 이름의 Topic을 구독하도록 구현하였습니다. 이러한 구조를 통해 해당 채팅 서버들 중 채팅 메시지 수신인과 WebSocket 통신이 연결된 채팅 서버에서 채팅 메시지를 발신해줄 수 있습니다.

5. 채팅 서버에서 메시지 수신인에게 채팅 메시지 발신

채팅 서버에서 채팅 메시지를 Redis로부터 수신한 후 채팅 메시지 수신인이 구독한 주소에 메시지를 전달해주는 과정입니다.

해당 /sub/chat/room/{roomId} 웹소켓 주소를 구독한 사용자에게 채팅 메시지를 수신해주면 실시간 채팅이 완료됩니다.

흐름을 정리해봤으니 실제 코드를 살펴보도록 하겠습니다.


📝 Redis 설정 파일

@Configuration
public class RedisConfig {


    /**
     * 채팅 내용을 전달하기 위한 채널
     */
    @Bean
    public ChannelTopic chatMessageTopic() {
        return new ChannelTopic("chatMessage");
    }

    /**
     * 채팅방에 대한 명령을 전달하기 위한 채널
     */
    @Bean
    public ChannelTopic chatRoomTopic() {
        return new ChannelTopic("chatRoom");
    }

    /**
     * redis에 발행(publish)된 메시지 처리를 위한 리스너 설정
     */
    @Bean
    public RedisMessageListenerContainer redisMessageListener(RedisConnectionFactory connectionFactory,
                                                              MessageListenerAdapter chatRoomListenerAdapter,
                                                              MessageListenerAdapter chatMessageListenerAdapter,
                                                              ChannelTopic chatMessageTopic,
                                                              ChannelTopic chatRoomTopic) {
        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
        container.setConnectionFactory(connectionFactory);
        container.addMessageListener(chatMessageListenerAdapter, chatMessageTopic);
        container.addMessageListener(chatRoomListenerAdapter, chatRoomTopic);
        return container;
    }

    /**
     * Redis에 발행되는 실제 메시지(채팅방 생성)를 처리하는 subscriber 설정 추가
     */
    @Bean
    public MessageListenerAdapter chatRoomListenerAdapter(ChatRoomSubscriber subscriber) {
        return new MessageListenerAdapter(subscriber, "sendMessage");
    }

    /**
     * Redis에 발행되는 실제 메시지(채팅 메시지)를 처리하는 subscriber 설정 추가
     */
    @Bean
    public MessageListenerAdapter chatMessageListenerAdapter(ChatMessageSubscriber subscriber) {
        return new MessageListenerAdapter(subscriber, "sendMessage");
    }

    /**
     * 어플리케이션에서 사용할 redisTemplate 설정
     */
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(connectionFactory);
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(String.class));
        return redisTemplate;
    }
}

RedisConfig 파일에서는 Redis SUB 관련 설정들을 추가해줬습니다.

Main Server로부터 양도받은 채팅방 관련 명령을 수신 받기 위한 chatRoom라는 Topic과 실시간 채팅 내용을 수신 받기 위한 chatMessage라는 Topic 두 가지를 스프링 빈으로 설정합니다.

실제 RedisTopic에 메시지가 발행될 시 Subscriber가 해당 메시지를 받아 처리하게 되는데 각각 Topic에 수신 받는 내용을 알맞게 처리해주기 위한 ChatRoomSubscriberChatMessageSubscriberRedisMessageListenerContainer 설정을 통해 등록해줍니다.


📝 WebSocket 설정 파일

@RequiredArgsConstructor
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    private final StompHandler stompHandler;

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.enableSimpleBroker("/sub");
        registry.setApplicationDestinationPrefixes("/pub");
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        System.out.println("WebSocket endpoint registered");
        registry.addEndpoint("/ws-stomp").setAllowedOriginPatterns("*").addInterceptors(new HttpHandshakeInterceptor()).withSockJS();
    }

    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        registration.interceptors(stompHandler);
    }
}

WebSocketConfig 파일은 STOMP 메시징 프로토콜을 활용하여 클라이언트와 서버 간의 WebSocket 통신을 정의하고 있습니다.

/ws-stompWebSocket endpoint로 등록하여 통신을 연결하게 됩니다.

MessageBroker 설정을 통해 /pub, /subprefix로 설정하여 /pub/~~를 통해 메시지를 전달하고 /sub/~~를 통해 메시지를 수신하게 됩니다.

예를 들어,

ws.send("/pub/chat/message",{"Authorization":getCookie("Authorization")},JSON.stringify(message))

ws.subscribe(`/sub/chatRoom/renew/${getCookie("memberId")}`, (message) => {
            console.log(message);
            const newMessage = JSON.parse(message.body);
            console.log(newMessage);
            setRenewRoom(prevState => !prevState)
        },{"Authorization" : getCookie("Authorization")})

해당 프론트 코드에서 메시지를 발송할 때 /pub/~~ 구조로 보내주고 /sub/~~ 형태의 주소를 subscribe함으로써 메시지를 수신할 수 있게 됩니다.

그 외에 클라이언트로부터 들어오는 메시지를 처리하기 위한 채널에서 StompHandler 인터셉터를 등록합니다. 이를 통해 메시지가 브로커로 전달되기 전에 처리될 수 있습니다.


📝 Subscriber 설정

1. ChatMessageSubscriber
@Slf4j
@RequiredArgsConstructor
@Component
public class ChatMessageSubscriber {

    private final ObjectMapper objectMapper;
    private final SimpMessageSendingOperations messagingTemplate;


    /**
     * Redis에서 메시지가 발행(publish)되면 대기하고 있던 RedisSubscriber가 해당 메시지를 받아 처리함
     */
    public void sendMessage(String publishMessage) {
        try {
            //RedisChatMessage 객체로 매핑
            RedisChatMessage redisChatMessage = objectMapper.readValue(publishMessage, RedisChatMessage.class);

            // 채팅방을 구독한 클라이언트에게 메시지 발송(Redis의 토픽에 메시지 발행 후 작업)
            messagingTemplate.convertAndSend("/sub/chat/room/"+redisChatMessage.getRoomId(), redisChatMessage.getChatMessageResponse());

            // 채팅 상대 및 본인의 채팅방 목록에게 renew 전달하기
            messagingTemplate.convertAndSend("/sub/chatRoom/renew/"+redisChatMessage.getReceiverId(),redisChatMessage);
            messagingTemplate.convertAndSend("/sub/chatRoom/renew/"+redisChatMessage.getChatMessageResponse().getMemberId(),redisChatMessage);
        } catch (Exception e) {
            log.error("Exception {}", e);
        }
    }
}

ChatMessageSubscriberchatMessage 이름의 Topic에 메시지가 발행됐을 때 sendMessage 메서드를 호출하여 메시지를 수신하게 됩니다.

수신 받는 메시지를 역직렬화하고 메시지 내 정보를 이용하여 WebSocket 통신으로 /sub/chat/room/{roomId}를 구독한 클라이언트에게 채팅 메시지를 전달해주는 역할을 하고 있습니다.


2. ChatRoomSubscriber
@Slf4j
@RequiredArgsConstructor
@Component
public class ChatRoomSubscriber {

    private final ObjectMapper objectMapper;

    private final ChatService chatService;

    private final SimpMessageSendingOperations messagingTemplate;

    /**
     * Redis에서 메시지가 발행(publish)되면 대기하고 있던 RedisSubscriber가 해당 메시지를 받아 처리함
     */
    public void sendMessage(String publishMessage) {
        ChatRoomMessage chatRoomMessage = null;
        try {
            //ChatMessage 객체로 매핑
            chatRoomMessage = objectMapper.readValue(publishMessage, ChatRoomMessage.class);
            System.out.println("chatRoom Message 도착");
            System.out.println(chatRoomMessage.toString());

            // 타입(QUIT, UNVISIBLE, CREATE)에 따른 처리
            if (chatRoomMessage.getType() == ChatRoomMessage.Type.CREATE) {
                // 채팅방 생성 로직(채팅 생성 로직 포함)
                ChatMessage message = chatService.createChatRoom(chatRoomMessage);

                // 채팅방을 구독한 클라이언트에게 메시지 발송(Redis의 토픽에 메시지 발행 후 작업)
                // 채팅 전송 로직
                messagingTemplate.convertAndSend("/sub/chatRoom/renew/"+chatRoomMessage.getReceiverId(),message);
                messagingTemplate.convertAndSend("/sub/chatRoom/renew/"+chatRoomMessage.getSenderId(),message);
            } else if (chatRoomMessage.getType() == ChatRoomMessage.Type.UNVISIBLE) {
                // 채팅방 안보이게 설정
                chatService.unvisibleChatRoom(chatRoomMessage);

                // 채팅방을 구독한 클라이언트에게 메시지 발송
                messagingTemplate.convertAndSend("/sub/chatRoom/renew/"+chatRoomMessage.getSenderId(),new ChatStatusResponse(ResponseCode.UNVISIBLE_COMPLETED));
            } else if (chatRoomMessage.getType() == ChatRoomMessage.Type.QUIT) {
                // 채팅방 나가도록 설정
                ChatMessageResponse chatMessageResponse = chatService.quitChatRoom(chatRoomMessage);

                if (chatMessageResponse != null) {
                    // 채팅방 자체에 나가기 메시지 전송
                    messagingTemplate.convertAndSend("/sub/chat/room/"+chatRoomMessage.getChatRoomId(),chatMessageResponse);
                }

                // 채팅방을 구독한 클라이언트에게 메시지 발송
                messagingTemplate.convertAndSend("/sub/chatRoom/renew/"+chatRoomMessage.getSenderId(),new ChatStatusResponse(ResponseCode.QUIT_COMPLETED));
                messagingTemplate.convertAndSend("/sub/chatRoom/renew/"+chatRoomMessage.getReceiverId(),new ChatStatusResponse(ResponseCode.QUIT_COMPLETED));
            }


        } catch (ApplicationException e) {
            // TODO: 예외 발생 시 해당 구독자(클라이언트)에게 예외 메시지 보내기 구현
            log.error("Exception {}", e);
            messagingTemplate.convertAndSend("/sub/errorMessage/" +chatRoomMessage.getSenderId(), new ChatStatusResponse(e.getErrorCode()));
        } catch (Exception e) {
            log.error("Exception {}", e);
            messagingTemplate.convertAndSend("/sub/errorMessage/" +chatRoomMessage.getSenderId(), new ChatStatusResponse(ErrorCode.INTERNAL_SERVER_ERROR));
        }
    }
}

ChatRoomSubscriberchatRoom 이름의 Topic에 메시지가 발행됐을 때 sendMessage 메서드를 호출하여 메시지를 수신하게 됩니다.

수신 받는 메시지를 역직렬화하고 메시지 내의 정보 중 이벤트 타입(QUIT, UNVISIBLE, CREATE)에 따른 분기 처리를 하게 됩니다.

채팅방 생성 시에 채팅방 목록을 갱신할 수 있도록 WebSocket 통신을 이용하여 /sub/chatRoom/renew/{receiverId}, /sub/chatRoom/renew/{senderId}를 구독하고 있는 양측 클라이언트에게 메시지를 전달합니다.

채팅방 안보이게 설정 시에 채팅방 목록을 갱신할 수 있도록 WebSocket 통신을 이용하여 /sub/chatRoom/renew/{senderId}를 구독하고 있는 클라이언트(안보이게 설정한 사용자 본인)에게 메시지를 전달합니다. 메시지를 받은 사용자는 프론트에서 채팅방 목록에서 해당 채팅방이 삭제되도록 처리하게 됩니다.

채팅방을 나갈 시 상대방이 나갔다는 메시지를 /sub/chat/room/{roomId}를 구독하고 있는 채팅 수신인께 전달하고 채팅방 목록을 갱신할 수 있도록 WebSocket 통신을 이용하여 /sub/chatRoom/renew/{receiverId}, /sub/chatRoom/renew/{senderId}를 구독하고 있는 양측 클라이언트에게 메시지를 전달합니다.

ChatService를 의존성 주입 받아 채팅 관련 데이터 처리를 맡깁니다.


📝 ChatController

@RequiredArgsConstructor
@RestController
@Slf4j
public class ChatController {

    private final ChatService chatService;

    private final SimpMessageSendingOperations messagingTemplate;

    /**
     * websocket "/pub/chat/message"로 들어오는 메시징을 처리한다.
     */
    @MessageMapping("/chat/message")
    public void message(ChatMessage message) {
        log.info("메시지 도착 : {}", message.toString());
        // Websocket에 발행된 메시지를 redis로 발행(publish)
        try {
            chatService.sendChatMessage(message);
        } catch (ApplicationException e) {
            messagingTemplate.convertAndSend("/sub/errorMessage/"+message.getMemberId(), new ChatStatusResponse(e.getErrorCode()));
        }
    }

    /**
     * websocket "/pub/chat/modifyConnectionStatus"로 들어오는 메시징을 처리한다.
     */
    @MessageMapping("/chat/modifyConnectionStatus")
    public void modifyConnectionStatus(ConnectionStatusMessage message) {
        log.info("메시지 도착 : {}", message.toString());
        chatService.modifyConnectionStatus(message);
    }

    /**
     * 채팅방 목록 조회
     */
    @GetMapping("/chat/rooms")
    public ResponseEntity<List<ChatRoomResponse>> getRoomList(Principal principal) {
        Long memberId = Long.parseLong(principal.getName());

        List<ChatRoomResponse> roomList = chatService.getRoomList(memberId);
        return new ResponseEntity<>(roomList,HttpStatus.OK);
    }

    /**
     * 채팅 목록 조회
     */
    // TODO: 채팅 읽음 로직 추가해야한다 또한 채팅방에 있을 시 클라이언트 화면 상에서 채팅 읽음 로직 어떻게 구성할지 생각
    // TODO: 상대가 채팅방을 보고 있을 시 채팅 치면 채팅 읽음으로 수정이 되어야함
    @GetMapping("/chat/{roomId}")
    public ResponseEntity<List<ChatMessageResponse>> getChatMessages(@PathVariable("roomId") Long roomId,
                                                                     Principal principal) {
        Long memberId = Long.parseLong(principal.getName());
        List<ChatMessageResponse> chatMessages = chatService.getChatMessages(roomId, memberId);
        return new ResponseEntity<>(chatMessages, HttpStatus.OK);
    }

    /**
     * 채팅 삭제
     */
    @DeleteMapping("/chat/{chatId}")
    public ResponseEntity deleteChat(@PathVariable("chatId") Long chatId,
                                     @RequestParam("opponentId") Long opponentId,
                                     Principal principal) {
        Long memberId = Long.parseLong(principal.getName());
        chatService.deleteChatMessage(memberId, opponentId,chatId);
        return new ResponseEntity(new SimpleChatMessageResponse(chatId),HttpStatus.NO_CONTENT);
    }

}

ChatController에서 message 메서드는 클라이언트가 WebSocket 통신으로 클라이언트가 메시지를 발행할 때 호출됩니다. /pub/chat/message 주소로 전달해오는 메시지를 받아 ChatServicesendChatMessage 메서드를 호출하여 채팅 메시지 생성 및 상대방에게 채팅 메시지 전달 등의 로직을 수행하게 됩니다.

그 외에 나머지 로직들은 다음 상세편에서 다뤄보도록 하겠습니다.


📝 ChatService

@Slf4j
@RequiredArgsConstructor
@Service
@Transactional
public class ChatService {

    private final MemberRepository memberRepository;
    private final ChatRoomRepository chatRoomRepository;

    private final ChatRepository chatRepository;

    private final ChatRoomMembershipRepository chatRoomMembershipRepository;

    private final RedisTemplate redisTemplate;

    private final RedisService redisService;

    private final ChannelTopic chatMessageTopic;

    private final SimpMessageSendingOperations messagingTemplate;

    /**
     * 메시지 생성 및 메시지 전달
     */
    public void sendChatMessage(ChatMessage message) throws ApplicationException {

        // Todo: 예외 발생 시 어떻게 클라이언트에게 전달해줄 지 결정해야한다.
        if (message.getType() == ChatMessage.Type.TALK) {
            // 메시지 저장
            Member member = memberRepository.findByMemberId(message.getMemberId()).orElseThrow(
                    () -> new ApplicationException(ErrorCode.USER_NOT_FOUND));
            ChatRoom room = chatRoomRepository.findById(message.getRoomId()).orElseThrow(
                    () -> new ApplicationException(ErrorCode.ROOM_NOT_FOUND));


            // 상대 회원 pk 찾기
            Member opponentMember = chatRoomMembershipRepository.findOpponentId(room.getId(), message.getMemberId())
                    .orElseThrow(() -> new ApplicationException(ErrorCode.USER_NOT_FOUND));

            // 상대방이 채팅방을 나갔는지 확인해야 한다
            ChatRoomMembership opponentMembership = chatRoomMembershipRepository.findValidChatRoom(room.getId(), opponentMember.getId(), member.getId()).orElseThrow(() -> new ApplicationException(ErrorCode.OPPONENT_LEFT_OUT));

            Chat chat = null;
            // 상대방이 현재 채팅방에 접속했는지 확인 후 접속시엔 읽음처리
            String connectedRoomId= redisService.getChatUserRoomId(opponentMember.getId());
            if (connectedRoomId!=null && connectedRoomId.equals(message.getRoomId().toString())) {
                System.out.println("상대방이 현재 채팅방에 접속 중이다");
                System.out.println(redisService.getChatUserRoomId(opponentMember.getId()));
                chat = Chat.createTalkMessage(member,room, message.getMessage(), true);
            } else {
                chat = Chat.createTalkMessage(member,room, message.getMessage(), false);
            }
            chatRepository.save(chat);

            // 채팅방 수정(마지막 메시지 UPDATE)
            room.modifyLastMessage(chat.getId());

            // 채팅방 멤버십에서 상대 유저의 visible을 true로 전환
            opponentMembership.changeVisible(true);


            // 레디스 구독자들에게 메시지 publish
            redisTemplate.convertAndSend(chatMessageTopic.getTopic(), new RedisChatMessage(message.getRoomId(),opponentMember.getId(),new ChatMessageResponse(chat)));

        }

    }
 ...
}

ChatService는 채팅 관련 데이터를 주로 처리하며 RedischatMessage 이름의 Topic으로 채팅 메시지를 발행하는 역할을 도맡아 합니다.

sendChatMessage 메서드는 클라이언트가 채팅 메시지를 전송하면 상대방이 채팅 나갔는지를 확인 후 나가지 않았다면 정상적으로 해당 채팅을 DB에 저장하고 채팅 읽기, 채팅방 마지막 메시지 수정 등의 로직을 처리하고 수신한 채팅 메시지를 Redispublish하는 역할을 하고 있습니다.

그 외에 나머지 로직들은 다음 상세편에서 다뤄보도록 하겠습니다.

요약 정리

Chat Server는 Main Server에서 발행된 채팅방 관련 로직에 대한 명령을 Redis를 통해 subscribe하여 처리하고, 클라이언트와 WebSocket 통신을 하여 채팅 메시지를 실시간으로 전송하고 Redis PUB/SUB 모델을 활용하여 다중 채팅 서버 간의 통신을 수행합니다.

profile
TO BE DEVELOPER

1개의 댓글

comment-user-thumbnail
2024년 12월 2일

아리가또

답글 달기

관련 채용 정보