
Kim JeongHyeonFrontend Focused Fullstack Developer
Supabase Realtime으로 실시간 채팅, Presence(온라인 사용자), Typing Indicator를 구현합니다.
Supabase의 Realtime 기능으로 실시간 채팅을 구현합니다.
CREATE TABLE messages (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
room_id TEXT NOT NULL,
user_name TEXT NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
-- RLS: 누구나 읽기/쓰기 가능
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Public access" ON messages
FOR ALL USING (true) WITH CHECK (true);
-- Realtime 활성화
ALTER PUBLICATION supabase_realtime ADD TABLE messages;
"use client";
import { useEffect, useState } from "react";
import { createClient } from "@/lib/supabase/client";
interface Message {
id: string;
room_id: string;
user_name: string;
content: string;
created_at: string;
}
export function useMessages(roomId: string) {
const [messages, setMessages] = useState<Message[]>([]);
const supabase = createClient();
useEffect(() => {
// 초기 로드
supabase
.from("messages")
.select("*")
.eq("room_id", roomId)
.order("created_at", { ascending: true })
.then(({ data }) => setMessages(data ?? []));
// 실시간 구독
const channel = supabase
.channel(`room:${roomId}`)
.on(
"postgres_changes",
{
event: "INSERT",
schema: "public",
table: "messages",
filter: `room_id=eq.${roomId}`,
},
(payload) => {
setMessages((prev) => [...prev, payload.new as Message]);
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [roomId, supabase]);
return messages;
}
async function sendMessage(roomId: string, userName: string, content: string) {
const supabase = createClient();
const { error } = await supabase.from("messages").insert({
room_id: roomId,
user_name: userName,
content,
});
if (error) throw error;
}
function usePresence(roomId: string, userName: string) {
const [users, setUsers] = useState<string[]>([]);
const supabase = createClient();
useEffect(() => {
const channel = supabase.channel(`presence:${roomId}`);
channel
.on("presence", { event: "sync" }, () => {
const state = channel.presenceState();
const names = Object.values(state)
.flat()
.map((p: any) => p.user_name);
setUsers(names);
})
.subscribe(async (status) => {
if (status === "SUBSCRIBED") {
await channel.track({ user_name: userName });
}
});
return () => {
supabase.removeChannel(channel);
};
}, [roomId, userName, supabase]);
return users;
}
// Broadcast로 타이핑 상태 공유
channel.send({
type: "broadcast",
event: "typing",
payload: { user_name: userName },
});
channel.on("broadcast", { event: "typing" }, ({ payload }) => {
setTypingUsers(prev => [...new Set([...prev, payload.user_name])]);
// 3초 후 자동 제거
setTimeout(() => {
setTypingUsers(prev => prev.filter(u => u !== payload.user_name));
}, 3000);
});