[SpringBoot] Websocket을 활용한 실시간 채팅 구현 (2) - Stomp

gyehwan·2023년 7월 3일
0

Websocket

목록 보기
2/3

이전 포스팅에서 websocket을 활용하여 간단한 서버/클라이언트 채팅 통신을 구현해봤다. 메세징 방식을 잘 정의한다면 websocket만으로도 충분히 좋은 서버/클라이언트 소켓 서버를 만들 수 있다. 하지만 단순한 통신 구조로 인해 websocket만을 이용해 채팅을 구현하면 해당 메세지가 어떤 요청인지, 어떻게 처리해야 하는지에 따라 채팅방과 세션을 일일히 구현하고 메세지 발송 부분을 관리하는 추가 코드를 구현해줘야 한다.

이번 포스팅에서는 Websocket의 프로세스를 더 고도화하고 메세징에 좀 더 최적화된 방식을 방식을 구현하기 위해 Stomp를 적용해 보겠다.

Stomp

  • Stomp는 메세징 전송을 효율적으로 하기 위해 나온 프로토콜
  • pub/sub 구조
    메세지를 발송하고, 메세지를 받아 처리하는 부분이 확실히 정해져있다.
  • 통신 메세지의 헤더에 값을 세팅할 수 있다.
    헤더 값을 기반으로 통신 시 인증 처리를 구현하는 것도 가능하다.

pub/sub란 메세지를 공급하는 주체와 소비하는 주체를 분리하여 제공하는 메세징 방법이다.
비유를 들자면, 우체통(Topic)이 있으면 집배원(publisher)이 신문을 우체통에 배달하는 액션이 있고 우체통에 신문이 배달되는 것을 기다렸다가 받아 보는 구독자(subscriber)의 액션이 있다. 당연히 구독자는 여러 명이 될 수 있다. pub/sub 콘셉트를 채팅방에 대입하면 다음과 같다.

  • 채팅방을 생성한다 - pub/sub 구현을 위한 Topic이 생성된다.
  • 채팅방에 입장한다 - Topic을 구독한다.
  • 채팅방에서 메세지를 보내고 받는다 - 해당 Topic으로 메세지를 발송(pub)하거나 메세지를 수신(sub)한다.

Stomp를 이용한 채팅 구현

build.gradle

아래와 같이 dependencies에 stomp 관련 라이브러리를 추가한다.

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

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

java {
	sourceCompatibility = '17'
}

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
}

repositories {
	mavenCentral()
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-validation'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	implementation 'org.springframework.boot:spring-boot-starter-websocket'
	implementation 'org.springframework.boot:spring-boot-starter-freemarker'
    implementation 'org.springframework.boot:spring-boot-devtools'

    implementation 'org.webjars.bower:bootstrap:4.3.1'
    implementation 'org.webjars.bower:vue:2.5.16'
    implementation 'org.webjars.bower:axios:0.17.1'
    implementation 'org.webjars:sockjs-client:1.1.2'
    implementation 'org.webjars:stomp-websocket:2.3.3-1'
    implementation 'com.google.code.gson:gson:2.8.0'

	compileOnly 'org.projectlombok:lombok'
	runtimeOnly 'com.h2database:h2'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

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

application.yml

freemarker를 사용할 수 있도록 아래와 같이 파일 경로와 확장자를 명시해준다.

spring:
  devtools:
    livereload:
      enabled: true
    restart:
      enabled: false
  freemarker:
    cache: false
    template-loader-path: classpath:/templates
    suffix: .ftl

WebSocketConfig 수정

Stomp를 사용하기 위해 @EnableWebSocketMessageBroker을 선언하고 WebSocketMessageBrokerConfigurer를 상속받아 configureMessageBroker를 구현한다.
pub/sub 메세징을 구현하기 위해 메세지를 발행하는 요청의 prefix는 /pub으로 시작하도록 설정하고 메세지를 구독하는 요청의 prefix는 /sub로 시작하도록 설정한다.
stomp websocket의 연결 endpoint는 /ws-stomp로 설정한다.
-> ws://localhost:8080/ws-stomp

//import 생략
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

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

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws-stomp").setAllowedOriginPatterns("*")
                .withSockJS();
    }
}

🚨 트러블 슈팅

