Spring 채팅 구현하기 - 3. 환영 메세지 작성하기

Kevin·2023년 8월 13일
0

Spring

목록 보기
8/11
post-thumbnail

내가 구현하고자 하는 기능

채팅을 보내려는 유저를 A라고 하고, 채팅을 받는 유저를 B라고 가정해보자.

나는 유저 A가 유저 B에게 채팅을 보낼 때 여러개의 상황에 맞게 분기 처리가 되어서, 채팅방에 신규 입장시 환영 메세지가 출력되고, 이미 입장을 했던 유저라면 일반적인 메세지를 전송하는 로직을 생각해보았다.


첫번째로 유저 A와 유저 B가 처음으로 채팅을 하는 상황이다.

이 경우에는 유저 A와 유저 B간의 채팅방이 존재하지 않기 때문에, 채팅시 채팅방을 생성할 필요가 존재한다.

또한 환영 메세지를 유저 B에게 보내주어야 한다.

두번째로 유저 A와 유저 B가 기존에 채팅을 했어서 채팅방이 존재하는 경우이다.

이 경우에는 유저 A와 유저 B간의 채팅방이 이미 존재하기 때문에, 채팅시 채팅방을 생성할 필요가 없다.

이 경우 환영 메세지를 보내줄 필요 없이, 메세지만 브로커를 통해 유저 B에게 전달하면 된다.

내가 어떻게 위의 기능을 구현했는지 코드와 함께 더 자세히 알아보도록 하자.

해당 글은 Spring Message + Stomp를 기본적으로 인지하고 있다는 전제하에 작성되었다.



엔티티

ChatRoom.class

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Builder
public class ChatRoom extends CommonEntity {

    private String roomName;

    private String lastReceivedChat;

    @ElementCollection
    @CollectionTable(
            name = "visitedNames",
            joinColumns = @JoinColumn(name = "chatRoom_id")
    )
    @Builder.Default
    private List<String> visitedNames = new ArrayList<>();

    public void setRandomRoomName(){
        this.roomName = UUID.randomUUID().toString();
    }

    public void updateLastRecievedChat(String lastReceivedChat){
        this.lastReceivedChat = lastReceivedChat;
    }
}
  • ChatRoom 같은 경우에는 CommonEntity를 상속 받았는데, CommonEntity는 id나 createAt같이 모든엔티티에 공통적으로 들어가는 필드를 가지고 있다.
  • visiteNames ArrayList 필드와 같은 경우에는 해당 채팅방에 한번이라도 접근한 유저를 저장하는 ArrayList이다.
  • roomName 같은 경우에는 UUID를 이용해서, 랜덤으로 값을 생성해준다.



ChatMessage.class

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Builder
public class ChatMessage extends CommonEntity {

    public enum MessageType {
        ENTER, TALK
    }

    private MessageType type;

    private String roomName;

    private String senderName;

    private String message;

    public static ChatMessage to(ChatMessageRequestDTO requestDTO){
        ChatMessage chatMessage = ChatMessage.builder()
                .message(requestDTO.getMessage())
                .senderName(requestDTO.getSenderName())
                .roomName(requestDTO.getRoomName())
                .build();

        return chatMessage;
    }

    public void updateSenderName(String username){
        this.senderName = username;
    }

    public void updateStatus(MessageType type){ this.type = type; }

    public void updateRoomName(String roomName){
        this.roomName = roomName;
    }
}
  • MessageType type 필드는 입장 메세지와 일반 채팅을 구분하기 위한 Enum 필드이다.

Repository 같은 경우에는 일반적인 쿼리만 있기에, 비즈니스 로직부터 알아보기로 하자.




Service

MessageService.class

@Service
@Transactional
@RequiredArgsConstructor
public class MessageService {

    private final MessageRepository messageRepository;

    private final ChatRoomRespository roomRepository;

    @Transactional
    public ChatMessageReponseDTO.ChatDTO saveMessage(ChatMessageRequestDTO messageDTO, SimpMessageHeaderAccessor headerAccessor) {

        ChatMessage message = ChatMessage.to(messageDTO);

        String userName = Objects.requireNonNull(headerAccessor.getUser()).getName();

        // 해당 roomName의 room이 있는지
        ChatRoom chatRoom = findRoom(message.getRoomName());

        // 있다면 message 저장
        if (chatRoom != null) {

            // 재입장인지 아닌지 분기 처리
            for (String s : chatRoom.getVisitedNames()) {
                // 재입장이라면
                if (s.equals(userName)) {
                    return ChatMessageReponseDTO.from(ifUserReEnter(chatRoom, message, userName));
                }
            }

            // 재입장이 아니더라도
            return ChatMessageReponseDTO.from(ifUserNotReEnterExistRoom(chatRoom, message, userName));
        }

        // 맨 처음 입장이라면 -> Room 생성 후 메시지 생성
        return ChatMessageReponseDTO.from(ifUserFirstEnter(message, userName));
    }

