Socket.io 찍먹해보기

김 주현·2023년 6월 30일
0

노션 방명록 위젯을 만들면서 느낀 건, 방명록을 남기면 일단 본인에게만 반영되는 형태가 아니라, 결국에는 서로 실시간으로 방명록이 반영되는 형태가 유저들에게 더 좋은 경험을 줄 수 있을 것 같다는 점이었습니다. 따라서, Socket.io를 도입해보기 전에 찍먹을 해보려고 해요.

Socket.io 도입 계기

가보자고.

여담으로, 이번 기회에 Velog 아이디를 만들어서 포스팅을 해보기로 했어요. 원래는 네이버 블로그를 쓰고 있었는데 개발 블로깅을 하기엔 조금 불편한 부분들이 많아서 한번 이 부분도 찍먹해보려고 합니다.

0. 개발 환경

Client쪽에서는 React + Vite + Tailwind를 쓰고 Server쪽에서는 Node.js + Express를 이용해 서버를 구현할 예정입니다. 프로젝트 이름은 가볍게 chat-example 로 정하고 가보겠습니다.

Client

(1) 프로젝트 생성

먼저, Vite + React 조합으로 생성해줍니다.

yarn create vite chat-example-client

생성이 완료되면 main.jsx, App.jsx만 놔두고 불필요한 파일을 다 삭제합니다.

(2) 패키지 설치

그 다음 socket.io-client 패키지를 설치합니다. socket.io를 클라이언트 쪽에서 쓰려면 socket.io가 아닌 socket.io-client를 설치해주어야 합니다.

yarn add socket.io-client

(3) Tailwind 설치

사실 찍먹하는데 그냥 할 수도 있겠지만, 안 예쁜 건 참을 수 없습니다. 가볍게 채팅 히스토리와 닉네임, 입력상자 정도가 있으면 될 것 같아요. 빠른 디자인을 위해 tailwind 를 써보겠습니다.

yarn add tailwindwcss postcss autoprefixer
npx tailwindcss init -p

이제 Client 환경은 완료되었으니, Server 환경을 설정해보겠습니다.

Server

(1) 프로젝트 생성

Server 폴더를 만들고 Server.js 파일을 만든 뒤, 패키지 초기 설정을 해줍니다.

mkdir Server
cd Server

touch Server.js
yarn init

(2) 패키지 설치

서버 구동에 필요한 패키지들을 설치합니다. 필요한 패키지들은 Express, Cors, Http, Socket.io 입니다.

yarn add express cors http socket.io

(3) nodemon

서버 코드가 변경되거나 서버가 죽으면 자동으로 실행시켜주기 위해 nodemon으로 실행합니다. pm2도 상관없어요.

nodemon server.js

서버쪽 환경도 구성되었으니, 본격적으로 코딩에 들어가볼까요?

1. 화면

디자인

채팅 디자인

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;

깔끔합니다. 좋습니다. 여기에 기본적인 이벤트 뼈대를 만들어봅시다.

(1) Input 입력받기

이름 입력 상자와 메시지 입력 상자에 상태를 달아줍니다.

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="메시지를 입력하세요..."
/>

닉네임과 메시지 내용을 하나의 상태에 저장하기 위해서 객체로 상태를 만들었습니다. 이렇게 객체로 상태를 저장할 때는 각각의 Inputid를 달아서 지정해주면 편하게 핸들링할 수 있습니다.

(2) 전송 핸들링

Form 안에서 Submit Event가 발생하면 지정해둔 콜백함수로 들어가게 핸들링합니다. 아직은 통신 부분을 만들지 않았기에 TODO로 만들어놓고 비워둡니다.

const handleSubmit = (e) => {
  e.preventDefault();
  
  // TODO: Server로 메시지 보내기

  setUserMessage((prevUser) => ({
    ...prevUser,
    message: '',
  }));
};


<Form onSubmit={handleSubmit}>
  ...
</Form>

좋습니다. 이제 본격적으로 실시간 채팅을 구현해보아요.

2. 실시간 통신

Server

Socket.io의 방식

먼저 알아두어야 할 것은, 저희가 사용하려고 하는 socket.io 라이브러리는 WebSocket ProtocolHttp 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 연결하기

본문에서는 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로 설정합니다. (실제 배포땐 이러면 안 되겠지만, 어디까지나 테스트용이니깐!)

Socket.io Server 이벤트 등록하기

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.io 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에서 기본으로 제공하는 이벤트는 disconnectdisconnecting인데, 재미있게도 이 외에 사용자가 원하는 이름의 이벤트, Custom Event도 등록 가능합니다. 이 Custom Event를 이용해서 메시지를 주고 받고, 방에 입장하고 나가고 어쩌구도 다 가능합니다.

저희는 클라이언트 쪽에서 MESSAGE라는 이름의 이벤트를 보내면, 서버에서는 해당 이벤트와 연결되어있는 콜백함수, handleSocketMessage로 돌려주게 할 겁니다. 그러면 저희는 해당 메시지를 받아볼 수 있어요.

Socket.io 이벤트 발생시키기

이벤트를 발생하게 만드는 메서드는 emit이라는 메서드입니다. 이 메서드는 조금 헷갈릴 수 있는데요, 그 이유는 어디에 emit을 시키냐에 따라서 보내는 대상이 달라지기 때문입니다.

SocketIO.emit() // 서버에 연결된 모든 클라이언트들에게
socket.emit() // 해당 소켓의 클라이언트에게
socket.broadcast.emit() // 해당 소켓의 클라이언트를 제외한 모든 클라이언트들에게

Server Instance에 emit을 하게 되면 연결된 모든 클라이언트들에게 이벤트를 보내게 되며, Socketemit을 하게 되면 해당 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로 돌아가서 실제로 메시지를 보내보고 받아보겠습니다.

Client

Socket.io Socket Instance 만들기

클라이언트 단에서는 Socket.io를 다루기 조금 더 가볍습니다. 여러 클라이언트들과 연결할 필요없이 그저 서버와 연결하는 하나의 소켓만 있으면 되거든요! 앞서 설치했던 socket.io-client를 불러와서 연결할 서버 엔드포인트를 지정해주면 Socket 생성이 완료됩니다.

import { Socket, io } from 'socket.io-client';

/** @type {Socket} */
const socket = new io('http://localhost:3000/');

Socket Event 등록

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가 보여지고, 그게 아니라면 폼에 맞게 보여줍니다.

이제 결과를 한번 확인해볼까요?

3. 결과

chat-example 동작

Chat-example 동작

초기에 저희가 원했던 결과가 무난히 잘 나왔네요. 다행입니다.


포스팅을 하며 찾아본 Socket.io의 기능들은 위에서 언급한 것들 보다 훨씬 다양했습니다. Room이라는 기능이라든지, 특정 소켓에만 보내는 to라든지. 언젠가 이 기능들을 활용하는 예제도 만들어보면 좋을 것 같다는 생각이 들었습니다.

이렇게 Socket.io를 찍먹해보았는데, 노션 방명록 위젯에 적용하면 아주 괜찮을 라이브러리임을 확인해볼 수 있었습니다. 조만간 리팩토링 해보자고~🔥


profile
FE개발자 가보자고🥳

0개의 댓글