김마드 2025. 5. 21. 10:37

> 채팅방을 만들어보자

 

메시지를 보내려고 할 때 기존 채팅방이 없으면 새로 만들어주고, 있으면 기존 채팅방에 메시지를 보내는 방법이다.

 

- get_room.sql

CREATE FUNCTION public.get_room(from_user_id uuid, to_user_id uuid)
RETURNS TABLE (message_room_id bigint)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
BEGIN
    RETURN QUERY SELECT m1.message_room_id
    FROM message_room_members m1
    INNER JOIN message_room_members m2
    ON m1.message_room_id = m2.message_room_id
    WHERE m1.profile_id = from_user_id
    AND m2.profile_id = to_user_id;
END;
$$;

 

- mutations.ts

export const sendMessage = async (
  client: SupabaseClient<Database>,
  {
    fromUserId,
    toUserId,
    content,
  }: { fromUserId: string; toUserId: string; content: string }
) => {
  const { data, error } = await client
    .rpc("get_room", {
      from_user_id: fromUserId,
      to_user_id: toUserId,
    })
    .maybeSingle();
  if (error) {
    throw error;
  }
  if (data?.message_room_id) {
    await client.from("messages").insert({
      message_room_id: data.message_room_id,
      sender_id: fromUserId,
      content,
    });
    return data.message_room_id;
  } else {
    const { data: roomData, error: roomError } = await client
      .from("message_rooms")
      .insert({})
      .select("message_room_id")
      .single();
    if (roomError) {
      throw roomError;
    }
    await client.from("message_room_members").insert([
      {
        message_room_id: roomData.message_room_id,
        profile_id: fromUserId,
      },
      {
        message_room_id: roomData.message_room_id,
        profile_id: toUserId,
      },
    ]);
    await client.from("messages").insert({
      message_room_id: roomData.message_room_id,
      sender_id: fromUserId,
      content,
    });
    return roomData.message_room_id;
  }
};

참고로 .insert({}) 이거는 빈방을 만드는 개념이다.

 

- send-message-page.tsx

import { makeSSRClient } from "~/supa-client";
import type { Route } from "./+types/send-message-page";
import { getLoggedInUserId, getUserProfile } from "../queries";
import { sendMessage } from "../mutations";
import { z } from "zod";
import { redirect } from "react-router";

const formSchema = z.object({
  content: z.string().min(1),
});

export const action = async ({ request, params }: Route.ActionArgs) => {
  if (request.method !== "POST") {
    return Response.json({ error: "Method not allowed" }, { status: 405 });
  }
  const formData = await request.formData();
  const { client } = makeSSRClient(request);
  const fromUserId = await getLoggedInUserId(client);
  const { profile_id: toUserId } = await getUserProfile(client, {
    username: params.username,
  });
  const messageRoomId = await sendMessage(client, {
    fromUserId,
    toUserId,
    content: formData.get("content") as string,
  });
  return redirect(`/my/messages/${messageRoomId}`);
};

중간 api 페이지

 

> 채팅방 rooms 부분 데이터 가져오기

 

- messages-view.sql

CREATE OR REPLACE VIEW messages_view AS
SELECT
  m1.message_room_id,
  profiles.name,
  (
    SELECT content
    FROM messages
    WHERE message_room_id = m1.message_room_id
    ORDER BY message_id DESC
    LIMIT 1
  ) AS last_message,
  m1.profile_id AS profile_id,
  m2.profile_id AS other_profile_id,
  profiles.avatar
FROM message_room_members m1
INNER JOIN message_room_members m2 ON m1.message_room_id = m2.message_room_id
INNER JOIN profiles ON profiles.profile_id = m2.profile_id;

 

- queries.ts

export const getMessages = async (
  client: SupabaseClient<Database>,
  { userId }: { userId: string }
) => {
  const { data, error } = await client
    .from("messages_view")
    .select("*")
    .eq("profile_id", userId)
    .neq("other_profile_id", userId);
  if (error) {
    throw error;
  }
  return data;
};

 

