STOMP

김파란·2024년 8월 10일

SpringAdv

목록 보기
6/8

1). STOMP

  • Simple Text-Oriented Messaging Protocol
  • 메시지 브로커를 활용하여 쉽게 메시지를 주고 받을 수 있는 프로토콜
  • pub/sub기반과 메시지 브로커 (발신자의 메시지를 받아와서 수신자에게 메시지를 전달하는 것)
  • 웹소켓 위에 얹어 함께 사용할 수 있는 하위(서브) 프로토콜
  • STOMP를 통해 클라이언트와 서버가 어떤 형식으로 메시지를 주고받을지 타입은 어떻게 명시할 것인지 본문과 설정데이터들을 어떻게 구분할 것인지를 정의해줄 수 있다
  • STOMP는 HTTP와 비슷하게 command, header, body로 이루어져 있다

1). 장점

  • 하위 프로토콜 혹은 컨벤션을 따로 정의할 필요 없다
  • 연결 주소마다 새로 핸들러를 구현하고 설정해줄 필요가 없다
  • 외부 Messaging Queue를 사용할 수 있다 (RabbitMQ, 카프카)
  • Spring Seucity를 사용할 수 있다

2). 형식

  • Command: Send, Subscribe를 지정할 수 있는 명령이다
  • header: 기존의 WebSocket으로는 표현이 불가능한 header를 작성할 수 있는 부분
  • "destincation" 헤더를 이용해 메시지를 보내거나(SEND), 구독(SUBSCRIBE) 할 수 있다

COMMAND
header1:value1
header2:value2

Body^@

클라이언트가 5번 채팅방을 구독하는 경우

SUBSCRIBE
destination: /topic/chat/room/5
id: sub-1

^@

클라이언트가 5번 채팅방에 메시지를 보내는 경우

SEND
destination: /pub/chat/room/5
content-type: application/json

{"type": "MESSAGE", "writer": "clientB"} ^@

3). 기본 동작

환경 설정

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketBrokerConfig implements WebSocketMessageBrokerConfigurer {
    // STOMP에서는 메시지 브로커를 사용한다
    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
       
        // 메시지를 구독 요청 URL => 즉 메시지를 받을 떄
        registry.enableSimpleBroker("/app","/sub");
        
         // 내장 브로커 사용, /queue, /topic, /pub 경로로 송신됐을 떄 심플 브로커가 메시지를 받고 구독자에게 전달
        // /topic은 메시지가 일대다 (브로드캐스팅) 일때 /queue 는 1:1 일때 사용한다는 컨벤션이 있다
        // 메시지를 발행 요청 URL => 즉 메시지를 보낼 때
        registry.setApplicationDestinationPrefixes("/queue", "/topic","/pub");

    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        // 웹소켓 연결주소, 이전처럼 Handler 하나하나 추가할 필요 없다
        registry.addEndpoint("/gs-guide-websocket").withSockJS();
    }
}

컨트롤러

@Controller
public class GreetingController {
	// 간단한 메시징 프로토콜(예: STOMP)에 사용하기 위한 메서드가 포함된 MessageSendingOperations의 구현체
    private final SimpMessageSendingOperations simpMessageSendingOperations;
    
    // SimpMessageSendingOperations 구현체
    SimpMessagingTemplate simpMessagingTemplate;
    
    // 기존 request 매핑과 비슷하다, http 매핑이 들어오면 여기로 온다
    // config에서 지정한 prefix인 /pub이 합쳐져 /pub/{chattingRoomId}/messages로 메시지 발행 요청
    @MessageMapping("/{chattingRoomId}/messages")
    // 데이터 처리가 완료되면 이쪽으로 보내겠다는 뜻
    @SendTo("/sub/{chattingRoomId") // ConvertAndSend 대신 사용해도 된다
    public String greeting(@DestinationVariable("chattingRoomId") String chattingRoomId, @Payload String message) {
    	// @DesticationVariable을 써줘야 인식을 한다
    	// simpMessageSendingOperations.convertAndSend("/topic/wiki", data);
        return "ok";
    }
}

2. Simple STOMP

  • 환경설정은 똑같으니 통과
  • 세션을 활용한 방식이니 jwt토큰이나 rest방식을 사용하려면 handler나 인증작업을 추가로 해줘야 한다

1). ChatController

@Slf4j
@RequiredArgsConstructor
@Controller
public class ChatController {

    private final SimpMessageSendingOperations template;

    private final ChatRoomRepository repository;

