채팅 기능 구현을 위한 web socket과 sse 방식 POC
HTTP 프로토콜의 주요 특징은 비연결성 이다.
위 특징으로 인해 Server가 전송하고 싶어도 해당 Client와 지속적으로 연결이 되어있지 않기 때문에 보낼 수 없는 상황이 발생하게 된다.
이를 해결하는 방식으로 Polling, Long Polling, Web Socket, SSE 총 네 가지가 있다.
Web socket
RFC 6455 명세서에 정의된 프로토콜.
서버와 브라우저 간 연결을 유지한 상태로 데이터 교환이 가능하다. 전송은 커넥션 중단과 추가 HTTP 요청 없이 양방향으로 이루어진다.
Web socket을 구현하는 방법
- Web socket → 기본으로 HTML5에서 제공하는 기능
- socket.io → 웹 소켓 서버를 쉽게 구현할 수 있게 도와주는 모듈이며 서버는 node.js
- sock.js → 웹 소켓 서버를 쉽게 구현할 수 있게 도와주는 모듈이며 서버는 spring framework
간단한 Web socket 구현 예제
- 자바스크립트에서 Web socket 연결
- 위 socket 변수에서 사용할 수 있는 이벤트
- open – 커넥션이 제대로 만들어졌을 때 발생함. 이 때 **send** 함수를 이용해 서버로 데이터를 전송할 수 있다.
- message – 데이터를 수신하였을 때 발생함
- error – 에러가 생겼을 때 발생함
- close – 커넥션이 종료되었을 때 발생함
이렇게 서버 쪽 코드가 동작하면서 open → message → close 순의 이벤트를 볼 수 있다.let socket = new WebSocket('ws://localhost:8002'); socket.onopen = function(e) { alert("[open] 커넥션이 만들어졌습니다."); alert("데이터를 서버에 전송해봅시다."); socket.send("My name is Bora"); // -> 여기서 send 요청을 보냄. }; socket.onmessage = function(event) { alert(`[message] 서버로부터 전송받은 데이터: ${event.data}`); }; socket.onclose = function(event) { if (event.wasClean) { alert(`[close] 커넥션이 정상적으로 종료되었습니다(code=${event.code} reason=${event.reason})`); } else { // 예시: 프로세스가 죽거나 네트워크에 장애가 있는 경우 // event.code가 1006이 됩니다. alert('[close] 커넥션이 죽었습니다.'); } }; socket.onerror = function(error) { alert(`[error]`); };
- 위 socket 변수에서 사용할 수 있는 이벤트
- const baseUrl = 'ws://localhost:8002'; const socket = new WebSocket(`${baseUrl}/chat/rooms/${roomIndex}`);
sock.js 를 이용해 채팅 구현한 예제 코드 뜯어보기
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();
}
changeInput(value) {
this.messageToSend = value;
this.publish();
}
sendMessage({ status }) {
if (status === "CONNECTED") {
this.messageToSend = `사용자 ${this.userIndex} 님이 채팅방 ${this.currentRoomIndex}에 입장했습니다.`;
}
if (status === "DISCONNECTED") {
this.messageToSend = `사용자 ${this.userIndex} 님이 채팅방 ${this.currentRoomIndex}에서 나갔습니다.`;
}
if (status === "SEND") {
this.messageToSend = `사용자 ${this.userIndex}: ${this.messageToSend}`;
}
messageService.sendMessage({
socket: this.socket,
messageToSend: this.messageToSend,
});
this.messageToSend = "";
this.publish();
}
위와 비슷한 방식으로 connect, disconnect, changeInput, sendMessage 함수를 만들어 구현
참고 자료
WebSocket을 이용한 실시간 채팅 구현하기 2: 클라이언트
SSE
SSE는 웹소켓과 달리, Client가 Server로부터 데이터만 받을 수 있는 방식.
SSE는 별도의 프로토콜을 사용하지 않고 HTTP 프로토콜만으로 사용할 수 있기 때문에 구현이 용이.
접속에 문제가 있으면 자동으로 재연결 시도하지만, 클라이언트가 close해도 서버에서 감지하기 어렵다.
IE를 제외한 모든 브라우저에서 지원하고 있다.
SSE 통신 과정
1. [Client] SSE Subscribe 요청
클라이언트측에서 우선 서버의 이벤트 구독을 위한 요청 전송 → EventSourse
이벤트의 미디어 타입은 text/event-stream이 표준
2. [Server] Subscription에 대한 응답
Response의 미디어 타입은 text/event-stream .
이때 Transfer-Encoding 헤더의 값을 chunked로 설정 (서버는 동적으로 생성된 컨텐츠를 스트리밍 하기 때문에 본문의 크기를 미리 알 수 없기 때문)
3. [Server] 이벤트 전달
클라이언트에서 subscribe를 하면, 서버는 해당 클라이언트에게 비동기적으로 데이터를 전송 가능(데이터는 utf-8로 인코딩된 텍스트 데이터만 가능)
서로 다른 이벤트는 \n\n 로 구분되며, 각각의 이벤트는 한 개 이상의 name:value로 구성
클라이언트에서 서버를 구독하기 위해 활용할 수 있는 인터페이스 EventSourse
- EventSourse 객체
- 서버에 대해서 영구적인 연결을 open 한다.
- error , message , open 이벤트를 가짐.
- error - 연결이 실패하면 발생, 연결 종료 시점에도 발생
- message - 데이터를 수신하면 발생
- open - 연결이 열렸을 때 발생
예시 데모 영상
- 1:1 채팅
- n:n 채팅
예시 클라이언트 코드
"use client";
import React, { useState, useEffect } from "react";
import axios from "axios";
const Chatting = () => {
const [user, setUser] = useState("");
const [text, setText] = useState("");
const [messages, setMessages] = useState([]);
// 클라이언트에서 서버로 데이터를 보내주는 함수들
const userHandler = (e) => {
setUser(e.target.value);
};
const textHandler = (e) => {
setText(e.target.value);
};
const sendTextHandler = () => {
const message = {
user: user,
text: text,
};
axios.post("<http://localhost:5500/chat/send>", message);
};
// 서버에서 클라이언트로 데이터를 받아오는 코드들
useEffect(() => {
const eventSource = new EventSource("<http://localhost:5500/chat>"); // EventSourse 객체를 활용해 서버를 구독
eventSource.onmessage = (event) => {
setMessages(JSON.parse(event.data));
};
return () => {
eventSource.close();
};
}, []);
return (
<div>
유저
<input onChange={userHandler}></input>
메세지
<input onChange={textHandler}></input>
<button onClick={sendTextHandler}>전송</button>
<br />
채팅
<div>
{messages.map((message) => {
return (
<div>
<span>{message.user} : </span>
<span>{message.text}</span>
</div>
);
})}
</div>
</div>
);
};
export default Chatting;
예시 서버 코드
const express = require("express");
const app = express();
const bodyParser = require("body-parser");
const cors = require("cors");
app.use(cors());
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
const messages = []; // messages 를 담는 전체 배열
app.post("/chat/send", (req, res) => {
messages.push(req.body); // messages 라는 전체 배열에 메시지들을 push
return res.send("ok");
});
app.get("/chat", (req, res) => {
res.writeHead(200, {
"Content-Type": "text/event-stream", // SSE 구현을 위한 Header 작성
"Cache-Control": "no-cache",
Connection: "keep-alive",
});
// Simulate a real-time data source
setInterval(() => {
res.write(`data: ${JSON.stringify(messages)}\\n\\n`);
}, 2000); // 2초에 한번씩 response 보내주기
});
app.listen(5500, () => {
console.log("SSE server listening on port 5500");
});
참고 자료
Building A Chat Application Using SvelteJS and SSE
Local Server와 연결해서 채팅 서비스 구현
서버에서 전달 주신 SSE 방식 채팅 구현 방법
- 하나의 SSE 연결(eventSource 구독)로 사용자의 모든 채팅방 메세지를 구독
- graphQL을 통해서 채팅방 데이터 받아오기, 채팅 메시지 전송 등의 데이터를 클라 → 서버로 전달
- 서버에서 변화가 있을 때 마다 eventSource 이벤트를 클라이언트로 전송
- 이벤트 종류
- 처음 SSE 연결을 맺을 때: sseType: "CONNECTED"
- 채팅 이벤트가 생겼을 때: sseType: "CHAT_MESSAGE_PUBLISHED"
- 채팅 메시지 생성: eventType: "CHAT"
- 채팅방 입장: eventType: "JOIN"
- 채팅방 퇴장: eventType: "LEAVE"
- 이벤트 종류
구현하면서 고민되었던 부분
- eventSource를 각 컴포넌트마다 따로 구독해줘야 하는 것이 코드상으로도 깔끔하지 않고 불편하게 느껴졌음.
- 채팅방리스트 컴포넌트와 채팅방 컴포넌트가 있을 때
- 채팅방리스트 컴포넌트에서 eventSource를 구독하고 채팅방 컴포넌트로 넘어갈 경우 eventSource로부터 이벤트를 받아올 수 없음. 다른 컴포넌트라서..
demo
https://youtube.com/shorts/X9R3yWYsYnc
Graphql 쿼리문
// 채팅방 리스트 받아오기
const GET_MY_CHAT_CHANNELS = gql`
query MyChatChannels {
myChatChannels {
id
ctype
unReadCnt
name
createdAt
}
}
`;
// 채팅 이벤트 데이터 받아오기
const GET_CHAT_CHANNEL_EVENTS = gql`
query ChatChannelEvents($channelId: ID!) {
chatChannelEvents(channelId: $channelId) {
contents {
id
eventType
createdAt
... on ChatChannelChatEvent {
id
eventType
createdAt
message
sender {
id
memberName
profileImage
}
}
}
}
}
`;
// 채팅 메시지 보내기
const SEND_MESSAGE = gql`
mutation SendChatMessage($channelId: ID!, $message: String!) {
sendChatMessage(channelId: $channelId, message: $message) {
id
eventType
createdAt
}
}
`;
채팅방 리스트 컴포넌트
const ChatChannelList = () => {
const [isNewMessage, setIsNewMessage] = useState(null);
const {
loading,
error,
data: myChatChannels,
} = useQuery(GET_MY_CHAT_CHANNELS);
useEffect(() => {
// eventSource 구독
const eventSource = new EventSource(
"<http://localhost:8080/v1/chats/subscriptions/channels?memberId=1>"
);
// 서버에서 이벤트 발생 시 onmessage 메소드로 전달됨
eventSource.onmessage = (event) => {
console.log(JSON.parse(event.data));
if (JSON.parse(event.data).sseType === "CHAT_MESSAGE_PUBLISHED") {
setIsNewMessage(JSON.parse(event.data));
}
};
return () => {
// eventSource 구독 끊기
eventSource.close();
};
}, []);
return (
<>
<h1>채팅방 목록</h1>
<div>
{myChatChannels?.myChatChannels.map((channel) => {
return (
<Link href={`/chat-channel/${channel.id}`}>
<ChatChannel key={channel.id}>
{isNewMessage?.ssePayload.chatChannelId === channel.id ? (
<ChatChannelAlarm />
) : null}
{channel.name}
</ChatChannel>
</Link>
);
})}
</div>
</>
);
};
export default ChatChannelList;
채팅방 컴포넌트
const ChatChannel = () => {
const params = useParams();
const [channelId, setChannelId] = useState(params.channelId);
// state
const [message, setMessage] = useState("");
// UseQuery
const {
loading,
error,
data: chatChannelEvents,
refetch,
} = useQuery(GET_CHAT_CHANNEL_EVENTS, {
variables: { channelId },
});
// UseMutation
const [sendMessage, { data: sendChatMessage }] = useMutation(SEND_MESSAGE, {
variables: {
channelId,
message: message,
},
});
// function
const messageHandler = (e) => {
setMessage(e.target.value);
};
const sendMessageHandler = () => {
sendMessage();
};
const onKeyDownHandler = (e) => {
if (e.key === "Enter") {
console.log("enter");
sendMessage();
}
};
useEffect(() => {
// eventSource 구독
const eventSource = new EventSource(
"<http://localhost:8080/v1/chats/subscriptions/channels?memberId=1>"
);
// 서버에서 이벤트 발생 시 onmessage 메소드로 전달됨
eventSource.onmessage = (event) => {
console.log(JSON.parse(event.data));
if (JSON.parse(event.data).sseType === "CHAT_MESSAGE_PUBLISHED") {
refetch();
}
};
return () => {
// eventSource 구독 끊기
eventSource.close();
};
}, []);
return (
<>
<h1>채팅방</h1>
{chatChannelEvents?.chatChannelEvents.contents
.slice()
.reverse()
.map((chatMessage) => {
return (
<Message key={chatMessage.id}>
{chatMessage.sender.memberName}: {chatMessage.message}
</Message>
);
})}
<MessageInputWrapper>
<MessageInput
placeholder="메시지를 입력하세요."
onChange={messageHandler}
onKeyDown={onKeyDownHandler}
></MessageInput>
<SendMessageButton onClick={sendMessageHandler}>
보내기
</SendMessageButton>
</MessageInputWrapper>
</>
);
};
export default ChatChannel;
위 코드는 일부 코드만 발췌한 것으로 전체 코드는 아래 링크 된 github repository 에서 확인할 수 있습니다.