[Spring Boot] STOMP를 이용한 실시간 채팅 및 채팅방 동적 생성

Jae_0·2023년 6월 28일
10

첫번째 프로젝트

목록 보기
2/2
post-thumbnail

[Spring Boot] STOMP를 이용한 실시간 채팅 및 채팅방 동적 생성


동작 과정

프로젝트 속 구현한 실시간 채팅은 다음과 같이 동작한다.

STOMP

What is STOMP

STOMP(Simple Text Oriented Message Protocol)는 기존 WebSocket 통신 방식을 좀 더 효율적으로, 조금 더 쉽게 다룰 수 있게 해주는 프로토콜이다.

이 프로토콜은 pub(publish), sub(subscribe)이라는 개념으로 동작한다. 클라이언트가 서버로 메시지를 보내는 것을 pub, 클라이언트가 서버로부터 메시지를 받는 것을 메시지를 구독한다는 개념으로 sub이 사용된다.

아래 사진을 참고하면 더 쉽게 이해 할 수 있다.

  1. Client(Sender)가 메시지를 보내면 서버에 메시지가 전달된다.
  2. Controller의 @MessageMapping에 의해 메시지를 받는다.
  3. Controller의 @SendTo로 특정 topic(/1)을 구독(/room)하는 클라이언트에게 메시지를 보낸다.
    (본 프로젝트에서 topic은 멘토링룸 개설시에 부여된 id이다.)

로직

Service, Repository 코드는 생략했다.

의존성 설정

implementation 'org.springframework.boot:spring-boot-starter-websocket'
implementation 'org.webjars:webjars-locator-core'
implementation 'org.webjars:sockjs-client:1.5.1'
implementation 'org.webjars:stomp-websocket:2.3.4'
implementation 'org.webjars:bootstrap:5.2.3'
implementation 'org.webjars:jquery:3.6.4'

Config

@Configuration
@RequiredArgsConstructor
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.setApplicationDestinationPrefixes("/send");       //클라이언트에서 보낸 메세지를 받을 prefix
        registry.enableSimpleBroker("/room");    //해당 주소를 구독하고 있는 클라이언트들에게 메세지 전달
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws-stomp")   //SockJS 연결 주소
                .withSockJS(); //버전 낮은 브라우저에서도 적용 가능
        // 주소 : ws://localhost:8080/ws-stomp
    }
}

@EnableWebSocketMessageBroker 어노테이션을 통해 웹소켓 메시지 핸들링을 활성화 한다.
WebSocketMessageBrokerConfigurer을 implements하고 위의 메서드를 오버라이딩한다.
(주석 참고)

Entity (Chat, ChatRoom)

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Chat {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "chat_id")
    private Long id;

    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(name = "room_id")
    private ChatRoom room;

    private String sender;

    private String senderEmail;

    @Column(columnDefinition = "TEXT")
    private String message;

    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime sendDate;

    @Builder
    public Chat(ChatRoom room, String sender, String senderEmail, String message) {
        this.room = room;
        this.sender = sender;
        this.senderEmail = senderEmail;
        this.message = message;
        this.sendDate = LocalDateTime.now();
    }

    /**
     * 채팅 생성
     * @param room 채팅 방
     * @param sender 보낸이
     * @param message 내용
     * @return Chat Entity
     */
    public static Chat createChat(ChatRoom room, String sender, String senderEmail, String message) {
        return Chat.builder()
                .room(room)
                .sender(sender)
                .senderEmail(senderEmail)
                .message(message)
                .build();
    }
}
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class ChatRoom {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "chatRoom_id")
    private Long id;
    private String name;

    @Builder
    public ChatRoom(String name) {
        this.name = name;
    }

    public static ChatRoom createRoom(String name) {
        return ChatRoom.builder()
                .name(name)
                .build();
    }
}

둘의 관계는 (1:N 단방향이다)

Controller

@Controller
@RequiredArgsConstructor
public class ChatController {

    private final ChatService chatService;

    @MessageMapping("/{roomId}") //여기로 전송되면 메서드 호출 -> WebSocketConfig prefixes 에서 적용한건 앞에 생략
    @SendTo("/room/{roomId}")   //구독하고 있는 장소로 메시지 전송 (목적지)  -> WebSocketConfig Broker 에서 적용한건 앞에 붙어줘야됨
    public ChatMessage chat(@DestinationVariable Long roomId, ChatMessage message) {

        //채팅 저장
        Chat chat = chatService.createChat(roomId, message.getSender(), message.getSenderEmail(), message.getMessage());
        return ChatMessage.builder()
                .roomId(roomId)
                .sender(chat.getSender())
                .senderEmail(chat.getSenderEmail())
                .message(chat.getMessage())
                .build();
    }
}

