이번 포스팅에서는 DoggyWalky
프로젝트에서의 채팅 기능을 구현해보도록 하겠습니다.
DoggyWalky
프로젝트의 채팅 관련 아키텍처 설계는 다음과 같습니다.
Main Server
와 Chat 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 Server
와 Chat Server
가 각각 역할이 나눠져 있기 때문에 따로 살펴보도록 하겠습니다.
Main Server
는 채팅방 개설, 채팅방 나가기, 채팅방 안보이게 설정 3가지의 로직을 처리하고 있습니다. 해당 기능들을 Main Server
에서 직접 처리하지 않고 Redis PUB/SUB 모델
을 이용하여 Chat Server
에게 해당 요청을 양도하는 목적으로 사용하고 있습니다.
Main Server
와 Chat Server
는 다음과 같은 흐름으로 통신하게 됩니다.
사용자가 게시글 작성자에게 1:1 문의하기 버튼을 눌러 채팅방 생성을 요청하게 되면 Main Server
에서는 ChatController
를 통해 해당 요청을 받게 됩니다.
Main Server
는 chatRoom
이라는 Topic
에 createRoom
이라는 명령을 의미하는 메시지를 Redis
에 publish
하게 됩니다.
chatRoom Topic
을 subscribe
하고 있는 ChatServer
에서는 createRoom
메시지를 수신하여 로직을 처리할 수 있게 됩니다.
createRoom
메시지를 수신한 ChatServer
는 메시지 안에 게시글 작성자, 채팅 문의한 사용자 등의 정보를 해독하고 해당 정보를 바탕으로 DB에 INSERT
쿼리를 보내게 됩니다. 실질적으로 채팅 관련 데이터베이스와의 커넥션은 이 과정에서 이루어집니다.
만약 위의 3번 과정에서 에러가 발생한 경우 ChatServer
와 사용자 간의 연결된 WebSocket
통로를 이용하여 에러 메시지를 전송하게 됩니다.
Main Server
가 전송한 메시지에 1:1 문의 신청을 한 사용자의 정보를 이용하여 해당 사용자가 구독한 WebSocket
주소에 에러메시지를 전송하게 됩니다.
흐름을 정리해봤으니 이제 실제 코드를 보도록 하겠습니다.
@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
를 통해 RedisTemplate
이 Redis
서버와 연결될 수 있도록 설정합니다. 이를 통해 RedisTemplate
이 데이터를 Redis
에 쓰고 읽을 때 사용하는 기본적인 네트워크 연결을 제공합니다.
redisTemplate.setKeySerializer(new StringRedisSerializer())
설정은 Redis
에서 키를 저장하고 검색할 때 사용되는 직렬화 방법을 정의하고 있습니다. StringRedisSerializer
는 키를 UTF-8
인코딩의 문자열로 직렬화하고 역직렬화합니다.
redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(String.class))
설정은 Redis
에 저장될 값의 직렬화 방법을 정의합니다. Jackson2JsonRedisSerializer
는 객체를 JSON
형태로 직렬화하여 저장하며, String.class
로 지정되어 있어 문자열 데이터가 JSON
형태로 직렬화됩니다.
그리고 chatRoom
이름의ChannelTopic
을 생성하여 스프링 빈으로 등록하였습니다.
@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
에 메시지를 전송하게 됩니다.
@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에 해당 메시지를 수신하여 대신 처리하게 됩니다.
이번에는 Chat Server
에 대해 알아보도록 하겠습니다. Chat Server
의 역할은 크게 두가지가 있습니다.
Redis PUB/SUB 모델
을 활용하여 위의 로직을 처리하게 되는데, 첫 번째 로직에 대해서는 Main Server
에서 설명하였습니다.
이번에는 두 번째 로직에 대해서 자세히 알아보도록 하겠습니다. 채팅 전송 시의 흐름을 살펴보도록 하겠습니다.
사용자는 채팅 모달창을 열때 WebSocket
연결 요청을 보내게 됩니다. 이후 채팅방에 접속하여 채팅을 입력하고 전송하기를 눌렀을 때 /pub/chat/message
API를 호출하게 됩니다.
ChatServer
에서 요청을 수신받고 채팅 메시지를 전달받아 유효성 검사(회원 존재 여부, 상대방의 채팅방 나가기 여부, 상대방의 채팅방 접속 여부 등등), 메시지 저장, 마지막 메시지 수정 등의 로직 수행을 하게 됩니다.
위에서의 채팅 메시지 관련 로직 수행에 필요한 데이터 처리를 하는 과정입니다.
채팅 메시지 관련 데이터 처리가 완료된 후 Redis
에 chatMessage
이름의 Topic
에 publish
하는 과정입니다.
다중 채팅 서버 간의 데이터 공유를 위해 Redis PUB/SUB 모델
을 사용합니다.
예를 들어, 사용자 C는 채팅 서버 A와 웹소켓 연결이 되어있는 상태이고 사용자 D는 채팅 서버 B와 웹소켓 연결이 되어있는 상태이고 사용자 C와 D가 서로 채팅을 전송한다고 가정해봅시다.
사용자 C가 채팅을 전송했을 시 채팅 서버 A에는 채팅 메시지가 전달될 것입니다. 하지만 채팅 서버 B는 이를 인지하지 못합니다. 서버가 독립적이기 때문에 채팅 서버 A가 채팅 메시지가 전달되었다는 사실을 채팅 서버 B에게 따로 알려줘야 합니다.
채팅 서버 A가 서버 B에게 해당 이벤트를 전달하는 역할을 하는 것이 바로 Redis PUB/SUB 모델
입니다.
chatMessage
이름의 Topic
을 구독(subscribe
)하고 있는 ChatServer
에서 채팅 메시지를 수신하게 됩니다.
이번 프로젝트에서는 다중 채팅 서버가 모두 chatMessage
이름의 Topic
을 구독하도록 구현하였습니다. 이러한 구조를 통해 해당 채팅 서버들 중 채팅 메시지 수신인과 WebSocket
통신이 연결된 채팅 서버에서 채팅 메시지를 발신해줄 수 있습니다.
채팅 서버에서 채팅 메시지를 Redis
로부터 수신한 후 채팅 메시지 수신인이 구독한 주소에 메시지를 전달해주는 과정입니다.
해당 /sub/chat/room/{roomId}
웹소켓 주소를 구독한 사용자에게 채팅 메시지를 수신해주면 실시간 채팅이 완료됩니다.
흐름을 정리해봤으니 실제 코드를 살펴보도록 하겠습니다.
@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
두 가지를 스프링 빈으로 설정합니다.
실제 Redis
의 Topic
에 메시지가 발행될 시 Subscriber
가 해당 메시지를 받아 처리하게 되는데 각각 Topic
에 수신 받는 내용을 알맞게 처리해주기 위한 ChatRoomSubscriber
와 ChatMessageSubscriber
를 RedisMessageListenerContainer
설정을 통해 등록해줍니다.
@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-stomp
를 WebSocket endpoint
로 등록하여 통신을 연결하게 됩니다.
MessageBroker
설정을 통해 /pub
, /sub
을 prefix
로 설정하여 /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
인터셉터를 등록합니다. 이를 통해 메시지가 브로커로 전달되기 전에 처리될 수 있습니다.
@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);
}
}
}
ChatMessageSubscriber
는 chatMessage
이름의 Topic
에 메시지가 발행됐을 때 sendMessage
메서드를 호출하여 메시지를 수신하게 됩니다.
수신 받는 메시지를 역직렬화하고 메시지 내 정보를 이용하여 WebSocket
통신으로 /sub/chat/room/{roomId}
를 구독한 클라이언트에게 채팅 메시지를 전달해주는 역할을 하고 있습니다.
@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));
}
}
}
ChatRoomSubscriber
는 chatRoom
이름의 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
를 의존성 주입 받아 채팅 관련 데이터 처리를 맡깁니다.
@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
주소로 전달해오는 메시지를 받아 ChatService
의 sendChatMessage
메서드를 호출하여 채팅 메시지 생성 및 상대방에게 채팅 메시지 전달 등의 로직을 수행하게 됩니다.
그 외에 나머지 로직들은 다음 상세편에서 다뤄보도록 하겠습니다.
@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
는 채팅 관련 데이터를 주로 처리하며 Redis
에 chatMessage
이름의 Topic
으로 채팅 메시지를 발행하는 역할을 도맡아 합니다.
sendChatMessage
메서드는 클라이언트가 채팅 메시지를 전송하면 상대방이 채팅 나갔는지를 확인 후 나가지 않았다면 정상적으로 해당 채팅을 DB에 저장하고 채팅 읽기, 채팅방 마지막 메시지 수정 등의 로직을 처리하고 수신한 채팅 메시지를 Redis
에 publish
하는 역할을 하고 있습니다.
그 외에 나머지 로직들은 다음 상세편에서 다뤄보도록 하겠습니다.
Chat Server는 Main Server에서 발행된 채팅방 관련 로직에 대한 명령을 Redis를 통해 subscribe하여 처리하고, 클라이언트와 WebSocket 통신을 하여 채팅 메시지를 실시간으로 전송하고 Redis PUB/SUB 모델을 활용하여 다중 채팅 서버 간의 통신을 수행합니다.
아리가또