- messages-layout.tsx

import { Outlet } from "react-router";
import {
  Sidebar,
  SidebarContent,
  SidebarGroup,
  SidebarMenu,
  SidebarProvider,
} from "~/common/components/ui/sidebar";
import MessageRoomCard from "../components/message-room-card";
import { Route } from "./+types/messages-layout";
import { makeSSRClient } from "~/supa-client";
import { getLoggedInUserId, getMessages } from "../queries";

export const loader = async ({ request }: Route.LoaderArgs) => {
  const { client } = await makeSSRClient(request);
  const userId = await getLoggedInUserId(client);
  const messages = await getMessages(client, { userId });
  return {
    messages,
  };
};

export default function MessagesLayout({ loaderData }: Route.ComponentProps) {
  return (
    <SidebarProvider className="flex max-h-[calc(100vh-14rem)] overflow-hidden h-[calc(100vh-14rem)] min-h-full">
      <Sidebar className="pt-16" variant="floating">
        <SidebarContent>
          <SidebarGroup>
            <SidebarMenu>
              {loaderData.messages.map((message) => (
                <MessageRoomCard
                  key={message.message_room_id}
                  id={message.message_room_id.toString()}
                  name={message.name}
                  lastMessage={message.last_message}
                  avatarUrl={message.avatar}
                />
              ))}
            </SidebarMenu>
          </SidebarGroup>
        </SidebarContent>
      </Sidebar>
      <div className=" h-full flex-1">
        <Outlet />
      </div>
    </SidebarProvider>
  );
}

 

> 메시지 보내기

 

- queries.ts

export const sendMessageToRoom = async (
  client: SupabaseClient<Database>,
  {
    messageRoomId,
    message,
    userId,
  }: { messageRoomId: string; message: string; userId: string }
) => {
  const { count, error: countError } = await client
    .from("message_room_members")
    .select("*", { count: "exact", head: true })
    .eq("message_room_id", messageRoomId)
    .eq("profile_id", userId);
  if (countError) {
    throw countError;
  }
  if (count === 0) {
    throw new Error("Message room not found");
  }
  const { error } = await client.from("messages").insert({
    content: message,
    message_room_id: Number(messageRoomId),
    sender_id: userId,
  });
  if (error) {
    throw error;
  }
};

 

- message-page.tsx

import {
  Card,
  CardDescription,
  CardHeader,
  CardTitle,
} from "~/common/components/ui/card";
import type { Route } from "./+types/message-page";
import {
  Avatar,
  AvatarFallback,
  AvatarImage,
} from "~/common/components/ui/avatar";
import { Form, useOutletContext } from "react-router";
import { Textarea } from "~/common/components/ui/textarea";
import { Button } from "~/common/components/ui/button";
import { SendIcon } from "lucide-react";
import { MessageBubble } from "../components/message-bubble";
import {
  getLoggedInUserId,
  getMessagesByMessagesRoomId,
  getRoomsParticipant,
  sendMessageToRoom,
} from "../queries";
import { makeSSRClient } from "~/supa-client";
import { useEffect, useRef } from "react";

export const meta: Route.MetaFunction = () => {
  return [{ title: "Message | wemake" }];
};

export const loader = async ({ request, params }: Route.LoaderArgs) => {
  const { client } = await makeSSRClient(request);
  const userId = await getLoggedInUserId(client);
  const messages = await getMessagesByMessagesRoomId(client, {
    messageRoomId: params.messageRoomId,
    userId,
  });
  const participants = await getRoomsParticipant(client, {
    messageRoomId: params.messageRoomId,
    userId,
  });
  return {
    messages,
    participants,
  };
};

