미소를뿌리는감자의 코딩

채팅 서버 구현 - WebSocket, Tomcat, STOMP 본문

프로젝트

채팅 서버 구현 - WebSocket, Tomcat, STOMP

미뿌감 2024. 8. 11. 13:58
728x90

1. 개요

처음엔 채팅 서버를 비동기적으로 처리하기로 목표를 잡았다.

그렇게 코드를 구현하던 도중, Webflux가 pub/sub을 지원하지 않는다는 것을 알게 되었다.

MVC 기반이 아닌, webflux를 기반 구현 목표로 하고 있던 나에게는 당황스러웠다.

구독 기능을 직접 구현하는 것이 이미 기존에 작성된 stomp 기능보다 효율적일 지 고민이 되었다.

직접 구독을 잘 구현할 엄두가 나지 않았던 것도 사실이다.

 

 

추후에 기능을 확장시켜서 여러명이서 채팅을 하도록 기능을 수정하게 된다면 pub/sub은 필수적이었기에, 아쉽지만 MVC 패턴으로 구현하기로 마음먹었다.

 

2. 백엔드 코드 구성

저장은 mongoDB에 저장하기로 결정하였다. 그 이유로는 관계형 database만큼을 필요로 하지 않기에, 상대적으로 빠른 NoSQL을 사용하고자 했기 때문이다.

 

build.gradle은 아래와 같이 구성하였다. 

주요 implementation으로는 websocket과 mongodb-reactive가 있다.

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.3.2'
    id 'io.spring.dependency-management' version '1.1.6'
}

group = 'com.sparta'
version = '0.0.1-SNAPSHOT'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(17)
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-websocket'
    implementation 'org.springframework.boot:spring-boot-starter-data-mongodb-reactive'
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'

    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'

    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

tasks.named('test') {
    useJUnitPlatform()
}

 

먼저 MongoConfig부터 알아보자.

MongoDB를 연결해주고, "aper"이라는 database에 저장하도록 명시해 주었다. 

@Configuration
public class MongoConfig {

    @Bean
    public ReactiveMongoTemplate reactiveMongoTemplate() {
        return new ReactiveMongoTemplate(MongoClients.create("mongodb://localhost:27017"), "aper");
    }
}

 

 

다음으로는 websocket configuration을 작성해 주었다.

enableSimpleBroker를 통해서, STOMP 프로토콜을 사용하도록 하였다.

이를 통해서 /subscribe를 통해 메시지를 클라이언트에게로 보내줄 수 있다. 

 

다음으로 EndPoint는 /ws/aper-chat으로 설정해 두어, client가 ws/aper-chat를 통해 webSocket에 연결을 시도할 수 있도록 하였다.

다음으로 CORS 정책을 설정하기 위해 setAllowedOrigins를 해두었는데, 일시적으로 모든 경로에 대해서 허용해 두었다. ("*") 이 부분은 추후에 front-end 도메인이 나오게 되면 해당 도메인을 넣어 줄 예정이다.

 

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        config.enableSimpleBroker("/subscribe");
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws/aper-chat").setAllowedOrigins("*");
    }
}

 

 

다음으로 controller로 넘어가서 알아보자. 

Sink를 이용해서 코드를 구성해 주었다.

Sink는 처음 사용해 보았기에 이에 대해서 간단히 정리하고 넘어갈 것이다.

 

아무래도 mongoDB를 이용하기 때문에, mongoDB에서의 값 반환이 Mono와 Flux로 이루어지게 되었다.

Sink는 이런 값 반환을 도와주는 중간자 역할이라고 생각하면 된다. 

즉,    [ 구독자 ] -- [ sink ] -- [  발행자 ] 이렇게 위치해 있다.

 

만약 발행자가 새로운 메시지를 emit하게 되면, sink가 이를 구독하고 있는 구독자들에게 Mono 혹은 Flux로 메시지를 전달해 준다.

@RestController
@CrossOrigin(origins = "http://localhost:8081")
public class ChatController {

    private final ChatService chatService;
    private final Sinks.Many<ChatMessage> sink;

    public ChatController(ChatService chatService) {
        this.chatService = chatService;
        this.sink = Sinks.many().multicast().onBackpressureBuffer();
    }