/send/{roomId}를 통해 컨트롤러의 @MessageMapping과 매핑된다.
@DestinationVariable로 roomId를 받아 사용해야 한다.

클라이언트에서 온 JSON data를 ChatMessage Dto에 저장핳고 roomId라는 식별자를 통해 DB에 저장한다.

그리고 @SendTo를 통해 (WebSocketConfig 에서 적은 "/room") 구독자(채팅방)에게 ChatMessage를 JSON 형식으로 전달한다.

Javascript

STOMP 연결, Subcribe (클라이언트)

function connect() {
        var socket = new SockJS("/ws-stomp");
        stompClient = Stomp.over(socket);
        stompClient.connect({}, function (frame) {
            setConnected(true);
            console.log('Connected: ' + frame);
            loadChat(chatList)  //저장된 채팅 불러오기

            //구독
            stompClient.subscribe('/room/'+roomId, function (chatMessage) {
                showChat(JSON.parse(chatMessage.body));
            });
        });
    }

서버에서 설정한 엔드포인트로 STOMP를 생성한다.

그후 subcribe로 구독자들에게 메세지를 보낸다.
(/room 이라는 prefix에 roomId로 채팅방 분류)

메세지를 보내면 서버를 거쳐 구독하고 있는 클라이언트들에게 showChat을 통해 메세지가 보여지게 된다.

Send

//html 에서 입력값, roomId 를 받아서 Controller 로 전달
    function sendChat() {
        if ($("#message").val() != "") {
            stompClient.send("/send/"+roomId, {},
                JSON.stringify({
                    'sender': sender,
                    'senderEmail': senderEmail,
                    'message' : $("#message").val()
                }));
            $("#message").val('');
        }
    }

WebSocketConfig에서 설정해준 /send 로 메세지를 보낸다. 추가로 구독(채팅방) 정보가 필요하므로 roomId를 url에 포함시키며, html에서 입력한 정보를 추출해 보낸이, 보낸사람의 이메일(동명이인 구분을 위한 회원 정보), 내용을 json으로 서버에 보내준다.

채팅 보기, 불러오기

//보낸 채팅 보기
    function showChat(chatMessage) {
        if (chatMessage.senderEmail == senderEmail) {
            $("#chatting").append(
                "<div class = 'chatting_own'><tr><td>" + chatMessage.message + "</td></tr></div>"
            );
        } else {
            $("#chatting").append(
                "<div class = 'chatting'><tr><td>" + "[" + chatMessage.sender + "] " + chatMessage.message + "</td></tr></div>"
            );
        }
        $('.col-md-12').scrollTop($('.col-md-12')[0].scrollHeight);
    }

//저장된 채팅 불러오기
    function loadChat(chatList){
        if(chatList != null) {
            for(chat in chatList) {
                if (chatList[chat].senderEmail == senderEmail) {
                    $("#chatting").append(
                        "<div class = 'chatting_own'><tr><td>" + chatList[chat].message + "</td></tr></div>"
                    );
                } else {
                    $("#chatting").append(
                        "<div class = 'chatting'><tr><td>" + "[" + chatList[chat].sender + "] " + chatList[chat].message + "</td></tr></div>"
                    );
                }
            }
        }
        $('.col-md-12').scrollTop($('.col-md-12')[0].scrollHeight); // 채팅이 많아질시에 자동 스크롤
    }

전달 받은 ChatMessage를 가지고 화면에 뿌려준다.

STOMP에 연결된 후 해당 채팅방에 채팅기록이 존재 한다면 DB에서 받아온 chatList를 통해 loadChat으로 화면에 뿌려주게 된다.

자신의 채팅은 오른쪽에, 다른 이들의 채팅은 왼쪽에 보여지게 되며, 모달창 형식으로 구현 했기 때문에 채팅의 개수가 모달의 크기를 넘어가게 되면 스크롤 되게 한다.

전체 javascript 코드

