토론 서비스에서 토론을 하기 위해선 실시간으로 사용자가 소통할 수 있어야 한다.
단순 투표나 게시글로 소통하는게 아니고 채팅으로 이야기를 나누는 서비스이기 때문에 웹소켓으로 실시간 서비스를 제공하고자 한다.
기본적으로 클라이언트-서버 관계는 무상태로 연결되어 있다.
그렇기에 요청마다 연결-종료의 과정을 거쳐야 한다.
클라이언트가 연속해서 서버에게 요청을 보내는 경우엔 네트워크 리소스 낭비가 발생하게 될 것이다.
또한 실시간 서비스의 경우는 A 클라이언트가 보낸 요청에 대해 B, C, D 클라이언트도 어떤 이벤트가 발생했는지 알 수 있어야 한다.
실시간 서비스는 단순 http 요청에서는 특정 클라이언트의 이벤트 발생을 알아챌 수 없기 때문에 서버에게 이벤트가 발생했는지 지속해서 요청을 보내 확인해야 한다.
위의 요청을 polling 방식이라고 한다.
일정 시간마다 서버에 요청을 보내 데이터가 갱신되었는지 확인하여 갱신된 값이 있다면 데이터를 응답받는 방식이다.
일반적인 클라이언트-서버 관계에서 주로 사용하는 방법이다.
Polling 방식은 클라이언트가 계속적으로 request를 보내기 때문에 서버에 부담이 될 수 있다.
그렇기에 실시간 정도의 빠른 응답을 기대하기 어려우며 http 오버헤드가 발생하게 된다.
Polling은 일정하게 갱신되는 서버 데이터의 경우에 유용하게 사용할 수 있다.
요청을 보낸 후 서버에서 응답을 보내줄 때까지 기다리는 방법이다.
즉, 서버와의 연결 지속 시간을 길게하는 방식이다.
서버의 응답이 없으면 대기하고, 이벤트가 존재하여 서버가 응답 메시지를 보내면 연결을 해제한다.
실시간 메시지 전달에 사용할 수 있으며 polling 방식보다는 서버 부담이 줄어들지만, 이벤트 발생 간격이 좁다면 Polling과 차이가 없다.
클라이언트와 서버가 한 번 연결을 맺고 난 후 일정시간 동안 서버에서 변경이 발생할 때마다 데이터를 전송하는 방법이다.
Long Polling 방식에 비해 다시 요청을 안해도 된다는 장점을 가지며, 서버의 데이터를 실시간 및 지속적으로 streaming하는 기술이다.
주로 실시간 서비스에서 사용하는 양방향 통신 프로토콜이다.
클라이언트와 서버가 HTTP 기반으로 HandShaking을 한 후, ws 프로토콜을 통해 상호간 응답 하는 방식이다.
websocket의 경우 ws 프로토콜을 통해 웹소켓 포트에 접속해 있는 모든 클라이언트에게 이벤트 방식으로 응답할 수 있다. (broadcast)
채팅의 경우 websocket을 사용하여 구현하는 것이 일반적이다.
나는 서버와 같은 Stompjs
를 사용하여 socket을 연결하려고 한다.
아무래도 같은 라이브러리, 버전을 사용하는 것이 호환성이 좋기 때문이다.
참고로 나는 next.js 14 버전과 typescript, yarn berry를 사용하고 있다.
먼저 라이브러리를 설치해준다.
$ yarn add @stomp/stompjs
라이브러리 설치는
@stomp/stompjs
와@sockjs-client
둘 다 해주었는데, 실제 사용은@stomp/stompjs
만 했다.
stomp의 플로우는 connect
, subscribe
, publish
, disconnect
로 나뉜다.
websoket 적용 시 사용하는 용어
우선 플로우에 대한 함수를 먼저 보이고, 이후 합쳐진 코드를 올리겠다.
먼저 stompjs를 import 해주고, 서버와의 연결을 맺기 위한 코드를 작성해준다.
나는 메시지 전송 함수를 따로 빼놨기 때문에 useRef
로 stompjs를 저장해주었다.
import * as StompJs from '@stomp/stompjs';
const client = useRef<StompJs.Client>();
const connect = () => {
console.log('Connecting...');
client.current = new StompJs.Client({
brokerURL: 'ws://웹소켓 서버 BASE_URL/ws',
connectHeaders: {
Authorization: `Bearer ${tokenManager.getToken()}`,
},
reconnectDelay: 200,
onConnect: () => {
console.log('connected');
subscribeError();
subscribe();
},
onWebSocketError: (error) => {
console.log('Error with websocket', error);
},
onStompError: (frame) => {
console.dir(`Broker reported error: ${frame.headers.message}`);
console.dir(`Additional details: ${frame}`);
},
});
console.log('Activating...');
client.current.activate();
};
처음 stomjs 객체를 생성할 때 에러 발생 시 처리도 작성해줄 수 있다. (onWebSocketError
, onStompError
)
연결이 끝나면 subscribe하도록 해주었고, client.current.activate()
를 통해 활성화해준다.
onConnect
의 subscribeError()
는 아래에서 설명!
서버로부터 오는 메시지를 받기 위해 subscribe(구독) 해준다.
const subscribe = () => {
console.log('Subscribing...');
client.current?.subscribe(`/topic/agoras/${agoraId}/chats`, (received_message: StompJs.IFrame) => {
console.log(`> Received message: ${received_message.body}`);
pushMessage(received_message.body, 'received');
});
};
클라이언트에서 서버로 메시지 전송하기
나는 메시지 전송 시 publish하도록 했다.
const sendMessage = () => {
if (message.trim().length < 1) return;
if (client.current) {
client.current?.publish({
destination: `/app/agoras/${agoraId}/chats`,
body: JSON.stringify({
type: 'CHAT',
message,
}),
});
pushMessage('send');
console.log(`> Send message: ${message}`);
}
setMessage('');
};
일단 웹소켓 연결 및 메시지 전송/받기에 대한 함수는 이렇게인데, 나는 백엔드분이 에러 메시지를 추가 구독함으로 받을 수 있도록 따로 빼두셔서 그것도 함께 구독을 해야 한다.
const subscribeError = () => {
console.log('Subscribing Error...');
client.current?.subscribe('/user/queue/errors', (received_message: StompJs.IFrame) => {
console.log(`> Received message: ${received_message.body}`);
});
};
onConnect에서 나왔던 subscribeError 함수가 위의 코드다.
전체 코드는 다음과 같다.
import * as StompJs from '@stomp/stompjs';
import React, { useEffect, useRef } from 'react';
const client = useRef<StompJs.Client>();
const agoraId = usePathname().split('/').pop() as string;
const sendMessage = () => {
if (message.trim().length < 1) return;
if (client.current) {
client.current?.publish({
destination: `/app/agoras/${agoraId}/chats`,
body: JSON.stringify({
type: 'CHAT',
message,
}),
});
pushMessage('send');
console.log(`> Send message: ${message}`);
}
setMessage('');
};
// 최초 렌더링 시 실행
useEffect(() => {
const disconnect = () => {
client.current?.deactivate();
console.log('Disconnected');
};
const subscribe = () => {
console.log('Subscribing...');
client.current?.subscribe(`/topic/agoras/${agoraId}/chats`, (received_message: StompJs.IFrame) => {
console.log(`> Received message: ${received_message.body}`);
pushMessage(received_message.body, 'received');
});
};
const subscribeError = () => {
console.log('Subscribing Error...');
client.current?.subscribe('/user/queue/errors', (received_message: StompJs.IFrame) => {
console.log(`> Received message: ${received_message.body}`);
});
};
const connect = () => {
console.log('Connecting...');
client.current = new StompJs.Client({
brokerURL: 'ws://웹소켓 서버 BASE_URL/ws',
connectHeaders: {
Authorization: `Bearer ${tokenManager.getToken()}`,
},
reconnectDelay: 200,
onConnect: () => {
console.log('connected');
subscribeError();
subscribe();
},
onWebSocketError: (error) => {
console.log('Error with websocket', error);
},
onStompError: (frame) => {
console.dir(`Broker reported error: ${frame.headers.message}`);
console.dir(`Additional details: ${frame}`);
},
});
console.log('Activating...');
client.current.activate();
};
if (tokenManager.getToken() !== undefined) {
connect();
} else {
console.error('Token is not found');
// 토큰 발급
// POST /api/v1/temp-user
}
return () => disconnect();
}, [agoraId]);
한 번에 다 할려고 하면 어디에서 문제인지 확인하기 어렵고, 에러가 발생하면 더 하기 싫어진다.
이전에 했던 프로젝트에서 한 팀원이 한 단계씩 확인하지 않고 전체 복사하기로 웹소켓을 구현하려고 해서 어디가 문제인지 파악하고 해결하는데 시간이 너무 오래 걸렸었다.
그러니, 연결, 구독, 메시지 발행 이 과정을 하나 하나 잘 되는지 확인하는게 매우 중요하다!!
이 모든 과정이 잘 되고 있는지 확인하기 위해선 console로의 출력으로는 한계가 있다.
콘솔 출력은 잘 되지만 연결이 안되고 있을 수도 있기 때문이다.
확인하는 방법은 생각보다 간단하다.
개발자 도구의 네트워크 탭으로 들어가서, 이름이 ws인 요청 클릭 후 Message 탭으로 들어가면 아래 사진처럼 연결, 구독, 전송이 잘 되고 있는지 확인할 수 있다.
사진 오른쪽이 잘린건, Authorization
옆에 토큰이 보여서 혹시 몰라 잘랐다.
위 사진처럼 CONNECT, CONNECTED, SUBSCRIBE 등이 뜨는건 잘 연결 되었고, 구독도 하고 있다는 뜻이다.
여기서 publish를 하면 SEND도 뜰 것이다.
이상, 끝!