험난했던 큐시즘 밋업 프로젝트가 드디어 끝났다.
와 ! 너무 기쁜데 너무 힘들어
기쁨을 뒤로하고 이번 밋업 때 특히 애정을 쏟아 만든 채팅을 어떻게 구현했는지 기록해보려고 한다. 채팅은 처음 구현해보는 거라 처음엔 감도 못 잡았었지만, 우리팀 서버 개발자가 작성해준 테스트코드 덕분에 잘 구현할 수 있었던 것 같다. ㅎㅎ
(영진오빠 최고)
우선 웹소켓에 대해 알아보자.
우리는 일반적으로 HTTP 통신을 이용하고, 클라이언트에서 서버에 요청을 보내야만 서버에서 응답을 하기 때문에, 서버에서 요청을 받지 않으면 클라이언트와 통신할 수 없다.
하지만 웹소켓 프로토콜은, 양방향으로 통신할 수 있다. 채팅, 문의, 주식 등과 같이 웹 서버와 웹 브라우저가 서로 실시간 메시지를 교환하는 데 사용할 수 있다. 웹소켓 connect를 위한 첫 번째 핸드셰이크를 주고받은 이후 지속적으로 연결이 유지되고, 매번 메시지 전송 시에 새롭게 connect를 시도할 필요가 없어 빠르게 통신 가능하다.
(원어로 수업을 들었던 컴네 기억이 슬며시 떠올랐다 ^,^)
웹 소켓 통신이 가능해지면 http
, https
프로토콜과 달리 ws
, wss
프로토콜을 사용하게 된다. connect 과정을 더 자세히 살펴보자.
웹 브라우저에서 하는 채팅이기에 소켓 통신을 이용하기 위해선 소켓 통신이 가능한지 확인하는 Hand Shake
과정이 필요하다.
1) 브라우저에서 HTTP 통신을 이용해서 서버 단에 소켓 통신이 가능한지 요청을 보낸다.
2) 서버에서 웹 소켓 통신이 가능해지면 101 상태의 response를 보내게 된다. 이 때 서버에서는 클라이언트에서 받은 ‘Sec-WebSocket-Key’ 키 값에 문자를 더한 뒤 암호화하여 ‘Sec-WebSocket-Accept’로 클라이언트에게 response를 보낸다.
*위 사진은 실제 웹소켓 연결 시 찍히는 네트워크이다.
3) 이제 wss
로 바뀐 프로토콜을 이용해 양방향 통신을 진행할 수 있게 되었다!
웹소켓의 대략적인 개념과 연결 과정을 알았으니 실제로 채팅 구현을 위해 사용했던 라이브러리에 대해 정리하고 넘어가보자.
프론트엔드에서 사용한 라이브러리는 다음과 같다.
@stomp/stompjs
@sockjs-client
sockjs-client의 공식 깃허브에 들어가보면 WebSocket emulation
이라는 키워드를 볼 수 있다.
이게 뭐냐면!?
기존의 웹소켓은 파이어폭스, 크롬, 엣지 등의 브라우저에서는 정상 작동하지만 모바일 크롬이나 익스플로러에서는 동작하지 않는 이슈가 있다고 한다. 그래서 커넥션이 끊기는 걸 방지하기 위해 websocket emulation 기술을 고안한 것으로 보인다.
또한 깃허브 설명을 보면 아래와 같이 sockJS의 장점이 나와있다.
sockJS는 웹소켓과 유사한 기능을 제공하는 브라우저 자바스크립트 라이브러리이다. 브라우저와 웹 서버 간에 짧은 대기 시간, 전이중, 도메인 간 통신 채널을 생성하는 일관된 크로스 브라우저 자바스크립트 API를 제공하는 역할을 한다.
백엔드에서 설정한 서브 프로토콜! 웹소켓의 서브 프로토콜인 stomp 위에서 sockJS가 정상적으로 작동되고 stomp 프로토콜 환경에서 stompJS에서 제공하는 프로토콜 연결, 메세지 전송, 구독 기능을 제공하는 라이브러리를 프론트에서 사용하였다.
우선 stomp는 Simple Text Oriented Messaging Protocol
의 약자다. 즉, 메세지 전송을 효율적으로 수행하기 위해 만든 프로토콜이다.
stomp의 플로우는 다음과 같다.
서버와 클라이언트 연결(connect) ⇒ 메세지 구독(subscribe) ⇒ 메세지 발행(publish) ⇒ 연결 종료(disconnect)
일반적인 웹소켓만을 사용하는 통신에서는 발신자와 수신자를 서버단에서 직접 관리하고, 웹소켓핸들러를 만들어 또 전달 및 관리가 필요하기 때문에 구현 코드가 복잡해진다. 반면에 stomp는 publish, subscribe를 기반으로 동작하기 때문에 메세지 송수신에 대한 처리가 훨씬 간단해진다.
stomp에서 정의하는 플로우는 다음과 같다.
우선 구현한 화면은 아래와 같다. 실제 코드를 정리하기에 앞서 대략적인 플로우부터 적어보자.
1) 채팅방 페이지에 들어오면 웹소켓을 연결한다. + 유저가 현재 참여하고 있는 채팅방 리스트를 패칭해온다.
⇒ 위 두 가지 액션은 동시에 일어나도록 구현한다.
2) 웹소켓이 연결된 후, 특정 채팅방을 클릭하면 특정 채팅방의 roomId로 subscribe(구독)한다.
3) 구독 콜백에서 메시지를 수신하면, 메시지 내용을 JSON으로 파싱하고 채팅데이터를 업데이트한다.
4) 다른 채팅방을 클릭한 경우, 클릭된 채팅방 roomId로 다시 subscribe(구독)하며, 이미 구독되어 있던 채팅방은 unsubscribe한다.
이제 실제 코드와 함께 정리해보자 😈
1) 소켓 및 클라이언트 생성
import { CompatClient, Stomp } from '@stomp/stompjs';
import SockJS from 'sockjs-client';
우선 웹소켓 연결에 필요한 라이브러리를 임포트해준다.
const socket = new SockJS(`${SOCKET_URL}`);
const client = Stomp.over(socket);
다음으로, 웹소켓 연결에 필요한 socket
과 client
객체를 정의해준다. stomp 프로토콜 위에서 sockJS가 동작하도록 Stomp.over()
의 인자에는 SockJS로 만든 socket
변수를 넣어준다.
이렇게 되면, client 객체는 서버 연결, 메시지 전송, 구독 관련 기능을 수행할 수 있게 된 것!
2) 웹소켓 연결
const connectToWebSocket = () => {
...
client.connect(
{},
() => {
// console.log('Connection success');
setIsSocketLoading(false);
},
() => {
// console.log('Connection failed');
},
);
...
return () => {
if (client) {
client.disconnect();
}
};
};
client.connect()
를 이용해 client가 웹소켓 서버에 연결되도록 작성한다. 이 때 세 가지 인자가 전달되는데, Connection Headers, Connection Success Callback, Connection Failure Callback이 전달된다.
setIsSocketLoading(false)
를 사용했는데, 이는 연결 여부를 의미하는 로딩 상태이며 연결이 성공적으로 이루어지면 false로 업데이트하는 로직으로 작성하였다.웹소켓 연결이 잘 이루어지면 콘솔엔 아래와 같은 로그가 찍힌다.
웹소켓 연결 성공 😃 히히
3) 채팅방 구독(subscribe)
웹소켓 연결에 성공했으니 이제 채팅방을 구독해보자!
특정 채팅방을 구독하게 되면, 이제 상대방이 해당 채팅방을 구독하고 메시지를 보내면 본인 또한 메시지를 받을 수 있게 된다. 구독을 위해 subscribe 프레임을 보내보자.
...
if (stompClient?.connected) {
if (subscription) {
subscription.unsubscribe();
}
const newSubscription = stompClient.subscribe(
`/topic/chatting/${roomId}`,
callback,
);
setSubscription(newSubscription);
...
}
return () => {
if (subscription) {
subscription.unsubscribe();
}
};
...
subscribe()
의 첫 번째 인자는 구독할 채팅방 URI를 의미한다. 그리고 두 번째 인자는 구독한 후에 실행되는 콜백 함수이며, 구독 이후 상대방으로부터 메시지를 수신할 때마다 해당 콜백 함수가 실행된다. 추후 또다른 채팅방 클릭 시, 이미 구독된 채팅방은 unsubscribe하도록 작성해준다. 여기서 unsubscribe는 구독을 끊는다는 의미 자체로, 서버와의 연결을 끊는 것이 아닌 특정 채팅방의 SEND 프레임을 수신하지 않겠다는 것을 의미한다.
*참고로 위에서 사용된 stompClient는 client 객체를 관리하는 state이다.
위에서 호출된 콜백 함수는 아래와 같다.
const callback = (message: any) => {
if (message.body) {
const msg = JSON.parse(message.body);
if (msg.chatMessageLog) {
setLogData(msg.chatMessageLog);
}
setChatList((chats) => [...chats, msg]);
}
};
나는 여기서 기존 채팅 기록은 logData로, 새로 업데이트 되는 채팅 데이터들은 chatList로 상태를 만들어 관리하였다.
구독이 완료되면 위와 같이 구독 정보와 메시지 데이터 정보들을 받을 수 있게 된다.
여기서 또 다시 다른 채팅방을 클릭하면 아래와 같이 이전 채팅방은 UNSUBSCRIBE되었다는 것을 확인할 수 있고 새로운 채팅방을 구독하여 데이터를 받아오는 과정을 확인할 수 있다!
우와 ~ 신기해 !
4) 메시지 전송
우리 서비스에선 메시지, 이모티콘, 약속 알림 메시지 총 3가지 타입으로 SEND 프레임을 전송하였다.
const sendChat = (id: number) => {
if (value === '') return;
const messageObject = {
senderId: myId,
text: value,
};
stompClient?.send(
`/app/chatting/${id}/text`,
{},
JSON.stringify(messageObject),
);
setValue('');
};
위와 같이 메시지 객체를 담아 client.send()
를 이용해 메시지를 전송할 수 있다.
여기서 두 번째 인자는 해당 프레임을 전송할 때 헤더를 설정하는 역할을 한다. 세 번째 인자는 해당 프레임을 전송할 때 보낼 데이터를 셋하는 body이며 이 부분에 상대방에게 보낼 메시지가 들어가게 된다.
우리 서비스에서는 이모티콘, 약속 알림 메시지 기능까지 있어서 다음과 같이 프레임을 구분하여 구현하였다.
stompClient?.send(
`/app/chatting/${id}/appointment`,
{},
JSON.stringify(messageObject),
);
stompClient?.send(
`/app/chatting/${id}/emoticon`,
{},
JSON.stringify(messageObject),
);
5) 연결 끊기
웹소켓 연결을 끊는 것은 위에서 작성했던 것처럼 클라이언트 객체에 disconnect()로 체이닝하면 된다.
...
return () => {
if (client) {
client.disconnect();
}
};
...
아래와 같이 커넥션이 성공적으로 끊긴다.
사실 처음에 기획에서 채팅 기능이 나왔을 때 다 구현할 수 있을지 너어어무 막막했었다. 그래서 테스트 레포도 따로 파서 로직을 미리 작성해보면서 완벽하게 구현하기 위해 많은 시간을 쏟았다,,! 그덕에 실제 개발할 땐 금방 구현할 수 있었던 것 같다. 서버팀의 테스트 코드 덕분인지도? ^.^
근데 사실 지금 보니까 프론트에서는 또 그렇게 어려운 구현은 아닌 듯 하다. ㅎㅎ
프로젝트를 마감하고 다시 코드를 보니까 음,,, 효율적인 로직같진 않긴 하다. 여유가 된다면 더 나은 성능을 위한 로직을 다시 짜보는 것도 좋을 것 같다.
아무튼 채팅 구현 완료 😈 히히
역시 천재 프론트엔드.. 잘 보고 갑니다