Supabase 완전 정복 시리즈 5편 — 실시간 기능(Realtime) 완벽 가이드




시리즈 목차 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 ChangesDB 변경 실시간 수신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 ChangesBroadcast 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 코드와 함께 살펴봅니다.




댓글 남기기