지난 시간에 이어 WebSocket
을 사용한 채팅 기능 구현을 STOMP를 이용하여 고도화 시키도록 하겠습니다.
우선 STOMP
에 대해 알아보도록 하겠습니다.
STOMP
는 메시지 전송을 효율적으로 하기 위해 나온 프로토콜의 종류이며 기본적으로 PUB/SUB
구조로 되어 있어 메시지를 발송하고, 메시지를 받아 처리하는 부분이 확실히 정해져 있어 개발하는 입장에서 명확하게 인지하고 개발할 수 있는 장점이 있습니다.
또한 STOMP
를 이용하면 통신 메시지의 헤더 값을 세팅할 수 있어 헤더 값을 기반으로 통신 시 인증 처리에 대한 구현도 가능하게 해줍니다.
STOMP
의 PUB/SUB
구조를 이용하여 채팅방에 대입하면 다음과 같은 흐름을 구성할 수 있습니다.
그러면 이제 STOMP
를 이용하여 고도화 작업을 시작하도록 하겠습니다.
dependencies {
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'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
서버 사이드 랜더링으로 구현하기 때문에 관련된 의존성을 세팅해주었습니다.
webjar
는 채팅 웹 화면을 구현하기 위해 필요한 js를 로드해주기 위해 선언하였습니다.(freemarker
, vue.js
)
STOMP
방식으로 통신하기 위한 js도 추가해주었습니다. sockjs
는 WebSocket
을 지원하지 않는 낮은 버전의 브라우저에서도 WebSocket
을 사용할 수 있도록 해주는 라이브러리 입니다.
package com.websocket.chat.config;
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").setAllowedOrigins("*")
.withSockJS();
}
}
STOMP
를 사용하기 위해 @EnableWebSocketMessageBroker
을 선언하고 WebSocketMessageBrokerConfigurer
를 상속받아 configureMessageBroker
를 구현하고 있습니다.
PUB/SUB
메시징을 구현하기 위해 메시지를 발행하는 요청의 prefix는 /pub
으로 시작하도록 설정하고 메시지를 구독하는 요청의 prefix는 /sub
으로 시작하도록 설정합니다.
STOMP
의 WebSocket
연결 endpoint는 /ws-stomp
로 설정하였습니다. 개발 서버의 WebSocket
접속 주소는 ws://localhost:8080/ws-stomp
가 됩니다.
@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;
}
}
PUB/SUB
방식을 이용하면 구독자 관리가 알아서 되기 때문에 WebSocket Session
관리를 따로 할 필요가 없어집니다.
발솔 구현 또한 알아서 해결되므로 일일이 클라이언트에게 메시지를 발송하는 구현 또한 필요없어졌습니다.
package com.spring.wschat.repo;
// import 생략....
@Repository
public class ChatRoomRepository {
private Map<String, ChatRoom> chatRoomMap;
@PostConstruct
private void init() {
chatRoomMap = new LinkedHashMap<>();
}
public List<ChatRoom> findAllRoom() {
// 채팅방 생성순서 최근 순으로 반환
List 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;
}
}
채팅방을 생성하고 정보를 조회하는 Repository
를 따로 생성해줍니다. 기존의 ChatService
를 대체합니다.
package com.websocket.chat.controller;
// import 생략...
@RequiredArgsConstructor
@Controller
public class ChatController {
private final SimpMessageSendingOperations messagingTemplate;
@MessageMapping("/chat/message")
public void message(ChatMessage message) {
if (ChatMessage.MessageType.JOIN.equals(message.getType()))
message.setMessage(message.getSender() + "님이 입장하셨습니다.");
messagingTemplate.convertAndSend("/sub/chat/room/" + message.getRoomId(), message);
}
}
@MessageMapping
을 통해 WebSocket
으로 들어오는 메시지 발행을 처리하게 됩니다.
클라이언트에서는 prefix를 붙여 /pub/chat/message
로 메시지 발행 요청을 하면 위의 컨트롤러가 해당 메시지를 받아 처리하는 구조입니다.
메시지가 발행되면 /sub/chat/room/{roomId}
로 메시지를 전송하게 되고 클라이언트에서 해당 주소(/sub/chat/room/{roomId}
)를 구독하고 있다가 메시지가 전달되면 화면에 출력하게 됩니다.
roomId
가 채팅방을 구분하는 값으로 PUB/SUB
에서 Topic
역할을 하고 있습니다.
기존의 WebSocketChatHandler
역할을 대신하므로 해당 클래스는 삭제합니다.
백엔드 서버에서는 구독자를 따로 구현하지 않고 View(프론트)에서 STOMP
라이브러리를 이용해서 subscribe
주소를 바라보는 코드를 구현하시면 됩니다.
useEffect(() => {
const sock = new SockJS('http://localhost:8081/ws-stomp');
const ws = Stomp.over(sock);
ws.connect({"Authorization": getCookie("Authorization")}, function(frame) {
console.log('Connected: ' + frame);
// 채팅방 목록에 방생성 및 각종 이벤트를 알리기 위한 구독(재랜더링용)
ws.subscribe(`/sub/chatRoom/renew/${getCookie("memberId")}`, (message) => {
const newMessage = JSON.parse(message.body);
console.log(newMessage);
setRenewRoom(prevState => !prevState)
},{"Authorization" : getCookie("Authorization")})
...
package com.websocket.chat.controller;
// import 생략...
@RequiredArgsConstructor
@Controller
@RequestMapping("/chat")
public class ChatRoomController {
private final com.spring.wschat.repo.ChatRoomRepository chatRoomRepository;
// 채팅 리스트 화면
@GetMapping("/room")
public String rooms(Model model) {
return "/chat/room";
}
// 모든 채팅방 목록 반환
@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/enter/{roomId}")
public String roomDetail(Model model, @PathVariable String roomId) {
model.addAttribute("roomId", roomId);
return "/chat/roomdetail";
}
// 특정 채팅방 조회
@GetMapping("/room/{roomId}")
@ResponseBody
public ChatRoom roomInfo(@PathVariable String roomId) {
return chatRoomRepository.findRoomById(roomId);
}
}
채팅 화면(채팅방 목록, 채팅방 입장)에 필요한 데이터를 전달하기 위해 ChatRoomController
를 생성해줍니다.
<!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>
<!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.subscribe("/sub/chat/room/"+vm.$data.roomId, function(message) {
var recv = JSON.parse(message.body);
vm.recvMessage(recv);
});
구독은 /sub/chat/room/roomId
로 구독하고 이 주소를 Topic
으로 삼아 서버에서 메시지를 발행하게 됩니다.
위의 코드에서 function
은 콜백함수를 정의한 내용입니다. 즉, 해당 Topic
으로 메시지가 발송될 시 해당 콜백함수에 전달되어 로직을 수행하게 됩니다.
정리하면 채팅방에서 클라이언트가 메시지를 입력하면 서버에서 Topic
으로 메시지를 발행하는데 이것을 구독자가 해당 Topic
에서 메시지 수신을 대기하고 있다가 발송된 메시지를 받아서 처리할 수 있습니다.