🍀 Spring

WebSocket으로 채팅 기능 만들기

GitHub Source Code

목표 기능

  1. WebSocket, STOMP을 이용한 채팅 기능
  1. 채팅방 정보, 회원 참여 정보 DB에 유지
  1. 연결 종료 감지
  1. sessionId
  1. 회원만 접근 가능

WebSocket이란?

기존의 단방향 HTTP 프로토콜과 호환되어 양방향 통신을 제공하기 위해 개발된 프로토콜

STOMP란?

메시지 전송을 효율적으로 하기 위한 프로토콜로, 메시지를 공급하는 주체와 소비하는 주체를 분리해 제공하는 Publish-Subscribe 구조로 되어있다.
특징, 작동 원리는 아래 링크 참고

당부 사항

  • 디버그 모드로 컨트롤러 쪽에 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); }

정리

연결

  1. 클라이언트와 서버 간 연결을 진행한다.
  1. 연결에 성공하면, 클라이언트는 send()로 서버에 메세지를 전송한다.
  1. 서버는 SimpMessageHeaderAccessor headerAccessor를 통해 sessionId 정보를 얻어내고 DB의 회원 정보에 sessionId를 저장한다.

연결 종료

  1. 연결이 끊어질 경우 서버가 이를 감지하고, sessionId를 얻어낸다.
  1. 서버는 sessionId를 통해 채팅방 나가기 로직을 실행한다.

추후 업데이트할 기능

  • 채팅 내용 서버 저장
  • 메시지 발신 시간을 서버 수신 기준으로 하기

출처