Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[BE] fix: 외부 메시지 큐(RabbitMQ) 도입, 분산 환경 채팅 안 되던 버그 수정 #630

Merged
merged 10 commits into from
Oct 23, 2024
16 changes: 9 additions & 7 deletions backend/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-websocket'
implementation 'org.springframework.boot:spring-boot-starter-amqp' // rabbitmq
implementation 'org.springframework.boot:spring-boot-starter-reactor-netty' // rabbitmq
implementation 'io.jsonwebtoken:jjwt:0.12.6'
implementation 'io.micrometer:micrometer-registry-prometheus'
implementation "org.springframework.boot:spring-boot-starter-actuator"
Expand Down Expand Up @@ -91,12 +93,12 @@ tasks.withType(GenerateSwaggerUI) {
doFirst {
def swaggerUIFile = file("${openapi3.outputDirectory}/openapi3.yaml")

def securitySchemesContent = " securitySchemes:\n" + \
" APIKey:\n" + \
" type: apiKey\n" + \
" name: Authorization\n" + \
" in: header\n" + \
"security:\n" +
def securitySchemesContent = " securitySchemes:\n" + \
" APIKey:\n" + \
" type: apiKey\n" + \
" name: Authorization\n" + \
" in: header\n" + \
"security:\n" +
" - APIKey: [] # Apply the security scheme here"

swaggerUIFile.append securitySchemesContent
Expand Down Expand Up @@ -130,5 +132,5 @@ resolveMainClassName {

test {
systemProperty 'user.timezone', 'Asia/Seoul'

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.happy.friendogly.chat.config;

public interface ChatTemplate {

void convertAndSend(Long chatRoomId, Object payload);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.happy.friendogly.chat.config;

import org.springframework.context.annotation.Profile;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Component;

@Component
@Profile("local")
public class InMemoryChatTemplate implements ChatTemplate {

private static final String TOPIC_CHAT_PREFIX = "/exchange/chat.exchange/room.";

private final SimpMessagingTemplate template;

public InMemoryChatTemplate(SimpMessagingTemplate template) {
this.template = template;
}

@Override
public void convertAndSend(Long chatRoomId, Object payload) {
template.convertAndSend(TOPIC_CHAT_PREFIX + chatRoomId, payload);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.happy.friendogly.chat.config;

import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;

@Component
@Profile("!local")
public class RabbitChatTemplate implements ChatTemplate {

private final RabbitTemplate rabbitTemplate;

public RabbitChatTemplate(RabbitTemplate rabbitTemplate) {
this.rabbitTemplate = rabbitTemplate;
}

@Override
public void convertAndSend(Long chatRoomId, Object payload) {
rabbitTemplate.convertAndSend("chat.exchange", "room." + chatRoomId, payload);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,48 +16,35 @@
import org.springframework.messaging.handler.annotation.MessageExceptionHandler;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class ChatSocketController {

private final ChatCommandService chatCommandService;
private final ChatRoomQueryService chatRoomQueryService;
private final SimpMessagingTemplate template;

public ChatSocketController(
ChatCommandService chatCommandService,
ChatRoomQueryService chatRoomQueryService,
SimpMessagingTemplate template
ChatRoomQueryService chatRoomQueryService
) {
this.chatCommandService = chatCommandService;
this.chatRoomQueryService = chatRoomQueryService;
this.template = template;
}

@MessageMapping("/invite")
@MessageMapping("/invite") // TODO: 1대1 채팅방 구현 시 수정 필요
public void invite(
@WebSocketAuth Long senderMemberId,
@Payload InviteToChatRoomRequest request
) {
chatRoomQueryService.validateInvitation(senderMemberId, request);
template.convertAndSend(
"/topic/invite/" + request.receiverMemberId(),
new InviteToChatRoomResponse(request.chatRoomId())
);
}

@Deprecated
@MessageMapping("/enter/{chatRoomId}")
public void enter(
@WebSocketAuth Long memberId,
@DestinationVariable(value = "chatRoomId") Long chatRoomId
) {
chatCommandService.sendEnter(memberId, chatRoomId);
// template.convertAndSend(
// "/topic/invite/" + request.receiverMemberId(),
// new InviteToChatRoomResponse(request.chatRoomId())
// );
Comment on lines +41 to +44
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

??

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

일대일 채팅방 만들때 다시 로직 살릴예정!

}

@MessageMapping("/chat/{chatRoomId}")
@MessageMapping("chat.{chatRoomId}")
public void sendMessage(
@WebSocketAuth Long memberId,
@DestinationVariable(value = "chatRoomId") Long chatRoomId,
Expand All @@ -66,15 +53,6 @@ public void sendMessage(
chatCommandService.sendChat(memberId, chatRoomId, request);
}

@Deprecated
@MessageMapping("/leave/{chatRoomId}")
public void leave(
@WebSocketAuth Long memberId,
@DestinationVariable(value = "chatRoomId") Long chatRoomId
) {
chatCommandService.sendLeave(memberId, chatRoomId);
}

@MessageExceptionHandler
public ResponseEntity<ApiResponse<ErrorResponse>> handleException(FriendoglyException exception) {
ErrorResponse errorResponse = new ErrorResponse(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import static com.happy.friendogly.chat.domain.MessageType.ENTER;
import static com.happy.friendogly.chat.domain.MessageType.LEAVE;

import com.happy.friendogly.chat.config.ChatTemplate;
import com.happy.friendogly.chat.domain.ChatMessage;
import com.happy.friendogly.chat.domain.ChatRoom;
import com.happy.friendogly.chat.domain.MessageType;
Expand All @@ -16,35 +17,33 @@
import com.happy.friendogly.member.repository.MemberRepository;
import com.happy.friendogly.notification.service.NotificationService;
import java.time.LocalDateTime;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@Transactional
public class ChatCommandService {

private static final String TOPIC_CHAT_PREFIX = "/topic/chat/";
private static final String EMPTY_CONTENT = "";

private final MemberRepository memberRepository;
private final ChatRoomRepository chatRoomRepository;
private final ChatMessageRepository chatMessageRepository;
private final NotificationService notificationService;
private final SimpMessagingTemplate template;
private final ChatTemplate chatTemplate;

public ChatCommandService(
MemberRepository memberRepository,
ChatRoomRepository chatRoomRepository,
ChatMessageRepository chatMessageRepository,
NotificationService notificationService,
SimpMessagingTemplate template
ChatTemplate chatTemplate
) {
this.memberRepository = memberRepository;
this.chatRoomRepository = chatRoomRepository;
this.chatMessageRepository = chatMessageRepository;
this.notificationService = notificationService;
this.template = template;
this.chatTemplate = chatTemplate;
}

public void sendEnter(Long senderMemberId, Long chatRoomId) {
Expand All @@ -71,9 +70,10 @@ public void sendLeave(Long senderMemberId, Long chatRoomId) {
}

private void sendAndSave(MessageType messageType, String content, ChatRoom chatRoom, Member senderMember) {
ChatMessageSocketResponse chat = new ChatMessageSocketResponse(messageType, content, senderMember, LocalDateTime.now());
ChatMessageSocketResponse chat = new ChatMessageSocketResponse(messageType, content, senderMember,
LocalDateTime.now());
notificationService.sendChatNotification(chatRoom.getId(), chat);
template.convertAndSend(TOPIC_CHAT_PREFIX + chatRoom.getId(), chat);
chatTemplate.convertAndSend(chatRoom.getId(), chat);
chatMessageRepository.save(new ChatMessage(chatRoom, messageType, senderMember, content));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package com.happy.friendogly.config;

import com.fasterxml.jackson.databind.Module;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import com.rabbitmq.client.ConnectionFactory;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import org.springframework.amqp.core.AmqpAdmin;
import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.Queue;
import org.springframework.amqp.core.TopicExchange;
import org.springframework.amqp.rabbit.annotation.EnableRabbit;
import org.springframework.amqp.rabbit.connection.CachingConnectionFactory;
import org.springframework.amqp.rabbit.core.RabbitAdmin;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;

@Configuration
@EnableRabbit
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오 이런게 있군용

@Profile("!local")
public class RabbitMqConfig {

private static final String CHAT_QUEUE_NAME = "chat.queue";
private static final String CHAT_EXCHANGE_NAME = "chat.exchange";
private static final String ROUTING_KEY = "*.room.*";

private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");

@Value("${spring.rabbitmq.host}")
private String host;

@Value("${spring.rabbitmq.username}")
private String username;

@Value("${spring.rabbitmq.password}")
private String password;

@Value("${spring.rabbitmq.stomp-port}")
private int port;

@Bean
public Queue queue() {
return new Queue(CHAT_QUEUE_NAME, true);
}

@Bean
public TopicExchange topicExchange() {
return new TopicExchange(CHAT_EXCHANGE_NAME, true, false);
}

@Bean
public Binding binding(Queue queue, TopicExchange topicExchange) {
return BindingBuilder.bind(queue)
.to(topicExchange)
.with(ROUTING_KEY);
}

@Bean
public ConnectionFactory connectionFactory() {
CachingConnectionFactory connectionFactory = new CachingConnectionFactory();
connectionFactory.setHost(host);
connectionFactory.setPort(port);
connectionFactory.setUsername(username);
connectionFactory.setPassword(password);
connectionFactory.setVirtualHost("/");
return connectionFactory.getRabbitConnectionFactory();
}

@Bean
public AmqpAdmin amqpAdmin(RabbitTemplate rabbitTemplate) {
RabbitAdmin rabbitAdmin = new RabbitAdmin(rabbitTemplate.getConnectionFactory());
rabbitAdmin.declareExchange(topicExchange());
return rabbitAdmin;
}

@Bean
public RabbitTemplate rabbitTemplate(
org.springframework.amqp.rabbit.connection.ConnectionFactory connectionFactory) {

RabbitTemplate rabbitTemplate = new RabbitTemplate(connectionFactory);
rabbitTemplate.setMessageConverter(jackson2JsonMessageConverter());
return rabbitTemplate;
}

@Bean
public Jackson2JsonMessageConverter jackson2JsonMessageConverter() {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
objectMapper.registerModule(javaTimeModule());
return new Jackson2JsonMessageConverter(objectMapper);
}

@Bean
public Module javaTimeModule() {
return new JavaTimeModule()
.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(TIME_FORMAT))
.addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(TIME_FORMAT));
}
Comment on lines +94 to +107
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

기본 설정만으로는 LocalDateTime을 직렬화하지 못해서 작성함
(기본 설정으로는 배열로 직렬화하도록 되어 있음)

}
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@
@Component
public class WebSocketInterceptor implements ChannelInterceptor {

private static final String TOPIC_CHAT_ENDPOINT = "/topic/chat/";
private static final String TOPIC_INVITE_ENDPOINT = "/topic/invite/";
private static final String TOPIC_CHAT_ENDPOINT = "/exchange/chat.exchange/room.";
// private static final String TOPIC_INVITE_ENDPOINT = "/topic/invite/";

private final ClubRepository clubRepository;
private final ChatRoomRepository chatRoomRepository;
Expand Down Expand Up @@ -50,9 +50,10 @@ public Message<?> preSend(Message<?> message, MessageChannel channel) {
String accessToken = accessor.getFirstNativeHeader(AUTHORIZATION);
long memberId = validateAndExtractMemberIdFrom(accessToken);

if (destination.startsWith(TOPIC_INVITE_ENDPOINT)) {
validateInviteSubscription(memberId, destination);
}
// TODO: 1대1 채팅방 구현할 때 고려하기
// if (destination.startsWith(TOPIC_INVITE_ENDPOINT)) {
// validateInviteSubscription(memberId, destination);
// }

if (destination.startsWith(TOPIC_CHAT_ENDPOINT)) {
validateChatSubscription(memberId, destination);
Expand All @@ -68,9 +69,9 @@ private void validateDestination(String destination) {
if (destination.startsWith(TOPIC_CHAT_ENDPOINT)) {
return;
}
if (destination.startsWith(TOPIC_INVITE_ENDPOINT)) {
return;
}
// if (destination.startsWith(TOPIC_INVITE_ENDPOINT)) {
// return;
// }
throw new FriendoglyWebSocketException(String.format("%s는 올바른 sub 경로가 아닙니다.", destination));
}

Expand All @@ -88,14 +89,14 @@ private long validateAndExtractMemberIdFrom(String accessToken) {
}
}

private void validateInviteSubscription(long memberId, String destination) {
String rawMemberIdToSubscribe = destination.substring(TOPIC_INVITE_ENDPOINT.length());
long memberIdToSubscribe = convertToLong(rawMemberIdToSubscribe);

if (memberId != memberIdToSubscribe) {
throw new FriendoglyWebSocketException("자신의 초대 endpoint만 구독할 수 있습니다.");
}
}
// private void validateInviteSubscription(long memberId, String destination) {
// String rawMemberIdToSubscribe = destination.substring(TOPIC_INVITE_ENDPOINT.length());
// long memberIdToSubscribe = convertToLong(rawMemberIdToSubscribe);
//
// if (memberId != memberIdToSubscribe) {
// throw new FriendoglyWebSocketException("자신의 초대 endpoint만 구독할 수 있습니다.");
// }
// }

private void validateChatSubscription(long memberId, String destination) {
String rawChatRoomId = destination.substring(TOPIC_CHAT_ENDPOINT.length());
Expand Down
Loading
Loading