STOMP를 사용하기 위해서는 여러가지 설정이 필요하다.
먼저 설정하는 순서부터 살펴보자.
하나씩 살펴보자.
plugins {
id 'org.springframework.boot' version '2.6.3'
id 'io.spring.dependency-management' version '1.0.11.RELEASE'
id 'java'
}
group = 'com.sample'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
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'
}
tasks.named('test') {
useJUnitPlatform()
}
핵심 적인 부분만 살펴보자.
해당 부분에서 socket, sockJS, stomp 관련 라이브러리를 다운받는다.
Config를 생성하여, Socket과 STOMP관련 설정을 해주어야 한다.
@Configuration
@EnableWebSocketMessageBroker
@RequiredArgsConstructor
public class ChatConfig implements WebSocketMessageBrokerConfigurer {
private final StompHandler stompHandler;
@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");
}
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(stompHandler);
}
}
하나씩 살펴보자.
해당 메소드는 소켓 연결과 관련된 설정이다.
CONNECT : 연결 요청을 거는 과정이다.
CONNECTED : 연결 성공
ERROR : 연결 실패
해당 메소드는 Stomp 사용을 위한 Message Broker 설정을 해주는 메소드이다.
먼저 방을 만들고, 입장해야 하니 해당하는 컨트롤러를 생성하자.
@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);
}
}
@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);
}
}
여기서 살펴보면, 매핑이
라고 적혀있는 것을 볼 수 있다.
아까 Config에서 설정하는 것과는 전혀 다르다. 이유는
setApplicationDestinationPrefixes()를 통해 prefix를 "/app"으로 설정 해주었기 때문에, 경로가 한번 더 수정되어 “/app/chat/message”로 바뀐다.
사실 상 앞에서 했던 작업은 api 매핑 정도이고, 채팅 관련 모든 작업은 여기서 수행된다.
@Service
@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;
}
}
메세지를 보냄에 있어서, 사전 작업이 필요할 가능성이 존재한다.
@RequiredArgsConstructor
@Component
public class StompHandler implements ChannelInterceptor {
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
log.info("Stomp Hanler 실행");
return message;
}
}
이렇게 실행하고, 메세지 1개를 보낸다음 로그를 살펴보면
로그를 보면 handler가 먼저 실행된다. handler가 통과하면 controller로 넘어가서 메세지 매핑이 되는 것을 볼 수 있다.
그럼 JWT와 함께 쓴다는 가정을 보면 다음과 같이 수정할 수 있을 것이다.
@RequiredArgsConstructor
@Component
public class StompHandler implements ChannelInterceptor {
private final JwtTokenProvider jwtTokenProvider;
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
if(accessor.getCommand() == StompCommand.CONNECT) {
if(!jwtTokenProvider.validateToken(accessor.getFirstNativeHeader("token")))
try {
throw new AccessDeniedException("");
} catch (AccessDeniedException e) {
e.printStackTrace();
}
}
return message;
}
}
현재는 JWT 관련 설정과 클래스가 존재하지 않으니 로그만 1개 뜨도록 해두자..
view는 간단하게 보자.
<!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>
<button onclick="location.href=`/chat/room`">돌아가기</button>
</div>
<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>
실패에 대한 상황을 만들지 않았다. 나중에 추가하도록 하자!