⌨️ 1:1 채팅 기능 구현 ( with socket.io )

박상은·2022년 6월 25일
2

🛒 blemarket 🛒

목록 보기
7/7

Next.js, prisma, socket.io를 이용해서 1:1 채팅 기능을 구현한 방식에 대한 포스트입니다.

📃 Prisma Schema

모델에서 다른 관계 모두 생략하고 채팅 관련한 것만 남겨뒀습니다.

model User {
  id        Int      @id @default(autoincrement())
  name      String   @unique
  phone     String?  @unique
  email     String?  @unique
  avatar    String?
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  
  rooms           Room[]
  chats           Chat[]
}

model Room {
  id              Int       @id @default(autoincrement())
  name            String    @unique
  createdAt       DateTime  @default(now())
  updatedAt       DateTime  @updatedAt
  // null: 둘 다 읽기 가능, 특정 유저의 아이디: 해당 유저만 읽기 불가능 ( 채팅방 나가기 기능을 위함 )
  chatInvisibleTo Int?
  lastChat        String?
  timeOfLastChat  DateTime?

  // user와 room N:M
  users User[]
  // room과 chat 1:N
  chats Chat[]
}

model Chat {
  id        Int      @id @default(autoincrement())
  chat      String
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  User   User? @relation(fields: [userId], references: [id], onDelete: Cascade)
  userId Int?
  Room   Room? @relation(fields: [roomId], references: [id], onDelete: Cascade)
  roomId Int?

  @@index([userId])
  @@index([roomId])
}

🖌️ 타입

Userprisma에서 생성해준 타입이고, SimpleUserUser에서 자주 사용하는 프로퍼티만 추출해서 만든 타입입니다.

// 서버 -> 클라이언트
export type ServerToClientEvents = {
  onReceive: ({
    user,
    chat,
  }: {
    user: SimpleUser | User;
    chat: string;
  }) => void;
};
// 클라이언트 -> 서버
export type ClientToServerEvents = {
  onJoinRoom: (roomId: string) => void;
  onSend: (data: {
    user: SimpleUser | User;
    roomId: string;
    chat: string;
  }) => void;
};
export type InterServerEvents = {};
export type SocketData = {};

✨ 서버 측 코드

채팅방 가져오기/생성/나가기기존 채팅 가져오는 코드는 양이 많아서 생략하고 깃헙으로 대체하겠습니다.

  • 아직 깃헙에 안 올려서 올리면 링크에 추가함 *
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;
    };
  };
};
import { NextApiRequest } from "next";
import { NextApiResponseServerIO } from "@src/@types/chat";
import { Server as ServerIO } from "socket.io";
import { Server as NetServer } from "http";
import prisma from "@src/libs/client/prisma";

import {
  ClientToServerEvents,
  InterServerEvents,
  ServerToClientEvents,
  SocketData,
} from "@src/types";

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

