사이먼's 코딩노트
[SpringBoot] 멀티 채팅방 (3) 본문
[Ajax, Stomp 기반 멀티 채팅방 구현]
- 저번 '멀티 채팅방 (2)' 포스팅에서 Ajax 기반의 멀티 채팅방을 구현하였다.
- 하지만, Ajax를 적용했을 때 불필요한 데이터 전달때문에 서버 부하가 생길 수도 있다는 단점이 있다.
- 해당 부분을 해결하기 위해서 웹 소켓을 사용해 볼 예정입니다.
- 웹 소켓을 사용하면 브라우저와 서버간에 서로 데이터 전달을 하지 않고, 브라우저는 서버에게만 전달, 서버는 브라우저에게만 전달하는 형식이기 때문에 부하가 생기는 단점을 막을 수 있다.
- Stomp가 바로 그 웹 소켓 방식 중 하나라고 생각하면 좋다.
[Stomp란?]
- Simple/Stream Text Oriented Message Protocol의 약어로 웹 소켓 위에서 동작하는 문자 기반 메세징 프로토콜로써 클라이언트와 서버가 전송할 메시지의 유형, 형식, 내용들을 정의하는 매커니즘이다.
- TCP와 웹 소켓과 같은 신뢰할 수 있는 양방향 스트리밍 네트워크 프로토콜에서 사용할 수 있다.
- 기본적으로 Publisher / Subscriber 구조로 되어있어 메시지를 전송하고 받아 처리하는 부분이 확실하게 정해져있다.
- 메시지의 발행자와 구독자가 존재하고 메시지를 보내는 사람과 받는 사람이 구분되어 있다고 생각하면 좋다.
- 메시지 브로커는 발행자가 보낸 메시지를 구독자에게 전달해주는 역할을 한다.
- Http와 마찬가지로 frame 기반 프로토콜 command, header, body로 이루어져 있다.
- 웹 소켓과 Stomp를 함께 사용하면 frame의 구조가 정해져있기 때문에 통신에 용이하다.
- 아래 사진을 보면 알 수 있듯이, 수신자는 topic 경로를 구독하고 있고 발행자는 app 또는 topic으로 메시지를 보내고, 만약 발행자가 topic 경로로 메시지를 보내면 바로 수신자에게 도착하고 app 경로로 메시지를 보내면 가공을 한 다음 보내게 된다.
[라이브러리 추가]
- Stomp를 사용하기 위해서는 해당 라이브러리를 build.gradle에 아래와 같이 추가해준다.
// web socket
implementation 'org.springframework.boot:spring-boot-starter-websocket'
[WebSocketConfig 생성]
- 다음은 웹 소켓에 대한 기본 설정을 위해 WebSocketConfig.java 클래스를 생성하여 아래와 같이 코드를 작성한다.
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws").withSockJS();
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/topic");
registry.setApplicationDestinationPrefixes("/app");
}
}
[room.html 수정]
- 다음은 실시간 채팅이 실제로 이뤄지는 화면인 room.html에서 웹 소켓이 적용되지 않는 브라우저가 있기 때문에 임시적으로 상단에 script를 아래와 같이 추가해준다.
- 또한 Ajax 방식은 이제 사용하지 않을 예정이기 때문에 전에 작성한 관련된 script는 모두 삭제해주고 Stomp와 관련된 script를 추가해준다.
- 가장 하단에 추가 작성된 스크립트를 살펴보면 SockJS를 통해 서버와 웹 소켓을 연결하고, stompClient를 소켓 위에 생성한다.
- connect를 통해 Stomp와 연결하고 연결에 성공했다면 콘솔창을 통해 콜백하도록 구현했다.
- 마지막엔 특정 메시지 생성 이벤트인 /topic/chat/room/${roomId}/messageCreated를 구독하고, 메시지를 JSON 형식으로 변환하여 변환된 메시지를 다시 메시지 화면에 그려주도록 하였다.
<script src="https://cdnjs.cloudflare.com/ajax/libs/sockjs-client/1.6.1/sockjs.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/stomp.js/2.3.3/stomp.min.js"></script>
<h1 th:text="|${roomId}번 채팅방|"></h1>
<div>
<a href="make">채팅방 생성</a>
<a href="list">채팅방 목록</a>
</div>
<form onsubmit="submitWriteForm(this); return false;" method="POST">
<input type="text" name="writerName" placeholder="작성자 명">
<input type="text" name="content" placeholder="내용">
<input type="submit" value="작성">
</form>
<div>
<div>
<button onclick="loadMoreChatMessages();" type="button">더 불러오기</button>
</div>
<ul class="chat__messages">
<li th:each="chatMessage : ${room.chatMessages}">
<th:block th:text="${chatMessage.writerName}"></th:block>
:
(
<th:block th:text="${chatMessage.id}"></th:block>
)
<th:block th:text="${chatMessage.content}"></th:block>
</li>
</ul>
</div>
<script th:inline="javascript">
const roomId = /*[[${roomId}]]*/ 0;
let lastChatMessageId = /*[[${room.chatMessages.size > 0 ? room.chatMessages.first.id : 0}]]*/ 0;
</script>
<script>
const chatMessagesEl = document.querySelector(".chat__messages");
function submitWriteForm(form) {
form.writerName.value = form.writerName.value.trim();
if ( form.writerName.value.length == 0 ) {
alert("작성자 명을 입력해주세요.");
form.writerName.focus();
return;
}
form.content.value = form.content.value.trim();
if ( form.content.value.length == 0 ) {
alert("내용을 입력해주세요.");
form.content.focus();
return;
}
const action = `/chat/room/${roomId}/write`;
fetch(
action,
{
method: 'POST',
headers: {
'accept' : 'application/json',
'Content-Type' : 'application/json',
},
body: JSON.stringify({
writerName: form.writerName.value,
content: form.content.value,
}),
}
).catch(error => alert(error));
form.content.value = '';
form.content.focus();
}
function drawMoreChatMessage(message) {
lastChatMessageId = message.id;
console.log(lastChatMessageId);
chatMessagesEl
.insertAdjacentHTML(
"afterBegin",
`<li>${message.writerName} : ( ${message.id} ) ${message.content}</li>`
);
}
const socket = new SockJS('/ws');
const stompClient = Stomp.over(socket);
stompClient.connect({}, function (frame) {
console.log('Connected: ' + frame);
stompClient.subscribe(`/topic/chat/room/${roomId}/messageCreated`, function (data) {
const jsonData = JSON.parse(data.body);
drawMoreChatMessage(jsonData.data.message);
});
});
</script>
[ChatRoomController 수정]
- 다음은 ChatRoomController.java 클래스에서 wrtie() 메서드에 웹 소켓 관련 코드를 아래와 같이 추가해준다.
- 기존 wrtie() 메서드 아래에 작성했던 메서드들은 Ajax를 사용하기 위해 작성했던 코드기 때문에 지금은 삭제해도 무방하다.
- 이 때, messagingTemplate의 convertAndSend() 메서드를 호출하기 위해선 SimpMessagingTemplate를 반드시 선언해줘야한다.
- room.html에서 작성한 스크립트를 보면 화면에 메시지를 그려주는 부분인 drawMoreChatMessage() 메서드에서 인자로 JSON 형식으로 변환한 jsonData.data.message를 컨트롤러에서도 호환이 되도록 WriteResponseBody() 메서드에서 변수 선언을 message로 맞춰야한다.
- 모든 코드를 작성하면 Ajax 방식과 같이 실시간 채팅이 원할하게 이뤄지는 것을 확인할 수 있다.
@Controller
@RequestMapping("/chat/room")
@RequiredArgsConstructor
public class ChatRoomController {
private final ChatRoomService chatRoomService;
private final ChatMessageService chatMessageService;
private final SimpMessagingTemplate messagingTemplate;
@GetMapping("/{roomId}")
public String showRoom(
@PathVariable("roomId") final long roomId,
@RequestParam(value = "writerName", defaultValue = "NoName") final String writerName,
Model model
) {
ChatRoom room = chatRoomService.findById(roomId).get();
model.addAttribute("room", room);
return "domain/chat/chatRoom/room";
}
@GetMapping("/make")
public String showMake() {
return "domain/chat/chatRoom/make";
}
@PostMapping("/make")
public String make(
@RequestParam(value = "name") final String name
) {
chatRoomService.make(name);
return "redirect:/chat/room/list";
}
@GetMapping("/list")
public String showList(Model model) {
List<ChatRoom> chatRooms = chatRoomService.findAll();
model.addAttribute("chatRooms", chatRooms);
return "domain/chat/chatRoom/list";
}
@Getter
@Setter
public static class WriteRequestBody {
private String writerName;
private String content;
}
@Getter
@AllArgsConstructor
public static class WriteResponseBody {
private ChatMessage message;
}
@PostMapping("/{roomId}/write")
@ResponseBody
public RsData<?> write(
@PathVariable("roomId") final long roomId,
@RequestBody final WriteRequestBody requestBody
) {
ChatMessage chatMessage = chatRoomService.write(roomId, requestBody.getWriterName(), requestBody.getContent());
RsData<WriteResponseBody> writeRs = RsData.of("S-1", "%d번 메시지를 작성하였습니다.".formatted(chatMessage.getId()), new WriteResponseBody(chatMessage));
messagingTemplate.convertAndSend("/topic/chat/room/" + roomId + "/messageCreated", writeRs);
return RsData.of("S-1", "성공");
}
}
반응형
'Java > SpringBoot' 카테고리의 다른 글
[SpringBoot] 실전 서비스 배포 (1) (2) | 2024.07.24 |
---|---|
[SpringBoot] 결제 시스템 (0) | 2024.07.18 |
[SpringBoot] 멀티 채팅방 (2) (0) | 2024.06.19 |
[SpringBoot] 멀티 채팅방 (1) (2) | 2024.06.19 |
[SpringBoot] REST API (4) (0) | 2024.06.19 |