10.29 추가 : 일반(문자) 채팅만 구현하는 코드는 git 의 master 브렌치를 참고해주시기 바랍니다. master-Webrtc-jpa 는 화상 채팅과 jpa 를 이용한 DB 연결을 포함하는 브렌치입니다.
1. 기본 개념 설명
STOMP
- STOMP 는 Simple Text Oriented Messaging Protocol 의 약자로 메시지 전송을 위한 프로토콜이다. 기본적인 Websocket 과 가장 크게 다른 점은 기존의 Websocket 만을 사용한 통신은 발신자와 수신자를 Spring 단에서 직접 관리를 해야만 했다. 즉 WebSocketHandler 를 만들어서 WebSocket 통신을 하는 사용자들을 Map 등으로 저장하고 이를 직접 관리하며 클라이언트에서 들어오는 메시지를 다른 사용자에게 전달하는 코드를 직접 구현해야만 했다.
그러나 STOMP 는 다르다! stmp 는 pub/sub 기반으로 동작하기 때문에 메시지의 송신, 수신에 대한 처리를 명확하게 정의 할 수 있다. 이 말인 즉슨 추가적으로 코드 작업할 필요 없이 @MessagingMapping 같은 어노테이션을 사용해서 메시지 발행 시 엔드포인트만 조정해줌으로써 훨씬 쉽게 메시지 전송/수신이 가능하다!
그럼 여기서 Sub/Pub 개념에 대해서 알아야 한다.
채팅방 생성 : pub/sub 구현을 위한 Topic 생성 -> 즉 채팅방과 그에 맞는 주제 혹은 채팅방 명을 생각하면 된다.
채팅방 입장 : Topic 구독(sub) -> 해당 채팅방을 웹 소켓이 연결되어있는 동안 구독한다. 구독의 개념은 해당 채팅방을 지속적으로 바라본다라고 생각하면 좋다. 지속적으로 연결되고 바라보고 있기 때문에 새로운 채팅이 송신(pub) 되면 이를 수신(구독, sub) 할 수 있다
==> 이해하기 힘들면 넷플릭스나 디즈니 플러스 같은 '구독' 서비스를 생각해보자. 해당 서비스를 내가 '구독' 하는 상태인 동안 연결해서 내용을 수신(sub) 가능하기 때문!!
채팅방 메시지 수신 : 해당 Topic 로 메시지 송신(pub) -> 해당 채팅방으로 메시지를 송신(pub) 한다.
2. 코드로 확인하기
- 중요한 내용만 추가 설명을 해두었고, 대부분의 내용은 코드 주석으로 달아두었습니다.
- 설명문과 함께 코드 주석 참고해주세요!
1) Gradle 라이브러리 임포트
// WebSocket
implementation 'org.springframework.boot:spring-boot-starter-websocket'
// sockjs
implementation 'org.webjars:sockjs-client:1.5.1'
// stomp
implementation 'org.webjars:stomp-websocket:2.3.4'
// gson
implementation 'com.google.code.gson:gson:2.9.0'
2) SpringConfig - Stomp 엔드포인트 -> sub/pub 엔드포인트 설정
- 여기서 엔드포인트란 일종의 "통신의 도작지점" 이라고 생각하면 될 것 같다. 즉 특정한 통신이 어떤 엔드포인트에 도착했을 때 어떤 행위를 하게 만들것이다라는 것이다.
- 아래에서 처럼 Endpoint 를 "/ws-stomp" 로 설정해두면 웹소켓 통신이 /ws-stomp 로 도착할때 우리는 해당 통신이 웹 소켓 통신 중에서 stomp 통신인 것을 확인하고, 이를 연결한다는 의미이다.
- 추가로 /sub 로 도착하는 것은 메시지를 구독(sub) 할 때 사용하고, "/pub" 로 도착하는 것은 메시지를 송신 할 때 사용하는 엔드포인트가 되는 것이다.
@Configuration
@EnableWebSocketMessageBroker
public class SpringConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// stomp 접속 주소 url => /ws-stomp
registry.addEndpoint("/ws-stomp") // 연결될 엔드포인트
.withSockJS(); // SocketJS 를 연결한다는 설정
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// 메시지를 구독하는 요청 url => 즉 메시지 받을 때
registry.enableSimpleBroker("/sub");
// 메시지를 발행하는 요청 url => 즉 메시지 보낼 때
registry.setApplicationDestinationPrefixes("/pub");
}
}
3) ChatDTO
- 채팅 내용을 위한 DTO => 간단하기 때문에 자세한 내용은 주석 참고
package webChat.dto;
import lombok.*;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class ChatDTO {
// 메시지 타입 : 입장, 채팅
// 메시지 타입에 따라서 동작하는 구조가 달라진다.
// 입장과 퇴장 ENTER 과 LEAVE 의 경우 입장/퇴장 이벤트 처리가 실행되고,
// TALK 는 말 그대로 내용이 해당 채팅방을 SUB 하고 있는 모든 클라이언트에게 전달된다.
public enum MessageType{
ENTER, TALK, LEAVE;
}
private MessageType type; // 메시지 타입
private String roomId; // 방 번호
private String sender; // 채팅을 보낸 사람
private String message; // 메시지
private String time; // 채팅 발송 시간간
}
4) ChatRoom
- 채팅룸을 위한 DTO => 간단하기 때문에 자세한 내용은 주석 참고
package webChat.dto;
import lombok.Data;
import java.util.HashMap;
import java.util.UUID;
// Stomp 를 통해 pub/sub 를 사용하면 구독자 관리가 알아서 된다!!
// 따라서 따로 세션 관리를 하는 코드를 작성할 필도 없고,
// 메시지를 다른 세션의 클라이언트에게 발송하는 것도 구현 필요가 없다!
@Data
public class ChatRoom {
private String roomId; // 채팅방 아이디
private String roomName; // 채팅방 이름
private long userCount; // 채팅방 인원수
private HashMap<String, String> userlist = new HashMap<String, String>();
public ChatRoom create(String roomName){
ChatRoom chatRoom = new ChatRoom();
chatRoom.roomId = UUID.randomUUID().toString();
chatRoom.roomName = roomName;
return chatRoom;
}
}
5) ChatRepository
- 사실 클래스명은 ChaService 가 맞고, 어노테이션도 @Service 가 맞습니다 : 10.26 수정
- DAO 역할을 하는 ChatRepository 이다.
- Repository 라고 해뒀지만 실제로 하는 일은 Service 단의 내용이 섞여있다. 원래는 DB 와 연결해서 로그인 시 유저끼리 채팅이 가능하도록하는 것을 계획했기 때문에 여기 있는 코드는 추후 DB 와 연결되면 Service 와 Repository 로 분리 예정이다.
- @PostConstruct 어노테이션 : 이 어노테이션은 의존성 주입이 이루어진 후 초기화 작업이 필요한 메서드에 사용된다. 해당 어노테이션이 적용된 초기화 메서드는 WAS 가 띄워질 때 혹은 Bean 이 생성된 후 실행된다.
=> 해당 어노테이션은 채팅방을 만들어 저장하는 ChatRoomMap 에 대해서 사용하였다. 이를 통해서 ChatRepository Bean 이 생성된 후 초기화 되도록 만들었다.
- LinkedHashMap : LinkedHashMap 은 key:value 를 저장할때 HashMap 과는 다르게 순서대로! 저장된다는 특징이 있다. 성능상 HashMap 보다 더 좋다는데 크게 차이가 나는것은 아니라고 한다.
자세한 내용은 아래 레퍼런스 참고
package webChat.dao;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Repository;
import webChat.dto.ChatRoom;
import javax.annotation.PostConstruct;
import java.util.*;
// 추후 DB 와 연결 시 Service 와 Repository(DAO) 로 분리 예정
@Repository
@Slf4j
public class ChatRepository {
private Map<String, ChatRoom> chatRoomMap;
@PostConstruct
private void init() {
chatRoomMap = new LinkedHashMap<>();
}
// 전체 채팅방 조회
public List<ChatRoom> findAllRoom(){
// 채팅방 생성 순서를 최근순으로 반환
List chatRooms = new ArrayList<>(chatRoomMap.values());
Collections.reverse(chatRooms);
return chatRooms;
}
// roomID 기준으로 채팅방 찾기
public ChatRoom findRoomById(String roomId){
return chatRoomMap.get(roomId);
}
// roomName 로 채팅방 만들기
public ChatRoom createChatRoom(String roomName){
ChatRoom chatRoom = new ChatRoom().create(roomName); // 채팅룸 이름으로 채팅 룸 생성 후
// map 에 채팅룸 아이디와 만들어진 채팅룸을 저장장
chatRoomMap.put(chatRoom.getRoomId(), chatRoom);
return chatRoom;
}
// 채팅방 인원+1
public void plusUserCnt(String roomId){
ChatRoom room = chatRoomMap.get(roomId);
room.setUserCount(room.getUserCount()+1);
}
// 채팅방 인원-1
public void minusUserCnt(String roomId){
ChatRoom room = chatRoomMap.get(roomId);
room.setUserCount(room.getUserCount()-1);
}
// 채팅방 유저 리스트에 유저 추가
public String addUser(String roomId, String userName){
ChatRoom room = chatRoomMap.get(roomId);
String userUUID = UUID.randomUUID().toString();
// 아이디 중복 확인 후 userList 에 추가
room.getUserlist().put(userUUID, userName);
return userUUID;
}
// 채팅방 유저 이름 중복 확인
public String isDuplicateName(String roomId, String username){
ChatRoom room = chatRoomMap.get(roomId);
String tmp = username;
// 만약 userName 이 중복이라면 랜덤한 숫자를 붙임
// 이때 랜덤한 숫자를 붙였을 때 getUserlist 안에 있는 닉네임이라면 다시 랜덤한 숫자 붙이기!
while(room.getUserlist().containsValue(tmp)){
int ranNum = (int) (Math.random()*100)+1;
tmp = username+ranNum;
}
return tmp;
}
// 채팅방 유저 리스트 삭제
public void delUser(String roomId, String userUUID){
ChatRoom room = chatRoomMap.get(roomId);
room.getUserlist().remove(userUUID);
}
// 채팅방 userName 조회
public String getUserName(String roomId, String userUUID){
ChatRoom room = chatRoomMap.get(roomId);
return room.getUserlist().get(userUUID);
}
// 채팅방 전체 userlist 조회
public ArrayList<String> getUserList(String roomId){
ArrayList<String> list = new ArrayList<>();
ChatRoom room = chatRoomMap.get(roomId);
// hashmap 을 for 문을 돌린 후
// value 값만 뽑아내서 list 에 저장 후 reutrn
room.getUserlist().forEach((key, value) -> list.add(value));
return list;
}
}
6) ChatRoomController
- 전체적으로 채팅방을 조회, 생성, 입장을 관리하는 Controller
package webChat.controller;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import webChat.dao.ChatRepository;
import webChat.dto.ChatRoom;
@Controller
@Slf4j
public class ChatRoomController {
// ChatRepository Bean 가져오기
@Autowired
private ChatRepository chatRepository;
// 채팅 리스트 화면
// / 로 요청이 들어오면 전체 채팅룸 리스트를 담아서 return
@GetMapping("/")
public String goChatRoom(Model model){
model.addAttribute("list", chatRepository.findAllRoom());
// model.addAttribute("user", "hey");
log.info("SHOW ALL ChatList {}", chatRepository.findAllRoom());
return "roomlist";
}
// 채팅방 생성
// 채팅방 생성 후 다시 / 로 return
@PostMapping("/chat/createroom")
public String createRoom(@RequestParam String name, RedirectAttributes rttr) {
ChatRoom room = chatRepository.createChatRoom(name);
log.info("CREATE Chat Room {}", room);
rttr.addFlashAttribute("roomName", room);
return "redirect:/";
}
// 채팅방 입장 화면
// 파라미터로 넘어오는 roomId 를 확인후 해당 roomId 를 기준으로
// 채팅방을 찾아서 클라이언트를 chatroom 으로 보낸다.
@GetMapping("/chat/room")
public String roomDetail(Model model, String roomId){
log.info("roomId {}", roomId);
model.addAttribute("room", chatRepository.findRoomById(roomId));
return "chatroom";
}
}
7) ChatController
- 채팅을 수신(sub) 하고, 송신(pub) 하기 위한 Controller
- @MessageMapping : 이 어노테이션은 Stomp 에서 들어오는 message 를 서버에서 발송(pub) 한 메시지가 도착하는 엔드포인트이다. 여기서 "/chat/enterUser" 로 되어있지만 실제로는 앞에 "/pub" 가 생략되어있다라고 생각하면 된다. 즉 클라이언트가 "/pub/chat/enterUser"로 메시지를 발송하면 @MessageMapping 에 의해서 아래의 해당 어노테이션이 달린 메서드가 실행된다.
- convertAndSend() : 이 메서드는 매개변수로 각각 메시지의 도착 지점과 객체를 넣어준다. 이를 통해서 도착 지점 즉 sub 되는 지점으로 인자로 들어온 객체를 Message 객체로 변환해서 해당 도작지점을 sub 하고 있는 모든 사용자에게 메시지를 보내주게 된다.
package webChat.controller;
// 임포트는 생략
@Slf4j
@RequiredArgsConstructor
@Controller
public class ChatController {
private final SimpMessageSendingOperations template;
@Autowired
ChatRepository repository;
// MessageMapping 을 통해 webSocket 로 들어오는 메시지를 발신 처리한다.
// 이때 클라이언트에서는 /pub/chat/message 로 요청하게 되고 이것을 controller 가 받아서 처리한다.
// 처리가 완료되면 /sub/chat/room/roomId 로 메시지가 전송된다.
@MessageMapping("/chat/enterUser")
public void enterUser(@Payload ChatDTO chat, SimpMessageHeaderAccessor headerAccessor) {
// 채팅방 유저+1
repository.plusUserCnt(chat.getRoomId());
// 채팅방에 유저 추가 및 UserUUID 반환
String userUUID = repository.addUser(chat.getRoomId(), chat.getSender());
// 반환 결과를 socket session 에 userUUID 로 저장
headerAccessor.getSessionAttributes().put("userUUID", userUUID);
headerAccessor.getSessionAttributes().put("roomId", chat.getRoomId());
chat.setMessage(chat.getSender() + " 님 입장!!");
template.convertAndSend("/sub/chat/room/" + chat.getRoomId(), chat);
}
// 해당 유저
@MessageMapping("/chat/sendMessage")
public void sendMessage(@Payload ChatDTO chat) {
log.info("CHAT {}", chat);
chat.setMessage(chat.getMessage());
template.convertAndSend("/sub/chat/room/" + chat.getRoomId(), chat);
}
// 유저 퇴장 시에는 EventListener 을 통해서 유저 퇴장을 확인
@EventListener
public void webSocketDisconnectListener(SessionDisconnectEvent event) {
log.info("DisConnEvent {}", event);
StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());
// stomp 세션에 있던 uuid 와 roomId 를 확인해서 채팅방 유저 리스트와 room 에서 해당 유저를 삭제
String userUUID = (String) headerAccessor.getSessionAttributes().get("userUUID");
String roomId = (String) headerAccessor.getSessionAttributes().get("roomId");
log.info("headAccessor {}", headerAccessor);
// 채팅방 유저 -1
repository.minusUserCnt(roomId);
// 채팅방 유저 리스트에서 UUID 유저 닉네임 조회 및 리스트에서 유저 삭제
String username = repository.getUserName(roomId, userUUID);
repository.delUser(roomId, userUUID);
if (username != null) {
log.info("User Disconnected : " + username);
// builder 어노테이션 활용
ChatDTO chat = ChatDTO.builder()
.type(ChatDTO.MessageType.LEAVE)
.sender(username)
.message(username + " 님 퇴장!!")
.build();
template.convertAndSend("/sub/chat/room/" + roomId, chat);
}
}
// 채팅에 참여한 유저 리스트 반환
@GetMapping("/chat/userlist")
@ResponseBody
public ArrayList<String> userList(String roomId) {
return repository.getUserList(roomId);
}
// 채팅에 참여한 유저 닉네임 중복 확인
@GetMapping("/chat/duplicateName")
@ResponseBody
public String isDuplicateName(@RequestParam("roomId") String roomId, @RequestParam("username") String username) {
// 유저 이름 확인
String userName = repository.isDuplicateName(roomId, username);
log.info("동작확인 {}", userName);
return userName;
}
}
8) Socket.js ; 매우매우매우 중요
- 사실상 채팅의 핵심인 Socket.js
- 스프링 채팅의 70% 는 얘가 다 해준다고 생각하면 된다. 백엔드단에서 할 것은 오히려 적은 편이라고 생각될 정도
- 처음 웹 통신 시작시 지정된 엔드포인트로 소켓 통신을 시작하고, 지정된 주소를 지속으로 sub(구독) 하게 된다. 또한 지정한 주소로 pub(발송) 하는 역할도 한다.
- 여기는 내 코드에 따라서 수정한 부분이 많다. 다만 소켓 통신에서 가장 중요한 부분인 connect, onConnected, onError, sendMessage, onMessageReceived 정도는 거의 변경하지 않았으니, 이 부분은 꼭 확인하자!
- 자세한 내용은 주석 참고 부탁드립니다! 여기는 제가 잘 모르는 JS 영역이기도 하고, 주석을 보면서 코드를 보면서 해야 더 이해가 쉬울 것 같습니다. 코드 자체를 어렵지 않거든요!
'use strict';
// document.write("<script src='jquery-3.6.1.js'></script>")
document.write("<script\n" +
" src=\"https://code.jquery.com/jquery-3.6.1.min.js\"\n" +
" integrity=\"sha256-o88AwQnZB+VDvE9tvIXrMQaPlFFSUTR+nldQm1LuPXQ=\"\n" +
" crossorigin=\"anonymous\"></script>")
var usernamePage = document.querySelector('#username-page');
var chatPage = document.querySelector('#chat-page');
var usernameForm = document.querySelector('#usernameForm');
var messageForm = document.querySelector('#messageForm');
var messageInput = document.querySelector('#message');
var messageArea = document.querySelector('#messageArea');
var connectingElement = document.querySelector('.connecting');
var stompClient = null;
var username = null;
var colors = [
'#2196F3', '#32c787', '#00BCD4', '#ff5652',
'#ffc107', '#ff85af', '#FF9800', '#39bbb0'
];
// roomId 파라미터 가져오기
const url = new URL(location.href).searchParams;
const roomId = url.get('roomId');
function connect(event) {
username = document.querySelector('#name').value.trim();
// username 중복 확인
isDuplicateName();
// usernamePage 에 hidden 속성 추가해서 가리고
// chatPage 를 등장시킴
usernamePage.classList.add('hidden');
chatPage.classList.remove('hidden');
// 연결하고자하는 Socket 의 endPoint
var socket = new SockJS('/ws-stomp');
stompClient = Stomp.over(socket);
stompClient.connect({}, onConnected, onError);
event.preventDefault();
}
function onConnected() {
// sub 할 url => /sub/chat/room/roomId 로 구독한다
stompClient.subscribe('/sub/chat/room/' + roomId, onMessageReceived);
// 서버에 username 을 가진 유저가 들어왔다는 것을 알림
// /pub/chat/enterUser 로 메시지를 보냄
stompClient.send("/pub/chat/enterUser",
{},
JSON.stringify({
"roomId": roomId,
sender: username,
type: 'ENTER'
})
)
connectingElement.classList.add('hidden');
}
// 유저 닉네임 중복 확인
function isDuplicateName() {
$.ajax({
type: "GET",
url: "/chat/duplicateName",
data: {
"username": username,
"roomId": roomId
},
success: function (data) {
console.log("함수 동작 확인 : " + data);
username = data;
}
})
}
// 유저 리스트 받기
// ajax 로 유저 리스를 받으며 클라이언트가 입장/퇴장 했다는 문구가 나왔을 때마다 실행된다.
function getUserList() {
const $list = $("#list");
$.ajax({
type: "GET",
url: "/chat/userlist",
data: {
"roomId": roomId
},
success: function (data) {
var users = "";
for (let i = 0; i < data.length; i++) {
//console.log("data[i] : "+data[i]);
users += "<li class='dropdown-item'>" + data[i] + "</li>"
}
$list.html(users);
}
})
}
function onError(error) {
connectingElement.textContent = 'Could not connect to WebSocket server. Please refresh this page to try again!';
connectingElement.style.color = 'red';
}
// 메시지 전송때는 JSON 형식을 메시지를 전달한다.
function sendMessage(event) {
var messageContent = messageInput.value.trim();
if (messageContent && stompClient) {
var chatMessage = {
"roomId": roomId,
sender: username,
message: messageInput.value,
type: 'TALK'
};
stompClient.send("/pub/chat/sendMessage", {}, JSON.stringify(chatMessage));
messageInput.value = '';
}
event.preventDefault();
}
// 메시지를 받을 때도 마찬가지로 JSON 타입으로 받으며,
// 넘어온 JSON 형식의 메시지를 parse 해서 사용한다.
function onMessageReceived(payload) {
//console.log("payload 들어오냐? :"+payload);
var chat = JSON.parse(payload.body);
var messageElement = document.createElement('li');
if (chat.type === 'ENTER') { // chatType 이 enter 라면 아래 내용
messageElement.classList.add('event-message');
chat.content = chat.sender + chat.message;
getUserList();
} else if (chat.type === 'LEAVE') { // chatType 가 leave 라면 아래 내용
messageElement.classList.add('event-message');
chat.content = chat.sender + chat.message;
getUserList();
} else { // chatType 이 talk 라면 아래 내용용
messageElement.classList.add('chat-message');
var avatarElement = document.createElement('i');
var avatarText = document.createTextNode(chat.sender[0]);
avatarElement.appendChild(avatarText);
avatarElement.style['background-color'] = getAvatarColor(chat.sender);
messageElement.appendChild(avatarElement);
var usernameElement = document.createElement('span');
var usernameText = document.createTextNode(chat.sender);
usernameElement.appendChild(usernameText);
messageElement.appendChild(usernameElement);
}
var textElement = document.createElement('p');
var messageText = document.createTextNode(chat.message);
textElement.appendChild(messageText);
messageElement.appendChild(textElement);
messageArea.appendChild(messageElement);
messageArea.scrollTop = messageArea.scrollHeight;
}
function getAvatarColor(messageSender) {
var hash = 0;
for (var i = 0; i < messageSender.length; i++) {
hash = 31 * hash + messageSender.charCodeAt(i);
}
var index = Math.abs(hash % colors.length);
return colors[index];
}
usernameForm.addEventListener('submit', connect, true)
messageForm.addEventListener('submit', sendMessage, true)
9) chatroom && roollist html
- chatroom 과 roomlist html 는 설명에서 빼두었습니다. 코드 길이도 엄청 길고, 사실 저도 이 부분들은 검색&다른 사람들것 가져와서 살짝 수정한 정도라 설명하기 어렵기도 하고 자칫 잘못 설명할까봐 부끄럽기도 해서요ㅠㅠ
- 뭣보다 제가 html 을 정말 정말...못하는지라ㅠㅠ 이 부분들은 git 참고해서 확인 부탁드립니다!
- Reference
https://velog.io/@qkrqudcks7/STOMP%EB%9E%80
https://wecandev.tistory.com/105
HashMap vs LinkedHashMap 성능 비교
댓글