개요
그동안 여러 프로젝트를 진행해오면서
SSE(Server Sent Event), CRUD API 연동은 진행해봤지만,
Web Socket을 다뤄본적은 없었다.
Web Socket 대신 SSE를 사용했던 이유는 알림 기능을 구현하기 위해서였다.
물론 Web Socket을 사용해도 되지만,
클라이언트는 단순히 알림조회를 하므로 양방향 소통이 필요하지 않았다.
그래서 단방향인 SSE를 다뤘었다.
그래서 Web Socket을 한번쯤은 다뤄보고 싶었는데,
언젠간 분명히 사용할 것 같아서 그냥 간단한 채팅을 구현하면서 트러블 슈팅을 해보려한다.
Server
평소에 프론트엔드를 주로 공부하고, 프로젝트도 모두 프론트엔드로 참여를 했다.
그리고 프론트엔드 직무로 프론트엔드를 준비중이기도 하다.
따라서 서버를 다룬 경험은 거의 없어서 고민했는데,
어차피 일단은 단순히 맛보기만 할것이기 때문에 node.js를 사용해서 서버를 구성하였다.
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });
console.log('웹소켓 서버가 8080 포트에서 실행되었습니다.');
wss.on('connection', (ws) => {
  console.log('클라이언트가 연결되었습니다.');
  ws.on('message', (message) => {
    wss.clients.forEach((client) => {
      if (client.readyState === WebSocket.OPEN) {
        client.send(message);
      }
    });
  });
  ws.on('close', () => {
    console.log('클라이언트 연결이 끊어졌습니다.');
  });
});
wss.on('error', (error) => {
  console.error('웹소켓 에러:', error);
});
1. 기본 설정
초기 설정은 아래 영상 참고해서 했습니다!
https://www.youtube.com/watch?v=ckaAhENDLLQ
먼저 client와 server 폴더를 나눠서 만들어주었다.

다음으로, 터미널에서 server 폴더로 들어간 뒤,
프로젝트를 다음 명령어로 초기화해주었다.
# server 폴더로 이동
cd server
# 프로젝트 초기화
npm init
명령어를 입력하게 되면 여러 옵션을 선택하는것이 나오는데,
일단은 모두 기본값으로 하기 위해 계속 엔터를 눌러준다.
정상적으로 모두 마쳤다면, server 폴더 밑에 package.json 파일이 생성된다.

이후에 소켓을 직접 구현해도 되지만,
비트 연산을 활용해서 소켓 프레임을 직접 구상하는 등 복잡하기 때문에
다음 명령어로 웹 소켓 라이브러리를 설치해준다.
npm i ws
2. server.js 구현
설정을 모두 마쳤다면 server.js 파일을 만들어준다.

그리고 다음과 같이 코드를 작성해준다.
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });
console.log('웹소켓 서버가 8080 포트에서 실행되었습니다.');
// ws는 클라이언트와 연결된 웹소켓 인스턴스
wss.on('connection', (ws) => {
  console.log('클라이언트가 연결되었습니다.');
  ws.on('message', (message, isBinary) => {
    wss.clients.forEach((client) => {
      if (client.readyState === WebSocket.OPEN) {
        client.send(message, { binary: false });
      }
    });
  });
  ws.on('close', () => {
    console.log('클라이언트 연결이 끊어졌습니다.');
  });
});
wss.on('error', (error) => {
  console.error('웹소켓 에러:', error);
});
wss.on('close', () => {
  console.log('웹소켓 서버가 종료되었습니다.');
});
하나씩 살펴보자.
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });
console.log('웹소켓 서버가 8080 포트에서 실행되었습니다.');
서버는 8080 포트에서 열리도록 했다.
만약 포트가 다른것과 충돌난다면, 포트번호를 바꿔서 하면 된다.
// ws는 클라이언트와 연결된 웹소켓 인스턴스
wss.on('connection', (ws) => {
  console.log('클라이언트가 연결되었습니다.');
  ws.on('message', (message, isBinary) => {
    wss.clients.forEach((client) => {
      if (client.readyState === WebSocket.OPEN) {
        client.send(message, { binary: false });
      }
    });
  });
  ws.on('close', () => {
    console.log('클라이언트 연결이 끊어졌습니다.');
  });
});
`on`이벤트를 통해서 웹 소켓관련 액션들을 처리하는데,
'connection'은 클라이언트가 연결되었을 때 실행된다.
여기서 콜백의 인자로 넘겨주는 `ws`는 클라이언트와 연결된 웹소켓 객체(인스턴스)이다.
연결이 완료된 후,
각 클라이언트 별로 이벤트 처리를 할 수 있다.
여기서 'message' 이벤트는 클라이언트로부터 어떠한 메시지를 받았을 때 실행되는 이벤트이다.
이를 처리할 콜백인자로 `message`와 `isBinary`를 넘겨주었는데,
`message`는 클라이언트로부터 받은 메시지,
`isBinary`는 받은 메시지가 바이너리 형식인지 판단하는 boolean 값이다.
모두가 같은 채팅방에 있다는 가정하에,
한 클라이언트가 보낸 메시지는 다른 모든 클라이언트에게 보여야한다.
따라서 연결된 모든 클라이언트를 순회하면서 메시지를 보내준다.
여기서 메시지는 문자열이기 때문에, binary는 false로 지정해준다.
(기본값이 false이므로 따로 지정해주지 않아도 된다.)
마지막으로 클라이언트의 연결이 끊어졌을 때인 'close' 이벤트를 처리해주면 된다.
3. server 구동
서버를 모두 구현한 후,
서버를 구동하려면 터미널에서 다음 명령어를 입력해주면 된다.
node server.js
정상적으로 작동이 되었다면, 터미널에서 다음 메시지를 확인할 수 있다.