export const action = async ({ request, params }: Route.ActionArgs) => {
  const { client } = await makeSSRClient(request);
  const userId = await getLoggedInUserId(client);
  const formData = await request.formData();
  const message = formData.get("message");
  await sendMessageToRoom(client, {
    messageRoomId: params.messageRoomId,
    message: message as string,
    userId,
  });
  return {
    ok: true,
  };
};

export default function MessagePage({
  loaderData,
  actionData,
}: Route.ComponentProps) {
  const { userId } = useOutletContext<{ userId: string }>();
  const formRef = useRef<HTMLFormElement>(null);
  useEffect(() => {
    if (actionData?.ok) {
      formRef.current?.reset();
    }
  }, [actionData]);
  return (
    <div className="h-full flex flex-col justify-between">
      <Card>
        <CardHeader className="flex flex-row items-center gap-4">
          <Avatar className="size-14">
            <AvatarImage src={loaderData.participants?.profile?.avatar ?? ""} />
            <AvatarFallback>
              {loaderData.participants?.profile?.name.charAt(0) ?? ""}
            </AvatarFallback>
          </Avatar>
          <div className="flex flex-col gap-0">
            <CardTitle className="text-xl">
              {loaderData.participants?.profile?.name ?? ""}
            </CardTitle>
            <CardDescription>2 days ago</CardDescription>
          </div>
        </CardHeader>
      </Card>
      <div className="py-10 overflow-y-scroll space-y-4 flex flex-col justify-start h-full">
        {loaderData.messages.map((message) => (
          <MessageBubble
            key={message.message_id}
            avatarUrl={message.sender?.avatar ?? ""}
            avatarFallback={message.sender?.name.charAt(0) ?? ""}
            content={message.content}
            isCurrentUser={message.sender?.profile_id === userId}
          />
        ))}
      </div>
      <Card>
        <CardHeader>
          <Form
            ref={formRef}
            method="post"
            className="relative flex justify-end items-center"
          >
            <Textarea
              placeholder="Write a message..."
              rows={2}
              required
              name="message"
              className="resize-none"
            />
            <Button type="submit" size="icon" className="absolute right-2">
              <SendIcon className="size-4" />
            </Button>
          </Form>
        </CardHeader>
      </Card>
    </div>
  );
}

여기까지만 하면, 내가 form에 데이터를 전송할때 화면이 revalidation 되면서 입력한 값이 보여진다. 하지만 상대방은 새로고침 하지 않는한 그대로 이기 때문에, 실시간 전송하는 방법을 아래 이어서 해볼 것이다.

 

> 메시지 실시간 연동

 

먼저 supabase에서 세팅을 해야한다. supabase -> database -> publications 에 가면 아래 supabase_realtime 1개만 보일 것이다. (위에꺼는 안보임). 거기에서 table을 누르고 내가 원하는 (messages) 테이블을 on 스위치 해주면 된다.

 

 

- message-page.tsx

import {
  Card,
  CardDescription,
  CardHeader,
  CardTitle,
} from "~/common/components/ui/card";
import type { Route } from "./+types/message-page";
import {
  Avatar,
  AvatarFallback,
  AvatarImage,
} from "~/common/components/ui/avatar";
import { Form, useOutletContext } from "react-router";
import { Textarea } from "~/common/components/ui/textarea";
import { Button } from "~/common/components/ui/button";
import { SendIcon } from "lucide-react";
import { MessageBubble } from "../components/message-bubble";
import {
  getLoggedInUserId,
  getMessagesByMessagesRoomId,
  getRoomsParticipant,
  sendMessageToRoom,
} from "../queries";
import { browserClient, type Database, makeSSRClient } from "~/supa-client";
import { useEffect, useRef, useState } from "react";

export const meta: Route.MetaFunction = () => {
  return [{ title: "Message | wemake" }];
};

export const loader = async ({ request, params }: Route.LoaderArgs) => {
  const { client } = await makeSSRClient(request);
  const userId = await getLoggedInUserId(client);
  const messages = await getMessagesByMessagesRoomId(client, {
    messageRoomId: params.messageRoomId,
    userId,
  });
  const participant = await getRoomsParticipant(client, {
    messageRoomId: params.messageRoomId,
    userId,
  });
  return {
    messages,
    participant,
  };
};