    // 재입장이라면
    private ChatMessage ifUserReEnter(ChatRoom chatRoom, ChatMessage message, String userName){
        chatRoom.updateLastRecievedChat(message.getMessage());

        message.updateStatus(ChatMessage.MessageType.TALK);
        message.updateSenderName(userName);
        message.updateRoomName(chatRoom.getRoomName());

        return messageRepository.save(message);
    }

    // 재입장은 아니되 이미 방이 존재할 때
    private ChatMessage ifUserNotReEnterExistRoom(ChatRoom chatRoom, ChatMessage message, String userName){
        chatRoom.getVisitedNames().add(userName);
        chatRoom.updateLastRecievedChat(message.getMessage());

        message.updateStatus(ChatMessage.MessageType.ENTER);
        message.updateSenderName(userName);
        message.updateRoomName(chatRoom.getRoomName());

        return messageRepository.save(message);
    }

    // 맨 처음 입장이라면
    private ChatMessage ifUserFirstEnter(ChatMessage message, String userName){
        ChatRoom chatRoomOP = createRoom(message);

        chatRoomOP.getVisitedNames().add(userName);
        chatRoomOP.setRandomRoomName();
        chatRoomOP.updateLastRecievedChat(message.getMessage());

        message.updateStatus(ChatMessage.MessageType.ENTER);
        message.updateSenderName(userName);
        message.updateRoomName(chatRoomOP.getRoomName());

        return messageRepository.save(message);
    }

    // Room 생성
    private ChatRoom createRoom(ChatMessage message){
        ChatRoom chatRoom =
                ChatRoom.builder()
                .lastReceivedChat(message.getMessage()).build();
        return roomRepository.save(chatRoom);
    }

		// Room select 쿼리
    private ChatRoom findRoom(String roomName) {
        Optional<ChatRoom> room = roomRepository.findByRoomName(roomName);

        return room.orElse(null);
    }
}
  • 코드가 워낙 길기에, 간단한 주석들을 달아놨다.
  • Flow는 다음과 같다.
    - Client로부터 받은 roomName을 가진 Room이 실제 DB에 존재 하지 않는 경우에는 맨 처음 입장하는 것을 의미하므로, createRoom 메서드를 이용해서 Room을 생성 및 Message를 저장해준다.
    - 만약 Client로부터 받은 roomName을 가진 Room이 실제 DB에 존재한다고 하면 유저가 해당 Room에 방문한 적이 있었는지에 대한 체크 후 분기 처리를 해준다.




Controller

MessageController.class

@RestController
@RequiredArgsConstructor
@Slf4j
public class MessageController {

    private final SimpMessagingTemplate template;

    private final MessageService messageService;

    @MessageMapping("/chat/message")
    public void enter(ChatMessageRequestDTO messageDTO, SimpMessageHeaderAccessor headerAccessor) {

        ChatMessageReponseDTO.ChatDTO chatMessage = messageService.saveMessage(messageDTO, headerAccessor);

        if (Objects.equals(chatMessage.getType(), "ENTER")) {
            template.convertAndSend("/sub/chat/room/" + chatMessage.getRoomName(), chatMessage.getSenderName() + "님이 채팅방에 입장하셨습니다.");
        }

        if (Objects.equals(chatMessage.getType(), "TALK")) {
            template.convertAndSend("/sub/chat/room/" + chatMessage.getRoomName(), chatMessage);
        }
    }
}
  • Spring Stomp를 사용하면서, 느낀 부분은 집중을 해야 하는 부분은 convertAndSend()이다. 나머지 로직은 해당 메세지를 어떻게 처리하고, 가공 및 저장해야할 지에 대한 로직이다.
  • Flow는 다음과 같다.
    • 프론트엔드가 메세지를 app/chat/message를 통해서 전달을 하면 Spring에서는 @MessageMapping(”/chat/message”) 어노테이션을 통해서 해당 enter 메서드로 매핑시켜준다.
    • enter 메서드에서는 messageService.saveMessage() 메서드를 통해 Message를 가공 및 최초 입장 및 일반 채팅 여부를 저장해서 DTO 객체에 저장한다.
    • 그 후 최초 입장 및 일반 채팅 여부를 통한 분기로 아래와 같이 나눠진다.
    • 최초 입장(=ENTER)일 시 RoomName의 채널을 구독한 구독자들에게 환영 메세지를 전송한다.
    • 일반 채팅(=TALK)일 시 RoomName의 채널을 구독한 구독자들에게 일반 메세지를 전송한다.
profile
Hello, World! \n

1개의 댓글

comment-user-thumbnail
2023년 8월 13일

글 잘 봤습니다.

답글 달기