
오늘은 다음의 2가지 기능을 구현하고자 한다.
- 채팅방 조회
- 채팅 메시지 저장

게시물 상세 페이지에서 게스트가 호스트에게 메시지 버튼을 누를 시, 채팅방을 조회해서 없으면 채팅방을 새로 생성하고 있으면 채팅방 정보를 반환하고자 한다.
채팅방과 회원(참여자)은 다대다 관계로 각 채팅방은 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);
}
}
// 채팅방 정보 조회
@PostMapping("chats")
public ChatRoomResponse getRoomInfo(@AuthenticationPrincipal(expression = "username") String username,
@Valid @RequestBody FindChatRoomRequest requestParam) {
return memberService.findRoom(username, requestParam);
}
// 수신자 송신자로 방 조회, 없다면 새로 생성
@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);
}
게스트와 호스트의 식별값(이름)으로 채팅방을 조회하고 없을 시에는 생성해준다.
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;
}
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);
먼저 채팅 메시지는 다음과 같이 채팅방 id, 메시지, 수신자 이름으로 구성된다. (1대1 채팅만을 구현할 것이라 sender와 receiver를 분리하지 않았다.)
{
roomId: roomId,
message: message,
recevierName: receiverName
}
public record ChatRequest(@NotNull Long roomId, @NotNull String message, @NotNull String receiverName) {
public ChatRequest {
}
}
채팅 메시지는 채팅방과 다대일 관계로 메시지를 받을때 마다 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);
}
}
@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("/messages") 어노테이션을 통해 클라이언트로부터 "/messages" 경로로 전송된 메시지를 받게 된다.
SimpMessagingTemplate 클래스는 WebSocket 메시지를 전송하는 데 사용된다. messagingTemplate.convertAndSend(destination, requestParam)를 통해 "/subscribe/rooms/%s"경로로 메시지를 브로드캐스팅한다.
convertAndSend() 메서드는 다음과 같이 동작한다.
1. 메시지 변환
- 전달받은 객체를 WebSocket 메시지 형식으로 변환
2. 대상 경로 설정
- destination 경로로 메시지 전달
3. 메시지 전송
- 변환된 메시지는 WebSocket 브로커로 전송됨, 브로커는 메시지를 구독 중인 클라이언트들에게 브로드 캐스팅
4. 구독 중인 클라이언트 수신
- 클라이언트는 WebSocket 연결을 통해 메시지를 실시간으로 받음
@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);
}
// 수신자 이름으로 채팅방 조회 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("");
}
};
