본문 바로가기
Next.js/개발 노트

Next.js 13에서 TypeScript를 사용한 WebSocket 구현

by tokkiC 2023. 12. 5.

WebSocket은 HTTP와는 다른 독특한 통신 방식을 사용하여, 실시간 양방향 통신을 가능하게 합니다. 이 글에서는 Next-Chat이라는 채팅 PWA 웹앱을 만들며 작성한 코드를 다시 분석해보려 합니다.
코드 줄마다 이해한 내용으로 주석을 적어놓았으니 코드만 보셔도 됩니다!

app router 가 아닌 pages router 를 선택한 이유

next.js 13 이상으로 프로젝트 생성 시, App Router를 사용하면 HTTP 메소드인 GET, POST, PUT, DELETE를 API 함수명으로 사용하지 않으면 에러가 발생하므로, App Router를 선택하지 않고 page Router를 사용해서 해결하였습니다. WebSocket은 일반적인 HTTP와 다르므로, App router를 사용해서 API 함수명을 GET, POST, PUT, DELETE 등을 사용하지 않고 Pages Router를 사용하였습니다.

초기에 HTTP 요청을 수행하는데 왜 HTTP 메소드로 API함수를 만들면 에러가 발생하는 원인에 대해 추측하자면, next.js는 AppRouter로 HTTP 메소드로 함수명을 정하면 요청 시 해당 메소드에 맞게 요청의 설정도 세팅되기 때문일것이라 생각함. WebSocket은 HTTP 메소드와 요청의 설정이 다르므로, next.js의 AppRouter에서 지원하는 GET 요청의 설정과 달라 에러가 발생하는 것이라 생각합니다. 그러므로 PageRouter를 사용하여 문제를 해결하였습니다.

WebSocket과 HTTP의 차이점

WebSocket 프로토콜은 HTTP와는 다르게 동작합니다. 일반적인 HTTP 요청(예: GET, POST)은 단방향 통신을 위한 것이며, 요청에 대한 응답이 완료되면 연결이 종료됩니다. 반면, WebSocket은 연결 초기에는 HTTP 요청을 통해 핸드셰이크를 수행하지만, 이후에는 전이중(full-duplex) 연결을 통해 지속적인 데이터 교환을 가능하게 합니다. 

WebSocket 서버 구성

Next.js 프로젝트에서 WebSocket 서버를 구성하기 위해 ServerIO 클래스를 사용합니다. 이 클래스는 Socket.IO 라이브러리에서 제공되며, WebSocket 통신을 위한 서버를 생성하고 관리하는 데 필요한 여러 기능을 포함하고 있습니다.

구현 포인트

  1. WebSocket 서버 초기화: res.socket.server.io가 설정되지 않았을 경우, 새로운 WebSocket 서버를 초기화합니다.
  2. 타입 단언: httpServer 변수를 통해 Next.js의 HTTP 서버 인스턴스를 가져오고, 이를 Socket.IO 서버 생성에 사용합니다.
  3. 이벤트 리스너 설정: 클라이언트가 WebSocket 서버에 연결될 때와 메시지를 보낼 때 실행될 이벤트 핸들러를 정의합니다.
    • connection 이벤트: 클라이언트의 웹소켓 서버 연결 시 실행될 이벤트 핸들러를 추가합니다.
    • createdMessage 이벤트: 클라이언트로부터 메시지를 받고, 이를 다른 모든 클라이언트에게 전달하는 이벤트 핸들러를 추가합니다.

주요 고려사항

  • Path 옵션: 본 코드에서는 기본 경로(/socket.io)를 사용하므로 따로 설정하지 않았음.
    • 기본 경로가 아닌 path 옵션을 사용할 경우는 다음과 같음
      • 1. 다른 HTTP 요청과 충돌을 피하기 위해서 
      • 2. 서버가 프록시 뒤에 있거나 특정 서브디렉토리에서 웹소켓 서비스를 제공해야 하는 경우, 이를 명시하기 위해서
      • 3. 보안, 네트워크 정책, 또는 아키텍처상의 이유로 인해 특정 경로를 통해서만 웹소켓 트래픽을 허용해야 할 수도 있음
  • 메시지 전송: io.emit을 사용하여 모든 클라이언트(메시지 발신자 포함)에게 메시지를 전송합니다.
  • 서버 인스턴스 재사용: 초기화된 WebSocket 서버 인스턴스를 Next.js의 서버 객체에 저장하여, 후속 요청에서 재사용합니다.

