자, 채팅방에 사용할 model를 정의해보자
우선 model chatRoom -> 채팅방에서 사용될 model들을 정의해보자
model ChatRoom {
id String @id @default(cuid())
created_at DateTime @default(now())
updated_at DateTime @updatedAt
users User[]
messages Message[]
}
채팅방도 고유한 id가 필요하다. 그런데 이전에는 @default(autoincrement()) 를 거의 주로 사용했었다 (1씩 증가하는 id)
하지만, 이는 보안상의 문제로 채팅방의 id에서는 사용하지 않을 것이다 -> cuid()를 사용하게 되는데, cuid()는 생성 시간과 랜덤한 요소를 조합하여 고유성을 보장하며, 이를 통해 ID의 예측 가능성을 줄여주게 된다.
또한, 채팅이 시간이나 업데이트 시간은 필요하고, chatRoom에 참여하는 users,chatRoom에서 주고 받게 될 messages를 포함시켰다. 
한명의 user는 여러 채팅방에 들어갈 수 있고, 하나의 채팅방에서는 여러 user들이 들어올 수 있기 떄문에 다대다 관계를 나타내고 있다.
그럼 이제 model Message에 대해서 살펴보자
model Message {
id Int @id @default(autoincrement())
payload String
created_at DateTime @default(now())
updated_at DateTime @updatedAt
room ChatRoom @relation(fields: [chatRoomId], references: [id])
user User @relation(fields: [userId], references: [id])
userId Int
chatRoomId String
}
message의 id, 댓글을 의미하는 payload, message를 작성한 시간, 업데이트 한 시간,
message가 오고 갈 chatRoom , message를 주고 받게 될 User가 포함되고 있다.
chatRoom ---- Messsage 일대다 관계
User ---- Message 일대다 관계
(하나의 채팅방에서 여러 메시지를 주고 받게 되고, 한명의 user가 여러 메시지를 보내게 된다)
우리는 채팅하기 버튼을 누르면, 데이터베이스에 chatRoom이 하나 생성이 되면서 -> /chats/[id]로 이동이 되어야 한다.
저 채팅하기 버튼을 form 태그로 감싼 button으로 만들어 줄 것이다.
<form action={createChatRoom}>
<button className="bg-orange-500 px-5 py-2.5 rounded-md text-white font-semibold">
채팅하기
</button>
</form>
왜 Link가 아니고 button이지 ? 이건 단순히 페이지 이동이 아닌 채팅하기를 눌렀을 때, chatRoom이라는 하나의 데이터베이스를 생성한 후, 그 생성했을 때의 id를 받아서
/chats/[id]로 이동할 것이기 때문이다.
그럼 createChatRoom을 생성해보자
const createChatRoom = async () => {
'use server';
const session = await getSession();
const room = await db.chatRoom.create({
data: {
users: {
connect: [
{
id: product.userId,
},
{
id: session.id,
},
],
},
},
select: {
id: true,
},
});
redirect(`/chats/${room.id}`);
};
자 여기서 해야할 것은 ? -> 글을 올린 판매자 (예를 들어서)와 지금 로그인한 사용자가 함께 chatRoom에 추가해주어야 한다.
고로 create -> data에 users를 추가한다 -> connet(연결해서) -> 누구랑? -> 제품을 업로드한 user의 id와, 로그인한 사용자의 id를 ! 이라고 생각해주면 된다.
이렇게 db.chatRoom.create를 통해서 하나의 chatRoom이 생성이 된다면, room.id를 받아서 chats/[room.id]를 넘겨주면 된다!
chatRoom에서는 이전에 model를 생성한 것을 살펴보면, id, user (글 올린사람, 로그인한 사용자) 이 두개만 신경쓰고 생성해주면 된다
자, 이제 chats/[room.id] page.tsx를 생성해주자
app/chats/[id]/page.tsx를 생성하면 된다.
자, 여기서 해야할 것은, 생성된 chatRoom에 대해서 정보를 가져오는 것,
이전에 chatRoom에서 생성할 때, users를 넘겨주었기 때문에, 이 users를 가지고 이 room이 실제로 허용되는 users인지 확인을 해야 한다.
이 room은 공개적인 것이 아니기 때문이다. (모든 사람을 위한게 아님) -> 사용자가 실제로 여기에 허용되는지 확인을 해야한다.
import db from '@/lib/db';
import getSession from '@/lib/session';
import { notFound } from 'next/navigation';
async function getRoom(id: string) {
const room = await db.chatRoom.findUnique({
where: {
id,
},
include: {
users: {
select: { id: true },
},
},
});
if (room) {
const session = await getSession();
const canSee = Boolean(room.users.find((user) => user.id === session.id));
if (!canSee) {
//즉 현재 로그인한 사용자의 id와 같은 것을 가진 object가 room의 users array에 없다면, 메시지를 볼 수 없음.
return null;
}
}
return room;
}
export default async function ChatRoom({ params }: { params: { id: string } }) {
const room = await getRoom(params.id);
if (!room) {
return notFound();
}
return <h1>chat!</h1>;
}
getRoom() 을 살펴보면, include 옵션으로 users를 포함시키며, 각 사용자의 id만 선택하여 가져온다. 즉, 해당 채팅방에 속한 사용자들의 id 목록을 가져오게 된다.
채팅방(room)이 존재하는지 확인한 후, getSession 함수를 호출하여 현재 로그인한 사용자의 세션 정보를 가져온다.
room.users 배열에서 현재 세션의 id와 일치하는 사용자가 있는지 확인한다.
find 메서드는 조건에 맞는 첫 번째 사용자를 반환하며, 찾지 못하면 undefined를 반환한다.
Boolean() 함수는 이 값을 true 또는 false로 변환한다. 즉, canSee는 현재 사용자가 채팅방에 속해있는지 여부를 나타낸다 --> 만약 canSee가 false라면, 즉 현재 사용자가 채팅방에 속해 있지 않다면, null을 반환하여 채팅방 정보를 볼 수 없게 한다.
이렇게 room을 return 한 getRoom()에 params로 받아온 id를 넘겨주면 된다!
그럼 이렇게 기존의 1,2 와 같이 숫자가 아닌 어려운 글자의 조합으로 [id]가 생성된 것을 확인할 수 있다.
간단하게, 데이터베이스에 message를 추가하고 우선 화면에 불러오도록 해보겠다.
async function getMessages(chatRoomId: string) {
const message = await db.message.findMany({
where: {
chatRoomId,
},
select: {
id: true,
payload: true,
created_at: true,
userId: true,
user: {
select: {
avatar: true,
username: true,
},
},
},
});
return message;
}
특정 chatRoomId 해당하는 message를 모두 불러오도록 했다.
chatRoomId에 해당하는 message를 가져오고 , 그 message는 initialMessage가 된다.
initialMessage ????? -> 우리가 채팅창에 들어갔을 때, 이전에 대화를 나눴던 기록들을 미리 뿌려주는 역할을 한다고 생각하면 된다.
이때, 새로 입력한 채팅은 state로 연결해서 initialMessage에 추가하는 방식으로 진행해볼 예정이다.

