[캡스톤디자인] Spring Boot, STOMP를 이용한 Chatting 구현 (2)

Dev_Sanizzang·2023년 5월 4일
0

캡스톤디자인

목록 보기
5/15

📕 개요

전 포스팅에서는 WebSocket과 STOMP에 대한 개념을 정리하는 시간을 가졌다.
개념을 정리했으니 이제 채팅을 구현해봐야지 않겠는가..
이번 포스팅에서는 Spring을 통해 간단한 채팅 프로그램을 만들어보고자 한다!

역시 이를 구현하기 위해 구글링을 해본 결과
아래 블로그에 아주 잘 구현이 돼 있었다!

💡 Spring Boot Web Chatting : 스프링 부트로 실시간 채팅 만들기(2)

위의 블로그에서 사용한 기술은 다음과 같다.

  • Java 8
  • Spring Boot MVC
  • Gradle
  • AJAX
  • jquery
  • WebSocket & SocketJS
  • Stomp
  • 추후 DB 기술 추가 예정

나는 Java17, Spring Boot 3.0.6을 통해 채팅을 구현해보도록 하겠다!

📌 채팅 로직

  • 채팅방 생성 : pub/sub 구현을 위한 Topic 생성 -> 즉 채팅방과 그에 맞는 주제 혹은 채팅방 명을 생각하면 된다.
  • 채팅방 입장 : Topic 구독(sub) -> 해당 채팅방을 웹 소켓이 연결되어있는 동안 구독한다. 구독의 개념은 해당 채팅방을 지속적으로 바라본다라고 생각하면 좋다. 지속적으로 연결되고 바라보고 있기 때문에 새로운 채팅이 송신(pub) 되면 이를 수신(구독, sub) 할 수 있다
  • 채팅방 메시지 수신 : 해당 Topic 로 메시지 송신(pub) -> 해당 채팅방으로 메시지를 송신(pub) 한다.

📜 Spring Config

@Configuration
@EnableWebSocketMessageBroker
public class SpringConfig implements WebSocketMessageBrokerConfigurer {

    // 웹소켓 configuration의 addHandler 메소드와 유사
    // cors, SockJS 설정 가능
    /*
       STOMP를 사용하면 웹소켓만 사용할 때와 다르게 하나의 연결주소마다 핸들러 클래스를 따로 구현할 필요없이
       Controller 방식으로 간편하게 사용할 수 있다.
     */
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        // stomp 접속 주소 url => /ws-stomp
        registry.addEndpoint("/ws-stomp") // 연결될 엔드포인트
                .withSockJS(); // SocketJS 를 연결한다는 설정
    }

    // STOMP에서 사용하는 메시지 브로커 설정
    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        // enableSimpleBroker: 내장 메시지 브로커를 사용하기 위한 메소드
        // 파라미터로 지정한 prefix가 붙은 메시지를 발행할 경우, 메시지 브로커가 이를 처리하게 된다.
        // 메시지를 구독하는 요청 url => 즉 메시지 받을 때
        registry.enableSimpleBroker("/sub");

        // 메세지 핸들러로 라우팅 되는 prefix를 파라미터로 지정할 수 있다.
        // 메시지 가공 처리가 필요한 경우, 가공 핸들러로 메시지를 라우팅 되도록하는 설정
        // 메시지를 발행하는 요청 url => 즉 메시지 보낼 때
        registry.setApplicationDestinationPrefixes("/pub");
    }
}

📜 ChatDTO

// 채팅 내용을 위한 DTO
@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; // 채팅 발송 시간
}

📜 ChatRoom

// 채팅룸을 위한 DTO
@Data
public class ChatRoom {
    private String roomId; // 채팅방 아이디
    private String roomName; // 채팅방 이름
    private long userCount; // 채팅방 인원수

    // key: 유저UUID, value: 유저아이디
    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;
    }
}

📜 ChatRepository

// DAO 역할을 하는 ChatRepository 이다.
@Repository
@Slf4j
public class ChatRepository {

    // 채팅방을 만들어 저장
    private Map<String, ChatRoom> chatRoomMap;

    /*
        LinkedHashMap은 key:value를 저장할 때 HashMap과는 다르게 순서대로 저장되다는 특징이 있다.
        성능상 HashMap 보다 더 좋다는데 크게 차이가 나는 것은 아니라고 한다.
     */
    @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;
    }
}

📜 ChatRoomController

// 전체적으로 채팅방을 조회, 생성, 입장을 관리
@Controller
@Slf4j
@RequiredArgsConstructor
public class ChatRoomController {

    private final 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, @RequestParam String roomId){

        log.info("roomId {}", roomId);
        model.addAttribute("room", chatRepository.findRoomById(roomId));
        return "chatroom";
    }
}

📜 ChatController