java.lang.IllegalArgumentException: When allowCredentials is true, allowedOrigins cannot contain the special value "*" since that cannot be set on the "Access-Control-Allow-Origin" response header. To allow credentials to a set of origins, list them explicitly or consider using "allowedOriginPatterns" instead.

위의 코드에서 .setAllowedOriginPatterns(" ") 대신 .setAllowedOrigins(" ")를 사용했는데 위와 같은 런타임에러가 나왔다. 스택오버플로우에 따르면 현재 업데이트 된 사항으로는 setAllowedOriginPatterns를 사용해야 한다고 한다.

채팅방 DTO 수정

pub/sub 방식을 이용하면 구독자 관리가 알아서 되므로 웹소켓 세션 관리가 필요 없어진다. 또한 메세지 발송의 구현도 알아서 해결되므로 일일이 클라이언트에게 메세지를 발송하는 구현이 필요 없어진다. 따라서 채팅방 DTO는 다음과 같이 간소화된다.

//import 생략
@Getter
@Setter
public class ChatRoom {

    private String roomId;
    private String name;

    public static ChatRoom create(String name) {
        ChatRoom chatRoom = new ChatRoom();
        chatRoom.roomId = UUID.randomUUID().toString();
        chatRoom.name = name;
        return chatRoom;
    }
}

채팅방 Repository 생성

채팅방을 생성하고 정보를 조회하는 Repository를 생성한다. 실습에서는 간단하게 만들 것이므로 채팅방 정보를 Map으로 관리하지만, 서비스에서는 DB에 저장되도록 구현해야한다. 그리고 ChatService는 ChatRoomRepository가 대체하므로 삭제한다.

//import 생략

@Repository
public class ChatRoomRepository {

    private Map<String, ChatRoom> chatRoomMap;

    @PostConstruct
    private void init() {
        chatRoomMap = new LinkedHashMap<>();
    }

    public List<ChatRoom> findAllRoom() {
        List<ChatRoom> chatRooms = new ArrayList<>(chatRoomMap.values());
        Collections.reverse(chatRooms);
        return chatRooms;
    }

    public ChatRoom findRoomById(String id) {
        return chatRoomMap.get(id);
    }

    public ChatRoom createChatRoom(String name) {
        ChatRoom chatRoom = ChatRoom.create(name);
        chatRoomMap.put(chatRoom.getRoomId(), chatRoom);
        return chatRoom;
    }
}

ChatController 수정 (publisher 구현)

@MessageMapping을 통해 Websocket으로 들어오는 메세지 발행을 처리한다. 클라이언트에서는 prefix를 붙여서 /pub/chat/message로 발행 요청을 하면 Controller가 해당 메세지를 받아 처리한다. 메세지가 발행되면 /sub/chat/room/{roomId}로 메세지를 send하는 것을 볼 수 있는데 클라이언트에서는 해당 주소를 구독(subscribe)하고 있다가 메세지가 전달되면 화면에 출력한다. 여기서 /sub/chat/room/{roomId}는 채팅방을 구분하는 값이므로 pub/sub에서 Topic의 역할이라고 불 수 있다. ChatController가 Handler의 역할을 대체하므로 Handler는 삭제한다.

@RequiredArgsConstructor
@Controller
public class ChatController {

    private final SimpMessageSendingOperations messagingTemplate;

    @MessageMapping("/chat/message")
    public void message(ChatMessage message) {
        if (message.getType().equals(ChatMessage.MessageType.ENTER)) {
            message.setMessage(message.getSender() + "님이 입장하셨습니다. 👋🏼");
        }
        messagingTemplate.convertAndSend("/sub/chat/room/" + message.getRoomId(), message);
    }
}

Subscriber 구현

서버단에서는 따로 구현할 것이 없다. 웹뷰에서 stomp 라이브러리를 이용해서 subscriber 주소를 바라보고 있는 코드만 작성하면 된다.

ChatRoomController 생성

Websocket 통신 외에 채팅 화면 View 구성을 위해 필요한 Controller를 생성한다.

@Controller
@RequiredArgsConstructor
@RequestMapping("/chat")
public class ChatRoomController {

    private final ChatRoomRepository chatRoomRepository;

    //채팅 리스트 화면
    @GetMapping("/room")
    public String rooms(Model model) {
        return "/chat/room";
    }