    @MessageMapping("/chat/enterUser")
    public void enterUser(@Payload ChatMessageDto chat, SimpMessageHeaderAccessor headerAccessor) {
        repository.plusUserCnt(chat.getRoomId());
        log.debug("enter User {}", chat);

        // 채팅방에 유저 추가 및 UserUUID 반환
        String userUUID = repository.addUser(chat.getRoomId(), new ChatUserDto(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 ChatMessageDto 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, new ChatUserDto(userUUID));
        repository.delUser(roomId, new ChatUserDto(userUUID));

        if (username != null) {
            log.info("User Disconnected : " + username);

            // builder 어노테이션 활용
            ChatMessageDto chat = ChatMessageDto.builder()
                    .type(ChatMessageDto.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;
    }*/
}

2). ChatRoomController

package hello.rootTest.chat;

import hello.rootTest.chat.config.ChatRoomRepository;
import hello.rootTest.chat.dto.ChatRoomDto;
import hello.rootTest.root.security.dto.PrincipalDetails;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
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 java.util.List;

@Controller
@Slf4j
@RequiredArgsConstructor
public class ChatRoomController {

    private final ChatRoomRepository chatRoomRepository;

    // 채팅 리스트 화면
    // / 로 요청이 들어오면 전체 채팅룸 리스트를 담아서 return

    // 스프링 시큐리티의 로그인 유저 정보는 Security 세션의 PrincipalDetails 안에 담긴다
    // 정확히는 PrincipalDetails 안에 ChatUser 객체가 담기고, 이것을 가져오면 된다.
    @GetMapping("/index")
    public String goChatRoom(Model model, @AuthenticationPrincipal PrincipalDetails principalDetails){
        List<ChatRoomDto> chatRooms = chatRoomRepository.findAllRoom();

        model.addAttribute("list", chatRooms);

        // principalDetails 가 null 이 아니라면 로그인 된 상태!!
        if (principalDetails != null) {
            // 세션에서 로그인 유저 정보를 가져옴
            model.addAttribute("user", principalDetails.getAccountDto());
            log.info("user [{}] ",principalDetails);
        }

//        model.addAttribute("user", "hey");
        log.info("SHOW ALL ChatList {}", chatRoomRepository.findAllRoom());
        return "roomlist";
    }

    // 채팅방 생성
    // 채팅방 생성 후 다시 / 로 return
    @PostMapping("/chat/createroom")
    public String createRoom(@RequestParam("roomName") String name, @RequestParam("roomPwd")String roomPwd, @RequestParam("secretChk")String secretChk,
                             @RequestParam(value = "maxUserCnt", defaultValue = "100")String maxUserCnt, RedirectAttributes rttr) {

//        log.info("chk {}", secretChk);
        // 매개변수 : 방 이름, 패스워드, 방 잠금 여부, 방 인원수
        ChatRoomDto room = chatRoomRepository.createChatRoom(name, roomPwd, Boolean.parseBoolean(secretChk), Integer.parseInt(maxUserCnt));

        log.info("CREATE Chat Room [{}]", room);

        rttr.addFlashAttribute("roomName", room);
        return "redirect:/";
    }

    // 채팅방 입장 화면
    // 파라미터로 넘어오는 roomId 를 확인후 해당 roomId 를 기준으로
    // 채팅방을 찾아서 클라이언트를 chatroom 으로 보낸다.
    @GetMapping("/chat/room")
    public String roomDetail(Model model, @RequestParam("roomId") String roomId, @AuthenticationPrincipal PrincipalDetails principalDetails){

        log.info("roomId {}", roomId);

        // principalDetails 가 null 이 아니라면 로그인 된 상태!!
        if (principalDetails != null) {
            // 세션에서 로그인 유저 정보를 가져옴
            model.addAttribute("user", principalDetails.getAccountDto());
        }

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

    // 채팅방 비밀번호 확인
    @PostMapping("/chat/confirmPwd/{roomId}")
    @ResponseBody
    public boolean confirmPwd(@PathVariable String roomId, @RequestParam String roomPwd){

        // 넘어온 roomId 와 roomPwd 를 이용해서 비밀번호 찾기
        // 찾아서 입력받은 roomPwd 와 room pwd 와 비교해서 맞으면 true, 아니면  false
        return chatRoomRepository.confirmPwd(roomId, roomPwd);
    }

/*    // 채팅방 삭제
    @GetMapping("/chat/delRoom/{roomId}")
    public String delChatRoom(@PathVariable String roomId){

        // roomId 기준으로 chatRoomMap 에서 삭제, 해당 채팅룸 안에 있는 사진 삭제
        chatRoomRepository.delChatRoom(roomId);

        return "redirect:/";
    }*/


    @GetMapping("/chat/chkUserCnt/{roomId}")
    @ResponseBody
    public boolean chUserCnt(@PathVariable String roomId){

        return chatRoomRepository.checkRoomUserCount(roomId);
    }

}

3). Js

'use strict';

var chatPage = document.querySelector('#chat-page');
var messageForm = document.querySelector('#messageForm');
var messageInput = document.querySelector('#message');
var messageArea = document.querySelector('#messageArea');
var connectingElement = document.querySelector('.connecting');

var stompClient = null;
// username은 HTML에서 Thymeleaf를 통해 설정됩니다.

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() {
    // 연결하고자하는 Socket 의 endPoint
    var socket = new SockJS('/ws-stomp');
    stompClient = Stomp.over(socket);

    stompClient.connect({}, onConnected, onError);
}

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'
        })
    )

    console.log("Connected and sent ENTER message"); // 디버깅을 위한 로그
    connectingElement.classList.add('hidden');
}

function onError(error) {
    connectingElement.textContent = 'Could not connect to WebSocket server. Please refresh this page to try again!';
    connectingElement.style.color = 'red';
}

