본격적으로 STOMP를 구현하는 방법에 대해 설명하고자 한다.
// WebSocket
implementation 'org.springframework.boot:spring-boot-starter-websocket'
// STOMP
implementation 'org.webjars:stomp-websocket:2.3.4'
// SockJS
implementation 'org.webjars:sockjs-client:1.5.1'
//thymeleaf
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
//JPA
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
DB의 경우 H2를 사용했으며, 필요시 H2 속성이나 다른 DB 속성을 추가해서 사용하면 된다.
Domain의 경우 chatRoom(채팅방)과 Chatter(채팅방 참여자)로 구분해 구현했다.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ChatRoom {
@Id
@Column(name = "id")
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
@Column(name = "chatRoomId")
private String chatRoomId;
@Column(name = "name")
private String name;
@OneToMany
@Column(name = "chatterList")
private List<Chatter> chatters;
@Builder
public ChatRoom(String name) {
this.chatRoomId = UUID.randomUUID().toString();
this.name = name;
this.chatters = new ArrayList<>();
}
... //아래에서 계속
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Chatter {
@Id
@Column(name = "chatterId")
private String chatterId;
@Builder
public Chatter(String id) {
this.chatterId = id;
}
}
ChatRoom 저장소와 채팅 Chatter 저장소로 나누어 구현했다.
@Repository
public interface ChatRoomRepository extends JpaRepository<ChatRoom, Long> {
}
ChatRoom 객체와 ChatRoom의 구분자인 Long을 사용해 JpaRepository를 상속받는 인터페이스를 구성한다.
@Repository
public interface ChatterRepository extends JpaRepository<Chatter, String> {
}
Chatter 객체와 Chatter의 구분자(Id)인 String을 사용해 JpaRepository를 상속받는 인터페이스를 구성한다.
MessageDto를 사용해 메시지를 전송한다.
DTO : Data Transfer Object, 프로세스 간 데이터 전달을 위해 사용되는 객체
@Getter
@AllArgsConstructor
@NoArgsConstructor
public class MessageDto {
public enum MessageType {
ENTER, TALK, LEAVE
}
private MessageType type;
private String roomId;
private String sender;
private String message;
public void setMessage(String s) {
this.message = s;
}
}
WebSocket과 STOMP를 사용하기 위해서는 설정이 필요하다.
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "DELETE")
.allowedHeaders("Authorization", "Content-Type")
.exposedHeaders("Custom-Header")
.maxAge(3600);
}
}
CORS를 방지하기 위한 설정이다.
CORS를 설정하면서 다양한 오류가 발생할 수 있으니 주의하기 바란다.
@Configuration
@RequiredArgsConstructor
@EnableWebSocketMessageBroker
public class WebSockConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry brokerRegistry) {
brokerRegistry.enableSimpleBroker("/sub"); //Publisher가 /sub 경로로 메시지 전달 시 Subscriber에게 전달
brokerRegistry.setApplicationDestinationPrefixes("/pub"); //Publisher가 /pub 경로로 메시지 전달 시 가공 후 Subscriber에게 전달
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws-stomp").setAllowedOriginPatterns("http://localhost:8080").withSockJS();
}
}
@Slf4j
@RequiredArgsConstructor
@Service
public class ChatService {
private final ObjectMapper mapper;
private ConcurrentHashMap<String, ChatRoom> chatRoomMap;
private final ChatRoomRepository chatRoomRepository;
@PostConstruct
private void init() {
chatRoomMap = new ConcurrentHashMap<>();
}
public List<ChatRoom> findAllRoom() {
return new ArrayList<>(chatRoomMap.values());
}
public ChatRoom findRoomById(String id) {
return chatRoomMap.get(id);
}
@Transactional
public ChatRoom createRoom(String name) {
ChatRoom chatRoom = chatRoomRepository.save(new ChatRoom(name));
log.info("Create Room : {} {}", chatRoom.getId(), chatRoom.getName());
chatRoomMap.put(chatRoom.getChatRoomId(), chatRoom);
return chatRoom;
}
}
컨트롤러에서는 채팅과 채팅방 로직을 수행한다.
이때 @RestController가 아닌 @Controller를 사용해 View와 Controller를 연결할 수 있도록 했다. (@RestController를 사용하게 될 경우 View가 아닌 객체 자체를 반환)
@RequiredArgsConstructor
@Controller
public class ChatController {
private final SimpMessageSendingOperations simpMessageSendingOperations;
@MessageMapping("/chat/message")
public void message(MessageDto messageDto) {
if (MessageDto.MessageType.ENTER.equals(messageDto.getType())) {
messageDto.setMessage(messageDto.getSender() + "님이 입장하셨습니다.");
}
if (MessageDto.MessageType.LEAVE.equals(messageDto.getType())) {
messageDto.setMessage(messageDto.getSender() + "님이 퇴장하셨습니다.");
}
simpMessageSendingOperations.convertAndSend("/sub/chat/room/" + messageDto.getRoomId(), messageDto);
}
}
@RequiredArgsConstructor
@Controller
public class ChatRoomController {
private final ChatService chatService;
@GetMapping("/chat/room")
public String chatRoomList(Model model) {
return "/chat/room";
} //채팅방 목록 화면
@GetMapping("/chat/room/all")
@ResponseBody
public List<ChatRoom> chatRoomAll() {
return chatService.findAllRoom();
} //채팅방 목록 조회
@PostMapping("/chat/room")
@ResponseBody
public ChatRoom createChatRoom(@RequestParam String name) {
return chatService.createRoom(name);
} //채팅방 생성
@GetMapping("/chat/room/enter/{chatRoomId}")
public String enterChatRoom(Model model, @PathVariable(value = "chatRoomId") String chatRoomId) {
model.addAttribute("chatRoomId", chatRoomId);
return "/chat/enter";
} //채팅방 입장 화면
@GetMapping("/chat/room/{chatRoomId}")
@ResponseBody
public ChatRoom chatRoom(@PathVariable(value = "chatRoomId") String chatRoomId) {
return chatService.findRoomById(chatRoomId);
} //채팅방 조회
templates 안에 view 파일을 만들어 사용했다.
HTML, CSS, Vanilla JS를 사용해 html 파일을 구성했다.
<!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="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css">
</head>
<body>
<div class="container" id="pub">
<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>
<label for="room_name"></label><input type="text" class="form-control" id="room_name" />
<div class="input-group-append">
<button class="btn btn-primary" type="button" id="createRoomBtn">채팅방 개설</button>
</div>
</div>
<ul class="list-group" id="chatroomList">
</ul>
</div>
<!-- JavaScript -->
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const roomNameInput = document.getElementById('room_name');
const chatroomList = document.getElementById('chatroomList');
const createRoomBtn = document.getElementById('createRoomBtn');
// 채팅방 목록 로드
function findAllRoom() {
axios.get('http://localhost:8080/chat/room/all')
.then(response => {
chatroomList.innerHTML = ''; // 리스트 초기화
response.data.forEach(item => {
const li = document.createElement('li');
li.className = 'list-group-item list-group-item-action';
li.textContent = item.name;
li.addEventListener('click', () => enterRoom(item.chatRoomId));
chatroomList.appendChild(li);
});
});
}
// 채팅방 생성
function createRoom() {
const roomName = roomNameInput.value.trim();
if (roomName === "") {
alert("방 제목을 입력해 주십시요.");
return;
}
const params = new URLSearchParams();
params.append("name", roomName);
axios.post('http://localhost:8080/chat/room', params)
.then(response => {
alert(response.data.name + " 방 개설에 성공하였습니다.");
roomNameInput.value = '';
findAllRoom();
})
.catch(() => {
alert("채팅방 개설에 실패하였습니다.");
});
}
// 방에 들어가기
function enterRoom(roomId) {
const sender = prompt('대화명을 입력해 주세요.');
if (sender) {
localStorage.setItem('wschat.sender', sender);
localStorage.setItem('wschat.roomId', roomId);
window.location.href = "http://localhost:8080/chat/room/enter/" + roomId;
}
}
// Enter 키로 채팅방 생성하기
roomNameInput.addEventListener('keyup', function(event) {
if (event.key === 'Enter') {
createRoom();
}
});
// 버튼 클릭으로 채팅방 생성
createRoomBtn.addEventListener('click', createRoom);
// 페이지 로드 시 채팅방 목록 불러오기
findAllRoom();
});
</script>
</body>
</html>
<!doctype html>
<html lang="en">
<head>
<title>Websocket ChatRoom</title>
<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="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css">
</head>
<body>
<div class="container" id="pub">
<div>
<h2 id="roomName"></h2>
<button class="btn btn-primary" type="button" id="leaveButton">나가기</button>
</div>
<div class="input-group">
<div class="input-group-prepend">
<label class="input-group-text">내용</label>
</div>
<input type="text" class="form-control" id="messageInput">
<div class="input-group-append">
<button class="btn btn-primary" type="button" id="sendButton">보내기</button>
</div>
</div>
<ul class="list-group" id="messagesList">
</ul>
</div>
<!-- JavaScript -->
<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/stompjs@2.3.3/lib/stomp.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script>
var sock = new SockJS('http://localhost:8080/ws-stomp'); //소켓 생성
var ws = Stomp.over(sock);
var reconnect = 0;
//채팅방 Id와 채팅 참여자 닉네임을 가져와서 저장
var roomId = localStorage.getItem('wschat.roomId');
var sender = localStorage.getItem('wschat.sender');
var room = {};
var messages = [];
console.log("Chat Room ID: " + roomId); //서버에서 채팅방 입장 확인
document.getElementById('sendButton').addEventListener('click', sendMessage);
document.getElementById('leaveButton').addEventListener('click', leaveChatRoom);
document.getElementById('messageInput').addEventListener('keypress', function(event) {
if (event.key === 'Enter') {
sendMessage();
}
});
function findRoom() {
axios.get('http://localhost:8080/chat/room/' + roomId).then(function(response) {
room = response.data;
document.getElementById('roomName').textContent = room.name;
});
} //채팅방 찾기 기능
function sendMessage() {
var messageInput = document.getElementById('messageInput');
var message = messageInput.value;
if (message) {
ws.send("/pub/chat/message", {}, JSON.stringify({type: 'TALK', roomId: roomId, sender: sender, message: message}));
messageInput.value = '';
}
} //메시지 전송 기능
function revMessage(rev) {
var messageList = document.getElementById('messagesList');
var newMessage = document.createElement('li');
newMessage.classList.add('list-group-item');
newMessage.textContent = (rev.type === 'ENTER' ? '[알림]' : rev.sender) + ' - ' + rev.message;
messageList.insertBefore(newMessage, messageList.firstChild);
} //전송받거나 전송한 메시지를 채팅방에 표시
function leaveChatRoom() {
ws.send("/pub/chat/message", {}, JSON.stringify({type: 'LEAVE', roomId: roomId, sender: sender, message: ''}));
window.location.href = "http://localhost:8080/chat/room";
} //채팅방 나가기 기능
function connect() {
ws.connect({}, function(frame) {
ws.subscribe("/sub/chat/room/" + roomId, function(message) {
var rev = JSON.parse(message.body);
revMessage(rev);
});
ws.send("/pub/chat/message", {}, JSON.stringify({type: 'ENTER', roomId: roomId, sender: sender}));
}, function(error) {
if (reconnect++ <= 5) {
setTimeout(function() {
console.log("connection reconnect");
sock = new SockJS("http://localhost:8080/ws-stomp");
ws = Stomp.over(sock);
connect();
}, 10 * 1000);
}
});
} //소켓 연결이 되지 않을 경우 5번 재연결 시도
findRoom();
connect();
</script>
</body>
</html>
"/sub"로 시작하는 경로는 메시지 브로커를 향하도록 설정
"/pub"로 시작하는 경로는 @MessageMapping을 향하도록 설정
이제 localhost에서 view에 접속해 채팅방 생성, 참여, 채팅 보내기, 퇴장 등을 했을 때 실시간으로 메시지를 받을 수 있다.