Life is connecting the dots .

Node.js(express) + ws(WebSocket) + React로 채팅 기능 만들기 (+. app.listen vs http.createServer) 본문

Programming/Node.js(express)

Node.js(express) + ws(WebSocket) + React로 채팅 기능 만들기 (+. app.listen vs http.createServer)

soyeori 2024. 10. 17. 18:03

 

이번에 토이 프로젝트로 채팅 기능을 만들어보게 되면서 웹소켓을 사용해 보았다. 이번 포스팅에서는 웹소켓을 사용하여 메시지를 주고받는 채팅 기능을 구현하는 과정을 기록해 보려 한다. 지금은 연결 후 간단하게만 구현해 놓은 상태이고, 이후 인증 처리 및 메시지 저장하는 기능을 붙일 예정이다. 사용한 기술 스택은 프론트엔드는 React, WebSocket API, 백엔드는 Node.js(express), ws (node.js 웹소켓 라이브러리)를 사용했다.

 

1. WebSocket

웹소켓은 하나의 TCP 접속(connection)에 서버와 클라이언트 간 양방향 통신을 할 수 있게 만든 프로토콜이다. 웹소켓 프로토콜은 HTTP 폴링과 같은 방식에 비해 더 낮은 부하를 사용하여 웹브라우저와 웹서버 간의 통신을 가능하게 하고, 서버와의 실시간 데이터 전송을 가능하게 한다.

웹소켓을 연결하기 위해 클라이언트는 웹소켓 핸드셰이크 요청을 보내고, 서버는 웹소켓 핸드셰이크 응답을 반환한다. 이후에 웹소켓으로 양방향 통신을 할 수 있다.

// 클라이언트 요청
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket // HTTP 프로토콜에서 웹소켓 프로토콜로 변경
Connection: Upgrade 
Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13
Origin: http://example.com

// 서버 응답
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk=
Sec-WebSocket-Protocol: chat

Socket.io 라이브러리

웹소켓을 활용하여 다양한 기능을 제공해 주는 대표적인 라이브러리로 socket.io가 있다. socket.io는 클라이언트와 서버 간 실시간 데이터 전송을 쉽게 구현할 수 있기 때문에 채팅, 알림, 업데이트 등의 기능을 만들 때 사용된다. 또한, 웹소켓이 지원되지 않는 환경에서 HTTP 롱폴링으로 대체할 수 있고, 방(room) 및 네임스페이스 지원, 브로드캐스팅, 자동 재연결 기능 등을 제공한다는 장점이 있다.

WebSocket API와 ws 라이브러리

채팅 기능을 만들어야 하기에 socket.io라이브러리를 사용하는 것이 더 적절해 보일 것 같지만 서버는 ws 라이브러리와 브라우저는 기본 웹소켓 API를 사용해서 만들어보기로 했다. ws는 Node.js에서 웹소켓 통신을 가능하게 해주는 라이브러리이다. 브라우저에서는 WebSocket API와 서버에서 ws를 선택한 이유는 현재는 다양한 기능을 고려하는 상황이 아니고, 작은 프로젝트에서 간단한 설정만으로 사용하기에 더 적합하다고 판단했다.

 

2. Node.js(express) + WS (WebSocket) 설정

Node.js(express) + WS (WebSocket)를 사용해 구현한 코드를 살펴보면 다음과 같다. 웹소켓을 연결하여 클라이언트로부터 메시지를 수신하는 순서이다. (참고 ws docs)

  1. ws 모듈 호출
  2. HTTP 서버와 WebSocket server 생성
  3. 클라이언트에서 WebSocket 객체를 생성하고 서버에 연결 요청을 보내면 연결 처리 (connection)
    • 연결된 클라이언트(ws)를 sec-websocket-key를 사용하여 식별
    • 연결된 클라이언트(ws)를 sockets 배열에 추가
    • 클라이언트가 연결을 끊을 경우 sockets 배열에서 제거
  4. 연결된 웹소켓 클라이언트들에게 받은 메시지를 브로드캐스팅
  5. 클라이언트가 서버로 메시지를 보내면 서버는 메시지를 수신하고 연결된 웹소켓 클라이언트들에게 받은 메시지를 응답
// 1.
const WebSocket = require("ws");

// 2. Create an HTTP server and a WebSocket server
const server = require("http").createServer(app);
const ws = new WebSocket.WebSocketServer({ server });

const port = 3001;

let sockets = [];

// 3. Emitted when the handshake is complete
// Request is the http GET request sent by the client
ws.on("connection", (ws, req) => {
  // 3-1. 웹소켓 식별
  ws.id = req.headers["sec-websocket-key"]; // 유효한 요청인지 확인하기 위해 사용하는 키  
  // 3-2.
  sockets.push(ws);

  // 3-3. Closing handshake
  ws.on("close", (code, reason) => {
    // code(number) - status code explaining why the connection is being closed
    // reason(String|Buffer) - why the connection is closing
    sockets = sockets.filter((socket) => socket.id !== ws.id);

    // 연결이 끊어졌을 때 length로 확인할 수 있음
    console.log(sockets.length);
  });

  // 4. Broadcast the message to all connected clients
  sockets.forEach((client) => {
	  // readyState - current state of the connection
    if (client.readyState === WebSocket.OPEN) {
      client.send(`New client connected!`);
    }
  });

  // 5. Reply
  ws.on("message", (message) => {
    sockets.forEach((client) => {
      if (client.readyState === WebSocket.OPEN) {
        client.send(message.toString());
      }
    });
  });
});