    //채팅방 입장 화면
    @GetMapping("/room/enter/{roomId}")
    public String roomDetail(Model model, @PathVariable String roomId) {
        model.addAttribute("roomId", roomId);
        return "/chat/roomdetail";
    }

    //모든 채팅방 목록 반환
    @GetMapping("/rooms")
    @ResponseBody
    public List<ChatRoom> room() {
        return chatRoomRepository.findAllRoom();
    }

    //채팅방 생성
    @PostMapping("/room")
    @ResponseBody
    public ChatRoom createRoom(@RequestParam String name) {
        return chatRoomRepository.createChatRoom(name);
    }

    //특정 채팅방 조회
    @GetMapping("/room/{roomId}")
    @ResponseBody
    public ChatRoom roomInfo(@PathVariable String roomId) {
        return chatRoomRepository.findRoomById(roomId);
    }
}

채팅 화면 (View) 생성

채팅방 리스트, 채팅방 상세 화면을 위한 view를 구현한다. /resources/templates/chat 하위에 room.ftl, roomdetail.ftl 파일을 생성한다. freemarker 형식으로 생성되었지만 껍데기만 그렇게 생성하고 내부 로직은 vue.js로 이루어져있다. 기본 UI는 bootstrap으로 구성되어있다. 웹뷰는 익숙하지 않아 다른 블로그를 참고했다.

room.ftl

<!doctype html>
<html lang="en">
<head>
    <title>Websocket Chat</title>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
    <!-- CSS -->
    <link rel="stylesheet" href="/webjars/bootstrap/4.3.1/dist/css/bootstrap.min.css">
    <style>
        [v-cloak] {
            display: none;
        }
    </style>
</head>
<body>
<div class="container" id="app" v-cloak>
    <div class="row">
        <div class="col-md-12">
            <h3>채팅방 리스트</h3>
        </div>
    </div>
    <div class="input-group">
        <div class="input-group-prepend">
            <label class="input-group-text">방제목</label>
        </div>
        <input type="text" class="form-control" v-model="room_name" v-on:keyup.enter="createRoom">
        <div class="input-group-append">
            <button class="btn btn-primary" type="button" @click="createRoom">채팅방 개설</button>
        </div>
    </div>
    <ul class="list-group">
        <li class="list-group-item list-group-item-action" v-for="item in chatrooms" v-bind:key="item.roomId" v-on:click="enterRoom(item.roomId)">
            {{item.name}}
        </li>
    </ul>
</div>
<!-- JavaScript -->
<script src="/webjars/vue/2.5.16/dist/vue.min.js"></script>
<script src="/webjars/axios/0.17.1/dist/axios.min.js"></script>
<script>
    var vm = new Vue({
        el: '#app',
        data: {
            room_name : '',
            chatrooms: [
            ]
        },
        created() {
            this.findAllRoom();
        },
        methods: {
            findAllRoom: function() {
                axios.get('/chat/rooms').then(response => { this.chatrooms = response.data; });
            },
            createRoom: function() {
                if("" === this.room_name) {
                    alert("방 제목을 입력해 주십시요.");
                    return;
                } else {
                    var params = new URLSearchParams();
                    params.append("name",this.room_name);
                    axios.post('/chat/room', params)
                        .then(
                            response => {
                                alert(response.data.name+"방 개설에 성공하였습니다.")
                                this.room_name = '';
                                this.findAllRoom();
                            }
                        )
                        .catch( response => { alert("채팅방 개설에 실패하였습니다."); } );
                }
            },
            enterRoom: function(roomId) {
                var sender = prompt('대화명을 입력해 주세요.');
                if(sender != "") {
                    localStorage.setItem('wschat.sender',sender);
                    localStorage.setItem('wschat.roomId',roomId);
                    location.href="/chat/room/enter/"+roomId;
                }
            }
        }
    });
</script>
</body>
</html>

roomdetail.ftl

<!doctype html>
<html lang="en">
<head>
    <title>Websocket ChatRoom</title>
    <!-- Required meta tags -->
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">

    <!-- Bootstrap CSS -->
    <link rel="stylesheet" href="/webjars/bootstrap/4.3.1/dist/css/bootstrap.min.css">
    <style>
        [v-cloak] {
            display: none;
        }
    </style>