Client
클라이언트는 Next.js로 구현하였다.
사실 어떠한 서비스를 만드는것이 아니라 연습하는 것이므로,
단순 JS나 React로 구성해도 상관이없다.
CSS는 Tailwind CSS와 shadcn/ui로 작성하였다.
최대한 복잡한 db나 서버가 필요하지 않는 범위에서 카카오톡 채팅방을 클론해보며 하려한다.
다음은 1차로 구현한 전체 코드이다.
'use client';
import useSocket from '@/hooks/useSocket';
import { Avatar, AvatarFallback, AvatarImage } from '@radix-ui/react-avatar';
import { SearchIcon } from 'lucide-react';
import { useState } from 'react';
export default function Home() {
  const { messages, socket } = useSocket();
  const [message, setMessage] = useState('');
  function handleSend(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    socket?.send(message);
    setMessage('');
  }
  return (
    <div className="bg-blue-300 w-1/3 h-3/5 rounded-lg flex flex-col border border-gray-300 justify-between">
      <header className="p-2 flex items-center gap-2 justify-between">
        <div className="flex items-center gap-2">
          <Avatar>
            <AvatarImage
              src="https://github.com/shadcn.png"
              className="rounded-full w-5 h-5"
            />
            <AvatarFallback>CN</AvatarFallback>
          </Avatar>
          <p>유저1</p>
        </div>
        <div className="flex items-center gap-2">
          <button>
            <SearchIcon className="w-4 h-4" />
          </button>
        </div>
      </header>
      <div className="p-2 overflow-y-auto">
        {messages.map((item, index) => (
          <div key={index}>{item}</div>
        ))}
      </div>
      <form className="h-1/5 flex flex-col p-2 bg-white" onSubmit={handleSend}>
        <textarea
          className="w-full h-full resize-none focus:outline-none text-sm p-1"
          value={message}
          onChange={(e) => setMessage(e.target.value)}
          placeholder="메시지 입력"
          onKeyDown={(e) => {
            if (e.key === 'Enter' && !e.shiftKey) {
              e.preventDefault();
              e.currentTarget.form?.requestSubmit();
            }
          }}
        />
        <div className="flex justify-end">
          <button
            type="submit"
            className={`font-semibold text-sm rounded-lg py-2 px-4 ${
              message.length > 0
                ? 'bg-yellow-300 '
                : 'bg-gray-100 text-gray-400'
            }`}
            disabled={message.length === 0}
          >
            전송
          </button>
        </div>
      </form>
    </div>
  );
}
<textarea
  className="w-full h-full resize-none focus:outline-none text-sm p-1"
  value={message}
  onChange={(e) => setMessage(e.target.value)}
  placeholder="메시지 입력"
  onKeyDown={(e) => {
    if (e.key === 'Enter' && !e.shiftKey) {
      e.preventDefault();
      e.currentTarget.form?.requestSubmit();
    }
  }}
/>
socket 관련 로직 부분은 조금 길어서 커스텀 훅으로 별도로 분리했다.
그 전에, UI 조금만 살펴보자면 메시지 입력 부분에 <textarea>를 사용했다.
onKeyDown 이벤트에 엔터를 눌렀을 때 처리를 해주었는데,
엔터를 누르면 입력한 메시지가 전송이 되도록 폼 제출을 하도록 해주었다.
shift 키를 같이 누르면, 카카오톡과 똑같이 전송은 안되고 줄바꿈이 되도록 조건을 걸어주었다.
<div className="flex justify-end">
  <button
    type="submit"
    className={`font-semibold text-sm rounded-lg py-2 px-4 ${
      message.length > 0
        ? 'bg-yellow-300 '
        : 'bg-gray-100 text-gray-400'
    }`}
    disabled={message.length === 0}
  >
    전송
  </button>
</div>
전송버튼은 입력한 메시지가 없을 때 disable 되도록 해놨다.
또한, 입력한 메시지가 있는지 여부에 따라 디자인도 바뀌도록 해놨다.
완성하면 다음과 같은데, 여러 브라우저에서 실행해서 테스트해보면 된다.
다음에는 각 유저 구별을 위한 기능을 추가해봐야겠다!
