Next.js
,prisma
,socket.io
를 이용해서 1:1 채팅 기능을 구현한 방식에 대한 포스트입니다.
모델에서 다른 관계 모두 생략하고 채팅 관련한 것만 남겨뒀습니다.
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])
}
User
는prisma
에서 생성해준 타입이고,SimpleUser
는User
에서 자주 사용하는 프로퍼티만 추출해서 만든 타입입니다.
// 서버 -> 클라이언트
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 = {};
채팅방 가져오기/생성/나가기와 기존 채팅 가져오는 코드는 양이 많아서 생략하고 깃헙으로 대체하겠습니다.
- 아직 깃헙에 안 올려서 올리면 링크에 추가함 *
@types/chat.d.ts
( socket.io
에 대한 response
타입 설정 )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;
};
};
};
/api/chats/socketio.ts
( socket.io
연결 )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]
);
flex
를 이용해서 본인과 타인 채팅 다르게 스타일링서버 측에서 io.on("connection")
을 io.on("connect")
로 바꿔주니 해결됨
데이터베이스에는 하나만 생성되는데 본인이 입력하거나 받는 사람이 같은 채팅을 여러 개 랜더링하는 경우 발생
아직 해결 못함