getMessages를 호출해서 initialMessages를 얻은 뒤, <ChatMessageList/>에 보내주었다.
여기서 userId는 왜 보내줬을 까? -> 채팅을 한번에 불러오게 되면, 로그인한 유저와, 상품의 주인인 유저 두명의 유저를 구분을 하지 못하기 때문에,
로그인 (session.id)한 유저의 message는 따로 표시하기 위해서이다!
chat/[id]/page.tsx에서는 데이터를 불러오는 공간이기 때문에, message를 불러오는 client 컴포넌트를 따로 생성해 주었다.
<ChatMessageList/>
여기서 해야할 것은 -> initialMessages를 가져와서 실시간 서버에 연결하고, 그리고 새로운 message가 들어오면 state에 추가하는 것이다!
'use client';
import { InitialChatMessages } from '@/app/chats/[id]/page';
import { formatToTimeAgo } from '@/lib/utils';
import Image from 'next/image';
import { useState } from 'react';
interface IChatMessageProps {
initialMessages: InitialChatMessages;
userId: number;
}
export default function ChatMessageList({
initialMessages,
userId,
}: IChatMessageProps) {
const [messages, setMessage] = useState(initialMessages);
return (
<>
<div className="p-5 flex flex-col gap-5 min-h-screen justify-end">
{messages.map((message) => (
<div
key={message.id}
className={`flex gap-2 items-start ${
message.userId === userId ? 'justify-end' : ''
}`}
>
{message.userId === userId ? null : (
<Image
className="rounded-full size-8"
src={message.user?.avatar || '/default-avatar.png'}
alt={message.user?.username || 'Unknown User'}
width={30}
height={30}
/>
)}
<div
className={`flex flex-col gap-1 ${
message.userId === userId ? 'items-end' : ''
}`}
>
<span
className={`${
message.userId === userId
? 'bg-neutral-400 text-neutral-50 p-2.5 rounded-md'
: 'bg-orange-500 p-2.5 rounded-md'
}`}
>
{message.payload}
</span>
<span className="text-xs">
{formatToTimeAgo(message.created_at.toString())}
</span>
</div>
</div>
))}
</div>
</>
);
}
message.userId 가 userId와 같은지 확인하고, 같다면, 다른 css 속성을 주어서 채팅방에서 message를 나누어 주었다.
우선 간단하게 생성해본 최종 완성본이다!
✚ input 생성하기 ✚
form 태그 안에 input과 button을 생성해서 form을 제출하면서 메시지를 보내도록 코드를 작성해 보았다.
여기서 살펴봐야할 것은, messages (메시지들의 묶음), message(한개의 message)를 관리를 할 예정이다.
여기서 onchange() 함수에서는, event.target.value를 가져와 input에 입력된 value를 변경시켜 주었다. input에 입력된 값을 setMessage에 추가해주었다.
onSubmit() 함수의 경우에는 form을 제출하는 함수인데,
여기서 제출을 하면 -> setMessages()에 추가가 되어야 한다 -> 메시지 배열에 합류! 한다고 생각하면 된다.
여기서 해야할 것은 -> 기본적으로 메시지가 전송되었다는 것을 가짜로 생성하는것 -> 메시지 state에 새 메시지를 추가하는 것 뿐이다
우선 데이터를 저장하기 이전에, 메시지를 입력한 것을 화면에 띄워주면서 메시지가 전송되었다는 것을 나타내는 것이라고 생각하면 된다.
(동적으로 message를 추가하는 것을 원하기 때문에 client component임)
setMessages() 배열에 이전에 저장된 messages들 (prevMsgs) 와 새 message를 추가해 주어야 하지만, typescript는 어떤 형태의 message인지를 알기 때문에, message의 타입을 입력해주고 이러한 형태의 message를 추가하겠다! 라고 타입스크립트에게 알려주면 된다.
이렇게 하게 되면
입력한 메시지가 우선은 전송에 성공한 것처럼 보여지게 된다.
이제 우리는 supabase를 사용해 볼 시간이다.
supabase의 broadcast 를 사용하면 실시간 채팅을 구현할 수 있다고 한다.
Supabase의 Broadcast는 클라이언트 간의 간단한 실시간 메시징 기능을 제공하는 서비스이다. Broadcast는 기본적으로 Pub/Sub(Publish/Subscribe) 패턴을 사용하여 클라이언트들 사이에서 메시지를 주고받을 수 있게 한다. 이는 예를 들어, 채팅 애플리케이션이나 협업 도구에서 다수의 사용자가 실시간으로 데이터를 공유해야 할 때 유용하다.
우선 먼저, supabase의 개인 회원가입을 하고, 새로운 project를 생성한다.
그후, supabase가 제공하는 public_key와 supabase_url을 상수로 저장을 해두면 된다. 
이제 공식 홈페이지에 설명되어있는 대로 천천히 진행해보자!

