본문 바로가기

코딩강의/[풀스택]캐럿마켓 클론코딩(노마드코더)

#15 Realtime Chat

1. 상품 올린사람과 구매자와의 채팅방을 만들어보자

 

*prisma/schema.prisma

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

model User {
  id         Int        @id @default(autoincrement())
  username   String     @unique
  email      String?    @unique
  password   String?
  phone      String?    @unique
  github_id  String?    @unique
  avatar     String?
  created_at DateTime   @default(now())
  updated_at DateTime   @updatedAt
  tokens     SMSToken[]
  products   Product[]
  posts      Post[]
  comments   Comment[]
  likes      Like[]
  chat_rooms ChatRoom[]
  messages   Message[]
}

model SMSToken {
  id         Int      @id @default(autoincrement())
  token      String   @unique
  created_at DateTime @default(now())
  updated_at DateTime @updatedAt
  user       User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  userId     Int
}

model Product {
  id          Int    @id @default(autoincrement())
  title       String
  price       Float
  photo       String
  description String

  created_at DateTime @default(now())
  updated_at DateTime @updatedAt
  user       User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  userId     Int
}

model Post {
  id          Int     @id @default(autoincrement())
  title       String
  description String?
  views       Int     @default(0)

  created_at DateTime @default(now())
  updated_at DateTime @updatedAt

  user     User      @relation(fields: [userId], references: [id], onDelete: Cascade)
  userId   Int
  comments Comment[]
  likes    Like[]
}

model Comment {
  id      Int    @id @default(autoincrement())
  payload String

  created_at DateTime @default(now())
  updated_at DateTime @updatedAt

  user   User @relation(fields: [userId], references: [id], onDelete: Cascade)
  post   Post @relation(fields: [postId], references: [id], onDelete: Cascade)
  userId Int
  postId Int
}

model Like {
  created_at DateTime @default(now())
  updated_at DateTime @updatedAt

  user   User @relation(fields: [userId], references: [id], onDelete: Cascade)
  post   Post @relation(fields: [postId], references: [id], onDelete: Cascade)
  userId Int
  postId Int

  @@id(name: "id", [userId, postId])
}

model ChatRoom {
  id    String @id @default(cuid())
  users User[]

  created_at DateTime  @default(now())
  updated_at DateTime  @updatedAt
  messages   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])
  chatRoomId String
  userId     Int
}

 

1) ChatRoom과 Message 모델 생성

2) ChatRoom의 id는 cuid로 생성 (무작위) - 이유는 나중에 supabase 사용 시 실시간 서버 채널명이 고유이름이 되어야 하는데, 해당 ChatRoom id가 그 고유이름으로 쓰이게 된다.

 

https://github.com/nomadcoders/carrot-market-reloaded/commit/65008bc81d9c0f00c952b2b986de9a07cd5ff91e

--> 추가적으로 home/page.tsx, products/[id]/page.tsx, components/list-product.tsx 내용 일부 수정

 

*app/products/[id]/page.tsx

import db from "@/lib/db";
import { formatToWon } from "@/lib/utils";
import { UserIcon } from "@heroicons/react/24/solid";
import Image from "next/image";
import { notFound, redirect } from "next/navigation";
import {
  unstable_cache as nextCache,
  revalidatePath,
  revalidateTag,
} from "next/cache";
import getSession from "@/lib/session";

async function getIsOwner(userId: number) {
  // const session = await getSession();
  // if (session.id) {
  //   return session.id === userId;
  // }
  return false;
}

async function getProduct(id: number) {
  const product = await db.product.findUnique({
    where: {
      id,
    },
    include: {
      user: {
        select: {
          username: true,
          avatar: true,
        },
      },
    },
  });
  return product;
}

const getCachedProduct = nextCache(getProduct, ["product-detail"], {
  tags: ["product-detail"],
});

async function getProductTitle(id: number) {
  const product = await db.product.findUnique({
    where: {
      id,
    },
    select: {
      title: true,
    },
  });
  return product;
}

const getCachedProductTitle = nextCache(getProductTitle, ["product-title"], {
  tags: ["product-title"],
});

export async function generateMetadata({ params }: { params: { id: string } }) {
  const product = await getCachedProductTitle(Number(params.id));
  return {
    title: product?.title,
  };
}