<script th:inline="javascript">
    var stompClient = null;
    var roomId = [[${roomId}]];
    var chatList = [[${chatList}]];
    var sender = [[${userName}]];
    var senderEmail = [[${userEmail}]];

    function setConnected(connected) {
        $("#connect").prop("disabled", connected);
        $("#disconnect").prop("disabled", !connected);
        if (connected) {
            $("#conversation").show();
        }
        else {
            $("#conversation").hide();
        }
        $("#chatting").html("");
    }

    function connect() {
        var socket = new SockJS("/ws-stomp");
        stompClient = Stomp.over(socket);
        stompClient.connect({}, function (frame) {
            setConnected(true);
            console.log('Connected: ' + frame);
            loadChat(chatList)  //저장된 채팅 불러오기

            //구독
            stompClient.subscribe('/room/'+roomId, function (chatMessage) {
                showChat(JSON.parse(chatMessage.body));
            });
        });
    }

    function disconnect() {
        if (stompClient !== null) {
            stompClient.disconnect();
        }
        setConnected(false);
        console.log("Disconnected");
    }

    //html 에서 입력값, roomId 를 받아서 Controller 로 전달
    function sendChat() {
        if ($("#message").val() != "") {
            stompClient.send("/send/"+roomId, {},
                JSON.stringify({
                    'sender': sender,
                    'senderEmail': senderEmail,
                    'message' : $("#message").val()
                }));
            $("#message").val('');
        }
    }

    //저장된 채팅 불러오기
    function loadChat(chatList){
        if(chatList != null) {
            for(chat in chatList) {
                if (chatList[chat].senderEmail == senderEmail) {
                    $("#chatting").append(
                        "<div class = 'chatting_own'><tr><td>" + chatList[chat].message + "</td></tr></div>"
                    );
                } else {
                    $("#chatting").append(
                        "<div class = 'chatting'><tr><td>" + "[" + chatList[chat].sender + "] " + chatList[chat].message + "</td></tr></div>"
                    );
                }
            }
        }
        $('.col-md-12').scrollTop($('.col-md-12')[0].scrollHeight); // 채팅이 많아질시에 자동 스크롤
    }

    //보낸 채팅 보기
    function showChat(chatMessage) {
        if (chatMessage.senderEmail == senderEmail) {
            $("#chatting").append(
                "<div class = 'chatting_own'><tr><td>" + chatMessage.message + "</td></tr></div>"
            );
        } else {
            $("#chatting").append(
                "<div class = 'chatting'><tr><td>" + "[" + chatMessage.sender + "] " + chatMessage.message + "</td></tr></div>"
            );
        }
        $('.col-md-12').scrollTop($('.col-md-12')[0].scrollHeight);
    }

    $(function () {
        $("form").on('submit', function (e) {
            e.preventDefault();
        });
        $( "#connect" ).click(function() { connect(); });
        $( "#disconnect" ).click(function() { disconnect(); });
        $( "#send" ).click(function() { sendChat(); });
    });
</script>
<script>
    //창 키면 바로 연결
    window.onload = function (){
        connect();
    }

    window.BeforeUnloadEvent = function (){
        disconnect();
    }
</script>
<script>
    var modal = document.querySelector(".modal");
    var trigger = document.querySelector(".trigger");
    var closeButton = document.querySelector(".close-button");

    //console.log(modal);

    function toggleModal() {
        modal.classList.toggle("show-modal");
    }

    trigger.addEventListener("click", toggleModal);
    closeButton.addEventListener("click", toggleModal);
</script>

결과

처음 구현했을때 sender를 단순히 유저 이름으로만 해놓으니까 화면에 뿌려줄 때 같은 이름일 경우에 (내 이름 = sender일 경우) 오른쪽에 나오는 오류가 있어 senderEmail을 통해 구분을 해주어서 해결 하였다.

또한 채팅을 불러오는 작업이 DB에서 모두 불러오기 때문에 만약 채팅의 수가 매우 많다면 불러오는 속도가 저하 될 것 같다.

그러나 졸작 전시회, 테스트 동안 속도가 저하 될 정도로 충분히 많은 양의 데이터를 저장, 불러오기를 하지 못했기 때문에 이는 더 공부해보고 혹여나 속도가 저하 된다면 이를 개선 할 방법을 생각 해보아야 할 것 같다.

profile
기록하며 꾸준히 성장하는 코딩 공부

4개의 댓글

comment-user-thumbnail
2024년 4월 14일

좋은 포스트를 작성해 주셔서 감사합니다.

1개의 답글
comment-user-thumbnail
2024년 5월 9일

혹시 선생님 서비스랑 레포지토리 코드도 한번 볼수있을까요..?

1개의 답글