// 채팅을 수신(sub_하고, 송신(pub) 하기 위한 Controller
/*
- @MessageMapping : 이 어노테이션은 Stomp 에서 들어오는 message 를 서버에서
발송(pub) 한 메시지가 도착하는 엔드포인트이다. 여기서 "/chat/enterUser" 로 되어있지만 
실제로는 앞에 "/pub" 가 생략되어있다라고 생각하면 된다. 
즉 클라이언트가 "/pub/chat/enterUser"로 메시지를 발송하면 @MessageMapping 에 의해서 
아래의 해당 어노테이션이 달린 메서드가 실행된다.

- convertAndSend() : 이 메서드는 매개변수로 각각 메시지의 도착 지점과 객체를 넣어준다. 
이를 통해서 도착 지점 즉 sub 되는 지점으로 인자로 들어온 객체를 
Message 객체로 변환해서 해당 도작지점을 sub 하고 있는 모든 사용자에게 메시지를 보내주게 된다.
*/
@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;
    }
}

📜 Socket.js

  • 처음 웹 통신 시작시 지정된 엔드포인트로 소켓 통신을 시작하고, 지정된 주소를 지속으로 sub(구독) 하게 된다. 또한 지정한 주소로 pub(발송) 하는 역할도 한다.
'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');

// WebSocket 연결 수행
function connect(event) {
  	// name 이라는 ID를 가진 입력 필드에 입력된 값을 가져온다.
    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');
  	// 생성된 SockJS 객체를 이용하여 Stomp 클라이언트 객체를 생성
    stompClient = Stomp.over(socket);
  
  	// WebSocket 연결시도
    // onConnected 함수는 연결이 성공했을 때 호출되며,
    // onError 함수는 WebSocket 연결이 실패했을 때 호출
    stompClient.connect({}, onConnected, onError);

  	 // 폼 전송 이벤트를 취소, 이렇게 하면 페이지가 새로고침되지 않고, WebSocket 연결이 수행된다.
    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() {
  	// id가 list인 요소를 가져오는 jQuery 코드
    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++) {
                users += "<li class='dropdown-item'>" + data[i] + "</li>"
            }
          	// list 요소의 HTML 내용을 갱신
            $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();

  	// 입력된 메시지가 있고, WebSocket 연결이 성공했을 때만 메시지를 전송
    if (messageContent && stompClient) {
        var chatMessage = {
            "roomId": roomId,
            sender: username,
            message: messageInput.value,
            type: 'TALK'
        };

      	// 메시지를 서버로 보냄
        // /pub/chat/sendMessage: 메시지를 보낼 엔드포인트, {}: 헤더 정보를 전달하기 위한 객체, JSON: chatMessage 객체 전송
        stompClient.send("/pub/chat/sendMessage", {}, JSON.stringify(chatMessage));
      	// 입력 필드 초기화
        messageInput.value = '';
    }
    event.preventDefault();
}

// 메시지를 받을 때도 마찬가지로 JSON 타입으로 받으며,
// 넘어온 JSON 형식의 메시지를 parse 해서 사용한다.
function onMessageReceived(payload) {
    // JSON 형식의 채팅 메시지를 Javascript 객체로 변환
    var chat = JSON.parse(payload.body);

    var messageElement = document.createElement('li');

  	/*
       - 채팅 메시지의 type 값을 확인하여 'enter', 'leave', 'talk'에 따라 다르게 처리
       - 'enter'와 'leave'일 경우, 'getUserList()'함수를 호출하여 사용자 목록 갱신
       - 'talk'일 경우 아바타 색상을 결정하여 avatarElement의 배경 색상을 변경하고, 'usernameElement'에 사용자 이름 추가
    */
    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);
    }

  	// textEelement에 메시제 내용 추가
    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];
}

// 사용자 이름 입력 폼에서 submit 이벤트가 발생했을 때 connect 함수 호출
usernameForm.addEventListener('submit', connect, true)
// 메시지 입력 폼에서 submit 이벤트가 발생했을 때, sendMessage 함수 호출
messageForm.addEventListener('submit', sendMessage, true)

🔍 개선해야할 점들

현재 이 코드는 DB와도 연결이 되있지 않고, 현재 내가 진행하고 있는 프로젝트는 프론트엔드와 백엔드가 분리된 프로젝트이지만 위의 코드는 SSR 방식으로 코드를 구현했다.
위 코드를 통해 채팅의 로직을 이해하고 내 프로젝트에 맞게 코드를 재구성할 필요가 있다.

🚪 마무리

위의 코드를 통해서 Spring으로 채팅을 구현하려면 어떠한 로직으로 코드를 짜야하는지에 대한 공부를 마쳤다.
다음 포스트에서는 현재 내가 진행중인 온라인 모임 서비스 프로젝트에서 사용할 수 있는 코드로 재구성해보는 시간을 가져보겠다.

profile
기록을 통해 성장합니다.

0개의 댓글