export default async function ProductDetail({
  params,
}: {
  params: { id: string };
}) {
  const id = Number(params.id);
  if (isNaN(id)) {
    return notFound();
  }
  const product = await getCachedProduct(id);
  if (!product) {
    return notFound();
  }
  const isOwner = await getIsOwner(product.userId);
  const revalidate = async () => {
    "use server";
    revalidateTag("xxxx");
  };
  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}`);
  };
  return (
    <div className="pb-40">
      <div className="relative aspect-square">
        <Image
          className="object-cover"
          fill
          src={`${product.photo}`}
          alt={product.title}
        />
      </div>
      <div className="p-5 flex items-center gap-3 border-b border-neutral-700">
        <div className="size-10 overflow-hidden rounded-full">
          {product.user.avatar !== null ? (
            <Image
              src={product.user.avatar}
              width={40}
              height={40}
              alt={product.user.username}
            />
          ) : (
            <UserIcon />
          )}
        </div>
        <div>
          <h3>{product.user.username}</h3>
        </div>
      </div>
      <div className="p-5">
        <h1 className="text-2xl font-semibold">{product.title}</h1>
        <p>{product.description}</p>
      </div>
      <div className="fixed w-full bottom-0  p-5 pb-10 bg-neutral-800 flex justify-between items-center max-w-screen-sm">
        <span className="font-semibold text-xl">
          {formatToWon(product.price)}원
        </span>
        {isOwner ? (
          <form action={revalidate}>
            <button className="bg-red-500 px-5 py-2.5 rounded-md text-white font-semibold">
              Revalidate title cache
            </button>
          </form>
        ) : null}
        <form action={createChatRoom}>
          <button className="bg-orange-500 px-5 py-2.5 rounded-md text-white font-semibold">
            채팅하기
          </button>
        </form>
      </div>
    </div>
  );
}

export async function generateStaticParams() {
  const products = await db.product.findMany({
    select: {
      id: true,
    },
  });
  return products.map((product) => ({ id: product.id + "" }));
}

 

1) 채팅방으로 들어가는 부분 추가

 

*app/chats/[id]/page.tsx

import ChatMessagesList from "@/components/chat-messages-list";
import db from "@/lib/db";
import getSession from "@/lib/session";
import { Prisma } from "@prisma/client";
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) {
      return null;
    }
  }
  return room;
}

async function getMessages(chatRoomId: string) {
  const messages = await db.message.findMany({
    where: {
      chatRoomId,
    },
    select: {
      id: true,
      payload: true,
      created_at: true,
      userId: true,
      user: {
        select: {
          avatar: true,
          username: true,
        },
      },
    },
  });
  return messages;
}

async function getUserProfile() {
  const session = await getSession();
  const user = await db.user.findUnique({
    where: {
      id: session.id!,
    },
    select: {
      username: true,
      avatar: true,
    },
  });
  return user;
}

export type InitialChatMessages = Prisma.PromiseReturnType<typeof getMessages>;

export default async function ChatRoom({ params }: { params: { id: string } }) {
  const room = await getRoom(params.id);
  if (!room) {
    return notFound();
  }
  const initialMessages = await getMessages(params.id);
  const session = await getSession();
  const user = await getUserProfile();
  if (!user) {
    return notFound();
  }
  return (
    <ChatMessagesList
      chatRoomId={params.id}
      userId={session.id!}
      username={user.username}
      avatar={user.avatar!}
      initialMessages={initialMessages}
    />
  );
}

 

1) 채팅방 세부 화면

2) 세션을 통해, 채팅방 입장 시 본인 인지 검증

 

 

*components/chat-messages-list.tsx

"use client";

import { InitialChatMessages } from "@/app/chats/[id]/page";
import { saveMessage } from "@/app/chats/actions";
import { formatToTimeAgo } from "@/lib/utils";
import { ArrowUpCircleIcon } from "@heroicons/react/24/solid";
import { RealtimeChannel, createClient } from "@supabase/supabase-js";
import Image from "next/image";
import { useEffect, useRef, useState } from "react";

const SUPABASE_PUBLIC_KEY =
  "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImJmaHZzbHpsbnp6eXRmd21jd3dzIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MTE1MzIwMDYsImV4cCI6MjAyNzEwODAwNn0.6_CBGkLSb8hl06prsZkzsUrf98pjwcwgXgqeFLNiXL0";
const SUPABASE_URL = "https://bfhvslzlnzzytfwmcwws.supabase.co";

interface ChatMessageListProps {
  initialMessages: InitialChatMessages;
  userId: number;
  chatRoomId: string;
  username: string;
  avatar: string;
}
export default function ChatMessagesList({
  initialMessages,
  userId,
  chatRoomId,
  username,
  avatar,
}: ChatMessageListProps) {
  const [messages, setMessages] = useState(initialMessages);
  const [message, setMessage] = useState("");
  const channel = useRef<RealtimeChannel>();
  const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    const {
      target: { value },
    } = event;
    setMessage(value);
  };
  const onSubmit = async (event: React.FormEvent) => {
    event.preventDefault();
    setMessages((prevMsgs) => [
      ...prevMsgs,
      {
        id: Date.now(),
        payload: message,
        created_at: new Date(),
        userId,
        user: {
          username: "string",
          avatar: "xxx",
        },
      },
    ]);
    channel.current?.send({
      type: "broadcast",
      event: "message",
      payload: {
        id: Date.now(),
        payload: message,
        created_at: new Date(),
        userId,
        user: {
          username,
          avatar,
        },
      },
    });
    await saveMessage(message, chatRoomId);
    setMessage("");
  };
  useEffect(() => {
    const client = createClient(SUPABASE_URL, SUPABASE_PUBLIC_KEY);
    channel.current = client.channel(`room-${chatRoomId}`);
    channel.current
      .on("broadcast", { event: "message" }, (payload) => {
        setMessages((prevMsgs) => [...prevMsgs, payload.payload]);
      })
      .subscribe();
    return () => {
      channel.current?.unsubscribe();
    };
  }, [chatRoomId]);
  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
              src={message.user.avatar!}
              alt={message.user.username}
              width={50}
              height={50}
              className="size-8 rounded-full"
            />
          )}
          <div
            className={`flex flex-col gap-1 ${
              message.userId === userId ? "items-end" : ""
            }`}
          >
            <span
              className={`${
                message.userId === userId ? "bg-neutral-500" : "bg-orange-500"
              } p-2.5 rounded-md`}
            >
              {message.payload}
            </span>
            <span className="text-xs">
              {formatToTimeAgo(message.created_at.toString())}
            </span>
          </div>
        </div>
      ))}
      <form className="flex relative" onSubmit={onSubmit}>
        <input
          required
          onChange={onChange}
          value={message}
          className="bg-transparent rounded-full w-full h-10 focus:outline-none px-5 ring-2 focus:ring-4 transition ring-neutral-200 focus:ring-neutral-50 border-none placeholder:text-neutral-400"
          type="text"
          name="message"
          placeholder="Write a message..."
        />
        <button className="absolute right-0">
          <ArrowUpCircleIcon className="size-10 text-orange-500 transition-colors hover:text-orange-300" />
        </button>
      </form>
    </div>
  );
}

 

1) npm install @supabase/supabase-js 설치, supabase에는 실시간 서버 서비스가 있다. 프로세스는

A와 B가 있을 경우, A가 supabase에 실시간 서버를 만들면 B는 거기에 들어가고, 해당 실시간 서버에서 주고받는 데이터는 그 실시간 서버방에 있는 사람들끼리 send할 수 있는 것이다. 나의 서비스에는 이 데이터를 실시간으로 화면에 보여줄 수 있고, 새로고침하면 해당 데이터가 사라지기 때문에 해당 값을 db에 저장해주어야 새로고침했을 때도 데이터가 그대로 화면에 보여지게 된다.

 

2) 처음 유저가 해당 방에 들어오면, useEffect를 통해 supabase 고유 채널을 useRef를 통해 channel 이름으로 선언해준다. 그리고 subscribe 해준다. (만약 해당 페이지가 닫히면 unsubscribe됨)

이 때, setMessages를 통해 supabase 실시간 서버에서 들어오는 payload값이 실시간으로 업데이트 되게 해준다.

 

3) 이전 useOptimistic과 비슷하게 form input 값을 onSubmit하게 되면 setMessages값에 db로 가기전에 데이터 값을 화면에 보여준 후, 위에 연결한 supabase 채널에 데이터를 send해주고 db에 값을 저장한다. 그리고 setMessage값을 ("") 해준다. (채팅 입력 칸 초기화)

 

*app/chats/actions.ts

"use server";

import db from "@/lib/db";
import getSession from "@/lib/session";

export async function saveMessage(payload: string, chatRoomId: string) {
  const session = await getSession();
  await db.message.create({
    data: {
      payload,
      chatRoomId,
      userId: session.id!,
    },
    select: { id: true },
  });
}

 

1) db에 메시지를 저장해주어야지, 채팅방 새로고침 후 초기 데이터를 전달 할 수 있다.

 

***추가로 아래를 만들어야 한다. (다른 학우분들꺼 참조)

1. 채팅 목록 만들기 (대화 상대 아바타, 이름, 최근 메시지 표시)
2. 읽지 않은 메시지가 있다면 채팅 목록에 표시

 

https://nomadcoders.co/carrot-market/lectures/4855

'코딩강의 > [풀스택]캐럿마켓 클론코딩(노마드코더)' 카테고리의 다른 글

#17 NextJS Extras  (0) 2024.08.10
#16 Live Streaming  (0) 2024.08.09
#14 Optimistic Updates  (0) 2024.08.07
#13 Caching  (0) 2024.08.04
#12 Modals  (0) 2024.08.01