    @GetMapping("/history")
    public Flux<ChatMessage> getChatHistory(@RequestParam Long chatRoomId) {
        return chatService.getChatHistory(chatRoomId);
    }

    @MessageMapping("/chat")
    public Mono<Void> saveMessage(@Payload MessageRequestDto requestDto) {
        return chatService.saveAndBroadcastMessage(requestDto)
                .doOnSuccess(sink::tryEmitNext)
                .then();
    }
}

 

/history에 대해서 알아보자.

getChatHistory는 client가 채팅방에 들어오게 되면, 이전 채팅들을 한번에 반환해주는 역할이다.

chatService.getChatHistory는 아래와 같이 작성되어 있다.

chat_을 통해 해당 채팅방의 채팅들을 가져오서 모두 반환해 주었다.

@Transactional
public Flux<ChatMessage> getChatHistory(Long roomId) {
    String collectionName = "chat_" + roomId;
    return reactiveMongoTemplate.findAll(ChatMessage.class, collectionName);
}

 

다음으로 /chat에 대해서 알아보자.

이는 채팅을 받고, 해당 채팅방을 구독하고 있는 구독자들에게 메시지를 넘겨주는 api이다.

만약 chatService.saveAndBroadcastMessage가 성공하게 되면, sink를 통해서 구독자들에게 채팅을 emit 해준다.

아래는 chatService.saveAndBroadcastMessage이다.

우선 saveMessage를 통해서 해당 채팅방에 메시지를 저장하고, 성공한다면, subscribe/채팅방아이디에 저장된 메시지를 publish 해주었다.

messagingTemplate를 이용해서 /subscribe/채팅방 id 주소에, 메시지를 변환해서 보내준다.

@Transactional
public Mono<ChatMessage> saveAndBroadcastMessage(MessageRequestDto requestDto) {
    return saveMessage(requestDto)
            .doOnSuccess(savedMessage -> {
                messagingTemplate.convertAndSend("/subscribe/" + requestDto.getChatRoomId(), savedMessage);
            });
}

 

아래는 saveMessage이다.

.save().thenReturn(chatMessage)라는 코드를 적은 이유는 무엇일까.

우리가 필요한 것은 save한 결과가 아니라 chatMessage 객체가 필요하므로, chatMessage를 반환하도록 한 것이다.

@Transactional
public Mono<ChatMessage> saveMessage(MessageRequestDto requestDto) {
    String collectionName = "chat_" + requestDto.getChatRoomId();
    ChatMessage chatMessage = new ChatMessage(requestDto);
    return reactiveMongoTemplate.save(chatMessage, collectionName)
            .thenReturn(chatMessage);
}

 

3. 프론트엔드 코드 구성

프론트 코드는 백엔드 코드가 잘 작동되는지 확인하기 위해 작성한 것이라 조금 허술하다.

 

우선 전체 코드는 아래와 같다.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Chat Room</title>
</head>
<body>
<h2>Chat Room</h2>

<div>
    <label for="senderId">Sender ID:</label>
    <input type="text" id="senderId" name="senderId">
</div>
<div>
    <label for="chatRoomId">Chat Room ID:</label>
    <input type="text" id="chatRoomId" name="chatRoomId">
</div>
<div>
    <label for="content">Message:</label>
    <input type="text" id="content" name="content">
</div>
<button id="sendButton">Send Message</button>

<h3>Sent Messages:</h3>
<div id="messageArea"></div>

<!-- SockJS and StompJS libraries -->
<script src="https://code.jquery.com/jquery-3.1.1.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@stomp/stompjs@7.0.0/bundles/stomp.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/js-cookie@3.0.5/dist/js.cookie.min.js"></script>

