유저 간 소통을 할 수 있도록 실시간 채팅 구현을 해야 했었다. 채팅을 구현하거나 실시간성이 필요한 사이트의 경우 WebSocket 통신 프로토콜이 필요하다고 한다.
이번에 Spring에서 Websocket, STOMP를 이용하여 채팅 서버를 어떻게 구현해야 하는지 정리 해보자.
전체코드는 여기로 확인해주세요.
📚 목차
목차는 다음과 같다.
- HTTP 통신과 Socket 통신의 차이점
- STOMP로 채팅서버 고도화하기
- build.gradle에 라이브러리 추가
- ChatPreHandler 생성
- WebSocketConfig 생성
- 채팅 관련 Domain 설계
- 채팅창 Service 생성
- 채팅 Controller 생성
- 채팅창 Controller 생성
- 웹브라우저 확인
- 참고자료
- 회고
HTTP 통신과 Socket 통신의 차이점
HTTP 통신과 Socket 통신의 차이점
일반적인 HTTP 통신을 하는 서버들과 달리 채팅 서버는 Socket 통신을 하는 서버가 필요하다.
HTTP 통신은 Client의 요청이 있을 때만 서버가 응답하고 연결을 종료하는 단방향 통신이다.
그에 반해 Socket 통신
은 Server와 Client가 지속적으로 연결을 유지하고 양방향으로 통신
을 하는 방식이다. 주로 채팅과 같은 실시간성을 요구하는 서비스에서 많이 사용된다.
WebSocket은?
Websocket은 기존의 단방향 HTTP 프로토콜과 호환되어 양방향 통신을 제공하기 위해 개발된 프로토콜이다. 일반 socket 통신과 달리 HTTP 80 포트를 이용하므로 방화벽에 제약이 없다.접속을 HTTP 프로토콜을 이용하고, 통신은 자체적인 Websocket 프로토콜로 통신하게 된다.
웹소켓을 사용하게 되면, 클라이언트와 서버가 한번 연결을 맺으면 계속 유지되고 서로 양방향 통신이 가능해진다.
STOMP로 채팅서버 고도화하기
Websocket을 통하여 간단한 서버/클라이언트 통신을 구현할 수 있다.
하지만 단순한 통신 구조로 인해 Websocket만을 이용해 구현하면 해당 메시지가 어떤 요청인지,
어떻게 처리해야 되는지에 따라 채팅방과 세션을 일일이 구현하고 메시지 발송 부분을 관리하는 추가 코드를 구현해 줘야 한다.
Websocket의 프로세스를 좀 더 고도화하고 메시징에 좀 더 최적화된 방식을 구현하기 위해 STOMP를 적용했다.
STOMP란?
STOMP는 메시징 전송을 효율적으로 하기 위해 나온 프로토콜이며 기본적으로 pub/sub 구조로 되어 있다.
메시지를 발송하고, 메시지를 받아 처리하는 부분이 확실히 정해져 있기 때문에 개발하는 입장에서 이점이 있다.
또한 STOMP를 이용하면 통신 메시지의 헤더에 값을 세팅할 수 있어 헤더 값을 기반으로 통신 시 인증처리를 구현하는 것도 가능하다.
pub/sub란 메시지를 공급하는 주체와 소비하는 주체를 분리하여 제공하는 메시징 방법이다.
pub/sub를 채팅방에 대입하면 다음과 같다.
- 채팅방을 생성 : pub/sub 구현을 위한 Topic이 하나 생성된다.
- 채팅방에 입장 : Topic을 구독한다.
- 채팅방에서 메시지를 보내고 받는다. : 해당 Topic으로 메시지를 보내다(pub), 메시지를 받는다(sub).
build.gradle에 라이브러리 추가
다음과 같이 Websocket, STOMP 관련 라이브러리를 추가한다.
webjar는 채팅 웹 화면을 구현하기 위해 필요한 JS를 로드하기 위해 추가했다.
sokjs는 stomp 방식으로 통신하기 위한 JS다.
// WebSocket
implementation 'org.springframework.boot:spring-boot-starter-websocket'
//stomp
implementation 'org.webjars:stomp-websocket:2.3.3'
implementation 'org.webjars:webjars-locator-core'
implementation 'org.webjars:sockjs-client:1.0.2'
implementation 'org.webjars:jquery:3.6.0'
SockJS
SockJS는 다양한 기술을 이용해 웹소켓을 지원하지 않는 환경에서도 정상적으로 동작하도록 해준다.
전송 타입은 크게 다음 3가지로 나눠진다.
- WebSocket
- HTTP Streaming
- HTTP Long Polling
ChatPreHandler 생성
Websocket 연결 시 요청 Hearder의 JWT Token 유효성을 검증하는 코드를 다음과 같이 추가한다.
유효하지 않은 JWT Token이 세팅될 경우, Websocket 연결을 하지 않고 예외 처리된다.
package inf.questpartner.config.chat;
import inf.questpartner.config.login.jwt.JwtProperties;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.ChannelInterceptor;
import org.springframework.stereotype.Component;
@RequiredArgsConstructor
@Component
@Slf4j
public class ChatPreHandler implements ChannelInterceptor {
private final JwtProperties jwtProperties; // JWT 관련 속성 클래스
private static final String BEARER_PREFIX = "Bearer "; // JWT가 시작하는 접두사
// websocket을 통해 들어온 요청이 처리 되기전 실행된다.
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
if (StompCommand.CONNECT == accessor.getCommand()) { // websocket 연결요청
String jwtToken = accessor.getFirstNativeHeader("Authorization");
log.info("CONNECT {}", jwtToken);
// Header의 jwt token 검증
String token = jwtToken.substring(7);
jwtProperties.validateToken(token);
}
return message;
}
}
WebSocketConfig 생성
package inf.questpartner.config.chat;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.ChannelRegistration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
@Configuration
@RequiredArgsConstructor
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
private final ChatPreHandler chatPreHandler;
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.setApplicationDestinationPrefixes("/send");
registry.enableSimpleBroker("/room");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws-stomp").setAllowedOriginPatterns("*").withSockJS();
}
// websocket 앞단에서 token을 체크하도록 인터셉터로 설정한다.
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(chatPreHandler);
}
}
1. @EnableWebSocketMessageBroker
STOMP를 사용하기 위해 @EnableWebSocketMessageBroker을 선언하고 WebSocketMessageBrokerConfigurer를 상속받아 configureMessageBroker를 구현한다.
pub/sub 메시징을 구현하기 위해 메시지를 발행하는 요청의 prefix는 "/send"로 시작하도록 설정하고 메시지를 구독하는 요청의 prefix는 "/room"로 시작하도록 설정한다.
STOMP Websocket의 연결 endpoint는 "/ws-stomp"로 설정한다.
그리고 도메인이 다른 서버에서도 접속 가능하도록 CORS : setAllowdOrigins("*")를 설정을 추가해 준다. 따라서 개발서버의 접속 주소는 다음과 같다.
WS://localhost:8080/ws-stomp
2. STOMPHandler 인터셉터 설정
위에 생성한 ChatPreHandler가 Websocket 앞단에서 Token을 체크할 수 있도록 다음과 같이 인터셉터로 설정한다.
채팅 관련 Domain 설계
객체 관계도는 다음과 같다.
JoinChat
- JoinChat 엔티티는 User(사용자)와 채팅창(ChatBox)의 연결해주는 역할을 한다.
- 한 사용자가 여러 채팅방에 참여할 수 있고, 한 채팅창에 여러 사용자가 참여할 수 있는 조건을 만족한다.
- JoinChat을 통해 채팅에 참여한 사용자를 조회할 수 있다.
- JoinChat을 통해 특정 사용자가 참여한 채팅창을 조회할 수 있다.
Chat
- Chat 엔티티는 User(사용자)와 채팅창(ChatBox)의 메시지 교환 역할을 한다.
- 한 사용자가 여러 채팅창에서 메시지를 보낼 수 있다.
- 한 채팅창에서 여러 사용자가 보낸 메시지를 볼 수 있다.
- Chat을 통해 각 채팅창에서 있는 메시지를 추적할 수 있으며, 해당 메시지를 보낸 사용자를 찾을 수 있다.
ChatBox
- ChatBox 엔티티는 User(사용자)들이 채팅하는 공간이다.
- 하나의 채팅창에는 여러 사용자가 참여하며, 메시지를 주고 받는다.
채팅창에 새로 참여한 사용자인지 확인하는 로직 : findByUserIdAndChatBoxId()
사용자 이메일과 채팅창 ID를 기반으로 JoinChat 엔터티를 조회한다.
이 메서드를 통해, 특정 채팅창에 새로 참여한 사용자가 맞는지 확인할 수 있다.
package inf.questpartner.repository.chat;
import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.jpa.impl.JPAQueryFactory;
import inf.questpartner.domain.chat.JoinChat;
import jakarta.persistence.EntityManager;
import java.util.List;
import static inf.questpartner.domain.chat.QChatBox.chatBox;
import static inf.questpartner.domain.chat.QJoinChat.*;
import static inf.questpartner.domain.users.user.QUser.user;
public class JoinChatRepositoryCustomImpl implements JoinChatRepositoryCustom {
private final JPAQueryFactory queryFactory;
public JoinChatRepositoryCustomImpl(EntityManager em) {
this.queryFactory = new JPAQueryFactory(em);
}
@Override
public List<JoinChat> findByUserIdAndChatBoxId(String email, Long chatBoxId) {
List<JoinChat> result = queryFactory.selectFrom(joinChat)
.leftJoin(joinChat.chatBox, chatBox)
.leftJoin(joinChat.user, user)
.where(emailContains(email), chatRoomIdContains(chatBoxId))
.fetch();
return result;
}
private static BooleanExpression emailContains(String email) {
return email != null ? joinChat.user.email.eq(email) : null;
}
private static BooleanExpression chatRoomIdContains(Long chatRoomId) {
return chatRoomId != null ? joinChat.chatBox.id.eq(chatRoomId) : null;
}
}
채팅창 Service 생성
채팅창을 생성하고 정보를 조회하는 Service를 생성한다.
package inf.questpartner.service;
import inf.questpartner.domain.chat.ChatBox;
import inf.questpartner.domain.chat.JoinChat;
import inf.questpartner.domain.users.user.User;
import inf.questpartner.repository.chat.ChatBoxRepository;
import inf.questpartner.repository.chat.JoinChatRepository;
import inf.questpartner.repository.users.UserRepository;
import inf.questpartner.util.exception.ResourceNotFoundException;
import inf.questpartner.util.exception.room.NotFoundRoomException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.UUID;
@Service
@Transactional
@RequiredArgsConstructor
@Slf4j
public class ChatBoxService {
private final ChatBoxRepository chatBoxRepository;
private final JoinChatRepository joinChatRepository;
private final UserRepository userRepository;
//채팅창 생성
public Long createRoom(User user) {
User enterUser = userRepository.findByEmail(user.getEmail()).orElseThrow(
() -> new ResourceNotFoundException("User", "User Email", user.getEmail()));
ChatBox chatBox = createChatBox();
JoinChat joinChat = createJoinChat(enterUser, chatBox);
Long chatRoomId = chatBoxRepository.save(chatBox).getId();
joinChatRepository.save(joinChat);
return chatRoomId;
}
//스터디방에 새로운 사람이 들어왔다.
public void joinToChatRoom(String email, Long chatRoomId) {
User enterUser = userRepository.findByEmail(email).orElseThrow(
() -> new ResourceNotFoundException("User", "User Email", email));
ChatBox chatBox = chatBoxRepository.findById(chatRoomId)
.orElseThrow(() -> new NotFoundRoomException("존재하지 않는 채팅방입니다."));
JoinChat joinChat = createJoinChat(enterUser, chatBox);
List<JoinChat> joinChatList = joinChatRepository.findByUserIdAndChatBoxId(enterUser.getEmail(), chatRoomId);
if (joinChatList.isEmpty()) {
log.info(enterUser.getNickname() + "님이 스터디방에 입장했습니다.");
joinChatRepository.save(joinChat);
}
}
private static JoinChat createJoinChat(User user, ChatBox chatBox) {
return JoinChat.builder()
.user(user)
.chatBox(chatBox)
.build();
}
private static ChatBox createChatBox() {
return ChatBox.builder()
.name(UUID.randomUUID().toString())
.build();
}
}
1. 채팅창 생성 로직: createRoom(User user)
주어진 사용자 정보를 기반으로 새로운 채팅창을 만들고, 해당 사용자를 채팅창에 참여(JoinChat)시킨다.
2. 채팅창 입장 로직: joinToChatRoom(String email, Long chatRoomId)
새로운 사용자가 채팅창에 입장했을 때 경우를 처리한다.
채팅방에 처음으로 들어온 경우에 한하여, 새로운 참여 정보(JoinChat)를 저장한다.
채팅 Controller 생성
- @MessageMapping을 통해 Websocket으로 들어오는 메시지 발행을 처리한다.
- 클라이언트에서는 prefix를 붙여 "/send/{chatBoxId}"로 발행 요청을 하면 Controller가 메시지를 받아 처리한다.
- 메시지가 발행되면 "/room/{chatBoxId}"로 메시지를 send한다.
- 클라이언트에서는 해당 주소("/room/{chatBoxId}")를 구독(subscribe)하고 있다가 메시지가 전달되면 화면에 출력하면 된다.
- 여기서 "/room/{chatBoxId}"는 채팅창을 구분하는 값이므로 pub/sub에서 Topic의 역할이라고 보면 된다.
ChatController 코드는 아래와 같다.
package inf.questpartner.controller.api.chat;
import inf.questpartner.config.login.jwt.JwtProperties;
import inf.questpartner.domain.users.user.User;
import inf.questpartner.dto.chat.ChatDto;
import inf.questpartner.repository.users.UserRepository;
import inf.questpartner.service.ChatService;
import inf.questpartner.service.UserService;
import inf.questpartner.util.exception.ResourceNotFoundException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.messaging.handler.annotation.DestinationVariable;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.stereotype.Controller;
import org.springframework.messaging.handler.annotation.Header;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import java.util.List;
@Controller
@RequiredArgsConstructor
@Slf4j
public class ChatController {
private final ChatService chatService;
private final UserRepository userRepository;
private final JwtProperties jwtProperties;
private final UserService userService;
@MessageMapping("/{chatBoxId}")
@SendTo("/room/{chatBoxId}") // 여길 구독하고 있는 곳으로 메시지 전송
public ChatDto messageHandler(@DestinationVariable("chatBoxId") Long roomId, ChatDto message, @Header("Authorization") String token) {
log.info("chat controller -> token check={}", token);
String email = jwtProperties.getEmailFromJwt(token);
log.info("chat controller jwt check --> email {}", email);
User currentUser = userRepository.findByEmail(email).orElseThrow(() -> new ResourceNotFoundException("User", "User Email", email));
return chatService.createChat(roomId, message.getMessage(), currentUser.getNickname());
}
@GetMapping("/{chatBoxId}")
public ResponseEntity<List<ChatDto>> findAllChat(@PathVariable(name = "chatBoxId", required = false) Long chatBoxId) {
List<ChatDto> chatList = chatService.findAllByChatBoxId(chatBoxId);
return ResponseEntity.status(HttpStatus.OK).body(chatList);
}
}
채팅창 Controller 생성
ChatController까지 완료했다면 채팅구현은 끝났다.
Websocket 통신 외에 채팅 화면 View 구성을 만들어 잘 동작되는지 확인해보자.
package inf.questpartner.controller;
import inf.questpartner.domain.chat.ChatBox;
import inf.questpartner.domain.users.user.User;
import inf.questpartner.dto.chat.ChatDto;
import inf.questpartner.service.ChatBoxService;
import inf.questpartner.service.ChatService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
import java.util.List;
@Controller
@RequiredArgsConstructor
public class IndexController {
private final ChatBoxService roomService;
private final ChatService chatService;
// 로그인 창
@GetMapping("/view/login")
public String loginForm() {
return "chat/login";
}
// 채팅창 리스트 조회
@GetMapping("/roomList")
public String roomList(Model model) {
List<ChatBox> roomList = roomService.findAll();
model.addAttribute("roomList", roomList);
return "chat/roomList";
}
// 특정 채팅창에 보여줄 채팅 DTO 조회
@GetMapping("/view/{chatBoxId}")
public ResponseEntity<List<ChatDto>> joinRoom(@PathVariable(name = "chatBoxId", required = false) Long chatBoxId, @RequestParam("email") String email) {
roomService.joinToChatRoom(email, chatBoxId);
List<ChatDto> chatList = chatService.findAllByChatBoxId(chatBoxId);
return ResponseEntity.status(HttpStatus.OK).body(chatList);
}
// 특정 채팅창에 입장
@GetMapping("/view/join")
public String viewChatBox() {
return "chat/roomin";
}
}
채팅창 리스트 VIEW
채팅창 리스트에서 특정 채팅창 입장을 위한 view를 구현하자.
채팅창 리스트 중에서 특정 채팅창 이름을 클릭하면, 서버에서 해당 채팅창에 있는 채팅을 가져와 로컬 변수에 저장한다.
그리고 JWT를 사용하여 로그인 구현했기 때문에 JWT 인증된 사용자인 경우만, "/view/join"으로 해당 채팅창에 입장할 수 있다.
전체코드는 여기에 있다.
채팅창 입장 요청을 처리하는 JS 코드는 다음과 같다.
<body>
<div class="container">
<h1>Room List</h1>
<table>
<thead>
<tr>
<th>Room Name</th>
</tr>
</thead>
<tbody>
<tr th:each="room : ${roomList}">
<td>
<!-- Thymeleaf에서 서버에서 미리 생성된 URL을 가져와서 href 속성에 설정 -->
<a class="room-link" href="#" th:text="${room.getName()}" th:data-chatboxid="${room.getId()}"></a>
</td>
</tr>
</tbody>
</table>
</div>
<script>
// 페이지 이동 함수 정의
function navigateWithToken(chatBoxId) {
// 토큰 가져오기
var token = localStorage.getItem('token');
localStorage.setItem('chatBoxId', chatBoxId);
// 헤더에 토큰을 포함하여 페이지 이동
window.location.href = '/view/join';
// 이후 서버로 요청이 보내지며, 헤더에 토큰이 포함됩니다.
}
// 서버에서 채팅 리스트를 가져와 로컬 변수에 저장하는 함수
function getChatListFromServer(chatBoxId) {
var email = localStorage.getItem('email');
$.ajax({
type: 'GET',
url: '/view/' + chatBoxId,
data: { email: email }, // email을 쿼리 파라미터로 전달
success: function(response) {
// 서버에서 받은 채팅 리스트를 localChatList에 저장
localStorage.setItem('localChatList', JSON.stringify(response));
console.log("채팅 리스트를 로컬 변수에 저장했습니다:", localChatList);
},
error: function(xhr, status, error) {
console.error("서버에서 채팅 리스트를 가져오는 도중 오류가 발생했습니다:", error);
}
});
}
$(document).ready(function() {
// Room 링크 클릭 시 이벤트 처리
$('.room-link').click(function(event) {
event.preventDefault(); // 기존의 링크 동작 방지
const chatBoxId = $(this).data('chatboxid'); // 해당 링크의 chatBoxId 가져오기
// 서버에서 채팅 리스트를 가져와 로컬 변수에 저장
getChatListFromServer(chatBoxId);
// 페이지 이동 함수 호출
navigateWithToken(chatBoxId);
});
});
</script>
</body>
채팅창 VIEW
채팅창 view 파일은 여기에서 확인해주세요.
채팅창에 입장했을 때의 View다. 접속한 클라이언트 간에 간단하게 채팅 메시지를 주고받을 수 있다. 입장 시엔 "/ws-stomp"로 서버 연결을 한 후에 채팅창을 구독하는 액션을 수행하는 것을 볼 수 있다.
해당 프로젝트는 "/room/{chatBoxId}"로 구독하도록 작성했다. 이 주소를 Topic으로 삼아 서버에서 메시지를 발행한다.
이제 채팅 웹 화면을 구현하기 위해 작성한 JS를 살펴보며 정리해보자.
웹소켓
다음과 같이 웹소켓을 연결한다.
- 웹 소켓 연결 생성 → /ws-stomp
- 구독 설정 → /room/chatBoxId
// 웹 소켓 연결 생성
function connect() {
var socket = new SockJS('/ws-stomp');
stompClient = Stomp.over(socket);
let headers = {Authorization: "Bearer " + token};
stompClient.connect(headers, function (frame) {
console.log('Connected: ' + frame);
loadChat(chatList); // 저장된 채팅 불러오기
// 구독 설정
stompClient.subscribe('/room/' + chatBoxId, function (chatMessage) {
console.log(JSON.parse(chatMessage.body));
showChat(JSON.parse(chatMessage.body));
});
});
}
입력된 채팅 전송
JWT 인증된 사용자 경우, 채팅을 전송할 수 있다.
// 입력된 채팅 전송
function sendChat() {
const message = $("#message").val();
// WebSocket 통신을 위한 헤더 설정
const headers = {
"Authorization": "Bearer " + token // "Authorization" 키로 토큰을 보냄
};
stompClient.send("/send/" + chatBoxId, headers, JSON.stringify({message: message}));
$("#message").val("");
}
저장된 채팅 불러와 화면에 표시
특정 채팅방에 있는 채팅 데이터 chatList를 화면에 출력한다.
// 저장된 채팅 불러와 화면에 표시
function loadChat(chatList) {
if (chatList != null) {
for (let chat of chatList) {
let chatHtml = '';
if (chat.sender !== nickname) {
chatHtml = `<div class="chat ch1">
<div class="icon"><i class="fa-solid fa-user"></i></div>
<div class="sender">${chat.sender}</div>
<div class="textbox">${chat.message}</div>
</div>`;
} else if (chat.sender === nickname) {
chatHtml = `<div class="chat ch2">
<div class="icon"><i class="fa-solid fa-user"></i></div>
<div class="sender">${chat.sender}</div>
<div class="textbox">${chat.message}</div>
</div>`;
}
$("#chatting").append(chatHtml);
}
}
}
실시간으로 받은 채팅을 화면에 표시
보낸 이름(사용자 닉네임)과 해당 메시지를 담은 ChatMessage DTO로 조회된다.
// 실시간으로 받은 채팅을 화면에 표시
function showChat(chatMessage) {
let chatHtml = '';
if (chatMessage.sender !== nickname) {
chatHtml = `<div class="chat ch1">
<div class="icon"><i class="fa-solid fa-user"></i></div>
<div class="sender">${chatMessage.sender}</div>
<div class="textbox">${chatMessage.message}</div>
</div>`;
} else if (chatMessage.sender === nickname) {
chatHtml = `<div class="chat ch2">
<div class="icon"><i class="fa-solid fa-user"></i></div>
<div class="sender">${chatMessage.sender}</div>
<div class="textbox">${chatMessage.message}</div>
</div>`;
}
$("#chatting").append(chatHtml);
scrollChatToBottom(); // 스크롤 항상 아래로 유지
}
웹브라우저 확인
다음과 같이 여러 사용자들과 채팅을 할 수 있다.
개발자 도구에서 콘솔 확인
웹브라우저에서 채팅을 확인할 때, 개발자 도구로 pub/sub 흐름을 콘솔로 확인할 수 있다. 쉽게 이해하기 위해서 pub/sub 흐름을 그림으로 정리했다.
아래 그림과 같이 /send을 발행(pub)로 /room을 구독(sub)으로 치환해서 보면 더 쉽다.
- MESSAGE : 헤더 및 페이로드를 포함한 메시지를 말한다.
- SimpleAnnotationMethod : @MessageMapping을 이용해 메시지를 처리한다.
- MessageHandler : 메시지 처리를 위한 계약.
- SimpleBroker : subscription을 메모리에 저장하고 연결된 client에게 메시지를 보낸다.
발행(/send)
- destination에 /send로 들어오게 되면 @MessagingMapping 애노테이션이 붙은 스프링 컨트롤러로 매핑된다.
- 컨트롤러에서 메시지를 처리한 후에 /room으로 브로커에게 전달하면 브로커는 MESSAGE COMMAND를 이용해 구독하고 있는 모든 구독자들에게 response를 전송한다.
구독(/room)
- destination에 /room으로 들어오게 되면 스프링 컨트롤러를 거치지 않고 브로커에게 바로 접근하게 된다.
- SUBSCRIBE COMMAND의 경우가 여기에 해당되며 이 경우 브로커가 구독자를 메모리에 저장하여 관리한다.
아래 예시와 함께 살펴보자.
(1) 클라이언트 0번이 8번 채팅창에 구독한다.
>>> SUBSCRIBE
id:sub-0
destination:/room/8
(2) 8번 채팅창에서 메시지를 전송한다.
>>> SEND
Authorization:Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJiYm9uZzEyQGdtYWlsLmNvbSIsImlhdCI6MTcxMDA3MDE5MiwiZXhwIjoxNzEwMDg4MTkyfQ.NA4CbOUVr7gMHORR2ydlFsqIgdxmLbhVjy0XfmebPx2yasw1NM_FeIKwci7QgbM8i47D27rkahtCRUrz9RguJQ
destination:/send/8
content-length:19
{"message":"hello"}
(3) Message
위에서 발행(/send) 흐름에 해당하는 과정이 일어나게 되어 컨트롤러에서 메시지 처리가 일어난 후,
모든 구독자에게 메시지를 Broadcasting(전체 전송)을 하기 위해 아래와 같은 MESSAGE COMMAND를 전송한다.
<<< MESSAGE
destination:/room/8
content-type:application/json
subscription:sub-0
message-id:s5xe13su-0
content-length:91
{"chatBoxId":8,"sender":"bbong","message":"hello","sendDate":"2024-03-10T20:30:02.1571035"}
참고자료
회고
스터디 커뮤니티 프로젝트를 진행하면서 실시간 채팅을 구현하기 위해 필요한 기술들을 공부했었다. Websocket, STOMP 그리고 SockJS을 공부하면서 각각의 역할과 메시지 전송 흐름에 대해 이해할 수 있었다.
개인적으로 프론트엔드 팀원들에게 전달하기 전에, 채팅 웹 화면으로 테스트해 보고 싶었었다.
( API 흐름과 서버 연결을 실제 동작되는 웹 화면으로 설명하면 쉽게 내용을 전달할 수 있다고 생각했었다. 피드백을 위해서 구현했지만 좋은 경험이었다.)
이를 위해 자바스크립트를 사용하여 채팅 웹 화면을 구현해야 했었다.
필요한 JS를 로드하기 위해 많은 삽질을 하게 되었다. 이런 경험 또한 중요한 배움의 과정이겠지?
'프로젝트 > 팀프로젝트 qp편' 카테고리의 다른 글
JPA와 테이블 설계 (0) | 2024.03.22 |
---|---|
SpringBoot 3.x 버전 QueryDSL 설정 (0) | 2024.03.21 |
JPA N+1 발생 케이스과 MultipleBagFetchException 해결책 (0) | 2024.03.20 |
관계형 데이터베이스에서의 컬렉션 처리 : Room 엔티티의 구조 개선 (0) | 2024.03.18 |
Spring Security와 JWT로 로그인 구현 (0) | 2024.03.13 |
댓글