[실시간 채팅 구현기] 기능 구현하기

Nicky·2024년 5월 15일
post-thumbnail

오늘은 다음의 2가지 기능을 구현하고자 한다.

  1. 채팅방 조회
  2. 채팅 메시지 저장

1. 채팅방 조회

게시물 상세 페이지에서 게스트가 호스트에게 메시지 버튼을 누를 시, 채팅방을 조회해서 없으면 채팅방을 새로 생성하고 있으면 채팅방 정보를 반환하고자 한다.

ChatRoom 엔티티

채팅방과 회원(참여자)은 다대다 관계로 각 채팅방은 2명의 참여자를 주입받아 생성된다.

@Entity
@Getter
@Table(name = "chatRooms")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ChatRoom {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToMany
    private Set<Member> participants = new HashSet<>();

    @OneToMany(mappedBy = "chatRoom", cascade = CascadeType.ALL)
    private List<ChatMessage> chatMessages = new ArrayList<>();

    @Builder
    public ChatRoom(Member sender, Member receiver) {
        this.participants.add(sender);
        this.participants.add(receiver);
        sender.joinChatRooms(this);
        receiver.joinChatRooms(this);
    }

    public void setChatMessages(ChatMessage chatMessage){
        this.chatMessages.add(chatMessage);
    }

}

Member 도메인

MemberController

    // 채팅방 정보 조회
    @PostMapping("chats")
    public ChatRoomResponse getRoomInfo(@AuthenticationPrincipal(expression = "username") String username,
                                        @Valid @RequestBody FindChatRoomRequest requestParam) {
        return memberService.findRoom(username, requestParam);
    }

MemberService

    // 수신자 송신자로 방 조회, 없다면 새로 생성
    @Override
    @Transactional
    public ChatRoomResponse findRoom(String username, FindChatRoomRequest requestParam) {
        Member receiver = findMember(requestParam.receiverName());
        Member sender = findMember(username);
        ChatRoom chatRoom = chatService.findChatRoomByParticipants(receiver, sender);
        List<ChatMessageInfo> chatMessageInfoList = chatMessageInfoMapper.toMessageInfoList(chatRoom.getChatMessages());
        return new ChatRoomResponse(chatRoom.getId(), chatMessageInfoList);
    }

Chat 도메인

게스트와 호스트의 식별값(이름)으로 채팅방을 조회하고 없을 시에는 생성해준다.

ChatService

    public ChatRoom findChatRoomByParticipants(Member receiver, Member sender) {
        return chatRoomRepository.findChatRoomByParticipants(receiver, sender)
                .orElseGet(() -> saveChatRoom(sender, receiver));
    }
    
    // 방 생성 시, sender: 나, receiver: 상대
    private ChatRoom saveChatRoom(Member sender, Member receiver) {
        ChatRoom chatRoom = ChatRoom.builder()
                .sender(sender)
                .receiver(receiver)
                .build();
        chatRoomRepository.save(chatRoom);
        return chatRoom;
    }

ChatRoomRepository

public interface ChatRoomRepository extends JpaRepository<ChatRoom, Long> {
    @Query("SELECT cr FROM ChatRoom cr WHERE :member1 MEMBER OF cr.participants AND :member2 MEMBER OF cr.participants")
    Optional<ChatRoom> findChatRoomByParticipants(@Param("member1") Member member1, @Param("member2") Member member2);

2. 채팅방 메시지 저장

먼저 채팅 메시지는 다음과 같이 채팅방 id, 메시지, 수신자 이름으로 구성된다. (1대1 채팅만을 구현할 것이라 sender와 receiver를 분리하지 않았다.)

{
	roomId: roomId,
    message: message,
    recevierName: receiverName
}

Chat 도메인

ChatRequest

public record ChatRequest(@NotNull Long roomId, @NotNull String message, @NotNull String receiverName) {
    public ChatRequest {
    }
}

ChatMessage 엔티티

채팅 메시지는 채팅방과 다대일 관계로 메시지를 받을때 마다 DB에 저장될 예정이다.

@Entity
@Getter
@Table(name = "chatMessages")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ChatMessage extends BaseTimeEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "ChatRoomId")
    private ChatRoom chatRoom;

    @Column(name = "senderName")
    private String senderName;

    @Column(name = "message")
    private String message;

    @Builder
    public ChatMessage(ChatRoom chatRoom, String senderName, String message) {
        setChatRoom(chatRoom);
        this.senderName = senderName;
        this.message = message;
    }

    private void setChatRoom(ChatRoom chatRoom) {
        this.chatRoom = chatRoom;
        chatRoom.setChatMessages(this);
    }

}

