Next.js + Socket.io 채팅 기능 구현하기 1편

carrot·2022년 3월 21일
9
post-thumbnail

Next.js를 기반으로 하고 있는 개인 프로젝트에 socket.io를 이용한 채팅 기능을 구현했습니다. 생각보다 간단한 코드로 구현이 되고 적용되어 놀라움을 느꼈습니다! 👀

구현에 앞서 채팅방 UI, DB에 저장될 Chat 구조 등등을 고민했으나 구현 이후 수정하는 방향으로 진행하기로 했습니다. 1편에서 구현된 내용은 하나의 route에 접속한 유저들이 채팅으로 메시지를 주고 받을 수 있는 정도 입니다.

2편에서는 MongoDB를 기반으로 채팅방을 개설, 참가하는 기능을 구현해 볼 예정입니다.

Socket.io

모든 플랫폼에 대한 양방향 및 저지연 통신 기능을 지원하는 JS 라이브러리 입니다. 웹 클라이언트와 서버 간의 실시간 양방향 통신이 가능하게 해줍니다.
브라우저에서 실행되는 client측 라이브러리와 node.js용 서버측 라이브러리의 두 부분이 있고, 두 부분의 구성 요소 모두 거의 동일한 API를 가지고 있습니다.

Server

서버측 구성은 크게 3가지 정도로 나눌 수 있습니다.

  1. response 타입 설정
  2. socket.io 연결
  3. route 연결

1. response type 설정하기

Next.js에서 request와 respnoe의 타입은 주로 NextApiRequestNextApiResponse를 사용합니다. 여기서는 socket.io request에 대한 resonse 타입을 별도로 설정하도록 합니다.

types/chat.d.ts

import { Server as NetServer, Socket } from "net";
import { NextApiResponse } from "next";
import { Server as SocketIOServer } from "socket.io";

export type NextApiResponseServerIO = NextApiResponse & {
  socket: Socket & {
    server: NetServer & {
      io: SocketIOServer;
    };
  };
};

2. socket.io 연결하기

현재 프로젝트의 server와 socket.io를 연결합니다.

/pages/api/chat/socketio.ts

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

export const config = {
  api: {
    bodyParser: false,
  },
};

export default async (req: NextApiRequest, res: NextApiResponseServerIO) => {
  if (!res.socket.server.io) {
    console.log("New Socket.io server...✅");

    const httpServer: NetServer = res.socket.server as any;
    const io = new ServerIO(httpServer, {
      path: "/api/chat/socketio",
    });

    res.socket.server.io = io;
  }

  res.end();
};

3. route 연결

이제 /chat route에서 해당 route에 들어오는 chat 정보를 연결된 유저들에게 전달하는 response 코드를 작성합니다.

/pages/api/chat/index.ts

import { NextApiRequest } from "next";
import { NextApiResponseServerIO } from "../../../types/chat";

export default async (req: NextApiRequest, res: NextApiResponseServerIO) => {
  if (req.method === "POST") {
    const message = req.body;
    res.socket.server.io.emit("message", message);

    res.status(201).json(message);
  }
};

이제 서버측 구성은 완성되었습니다. 이제 클라이언트측 socket.io 구성을 /api/chat/socketio 경로로 연결하고 클라이언트 route에서 발생하는 socket 이벤트를 response로 받아서 출력하도록 합니다.

Client

/chat route에서 출력할 <Chatting.tsx> 파일을 생성하고 코드를 작성합니다.

components/views/chat/Chatting.tsx

import React, { useState, useRef, useEffect, useCallback } from "react";
import { useSelector } from "../../../store";
import axios from "../../../lib/api";

// * Socket.io
import SocketIOClient from "socket.io-client";

// * MUI
import { Stack, TextField, Alert, Button, Paper } from "@mui/material";
import SendIcon from "@mui/icons-material/Send";

interface IMessage {
  user: string;
  message: string;
}