// src/pages/api/socket.ts

import { NextApiRequest, NextApiResponse } from "next";
import { Server as ServerIO } from "socket.io";
import { Server as NetServer } from "http";
import { NextApiResponseServerIO } from "../../../types/chat";

// next.js 프로젝트 생성 시, App Router를 사용하면 HTTP 메소드인 GET, POST, PUT, DELETE를 API 함수명으로 사용하지 않으면 에러가 발생하므로, App Router를 선택하지 않고 page Router를 사용해서 해결함
// 웹소켓은 HTTP 기반의 프로토콜이지만, 일반 HTTP 요청과는 다르게 동작하기 때문임. 웹소켓은 연결 초기에 HTTP 요청을 통해 핸드셰이크를 수행하지만, 이후에는 전이중(full-duplex) 연결을 통해 데이터를 주고받음. 따라서, 웹소켓은 GET, POST 등의 HTTP 메소드를 사용하지 않고, 대신 지속적인 연결을 통해 데이터를 전송함
// 연결 초기에 웹소켓이 HTTP 요청을 사용한다고 하더라도, 이는 웹소켓 연결을 시작하기 위한 특수한 유형의 HTTP GET 요청임. 웹소켓은 일반적인 HTTP GET 요청을 처리하는 방식과 다름. 일반적인 HTTP GET 요청은 단방향 통신을 위한 것이며, 연결이 완료되면 종료됩니다. 반면, 웹소켓의 GET 요청은 연결을 시작하고, 이후 지속적이고 양방향 통신을 가능하게 함. 그래서 Next.js와 같은 프레임워크에서 일반적인 HTTP 메소드(GET, POST 등)를 사용하여 웹소켓 연결을 처리하려고 하면 문제가 발생함. 웹소켓 연결은 이러한 HTTP 메소드로 처리되지 않고, 별도의 프로토콜 및 처리 메커니즘을 사용하기 때문임
export default async function sockethandler(
  req: NextApiRequest,
  res: NextApiResponseServerIO
) {
  // 실행중인 웹소켓이 없다면 새 웹소켓 서버 실행
  if (!res.socket.server.io) {
    console.log("Initializing WebSocket");

    // res.socket.server의 기본 타입이 http.Server와 정확히 일치하지 않아서, Socket.IO와 호환되도록 타입을 강제로 맞추기 위해 타입 단언을 사용함
    const httpServer: NetServer = res.socket.server as any;

    // httpServer는 Next.js 서버의 HTTP 서버 인스턴스로, 이를 사용하여 Socket.IO 서버가 클라이언트의 연결 요청을 듣고 처리할 수 있도록 함
    // new ServerIO(httpServer)에서 path 옵션을 포함하지 않은 경우, 기본적으로 Socket.IO는 클라이언트 연결을 위한 기본 경로 /socket.io를 사용함. 이는 Socket.IO 서버와 클라이언트 간의 통신을 위한 기본 엔드포인트로 설정되어 있음. 해당 코드에서는 기본 경로(/socket.io)를 사용해도 되므로 path 옵션을 사용하지 않음
    // path 옵션은 다음의 경우에 사용함
    // 1. 다른 HTTP 요청과 충돌을 피하기 위해서
    // 2. 서버가 프록시 뒤에 있거나 특정 서브디렉토리에서 웹소켓 서비스를 제공해야 하는 경우, 이를 명시하기 위해서
    // 3. 보안, 네트워크 정책, 또는 아키텍처상의 이유로 인해 특정 경로를 통해서만 웹소켓 트래픽을 허용해야 할 수도 있습니다. 이럴 때 path 옵션을 사용하여 이를 구성함
    const io = new ServerIO(httpServer);

    // 클라이언트가 웹소켓 서버에 연결될 때 실행될 이벤트 핸들러를 정의함
    io.on("connection", (socket) => {
      // 클라이언트가 'createdMessage' 이벤트와 함께 메시지를 보낼 때 실행됨
      socket.on("createdMessage", (msg) => {
        // io.emit은 모든 클라이언트(메시지 전송자 포함)에게 클라이언트에서 받은 메시지를 전송
        // 메시지 전송자를 제외한 다른 모두에게 전송하기를 원하면 socket.broadcast.emit가 있지만 이 프로젝트에서는 emit이 적절하므로 io.emit을 사용함
        io.emit("newIncomingMessage", msg);
      });
    });

    //  초기화된 Socket.IO 서버 인스턴스(io)를 Next.js의 서버 객체(res.socket.server)에 할당하여, 추후 요청에서 해당 인스턴스를 재사용할 수 있게 함
    res.socket.server.io = io;

    // 웹소켓이 이미 실행중이라면
  } else {
    console.log("WebSocket already initialized");
  }

  // 해당 api 요청에 대한 응답을 완료하고 연결을 종료함
  res.end();
}