// Start the WebSocket server
server.listen(port, () => {
  console.log("서버가 3001번 포트에서 실행중");
});

 

 

app.listen vs http.createServer

웹소켓을 사용할 때 http.createServer로 서버를 생성해야 하는 이유

 

처음에 헷갈렸던 점은 기존 app.listen으로 서버를 실행했었는데, 웹소켓을 사용하면서 이 부분을 HTTP 서버로 변경해 주었다. 만약 변경하지 않았다면 다음과 같이 연결을 실패했다는 에러메시지를 확인할 수 있다.

...
const app = express();

// 기존
app.listen(port, () => {
  console.log("서버가 3001번 포트에서 실행중");
});

// 웹소켓 사용
// Start the WebSocket server
server.listen(port, () => {
  console.log("서버가 3001번 포트에서 실행중");
});

 

app.listen()은 express에 내장된 메소드로 기본적으로 HTTP 서버를 생성하고 해당 서버가 특정 포트에서 클라이언트의 요청을 대기하도록 만든다. 즉 기존 코드는 내부적으로 http.createServer(app)를 호출하여 HTTP 서버를 만드는데, app.listen()은 http.createServer를 감싸서 간편하게 사용하는 방법이다. 이 방식은 RESTful API 서버나 정적 파일을 제공하는 HTTP 서버에 적합하다.

 

여기서 “내부적으로”라는 의미에 대해 살펴보자면, node_modules/express/lib/application.js 에서 정의한 app.listen 메소드를 보면 다음과 같다. 즉, express를 사용하기 위해 const app = express(); 를 호출하는 것은 서버가 아니고, app.listen()을 호출했을 때 비로소 서버가 생성이 되고 연결이 허용된다.

// node_modules/express/lib/application.js
app.listen = function listen() {
  var server = http.createServer(this);
  return server.listen.apply(server, arguments);
};

 

이러한 app.listen()의 내부 구현 방식은 웹소켓을 사용할 때 문제가 발생한다. 즉, app.listen()을 호출하면 express에 의해 서버가 내부적으로 생성되는데 웹소켓은 HTTP 프로토콜에서 웹소켓 프로토콜로 변경하는 업그레이드 메커니즘을 통해 웹소켓으로 변환되므로 HTTP서버와 연결되어 있어야 한다.

 

하지만 app.listen()을 사용하면 HTTP 서버는 생성되지만, 이 생성된 서버 객체를 WebSocket 서버와 연결하는 기능을 제공하지 않는다. 따라서 WebSocket 요청을 처리할 수 없기 때문에 require("http"). createServer(app)을 사용해서 HTTP 서버를 생성해야지 WebSocket 서버도 해당 HTTP 서버를 통해 클라이언트와 통신할 수 있다.

 

3. React + WebSocket API 설정

프론트엔드에서 리액트와 WebSocket API를 사용하여 메시지 주고받는 기능을 완성해 보았다.

useRef를 웹소켓과 함께 사용한 이유는 WebSocket 객체의 상태를 컴포넌트가 리렌더링 될 때 다시 생성하지 않기 위해서다. 만약 WebSocket 객체를 useState 또는 컴포넌트 함수 내부에 직접 정의하면, 컴포넌트가 리렌더링 될 때마다 WebSocket 연결이 끊기고 재생성하는 과정이 발생하고 이는 큰 비용이 발생된다.

 

그러므로 useRef를 사용하여 Websocket 객체를 선언해 주고, clean up 로직을 사용하여 컴포넌트가 언마운트될 때 연결을 종료해 주었다. 그 외 기능은 웹소켓 API의 프로퍼티를 사용하여 서버에서 메시지를 보내고 받는 방식과 유사하다.

참고로 WebSocket의 readyState 프로퍼티는 웹소켓의 연결 상태를 반환하는데 반환값(value)은 숫자이고, 그 숫자는 4가지 가능한 상태를 정의한다.

 

  const ws = useRef<null | WebSocket>(null);
  const [messages, setMessages] = useState<string[]>([]); // 메세지 담을 배열

  // 메세지 보내기 이벤트 핸들러
  const sendMessage = () => {
    // Broadcast the message to all connected clients
    // readyState - current state of the WebSocket connection
    if (ws.current?.readyState === WebSocket.OPEN) {
      ws?.current.send(values.message);
    }
  };

  useEffect(() => {
    // Create WebSocket connection
    ws.current = new WebSocket('ws://localhost:3001');

    // Connection opened
    ws.current.onopen = () => {
      ws.current?.send("Connected to WS Server!");
    };

    ws.current.onerror = (error) => {
      console.log(error);
    };

    ws.current.onmessage = (event) => {
      setMessages((prev) => [...prev, event.data]);
    };

    return () => {
      if (ws.current) {
        console.log("websocket 연결 종료");
        ws.current.close();
      }
    };
  }, []);

 

다음에는 채팅 기능을 완성해 보면서 웹소켓을 다양하게 사용해 보고자 한다.

 

 


참고

https://en.wikipedia.org/wiki/WebSocket

https://developer.mozilla.org/en-US/docs/Web/HTTP/Protocol_upgrade_mechanism

https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/101

https://www.reddit.com/r/node/comments/11fysko/why_do_i_have_to_create_a_server_with/

https://bitkunst.tistory.com/entry/Nodejs-express-19-WebSocket