-
WebSocket을 이용한 실시간 채팅 구현하기 2: 클라이언트Today I Learned 2022. 12. 21. 10:25
WebSocket을 이용한 실시간 채팅 구현 기록은 다음의 네 단계에 걸쳐 작성할 예정이다.
- WebSocket을 이용한 실시간 채팅 구현 1: 서버
- WebSocket을 이용한 실시간 채팅 구현 2: 클라이언트
- WebSocket을 이용한 실시간 채팅 구현 3: STOMP
- WebSocket을 이용한 실시간 채팅 구현 4: 프로젝트에 적용하기
실시간 채팅을 처리하는 서버에 요청을 전송할 간단한 클라이언트를 React를 이용해 구현했다.
동작은 아래의 화면과 같이 이루어진다. 클라이언트에서는 3개의 채팅방에 접근할 수 있다. 각 채팅방을 열면 입력창이 출력되고, 채팅방과 연결된 모든 세션에 해당 사용자가 채팅방에 입장했다는 메시지가 전달된다.
사용자가 메시지를 입력하고 전송 버튼을 누르면 채팅방과 연결된 모든 세션에 사용자가 입력한 메시지가 전달된다.
사용자가 다른 채팅방에 접속하거나 연결을 종료하면 해당 채팅방과 연결된 모든 세션에 해당 사용자가 채팅방을 나갔다는 메시지가 전달되고, 해당 사용자의 화면에서 기존에 접속했던 채팅방의 메시지 로그는 사라진다. 입력창은 사라지거나 다른 채팅방의 입력창으로 대체된다.
간단한 채팅 클라이언트이지만 프로젝트에 추가될 때에는 연결을 수립하거나 메시지를 전송하는 동작과 같은 비즈니스 로직과 화면에 메시지를 보여주는 UI가 구분되어야 할 것이기 때문에 프로젝트에서 적용하고 있는 Flux Architecture 패턴을 따라 구현하는 것을 시도했다. 다음 링크에서 전체 소스코드를 확인할 수 있다.
먼저 JavaScript에서 WebSocket 연결 수립은 다음과 같이 WebSocket에 연결할 주소를 인자로 주어 호출하는 방식으로 이루어진다.
const baseUrl = 'ws://localhost:8002'; const socket = new WebSocket(`${baseUrl}/chat/rooms/${roomIndex}`);
서버와 핸드 셰이킹이 정상적으로 이루어지면, 클라이언트에서는 socket에 대해 다음 4개의 이벤트 함수의 몸체를 구현할 수 있다.
- onopen: 서버와 핸드 셰이킹이 이루어진 직후 해당 함수를 수행한다.
- onmessage: 서버로부터 데이터를 수신할 경우 해당 함수를 수행한다.
- onclose: 서버와의 연결이 종료된 직후 해당 함수를 수행한다.
- onerror: 에러가 발생했을 때 해당 함수를 수행한다.
구체적으로 구현한 내용을 살펴보자. 사용자가 채팅방 목록 컴포넌트에 있는 채팅방 버튼을 클릭하면 컴포넌트는 MessageStore에 있는 서버 연결 함수를 호출하고, Store의 해당 함수에서는 채팅방 주소를 포함한 주소에 대해 서버에 WebSocket 핸드 셰이킹을 요청한다. 이때 이미 서버의 다른 채팅방과 연결이 수립되어 있다면, 해당 연결을 종료시킨 뒤 핸드 셰이킹 요청을 통해 연결을 수립한다.
서버와 핸드 셰이킹이 정상적으로 이루어지면, onopen 이벤트 함수가 실행되면서 클라이언트가 채팅방에 입장했다는 메시지를 서버에 전송하는 Store의 함수를 호출한다. Store에서는 MessageService의 메시지 전송 함수를 다시 호출하면서 socket과 사용자가 채팅방에 입장했다는 메시지를 인자로 전달하고, MessageService의 함수에서는 인자로 받은 socket과 메시지를 이용해 서버에 메시지를 전송한다. 서버는 전송받은 메시지를 채팅방에 연결되어 있는 모든 session들에 다시 전송한다.
(채팅방에 입장했다는 메시지 전송은 onopen 함수 내에 존재해야 한다. WebSocket 함수 호출 다음 라인에 바로 socket.send(메시지)와 같이 호출할 경우 에러가 발생한다. WebSocket 함수 호출 다음 라인에서 socket.readyState 속성 값을 확인해보면 연결 중인 상태임을 나타내는 0을 반환하는 것을 확인할 수 있다.)
사용자가 연결을 종료하는 경우에는 서버에 해당 클라이언트가 채팅방을 나갔다는 메시지를 서버에 전송한 뒤, socket의 close 함수를 호출해 서버와의 연결을 종료시킨다.
// components/RoomList.jsx export default function RoomList() { const messageStore = useMessageStore(); const { socket, connected, currentRoomIndex, roomIndices, } = messageStore; const handleClickEnterRoom = ({ newRoomIndex, }) => { if (connected) { messageStore.disconnect(currentRoomIndex); } messageStore.connect(newRoomIndex); }; const handleClickQuitRoom = async () => { messageStore.disconnect(currentRoomIndex); }; socket.onopen = () => { messageStore.sendMessage({ status: 'CONNECTED' }); }; return ( <div> <ul> {roomIndices.map((roomIndex) => ( <li key={roomIndex}> <button type="button" onClick={() => handleClickEnterRoom({ previousRoomIndex: currentRoomIndex, newRoomIndex: roomIndex, })} > 채팅방 {' '} {roomIndex} </button> </li> ))} </ul> <button type="button" onClick={() => handleClickQuitRoom()} > 연결 종료 </button> </div> ); }
// stores/MessageStore.js export default class MessageStore { constructor() { this.listeners = new Set(); this.userIndex = Math.ceil(Math.random() * 1000); this.socket = ''; this.connected = false; this.currentRoomIndex = 0; this.roomIndices = [1, 2, 3]; this.messageToSend = ''; this.messageLogs = []; } connect(roomIndex) { this.socket = new WebSocket(`${baseUrl}/chat/rooms/${roomIndex}`); this.currentRoomIndex = roomIndex; this.connected = true; this.publish(); } disconnect() { this.sendMessage({ status: 'DISCONNECTED' }); this.socket.close(); this.connected = false; this.currentRoomIndex = 0; this.messageLogs = []; this.publish(); } sendMessage({ status }) { if (status === 'CONNECTED') { this.messageToSend = `사용자 ${this.userIndex} 님이 채팅방 ${this.currentRoomIndex}에 입장했습니다.`; } if (status === 'DISCONNECTED') { this.messageToSend = `사용자 ${this.userIndex} 님이 채팅방 ${this.currentRoomIndex}에서 나갔습니다.`; } messageService.sendMessage({ socket: this.socket, messageToSend: this.messageToSend, }); this.messageToSend = ''; this.publish(); } }
// services/MessageService.js export default class MessageService { sendMessage({ socket, messageToSend, }) { socket.send(messageToSend); } } export const messageService = new MessageService();
연결이 수립되면 채팅방 컴포넌트에서는 메시지 입력 form과 연결 이후에 전달받는 모든 메시지를 확인할 수 있게 된다. 사용자가 폼에 입력하는 메시지는 Store에 상태로 저장되고, 전송 버튼을 누르면 상태로 저장하고 있던 메시지를 전송하는 함수를 호출한다.
서버로부터 전송된 메시지를 받은 다른 클라이언트들에서는 onmessage 이벤트 함수가 실행된다. onmessage 이벤트 함수에서는 메시지를 받아 Store의 메시지 로그 배열에 메시지를 추가하고, 컴포넌트를 리렌더링해 전달받은 메시지를 화면에 출력시킨다.
// components/Room.jsx export default function Room() { const messageStore = useMessageStore(); const { socket, connected, messageToSend, messageLogs, } = messageStore; socket.onmessage = (event) => { const messageReceived = event.data; messageStore.receiveMessage(messageReceived); }; const handleSubmit = (event) => { event.preventDefault(); messageStore.sendMessage({ status: 'SEND' }); }; const handleChangeInput = (event) => { const { value } = event.target; messageStore.changeInput(value); }; if (!connected) { return ( null ); } return ( <div> <form onSubmit={handleSubmit}> <label htmlFor="message-to-send"> 메시지 입력 </label> <input type="text" value={messageToSend} onChange={handleChangeInput} /> <button type="submit" > 전송 </button> </form> <ul> {messageLogs.map((message) => ( <li key={message}> {message} </li> ))} </ul> </div> ); }
// stores/MessageStore.js export default class MessageStore { constructor() { // ... } changeInput(value) { this.messageToSend = value; this.publish(); } sendMessage({ status }) { if (status === 'SEND') { this.messageToSend = `사용자 ${this.userIndex}: ${this.messageToSend}`; } messageService.sendMessage({ socket: this.socket, messageToSend: this.messageToSend, }); this.messageToSend = ''; this.publish(); } receiveMessage(message) { this.messageLogs = [...this.messageLogs, message]; this.publish(); } publish() { this.listeners.forEach((listener) => listener()); } }
백엔드 서버도 다음과 같이 약간 수정되었다.
먼저 WebSocketConfig에서 더 이상 HandshakeInterceptor를 거쳐 채팅방의 주소를 알아내지 않고, addHandler 메서드에서 주소 인자로 채팅방 id를 중괄호로 감싸 가변 값임을 나타내도록 했다. 대신 SocketTextHandler에서 메시지를 전송받았을 때, 요청을 받은 session의 URI로부터 채팅방의 주소를 알아내도록 했다.
// config/WebSocketConfig.java @Configuration @EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(socketTextHandler(), "/chat/rooms/{roomId}") .setAllowedOriginPatterns("http://localhost:9991"); } @Bean public WebSocketHandler socketTextHandler() { return new SocketTextHandler(); } }
// utils/SocketTextHandler.java public class SocketTextHandler extends TextWebSocketHandler { @Autowired private RoomRepository roomRepository; @Override public void afterConnectionEstablished(WebSocketSession session) { Long roomId = getRoomId(session); roomRepository.room(roomId).sessions().add(session); System.out.println("새 클라이언트와 연결되었습니다."); } @Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws IOException { Long roomId = getRoomId(session); Room room = roomRepository.room(roomId); for (WebSocketSession connectedSession : room.sessions()) { connectedSession.sendMessage(message); } } @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { Long roomId = getRoomId(session); roomRepository.room(roomId).sessions().remove(session); System.out.println("특정 클라이언트와의 연결이 해제되었습니다."); } private Long getRoomId(WebSocketSession session) { String uri = Objects.requireNonNull(session.getUri()) .toString(); String[] parts = uri.split("/"); String roomId = parts[parts.length - 1]; return Long.parseLong(roomId); } }
클라이언트 구현에 예상보다 많은 시간을 소요했는데, 처음에는 클라이언트에서 SockJS를 호출해 핸드 셰이크를 요청하고, 서버에서도 SockJS 형태의 요청을 받을 수 있는 설정을 registry에 추가하려 했으나, 서버에서 'Invalid SockJS path - required to have 3 path segments' 에러가 발생하면서 핸드 셰이크가 이루어지지 않는 이슈를 확인했다.
해당 이슈는 STOMP와 관련이 있는 것으로 보여 STOMP를 적용한 실시간 채팅을 구현하면서 원인을 알아볼 예정이다.
References
- https://ko.javascript.info/websocket
- https://developer.mozilla.org/ko/docs/Web/API/WebSockets_API/Writing_WebSocket_client_applications
- https://www.devglan.com/spring-boot/spring-websocket-integration-example-without-stomp
'Today I Learned' 카테고리의 다른 글
CodeceptJS에서 로그인하지 않고 특정 페이지에 접속하는 경우 인가 정보를 가져오지 못하는 문제 해결 과정 (0) 2023.01.02 Parcel로 React 프로젝트 빌드 후 실행 시 src 디렉터리를 참조하지 못하는 문제 해결 과정 (0) 2023.01.02 WebSocket을 이용한 실시간 채팅 구현하기 1: 서버 (0) 2022.12.18 Web Socket과 STOMP 이해하기 (1) 2022.12.17 Polling 방식을 이용해 간단한 실시간(?) 채팅 구현하기 (0) 2022.12.15