// eslint-disable-next-line import/no-anonymous-default-export
export async function handler(req: NextApiRequest, res: NextApiResponseServerIO) {
  if (!res.socket.server.io) {
    const httpServer: NetServer = res.socket.server as any;
    const io = new ServerIO<
      ClientToServerEvents,
      ServerToClientEvents,
      InterServerEvents,
      SocketData
    >(httpServer, {
      path: "/api/chats/socketio",
    });

    io.on("connect", (socket) => {
      // console.log("소켓 연결 완료 >> ", socket.id);
      // console.log("연결된 소켓 개수 >> ", io.engine.clientsCount);

      // 소켓 연결 후 방에 입장
      socket.on("onJoinRoom", (roomId) => socket.join(roomId));
![](https://velog.velcdn.com/images/1-blue/post/606d2a74-8915-46ae-bc75-9c3509993450/image.gif)

      // 채팅 수신
      socket.on("onSend", ({ user, roomId, chat }) => {
        // 채팅 생성
        const chatPromise = prisma.chat.create({

          data: {
            chat,
            User: {
              connect: {
                id: user.id,
              },
            },
            Room: {
              connect: {
                id: +roomId,
              },
            },
          },
        });
        // 마지막 채팅과 시간 기록
        const roomPromise = prisma.room.update({
          where: {
            id: +roomId,
          },
          data: {
            lastChat: chat,
            timeOfLastChat: new Date(),
          },
        });

        // 채팅 생성은 어차피 현재 연결된 클라이언트에게 바로 전송하니까 데이터베이스에 생성하는 것을 기다릴 필요 없음
        Promise.allSettled([chatPromise, roomPromise]);

        // 받은 채팅 송신
        socket.broadcast.to(roomId).emit("onReceive", {
          user,
          chat,
        });
      });
    });
  }

  res.end();
}

✨ 클라이언트 측 코드

소켓 연결, 채팅 수신/송신 코드만 작성하겠습니다.

// 2022/06/10 - 서버와 소켓 연결 및 채팅방 입장 - by 1-blue
useEffect(() => {
  if (!me) return;
  if (socket) return;

  const mySocket = io(process.env.NEXT_PUBLIC_VERCEL_URL!, {
    path: "/api/chats/socketio",
    // withCredentials: true,
    // transports: ["websocket"],
  });

  // 소켓 연결 성공 했다면
  mySocket.on("connect", () => {
    // 채팅방 입장
    mySocket.emit("onJoinRoom", router.query.id + "");

    // 채팅 송신 이벤트 등록
    mySocket.on("onReceive", ({ user, chat }) => {
      // swrInfinite에서 얻은 mutate
      // 채팅을 받았으니 해당 채팅에 대한 정보를 추가해주는 코드
      chatMutate(
        (prev) =>
          prev && [
            {
              ok: true,
              message: "mutate로 추가",
              isMine: true,
              chats: [
                {
                  User: user,
                  chat,
                  id: Date.now(),
                  createdAt: new Date(),
                  updatedAt: new Date(),
                  roomId: +(router.query.id as string),
                  userId: user.id,
                },
              ],
            },
            ...prev,
          ],
        false
      );
    });
  });

  setSocket((prev) => prev || mySocket);
}, [me, router, chatMutate, socket]);

// 2022/06/10 - 채팅 전송 - by 1-blue
const onAddChatting = useCallback(
  ({ chat }: ChatForm) => {
    if (!me) return;
    if (chat.trim() === "") return toast.error("내용을 채우고 전송해주세요!");
    if (chat.length > 200)
      return toast.error(
        `200자 이내만 입력가능합니다... ( 현재 ${chat.length}자 )`
      );

    // 채팅 수신
    socket?.emit("onSend", {
      user: me,
      roomId: router.query.id as string,
      chat,
    });

    // 채팅을 전송했으니 본인에게도 추가되도록 해주는 코드
    chatMutate(
      (prev) =>
        prev && [
          {
            ok: true,
            message: "mutate로 추가",
            isMine: true,
            chats: [
              {
                User: {
                  id: me.id,
                  name: me.name,
                  avatar: me.avatar,
                },
                chat,
                id: Date.now(),
                createdAt: new Date(),
                updatedAt: new Date(),
                roomId: +(router.query.id as string),
                userId: me.id,
              },
            ],
          },
          ...prev,
        ],
      false
    );

    // 채팅방이니까 스크롤을 맨밑으로 내림
    moveToScrollBottom();
    // 입력값 초기화 ( react-hook-form )
    reset();
  },
  [me, reset, router, socket, chatMutate, moveToScrollBottom]
);

다른 기능들

  1. flex를 이용해서 본인과 타인 채팅 다르게 스타일링
  2. 스크롤은 맨 밑으로 내리고 올릴 때마다 추가로 채팅 패치

😥 발생한 문제

1. 하나의 클라이언트가 두 번 연결

서버 측에서 io.on("connection")io.on("connect")로 바꿔주니 해결됨

2. 같은 채팅 여러 개 생성됨

데이터베이스에는 하나만 생성되는데 본인이 입력하거나 받는 사람이 같은 채팅을 여러 개 랜더링하는 경우 발생

아직 해결 못함

0개의 댓글