ChatController

@RequiredArgsConstructor
@Controller
public class ChatController {

    private final SimpMessagingTemplate messagingTemplate;
    private final ChatService chatService;

    @MessageMapping("/messages") // publish
    public void sendMessage(@AuthenticationPrincipal(expression = "username") String username,
                            @Payload ChatRequest requestParam) {
        // 채팅 내역 저장
        chatService.saveMessage(username, requestParam);
        String destination = String.format("/subscribe/rooms/%s", requestParam.roomId());
        messagingTemplate.convertAndSend(destination, requestParam);  // subscribe
    }

}
MessageMapping

@MessageMapping("/messages") 어노테이션을 통해 클라이언트로부터 "/messages" 경로로 전송된 메시지를 받게 된다.

SimpMessagingTemplate

SimpMessagingTemplate 클래스는 WebSocket 메시지를 전송하는 데 사용된다. messagingTemplate.convertAndSend(destination, requestParam)를 통해 "/subscribe/rooms/%s"경로로 메시지를 브로드캐스팅한다.

convertAndSend()

convertAndSend() 메서드는 다음과 같이 동작한다.

1. 메시지 변환

  • 전달받은 객체를 WebSocket 메시지 형식으로 변환

2. 대상 경로 설정

  • destination 경로로 메시지 전달

3. 메시지 전송

  • 변환된 메시지는 WebSocket 브로커로 전송됨, 브로커는 메시지를 구독 중인 클라이언트들에게 브로드 캐스팅

4. 구독 중인 클라이언트 수신

  • 클라이언트는 WebSocket 연결을 통해 메시지를 실시간으로 받음

ChatService

    @Transactional
    public void saveMessage(String username, ChatRequest requestParam) {
        saveChatMessage(username, requestParam);
    }

    private void saveChatMessage(String username, ChatRequest requestParam) {
        ChatRoom chatRoom = findChatRoom(requestParam.roomId());
        ChatMessage chatMessage = ChatMessage.builder()
                .chatRoom(chatRoom)
                .senderName(username)
                .message(requestParam.message())
                .build();
        chatMessageRepository.save(chatMessage);
    }

React 코드

채팅방 조회

// 수신자 이름으로 채팅방 조회 API 요청
  useEffect(() => {
    const fetchRoomInfo = async () => {
      const apiUrl = "/api/users/chats";
      try {
        const findChatRoomRequest = { receiverName: receiverName };
        const response = await axiosInstance.post(apiUrl, findChatRoomRequest, {headers});
        setRoomInfo(response.data);
      } catch (error) {
        if (error.response) {
          const {status, data:{errorMessage}} = error.response
          notification.open({
            message: `${status} 에러`,
            description: errorMessage,
            icon: <FrownOutlined style={{ color: "#ff3333" }} />
          });
        }
      }
    };
    fetchRoomInfo();
  }, []);

채팅 메시지 불러오기

// 메시지 리스트 불러오기
  useEffect(() => {
    if (messageInfoList) { 
      const initialMessages = messageInfoList.map(info => ({
        ...info,
        timestamp: new Date(info.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
        isUserMessage: info.senderName !== receiverName, 
      }));
      setMessages(initialMessages);
    }
  }, [roomInfo]);

웹소켓 연결

  // 웹소켓 연결
  useEffect(() => {
    if (Object.keys(roomInfo).length !== 0) {
      const socket = new SockJS(`${API_HOST}/ws`);
      const stompClient = Stomp.over(socket);
      setStompClient(stompClient);

      stompClient.connect(headers, () => {
        stompClient.subscribe(`/subscribe/rooms/${roomId}`, (data) => {
          const newMessage = JSON.parse(data.body);
          const messageTime = new Date();
          const timestamp = messageTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
          
          const isUserMessage = newMessage.receiverName === receiverName; // 수신자 이름 같으면 본인 메시지

          setMessages(messages => [...messages, { ...newMessage, timestamp, isUserMessage }]);
        });
      });

      return () => {
        if (stompClient !== null) {
          stompClient.disconnect();
        }
      };
    }
  }, [roomInfo]);

메시지 전송

// 메시지 전송
  const sendMessage = () => {
    if (message.trim() !== '') {
      stompClient.send("/publish/messages", {}, JSON.stringify({
        roomId: roomInfo.roomId,
        message: message,
        receiverName: receiverName
      }));
      setMessage("");
    }
  };

구현 확인

profile
코딩 연구소

0개의 댓글