시리즈 목차 1편 – Supabase란 무엇인가? Firebase와 제대로 비교해보기 2편 – 데이터베이스 & 자동 API (REST/GraphQL) 3편 – 인증(Auth) — 소셜 로그인, JWT, MFA, SSO 4편 – RLS 보안 — Row Level Security 완벽 가이드 5편 👉 실시간 기능(Realtime) — Broadcast, Presence, Postgres Changes (현재 글) 6편 – 스토리지(Storage) — 파일, 이미지, CDN …
들어가며
실시간 기능은 현대 웹 서비스에서 빠질 수 없는 요소입니다. 채팅, 라이브 알림, 협업 도구, 실시간 대시보드 — 이 모든 것의 기반은 WebSocket입니다.
Supabase Realtime은 Elixir + Phoenix Framework 위에 구축된 고성능 실시간 서버로, 세 가지 핵심 기능을 제공합니다.
| 기능 | 용도 | 특징 |
|---|---|---|
| Broadcast | 클라이언트 간 저지연 메시지 전송 | 비영속적(ephemeral), 빠름 |
| Presence | 접속 사용자 상태 추적 | 분산 상태 동기화 |
| Postgres Changes | DB 변경 실시간 수신 | WAL 기반 |
2025년에는 Broadcast from Database라는 새로운 방식도 추가되어, DB 트리거 기반으로 더 세밀하게 실시간 이벤트를 제어할 수 있게 되었습니다.
이번 편에서 다룰 내용:
- Realtime 아키텍처와 채널 개념
- Broadcast로 실시간 채팅 구현
- Presence로 온라인 사용자 표시
- Postgres Changes로 DB 변경 구독
- Broadcast from Database (2025 신기능)
- Next.js App Router에서 실전 구현
- Realtime 인증(RLS)과 보안
- 성능 고려사항과 스케일링 팁
Realtime 아키텍처
채널(Channel) 개념
Realtime의 모든 것은 채널 중심으로 동작합니다. 채널은 일종의 “방”이며, 클라이언트들이 같은 채널에 접속해 메시지를 주고받습니다.
클라이언트 A ──┐
├── channel('chat-room-1') ──→ Realtime 서버 ──→ WebSocket
클라이언트 B ──┘
채널은 공개(Public) 와 비공개(Private) 두 종류가 있습니다.
- Public: 누구나 구독 가능. 개발·테스트용으로 적합
- Private: RLS 정책으로 접근 제어. 프로덕션에서는 반드시 사용
Realtime 활성화
실시간으로 수신할 테이블은 Supabase Replication에 추가해야 합니다.
-- 테이블을 Realtime publication에 추가
ALTER PUBLICATION supabase_realtime ADD TABLE messages;
ALTER PUBLICATION supabase_realtime ADD TABLE posts;
또는 대시보드 → Database → Replication에서 토글로 활성화할 수 있습니다.
1. Broadcast — 저지연 메시지 전송
Broadcast는 클라이언트 간 빠르고 가벼운 메시지 전달에 최적화되어 있습니다. 메시지는 DB에 저장되지 않으며(비영속적), 실시간으로 연결된 클라이언트에게만 전달됩니다.
활용 사례: 채팅, 커서 위치 공유, 게임 이벤트, 실시간 알림, 라이브 반응(좋아요 등)
기본 Broadcast 구현
// hooks/useRealtimeChat.ts (Client Component용 훅)
'use client'
import { useEffect, useRef, useState, useCallback } from 'react'
import { createClient } from '@/lib/supabase/client'
import type { RealtimeChannel } from '@supabase/supabase-js'
interface ChatMessage {
id: string
userId: string
userName: string
content: string
createdAt: string
}
export function useRealtimeChat(roomId: string) {
const [messages, setMessages] = useState<ChatMessage[]>([])
const channelRef = useRef<RealtimeChannel | null>(null)
const supabase = createClient()
useEffect(() => {
// 채널 생성 및 구독
const channel = supabase
.channel(`chat-room:${roomId}`, {
config: {
broadcast: { self: true }, // 본인 메시지도 수신
},
})
.on('broadcast', { event: 'new-message' }, ({ payload }) => {
setMessages((prev) => [...prev, payload as ChatMessage])
})
.subscribe()
channelRef.current = channel
// 컴포넌트 언마운트 시 구독 해제
return () => {
supabase.removeChannel(channel)
}
}, [roomId])
// 메시지 전송
const sendMessage = useCallback(
async (content: string, userId: string, userName: string) => {
if (!channelRef.current) return
const message: ChatMessage = {
id: crypto.randomUUID(),
userId,
userName,
content,
createdAt: new Date().toISOString(),
}
await channelRef.current.send({
type: 'broadcast',
event: 'new-message',
payload: message,
})
},
[]
)
return { messages, sendMessage }
}
채팅 UI 컴포넌트
// components/ChatRoom.tsx
'use client'
import { useState } from 'react'
import { useRealtimeChat } from '@/hooks/useRealtimeChat'
interface Props {
roomId: string
userId: string
userName: string
}
export default function ChatRoom({ roomId, userId, userName }: Props) {
const { messages, sendMessage } = useRealtimeChat(roomId)
const [input, setInput] = useState('')
const handleSend = async () => {
if (!input.trim()) return
await sendMessage(input.trim(), userId, userName)
setInput('')
}
return (
<div className="flex flex-col h-[600px]">
{/* 메시지 목록 */}
<div className="flex-1 overflow-y-auto p-4 space-y-2">
{messages.map((msg) => (
<div
key={msg.id}
className={`flex ${msg.userId === userId ? 'justify-end' : 'justify-start'}`}
>
<div className={`max-w-xs rounded-lg px-4 py-2 ${
msg.userId === userId
? 'bg-blue-500 text-white'
: 'bg-gray-100 text-gray-900'
}`}>
{msg.userId !== userId && (
<p className="text-xs font-semibold mb-1">{msg.userName}</p>
)}
<p>{msg.content}</p>
<p className="text-xs opacity-70 mt-1">
{new Date(msg.createdAt).toLocaleTimeString()}
</p>
</div>
</div>
))}
</div>
{/* 입력창 */}
<div className="border-t p-4 flex gap-2">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
placeholder="메시지 입력..."
className="flex-1 border rounded-lg px-4 py-2"
/>
<button
onClick={handleSend}
className="bg-blue-500 text-white px-4 py-2 rounded-lg"
>
전송
</button>
</div>
</div>
)
}
타이핑 인디케이터
// 타이핑 상태 Broadcast 예시
const sendTyping = useCallback(async (isTyping: boolean) => {
await channelRef.current?.send({
type: 'broadcast',
event: 'typing',
payload: { userId, userName, isTyping },
})
}, [userId, userName])
// 구독 시 타이핑 이벤트도 함께 처리
.on('broadcast', { event: 'typing' }, ({ payload }) => {
if (payload.userId !== userId) {
setTypingUsers((prev) =>
payload.isTyping
? [...new Set([...prev, payload.userName])]
: prev.filter((u) => u !== payload.userName)
)
}
})
커서 위치 공유 (협업 도구)
// 마우스 커서 실시간 공유
const sendCursorPosition = useCallback(
async (x: number, y: number) => {
await channelRef.current?.send({
type: 'broadcast',
event: 'cursor-move',
payload: { userId, userName, x, y },
})
},
[userId, userName]
)
// 마우스 이동 이벤트 (throttle 적용 권장)
const handleMouseMove = useCallback(
throttle((e: MouseEvent) => {
sendCursorPosition(e.clientX, e.clientY)
}, 50), // 50ms마다 최대 1회 전송
[sendCursorPosition]
)
2. Presence — 온라인 사용자 추적
Presence는 누가 지금 접속해 있는지를 실시간으로 추적하고 동기화합니다. 분산된 여러 클라이언트 간에 상태를 일관되게 유지하는 것이 핵심입니다.
활용 사례: 온라인 사용자 목록, 문서 편집 중인 참여자 표시, 게임 로비
Presence 구현
// hooks/usePresence.ts
'use client'
import { useEffect, useRef, useState } from 'react'
import { createClient } from '@/lib/supabase/client'
import type { RealtimeChannel } from '@supabase/supabase-js'
interface UserPresence {
userId: string
userName: string
avatarUrl?: string
onlineAt: string
}
export function usePresence(roomId: string, currentUser: UserPresence) {
const [onlineUsers, setOnlineUsers] = useState<UserPresence[]>([])
const channelRef = useRef<RealtimeChannel | null>(null)
const supabase = createClient()
useEffect(() => {
const channel = supabase.channel(`presence:${roomId}`)
channel
.on('presence', { event: 'sync' }, () => {
// 전체 상태 동기화 (초기 접속 시 또는 변경 시)
const state = channel.presenceState<UserPresence>()
const users = Object.values(state)
.flat()
.map((p) => p as unknown as UserPresence)
setOnlineUsers(users)
})
.on('presence', { event: 'join' }, ({ newPresences }) => {
// 새 사용자 접속
console.log('접속:', newPresences)
})
.on('presence', { event: 'leave' }, ({ leftPresences }) => {
// 사용자 이탈
console.log('이탈:', leftPresences)
})
.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
// 구독 완료 후 본인 상태 등록
await channel.track({
userId: currentUser.userId,
userName: currentUser.userName,
avatarUrl: currentUser.avatarUrl,
onlineAt: new Date().toISOString(),
})
}
})
channelRef.current = channel
return () => {
supabase.removeChannel(channel)
}
}, [roomId, currentUser.userId])
return { onlineUsers }
}
온라인 사용자 UI
// components/OnlineUsers.tsx
'use client'
import { usePresence } from '@/hooks/usePresence'
interface Props {
roomId: string
currentUserId: string
currentUserName: string
}
export default function OnlineUsers({ roomId, currentUserId, currentUserName }: Props) {
const { onlineUsers } = usePresence(roomId, {
userId: currentUserId,
userName: currentUserName,
onlineAt: new Date().toISOString(),
})
return (
<div className="p-4 border rounded-lg">
<h3 className="font-semibold mb-2">
온라인 ({onlineUsers.length}명)
</h3>
<ul className="space-y-2">
{onlineUsers.map((user) => (
<li key={user.userId} className="flex items-center gap-2">
{/* 온라인 상태 표시 dot */}
<span className="w-2 h-2 rounded-full bg-green-500" />
<span className="text-sm">
{user.userName}
{user.userId === currentUserId && (
<span className="text-gray-400 ml-1">(나)</span>
)}
</span>
</li>
))}
</ul>
</div>
)
}
3. Postgres Changes — DB 변경 구독
Postgres Changes는 PostgreSQL의 WAL(Write-Ahead Log) 를 감시해 테이블의 INSERT, UPDATE, DELETE 이벤트를 실시간으로 수신합니다.
활용 사례: 실시간 알림, 라이브 대시보드, 데이터 변경 감지
기본 Postgres Changes 구독
// hooks/usePostgresChanges.ts
'use client'
import { useEffect, useState } from 'react'
import { createClient } from '@/lib/supabase/client'
interface Post {
id: number
title: string
published: boolean
created_at: string
}
export function useLivePosts() {
const [posts, setPosts] = useState<Post[]>([])
const supabase = createClient()
useEffect(() => {
// 초기 데이터 로드
supabase
.from('posts')
.select('*')
.eq('published', true)
.order('created_at', { ascending: false })
.then(({ data }) => {
if (data) setPosts(data)
})
// 실시간 변경 구독
const channel = supabase
.channel('live-posts')
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'posts',
filter: 'published=eq.true', // 공개 게시글만
},
(payload) => {
setPosts((prev) => [payload.new as Post, ...prev])
}
)
.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'posts',
},
(payload) => {
setPosts((prev) =>
prev.map((p) =>
p.id === (payload.new as Post).id ? (payload.new as Post) : p
)
)
}
)
.on(
'postgres_changes',
{
event: 'DELETE',
schema: 'public',
table: 'posts',
},
(payload) => {
setPosts((prev) =>
prev.filter((p) => p.id !== (payload.old as Post).id)
)
}
)
.subscribe()
return () => {
supabase.removeChannel(channel)
}
}, [])
return { posts }
}
특정 행 필터링
// 특정 사용자의 알림만 구독
const channel = supabase
.channel('my-notifications')
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'notifications',
filter: `recipient_id=eq.${userId}`, // 본인 알림만
},
(payload) => {
addNotification(payload.new)
}
)
.subscribe()
실시간 알림 훅
// hooks/useNotifications.ts
'use client'
import { useEffect, useState } from 'react'
import { createClient } from '@/lib/supabase/client'
interface Notification {
id: number
message: string
read: boolean
created_at: string
}
export function useNotifications(userId: string) {
const [notifications, setNotifications] = useState<Notification[]>([])
const [unreadCount, setUnreadCount] = useState(0)
const supabase = createClient()
useEffect(() => {
// 초기 미읽은 알림 로드
supabase
.from('notifications')
.select('*')
.eq('recipient_id', userId)
.eq('read', false)
.order('created_at', { ascending: false })
.then(({ data }) => {
if (data) {
setNotifications(data)
setUnreadCount(data.length)
}
})
// 새 알림 실시간 수신
const channel = supabase
.channel(`notifications:${userId}`)
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'notifications',
filter: `recipient_id=eq.${userId}`,
},
(payload) => {
const newNotif = payload.new as Notification
setNotifications((prev) => [newNotif, ...prev])
setUnreadCount((prev) => prev + 1)
}
)
.subscribe()
return () => {
supabase.removeChannel(channel)
}
}, [userId])
const markAsRead = async (notificationId: number) => {
await supabase
.from('notifications')
.update({ read: true })
.eq('id', notificationId)
setNotifications((prev) =>
prev.map((n) => (n.id === notificationId ? { ...n, read: true } : n))
)
setUnreadCount((prev) => Math.max(0, prev - 1))
}
return { notifications, unreadCount, markAsRead }
}
4. Broadcast from Database (2025 신기능)
기존 Postgres Changes의 한계를 보완한 새로운 방식입니다. DB 트리거를 통해 변경 이벤트를 Broadcast로 전송합니다.
Postgres Changes vs Broadcast from Database
| 항목 | Postgres Changes | Broadcast from Database |
|---|---|---|
| 방식 | WAL 폴링 | 트리거 → realtime.messages |
| 스케일 | 구독자 많을수록 느려짐 | 구독자 수와 무관 |
| 커스터마이징 | 제한적 | 특정 컬럼만 전송 가능 |
| 설정 복잡도 | 간단 | 트리거 설정 필요 |
| 권장 사용 | 소규모 / 빠른 개발 | 대규모 / 프로덕션 |
Broadcast from Database 설정
-- 1. 트리거 함수 생성: 새 메시지 INSERT 시 Broadcast
CREATE OR REPLACE FUNCTION broadcast_new_message()
RETURNS TRIGGER AS $$
BEGIN
PERFORM realtime.broadcast_changes(
'chat-room:' || NEW.room_id::TEXT, -- 채널 토픽
TG_OP, -- 이벤트 (INSERT/UPDATE/DELETE)
TG_TABLE_NAME, -- 테이블명
TG_TABLE_SCHEMA, -- 스키마명
OLD, -- 이전 레코드
NEW -- 새 레코드
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- 2. 트리거 등록
CREATE TRIGGER on_new_message
AFTER INSERT ON messages
FOR EACH ROW EXECUTE FUNCTION broadcast_new_message();
-- 특정 컬럼만 전송하고 싶을 때 (realtime.send 직접 사용)
CREATE OR REPLACE FUNCTION broadcast_message_preview()
RETURNS TRIGGER AS $$
BEGIN
PERFORM realtime.send(
jsonb_build_object(
'id', NEW.id,
'content', NEW.content, -- 내용만
'sender_name', NEW.sender_name,
'created_at', NEW.created_at
-- 민감한 정보(전화번호 등)는 제외
),
'new-message', -- 이벤트명
'chat-room:' || NEW.room_id::TEXT, -- 채널
false -- private 여부
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
Next.js에서 Broadcast from Database 수신
// Client Component에서 수신
const channel = supabase
.channel(`chat-room:${roomId}`, {
config: { private: true }, // private 채널 사용 권장
})
.on('broadcast', { event: 'INSERT' }, ({ payload }) => {
// DB 트리거에서 보낸 메시지 수신
addMessage(payload.record)
})
.subscribe(async () => {
// private 채널은 인증 토큰 설정 필요
await supabase.realtime.setAuth()
})
Realtime 인증과 RLS
Private 채널 RLS 설정
프로덕션에서는 반드시 Private 채널과 RLS를 함께 사용해야 합니다.
-- realtime.messages 테이블에 RLS 활성화
ALTER TABLE realtime.messages ENABLE ROW LEVEL SECURITY;
-- 인증된 사용자만 채널 접근 허용
CREATE POLICY "인증 사용자 Broadcast 읽기"
ON realtime.messages
FOR SELECT
TO authenticated
USING (true);
CREATE POLICY "인증 사용자 Broadcast 쓰기"
ON realtime.messages
FOR INSERT
TO authenticated
WITH CHECK (true);
-- 특정 방 멤버만 접근 허용하는 더 세밀한 정책
CREATE POLICY "방 멤버만 접근"
ON realtime.messages
FOR SELECT
TO authenticated
USING (
-- 채널 토픽에서 room_id 추출
(regexp_match(realtime.topic(), 'chat-room:(.*)'))[1]::UUID IN (
SELECT room_id FROM room_members
WHERE user_id = (select auth.uid())
)
);
Next.js에서 인증 토큰 설정
// private 채널 사용 시 인증 토큰 설정 필수
useEffect(() => {
const setupRealtime = async () => {
// 인증 토큰 설정
await supabase.realtime.setAuth()
const channel = supabase
.channel(`private-room:${roomId}`, {
config: { private: true },
})
.on('broadcast', { event: 'message' }, handleMessage)
.subscribe()
return () => supabase.removeChannel(channel)
}
setupRealtime()
}, [roomId])
실전: 실시간 라이브 대시보드
Postgres Changes를 활용한 실시간 통계 대시보드 예시입니다.
// components/LiveDashboard.tsx
'use client'
import { useEffect, useState } from 'react'
import { createClient } from '@/lib/supabase/client'
interface Stats {
totalUsers: number
todaySignups: number
activeSessions: number
revenueToday: number
}
export default function LiveDashboard() {
const [stats, setStats] = useState<Stats>({
totalUsers: 0,
todaySignups: 0,
activeSessions: 0,
revenueToday: 0,
})
const supabase = createClient()
useEffect(() => {
// 초기 데이터 로드
const loadStats = async () => {
const today = new Date().toISOString().split('T')[0]
const [{ count: total }, { count: todayCount }] = await Promise.all([
supabase.from('profiles').select('*', { count: 'exact', head: true }),
supabase
.from('profiles')
.select('*', { count: 'exact', head: true })
.gte('created_at', today),
])
setStats((prev) => ({
...prev,
totalUsers: total ?? 0,
todaySignups: todayCount ?? 0,
}))
}
loadStats()
// 새 사용자 가입 실시간 감지
const channel = supabase
.channel('dashboard-stats')
.on(
'postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'profiles' },
() => {
setStats((prev) => ({
...prev,
totalUsers: prev.totalUsers + 1,
todaySignups: prev.todaySignups + 1,
}))
}
)
.subscribe()
return () => supabase.removeChannel(channel)
}, [])
return (
<div className="grid grid-cols-2 gap-4 p-6">
<StatCard label="총 사용자" value={stats.totalUsers} />
<StatCard label="오늘 가입" value={stats.todaySignups} />
<StatCard label="활성 세션" value={stats.activeSessions} />
<StatCard label="오늘 매출" value={`₩${stats.revenueToday.toLocaleString()}`} />
</div>
)
}
function StatCard({ label, value }: { label: string; value: string | number }) {
return (
<div className="bg-white rounded-xl p-6 shadow">
<p className="text-sm text-gray-500">{label}</p>
<p className="text-3xl font-bold mt-1">{value}</p>
</div>
)
}
세 가지 기능 조합: 완성형 채팅 앱 구조
실제 서비스에서는 세 기능을 조합해 사용합니다.
// hooks/useChatRoom.ts — Broadcast + Presence + Postgres Changes 조합
'use client'
import { useEffect, useRef, useState } from 'react'
import { createClient } from '@/lib/supabase/client'
export function useChatRoom(roomId: string, currentUser: { id: string; name: string }) {
const [messages, setMessages] = useState<Message[]>([])
const [onlineUsers, setOnlineUsers] = useState<OnlineUser[]>([])
const [typingUsers, setTypingUsers] = useState<string[]>([])
const channelRef = useRef<RealtimeChannel | null>(null)
const supabase = createClient()
useEffect(() => {
// 과거 메시지 로드
supabase
.from('messages')
.select('*')
.eq('room_id', roomId)
.order('created_at', { ascending: true })
.limit(50)
.then(({ data }) => setMessages(data ?? []))
const channel = supabase
.channel(`chat:${roomId}`)
// 1. Broadcast: 실시간 채팅 메시지
.on('broadcast', { event: 'chat-message' }, ({ payload }) => {
setMessages((prev) => [...prev, payload])
})
// 2. Broadcast: 타이핑 상태
.on('broadcast', { event: 'typing' }, ({ payload }) => {
if (payload.userId === currentUser.id) return
setTypingUsers((prev) =>
payload.isTyping
? [...new Set([...prev, payload.userName])]
: prev.filter((u) => u !== payload.userName)
)
})
// 3. Presence: 온라인 사용자
.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState()
setOnlineUsers(Object.values(state).flat() as OnlineUser[])
})
// 4. Postgres Changes: DB에 저장된 메시지 동기화 (Broadcast from DB로 대체 가능)
.on(
'postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'messages', filter: `room_id=eq.${roomId}` },
(payload) => {
// 이미 Broadcast로 받은 메시지는 중복 방지
setMessages((prev) => {
const exists = prev.some((m) => m.id === payload.new.id)
return exists ? prev : [...prev, payload.new as Message]
})
}
)
.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
await channel.track({
userId: currentUser.id,
userName: currentUser.name,
onlineAt: new Date().toISOString(),
})
}
})
channelRef.current = channel
return () => supabase.removeChannel(channel)
}, [roomId])
const sendMessage = async (content: string) => {
// 1. DB에 저장 (영속성)
const { data } = await supabase
.from('messages')
.insert({ room_id: roomId, user_id: currentUser.id, content })
.select()
.single()
// 2. Broadcast로 즉시 전송 (저지연)
await channelRef.current?.send({
type: 'broadcast',
event: 'chat-message',
payload: data,
})
}
return { messages, onlineUsers, typingUsers, sendMessage }
}
성능 고려사항
Postgres Changes 주의점
Postgres Changes는 모든 구독자에 대해 RLS 정책을 검사하므로, 구독자가 많을수록 DB 부하가 증가합니다. 대규모 서비스에서는 Broadcast from Database 방식을 권장합니다.
구독 최적화 팁
// ❌ 너무 광범위한 구독
.on('postgres_changes', { event: '*', schema: 'public', table: 'messages' }, ...)
// ✅ 필요한 이벤트와 필터만 사용
.on('postgres_changes', {
event: 'INSERT',
schema: 'public',
table: 'messages',
filter: `room_id=eq.${roomId}`, // 특정 방만
}, ...)
컴포넌트 언마운트 시 반드시 구독 해제
useEffect(() => {
const channel = supabase.channel('...').subscribe()
// ✅ cleanup 함수에서 반드시 제거
return () => {
supabase.removeChannel(channel)
}
}, [])
Presence는 최소화
Presence는 내부적으로 분산 상태를 관리하므로 계산 오버헤드가 있습니다. 온라인 사용자 수가 수백 명 이상인 채널에서는 Broadcast 기반의 커스텀 온라인 추적을 고려하세요.
마치며
Supabase Realtime은 세 가지 핵심 기능 — Broadcast, Presence, Postgres Changes — 을 조합해 채팅, 협업 도구, 라이브 대시보드 등 다양한 실시간 앱을 만들 수 있는 강력한 플랫폼입니다.
2025년에 추가된 Broadcast from Database는 DB 트리거를 통해 더 세밀하고 확장성 있는 실시간 이벤트 제어를 가능하게 해줍니다. 특히 구독자가 많은 프로덕션 환경에서는 Postgres Changes 대신 Broadcast from Database를 적극 활용하는 것이 좋습니다.
다음 편에서는 Supabase Storage 를 다룹니다. 파일 업로드, 이미지 최적화, CDN 활용, 그리고 RLS로 파일 접근을 제어하는 방법을 Next.js 코드와 함께 살펴봅니다.