</head>
<body>
<div class="container" id="app" v-cloak>
    <div>
        <h2>{{room.name}}</h2>
    </div>
    <div class="input-group">
        <div class="input-group-prepend">
            <label class="input-group-text">내용</label>
        </div>
        <input type="text" class="form-control" v-model="message" v-on:keypress.enter="sendMessage">
        <div class="input-group-append">
            <button class="btn btn-primary" type="button" @click="sendMessage">보내기</button>
        </div>
    </div>
    <ul class="list-group">
        <li class="list-group-item" v-for="message in messages">
            {{message.sender}} - {{message.message}}</a>
        </li>
    </ul>
    <div></div>
</div>
<!-- JavaScript -->
<script src="/webjars/vue/2.5.16/dist/vue.min.js"></script>
<script src="/webjars/axios/0.17.1/dist/axios.min.js"></script>
<script src="/webjars/sockjs-client/1.1.2/sockjs.min.js"></script>
<script src="/webjars/stomp-websocket/2.3.3-1/stomp.min.js"></script>
<script>
    //alert(document.title);
    // websocket & stomp initialize
    var sock = new SockJS("/ws-stomp");
    var ws = Stomp.over(sock);
    var reconnect = 0;
    // vue.js
    var vm = new Vue({
        el: '#app',
        data: {
            roomId: '',
            room: {},
            sender: '',
            message: '',
            messages: []
        },
        created() {
            this.roomId = localStorage.getItem('wschat.roomId');
            this.sender = localStorage.getItem('wschat.sender');
            this.findRoom();
        },
        methods: {
            findRoom: function() {
                axios.get('/chat/room/'+this.roomId).then(response => { this.room = response.data; });
            },
            sendMessage: function() {
                ws.send("/pub/chat/message", {}, JSON.stringify({type:'TALK', roomId:this.roomId, sender:this.sender, message:this.message}));
                this.message = '';
            },
            recvMessage: function(recv) {
                this.messages.unshift({"type":recv.type,"sender":recv.type=='ENTER'?'[알림]':recv.sender,"message":recv.message})
            }
        }
    });

    function connect() {
        // pub/sub event
        ws.connect({}, function(frame) {
            ws.subscribe("/sub/chat/room/"+vm.$data.roomId, function(message) {
                var recv = JSON.parse(message.body);
                vm.recvMessage(recv);
            });
            ws.send("/pub/chat/message", {}, JSON.stringify({type:'ENTER', roomId:vm.$data.roomId, sender:vm.$data.sender}));
        }, function(error) {
            if(reconnect++ <= 5) {
                setTimeout(function() {
                    console.log("connection reconnect");
                    sock = new SockJS("/ws-stomp");
                    ws = Stomp.over(sock);
                    connect();
                },10*1000);
            }
        });
    }
    connect();
</script>
</body>
</html>

채팅방에 입장 시에 ws-stomp로 서버 연결을 한 후에 채팅방을 구독하는 액션을 수행하는 것을 볼 수 있다.
구독은 /sub/chat/room/{roomId}로 하는 것을 볼 수 있는데, 이 주소를 Topic으로 삼아 서버에서 메세지를 발행한다. 채팅방에서 클라이언트가 메세지를 입력하면 서버에서 Topic으로 메세지를 발행(pub)하는데 구독자는 ws.subscribe에서 대기하고 있다가 발송된 메세지를 받아 볼 수 있는 것이다.

테스트

서버를 실행하고 localhost:8080/chat/room을 실행한다.

방 제목을 입력하고 채팅방을 개설한다.

이름을 입력하고 채팅을 보낸다. 웹을 2개 띄우고 실시간 채팅 테스트를 진행한다.

결론

Stomp를 통해 기존의 Websocket으로 구현한 채팅 서버를 개선해 보았다. pub/sub을 이용함으로써 메세지의 이동경로가 명확해졌고 채팅방마다 개별의 클라이언트 세션을 저장하고 있다가 메세지를 발송해야하는 절차를 없앨 수 있었다.

이전에 학교에서 진행한 팀프로젝트에서 실시간 채팅 어플을 개발할 때 stomp를 활용하여 개발하려고 했는데 백엔드단에서 독단적으로 하기 어려워 보여 포기하고 Firebase realtime database를 이용하여 개발했었다. DEPth 스터디를 통해 간단하지만 웹소켓을 구현해보고 자신감을 얻을 수 있어 좋았다.


레퍼런스

stomp 활용
cors 정책

0개의 댓글

관련 채용 정보