function sendMessage(event) {
    var messageContent = messageInput.value.trim();
    var now = new Date();
    var hours = String(now.getHours()).padStart(2, '0'); // 두 자리 수로 포맷팅
    var minutes = String(now.getMinutes()).padStart(2, '0'); // 두 자리 수로 포맷팅
    var time = hours + ':' + minutes; // "HH:mm" 형식으로 시간 포맷팅

    if (messageContent && stompClient) {
        var chatMessage = {
            "roomId": roomId,
            sender: username,
            message: messageInput.value,
            time: time,
            type: 'TALK'
        };

        stompClient.send("/pub/chat/sendMessage", {}, JSON.stringify(chatMessage));
        messageInput.value = '';
    }
    event.preventDefault();
}

function onMessageReceived(payload) {
    var chat = JSON.parse(payload.body);
    console.log("Received message:", chat); // 디버깅을 위한 로그

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

    if (chat.type === 'ENTER') {
        messageElement.classList.add('event-message');
        chat.content = chat.sender + "님이 입장하셨습니다.";
        getUserList();
    } else if (chat.type === 'LEAVE') {
        messageElement.classList.add('event-message');
        chat.content = chat.sender + '님이 퇴장하셨습니다.';
        getUserList();
    } else {
        messageElement.classList.add('chat-message');

        if (chat.sender === username) {
            messageElement.classList.add('my-message');
        } else {
            messageElement.classList.add('other-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 contentElement = document.createElement('p');

    if (chat.s3DataUrl != null) {
        var imgElement = document.createElement('img');
        imgElement.setAttribute("src", chat.s3DataUrl);
        imgElement.setAttribute("width", "300");
        imgElement.setAttribute("height", "300");

        var downBtnElement = document.createElement('button');
        downBtnElement.setAttribute("class", "btn fa fa-download");
        downBtnElement.setAttribute("id", "downBtn");
        downBtnElement.setAttribute("name", chat.fileName);
        downBtnElement.setAttribute("onclick", `downloadFile('${chat.fileName}', '${chat.fileDir}')`);

        contentElement.appendChild(imgElement);
        contentElement.appendChild(downBtnElement);
    } else {
        var messageText = document.createTextNode(chat.message);
        contentElement.appendChild(messageText);
    }

    // 시간 정보 추가
    var timeElement = document.createElement('span');
    var timeText = document.createTextNode(chat.time); // 서버에서 받은 time 정보 사용
    timeElement.classList.add('message-time');
    contentElement.appendChild(timeElement); // 시간 요소를 메시지 내용의 오른쪽에 추가

    messageElement.appendChild(contentElement);
    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];
}

// 유저 리스트 받기
// 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);
        }
    })
}


// 페이지 로드 시 자동으로 연결
window.addEventListener('load', connect);

messageForm.addEventListener('submit', sendMessage, true);

3. Kafka

1). 도커 환경설정

# compose 파일 버전
version: '3'
services:
  # 서비스 명
  zookeeper:
    # 사용할 이미지
    image: wurstmeister/zookeeper
    # 컨테이너명 설정
    container_name: zookeeper
    # 접근 포트 설정 (컨테이너 외부:컨테이너 내부)    
    ports:
      - "2181:2181"
  # 서비스 명
  kafka:
    # 사용할 이미지
    image: wurstmeister/kafka
    # 컨테이너명 설정
    container_name: kafka
    # 접근 포트 설정 (컨테이너 외부:컨테이너 내부)
    ports:
      - "9092:9092"
    # 환경 변수 설정
    environment:
      KAFKA_ADVERTISED_HOST_NAME: localhost
      KAFKA_CREATE_TOPICS: "Topic:1:1" #  토픽이름:파티션개수:레플리카
      KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
    # 볼륨 설정
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    # 의존 관계 설정
    depends_on:
      - zookeeper

1-1 Zookeeper 설정

# zookeeper 도커 접속
docker exec -it zookeeper bash
# Zookeper 서버 실행
bash bin/zkServer.sh start /opt/zookeeper-3.4.13/conf/zoo.cfg -demon

1-2 Kafka 설정

# kafka 도커 접속
docker exec -it kafka bash
# kafka 서버 실행
kafka-server-start.sh -daemon
# 토픽 생성
kafka-topics.sh --create --zookeeper zookeeper:2181 --topic test-topic --partitions 1 --replication-factor 1
# 토픽 생성 확인
kafka-topics.sh --list --zookeeper zookeeper
# 토픽 삭제 명령어
kafka-topics.sh --delete --zookeeper zookeeper:2181 --topic [토픽 이름]

4. RabbitMQ

  • Kafka에는 라우팅기능이 없어서 채팅기능만 구현할 거라면 오히려 RabbitMQ가 더 좋은 선택지가 될 수 있다
  • 채팅 기능에서의 라우팅:
  1. 개인 메시지: 특정 사용자에게 메시지를 직접 전송합니다.
  2. 그룹 메시지: 특정 그룹의 모든 사용자에게 메시지를 전송합니다.
  3. 채널 메시지: 특정 채널의 구독자에게만 메시지를 전달합니다.

0개의 댓글