스프링부트에서 STOMP를 사용하는 PUB/SUB 채팅방을 만들어 보겠습니다!
깃헙에 있는 정리본을 참고해주세요!
https://github.com/namusik/TIL-SampleProject/tree/main/websocket
https://github.com/namusik/TIL-SampleProject/tree/main/websocket/STOMP%20%EC%98%88%EC%A0%9C
IntelliJ
Spring-boot
java 11
gradle
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
runtimeOnly 'mysql:mysql-connector-java'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
//websocket
implementation 'org.springframework.boot:spring-boot-starter-websocket'
implementation 'org.webjars:sockjs-client:1.1.2'
implementation 'org.webjars:stomp-websocket:2.3.3-1'
//view
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
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 'com.google.code.gson:gson:2.8.0'
}
spring.devtools.livereload.enabled=true
spring.devtools.restart.enabled=false
spring.freemarker.cache=false
spring.jackson.serialization.fail-on-empty-beans=false
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class ChatMessage {
public enum MessageType {
ENTER, TALK
}
private MessageType type;
//채팅방 ID
private String roomId;
//보내는 사람
private String sender;
//내용
private String message;
}
채팅 메시지 객체
@Getter
@Setter
@NoArgsConstructor
public class ChatRoom {
private String roomId;
private String roomName;
public static ChatRoom create(String name) {
ChatRoom room = new ChatRoom();
room.roomId = UUID.randomUUID().toString();
room.roomName = name;
return room;
}
}
채팅방 객체
roomId는 일단 랜덤하게 생성해서 set 해줌.
@Configuration
@EnableWebSocketMessageBroker
public class ChatConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws/chat").setAllowedOriginPatterns("*").withSockJS();
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/queue", "/topic");
registry.setApplicationDestinationPrefixes("/app");
}
}
메시지 브로커에 대한 설정을 해주는 Config입니다.
registerStompEndpoints():
Client에서 websocket연결할 때 사용할 API 경로를 설정해주는 메서드.
roomdetail.html의 46번째 줄, var sock = new SockJS("/ws/chat");
에서 새로운 핸드쉐이크 커넥션을 생성할 때 사용됨.
enableSimpleBroker("/queue", "/topic"):
메시지 받을 때 관련 경로 설정
"/queue", "/topic" 이 두 경로가 prefix(api 경로 맨 앞)에 붙은 경우, messageBroker가 잡아서 해당 채팅방을 구독하고 있는 클라이언트에게 메시지를 전달해줌
주로 "/queue"는 1대1 메시징, "/topic"은 1대다 메시징일 때 주로 사용함.
setApplicationDestinationPrefixes("/app"):
메시지 보낼 때 관련 경로 설정
클라이언트가 메시지를 보낼 때 경로 맨앞에 "/app"이 붙어있으면 Broker로 보내짐.
@Service
@Slf4j
@RequiredArgsConstructor
public class ChatService {
private Map<String, ChatRoom> chatRooms;
@PostConstruct
//의존관게 주입완료되면 실행되는 코드
private void init() {
chatRooms = new LinkedHashMap<>();
}
//채팅방 불러오기
public List<ChatRoom> findAllRoom() {
//채팅방 최근 생성 순으로 반환
List<ChatRoom> result = new ArrayList<>(chatRooms.values());
Collections.reverse(result);
return result;
}
//채팅방 하나 불러오기
public ChatRoom findById(String roomId) {
return chatRooms.get(roomId);
}
//채팅방 생성
public ChatRoom createRoom(String name) {
ChatRoom chatRoom = ChatRoom.create(name);
chatRooms.put(chatRoom.getRoomId(), chatRoom);
return chatRoom;
}
}
채팅방과 관련된 비즈니스 로직을 담은 Service
@Controller
@RequiredArgsConstructor
@RequestMapping("/chat")
public class ChatRoomController {
private final ChatService chatService;
// 채팅 리스트 화면
@GetMapping("/room")
public String rooms(Model model) {
return "/chat/room";
}
// 모든 채팅방 목록 반환
@GetMapping("/rooms")
@ResponseBody
public List<ChatRoom> room() {
return chatService.findAllRoom();
}
// 채팅방 생성
@PostMapping("/room")
@ResponseBody
public ChatRoom createRoom(@RequestParam String name) {
return chatService.createRoom(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 chatService.findById(roomId);
}
}
채팅방과 관련된 비즈니스 로직을 담은 Controller.
실제 수행은 Service에서 하게 됨.
@RestController
@RequiredArgsConstructor
public class MessageController {
private final SimpMessageSendingOperations sendingOperations;
@MessageMapping("/chat/message")
public void enter(ChatMessage message) {
if (ChatMessage.MessageType.ENTER.equals(message.getType())) {
message.setMessage(message.getSender()+"님이 입장하였습니다.");
}
sendingOperations.convertAndSend("/topic/chat/room/"+message.getRoomId(),message);
}
}
메시지 전달관 관련된 Controller
@MessageMapping()의 경로가 "/chat/message"이지만 ChatConfig의 setApplicationDestinationPrefixes()를 통해 prefix를 "/app"으로 해줬기 때문에
실질 경로는 "/app/chat/message"가 됨
클라이언트에서 "/app/chat/message"의 경로로 메시지를 보내는 요청을 하면,
메시지 Controller가 받아서 "topic/chat/room/{roomId}"를 구독하고 있는 클라이언트에게 메시지를 전달하게 됨.
!!!html 파일들은 resources/templates 폴더 안에 chat 폴더를 만들고 안에 만들어 주면 됨!!
<!doctype html>
<html lang="en" xmlns:v-on="http://www.w3.org/1999/xhtml" xmlns:v-bind="http://www.w3.org/1999/xhtml">
<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.roomName}}
</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.roomName+"방 개설에 성공하였습니다.")
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" xmlns:v-on="http://www.w3.org/1999/xhtml">
<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}}
</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/chat");
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("/app/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("/topic/chat/room/"+vm.$data.roomId, function(message) {
var recv = JSON.parse(message.body);
vm.recvMessage(recv);
});
ws.send("/app/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/chat");
ws = Stomp.over(sock);
connect();
},10*1000);
}
});
}
connect();
</script>
</body>
</html>
69번째 줄을 보면 메시지를 보낼 때 수행되는 sendMessage 메서드가 있는데
안에 경로를 보면 "/app/chat/message"로 되어있다.
MessageController의 enter()메서드의 실질 경로가 "/app/chat/message"이기 때문에 이 쪽으로 요청이 들어감.
그러면 /topic/chat/room/{roomId}를 구독하고 있는 client에게 메시지를 전달해주는데,
81번째 줄을 보면 ws.subscribe("/topic/chat/room/roomId")로 구독 경로를 설정하고 있기 때문에 메시지를 받을 수 있게 됨.
먼저 브라우저 2개에서 각각 경로로 접속해서 한쪽에서 채팅방을 만들면, 양쪽에 보이게 됩니다.
모두 채팅방에 입장하면 입장 알림 메시지가 전달되고,
각각 메시지를 보내보면 양쪽에서 모두 잘 메시를 받는 것이 보입니다.
https://dev-gorany.tistory.com/325
https://daddyprogrammer.org/post/4691/spring-websocket-chatting-server-stomp-server/