<script>
    const roomId = 1;
    let stompClient;  // 전역 변수로 설정

    // 서버로부터 히스토리 데이터를 한 번 로드
    fetch(`http://localhost:8080/history?chatRoomId=${roomId}`)
        .then(response => response.json())
        .then(data => {
            data.forEach(message => {
                displayMessage(message);
            });
            // WebSocket 연결을 초기화
            initializeWebSocket();
        })
        .catch(error => console.error('Error fetching chat history:', error));

    function initializeWebSocket() {
        stompClient = new StompJs.Client({
            brokerURL: 'ws://localhost:8080/ws/aper-chat',
            debug: function (str) {
                console.log(str);
            },
            reconnectDelay: 5000,
        });

        stompClient.onConnect = function (frame) {
            console.log('Connected: ' + frame);

            stompClient.subscribe('/subscribe/' + roomId, (message) => {
                const chatMessage = JSON.parse(message.body);
                displayMessage(chatMessage);
            });

            // 메시지 전송 버튼 클릭 이벤트 핸들러
            document.getElementById('sendButton').onclick = function() {
                const senderId = document.getElementById('senderId').value;
                const chatRoomId = document.getElementById('chatRoomId').value;
                const content = document.getElementById('content').value;

                sendMessage(senderId, chatRoomId, content);
            };
        };

        stompClient.activate();
    }

    function sendMessage(senderId, chatRoomId, content) {
        const chatMessage = {
            senderId: senderId,
            chatRoomId: chatRoomId,
            content: content
        };

        stompClient.publish({
            destination: "/chat",
            body: JSON.stringify(chatMessage)
        });
    }

    function displayMessage(message) {
        const messageArea = document.getElementById('messageArea');
        const messageElement = document.createElement('div');
        messageElement.textContent = `Sender ID: ${message.senderId}, Chat Room ID: ${message.chatRoomId}, Message: ${message.content}`;
        messageArea.appendChild(messageElement);
    }
</script>
</body>
</html>

 

코드 각각에 대해서 알아보자.

우선 roomId가 1이라고 가정하고 코드를 작성하였다.

채팅방에 들어가면, fetch를 통해 해당 채팅방의 채팅 기록을 불러온다. 이는 webSocket 연결하기 전에 가져와 준다.

displayMessage 함수는 그저 화면에 띄우도록 하는 코드이다.

    const roomId = 1;
    let stompClient;  // 전역 변수로 설정

    // 서버로부터 히스토리 데이터를 한 번 로드
    fetch(`http://localhost:8080/history?chatRoomId=${roomId}`)
        .then(response => response.json())
        .then(data => {
            data.forEach(message => {
                displayMessage(message);
            });
            // WebSocket 연결을 초기화
            initializeWebSocket();
        })
        .catch(error => console.error('Error fetching chat history:', error));

 

initializeWebSocket()에 대해서 알아보자.

우선, stompClient 객체 생성을 통해서, STOMP 프로토콜을 사용해 주자.

이후 해당 방 번호를 .subscribe를 해주고, 메시지가 도착하게 되면 displayMessage 함수를 통해서 화면에 출력해준다.

또한 메시지 전송을 위한 버튼을 클릭하게 되면, sendMessage를 통해서 보내준다.

 

    function initializeWebSocket() {
        stompClient = new StompJs.Client({
            brokerURL: 'ws://localhost:8080/ws/aper-chat',
            debug: function (str) {
                console.log(str);
            },
            reconnectDelay: 5000,
        });

        stompClient.onConnect = function (frame) {
            console.log('Connected: ' + frame);

            stompClient.subscribe('/subscribe/' + roomId, (message) => {
                const chatMessage = JSON.parse(message.body);
                displayMessage(chatMessage);
            });

            // 메시지 전송 버튼 클릭 이벤트 핸들러
            document.getElementById('sendButton').onclick = function() {
                const senderId = document.getElementById('senderId').value;
                const chatRoomId = document.getElementById('chatRoomId').value;
                const content = document.getElementById('content').value;

                sendMessage(senderId, chatRoomId, content);
            };
        };

        stompClient.activate();
    }

 

아래는 sendMessage 함수이다. publish를 통해서 publish해준다.

function sendMessage(senderId, chatRoomId, content) {
    const chatMessage = {
        senderId: senderId,
        chatRoomId: chatRoomId,
        content: content
    };

    stompClient.publish({
        destination: "/chat",
        body: JSON.stringify(chatMessage)
    });
}

 

 

4. 결론

이렇게 코드를 작성해 보았다.

비동기 채팅을 포기한 것이 조금 아쉽지만, webSocket을 다시 확실하게 공부할 수 있던 기회였던 것 같다.

728x90