타임리프와 웹소켓을 이용해서 채팅기능을 구현해야 했다.
그 과정에서 나온 각종 버그들과 해결 방법을 적어보려고 한다.
1. 웹소켓 설정 작성하기
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws").withSockJS();
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.setApplicationDestinationPrefixes("/app");
registry.enableSimpleBroker("/topic"); // 메모리 기반의 메시지 브로커를 사용
}
}
- registry.addEndpoint("/ws"): 이 부분은 클라이언트가 서버에 웹소켓 연결을 시도할 수 있는 경로를 정의합니다. 여기서는 "/ws"라는 경로를 사용하고 있습니다. 즉, 클라이언트는 이 경로를 통해 웹소켓 연결을 시도할 수 있습니다.
-
- registry.setApplicationDestinationPrefixes("/app");: 이 코드는 클라이언트가 서버로 메시지를 보낼 때 사용할 수 있는 목적지(prefix)의 접두어를 설정합니다.
여기서 설정된 "/app" 접두어는 클라이언트가 메시지를 보낼 때 해당 메시지가 어플리케이션 내부의 어떤 핸들러로 라우팅되어야 하는지를 결정하는 데 사용됩니다. 예를 들어, 클라이언트가 "/app/message"로 메시지를 보내면, 이 메시지는 "/message"에 해당하는 어플리케이션의 핸들러로 라우팅됩니다. 이를 통해 어플리케이션 내부에서 메시지를 처리하는 데 필요한 라우팅 로직을 구성할 수 있습니다. - registry.enableSimpleBroker("/topic");: 이 코드는 간단한 메모리 기반의 메시지 브로커를 활성화합니다. "/topic"이라는 접두어는 이 메시지 브로커가 처리할 수 있는 목적지 메시지의 접두어로 설정됩니다. 클라이언트가 이 접두어("/topic")를 사용하여 메시지를 구독(subscribe)하면, 메시지 브로커는 해당 목적지로 메시지가 발행(publish)될 때마다 구독자에게 메시지를 전달합니다.
예를 들어, 서버가 "/topic/news"에 메시지를 발행하면, "/topic/news"를 구독하고 있는 모든 클라이언트는 그 메시지를 받게 됩니다. 이 메모리 기반의 간단한 메시지 브로커는 복잡한 설정 없이도 메시지의 라우팅과 전달을 처리할 수 있게 해주지만, 대규모 분산 시스템에서는 더 고급 메시지 브로커가 필요할 수 있습니다.
- registry.setApplicationDestinationPrefixes("/app");: 이 코드는 클라이언트가 서버로 메시지를 보낼 때 사용할 수 있는 목적지(prefix)의 접두어를 설정합니다.
2. 도메인설정하기
1. 멤버 엔티티
@Entity
@Table(name = "member")
@Data
public class Member {
@Id
@Column(name = "member_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long memberId;
@Column(name = "name")
private String name;
@Column(name = "email")
private String email;
}
2.대화 엔티티
@Entity
@Table(name = "conversation")
@Data
@RequiredArgsConstructor
public class Conversation {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "conversation_id")
private Long conversationId;
@ManyToMany
@JoinTable(
name = "conversation_members",
joinColumns = @JoinColumn(name = "conversation_id"),
inverseJoinColumns = @JoinColumn(name = "member_id")
)
private Set<Member> members;
// 기본 생성자, 게터, 세터 생략
}
3. 메시지 엔티티
@Entity
@Table(name = "message")
@Data
@RequiredArgsConstructor
public class Message {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "message_id")
private Long messageId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "conversation_id", nullable = false)
private Conversation conversation;
@ManyToOne
@JoinColumn(name = "sender_id", nullable = false)
private Member sender;
@Column(name = "content", nullable = false)
private String content;
@Column(name = "timestamp", nullable = false)
private Long timestamp; // UNIX 타임스탬프 사용
// 기본 생성자, 게터, 세터 생략
}
4.채팅메시지 엔티티
3. DTO 만들기
@Data
@NoArgsConstructor
public class MessageDTO {
private String content;
private Long conversationId;
private Long senderId; // sender의 타입을 Long으로 가정합니다.
// 기본 생성자, 게터, 세터 생략
}
타임리프단에서 서버로 메시지를 날려줄때 컨트롤러에서 인식을 못하는 오류가 있었다.
그래서 DTO를 사용해서 타임리프에서 보내는 메시지 객체를 저장하고
이 DTO를 이용해서 메시지를 만들어줬다.
DTO를 왜 사용하는지 알게 된 부분
4. 리포지토리 만들기
1. 대화 리포지토리
@Repository
public interface ConversationRepository extends JpaRepository<Conversation, Long> {
List<Conversation> findByMembers_MemberId(Long memberId);
}
2.멤버 리포지토리
@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
}
3. 메시지 리포지토리
@Repository
public interface MessageRepository extends JpaRepository<Message, Long> {
List<Message> findByConversation_ConversationId(Long conversationId);
}
5. 채팅 컨트롤러
@Controller
public class ChatController {
@Autowired
private MessagingService messagingService;
private final SimpMessagingTemplate messagingTemplate;
@Autowired
public ChatController(MessagingService messagingService, SimpMessagingTemplate messagingTemplate) {
this.messagingService = messagingService;
this.messagingTemplate = messagingTemplate;
}
// memberId를 통해 특정 사용자의 모든 대화 목록을 보여주는 페이지를 반환합니다.
@GetMapping("/chat/{memberId}")
public String getAllChatsForUser(@PathVariable Long memberId, Model model) {
model.addAttribute("conversations", messagingService.getAllConversationsByMemberId(memberId));
model.addAttribute("memberId", memberId);
return "chat";
}
// 선택된 대화에 대한 대화창을 보여주는 페이지를 반환합니다.
@GetMapping("/chat/{memberId}/{conversationId}")
public String getConversation(@PathVariable Long memberId, @PathVariable Long conversationId, Model model) {
Conversation selectedConversation = messagingService.getConversation(conversationId);
if (selectedConversation != null && selectedConversation.getMembers().stream().anyMatch(member -> member.getMemberId().equals(memberId))) {
model.addAttribute("selectedConversation", selectedConversation);
model.addAttribute("messages", messagingService.getMessagesByConversation(conversationId));
model.addAttribute("memberId", memberId);
return "conversation";
}
return "redirect:/chat/" + memberId;
}
// 클라이언트로부터 메시지를 받아 다른 클라이언트에게 전송합니다.
@MessageMapping("/chat.sendMessage")
@SendTo("/topic/messages")
@Transactional
public Message sendMessage(MessageDTO messageDTO) {
Conversation conversation = messagingService.getConversation(messageDTO.getConversationId());
Member sender = messagingService.getMember(messageDTO.getSenderId()); // getMember 메서드는 회원의 ID를 기반으로 회원 정보를 조회하는 메서드입니다.
if (conversation == null || sender == null) {
throw new IllegalStateException("No conversation or sender found with the given ID.");
}
Message message = new Message();
message.setContent(messageDTO.getContent());
message.setConversation(conversation);
message.setSender(sender);
message.setTimestamp(System.currentTimeMillis());
// timestamp 설정 등 필요한 추가 설정
// 메시지 저장
Message savedMessage = messagingService.sendMessage(conversation.getConversationId(), message);
String destination = "/topic/messages/" + message.getConversation().getConversationId();
// 구독자에게 메시지 전송
messagingTemplate.convertAndSend(destination, message);
return savedMessage;
}
// // 웹소켓 구독자에게 메시지를 보냅니다. (예시: 새 메시지 알림)
// public void sendMessagesToSubscribers(Message message) {
// Message savedMessage = messagingService.sendMessage(message.getConversation().getConversationId(), message);
// messagingTemplate.convertAndSend("/topic/messages", savedMessage);
// }
}
로그인 기능을 구현하지 않아서 멤버, 채팅방을 path를 통해 구분했다.
나머지 기능들은 JPA+타임리프의 기능과 똑같은데, 이제 아래에 웹소켓을 사용하는 기능을 추가 설명해보려고 한다.
메시지가 오면 sendMessage 메소드가 호출이 되고,
원래는 메시지 DTO가 아니라 그냥 MESSAGE 객체를 받았었는데
메시지 객체를 타임리프에서 보내 줄 때 문제가 발생해서 CONTENT빼고 나머지 내용이 빠져있는 문제점이 있었다.
그래서 DTO를 써서 받아줬다.
그리고 메시지 객체를 생성해서 보내는데,
여기서 sendmessage 메소드는 사실 그냥 메시지를 데이터베이스에 저장하는 메소드이다.
우리가 생각하는 메시지를 보내는 기능은
messagingTemplate.convertAndSend(destination, message);
이 코드를 통해 이뤄지는데
desination을 채팅방 경로로 수정하지않아서 데이터베이스에 저장만 되고 구독이 되지않는 문제점이 있었다.
String destination = "/topic/messages/" + message.getConversation().getConversationId();
그래서 이런식으로 데스티네이션을 설정해줘서 채팅방 멤버들이 채팅방 내용만 구독할 수 있게 만들어줬다.
그리고 데이터 영속성 문제때문인지, 자꾸 LazyInitializationException이 발생했는데
그냥 sendMessage에 @Transactional을 붙여주니까 해결이 됐다.
6. 메세지 서비스
@Service
@Transactional
public class MessagingService {
@Autowired
private ConversationRepository conversationRepository;
@Autowired
private MessageRepository messageRepository;
@Autowired
private MemberRepository memberRepository;
@Transactional
public Conversation createConversation(Conversation conversation) {
return conversationRepository.save(conversation);
}
@Transactional
public Message sendMessage(Long conversationId, Message message) {
Conversation conversation = conversationRepository.findById(conversationId)
.orElseThrow(() -> new EntityNotFoundException("Conversation not found with id: " + conversationId));
// Associate the existing conversation with the message.
message.setConversation(conversation);
// Save the message with the associated conversation.
return messageRepository.save(message);
}
public List<Message> getMessagesByConversation(Long conversationId) {
return messageRepository.findByConversation_ConversationId(conversationId);
}
// 모든 대화 목록을 반환하는 메소드
public List<Conversation> getAllConversations() {
return conversationRepository.findAll();
}
// 특정 대화를 반환하는 메소드
public Conversation getConversation(Long conversationId) {
Optional<Conversation> conversation = conversationRepository.findById(conversationId);
if(conversation.isPresent()) {
return conversation.get();
} else {
// 적절한 예외 처리나 대체 로직을 수행합니다.
throw new RuntimeException("Conversation not found!");
}
}
// memberId에 따라 대화 목록을 가져오는 메서드
public List<Conversation> getAllConversationsByMemberId(Long memberId) {
return conversationRepository.findByMembers_MemberId(memberId);
}
public Member getMember(Long memberId) {
return memberRepository.findById(memberId)
.orElseThrow(() -> new EntityNotFoundException("Member not found with id: " + memberId));
}
}
7. 타임리프 코드
1.chat.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Chat Application</title>
<link rel="stylesheet" type="text/css" th:href="@{/css/chat.css}">
<script th:src="@{/webjars/jquery/jquery.min.js}"></script>
<script th:src="@{/webjars/sockjs-client/sockjs.min.js}"></script>
<script th:src="@{/webjars/stomp-websocket/stomp.min.js}"></script>
</head>
<body>
<div class="chat-container">
<div class="chat-sidebar">
<!-- 사용자의 대화 목록 -->
<div class="chat-list">
<ul>
<li th:each="conversation : ${conversations}"
th:text="${#strings.listJoin(conversation.members.![name], ', ')}"
th:data-convo-id="${conversation.conversationId}"
th:onclick="'window.location.href=\'' + @{/chat/{memberId}/{conversationId}(memberId=${memberId}, conversationId=${conversation.conversationId})} + '\''">
</li>
</ul>
</div>
</div>
<div class="chat-main">
<!-- 대화 내용 -->
<div class="chat-header">
<h3 id="conversationName">Select a chat</h3>
</div>
<div class="chat-messages" id="chatWindow">
<!-- 메시지들이 여기에 표시됩니다. -->
</div>
<div class="chat-footer">
<input type="text" id="messageInput" placeholder="Type a message...">
<button onclick="sendMessage()">Send</button>
</div>
</div>
</div>
<script th:inline="javascript">
/*<![CDATA[*/
var stompClient = null;
var selectedConversationId = null;
function openConversation(element) {
var convoId = element.getAttribute('data-convo-id');
selectedConversationId = convoId;
$('#conversationName').text(element.textContent);
$('#chatWindow').empty(); // 이전 대화 내용을 지웁니다.
if(stompClient) {
stompClient.disconnect();
}
connectToChat(convoId);
}
function connectToChat(convoId) {
var socket = new SockJS('/ws');
stompClient = Stomp.over(socket);
stompClient.connect({}, function(frame) {
console.log('Connected: ' + frame);
stompClient.subscribe('/topic/messages/' + convoId, function(message) {
showMessage(JSON.parse(message.body));
});
});
}
function sendMessage() {
var messageContent = $('#messageInput').val().trim();
if(messageContent && selectedConversationId) {
var chatMessage = {
content: messageContent,
conversationId: selectedConversationId
};
stompClient.send("/app/chat.sendMessage", {}, JSON.stringify(chatMessage));
$('#messageInput').val('');
}
}
function showMessage(message) {
$('#chatWindow').append('<div class="message">' + message.content + '</div>');
}
/*]]>*/
</script>
</body>
</html>
사실 내가 프론트엔드쪽은 아예 몰라서 chatgpt로 많이 구현했다.
그래서 버그 잡기도 굉장히 힘들었는데, 일단 동적페이징을 생각했으나, 생각대로 잘 작동하지 않았다
프론트엔드 디버깅을 하려면
F12눌러서 개발자도구를 띄워서 콘솔창을 띄우는 것 부터 시작이다.
사실 아래쪽에 있는 sendmessage나 connectTochat은 동적프로그래밍을 구현하려고 한 흔적이다.
작동하지 않아서 애를 먹었다. 지우기도 뭐해서 그냥 내버려뒀다.
1.
<script th:src="@{/webjars/jquery/jquery.min.js}"></script>
<script th:src="@{/webjars/sockjs-client/sockjs.min.js}"></script>
<script th:src="@{/webjars/stomp-websocket/stomp.min.js}"></script>
이 스크립트들을 사용하려먼 의존성 주입이 필요했다.
//webjars
implementation 'org.webjars:webjars-locator-core'
implementation 'org.webjars:sockjs-client:1.0.2'
implementation 'org.webjars:stomp-websocket:2.3.3'
implementation 'org.webjars:bootstrap:3.3.7'
implementation 'org.webjars:jquery:3.1.1-1'
2. conversation.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Chat Conversation</title>
<link rel="stylesheet" type="text/css" th:href="@{/css/chat.css}">
<script th:src="@{/webjars/jquery/jquery.min.js}"></script>
<script th:src="@{/webjars/sockjs-client/sockjs.min.js}"></script>
<script th:src="@{/webjars/stomp-websocket/stomp.min.js}"></script>
</head>
<body>
<div class="chat-container">
<div class="chat-header">
<h3>Chat Room</h3>
</div>
<div class="chat-messages" id="chatWindow">
<div th:each="message : ${messages}" class="message">
<p th:text="${message.sender.name + ': ' + message.content}"></p>
</div>
</div>
<div class="chat-footer">
<input type="text" id="messageInput" placeholder="Type a message...">
<button onclick="sendMessage()">Send</button>
</div>
</div>
<script th:inline="javascript">
/*<![CDATA[*/
var stompClient = null;
var selectedConversationId = /*[[${selectedConversation.conversationId}]]*/ 'default';
// URL에서 senderId 추출
var senderId = window.location.pathname.split('/')[2];
$(document).ready(function() {
connectToChat();
});
function connectToChat() {
var socket = new SockJS('/ws');
stompClient = Stomp.over(socket);
stompClient.connect({}, function(frame) {
console.log('Connected: ' + frame);
stompClient.subscribe('/topic/messages/' + selectedConversationId, function(message) {
showMessage(JSON.parse(message.body));
});
});
}
function sendMessage() {
var messageContent = $('#messageInput').val().trim();
if(messageContent && selectedConversationId) {
var chatMessage = {
content: messageContent,
conversationId: selectedConversationId,
senderId: parseInt(senderId) // sender 정보 추가
};
stompClient.send("/app/chat.sendMessage", {}, JSON.stringify(chatMessage));
$('#messageInput').val('');
}
}
function showMessage(message) {
$('#chatWindow').append('<div class="message">' + message.sender.name + ': ' + message.content + '</div>');
}
/*]]>*/
</script>
</body>
</html>
여기서 내가 설명할 수 있는 부분은 많이 없는데
내가 찾은걸 설명하자면
sendMessage 함수 부분에서 보낼때
변수명과 아까 DTO의 변수명을 맞춰줘야 한다.
그래야만 인식해서 DTO안으로 들어간다.
'스프링' 카테고리의 다른 글
엔티티에 @Setter 사용에 관하여 (0) | 2024.04.17 |
---|---|
자바 stream map함수란? (0) | 2024.04.16 |
스프링 MVC 2편 쿠키, 세션 (0) | 2024.03.11 |
스프링 MVC 2편 VALIDATION 정리 (0) | 2024.03.11 |
스프링 MVC 2편 메시지, 국제화 정리 (0) | 2024.03.11 |