여기서부터는 프론트 사이드의 구현에 대해서입니다
코드부터 보고 해석하겠습니다. 물론 주석에 다~ 녹아있는 내용입니다아

// src/pages/components/SocketChat.tsx

"use client";

import io, { Socket } from "socket.io-client";
import { useState, useEffect, useRef } from "react";

type Message = {
  author: string;
  message: string;
};

export default function SocketChat() {
  const [username, setUsername] = useState(""); //이름 지정
  const [chosenUsername, setChosenUsername] = useState(""); //선택된 유저 이름 지정
  const [message, setMessage] = useState(""); // 메시지 (채팅창에 치는 중인 글)
  const [messages, setMessages] = useState<Array<Message>>([]); //매세지들 (채티창에 전부 다 쳐서 쌓인 글들)

  const socketRef = useRef<Socket | null>(null);
  const isInitialized = useRef<boolean>(false); // 소켓이 초기화 되었는지 여부
  const messagesEndRef = useRef<null | HTMLDivElement>(null); // 새로운 메시지가 추가될 때 스크롤을 맨 아래로 이동시키기 위한 ref

  useEffect(() => {
    // strict mode가 true일때나 다른 상황으로 인해 재랜더링 될때 useEffect가 2번 이상 실행되어 이벤트 리스너가 중복으로 설정되지 않기 위해 소켓 초기화 여부를 확인함
    if (isInitialized.current) {
      return;
    }

    // 소켓에 연결하고 이벤트 리스너를 설정하기 위한 위한 비동기 함수
    const setupSocket = async () => {
      if (!socketRef.current) {
        await fetch("/api/socket");
        socketRef.current = io();
      }

      // 웹소켓에 연결된 상대들에게서 메시지가 도착할 때마다 이 함수가 호출됨
      // 받은 메시지를 채팅 기록(이 코드에서는 messages)에 추가함
      socketRef.current.on("newIncomingMessage", (msg: Message) => {
        setMessages((currentMsg) => [...currentMsg, msg]);
      });
    };

    // 소켓 연결 및 설정 함수 실행
    setupSocket();

    // 소켓이 초기화되었음을 체크함
    isInitialized.current = true;

    return () => {
      // 컴포넌트가 언마운트될 때, 현재 설정된 소켓 연결이 있다면 그 연결을 종료함
      if (socketRef.current) {
        socketRef.current.close();
      }
    };
  }, []);

  const scrollToBottom = () => {
    messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
  };

  useEffect(() => {
    // 메시지 목록이 변경될 때 스크롤을 맨 아래로 이동
    scrollToBottom();
  }, [messages]);

  const sendMessage = (e: React.FormEvent) => {
    e.preventDefault();

    if (message && socketRef.current) {
      // 서버에 새 메시지 전송함
      // createdMessage 이벤트는 클라이언트에서 서버로 메시지를 보내는 데 사용됨. 이벤트에는 메시지 데이터가 포함됨
      // 클라이언트에서의 emit은 서버에서의 emit과 다르게 서버로의 이벤트 전송의 기능만을 담당함. 자신을 제외한 다른 사용자들에게 전송하는 기능이 아님. 그건 서버에서 사용됐을때의 기능임
      socketRef.current.emit("createdMessage", {
        author: chosenUsername,
        message,
      });

      // // 전송한 메시지를 메시지 목록에 추가
      // // 직접 배열에 추가하여 서버 데이터 교환전에도 UI에 전송한 메시지가 바로 보이도록 함
      // setMessages((currentMsg) => [
      //   ...currentMsg,
      //   { author: chosenUsername, message },
      // ]);
      console.log("Sent Message");
      // 메시지 전송 후 input 초기화
      setMessage("");
    }
  };

  return (
    <div className="flex items-center min-h-screen justify-center bg-[url('/images/웹소켓ai배경.png')] bg-cover">
      <div className="flex flex-col items-center justify-center w-full h-full">
        {!chosenUsername ? (
          <>
            <form
              onSubmit={(e) => {
                e.preventDefault();
                setChosenUsername(username);
              }}
              className="flex flex-col items-center"
            >
              <h3 className="font-bold text-white text-xl ">
                사용할 닉네임을 입력해주세요
              </h3>
              <input
                type="text"
                value={username}
                className="py-2 px-4 rounded-md outline-none my-6"
                onChange={(e) => setUsername(e.target.value)}
                required
              />
              <button
                type="submit"
                className="bg-white rounded-md px-4 py-2 text-lg font-bold text-black/70"
              >
                채팅 시작하기
              </button>
            </form>
          </>
        ) : (
          <>
            <p className="font-bold text-white text-xl mb-4">
              Username : {username}
            </p>
            <div className="flex flex-col justify-end bg-white h-80 w-80 rounded-md shadow-md overflow-hidden">
              <div className="h-full overflow-y-scroll">
                {messages.map((msg, i) => {
                  return (
                    <div
                      className="w-full py-1 px-2 border-b last:border-b-0 border-gray-200"
                      key={i}
                    >
                      {msg.author} : {msg.message}
                    </div>
                  );
                })}
                <div ref={messagesEndRef} />
              </div>
              <form
                onSubmit={sendMessage}
                className="border-t border-gray-300 w-full flex rounded-bl-md"
              >
                <input
                  type="text"
                  placeholder="새로운 메시지를 입력하세요"
                  value={message}
                  className="outline-none py-2 px-2 rounded-bl-md flex-1"
                  onChange={(e) => setMessage(e.target.value)}
                />
                <button className="border-l font-bold border-gray-300 flex justify-center items-center rounded-br-md group hover:bg-purple-500/60 transition-all px-3 h-full">
                  Send
                </button>
              </form>
            </div>
          </>
        )}
      </div>
    </div>
  );
}