export const action = async ({ request, params }: Route.ActionArgs) => {
  const { client } = await makeSSRClient(request);
  const userId = await getLoggedInUserId(client);
  const formData = await request.formData();
  const message = formData.get("message");
  await sendMessageToRoom(client, {
    messageRoomId: params.messageRoomId,
    message: message as string,
    userId,
  });
  return {
    ok: true,
  };
};

export default function MessagePage({
  loaderData,
  actionData,
}: Route.ComponentProps) {
  const [messages, setMessages] = useState(loaderData.messages);
  const { userId, name, avatar } = useOutletContext<{
    userId: string;
    name: string;
    avatar: string;
  }>();
  const formRef = useRef<HTMLFormElement>(null);
  useEffect(() => {
    if (actionData?.ok) {
      formRef.current?.reset();
    }
  }, [actionData]);
  useEffect(() => {
    const changes = browserClient
      .channel(`room:${userId}-${loaderData.participant?.profile?.profile_id}`)
      .on(
        "postgres_changes",
        {
          event: "INSERT",
          schema: "public",
          table: "messages",
        },
        (payload) => {
          setMessages((prev) => [
            ...prev,
            payload.new as Database["public"]["Tables"]["messages"]["Row"],
          ]);
        }
      )
      .subscribe();
    return () => {
      changes.unsubscribe();
    };
  }, []);
  return (
    <div className="h-full flex flex-col justify-between">
      <Card>
        <CardHeader className="flex flex-row items-center gap-4">
          <Avatar className="size-14">
            <AvatarImage src={loaderData.participant?.profile?.avatar ?? ""} />
            <AvatarFallback>
              {loaderData.participant?.profile?.name.charAt(0) ?? ""}
            </AvatarFallback>
          </Avatar>
          <div className="flex flex-col gap-0">
            <CardTitle className="text-xl">
              {loaderData.participant?.profile?.name ?? ""}
            </CardTitle>
            <CardDescription>2 days ago</CardDescription>
          </div>
        </CardHeader>
      </Card>
      <div className="py-10 overflow-y-scroll space-y-4 flex flex-col justify-start h-full">
        {messages.map((message) => (
          <MessageBubble
            key={message.message_id}
            avatarUrl={
              message.sender_id === userId
                ? avatar
                : loaderData.participant?.profile?.avatar ?? ""
            }
            avatarFallback={
              message.sender_id === userId
                ? name.charAt(0)
                : loaderData.participant?.profile.name.charAt(0) ?? ""
            }
            content={message.content}
            isCurrentUser={message.sender_id === userId}
          />
        ))}
      </div>
      <Card>
        <CardHeader>
          <Form
            ref={formRef}
            method="post"
            className="relative flex justify-end items-center"
          >
            <Textarea
              placeholder="Write a message..."
              rows={2}
              required
              name="message"
              className="resize-none"
            />
            <Button type="submit" size="icon" className="absolute right-2">
              <SendIcon className="size-4" />
            </Button>
          </Form>
        </CardHeader>
      </Card>
    </div>
  );
}

export const shouldRevalidate = () => false;

1) form을 제출하면 자동으로 revalidate됨. 이를 방지하려면 맨 아래 shouldRevalidate 부분을 false로 처리

2) 브라우저 단에서 subscribe해야 하고, 위 세팅대로 하게 되면, messages 테이블을 모두 공유 하기 때문에 보안에 문제가 생김. (이 문제 해결은 아마 다음 강의에서 다룰 것 같음)

 

> RLS (Row Level Security) 관리

 

위 실시간 채팅 구현 같은경우에는 브라우저단에서 접근해야하기 때문에 SUPABASE URL과 ANON_KEY가 노출된다.

