🍀 Spring
WebSocket으로 채팅 기능 만들기
GitHub Source Code
목표 기능
- WebSocket, STOMP을 이용한 채팅 기능
- 채팅방 정보, 회원 참여 정보 DB에 유지
- 연결 종료 감지
- sessionId
- 회원만 접근 가능
WebSocket이란?
기존의 단방향 HTTP 프로토콜과 호환되어 양방향 통신을 제공하기 위해 개발된 프로토콜
STOMP란?
메시지 전송을 효율적으로 하기 위한 프로토콜로, 메시지를 공급하는 주체와 소비하는 주체를 분리해 제공하는 Publish-Subscribe 구조로 되어있다.
특징, 작동 원리는 아래 링크 참고
당부 사항
- 구현 전에 구조가 완전히 이해가 되지 않는다면, 을 fork하여 실행해보길 바란다.gs-messaging-stomp-websocketspring-guides • Updated Aug 30, 2023
- 디버그 모드로 컨트롤러 쪽에 BreakPoint를 찍고 확인하면 이해가 더 쉬울 것이다.
구현
Dependency
//WebSocket implementation 'org.springframework.boot:spring-boot-starter-websocket' //stomp implementation 'org.webjars:stomp-websocket:2.3.4' //클라이언트를 위한 라이브러리 //sockjs implementation 'org.webjars:sockjs-client:1.5.1' //jQuery implementation 'org.webjars:jquery:3.6.4' //Webjars implementation 'org.webjars:webjars-locator-core'
Config
package site.websocketchat.config.websocket; import org.springframework.context.annotation.Configuration; 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 @EnableWebSocketMessageBroker public class WebSocketMessageBrokerConfig implements WebSocketMessageBrokerConfigurer { @Override public void registerStompEndpoints(StompEndpointRegistry registry) { //stomp 접속 주소 url 설정 registry.addEndpoint("/stomp/chat") .setAllowedOrigins("https://localhost:8080") .withSockJS(); } @Override public void configureMessageBroker(MessageBrokerRegistry registry) { //메시지를 구독하는 요청 url => 메시지 받을 때 registry.enableSimpleBroker("/sub"); //메시지를 발행하는 요청 url => 메시지 보낼 때 registry.setApplicationDestinationPrefixes("/pub"); } }
채팅 메시지 DTO
package site.websocketchat.dto.chat; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import site.websocketchat.enumstorage.chat.MessageType; @Getter @NoArgsConstructor public class ChatDto { private MessageType messageType; private Long chatRoomId; private Long memberId; private String writer; private String message; @Builder protected ChatDto(MessageType messageType, Long chatRoomId, Long memberId, String writer, String message) { this.messageType = messageType; this.chatRoomId = chatRoomId; this.memberId = memberId; this.writer = writer; this.message = message; } public void setMessage(String message) { this.message = message; } }
오고 가는 채팅 메시지에 대한 Data Transfer Object
ChatRoom Entity
package site.websocketchat.domain; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import site.websocketchat.dto.chat.ChatRoomDto; import site.websocketchat.enumstorage.errormessage.ChatRoomErrorMessage; import site.websocketchat.exception.ChatRoomException; import javax.persistence.*; import javax.validation.constraints.NotBlank; import javax.validation.constraints.NotNull; import java.util.HashSet; import java.util.Set; @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class ChatRoom { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @NotBlank @Column(unique = true) private String name; @NotNull private Long maxCapacity; @NotNull private Long currentCapacity = 0L; @NotNull @OneToMany(mappedBy = "chatRoom") private Set<ChatRoomMember> chatRoomMembers = new HashSet<>(); @Builder protected ChatRoom(String name, Long maxCapacity) { this.name = name; this.maxCapacity = maxCapacity; } //== 비즈니스 로직 ==// public void joinChatRoomMember(ChatRoomMember chatRoomMember) { if (this.getCurrentCapacity() >= this.getMaxCapacity()) { throw new ChatRoomException(ChatRoomErrorMessage.CHAT_ROOM_IS_FULL.getMessage()); } chatRoomMembers.add(chatRoomMember); currentCapacity += 1; } public void leaveChatRoomMember(ChatRoomMember chatRoomMember) { chatRoomMembers.remove(chatRoomMember); currentCapacity -= 1; } //== Dto ==// public ChatRoomDto toChatRoomDto() { return ChatRoomDto.builder() .id(this.getId()) .name(this.getName()) .maxCapacity(this.getMaxCapacity()) .currentCapacity(this.getCurrentCapacity()) .build(); } }
- 채팅방에 대한 상태값 저장
- 채팅방 참여 회원에 대한 일대다 관계
- 비즈니스 로직(최대 정원)
ChatRoom Service
package site.websocketchat.service; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import site.websocketchat.domain.ChatRoom; import site.websocketchat.domain.ChatRoomMember; import site.websocketchat.domain.member.Member; import site.websocketchat.dto.chat.ChatRoomDto; import site.websocketchat.enumstorage.chat.Capacity; import site.websocketchat.enumstorage.errormessage.ChatRoomErrorMessage; import site.websocketchat.enumstorage.errormessage.MemberErrorMessage; import site.websocketchat.exception.ChatRoomException; import site.websocketchat.repository.chat.ChatRoomMemberRepository; import site.websocketchat.repository.chat.ChatRoomRepository; import site.websocketchat.repository.member.MemberRepository; import java.util.List; import java.util.stream.Collectors; @Service @Transactional(readOnly = true) @RequiredArgsConstructor public class ChatRoomService { private final MemberRepository memberRepository; private final ChatRoomRepository chatRoomRepository; private final ChatRoomMemberRepository chatRoomMemberRepository; public ChatRoomDto findById(Long id) { return chatRoomRepository.findById(id) .orElseThrow(() -> new ChatRoomException(ChatRoomErrorMessage.CHAT_ROOM_NOT_FOUND.getMessage())) .toChatRoomDto(); } public List<ChatRoomDto> findAllChatRooms() { return chatRoomRepository.findAll().stream() .map(ChatRoom::toChatRoomDto) .collect(Collectors.toList()); } @Transactional public Long createChatRoom(String name, Long capacity) throws ChatRoomException { if (capacity > Capacity.MAX.getCapacity()) { throw new ChatRoomException(ChatRoomErrorMessage.CAPACITY_EXCEEDS_MAX.getMessage()); } if (capacity < Capacity.MIN.getCapacity()) { throw new ChatRoomException(ChatRoomErrorMessage.CAPACITY_BELOW_MIN.getMessage()); } if (name.isBlank()) { throw new ChatRoomException(ChatRoomErrorMessage.NAME_IS_BLANK.getMessage()); } ChatRoom chatRoom = ChatRoom.builder() .name(name) .maxCapacity(capacity) .build(); return chatRoomRepository.save(chatRoom).getId(); } @Transactional public void joinChatRoom(Long chatRoomId, Long memberId, String sessionId) { //chatRoomMember에 똑같은 속성의 chatRoomMember가 있는지 확인 chatRoomMemberRepository.findByChatRoomIdAndMemberId(chatRoomId, memberId) //있으면 예외 발생 .ifPresent(chatRoomMember -> { throw new ChatRoomException(ChatRoomErrorMessage.ALREADY_JOINED.getMessage()); }); //chatRoomMember가 없으면 ChatRoom chatRoom = chatRoomRepository.findById(chatRoomId) .orElseThrow(() -> new ChatRoomException(ChatRoomErrorMessage.CHAT_ROOM_NOT_FOUND.getMessage())); Member member = memberRepository.findNotDeletedById(memberId) .orElseThrow(() -> new ChatRoomException(MemberErrorMessage.NO_SUCH_MEMBER.getMessage())); ChatRoomMember chatRoomMember = ChatRoomMember.builder() .sessionId(sessionId) .chatRoom(chatRoom) .member(member) .build(); chatRoomMemberRepository.save(chatRoomMember); } @Transactional public ChatRoomMember leaveChatRoom(String sessionId) { ChatRoomMember chatRoomMember = chatRoomMemberRepository.findWithChatRoomAndMemberBySessionId(sessionId) .orElseThrow(() -> new ChatRoomException(ChatRoomErrorMessage.NOT_JOINED.getMessage())); ChatRoom chatRoom = chatRoomMember.getChatRoom(); Member member = chatRoomMember.getMember(); chatRoom.leaveChatRoomMember(chatRoomMember); member.leaveChatRoomMember(chatRoomMember); chatRoomMemberRepository.delete(chatRoomMember); return chatRoomMember; } @Transactional public void deleteChatRoom(Long chatRoomId) { chatRoomMemberRepository.findByChatRoomId(chatRoomId) .forEach(chatRoomMember -> { Member member = memberRepository.findNotDeletedById(chatRoomMember.getMember().getId()) .orElseThrow(() -> new ChatRoomException(MemberErrorMessage.NO_SUCH_MEMBER.getMessage())); member.leaveChatRoomMember(chatRoomMember); chatRoomMemberRepository.delete(chatRoomMember); }); ChatRoom chatRoom = chatRoomRepository.findById(chatRoomId) .orElseThrow(() -> new ChatRoomException(ChatRoomErrorMessage.CHAT_ROOM_NOT_FOUND.getMessage())); chatRoomRepository.delete(chatRoom); } }
- 채팅방에 대한 서비스 로직
Chat Controller
package site.websocketchat.controller; import lombok.RequiredArgsConstructor; import org.springframework.context.event.EventListener; import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.handler.annotation.Payload; import org.springframework.messaging.simp.SimpMessageHeaderAccessor; import org.springframework.messaging.simp.SimpMessageSendingOperations; import org.springframework.stereotype.Controller; import org.springframework.web.socket.messaging.SessionDisconnectEvent; import site.websocketchat.domain.ChatRoomMember; import site.websocketchat.dto.chat.ChatDto; import site.websocketchat.enumstorage.chat.MessageType; import site.websocketchat.service.ChatRoomService; @Controller @RequiredArgsConstructor public class ChatController { private final SimpMessageSendingOperations simpMessageSendingOperations; private final ChatRoomService chatRoomService; //Config Class의 registry.setApplicationDestinationPrefixes("/pub")으로 설정한 접두어 "/pub"가 앞에 붙는다. @MessageMapping("/chat-room/join") public void joinChatRoom(@Payload ChatDto chatDto, SimpMessageHeaderAccessor headerAccessor) { //채팅방에 입장하여 클라이언트가 메시지를 보내면, 이 부분에서 sessionId를 얻을 수 있다. String sessionId = headerAccessor.getSessionId(); chatRoomService.joinChatRoom(chatDto.getChatRoomId(), chatDto.getMemberId(), sessionId); chatDto.setMessage(chatDto.getWriter() + "님이 입장하셨습니다."); simpMessageSendingOperations.convertAndSend("/sub/chat-room/" + chatDto.getChatRoomId(), chatDto); } @MessageMapping("/chat-room/send-message") public void sendMessage(@Payload ChatDto chatDto) { simpMessageSendingOperations.convertAndSend("/sub/chat-room/" + chatDto.getChatRoomId(), chatDto); } @EventListener public void webSocketDisconnectListener(SessionDisconnectEvent event) { String sessionId = event.getSessionId(); ChatRoomMember chatRoomMember = chatRoomService.leaveChatRoom(sessionId); ChatDto chatDto = ChatDto.builder() .messageType(MessageType.LEAVE) .chatRoomId(chatRoomMember.getChatRoom().getId()) .memberId(chatRoomMember.getMember().getId()) .writer(chatRoomMember.getMember().getEmail()) .message(chatRoomMember.getMember().getEmail() + "님이 퇴장하셨습니다.") .build(); simpMessageSendingOperations.convertAndSend("/sub/chat-room/" + chatRoomMember.getChatRoom().getId(), chatDto); } }
- 채팅 수발신 컨트롤러
ChatRoom Controller
package site.websocketchat.controller; import lombok.RequiredArgsConstructor; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.validation.BindingResult; import org.springframework.validation.FieldError; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import site.websocketchat.config.auth.PrincipalUserDetails; import site.websocketchat.dto.chat.ChatRoomDto; import site.websocketchat.enumstorage.errormessage.ChatRoomErrorMessage; import site.websocketchat.exception.ChatRoomException; import site.websocketchat.form.ChatRoomCreateForm; import site.websocketchat.service.ChatRoomMemberService; import site.websocketchat.service.ChatRoomService; import java.util.List; @Controller @RequiredArgsConstructor public class ChatRoomController { private final ChatRoomService chatRoomService; private final ChatRoomMemberService chatRoomMemberService; @GetMapping("/chat-room/list") public String chatRoomList(Model model) { List<ChatRoomDto> allChatRooms = chatRoomService.findAllChatRooms(); model.addAttribute("allChatRooms", allChatRooms); return "chat/chatRoomList"; } @GetMapping("/chat-room/create") public String chatRoomCreateForm(Model model) { model.addAttribute("chatRoomCreateForm", new ChatRoomCreateForm()); return "chat/chatRoomCreateForm"; } @PostMapping("/chat-room/create") public String createChatRoom(ChatRoomCreateForm chatRoomCreateForm, Model model, BindingResult result) { try { chatRoomService.createChatRoom(chatRoomCreateForm.getName(), chatRoomCreateForm.getMaxCapacity()); } catch (ChatRoomException e) { if (e.getMessage().equals(ChatRoomErrorMessage.NAME_IS_BLANK.getMessage())) { result.addError(new FieldError("chatRoomCreateForm", "name", e.getMessage())); } if (e.getMessage().equals(ChatRoomErrorMessage.CAPACITY_EXCEEDS_MAX.getMessage())) { result.addError(new FieldError("chatRoomCreateForm", "maxCapacity", e.getMessage())); } else if (e.getMessage().equals(ChatRoomErrorMessage.CAPACITY_BELOW_MIN.getMessage())) { result.addError(new FieldError("chatRoomCreateForm", "maxCapacity", e.getMessage())); } if (result.hasErrors()) { model.addAttribute("chatRoomCreateForm", chatRoomCreateForm); return "chat/chatRoomCreateForm"; } } return "redirect:/chat-room/list"; } @GetMapping("/chat-room/join/{chatRoomId}") public String joinChatRoom(@PathVariable Long chatRoomId, Model model) { Long memberId = ((PrincipalUserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal()).getMember().getId(); try { chatRoomMemberService.findByChatRoomIdAndMemberId(chatRoomId, memberId); } //이미 채팅방에 참여한 경우 catch (ChatRoomException e) { model.addAttribute("message", e.getMessage()); model.addAttribute("href", "/chat-room/list"); return "message/message"; } model.addAttribute("chatRoomDto", chatRoomService.findById(chatRoomId)); model.addAttribute("memberId", memberId); return "chat/chatRoom"; } }
- 채팅방 생성, 참여, 리스트, 예외처리 컨트롤러
ChatRoom.html
<!DOCTYPE html> <html lang="en" xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security"> <script src="/webjars/jquery/jquery.min.js"></script> <script src="/webjars/sockjs-client/sockjs.min.js"></script> <script src="/webjars/stomp-websocket/stomp.min.js"></script> <th:block th:fragment="content"> <div class="container"> <div class="col-6"> <h1>[[${chatRoomDto.name}]]</h1> </div> <div> <div id="msgArea" class="col"></div> <div class="col-6"> <div class="input-group mb-3"> <input type="text" id="msg" class="form-control"> <div class="input-group-append"> <button class="btn btn-outline-secondary" type="button" id="button-send">전송</button> </div> </div> </div> </div> <div class="col-6"></div> </div> </th:block> <script th:inline="javascript"> $(document).ready(function() { let chatRoomName = [[${chatRoomDto.name}]]; let chatRoomId = [[${chatRoomDto.id}]]; let username = [[${#authentication.principal.username}]]; let memberId = [[${memberId}]]; console.log("chatRoomName is " + chatRoomName + ", chatRoomId is " + chatRoomId + ", username is " + username + ", memberId is " + memberId); let sockJs = new SockJS("/stomp/chat"); //1. SockJS를 내부에 들고있는 stomp를 내어줌 let stomp = Stomp.over(sockJs); //2. connection이 맺어지면 실행 stomp.connect({}, function () { console.log("STOMP Connection"); //4. subscribe(path, callback)으로 메세지를 받을 수 있음 stomp.subscribe("/sub/chat-room/" + chatRoomId, function (chat) { let content = JSON.parse(chat.body); let writer = content.writer; let str = ''; if (writer === username) { str = "<div class='col-6'>"; str += "<div class='alert alert-secondary'>"; str += "<b>" + writer + " : " + content.message + "</b>"; str += "</div></div>"; } else { str = "<div class='col-6'>"; str += "<div class='alert alert-warning'>"; str += "<b>" + writer + " : " + content.message + "</b>"; str += "</div></div>"; } $("#msgArea").append(str); }); //3. send(path, header, message)로 메세지를 보낼 수 있음 console.log("sockJs._transport.url is " + sockJs._transport.url); stomp.send('/pub/chat-room/join', {}, JSON.stringify({messageType: "ENTER", chatRoomId: chatRoomId, memberId: memberId, writer: username})) }); $("#button-send").on("click", function() { let msg = document.getElementById("msg"); console.log(username + " : " + msg.value); stomp.send('/pub/chat-room/send-message', {}, JSON.stringify({messageType: "TALK", chatRoomId: chatRoomId, memberId: memberId, writer: username, message: msg.value})); msg.value = ''; }); }); </script> </html>
- 채팅방 템플릿과 javascript
SessionId를 이용해 연결 종료(퇴장) 감지
사용자들은 브라우저의 탭을 닫는 등 (개발자 입장에선)비정상적인 방법을 많이 이용한다.
그래서 Disconnect 버튼과 관계없이, 클라이언트와 연결이 끊기면 서버에서 이를 감지하는 방법을 사용한다.
@EventListener + SessionDisconnectEvent
//ChatController.java ... @EventListener public void webSocketDisconnectListener(SessionDisconnectEvent event) { String sessionId = event.getSessionId(); ChatRoomMember chatRoomMember = chatRoomService.leaveChatRoom(sessionId); ChatDto chatDto = ChatDto.builder() .messageType(MessageType.LEAVE) .chatRoomId(chatRoomMember.getChatRoom().getId()) .memberId(chatRoomMember.getMember().getId()) .writer(chatRoomMember.getMember().getEmail()) .message(chatRoomMember.getMember().getEmail() + "님이 퇴장하셨습니다.") .build(); simpMessageSendingOperations.convertAndSend("/sub/chat-room/" + chatRoomMember.getChatRoom().getId(), chatDto); }
@EventListener
어노테이션과 SessionDisconnectEvent
파라미터를 조합한 메서드를 준비하면 웹 소켓 연결이 종료될 경우 SessionDisconnectEvent
파라미터에 연결종료 관련 정보가 담겨 핸들링이 가능했다. 그 중 sessionId
가 어떤 클라이언트인지를 식별 할 수 있다.sessionId
는 최초 서버와 클라이언트가 소켓으로 연결이 되는 시점에 발급되는 식별자다. sessionId
를 통해 연결 종료시 클라이언트를 식별하기 위해서는 DB의 회원 정보에 sessionId
를 저장해야한다.ChannelInterceptor
Spring Integration
스프링 기반 어플리케이션 내에 메시징 기반 서비스를 제공하고 선언적 어댑터를 사용해 외부 시스템과의 통합을 쉽게 해주는 프레임워크다. 리모팅, 메시징, 스케줄링과 같이 스프링이 제공하는 기능들을 추상화하고 있다. 그것 중 하나가
ChannelInterceptor
.StompInterceptor
package site.websocketchat.interceptor.chat; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Configuration; import org.springframework.messaging.Message; import org.springframework.messaging.MessageChannel; import org.springframework.messaging.MessageHeaders; import org.springframework.messaging.simp.stomp.StompCommand; import org.springframework.messaging.simp.stomp.StompHeaderAccessor; import org.springframework.messaging.support.ChannelInterceptor; @Configuration @RequiredArgsConstructor public class StompInterceptor implements ChannelInterceptor { @Override public Message<?> preSend(Message<?> message, MessageChannel channel) { StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message); if (accessor.getCommand() == StompCommand.DISCONNECT) { MessageHeaders messageHeaders = accessor.getMessageHeaders(); String simpSessionId = (String) messageHeaders.get("simpSessionId"); } if (accessor.getCommand() == StompCommand.CONNECT) { MessageHeaders messageHeaders = accessor.getMessageHeaders(); String simpSessionId = (String) messageHeaders.get("simpSessionId"); } if (accessor.getCommand() == StompCommand.SUBSCRIBE) { MessageHeaders messageHeaders = accessor.getMessageHeaders(); String simpSessionId = (String) messageHeaders.get("simpSessionId"); } return message; } }
ChannelInterceptor.preSend()
메서드를 구현하면 클라이언트에서 보낸 정보를 중간에 가로챌 수 있다.하지만
sessionId
는 아직 얻을 수 없다.연결 이후, sessionId 얻기
연결 직후 메시지를 1회 전송하는 방식으로 sessionId를 얻을 수 있다.
연결이 완료 된 직후
send()
를 통해 메시지를 보낸다면 서버에서 SimpMessageHeaderAccessor headerAccessor
를 분석해 sessionId
를 알아낼 수 있다. DB에 회원 정보에 sessionId
를 저장하면 소켓 연결이 강제로 종료 되었을 때도 sessionId
를 통해 접속 종료 처리가 가능하다.ChatRoom.html
<script th:inline="javascript"> ... //2. connection이 맺어지면 실행 stomp.connect({}, function () { ... //3. send(path, header, message)로 메세지를 보낼 수 있음 //이 메시지를 통해 서버에서 sessionId를 얻을 수 있음 console.log("sockJs._transport.url is " + sockJs._transport.url); stomp.send('/pub/chat-room/join', {}, JSON.stringify({messageType: "ENTER", chatRoomId: chatRoomId, memberId: memberId, writer: username})) }); ... </script>
연결 종료를 감지하면, 채팅방 나가기 로직 실행
Chat Controller
@EventListener public void webSocketDisconnectListener(SessionDisconnectEvent event) { String sessionId = event.getSessionId(); ChatRoomMember chatRoomMember = chatRoomService.leaveChatRoom(sessionId); ChatDto chatDto = ChatDto.builder() .messageType(MessageType.LEAVE) .chatRoomId(chatRoomMember.getChatRoom().getId()) .memberId(chatRoomMember.getMember().getId()) .writer(chatRoomMember.getMember().getEmail()) .message(chatRoomMember.getMember().getEmail() + "님이 퇴장하셨습니다.") .build(); simpMessageSendingOperations.convertAndSend("/sub/chat-room/" + chatRoomMember.getChatRoom().getId(), chatDto); }
정리
연결
- 클라이언트와 서버 간 연결을 진행한다.
- 연결에 성공하면, 클라이언트는 send()로 서버에 메세지를 전송한다.
- 서버는
SimpMessageHeaderAccessor headerAccessor
를 통해sessionId
정보를 얻어내고 DB의 회원 정보에sessionId
를 저장한다.
연결 종료
- 연결이 끊어질 경우 서버가 이를 감지하고,
sessionId
를 얻어낸다.
- 서버는
sessionId
를 통해 채팅방 나가기 로직을 실행한다.
추후 업데이트할 기능
- 채팅 내용 서버 저장
- 메시지 발신 시간을 서버 수신 기준으로 하기