코드 해석

  • State 및 Ref 사용: 사용자 이름, 메시지, 메시지 목록을 관리하기 위한 상태(state)와 참조(ref)가 설정됩니다.
  • useEffect 훅: 컴포넌트가 마운트될 때 소켓 연결을 초기화하고, 새로운 메시지가 도착할 때마다 메시지 목록에 추가합니다. 이는 useEffect 훅을 사용하여 구현됩니다.
  • 이벤트 리스너: 소켓에 'newIncomingMessage' 이벤트 리스너를 추가하여 서버로부터 오는 메시지를 받아 처리합니다.
  • 메시지 전송: 사용자가 메시지를 보낼 때 'createdMessage' 이벤트를 서버로 전송합니다.

구현 포인트

  1. 소켓 연결 확인: isInitialized ref를 사용하여 소켓이 이미 초기화되었는지 확인함으로써 불필요한 재연결을 방지합니다.
  2. 스크롤 관리: 새 메시지가 추가될 때마다 채팅창의 스크롤을 자동으로 최신 메시지 위치로 이동시킵니다.

사용자 인터페이스

사용자 인터페이스는 React의 기본적인 조건부 렌더링을 사용하여 구현됩니다.

구현 내용

  • 닉네임 설정: 사용자가 처음 채팅 애플리케이션에 접속할 때 닉네임을 설정할 수 있는 입력 폼을 제공합니다.
  • 메시지 표시: 사용자가 전송한 메시지와 다른 사용자의 메시지가 화면에 표시됩니다.
  • 메시지 입력 및 전송: 사용자가 새로운 메시지를 입력하고 전송할 수 있는 입력 필드와 버튼이 제공됩니다.

코드 구조

  • 조건부 렌더링: 사용자가 닉네임을 설정했는지 여부에 따라 다른 UI를 보여줍니다.
  • 메시지 렌더링: messages 배열을 순회하며 각 메시지를 화면에 표시합니다.

 

흠... app 라우팅은 아직 방법이 없나?

후기

node.js 를 사용하는 웹소켓 서버 코드는 널려있으니 그대로 복붙해서 쓰면 됐다만... next.js + TS 를 애정하는 나로서는 어떻게든 next.js만으로 구현하고 싶었다. app 라우팅을 고집하다가 꽤나 고생했지만, pages 라우팅을 사용하니 한순간에 에러가 사라지는 것을 보고 참 기분이 찝찝했다. next.js 의 앞으로의 대세는 app 라우팅일테니 말이다. 언젠가 next.js의 app라우팅으로 웹소켓 서버를 구현하게 된다면 그때 다시 글을 쓰겠다아

댓글