노션 방명록 위젯을 만들면서 느낀 건, 방명록을 남기면 일단 본인에게만 반영되는 형태가 아니라, 결국에는 서로 실시간으로 방명록이 반영되는 형태가 유저들에게 더 좋은 경험을 줄 수 있을 것 같다는 점이었습니다. 따라서, Socket.io
를 도입해보기 전에 찍먹을 해보려고 해요.
가보자고.
여담으로, 이번 기회에 Velog 아이디를 만들어서 포스팅을 해보기로 했어요. 원래는 네이버 블로그를 쓰고 있었는데 개발 블로깅을 하기엔 조금 불편한 부분들이 많아서 한번 이 부분도 찍먹해보려고 합니다.Client쪽에서는 React + Vite + Tailwind
를 쓰고 Server쪽에서는 Node.js + Express
를 이용해 서버를 구현할 예정입니다. 프로젝트 이름은 가볍게 chat-example
로 정하고 가보겠습니다.
먼저, Vite + React
조합으로 생성해줍니다.
yarn create vite chat-example-client
생성이 완료되면 main.jsx
, App.jsx
만 놔두고 불필요한 파일을 다 삭제합니다.
그 다음 socket.io-client
패키지를 설치합니다. socket.io
를 클라이언트 쪽에서 쓰려면 socket.io
가 아닌 socket.io-client
를 설치해주어야 합니다.
yarn add socket.io-client
사실 찍먹하는데 그냥 할 수도 있겠지만, 안 예쁜 건 참을 수 없습니다. 가볍게 채팅 히스토리와 닉네임, 입력상자 정도가 있으면 될 것 같아요. 빠른 디자인을 위해 tailwind
를 써보겠습니다.
yarn add tailwindwcss postcss autoprefixer
npx tailwindcss init -p
이제 Client 환경은 완료되었으니, Server 환경을 설정해보겠습니다.
Server 폴더를 만들고 Server.js
파일을 만든 뒤, 패키지 초기 설정을 해줍니다.
mkdir Server
cd Server
touch Server.js
yarn init
서버 구동에 필요한 패키지들을 설치합니다. 필요한 패키지들은 Express
, Cors
, Http
, Socket.io
입니다.
yarn add express cors http socket.io
서버 코드가 변경되거나 서버가 죽으면 자동으로 실행시켜주기 위해 nodemon
으로 실행합니다. pm2
도 상관없어요.
nodemon server.js
서버쪽 환경도 구성되었으니, 본격적으로 코딩에 들어가볼까요?
Client의 채팅 디자인
대충 이런 식으로 가볍게 만들어보았습니다. 위로는 주고받은 메시지 히스토리가 보여지구요, 아래로는 닉네임과 메시지를 적을 수 있는 칸과 메시지를 보내는 버튼이 존재합니다.
저는 CSS 짜는 건 따로 파일로 뺀 다음 컴포넌트화를 시키는 편입니다. Tailwind
의 단점이 가독성이 좋지 않다는 건데, 컴포넌트화를 시켜놓으면 코드 읽기가 편해지거든요. 그러므로 styled.jsx
파일을 만들어 관리해주겠습니다.
// styled.jsx
export const StyledApp = {
Container: ({ children }) => (
<div className=" flex flex-col w-screen h-screen p-6 gap-6">{children}</div>
),
Form: ({ children, ...props }) => (
<form className=" border rounded-sm flex justify-between" {...props}>
{children}
</form>
),
HistoryWrapper: ({ children }) => (
<ul className=" list-none border rounded-sm p-3 flex-1 flex gap-3 flex-col overflow-y-scroll">
{children}
</ul>
),
ChatItem: ({ children }) => <li className="">{children}</li>,
ChatName: ({ children }) => <strong>{children}</strong>,
ChatMessage: ({ children }) => <p>{children}</p>,
NameInput: ({ children, ...props }) => (
<input className=" py-1 px-3 w-1/4 font-bold outline-none appearance-none" {...props} />
),
MessageInput: (props) => (
<input className=" py-1 px-3 w-full outline-none appearance-none" {...props} />
),
SubmitButton: ({ children, props }) => (
<button className=" min-w-fit py-1 px-3 font-bold" type="submit">
{children}
</button>
),
};
역시 어지럽네요. 이어서 App.jsx
구조입니다.
// App.jsx
import React from 'react';
import { StyledApp } from './styled';
const {
Container,
Form,
HistoryWrapper,
ChatItem,
ChatName,
ChatMessage,
NameInput,
MessageInput,
SubmitButton,
} = StyledApp;
const App = () => {
return (
<Container>
<HistoryWrapper>
<ChatItem>
<ChatName>김주현</ChatName>
<ChatMessage>메시지입니다123</ChatMessage>
</ChatItem>
<ChatItem>
<ChatName>김주현</ChatName>
<ChatMessage>메시지입니다123</ChatMessage>
</ChatItem>
<ChatItem>
<ChatName>김주현</ChatName>
<ChatMessage>메시지입니다123</ChatMessage>
</ChatItem>
</HistoryWrapper>
<Form>
<NameInput placeholder="닉네임" />
<MessageInput placeholder="메시지를 입력하세요..." />
<SubmitButton>보내기</SubmitButton>
</Form>
</Container>
);
};
export default App;
깔끔합니다. 좋습니다. 여기에 기본적인 이벤트 뼈대를 만들어봅시다.
이름 입력 상자와 메시지 입력 상자에 상태를 달아줍니다.
const [userMessage, setUserMessage] = useState({
name: '김주현',
message: '',
});
const handleChange = (e) => {
setUserMessage((prevState) => ({
...prevState,
[e.target.id]: e.target.value,
}));
};
<NameInput
id="name"
value={userMessage.name}
onChange={handleChange}
placeholder="닉네임"
/>
<MessageInput
id="message"
value={userMessage.message}
onChange={handleChange}
placeholder="메시지를 입력하세요..."
/>
닉네임과 메시지 내용을 하나의 상태에 저장하기 위해서 객체로 상태를 만들었습니다. 이렇게 객체로 상태를 저장할 때는 각각의 Input
에 id
를 달아서 지정해주면 편하게 핸들링할 수 있습니다.
Form
안에서 Submit Event가 발생하면 지정해둔 콜백함수로 들어가게 핸들링합니다. 아직은 통신 부분을 만들지 않았기에 TODO로 만들어놓고 비워둡니다.
const handleSubmit = (e) => {
e.preventDefault();
// TODO: Server로 메시지 보내기
setUserMessage((prevUser) => ({
...prevUser,
message: '',
}));
};
<Form onSubmit={handleSubmit}>
...
</Form>
좋습니다. 이제 본격적으로 실시간 채팅을 구현해보아요.
먼저 알아두어야 할 것은, 저희가 사용하려고 하는 socket.io
라이브러리는 WebSocket Protocol과 Http long-polling를 사용한다는 점입니다. 기본적으로 서버와 클라이언트가 통신을 할 때는 WebSocket Protocol을 기반으로 통신하게 되지만, 만약 해당 프로토콜로 통신이 불가한 상황이라면 폴백(fallback)으로 Http long-polling 방식을 사용하여 통신하게 됩니다.
Socket.IO is a library that enables low-latency, bidirectional and event-based communication between a client and a server. It is built on top of the WebSocket protocol and provides additional guarantees like fallback to HTTP long-polling or automatic reconnection.
- 'What Socket.IO is', https://socket.io/docs/v4/
Socket.IO는 클라이언트와 서버 간의 저지연, 양방향, 이벤트 기반 통신을 가능하게 하는 라이브러리입니다. 웹소켓 프로토콜을 기반으로 구축되었으며 HTTP 롱폴링 또는 자동 재연결에 대한 폴백 등의 추가 보장 기능을 제공합니다.
WebSocket Protocol과 http long-polling. 즉, socket.io
는 어쨌거나 http Layer 위에서 동작하는 놈입니다. 그 말인즉슨, 서버를 열때 http 서버가 필요하다는 말이 되겠습니다. http 서버를 열기 위해서는 어떻게 해야 할까요?
인터넷을 뒤적거리다 보면 주로 쓰이는 방법은 다음과 같습니다.
Express
+ http 모듈
+ socket.io
http 모듈
+ socket.io
다만,, 조금 더 생각을 해보아야 할 것은 기본적으로 Express
는 웹 애플리케이션 프레임워크입니다. Express
로 서버를 여는 것 자체가 웹서버, 즉 http 서버를 구성하는 것입니다. 그러므로 Express
에 따로 http 모듈을 달 필요가 없습니다(!) (정확히 말하자면, Express 내부적으로 이미 http 모듈을 사용합니다.) 따라서 Express를 사용하고자 한다면 굳이 http 모듈을 따로 불러와서 미들웨어로 등록할 필요가 없습니다.
만약 Express
없이 사용하고자 한다면 Nodejs
에서 기본적으로 제공하는 http 모듈
을 사용해 Http 서버를 만든 뒤, socket.io
에 넘겨주면 됩니다.
본문에서는 Express
+ socket.io
로 서버를 열고 io 객체(이후 Server Instance로 기술)를 생성하겠습니다. 먼저 Express
로 서버를 열어줍니다.
const app = require("express")();
const port = 3000;
const server = app.listen(port, () => {
console.log(`Server is running at ${port}`);
});
이제 Express
로 열어준 Server를 이용해 socket.io의 Server instance를 만들어봅시다.
const SocketIO = require('socket.io');
const socketIO = new SocketIO.Server(server, {
cors: {
origin: '*',
},
});
SocketIO라는 이름으로 socket.io
패키지를 불러오고, 패키지 안에 있는 Server를 이용해 socketIO라는 이름의 Server instance를 만듭니다. 만들 때, Cors 이슈를 피하기 위해 origin을 any로 설정합니다. (실제 배포땐 이러면 안 되겠지만, 어디까지나 테스트용이니깐!)
Server instance를 만들고 나면, 이벤트를 등록해주어 통신에 대한 로직을 처리해줍니다. 여기서부터가 실제로 통신 이벤트를 처리하는 부분이 되겠습니다. 이벤트 등록은 on
이라는 메서드로 가능합니다.
/** @param {SocketIO.Socket} socket */
const handleConnection = (socket) => {
console.log('User Entered: ', socket.id);
};
socketIO.on('connection', handleConnection);
JsDoc은 인텔리센스(Intellisense)를 위한 그냥 저의 코딩 스타일입니다(ㅋㅋ)
Server instance가 가질 수 있는 이벤트는 단 하나의 이벤트, connection
이라는 이벤트입니다. 연결이 되었을 때 발생하는 이벤트입니다. 해당 이벤트가 발생하면 첫 번째 인자로 Socket
을 반환합니다.
여기에서 Socket
이란, 어떤 클라이언트와 서버가 연결된 녀석을 말합니다. 서버는 여러 클라이언트들와 연결을 할 수 있으므로, 각각 클라이언트들과의 연결을 담당하는 녀석이 Socket
이라는 개념입니다. 서버 입장에서 Socket
은 여러 개가 생성될 수 있겠죠?
이렇게 새로 들어온 Socket
에도 이벤트를 등록해주어야 합니다. 이벤트를 등록해주지 않으면 해당 소켓은 아무것도 할 줄 모릅니다. 모르는데 어떻게 해요. Socket
의 이벤트 등록도 on
으로 진행하는데, 일반적으로 Socket
이벤트 등록은 Server instance의 connection
Event 안에서 진행합니다.
/** @param {SocketIO.Socket} socket */
const handleConnection = (socket) => {
console.log('User Entered: ', socket.id);
socket.on("MESSAGE", handleSocketMessage);
};
socketIO.on('connection', handleConnection);
Socket
에서 기본으로 제공하는 이벤트는 disconnect
와 disconnecting
인데, 재미있게도 이 외에 사용자가 원하는 이름의 이벤트, Custom Event
도 등록 가능합니다. 이 Custom Event
를 이용해서 메시지를 주고 받고, 방에 입장하고 나가고 어쩌구도 다 가능합니다.
저희는 클라이언트 쪽에서 MESSAGE
라는 이름의 이벤트를 보내면, 서버에서는 해당 이벤트와 연결되어있는 콜백함수, handleSocketMessage
로 돌려주게 할 겁니다. 그러면 저희는 해당 메시지를 받아볼 수 있어요.
이벤트를 발생하게 만드는 메서드는 emit
이라는 메서드입니다. 이 메서드는 조금 헷갈릴 수 있는데요, 그 이유는 어디에 emit
을 시키냐에 따라서 보내는 대상이 달라지기 때문입니다.
SocketIO.emit() // 서버에 연결된 모든 클라이언트들에게
socket.emit() // 해당 소켓의 클라이언트에게
socket.broadcast.emit() // 해당 소켓의 클라이언트를 제외한 모든 클라이언트들에게
Server Instance에 emit
을 하게 되면 연결된 모든 클라이언트들에게 이벤트를 보내게 되며, Socket
에 emit
을 하게 되면 해당 Socket
에만 메시지를 보내게 됩니다. 여기에서의 해당 Socket이란 말은 클라이언트와 서버가 1:1 연결되어있는 개별 소켓을 뜻합니다. 반대로, 해당 Socket
을 제외한 모든 클라이언트들에게 이벤트를 보내려면 boradcast
를 통해 emit
을 하면 됩니다.
그렇다면, 특정 클라이언트에서 받은 메시지를 다른 클라이언트들에게 뿌려주려면 다음과 같이 하면 될 것 같습니다.
/**
* @param {SocketIO.Socket} socket
* @param {*} data
*/
const handleSocketMessage = (socket, data) => {
console.log(`${socket.id}가 보냄: `, data);
socket.broadcast.emit('Message', data);
};
/** @param {SocketIO.Socket} socket */
const handleConnection = (socket) => {
console.log('User Entered: ', socket.id);
socket.on('Message', (data) => handleSocketMessage(socket, data));
};
콜백함수에 data를 넘겨줄 때, 해당 이벤트를 일으킨 Socket
을 함께 넘겨줍니다. 넘겨받은 콜백함수는 해당 Socket
을 이용해 id
를 얻고, 그대로 다시 broadcast
합니다.
이번엔 또다른 Custom Event
를 만들어보겠습니다. 클라이언트가 입장(USER_ENTER
)하고 나갈 때(USER_LEAVE
) 다른 클라이언트들에게 알려주는 이벤트입니다. 입장 알림은 connection
이 호출될 때 보내고, 퇴장 알림은 disconnect
때 보내면 됩니다.
/** @param {SocketIO.Socket} socket */
const handleSocketDisconnect = (socket) => {
console.log('User Leaved: ', socket.id);
socket.broadcast.emit('USER_LEAVE', socket.id);
};
/** @param {SocketIO.Socket} socket */
const handleConnection = (socket) => {
console.log('User Entered: ', socket.id);
socket.broadcast.emit('USER_ENTER', socket.id);
socket.on('disconnect', () => handleSocketDisconnect(socket));
};
이정도까지만 구현하면 서로 데이터를 주고 받을 기본적인 설계는 끝났습니다. 이제 Client로 돌아가서 실제로 메시지를 보내보고 받아보겠습니다.
클라이언트 단에서는 Socket.io
를 다루기 조금 더 가볍습니다. 여러 클라이언트들과 연결할 필요없이 그저 서버와 연결하는 하나의 소켓만 있으면 되거든요! 앞서 설치했던 socket.io-client
를 불러와서 연결할 서버 엔드포인트를 지정해주면 Socket
생성이 완료됩니다.
import { Socket, io } from 'socket.io-client';
/** @type {Socket} */
const socket = new io('http://localhost:3000/');
Socket
생성이 완료되었다면, 이벤트를 등록해주어야 합니다. 이벤트 등록은 컴포넌트 렌더 초기에 진행해보겠습니다.
const App = () => {
const uid = useRef(null);
useEffect(() => {
const onConnect = () => {
uid.current = socket.id;
};
const onMessage = (data) => {
console.log(`${data.id}가 보냈다잉: `, data);
};
const onUserEnter = (userId) => {
console.log(`${userId}가 들어왔다잉`);
};
const onUserLeave = (userId) => {
console.log(`${userId}가 떠났다잉ㅠ`);
};
socket.on('connect', onConnect);
socket.on('Message', onMessage);
socket.on('USER_ENTER', onUserEnter);
socket.on('USER_LEAVE', onUserLeave);
}, []);
}
Socket
에서 기본적으로 제공하는 이벤트 중에, 연결이 완료되면 발생하는 connect
라는 이벤트가 존재합니다. 소켓이 연결이 되어야 소켓ID가 생성됩니다. 이 소켓ID는 클라이언트를 구별할 키가 되므로 따로 저장해둡니다.
이제 만든 Custom Event
를 발생시켜서 메시지를 서버에 보내봅시다. 아까 handleSubmit
에서 TODO로 남겨놨던 부분을 채웁니다.
const handleSubmit = (e) => {
e.preventDefault();
const messageToSend = { id: uid.current, ...userMessage };
socket.emit('Message', messageToSend);
setUserMessage((prevUser) => ({
...prevUser,
message: '',
}));
};
메시지를 보낼 땐 현재 소켓의 아이디와 함께 보냅니다. 생각해보니 굳이 같이 보낼 필요는 없어보이기도 하네요(...) 어차피 서버에서 socket을 받으니.
다른 클라이언트 쪽에서 메시지를 보내면 받아서 표시를 해주어야겠죠? 더미 텍스트로 남겨뒀던 부분을 바꿔봅시다. 하는 김에 아무런 채팅이 없을 때 보여질 화면도 구성하겠습니다.
// styled.jsx
import { forwardRef } from 'react';
export const StyledApp = {
NoHistory: ({ children }) => (
<div className=" w-full h-full flex place-content-center place-items-center">{children}</div>
),
ChatItem: ({ children, me }) => (
<li className={`${me ? 'self-end text-right text-blue-800' : ''}`}>{children}</li>
),
};
// App.jsx
const App = () => {
const [messageHistory, setMessageHistory] = useState([]);
useEffect(() => {
const onMessage = (data) => {
console.log(`${data.id}가 보냈다잉: `, data);
setMessageHistory((prevHistory) => [...prevHistory, { id: uid.current, ...data }]);
};
}, []);
const handleSubmit = (e) => {
e.preventDefault();
const messageToSend = { id: uid.current, ...userMessage };
socket.emit('Message', messageToSend);
setMessageHistory((prevHistory) => [...prevHistory, messageToSend]);
setUserMessage((prevUser) => ({
...prevUser,
message: '',
}));
};
return (
<Container>
<HistoryWrapper ref={historyElement}>
{messageHistory.length === 0 ? (
<NoHistory>첫 메시지를 남겨보세요🥳</NoHistory>
) : (
messageHistory.map(({ id, name, message }, index) => (
<ChatItem key={index} me={id === socket.id}>
<ChatName>{name}</ChatName>
<ChatMessage>{message}</ChatMessage>
</ChatItem>
))
)}
</HistoryWrapper>
</Container>
);
}
메시지를 받거나 보내면 messageHistory
배열에 담아줍니다. 그러면 update가 진행되면서 컴포넌트가 re-rendering 됩니다. messageHistory
가 비어있으면 NoHistory
가 보여지고, 그게 아니라면 폼에 맞게 보여줍니다.
이제 결과를 한번 확인해볼까요?
Chat-example 동작
초기에 저희가 원했던 결과가 무난히 잘 나왔네요. 다행입니다.
포스팅을 하며 찾아본 Socket.io
의 기능들은 위에서 언급한 것들 보다 훨씬 다양했습니다. Room
이라는 기능이라든지, 특정 소켓에만 보내는 to
라든지. 언젠가 이 기능들을 활용하는 예제도 만들어보면 좋을 것 같다는 생각이 들었습니다.
이렇게 Socket.io
를 찍먹해보았는데, 노션 방명록 위젯에 적용하면 아주 괜찮을 라이브러리임을 확인해볼 수 있었습니다. 조만간 리팩토링 해보자고~🔥