const Chatting: React.FC = () => {
  const [sendMessage, setSendMessage] = useState<string>("");
  const [connected, setConnected] = useState<boolean>(false);
  const [chat, setChat] = useState<IMessage[]>([]);

  const username = useSelector((state) => state.user.name);

  useEffect((): any => {
    // connect to socket server
    const socket = SocketIOClient.connect(process.env.NEXT_PUBLIC_API_URL, {
      path: "/api/chat/socketio",
    });

    // log socket connection
    socket.on("connect", () => {
      console.log("SOCKET CONNECTED!", socket.id);
      setConnected(true);
    });

    // update chat on new message dispatched
    socket.on("message", (message: IMessage) => {
      chat.push(message);
      setChat([...chat]);
    });

    // socket disconnect on component unmount if exists
    if (socket) return () => socket.disconnect();
  }, []);

  const sendMessageHandler = useCallback(
    (event: React.ChangeEvent<HTMLInputElement>) => {
      setSendMessage(event.target.value);
    },
    [sendMessage]
  );

  const enterKeyPress = (event: React.KeyboardEvent<HTMLInputElement>) => {
    if (event.key === "Enter" && !event.shiftKey) {
      // send message
      event.preventDefault();
      submitSendMessage(event);
    }
  };

  const submitSendMessage = async (
    event: React.FormEvent<HTMLButtonElement>
  ) => {
    event.preventDefault();
    if (sendMessage) {
      const message: IMessage = {
        user: username,
        message: sendMessage,
      };

      const response = await axios.post("/api/chat", message);
      setSendMessage("");
    }
  };

  return (
    <>
      <Stack spacing={2} direction="column">
        <Alert severity="info">
          채팅 기능은 로그인된 유저에게만 제공됩니다.
        </Alert>
        {/* 채팅 메시지 출력 영역 */}
        <Stack spacing={2} direction="column">
          <Paper variant="outlined" sx={{ minHeight: "300px" }}>
            {chat.length ? (
              chat.map((chat, index) => (
                <div className="chat-message" key={index}>
                  {chat.user === username ? "Me" : chat.user} : {chat.message}
                </div>
              ))
            ) : (
              <div className="alert-message">No Chat Messages</div>
            )}
          </Paper>
        </Stack>
        {/* 채팅 메시지 입력 영역 */}
        <Stack spacing={1} direction="row">
          <TextField
            id="chat-message-input"
            label="enter your message"
            variant="outlined"
            value={sendMessage}
            onChange={sendMessageHandler}
            margin="normal"
            autoFocus
            multiline
            rows={2}
            fullWidth
            onKeyPress={enterKeyPress}
            placeholder={connected ? "enter your message" : "Connecting...🕐"}
          />
          <Button
            type="submit"
            variant="contained"
            color="primary"
            endIcon={<SendIcon />}
            onClick={submitSendMessage}
          >
            Send
          </Button>
        </Stack>
      </Stack>
    </>
  );
};

export default Chatting;

useEffect를 통해 클라이언트가 /chat route에 접근시 socket을 연결합니다. 생성된 메시지가 있으면 server에서 response로 보내주며, 받아온 메시지 정보는 useState를 통해 저장되고 업데이트된 정보가 채팅창에 출력되는 구조 입니다.

유저가 /chat route를 이탈하게 되면 cleanup 함수를 통해 socket 연결을 해제하도록 합니다.

이제 next.js를 구동시키고 브라우저에서 테스트를 진행해 봅니다.

왼쪽 크롬 브라우저에서 Tester1 유저가 접속중이고, 오른쪽 사파리 브라우저에서 Tester2 유저가 접속중입니다.
아직 채팅 메시지 출력 UI Component를 구성하지 않아서 username과 메시지만 출력되고 있습니다.

정리

socket.io를 통해 서버와 클라이언트 코드를 구성하여 path를 연결해 주면 해당 path에 연결된 route에서는 유저가 작성하고 발송하는 내용들을 실시간으로 받아볼 수 있습니다.

socket에서 제공하는 on, emit 메서드를 활용해서 소켓서버연결, 메시지 발송 등의 작업을 할 수 있습니다.

2편에서는 MongoDB에 chat document를 생성하고, chat 내용을 채팅방 별로 저장하는 기능과 이를 불러와 출력하는 기능 등을 구현해 보도록 하겠습니다. 👋

profile
당근같은사람

3개의 댓글

comment-user-thumbnail
알 수 없음
2022년 11월 10일
수정삭제

삭제된 댓글입니다.

1개의 답글
comment-user-thumbnail
2023년 5월 16일

/pages/api/chat/index.ts 의 존재이유를 모르겠네요.
클라이언트가 메세지를 보낼때마다 post 보내고 그걸 또 emit 하는 방식인거 같은데
그냥 socket.io-client 에서 바로 emit 가능할텐데 next.js 라서 그런건지 궁금하네요.

답글 달기