먼저 위에서 public_key와 supabase_url를 상수로 지정해 주었고, 다음은 Supabase의 Broadcast 기능을 사용하여 특정 채널에 실시간으로 연결하고, 메시지를 수신하는 로직을 구현하면 된다.
useEffect(() => {
const client = createClient(SUPABASE_URL, SUPABASE_PUBLIC_KEY);
const channel = client.channel(`room -${chatRoomId}`);
channel.on(
'broadcast',
{
event: 'message',
},
(payload) => {
console.log(payload);
}
);
}, []);
createClient
먼저, createClient() 를 통해서 Supabase 클라이언트를 생성해 주었다
-> 이 클라이언트는 이후 Supabase 서비스와 통신하는 데 사용된다.
채널 생성
const channel = client.channel(room-${chatRoomId});
client.channel 은 지정된 이름의 채널에 연결하는 메서드이다.
room-${chatRoomId} 은 채널의 이름이며, 동적으로 주어진 chatRoomId 값을 사용하여 특정 채팅방에 해당하는 채널을 생성하거나 연결하게 된다.
결국은 하나의 채팅방에 연결된다는 것을 의미한다고 이해하면 된다!
채널 구독 및 메시지 수신
channel.on 메서드는 채널에서 특정 이벤트를 구독하는 역할을 한다.
첫 번째 인자 'broadcast'는 메시지가 브로드캐스트(다른 모든 구독자에게 전송)될 때 구독할 이벤트의 유형이다.
두 번째 인자 { event: 'message' }는 구체적인 이벤트 이름을 지정한다. 이 경우, 'message' 이벤트를 구독하게 된다.
세 번째 인자는 이벤트가 발생했을 때 실행될 콜백 함수이다.
--> 여기서 payload는 수신된 메시지의 데이터를 포함하며, console.log(payload);를 통해 이를 콘솔에 출력하게 된다.
이 코드는 채팅방에서 메시지가 오면 그 메시지를 화면에 보여주거나 콘솔에 출력하는 기능을 합니다.
이벤트 이름 지정:
event: 'message' 라는 부분은, 이 코드를 통해 채팅방에서 오고 가는 메시지를 '이벤트'로 다루고 있다고 생각하면 된다. 여기서 'message'라는 이름을 붙여준 것이다.
메시지 받기:
이 코드는 채팅방에서 누군가가 메시지를 보내면 그 메시지를 자동으로 받게 된다.
예를 들어, 다른 사람이 "안녕하세요"라는 메시지를 보내면, 이 코드가 그 메시지를 받아서 우리가 지정한 작업을 수행하게 되는 것이다 -> 현재로는 지정한 작업이라는 것이 바로 console.log를 의미한다고 생각하면 된다.
supabase를 초기화 하고, channel에 참여하는 것까지 완성했다.
만약, user가 채팅방을 나갔다면, (채널을 떠나면) subscribe를 계속 유지하고 싶은 생각은 없을 것이다.
channel.current.unsubscribe()를 통해 subscribe()를 하던 것을 취소시켜주었다.
subscribe() 메서드는 Supabase 채널에서 이벤트를 구독하기 위해 사용된다.
--> 이 메서드는 채널을 활성화하여, 해당 채널에서 발생하는 이벤트를 수신할 수 있게 한다.
이전에, onSubmit() 함수를 제출할 때, setMessages()에 prevMsg와 새로운 메시지를 추가해서 화면에 메시지가 전송된 것처럼 보여주었다.
이제 setMessages()에 저장시켰던 것을 channel에도 전송시켜야 한다.
이때, useRef()를 사용하려고 한다.
const channel = useRef<RealtimeChannel>()
useRef는 React 훅 중 하나로, 컴포넌트의 라이프사이클 동안 변경 가능한 값을 유지할 수 있게 한다.
useRef는 .current 라는 속성을 가지고 있으며, 이 속성에 원하는 값을 저장할 수 있다.
여기서는 channel이라는 변수를 useRef를 통해 정의하고, 타입을 RealtimeChannel로 설정했다.
--> 이 RealtimeChannel은 Supabase의 실시간 채널을 나타낸다.
여기서 중요한 것은,
여기서 channel.current는 useRef에서 정의한 channel를 사용한 것이다.
이제, onSubmit()을 할 때-> channel.current.send()를 통해 message를 channel에 저장해보도록 하겠다.
channel.current.send({}) 를 통해서 message를 저장해 주었다.
channel.current :
channel은 useRef 훅으로 생성된 참조 객체이다.--> channel.current는 Supabase의 실시간 채널(RealtimeChannel) 객체를 가리키게 된다
.
?.는 optional chaining 연산자입니다. channel.current가 존재할 때만 send 메서드를 호출하게 된다. 만약 channel.current가 null 또는 undefined라면, 이 줄은 아무 작업도 수행하지 않고 넘어가게 된다.
--> 이를 통해 channel.current가 아직 초기화되지 않았거나, 존재하지 않을 때 발생할 수 있는 오류를 방지할 수 있다.
send 메서드 :
send 메서드는 실시간 채널에 메시지를 전송하는 기능을 한다. 이 메시지는 채널에 연결된 다른 모든 클라이언트로 브로드캐스트된다.
type: 'broadcast' :
이 필드는 메시지의 유형을 지정한다.
'broadcast'는 이 메시지가 실시간으로 채널에 연결된 모든 사용자에게 전송된다는 것을 의미한다.
event: 'message' :
이 필드는 메시지의 이벤트 이름을 지정한다.
여기서는 'message' 이벤트로 지정했다 -> 이 이름은 메시지를 수신하는 쪽에서 어떤 종류의 메시지인지를 구분하는 데 사용된다.
payload: { message }:
payload는 전송할 실제 데이터이다. 여기서는 message라는 변수에 담긴 내용을 payload로 보내고 있다.
이 payload 객체는 수신자 쪽에서 사용할 데이터로, 예를 들어, 채팅 애플리케이션에서는 message가 사용자가 입력한 채팅 메시지가 될 것이다.
아직 화면에는 나타나지는 않지만, 서로 메시지가 전송이 되는지 확인해볼 시간이다.
이렇게 메시지를 보내면, 상대방의 console를 확인해보면
이렇게 message가 뜨는 것을 확인할 수 있다.
event : 'message', payload : {message : '그럼 한개만..'}, type : 'broadcast' 이러한 정보들을 모두 확인할 수 있다.
여기 message에 담긴 정보를 살펴보면, 우리가 setMessages를 할 때, 새로운 Message추가하려고 할 때 message에 해당하는 정보들을 모두 보내줬었다.

이 또한, channel.currnet.send({})를 할 때, 위와 같이 message에 정보를 모두 추가해주서야 한다.
이처럼, 나머지는 비슷하게 추가해주면 되지만, user에 포함되어있는 username, avatar 정보를 추가해주어야 한다.
그렇기 떄문에-> chat/[id]/page.tsx에서 user에 대한 정보를 불러다와야 한다.
async function getUserProfile() {
const session = await getSession();
const user = await db.user.findUnique({
where: {
id: session.id,
},
select: {
username: true,
avatar: true,
},
});
return user;
}
이렇게 보내주고,
username과 avatar를 <ChatMessageList/>에 넘겨주었다!
요렇게 추가한 상태에서 다시 메시지를 보내보도록 하겠다!
내가 지정한 message에 필요한 정보들이 함께 출력되는 것을 확인할 수 있다! 굳!