Supabase Realtime 실전: 세 가지 모드 비교와 협업 앱 개발

화면의 ‘입력 중…’ 표시를 바라보며 채팅 페이지를 열일곱 번째 새로고침했습니다. 상대방은 분명 온라인인데 메시지는 도무지 나타나지 않았습니다. 진짜 ‘실시간’ 앱을 만드는 일은 생각보다 훨씬 어려웠습니다.
WebSocket을 쓰며 많은 문제를 겪었습니다. 연결이 끊기면 다시 연결해야 하고, 상태 동기화를 처리해야 하며, 메시지 브로드캐스트 방식도 설계해야 합니다. 작년에 Supabase Realtime을 사용하고 나서야 이런 번거로운 일을 다른 서비스에 맡길 수 있다는 사실을 알았습니다. Supabase는 세 가지 실시간 모드를 제공합니다. Postgres Changes는 데이터베이스 변경을 수신하고, Presence는 사용자 상태를 추적하며, Broadcast는 임시 메시지를 전송합니다. 각 모드에 맞는 용도가 있으므로 제대로 선택하면 일이 쉬워지지만, 잘못 선택하면 오히려 문제를 키울 수 있습니다.
이 글에서는 세 모드의 차이, 선택 기준, 코드 작성법, RLS 보안 정책 설정 방법을 구체적으로 설명합니다. 마지막에는 세 모드를 조합해 완전한 협업 채팅 앱을 만듭니다.
Supabase Realtime의 세 가지 핵심 기능 비교
결론부터 말하면 세 모드의 역할은 서로 다르므로 뒤섞어 사용해서는 안 됩니다.
Postgres Changes는 데이터베이스 변경을 수신합니다. 채팅 메시지, 알림, 주문 상태처럼 영구 저장해야 하는 데이터에 적합합니다. 데이터는 데이터베이스에 저장되고 클라이언트는 변경 사항만 구독합니다.
Presence는 사용자의 온라인 상태를 추적합니다. ‘누가 온라인인지’ 표시하거나 입력 상태, 협업 편집기의 커서 위치를 보여 주는 데 적합합니다. 데이터는 데이터베이스가 아니라 메모리에 있으며 사용자의 연결이 끊기면 사라집니다.
Broadcast는 임시 메시지를 전송합니다. 캔버스 위의 커서 이동, 게임 내 실시간 위치, 일시적인 작업 동기화에 적합합니다. Presence와의 차이는 Broadcast가 빈도 높은 전송에 적합하고, Presence는 상태 동기화에 적합하다는 점입니다.
표로 정리하면 다음과 같습니다.
| 모드 | 데이터 저장 위치 | 대표 사용 사례 | 영구 저장 |
|---|---|---|---|
| Postgres Changes | PostgreSQL | 채팅 메시지, 알림, 주문 상태 | 예 |
| Presence | 메모리(Realtime 서비스) | 온라인 사용자, 입력 표시기 | 아니요 |
| Broadcast | 저장하지 않음(즉시 전달) | 커서 이동, 실시간 위치 | 아니요 |
‘전부 Postgres Changes를 사용하면 되지 않을까?‘라는 생각이 들 수 있습니다. 저도 처음에는 그렇게 생각했습니다. 하지만 협업 화이트보드 앱에서 커서가 움직일 때마다 데이터베이스에 기록했다가 데이터베이스 CPU 사용률이 곧바로 90%까지 치솟았습니다. 그때서야 일부 데이터는 전혀 영구 저장할 필요가 없다는 사실을 깨달았습니다.
모드를 선택할 때는 이 원칙만 기억하면 됩니다. 기록을 조회해야 하면 Postgres Changes, 현재 상태만 중요하면 Presence, 빈도가 높고 일시적인 데이터라면 Broadcast를 사용합니다.
Postgres Changes: 데이터베이스 변경 수신
먼저 가장 흔한 사용 사례인 데이터베이스 변경 수신을 살펴보겠습니다. 채팅방에 새 메시지가 들어오거나 주문 상태가 바뀌고, 누군가 ‘좋아요’를 누르는 일은 모두 데이터를 영구 저장해야 하는 사례입니다.
Supabase Realtime은 PostgreSQL의 logical replication(논리 복제) 메커니즘으로 변경 사항을 수신합니다. 간단히 말해 데이터베이스에서 INSERT, UPDATE, DELETE 작업이 일어날 때마다 Realtime 서비스가 이를 포착해 구독 중인 클라이언트로 전송합니다.
Realtime 수신 활성화
먼저 데이터베이스에서 publication을 활성화해야 합니다. Supabase SQL Editor에서 다음 명령을 실행합니다.
-- Realtime publication 활성화
ALTER publication supabase_realtime ADD TABLE messages;
-- UPDATE와 DELETE를 수신해야 하는 테이블에는 REPLICA IDENTITY FULL 설정 필요
ALTER TABLE messages REPLICA IDENTITY FULL;
왜 REPLICA IDENTITY FULL을 설정해야 할까요? PostgreSQL은 기본적으로 변경된 행의 기본 키만 기록합니다. 감사 로그처럼 변경 전후의 전체 데이터가 필요하다면 이 옵션을 활성화해야 합니다. 단, 데이터베이스 쓰기 작업이 늘어나므로 실제로 필요한 테이블에만 활성화하세요.
클라이언트 구독 코드
다음은 클라이언트 코드입니다. 채팅 메시지를 예로 들어 보겠습니다.
import { createClient } from '@supabase/supabase-js'
import { useEffect, useState } from 'react'
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY)
interface Message {
id: string
content: string
user_id: string
created_at: string
}
export function useRealtimeMessages(roomId: string) {
const [messages, setMessages] = useState<Message[]>([])
useEffect(() => {
// 먼저 이전 메시지 가져오기
const fetchMessages = async () => {
const { data } = await supabase
.from('messages')
.select('*')
.eq('room_id', roomId)
.order('created_at', { ascending: true })
if (data) setMessages(data)
}
fetchMessages()
// 새 메시지 구독
const channel = supabase
.channel(`messages:${roomId}`)
.on(
'postgres_changes',
{
event: 'INSERT', // 새 항목만 수신
schema: 'public',
table: 'messages',
filter: `room_id=eq.${roomId}` // 특정 방 필터링
},
(payload) => {
// payload.new에는 새로 삽입된 데이터가 들어 있음
setMessages(prev => [...prev, payload.new as Message])
}
)
.subscribe()
// 구독 정리
return () => {
supabase.removeChannel(channel)
}
}, [roomId])
return messages
}
몇 가지 세부 사항에 주의해야 합니다.
- 이전 메시지를 먼저 가져온 뒤 새 항목 구독하기: 채팅방에 처음 들어오면 이전 메시지를 먼저 표시한 다음 새 메시지를 받아야 합니다. 새 메시지만 구독하고 기록을 가져오지 않는 실수가 흔합니다.
- filter 매개변수: 데이터베이스 필드로 필터링하면 관련 없는 메시지를 받지 않을 수 있습니다. 구문은
필드명=eq.값입니다. - 구독 정리: 컴포넌트를 언마운트할 때
removeChannel을 호출하지 않으면 메모리 누수가 발생합니다.
RLS 보안 설정(중요)
많이 놓치는 부분이지만 프로덕션 앱에서는 매우 중요합니다. Realtime은 기본적으로 테이블의 RLS(Row Level Security) 정책을 따릅니다. RLS를 설정하지 않으면 클라이언트가 아무것도 받지 못하거나, 반대로 받아서는 안 되는 데이터까지 받을 수 있습니다.
채팅방 테이블을 예로 들어 보겠습니다.
-- 메시지 테이블
CREATE TABLE messages (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
room_id uuid REFERENCES rooms(id),
user_id uuid REFERENCES auth.users(id),
content text NOT NULL,
created_at timestamptz DEFAULT now()
);
-- RLS 활성화
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
-- 자신이 참여한 방의 메시지 조회 허용
CREATE POLICY "Users can view messages in their rooms"
ON messages FOR SELECT
USING (
room_id IN (
SELECT room_id FROM room_members
WHERE user_id = auth.uid()
)
);
-- 방 구성원의 메시지 전송 허용
CREATE POLICY "Room members can insert messages"
ON messages FOR INSERT
WITH CHECK (
room_id IN (
SELECT room_id FROM room_members
WHERE user_id = auth.uid()
)
);
Realtime 구독에는 이러한 정책이 자동으로 적용됩니다. 사용자는 자신에게 조회 권한이 있는 메시지의 변경만 받을 수 있습니다. 이 점이 특히 중요합니다. 프런트엔드에서 필터링하는 방식은 안전하지 않습니다.
구독에서 데이터를 받지 못한다면 먼저 다음 두 가지를 확인하세요.
- 테이블이
supabase_realtimepublication에 추가되었는가 - RLS 정책이 올바르게 설정되었는가
Presence: 사용자 온라인 상태 추적
Presence는 ‘지금 누가 온라인인가’가 중요한 상황에 적합합니다. 채팅방의 온라인 사용자 수, 협업 문서에서 누가 어느 부분을 편집하는지, 누가 입력 중인지를 표시할 수 있습니다. 이런 데이터는 데이터베이스에 저장할 필요 없이 메모리에만 있으면 충분합니다.
기본 원리
Presence는 각 클라이언트가 채널에 참여한 뒤 track() 메서드로 자신의 상태를 등록하는 방식으로 작동합니다. Realtime 서비스는 모든 클라이언트 상태의 스냅샷을 유지합니다. 누군가 참여하거나 나가거나 상태를 갱신하면 모든 구독자가 알림을 받습니다.
온라인 사용자 목록 구현
코드를 살펴보겠습니다. 다음은 채팅방의 온라인 사용자를 표시하는 컴포넌트입니다.
import { createClient } from '@supabase/supabase-js'
import { useEffect, useState } from 'react'
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY)
interface UserPresence {
user_id: string
username: string
online_at: string
}
export function useOnlineUsers(roomId: string, currentUser: { id: string; username: string }) {
const [users, setUsers] = useState<UserPresence[]>([])
useEffect(() => {
const channel = supabase.channel(`room:${roomId}`, {
config: {
presence: {
key: currentUser.id // 사용자 ID를 key로 사용
}
}
})
channel
.on('presence', { event: 'sync' }, () => {
// 동기화할 때 모든 온라인 사용자 가져오기
const state = channel.presenceState()
// presenceState()는 { [key]: [UserPresence, ...] } 형식으로 반환
const onlineUsers = Object.values(state).flat() as UserPresence[]
setUsers(onlineUsers)
})
.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({
user_id: currentUser.id,
username: currentUser.username,
online_at: new Date().toISOString()
})
}
})
return () => {
supabase.removeChannel(channel)
}
}, [roomId, currentUser])
return users
}
사용법은 간단합니다.
function ChatRoom({ roomId, currentUser }) {
const onlineUsers = useOnlineUsers(roomId, currentUser)
return (
<div className="flex items-center gap-2 mb-4">
<span className="text-sm text-gray-500">
온라인 {onlineUsers.length}명
</span>
<div className="flex -space-x-2">
{onlineUsers.map(user => (
<div
key={user.user_id}
className="w-8 h-8 rounded-full bg-blue-500 flex items-center justify-center text-white text-sm"
title={user.username}
>
{user.username[0]}
</div>
))}
</div>
</div>
)
}
입력 표시기
Presence로 ‘입력 중’ 표시도 만들 수 있습니다. 사용자가 입력을 시작할 때 자신의 상태를 갱신하고, 입력을 멈춘 뒤 일정 시간이 지나면 상태를 해제하는 방식입니다.
export function useTypingIndicator(roomId: string, currentUser: { id: string }) {
const [typingUsers, setTypingUsers] = useState<string[]>([])
const channelRef = useRef<RealtimeChannel | null>(null)
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null)
useEffect(() => {
const channel = supabase.channel(`room:${roomId}`)
channelRef.current = channel
channel
.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState()
const typing = Object.values(state)
.flat()
.filter((u: any) => u.is_typing && u.user_id !== currentUser.id)
.map((u: any) => u.username)
setTypingUsers(typing)
})
.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
await channel.track({
user_id: currentUser.id,
is_typing: false
})
}
})
return () => {
supabase.removeChannel(channel)
}
}, [roomId, currentUser])
// 사용자가 입력을 시작할 때 호출
const setTyping = (isTyping: boolean) => {
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current)
}
channelRef.current?.track({
user_id: currentUser.id,
is_typing
})
// 3초 뒤 입력 상태 자동 해제
if (isTyping) {
typingTimeoutRef.current = setTimeout(() => {
channelRef.current?.track({
user_id: currentUser.id,
is_typing: false
})
}, 3000)
}
}
return { typingUsers, setTyping }
}
여기에는 작은 함정이 있습니다. 키를 누를 때마다 track()을 호출하지 마세요. 너무 자주 호출하면 문제가 생길 수 있습니다. 디바운스를 적용하거나 위 코드처럼 입력 후 몇 초가 지나면 상태를 자동으로 해제하세요.
Broadcast: 커서 추적과 즉시 메시지
Broadcast는 세 모드 중 가장 ‘가볍습니다’. 메시지를 저장하거나 영구 보관하지 않고 전송한 뒤 잊어버리므로 빈도가 높고 일시적인 데이터 전송에 적합합니다.
커서 추적 예제
협업 화이트보드와 다중 사용자 편집기에서는 각 사용자의 커서 위치를 실시간으로 표시해야 합니다. 이때 Broadcast가 가장 적합합니다. 커서 위치를 데이터베이스에 저장할 필요도 없고, ‘과거에 커서가 어디 있었는지’ 알 필요도 없기 때문입니다.
import { createClient } from '@supabase/supabase-js'
import { useEffect, useState, useRef } from 'react'
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY)
interface CursorPosition {
user_id: string
username: string
x: number
y: number
color: string
}
export function useCursors(canvasId: string, currentUser: { id: string; username: string }) {
const [cursors, setCursors] = useState<Record<string, CursorPosition>>({})
const channelRef = useRef<RealtimeChannel | null>(null)
useEffect(() => {
const channel = supabase.channel(`canvas:${canvasId}`)
channelRef.current = channel
channel
.on('broadcast', { event: 'cursor' }, ({ payload }) => {
// 다른 사용자의 커서 위치 수신
if (payload.user_id !== currentUser.id) {
setCursors(prev => ({
...prev,
[payload.user_id]: payload
}))
}
})
.subscribe()
return () => {
supabase.removeChannel(channel)
}
}, [canvasId, currentUser])
// 자신의 커서 위치 전송
const sendCursor = (x: number, y: number) => {
channelRef.current?.send({
type: 'broadcast',
event: 'cursor',
payload: {
user_id: currentUser.id,
username: currentUser.username,
x,
y,
color: getUserColor(currentUser.id)
}
})
}
return { cursors, sendCursor }
}
// 사용자 ID로 색상 생성
function getUserColor(userId: string): string {
const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4']
const index = userId.charCodeAt(0) % colors.length
return colors[index]
}
컴포넌트에서는 다음과 같이 사용합니다.
function CollaborativeCanvas({ canvasId, currentUser }) {
const { cursors, sendCursor } = useCursors(canvasId, currentUser)
const handleMouseMove = (e: React.MouseEvent) => {
sendCursor(e.clientX, e.clientY)
}
return (
<div className="relative w-full h-full" onMouseMove={handleMouseMove}>
{/* 다른 사용자의 커서 표시 */}
{Object.entries(cursors).map(([userId, cursor]) => (
<div
key={userId}
className="absolute pointer-events-none"
style={{ left: cursor.x, top: cursor.y }}
>
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: cursor.color }} />
<span className="text-xs ml-1">{cursor.username}</span>
</div>
))}
</div>
)
}
Broadcast와 Presence 비교
Broadcast와 Presence의 역할이 겹친다고 느낄 수 있습니다. 차이는 다음과 같습니다.
- Broadcast는 커서 이동이나 게임 내 위치 동기화처럼 초당 수십 번 발생할 수 있는 빈도 높은 전송에 사용합니다.
- Presence는 ‘누가 온라인인지’, ‘누가 입력 중인지’처럼 가끔 갱신되는 상태 동기화에 사용합니다.
둘을 함께 사용하면 가장 효과적입니다. Broadcast는 ‘움직이는’ 데이터에, Presence는 ‘상태’ 데이터에 적합합니다.
종합 실전: 협업 채팅 앱 만들기
이제 세 모드를 조합해 진짜 협업 채팅 앱을 만들어 보겠습니다. 제공하는 기능은 다음과 같습니다.
- 실시간 메시지(Postgres Changes)
- 온라인 사용자 목록(Presence)
- 입력 표시기(Presence)
데이터베이스 준비
먼저 테이블을 만듭니다.
-- 방 테이블
CREATE TABLE rooms (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
created_at timestamptz DEFAULT now()
);
-- 방 구성원 테이블
CREATE TABLE room_members (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
room_id uuid REFERENCES rooms(id) ON DELETE CASCADE,
user_id uuid REFERENCES auth.users(id),
joined_at timestamptz DEFAULT now(),
UNIQUE(room_id, user_id)
);
-- 메시지 테이블
CREATE TABLE messages (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
room_id uuid REFERENCES rooms(id) ON DELETE CASCADE,
user_id uuid REFERENCES auth.users(id),
content text NOT NULL,
created_at timestamptz DEFAULT now()
);
-- Realtime 활성화
ALTER publication supabase_realtime ADD TABLE messages;
-- RLS 활성화
ALTER TABLE messages ENABLE ROW LEVEL Security;
-- RLS 정책: 자신이 참여한 방의 메시지만 조회
CREATE POLICY "Users can view messages in their rooms"
ON messages FOR SELECT
USING (
room_id IN (
SELECT room_id FROM room_members WHERE user_id = auth.uid()
)
);
-- RLS 정책: 방 구성원만 메시지 전송 가능
CREATE POLICY "Room members can send messages"
ON messages FOR INSERT
WITH CHECK (
room_id IN (
SELECT room_id FROM room_members WHERE user_id = auth.uid()
)
);
완전한 React 컴포넌트
다음은 세 모드를 모두 사용하는 간소화된 협업 채팅 컴포넌트입니다.
import { createClient } from '@supabase/supabase-js'
import { useEffect, useState, useRef, useCallback } from 'react'
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY)
interface Message {
id: string
content: string
user_id: string
username: string
created_at: string
}
interface UserPresence {
user_id: string
username: string
is_typing?: boolean
}
export function CollaborativeChat({ roomId, currentUser }) {
const [messages, setMessages] = useState<Message[]>([])
const [onlineUsers, setOnlineUsers] = useState<UserPresence[]>([])
const [typingUsers, setTypingUsers] = useState<string[]>([])
const [inputValue, setInputValue] = useState('')
const channelRef = useRef<RealtimeChannel | null>(null)
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null)
useEffect(() => {
// channel 하나를 만들어 세 기능이 공유
const channel = supabase.channel(`room:${roomId}`, {
config: { presence: { key: currentUser.id } }
})
channelRef.current = channel
// 1. Postgres Changes: 새 메시지 수신
channel.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'messages',
filter: `room_id=eq.${roomId}`
},
(payload) => setMessages(prev => [...prev, payload.new as Message])
)
// 2. Presence: 온라인 사용자와 입력 상태 수신
channel.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState()
const users = Object.values(state).flat() as UserPresence[]
setOnlineUsers(users)
const typing = users
.filter(u => u.is_typing && u.user_id !== currentUser.id)
.map(u => u.username)
setTypingUsers(typing)
})
// 구독 후 Presence 등록
channel.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
await channel.track({
user_id: currentUser.id,
username: currentUser.username,
is_typing: false
})
// 이전 메시지 불러오기
const { data } = await supabase
.from('messages')
.select('*')
.eq('room_id', roomId)
.order('created_at', { ascending: true })
if (data) setMessages(data)
}
})
return () => supabase.removeChannel(channel)
}, [roomId, currentUser])
const sendMessage = async () => {
if (!inputValue.trim()) return
await supabase.from('messages').insert({
room_id: roomId,
user_id: currentUser.id,
content: inputValue.trim()
})
setInputValue('')
setTyping(false)
}
const setTyping = useCallback((isTyping: boolean) => {
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current)
channelRef.current?.track({
user_id: currentUser.id,
username: currentUser.username,
is_typing
})
if (isTyping) {
typingTimeoutRef.current = setTimeout(() => setTyping(false), 3000)
}
}, [currentUser])
return (
<div className="flex h-full">
{/* 왼쪽: 온라인 사용자 */}
<div className="w-64 border-r p-4">
<h3 className="font-bold mb-2">온라인 ({onlineUsers.length})</h3>
{onlineUsers.map(user => (
<div key={user.user_id} className="py-1">
{user.username}
{user.is_typing && <span className="text-sm text-gray-500"> (입력 중)</span>}
</div>
))}
</div>
{/* 오른쪽: 채팅 */}
<div className="flex-1 flex flex-col">
<div className="flex-1 overflow-y-auto p-4">
{messages.map(msg => (
<div key={msg.id} className="mb-2">
<span className="font-bold">{msg.username}: </span>
{msg.content}
</div>
))}
{typingUsers.length > 0 && (
<div className="text-gray-500 text-sm">
{typingUsers.join(', ')} 입력 중...
</div>
)}
</div>
<div className="p-4 border-t">
<input
value={inputValue}
onChange={e => { setInputValue(e.target.value); setTyping(true) }}
onKeyDown={e => e.key === 'Enter' && sendMessage()}
placeholder="메시지를 입력하세요..."
className="w-full p-2 border rounded"
/>
</div>
</div>
</div>
)
}
핵심 설계 포인트
- Channel 하나 공유: 세 기능이 하나의 channel을 사용해 연결 수를 줄입니다.
- Presence 데이터 구조:
is_typing을 같은 presence 객체에 넣습니다. - 정리 작업: 컴포넌트를 언마운트할 때 반드시
removeChannel을 호출합니다.
성능과 보안 모범 사례
여기까지 작성하면 코드는 모두 동작합니다. 하지만 배포하기 전에 몇 가지 세부 사항을 더 처리해야 합니다.
연결 관리
각 channel은 WebSocket 연결 하나를 차지합니다. Supabase는 여러 채널이 한 연결을 공유하도록 지원하지만, 무분별하게 사용하면 여전히 문제가 생길 수 있습니다.
권장 방법:
- 한 페이지에서 channel을 최대 2~3개만 사용합니다.
- 관련 기능은 하나의 channel을 공유합니다(위 채팅 예제와 같은 방식).
- 페이지를 벗어나면 즉시
removeChannel을 호출합니다.
// 정리 예제
useEffect(() => {
const channel = supabase.channel('my-channel')
channel.subscribe()
return () => {
// unsubscribe만 호출하지 말고 removeChannel 사용
supabase.removeChannel(channel)
}
}, [])
RLS는 필수
‘프런트엔드 필터링이면 충분하다’고 생각해서는 안 됩니다. Realtime 구독에는 RLS 정책이 자동 적용되므로 사용자는 자신에게 조회 권한이 있는 데이터의 변경만 받을 수 있습니다.
구독에서 데이터를 받지 못한다면 다음 순서로 확인하세요.
- 테이블이
supabase_realtimepublication에 추가되었는가 - RLS 정책이 올바른가(Supabase Dashboard의 RLS 테스트 도구 사용)
- 사용자가 로그인했는가(
auth.uid()의 반환값 확인)
요금 관련 주의 사항
Supabase Realtime 요금은 동시 연결 수를 기준으로 계산되며, 최대 동시 연결 1,000개당 10달러입니다. 대부분의 중소 규모 앱은 무료 할당량으로 충분합니다. 하지만 많은 사용자가 동시에 접속하는 앱이라면 channel 수를 관리해야 합니다.
공식 데모 프로젝트인 Multiplayer.dev에서 세 모드가 실제로 어떻게 동작하는지 확인할 수 있습니다.
결론
어떤 모드를 선택할지는 생각보다 간단합니다.
| 기록 저장이 필요한가? | 빈도 높은 전송인가? | 권장 모드 |
|---|---|---|
| 예 | 아니요 | Postgres Changes |
| 아니요 | 아니요 | Presence |
| 아니요 | 예 | Broadcast |
채팅방, 화이트보드, 문서 편집기 같은 협업 앱을 만든다면 세 모드를 모두 사용할 가능성이 큽니다. Postgres Changes는 메시지를 저장하고, Presence는 온라인 상태를 추적하며, Broadcast는 커서를 처리합니다.
먼저 Multiplayer.dev에서 실제 동작을 체험해 보세요. 그런 다음 작은 채팅 데모를 직접 만들어 보는 것이 가장 빠른 학습 방법입니다.
다음 글에서는 Supabase Storage의 파일 업로드와 이미지 처리를 다룰 예정입니다. Realtime에 관해 궁금한 점이 있다면 댓글로 알려 주세요.
FAQ
Supabase Realtime의 세 가지 모드는 무엇이 다른가요?
• Postgres Changes는 데이터베이스 변경을 수신하며 데이터를 영구 저장하므로 채팅 메시지와 주문 상태에 적합합니다.
• Presence는 사용자의 온라인 상태를 추적하며 데이터를 메모리에 저장하므로 온라인 사용자 목록과 입력 표시기에 적합합니다.
• Broadcast는 임시 메시지를 저장하지 않고 전송하므로 커서 추적과 실시간 위치 공유에 적합합니다.
구독에서 데이터를 받지 못하는 이유는 무엇인가요?
1. 테이블이 supabase_realtime publication에 추가되었는지 확인합니다(ALTER publication supabase_realtime ADD TABLE 테이블명 실행).
2. RLS 정책이 올바르게 설정되었는지 확인합니다(Realtime 구독에는 RLS가 자동 적용됩니다).
3. 사용자에게 데이터를 볼 권한이 있는지 확인합니다(auth.uid() 반환값 확인).
Postgres Changes와 Broadcast 중 무엇을 선택해야 하나요?
Presence 데이터는 영구 저장되나요?
Realtime 구독에는 RLS가 어떻게 적용되나요?
한 페이지에서 channel을 몇 개까지 사용할 수 있나요?
7분 읽기 · 게시일: 2026년 4월 15일 · 수정일: 2026년 9월 4일
Supabase 실전
검색으로 들어왔다면 같은 시리즈의 이전 글이나 다음 글로 이동하는 것이 가장 빠릅니다.
이전
Supabase Storage 실전: 파일 업로드, 권한 제어, CDN 가속
Supabase Storage에서 파일 업로드, RLS Policy 기반 사용자 격리, Smart CDN 연동, 이미지 변환까지 설정하는 전체 과정을 설명합니다.
8편 중 4편
다음
Supabase Storage 실전 가이드: 파일 업로드, CDN, 액세스 제어
Supabase Storage 완전 실전 가이드입니다. 세 가지 액세스 제어 방식 비교, TUS 청크 업로드, Smart CDN 최적화 팁, R2/S3 비용 비교를 다루며 React 코드 예제와 문제 해결 방법도 제공합니다.
8편 중 6편



댓글
GitHub로 로그인하여 댓글을 남기세요