socket.io
새로고침을 해야 최신 데이터가 갱신되는 웹 서비스 보단 실시간으로 갱신되는 모습이 보이는 웹 서비스가 보기에 재미있고, 편하다. 웹에서 실시간 통신을 어떻게 구현할 수 있을까?
1. 실시간 통신을 위한 방법
HTTP 통신은 기본적으로 클라이언트의 하나의 요청에 서버가 한 번 응답하면 연결이 종료되는 비연결/단방향 특성이 있다. 이러한 환경 위에서 실시간 통신을 하기 위해 Polling, Streaming, Websocket이라는 방법이 존재한다. 먼저 Polling과 Streaming을 알아보자.
1) Polling
정기적으로 HTTP 요청을 전송하고 응답을 받는 방식이다. 따라서 데이터 변동이 없을 때도 불필요한 요청이 발생한다.
2) Streaming
서버와 클라이언트간 연결을 해제하지 않은 상태로 유지하는 방식이다. 하지만 동시에 하나의 포트를 이용한 데이터 전송이 불가능하다. 즉, 클라이언트가 서버로 데이터를 보내는 동안에는 서버가 클라이언트로 데이터를 보낼 수 없다는 것이다.(동시에 주고받기 위해서는 추가적으로 포트를 열어야 할 것이다.)
3-1) HTTP 2.0 서버푸시
서버가 브라우저 캐시에 리소스를 사전 전송할 때 쓰인다. 즉, 클라이언트 요청에 대해 원래 응답데이터 + 추가적인 리소스를 푸시하는 것이다.
3-2) SSE(Server Send Events)
서버가 클라이언트에 비동기적으로 데이터를 푸시할 수 있는 방법이다.
HTTP 2.0 서버푸시와 SSE는 클라이언트에서 서버로 요청을 보내야 서버의 데이터를 받을 수 있는 문제점을 해결할 수 있으나, 결국 단방향이므로 주식차트 등 서버 -> 클라이언트로 일방적인 실시간 데이터를 보낼 때는 적합하지만, 채팅과 같잉 양방향 통신이 필요한 경우 WebSocket 방식이 더 적합하다.
4) WebSocket
WebSocket은 서버와 클라이언트간의 연결을 유지하면서 전이중 통신을 지원한다. 즉, 서버와 클라이언트가 동시에 데이터를 주고받을 수 있는 것이다. HTTP를 통해 서버에 연결한 후 ws로 전환하여 통신이 진행되며, 포트 80(HTTP), 443(HTTPS) 위에서 동작한다.
2. socket.io
socket.io는 Websocket을 보다 쉽게 사용할 수 있게 지원하는 라이브러리이다. 더불어 WebSocket은 오래된 브라우저는 지원하지 않는다는 단점이 있는데, socket.io는 Websocket 뿐만 아니라 Polling, Streaming 등의 방법도 지원하므로, WebSocket이 지원되지 않는 브라우저는 다른 방식으로 실시간 통신이 가능하도록 구현할 수 있다.
3. socket.io의 구조
socket.io는 위와 같이 namespace와 room이라는 단위를 갖는다. 하나의 socket.io 서버는 여러 개의 namespace를 가질 수 있고, 각 namespace는 여러 개의 룸을 가질 수 있다.
예를 들어 채팅을 재공하는 경매 서비스가 있다고 하자. 이 서비스는 물품 페이지에서 실시간으로 최신가격이 갱신되며, 경매가 종료되면 해당 물품의 판매자와 최종 입찰자가 채팅을 할 수 있다. 이 경우 크게 실시간 입찰정보가 오가는 구역(/auction)과 채팅이 오가는 구역(/chat)으로 나뉜다. 즉, 두 개의 namespace를 가질 수 있는 것이다. 또한 각 물품 별로 판매자와 입찰자가 입장가능한 채팅방을 생성해야한다. 즉, /chat 네임스페이스 내에 각 아이템별 채팅방을 만들면 되는 것이다.
4. 통신 시나리오와 코드
1) 실시간 입찰
어떤 사람이 물품에 새로운 가격을 입찰한 경우 데이터 흐름은 위와 같이 구성할 수 있다. 입찰가를 서버에 보내고 성공하면 /auction 네임스페이스에 접속된 모든 클라이언트에게 해당 정보를 보내는 것이다. 코드는 다음과 같다.
const bucket = { 1: 1000, 2: 2000 }; //최고가 데이터 저장
const auction = io.of('/auction');
auction.on('connection', (socket) => {
socket.on('bid', ({ userId, itemId, price }) => {
if (bucket[itemId] && bucket[itemId] >= price) { //최고가 조회
socket.emit('refuse', 'fail to bid'); //입찰실패이벤트 전달
} else {
updateItemPrice(userId, itemId, price) //DB갱신
.then(() => {
insertJoinBidData(userId, itemId); //DB저장
})
.then(() => {
bucket[itemId] = price;
auction.emit('bid', { userId, itemId, price }); //입찰성공이벤트 전달
});
}
});
});
먼저 서버측 코드이다. of 키워드로 /auction 이라는 네임스페이스를 지정한 후 on한 후 연결된 클라이언트 소켓에 대해 bid라는 이벤트를 등록한다. 이 이벤트를 받으면 userId, itemId, price를 받아 최고가보다 높은 가격인지 조회한 후 맞으면 저장 후 /auction 네임스페이스에 접속한 클라이언트 모두에게 bid라는 이벤트로 해당 데이터를 전달한다.
import io from 'socket.io-client';
const auctionSocket = io(`${process.env.REACT_APP_SERVER_ADDRESS}/auction`, { transports: ['websocket'] });
auctionSocket.emit('bid', { //송신
userId: id,
itemId: item.id,
price: price
});
auctionSocket.on('bid', ({itemId, price, userId}: bidData) => { //수신
const newItems = items.map((item: Item) => {
if(item.id === itemId) {
item.winnerId = userId;
item.price = price;
}
return item;
});
});
클라이언트측 코드이다. 우선 npm install socket.io-client 를 한 후 auction 소켓 인스턴스를 위와 같이 선언한다. emit은 서버로 송신, on은 서버로 부터 데이터를 수신할 때 사용하는 키워드이다. 둘 다 bid라는 이벤트와 함께 데이터를 송수신하고 있다.
2) 채팅
채팅할 때 데이터 흐름은 위와 같다. 입장하면 이전채팅내역을 받아 렌더링하고, 하나의 채팅을 입력하면 입력한 측은 해당 메시지를 바로 렌더링하고, 서버로 송신한다. 서버에서 메시지를 받으면 같은 room에 있는 다른 클라이언트에게 메시지를 보낸다.
const chat = io.of('/chat');
chat.on('connection', (socket) => {
socket.on('join', ({ userId, itemId: roomId }) => {
getChatMessages(roomId) //이전 채팅내역 DB 조회
.then((data) => {
socket.join(roomId); //room에 접속시킨다.
socket.emit('messages', data);
});
});
socket.on('message', ({ userId, itemId: room, text }) => {
createChatMessage(userId, room, text) //DB에 채팅내역 저장
.then((data) => {
socket.broadcast.to(room).emit('message', { //같은 room의 나머지에게 전달
userId: data.UserId,
createdAt: data.createdAt,
itemId: data.itemId,
text: data.message
});
});
});
서버측 코드이다. /chat 이라는 네임스페이스 선언 후 /chat에 접속한 소켓에 대해 connection과 message라는 이벤트를 수신한다. join 이벤트에서는 클라이언트를 room에 접속시키고 이전 채팅내역을 해당 클라이언트 소켓에게만 돌려준다. message 이벤트에서는 DB에 채팅내역을 저장 후 같은 room에 있는 해당 소켓을 제외한 모든 클라이언트에게 받은 메시지를 전송한다.
import io from 'socket.io-client';
const chatSocket = io(`${process.env.REACT_APP_SERVER_ADDRESS}/chat`, { transports: ['websocket'] });
chatSocket.emit('join', {
userId: id,
itemId: itemId
});
chatSocket.on('messages', (data) => {
setChats(getFormatedMessages(data)); //이전까지의 메시지내용을 렌더링한다.
});
chatSocket.on('message', (data) => {
setChats([...chats, getFormatedMessage(data)]); //하나의 메시지를 받아 렌더링한다.
});
클라이언트측 코드이다. itemId가 room을 구분하는 식별자가 되므로, join 이벤트를 보낼 때 itemId를 함께 전송한다. 그 후 messages 혹은 message 이벤트를 받아 메시지를 새롭게 렌더링한다.
4. 주의점
1) socket은 하나의 인스턴스로 충분하다.
하나의 웹서비스에 여러 개의 소켓을 가질 이유는 없다. 따라서 소켓인스턴스를 전역에 단 하나만 존재하도록 작성하여 불필요한 리소스 낭비를 줄여야 한다. React의 경우 Context나 Redux 미들웨어를 사용하기도 한다.
2) 페이지를 벗어날 때 이벤트 제거
페이지마다 받아야하는 데이터가 다를 수 있다. 예를 들어 채팅 페이지에서는 입찰가격 데이터를 받을 필요가 없다. 입찰가격을 표시하는 UI가 없기 때문이다. 따라서 페이지를 벗어날 때 소켓이벤트 수신을 중지시켜야 한다.
useEffect(() => {
auctionSocket.on('bid', ({itemId, price, userId}: bidData) => {
const newItems = items.map((item: Item) => {
if(item.id === itemId) {
item.winnerId = userId;
item.price = price;
}
return item;
});
dispatch(ItemHandler({items: newItems}));
});
return () => {
auctionSocket.off('bid'); //이벤트 제거
};
}, [items]);
이벤트 제거는 off 라는 키워드로 가능하다.
3) CLB
AWS를 사용하는 경우 CLB는 웹소켓 지원이 안된다. 해결방법은 이전포스팅을 참고하자.
ITFIND 주간 기술 동향 - 웹기반 실시간 양방향 통신기술 및 표준화 동향