그렇기 때문에 누구든지 내 DB에 접근 할 수 있는데, 이를 권한설정으로 막아야 한다.

이를 위해 RLS를 각 테이블별로 설정하도록 하자.

supabase db에서 RLS를 통해 각 테이블별로 접근 권한 설정을 할 수 있다. (만약 설정 안하면 모두 public 된 상태)

 

각 테이블 별로 아래 버튼이 있고, 해당 버튼을 클릭하고 설정을 진행 하면 된다.

 

이 때, update 시에는 보통 using과 with check을 동시에 사용 한다.

 

 

하지만 INSERT에는 아래와 같은 이유로 using은 필요가 없다.

 

> Security Definer 관리

 

security advisor 화면을 보면 errors 탭에 Security Definer View와 RLS~~가 있다. RLS부분은 위에서 다룬 내용이고, Security Definer만 해결하면 된다.

 

 

해당 에러는 우리가 sql 구문에서 설정한 View에 권한 설정 때문에 나오는 것이다. 기본적으로 View를 생성할 때 기본 권한은 admin이다 (관리자가 해당 View를 만들었기 때문에 해당 View에 대한 접근 권한은 admin) 이를 해당 View를 호출한 사람 기준으로 바꿔주면 된다.

아래와 같이, with ~~ 구문만 추가해주면 된다.

CREATE OR REPLACE VIEW community_post_list_view 
with (security_invoker=on)
AS
SELECT
  posts.post_id,
  posts.title,
  posts.created_at,
  topics.name AS topic,
  profiles.name AS author,
  profiles.avatar AS author_avatar,
  profiles.username AS author_username,
  posts.upvotes,
  topics.slug AS topic_slug,
  (SELECT EXISTS (SELECT 1 FROM public.post_upvotes WHERE post_upvotes.post_id = posts.post_id AND post_upvotes.profile_id = auth.uid())) AS is_upvoted
FROM posts
INNER JOIN topics USING (topic_id)
INNER JOIN profiles USING (profile_id);

 

> Function Search Path

 

아래와 같이 함수에 대해 search path에러가 뜬다. search path는 어떤 스키마에서 데이터를 찾는지 정해주는 것인데, 기본적으로 아래 코드와 같이 '' 빈 문자열을 넣어준다. 만약 이렇게 빈 문자열을 넣어주게 되면, 테이블 값에 스키마명 (public을 적어주어야한다) 이렇게 되면 해당 에러는 사라진다.

CREATE OR REPLACE FUNCTION public.get_room(from_user_id uuid, to_user_id uuid)
RETURNS TABLE (message_room_id bigint)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
BEGIN
    RETURN QUERY SELECT m1.message_room_id
    FROM public.message_room_members m1
    INNER JOIN public.message_room_members m2
    ON m1.message_room_id = m2.message_room_id
    WHERE m1.profile_id = from_user_id
    AND m2.profile_id = to_user_id;
END;
$$;

 

**추가로 아래와 같이 함수들에 대해 Security 부분에 Invoker와 Definer가 있는데, 코드에서 내가 해당 함수를 호출할거면 Invoker로 해야 한다(별도 명시를 안하면 Definer로 설정됨). 아래 같은 경우에는 get_room이 Definer로 되어있는 문제가 있다. 실수로 SECURITY DEFINER라고 명시했기 때문이다. 해당 함수를 삭제하고 다시 선언해주면 된다.(이건 연습이니, 따로 안함)

CREATE OR REPLACE FUNCTION public.get_room(from_user_id uuid, to_user_id uuid)
RETURNS TABLE (message_room_id bigint)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
BEGIN
    RETURN QUERY SELECT m1.message_room_id
    FROM public.message_room_members m1
    INNER JOIN public.message_room_members m2
    ON m1.message_room_id = m2.message_room_id
    WHERE m1.profile_id = from_user_id
    AND m2.profile_id = to_user_id;
END;
$$;