실시간 채팅 기능, 백엔드 서버 없이 30분 만에 만들 수 있다면 믿으시겠어요?
안녕하세요! 2026년에도 여전히 실시간 채팅 구현이 어렵다고 느끼시는 분들 많으실 거예요. 저도 처음에는 WebSocket 서버 구축하고, 데이터베이스 설정하고... 생각만 해도 머리가 아팠거든요. 근데요, Supabase를 알게 된 후로는 완전 달라졌어요. PostgreSQL 기반의 실시간 기능이 이미 다 준비되어 있어서, 프론트엔드 개발자도 쉽게 실시간 채팅을 만들 수 있다는 거 알고 계셨나요? 오늘은 제가 직접 여러 프로젝트에 적용해본 경험을 바탕으로, Supabase로 실시간 채팅 구현하는 전 과정을 A부터 Z까지 다 알려드릴게요. 특히 2026년 최신 버전 기준으로 설명드리니까, 바로 따라하셔도 문제없으실 거예요!
? Supabase 프로젝트 생성과 초기 설정 방법
자, 그럼 본격적으로 시작해볼까요? 먼저 Supabase 계정이 필요해요. 이미 있으신 분들은 바로 다음 단계로 넘어가셔도 되고요. 없으신 분들은 supabase.com에 접속해서 GitHub 계정으로 간편하게 가입하실 수 있어요. 진짜 30초면 끝나요.
로그인하시면 대시보드가 보이실 거예요. 여기서 "New Project" 버튼을 클릭하세요. 프로젝트 이름은 원하시는 대로 지으시면 되는데요, 저는 보통 "realtime-chat-2026" 이런 식으로 연도를 붙여서 관리해요. 나중에 헷갈리지 않게요.
데이터베이스 비밀번호는 꼭 안전한 곳에 저장하세요! 나중에 복구가 안 돼요. 저는 Bitwarden 같은 비밀번호 관리자에 저장해두는데, 여러분도 그렇게 하시는 걸 추천드려요. 그리고 리전(Region)은 가능하면 한국이나 일본으로 선택하시면 레이턴시가 훨씬 적어져요.
프로젝트가 생성되면 약 2-3분 정도 기다리셔야 해요. 이 시간 동안 Supabase가 PostgreSQL 데이터베이스, API 엔드포인트, 인증 시스템 등을 자동으로 세팅해주거든요. 솔직히 말하자면, 이게 진짜 놀라운 부분이에요. 예전에는 이런 걸 직접 다 설정하느라 하루가 걸렸는데...
프로젝트가 준비되면 대시보드에서 "Settings" 메뉴로 들어가세요. 여기서 "API" 섹션에 가시면 두 가지 중요한 키가 보일 거예요. Project URL과 anon/public key인데요, 이 두 가지를 복사해서 환경 변수 파일(.env)에 저장하셔야 해요. 이게 나중에 프론트엔드에서 Supabase와 연결할 때 필요한 인증 정보거든요.
?️ PostgreSQL 스키마 설계로 실시간 채팅 기초 다지기
Supabase로 실시간 채팅을 만들 때 가장 먼저 해야 할 게 뭘까요? 바로 PostgreSQL 스키마 설계예요. 사실 많은 분들이 이 부분을 대충 넘어가시는데요, 여기서 제대로 설계해두지 않으면 나중에 진짜 골치 아파지거든요. 저도 처음엔 "그냥 messages 테이블 하나면 되겠지" 했다가... 나중에 전부 다시 짜야 했어요.
채팅 애플리케이션의 스키마 설계는 생각보다 섬세해야 해요. 단순히 메시지만 저장하는 게 아니라, 실시간 구독 성능과 쿼리 효율성까지 고려해야 하거든요.
? 채팅에 필요한 핵심 테이블 구조
실시간 채팅을 구현하려면 최소 3개의 테이블이 필요해요. 많아 보이시나요? 근데 이게 가장 효율적인 구조예요. 제가 여러 프로젝트에서 써본 결과, 이 구조가 확장성도 좋고 관리하기도 편하더라고요.
| 테이블명 | 주요 역할 | 필수 컬럼 |
|---|---|---|
| rooms | 채팅방 정보 관리 | id, name, created_at, updated_at |
| messages | 실제 메시지 저장 | id, room_id, user_id, content, created_at |
| room_members | 사용자-채팅방 연결 | id, room_id, user_id, joined_at |
? messages 테이블 상세 설계하기
자, 이제 가장 중요한 messages 테이블을 만들어볼까요? 이 테이블은 실시간으로 업데이트되는 핵심 테이블이거든요. 그래서 인덱싱이랑 타입 선택이 진짜 중요해요.
CREATE TABLE messages (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
room_id UUID REFERENCES rooms(id) ON DELETE CASCADE,
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
content TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
is_edited BOOLEAN DEFAULT false
);
-- 성능을 위한 인덱스 추가
CREATE INDEX idx_messages_room_created
ON messages(room_id, created_at DESC);
CREATE INDEX idx_messages_user
ON messages(user_id);
여기서 포인트가 몇 가지 있어요. 첫째, UUID를 기본키로 사용했다는 거예요. 2026년 현재는 분산 시스템을 고려하면 UUID가 훨씬 안전하거든요. 둘째는 복합 인덱스 (room_id, created_at DESC)를 만들었다는 건데요, 이게 채팅방별로 최신 메시지를 가져올 때 엄청 빨라져요.
created_at과 updated_at을 모두 만들어두세요. 나중에 "메시지 수정됨" 표시를 할 때 진짜 유용해요. is_edited 플래그도 함께 두면 완벽하죠!
? rooms와 room_members 테이블 구성
채팅방 테이블은 단순해 보이지만, 확장성을 생각하면 여러 컬럼을 준비해둬야 해요. 나중에 "그룹 채팅 vs 1:1 채팅 구분이 필요한데..." 하면서 테이블 수정하는 건 정말 번거롭거든요.
-- 채팅방 테이블
CREATE TABLE rooms (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
name TEXT,
room_type TEXT CHECK (room_type IN ('direct', 'group')),
created_by UUID REFERENCES auth.users(id),
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- 채팅방 멤버 테이블
CREATE TABLE room_members (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
room_id UUID REFERENCES rooms(id) ON DELETE CASCADE,
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
joined_at TIMESTAMPTZ DEFAULT now(),
last_read_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(room_id, user_id)
);
CREATE INDEX idx_room_members_user
ON room_members(user_id);
room_members 테이블에 last_read_at 컬럼을 넣은 이유가 뭘까요? 바로 읽지 않은 메시지 개수를 계산하기 위해서예요. 이거 하나로 "새 메시지 5개" 같은 알림 배지를 쉽게 구현할 수 있거든요.
⚡ 실시간 성능을 위한 인덱싱 전략
솔직히 말하자면요, 처음 만들 땐 인덱스를 대충 생각했어요. "나중에 필요하면 추가하지 뭐" 하면서요. 근데 사용자가 늘어나니까 쿼리가 엄청 느려지더라고요. 특히 채팅은 실시간이잖아요? 0.5초만 늦어져도 체감이 확 달라요.
| 인덱스 유형 | 적용 위치 | 성능 개선 효과 |
|---|---|---|
| 복합 인덱스 | messages(room_id, created_at) | 채팅방별 메시지 조회 3~5배 향상 |
| 단일 인덱스 | room_members(user_id) | 사용자 채팅방 목록 2~3배 향상 |
| UNIQUE 제약 | room_members(room_id, user_id) | 중복 가입 방지 + 조회 성능 향상 |
여기서 하나 더 팁을 드리자면요, DESC 정렬 인덱스를 꼭 활용하세요. 채팅은 보통 최신 메시지부터 보여주잖아요? created_at에 DESC 인덱스를 걸어두면 정렬 작업을 건너뛸 수 있어서 완전 빨라져요.
인덱스를 너무 많이 만들면 오히려 INSERT/UPDATE 성능이 떨어져요. 실시간 채팅은 쓰기 작업도 엄청 많거든요. 제 경험상 테이블당 3~4개 정도의 인덱스가 적당해요.
? Row Level Security로 보안 강화하기
PostgreSQL 스키마를 설계할 때 절대 빼먹으면 안 되는 게 RLS(Row Level Security)예요. Supabase는 이걸 기본으로 활용하거든요. 근데 많은 분들이 "나중에 설정하지 뭐" 하다가... 보안 이슈가 생겨요.
-- RLS 활성화
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
ALTER TABLE rooms ENABLE ROW LEVEL SECURITY;
ALTER TABLE room_members ENABLE ROW LEVEL SECURITY;
-- 메시지 읽기: 채팅방 멤버만 가능
CREATE POLICY "Members can read messages"
ON messages FOR SELECT
USING (
room_id IN (
SELECT room_id FROM room_members
WHERE user_id = auth.uid()
)
);
-- 메시지 작성: 채팅방 멤버만 가능
CREATE POLICY "Members can insert messages"
ON messages FOR INSERT
WITH CHECK (
room_id IN (
SELECT room_id FROM room_members
WHERE user_id = auth.uid()
)
);
이렇게 설정해두면 사용자가 속하지 않은 채팅방의 메시지는 아예 조회할 수 없어요. API를 통해 직접 접근하려고 해도 PostgreSQL 레벨에서 막히거든요. 진짜 안전하죠?
- UUID 기본키 사용으로 확장성 확보했나요?
- 복합 인덱스로 채팅방별 조회 최적화했나요?
- last_read_at 컬럼으로 읽지 않은 메시지 추적 가능한가요?
- RLS 정책으로 멤버만 메시지 접근 가능한가요?
- ON DELETE CASCADE로 연쇄 삭제 처리했나요?
자, 이제 PostgreSQL 스키마 설계의 핵심을 다 알려드렸어요. 이 구조대로만 만들어도 수천 명이 동시에 사용하는 채팅 서비스를 충분히 감당할 수 있거든요. 물론 나중에 기능이 추가되면 테이블도 조금씩 늘어나겠지만, 이 기본 구조는 그대로 유지하시면 돼요.
⚡ Supabase Realtime 설정하기

자, 이제 가장 중요한 부분이에요! Supabase Realtime 설정 없이는 실시간 채팅이 불가능하거든요. 사실은요, 2026년 현재 Supabase Realtime 설정이 이전보다 훨씬 간단해졌어요. 근데 처음 하시는 분들은 어디서부터 시작해야 할지 막막할 수 있죠.
제가 처음 Supabase로 실시간 기능 구현할 때요? 진짜 헤맸어요. 왜 데이터가 실시간으로 안 오는지 한참을 고민했는데, 알고 보니 테이블에 Realtime을 활성화 안 했더라고요.
? PostgreSQL 테이블에 Realtime 활성화하기
먼저 해야 할 일은 우리가 만든 messages 테이블에 Realtime을 켜는 거예요. Supabase 대시보드에서 클릭 몇 번이면 끝나요!
- Supabase 대시보드에서 Database 메뉴 클릭
- 왼쪽 사이드바에서 Replication 선택
- Source 섹션에서 0 tables 클릭
- messages 테이블 토글 켜기
- 완료!
이게 전부예요. 엄청 간단하죠?
Realtime을 활성화하면 해당 테이블의 모든 변경사항이 브로드캐스트돼요. 민감한 데이터가 있다면 RLS(Row Level Security) 정책을 꼭 설정하세요! 안 그러면 나중에 보안 문제가 생길 수 있거든요.
? 클라이언트에서 Realtime 구독 설정하기
테이블에 Realtime을 켰으면 이제 프론트엔드에서 실시간 데이터를 받아와야겠죠? JavaScript로 Supabase 클라이언트 구독을 설정하는 방법을 알려드릴게요.
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(
'YOUR_SUPABASE_URL',
'YOUR_SUPABASE_ANON_KEY'
)
// messages 테이블 변경사항 구독
const channel = supabase
.channel('messages-channel')
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'messages'
},
(payload) => {
console.log('실시간 데이터:', payload)
// 여기서 UI 업데이트!
}
)
.subscribe()
// 구독 해제는 이렇게
// supabase.removeChannel(channel)
위 코드가 기본 뼈대예요. 근데요, 실제로 사용할 때는 좀 더 세밀한 설정이 필요해요. 예를 들면 특정 이벤트만 감지한다거나, 필터를 걸어서 특정 데이터만 받아온다거나 하는 거죠.
? 이벤트 타입별 Realtime 설정 방법
Supabase Realtime에서는 여러 가지 이벤트를 감지할 수 있어요. 채팅 앱에서 필요한 핵심 이벤트들을 정리해드릴게요!
| 이벤트 타입 | 설명 | 사용 시기 |
|---|---|---|
| INSERT | 새 메시지 추가됨 | 새 채팅 표시 |
| UPDATE | 메시지 수정됨 | 메시지 편집 기능 |
| DELETE | 메시지 삭제됨 | 메시지 삭제 반영 |
| * | 모든 변경사항 | 전체 감지 |
보통은 INSERT 이벤트만 사용해도 기본 채팅 기능은 충분해요. 근데 메시지 편집이나 삭제 기능까지 넣으려면 다른 이벤트도 필요하죠.
const channel = supabase
.channel('new-messages')
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'messages'
},
(payload) => {
const newMessage = payload.new
addMessageToUI(newMessage)
}
)
.subscribe()
? 필터를 활용한 채널별 실시간 구독
진짜 중요한 건 바로 이거예요! 채팅방이 여러 개 있을 때, 각 방의 메시지만 따로 받아오려면 필터를 사용해야 해요. 안 그러면 모든 채팅방 메시지가 다 날아오거든요.
const roomId = 'room-123'
const channel = supabase
.channel(`room-${roomId}`)
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'messages',
filter: `room_id=eq.${roomId}` // 핵심!
},
(payload) => {
console.log(`${roomId}의 새 메시지:`, payload.new)
}
)
.subscribe()
filter 옵션이 바로 비밀이에요. 이걸로 특정 조건에 맞는 데이터만 실시간으로 받아올 수 있거든요. 2026년에는 이 필터 문법이 더욱 강력해졌어요!
필터는 PostgREST 문법을 따라요.
column=operator.value 형식이죠. 자주 쓰는 연산자는:
eq- 같음 (equal)neq- 같지 않음gt- 초과 (greater than)lt- 미만 (less than)
⚙️ 실전 Realtime 설정 최적화 팁
제가 실제로 사용하면서 터득한 최적화 방법들이에요. 이거 안 하면 나중에 성능 문제 생길 수 있어요!
- 컴포넌트 언마운트 시 구독 해제 - 메모리 누수 방지! React라면 useEffect cleanup 함수에서
supabase.removeChannel()호출하세요 - 채널 이름 중복 피하기 - 같은 채널명으로 여러 번 구독하면 문제 생겨요. 유니크한 이름 사용!
- 필요한 필드만 가져오기 -
select옵션으로 필요한 컬럼만 받아오세요 - 에러 핸들링 - 네트워크 끊김 등 예외 상황 대비는 필수예요
- 재연결 로직 구현 - 연결이 끊겼을 때 자동으로 다시 연결되게 해야죠
import { useEffect } from 'react'
function ChatRoom({ roomId }) {
useEffect(() => {
// 구독 시작
const channel = supabase
.channel(`room-${roomId}`)
.on('postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'messages',
filter: `room_id=eq.${roomId}`
},
handleNewMessage
)
.subscribe()
// cleanup 함수로 구독 해제
return () => {
supabase.removeChannel(channel)
}
}, [roomId]) // roomId 바뀔 때마다 재구독
function handleNewMessage(payload) {
// 메시지 처리 로직
}
return 채팅방
}
이렇게 하면 컴포넌트가 사라질 때 자동으로 구독이 해제돼요. 안 그러면 메모리 누수가 발생해서 앱이 점점 느려지거든요. 진짜예요!
? Presence와 Broadcast 기능 활용하기
2026년 Supabase Realtime의 진짜 강력한 기능은 Presence와 Broadcast예요. 이걸로 "누가 지금 타이핑 중" 같은 기능을 구현할 수 있거든요!
const channel = supabase.channel('room-presence')
// 내 정보 추가
channel
.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState()
console.log('현재 접속자:', state)
})
.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
await channel.track({
user_id: currentUser.id,
username: currentUser.name,
online_at: new Date().toISOString()
})
}
})
Presence는 누가 접속했는지 실시간으로 알려줘요. 채팅방에 몇 명이 있는지 보여주는 기능 만들 때 완전 유용하죠!
const channel = supabase.channel('typing-status')
// 타이핑 상태 보내기
function handleTyping() {
channel.send({
type: 'broadcast',
event: 'typing',
payload: {
user: currentUser.name,
isTyping: true
}
})
}
// 타이핑 상태 받기
channel
.on('broadcast', { event: 'typing' }, (payload) => {
console.log(`${payload.user}가 타이핑 중...`)
})
.subscribe()
Broadcast는 데이터베이스 저장 없이 바로 메시지를 전송해요. 임시 상태 같은 거 공유할 때 딱이에요. DB에 매번 저장하면 비효율적이잖아요?
| 기능 | 사용 시나리오 | DB 저장 |
|---|---|---|
| Database Changes | 채팅 메시지, 영구 데이터 | ⭕ 저장됨 |
| Broadcast | 타이핑 상태, 임시 이벤트 | ❌ 저장 안 됨 |
| Presence | 접속자 목록, 온라인 상태 | ❌ 저장 안 됨 |
이 세 가지를 잘 조합하면 완벽한 실시간 채팅 앱을 만들 수 있어요. 실제 메시지는 Database Changes로, 부가 기능은 Broadcast와 Presence로 처리하는 게 베스트 프랙티스예요!
? 프론트엔드 구현하기
자, 이제 Supabase 백엔드 설정이 끝났으니까요, 진짜 재미있는 파트인 프론트엔드 구현을 시작해볼게요. React를 사용해서 실시간 채팅 인터페이스를 만들 건데요, 생각보다 훨씬 간단해요.
근데... 처음에는 저도 "실시간 채팅이라니, 되게 복잡하겠네"라고 생각했거든요? 그런데 Supabase의 실시간 기능을 쓰면 WebSocket 설정 같은 복잡한 거 하나도 없이 바로 구현할 수 있어요. 진짜예요!
⚛️ React 프로젝트 초기 설정
먼저 React 프로젝트부터 만들어야겠죠. 2026년 기준으로 Vite가 진짜 빠르고 좋아서 이걸로 프로젝트를 생성해볼게요.
npm create vite@latest realtime-chat -- --template react
cd realtime-chat
npm install
# Supabase 클라이언트 라이브러리 설치
npm install @supabase/supabase-js
# 날짜 포맷팅용 (선택사항)
npm install date-fns
설치가 끝났으면요, Supabase 클라이언트를 초기화해야 해요. src 폴더에 supabaseClient.js 파일을 만들어주세요.
// src/supabaseClient.js
import { createClient } from '@supabase/supabase-js'
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY
export const supabase = createClient(supabaseUrl, supabaseAnonKey)
아 그리고요, 환경 변수 파일도 만들어야 해요. 프로젝트 루트에 .env 파일을 만들고 Supabase 대시보드에서 복사한 값들을 넣어주세요.
VITE_SUPABASE_URL=https://your-project.supabase.co
VITE_SUPABASE_ANON_KEY=your-anon-key-here
? 채팅 컴포넌트 만들기
이제 본격적으로 채팅 UI를 만들어볼 차례예요. 솔직히 말하자면요, 처음에는 복잡해 보이는데 하나씩 나눠서 보면 진짜 간단해요. 메시지 목록 보여주기, 메시지 입력하기, 이 두 가지만 잘 만들면 돼요.
// src/components/Chat.jsx
import { useState, useEffect, useRef } from 'react'
import { supabase } from '../supabaseClient'
import { formatDistanceToNow } from 'date-fns'
import { ko } from 'date-fns/locale'
export default function Chat({ currentUser }) {
const [messages, setMessages] = useState([])
const [newMessage, setNewMessage] = useState('')
const [loading, setLoading] = useState(true)
const messagesEndRef = useRef(null)
// 메시지 자동 스크롤
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}
useEffect(() => {
scrollToBottom()
}, [messages])
return (
<div className="chat-container">
<div className="messages">
{/* 메시지 목록 */}
</div>
<div className="input-area">
{/* 메시지 입력 */}
</div>
</div>
)
}
근데 여기서 중요한 게 있어요!
messagesEndRef라는 ref를 만들었잖아요? 이게 진짜 유용한데요, 새 메시지가 올 때마다 자동으로 스크롤을 맨 아래로 내려주는 역할을 해요. 이거 없으면 사용자가 일일이 스크롤해야 되거든요.
? 메시지 불러오기 및 실시간 구독
자, 이제 가장 중요한 부분이에요. Supabase에서 메시지를 불러오고, 실시간으로 새 메시지를 받아오는 코드를 작성해볼게요. 이 부분이 진짜 Supabase의 강점이거든요!
// Chat.jsx에 추가
useEffect(() => {
// 초기 메시지 로드
fetchMessages()
// 실시간 구독 설정
const subscription = supabase
.channel('public:messages')
.on('postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'messages'
},
(payload) => {
// 새 메시지가 추가되면 실행
setMessages(prev => [...prev, payload.new])
}
)
.subscribe()
// 컴포넌트 언마운트 시 구독 해제
return () => {
subscription.unsubscribe()
}
}, [])
// 메시지 가져오기 함수
async function fetchMessages() {
setLoading(true)
const { data, error } = await supabase
.from('messages')
.select('*')
.order('created_at', { ascending: true })
.limit(50)
if (error) {
console.error('메시지 로드 실패:', error)
} else {
setMessages(data)
}
setLoading(false)
}
와, 이 코드 좀 보세요! supabase.channel()을 사용하면 실시간으로 데이터베이스 변경사항을 감지할 수 있어요. WebSocket이니 뭐니 복잡한 거 전혀 없이요!
꼭 기억하세요!
return문에서 구독을 해제해야 해요. 안 그러면 메모리 누수가 발생할 수 있거든요. 제가 처음에 이거 안 해서 브라우저가 느려진 적이 있어요...
✍️ 메시지 전송 기능 구현
이제 메시지를 보내는 기능을 만들어야죠. 사실은요, 이 부분도 엄청 간단해요. Supabase의 insert() 메서드만 쓰면 되거든요.
async function sendMessage(e) {
e.preventDefault()
// 빈 메시지 방지
if (!newMessage.trim()) return
const { error } = await supabase
.from('messages')
.insert([
{
content: newMessage.trim(),
user_id: currentUser.id
}
])
if (error) {
console.error('메시지 전송 실패:', error)
alert('메시지 전송에 실패했어요. 다시 시도해주세요!')
} else {
setNewMessage('') // 입력창 비우기
}
}
근데 여기서 중요한 포인트가 있어요. 메시지를 보낸 후에 setMessages()로 직접 추가하지 않아요. 왜냐면 실시간 구독이 이미 설정되어 있어서, 데이터베이스에 INSERT가 발생하면 자동으로 payload.new로 새 메시지를 받아오거든요!
? 메시지 UI 렌더링하기
자, 이제 실제로 화면에 메시지를 보여줄 차례예요. 채팅앱이니까 내가 보낸 메시지랑 다른 사람이 보낸 메시지를 구분해서 보여줘야겠죠?
<div className="messages-container" style={{
height: '500px',
overflowY: 'auto',
padding: '20px',
backgroundColor: '#f9fafb'
}}>
{loading ? (
<div style={{ textAlign: 'center', padding: '20px' }}>
로딩중...
</div>
) : (
messages.map((message) => (
<div
key={message.id}
style={{
display: 'flex',
justifyContent: message.user_id === currentUser.id
? 'flex-end'
: 'flex-start',
marginBottom: '12px'
}}
>
<div style={{
maxWidth: '70%',
padding: '12px 16px',
borderRadius: '16px',
backgroundColor: message.user_id === currentUser.id
? '#4F46E5'
: '#ffffff',
color: message.user_id === currentUser.id
? '#ffffff'
: '#374151',
boxShadow: '0 1px 2px rgba(0,0,0,0.1)'
}}>
<div style={{ fontSize: '14px', marginBottom: '4px' }}>
{message.content}
</div>
<div style={{
fontSize: '11px',
opacity: 0.7,
textAlign: 'right'
}}>
{formatDistanceToNow(new Date(message.created_at), {
addSuffix: true,
locale: ko
})}
</div>
</div>
</div>
))
)}
<div ref={messagesEndRef} />
</div>
보세요! 내 메시지는 오른쪽에 보라색으로, 다른 사람 메시지는 왼쪽에 흰색으로 표시되게 만들었어요. 그리고 date-fns 라이브러리로 "5분 전", "1시간 전" 이런 식으로 시간을 표시하니까 훨씬 보기 좋더라고요.
⚡ 성능 최적화 포인트
실시간 채팅을 만들 때 놓치기 쉬운 성능 이슈들이 있어요. 제가 직접 겪었던 문제들을 바탕으로 정리해드릴게요.
| 문제 상황 | 원인 | 해결 방법 |
|---|---|---|
| 메시지가 너무 많아지면 느려짐 | 모든 메시지를 한번에 렌더링 | 최근 50개만 불러오기, 무한 스크롤 적용 |
| 중복 메시지 표시 | 실시간 구독과 fetch가 동시에 | 메시지 ID로 중복 체크 후 추가 |
| 스크롤이 튀는 현상 | 새 메시지 추가 시 레이아웃 변경 | smooth scroll 옵션 사용 |
| 메모리 누수 | 구독 해제를 안 함 | useEffect cleanup에서 unsubscribe() |
특히 중복 메시지 문제는 진짜 골치 아팠어요. 처음에는 왜 같은 메시지가 두 번씩 보이는지 몰랐거든요. 알고 보니까 fetchMessages()로 불러온 메시지에 실시간 구독으로 받은 메시지가 겹쳐서 그런 거였어요.
// 실시간 구독 부분 수정
.on('postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'messages' },
(payload) => {
setMessages(prev => {
// 이미 존재하는 메시지인지 확인
const exists = prev.find(msg => msg.id === payload.new.id)
if (exists) return prev
// 없으면 추가
return [...prev, payload.new]
})
}
)
? 사용자 인증 상태 관리하기
채팅을 구현했으니까 이제 누가 메시지를 보냈는지 알아야겠죠? 간단한 사용자 인증을 추가해볼게요. 복잡한 OAuth는 나중에 하고, 일단 닉네임만 입력받는 방식으로 시작해요.
// App.jsx
import { useState, useEffect } from 'react'
import Chat from './components/Chat'
function App() {
const [currentUser, setCurrentUser] = useState(null)
const [username, setUsername] = useState('')
// 로컬 스토리지에서 사용자 정보 가져오기
useEffect(() => {
const savedUser = localStorage.getItem('chatUser')
if (savedUser) {
setCurrentUser(JSON.parse(savedUser))
}
}, [])
const handleLogin = (e) => {
e.preventDefault()
if (!username.trim()) return
const user = {
id: Date.now(), // 임시 ID (나중에 실제 인증으로 교체)
username: username.trim()
}
localStorage.setItem('chatUser', JSON.stringify(user))
setCurrentUser(user)
}
if (!currentUser) {
return (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
backgroundColor: '#f9fafb'
}}>
<form onSubmit={handleLogin} style={{
background: 'white',
padding: '40px',
borderRadius: '16px',
boxShadow: '0 4px 6px rgba(0,0,0,0.1)'
}}>
<h2 style={{ marginBottom: '20px' }}>채팅 시작하기</h2>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="닉네임을 입력하세요"
style={{
width: '100%',
padding: '12px',
marginBottom: '12px',
border: '1px solid #e5e7eb',
borderRadius: '8px'
}}
/>
<button type="submit" style={{
width: '100%',
padding: '12px',
backgroundColor: '#4F46E5',
color: 'white',
border: 'none',
borderRadius: '8px',
cursor: 'pointer'
}}>
입장하기
</button>
</form>
</div>
)
}
return <Chat currentUser={currentUser} />
}
이렇게 하면 일단 채팅을 시작할 수 있어요. 나중에 Supabase Auth를 연동하면 진짜 제대로 된 사용자 관리를 할 수 있는데요, 그건 다음 섹션에서 다룰게요!
지금 만든 사용자 시스템은 임시예요. 실제 프로덕션에서는 반드시 Supabase Auth나 다른 인증 시스템을 사용해야 해요. 보안 문제가 있거든요!
? 반응형 디자인 적용하기
요즘은 모바일에서 채팅 많이 하잖아요? 그래서 반응형으로 만드는 게 진짜 중요해요. 솔직히 데스크톱에서만 테스트하다가 나중에 모바일에서 보면 깨져있는 경우 많거든요.
/* App.css */
.chat-container {
max-width: 800px;
margin: 0 auto;
height: 100vh;
display: flex;
flex-direction: column;
background: white;
}
.messages-container {
flex: 1;
overflow-y: auto;
padding: 20px;
background: #f9fafb;
}
.input-area {
padding: 16px;
background: white;
border-top: 1px solid #e5e7eb;
display: flex;
gap: 8px;
}
/* 모바일 대응 */
@media (max-width: 768px) {
.chat-container {
max-width: 100%;
border-radius: 0;
}
.messages-container {
padding: 12px;
}
.message-bubble {
max-width: 85% !important;
}
}
모바일에서는 화면이 작으니까 메시지 말풍선 최대 너비를 85%로 줄였어요. 그리고 패딩도 좀 줄여서 공간을 효율적으로 쓰도록 했고요. 이런 작은 디테일이 사용자 경험을 많이 좌우해요.
크롬 개발자 도구에서 Device Toolbar를 켜고 여러 기기 크기로 테스트해보세요. 특히 iPhone SE 같은 작은 화면에서도 잘 보이는지 확인하는 게 중요해요!
? 메시지 송수신 처리하기
이제 본격적으로 Supabase로 실시간 채팅의 핵심인 메시지 송수신을 구현해볼 차례예요. 사실 처음에는 복잡할 것 같지만, 막상 해보면 생각보다 간단하거든요. 메시지를 보내고 받는 건 결국 PostgreSQL에 데이터를 넣고 빼는 거잖아요. 근데 여기에 실시간 구독 기능이 더해지면서 마법같은 일이 일어나는 거죠.
메시지 전송 구현하기
메시지를 보내는 건 정말 간단해요. Supabase 클라이언트로 INSERT 쿼리만 날리면 되거든요. 제가 처음 구현했을 때는 "이게 전부야?" 싶을 정도로 코드가 짧아서 놀랐어요.
const sendMessage = async (content) => {
const { data, error } = await supabase
.from('messages')
.insert([
{
content: content,
user_id: currentUser.id,
room_id: currentRoomId,
created_at: new Date().toISOString()
}
])
.select()
if (error) {
console.error('메시지 전송 실패:', error)
return false
}
return true
}
근데요, 실전에서는 이것보다 조금 더 신경 써야 할 부분들이 있어요. 에러 처리는 기본이고, 로딩 상태 관리, 그리고 전송 실패했을 때 재시도 로직까지 넣어주면 훨씬 안정적인 채팅 앱이 되거든요.
메시지 전송할 때 낙관적 업데이트(Optimistic Update)를 사용하면 사용자 경험이 완전 달라져요. 서버 응답을 기다리지 않고 먼저 UI에 메시지를 표시한 다음, 실패하면 다시 제거하는 방식이죠. 제가 써봤는데 채팅이 훨씬 빠르게 느껴지더라고요.
실시간 메시지 수신 구독하기
이제 진짜 핵심이에요. Supabase의 실시간 구독 기능을 사용해서 새로운 메시지가 들어올 때마다 자동으로 화면에 표시되게 만드는 거죠. 이게 바로 실시간 채팅의 마법이거든요.
useEffect(() => {
const channel = supabase
.channel('room-messages')
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'messages',
filter: `room_id=eq.${currentRoomId}`
},
(payload) => {
setMessages(prev => [...prev, payload.new])
}
)
.subscribe()
return () => {
supabase.removeChannel(channel)
}
}, [currentRoomId])
솔직히 말하자면 처음에는 이 구독 로직이 좀 헷갈렸어요. 특히 컴포넌트가 언마운트될 때 채널을 제대로 정리하지 않으면 메모리 누수가 생기거든요. 그래서 꼭 클린업 함수에서 채널을 제거해주셔야 해요.
메시지 상태 관리 전략
메시지를 어떻게 관리할지도 진짜 중요해요. 단순하게 state 배열에 계속 추가하다 보면 나중에 성능 문제가 생기거든요. 특히 메시지가 수천 개 쌓이면 렌더링이 느려지는 걸 체감할 수 있어요.
| 관리 방식 | 장점 | 단점 | 추천 상황 |
|---|---|---|---|
| 단순 배열 | 구현이 쉽고 직관적 | 메시지 많아지면 성능 저하 | 소규모 채팅, 프로토타입 |
| 페이지네이션 | 성능 최적화, 메모리 효율적 | 구현 복잡도 증가 | 중대규모 채팅 앱 |
| 무한 스크롤 | UX 최고, 자연스러운 로딩 | 스크롤 위치 관리 어려움 | 메신저 스타일 채팅 |
| 가상화(Virtualization) | 수만 개 메시지도 빠름 | 라이브러리 필요, 복잡한 설정 | 대용량 채팅, 기업용 |
제가 2026년에 프로젝트 할 때는 무한 스크롤 방식을 썼는데요, react-intersection-observer 라이브러리랑 조합하니까 정말 자연스럽게 구현되더라고요. 사용자가 위로 스크롤하면 자동으로 이전 메시지를 로드하는 거죠.
메시지 전송 상태 표시하기
카카오톡이나 슬랙 같은 메신저를 보면 메시지 옆에 '전송 중', '전송 완료', '읽음' 같은 상태가 표시되잖아요. 이것도 Supabase로 구현할 수 있어요. 생각보다 어렵지 않거든요.
const [messages, setMessages] = useState([])
const sendMessage = async (content) => {
// 임시 ID로 낙관적 업데이트
const tempId = `temp-${Date.now()}`
const tempMessage = {
id: tempId,
content,
status: 'sending',
created_at: new Date().toISOString()
}
setMessages(prev => [...prev, tempMessage])
const { data, error } = await supabase
.from('messages')
.insert([{ content, user_id: userId, room_id: roomId }])
.select()
.single()
if (error) {
// 전송 실패 상태로 업데이트
setMessages(prev =>
prev.map(msg =>
msg.id === tempId
? { ...msg, status: 'failed' }
: msg
)
)
} else {
// 임시 메시지를 실제 메시지로 교체
setMessages(prev =>
prev.map(msg =>
msg.id === tempId
? { ...data, status: 'sent' }
: msg
)
)
}
}
이 방식의 좋은 점은 사용자가 메시지를 보낸 순간 바로 화면에 표시된다는 거예요. 네트워크가 느려도 답답함이 없죠. 근데 주의할 점은 임시 ID와 실제 ID를 제대로 매핑해줘야 한다는 거예요.
타이핑 인디케이터 구현하기
"상대방이 입력 중입니다..." 이 기능도 실시간 채팅에서 빠질 수 없죠. 이것도 Supabase의 Presence 기능을 사용하면 되는데요, 진짜 신기하게도 몇 줄 안 되는 코드로 구현돼요.
const channel = supabase.channel('room-typing')
// 타이핑 시작
const startTyping = () => {
channel.track({
user: currentUser.name,
typing: true,
timestamp: Date.now()
})
}
// 타이핑 중지
const stopTyping = () => {
channel.track({
user: currentUser.name,
typing: false
})
}
// 다른 사용자 타이핑 상태 구독
channel.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState()
const typingUsers = Object.values(state)
.flat()
.filter(user => user.typing && user.user !== currentUser.name)
setTypingUsers(typingUsers)
})
참고로 타이핑 이벤트는 너무 자주 보내면 성능에 영향을 줄 수 있어요. 그래서 디바운스(debounce)를 걸어주는 게 좋은데요, 보통 300ms 정도가 적당하더라고요. 제가 직접 테스트해봤을 때는 이 정도가 가장 자연스러웠어요.
타이핑 인디케이터는 편리하지만 네트워크 트래픽을 증가시켜요. 특히 사용자가 많은 채팅방에서는 부담이 될 수 있으니, 필요에 따라 활성화/비활성화 옵션을 주는 것도 좋은 방법이에요.
메시지 에러 처리 및 재시도
실전에서는 네트워크 오류, 권한 문제, 서버 에러 등 다양한 상황이 발생할 수 있어요. 사용자가 메시지를 보냈는데 전송이 실패하면 정말 답답하잖아요. 그래서 에러 처리와 재시도 로직은 필수예요.
| 에러 타입 | 원인 | 처리 방법 | 재시도 여부 |
|---|---|---|---|
| 네트워크 오류 | 인터넷 연결 끊김 | 자동 재시도 (exponential backoff) | ✅ 권장 |
| 권한 에러 | RLS 정책 위반, 토큰 만료 | 토큰 갱신 후 재시도 | ✅ 1회 |
| 유효성 검증 실패 | 빈 메시지, 너무 긴 내용 | 사용자에게 에러 메시지 표시 | ❌ 불가 |
| 서버 에러 | 500 에러, DB 문제 | 제한된 재시도 (최대 3회) | ✅ 제한적 |
저는 보통 exponential backoff 방식으로 재시도를 구현하는데요, 첫 시도 실패 후 1초, 2초, 4초... 이런 식으로 대기 시간을 늘려가는 거예요. 이렇게 하면 서버에 부담도 덜 주고 사용자 경험도 좋아지거든요.
IndexedDB나 LocalStorage를 활용해서 오프라인 모드를 지원하면 완전 프로급이에요. 사용자가 지하철에서 메시지를 쓰면 일단 로컬에 저장했다가, 네트워크가 복구되면 자동으로 전송하는 거죠. 제가 구현해봤는데 생각보다 어렵지 않더라고요.
메시지 송수신 처리는 실시간 채팅의 핵심이지만, 막상 하나하나 구현해보면 그렇게 복잡하지 않아요. Supabase가 대부분의 복잡한 부분을 처리해주니까요. 여러분도 한번 직접 해보시면 금방 익숙해질 거예요!
? 배포 환경 최적화와 운영 노하우
자, 이제 채팅 앱을 실제로 배포할 차례예요. 근데요, 그냥 배포만 하면 되는 게 아니라 Supabase 실시간 기능을 운영 환경에서 안정적으로 돌리려면 알아야 할 게 진짜 많거든요. 2026년 현재 프로덕션 환경에서 PostgreSQL 기반 실시간 채팅을 운영하면서 겪은 시행착오들을 공유해드릴게요.
? Vercel 배포 시 환경 변수 설정
솔직히 말하자면, 처음 Vercel에 배포했을 때 실시간 연결이 계속 끊기는 문제가 있었어요. 알고 보니까 환경 변수를 제대로 안 설정해서 그랬더라고요.
- NEXT_PUBLIC_SUPABASE_URL: 클라이언트에서 접근 가능해야 해요
- NEXT_PUBLIC_SUPABASE_ANON_KEY: 공개 키는 반드시 NEXT_PUBLIC_ 접두사 필요
- SUPABASE_SERVICE_ROLE_KEY: 서버 사이드 작업용, 절대 노출 금지!
- NODE_ENV: production으로 꼭 설정하세요
참고로 Vercel 대시보드에서 환경 변수를 추가할 때 Preview, Development, Production 환경을 각각 체크해줘야 해요. 저는 이거 몰라서 스테이징 환경에서 테스트가 안 됐었거든요.
⚡ Realtime 연결 최적화 설정
배포 후에 가장 중요한 건 실시간 연결을 안정적으로 유지하는 거예요. 특히 서버리스 환경에서는 콜드 스타트 문제도 있고, WebSocket 연결 관리가 좀 까다롭거든요.
// supabase 클라이언트 설정
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
realtime: {
params: {
eventsPerSecond: 10 // 초당 이벤트 제한
}
},
auth: {
persistSession: true,
autoRefreshToken: true,
detectSessionInUrl: true
}
}
)
// 재연결 로직 추가
channel
.on('system', { event: 'error' }, (payload) => {
console.error('Realtime error:', payload)
// 자동 재연결 시도
setTimeout(() => {
channel.subscribe()
}, 3000)
})
뭐랄까, eventsPerSecond 값을 너무 높게 설정하면 Supabase 무료 플랜의 quota를 금방 넘어버려요. 제 경험상 10~15 정도가 채팅 앱에는 적당하더라고요.
? Row Level Security 프로덕션 설정
아 그리고요, 개발할 때는 RLS를 좀 느슨하게 설정했다면, 배포 전에 반드시 보안 정책을 강화해야 해요. 진짜 중요해요!
-- 읽기 권한: 채팅방 멤버만
CREATE POLICY "Members can view messages"
ON messages FOR SELECT
USING (
EXISTS (
SELECT 1 FROM room_members
WHERE room_members.room_id = messages.room_id
AND room_members.user_id = auth.uid()
)
);
-- 쓰기 권한: 본인 메시지만
CREATE POLICY "Users can insert own messages"
ON messages FOR INSERT
WITH CHECK (
auth.uid() = user_id
AND EXISTS (
SELECT 1 FROM room_members
WHERE room_members.room_id = messages.room_id
AND room_members.user_id = auth.uid()
)
);
-- 삭제 권한: 본인 메시지 또는 관리자
CREATE POLICY "Users can delete own messages"
ON messages FOR DELETE
USING (
auth.uid() = user_id
OR EXISTS (
SELECT 1 FROM room_members
WHERE room_members.room_id = messages.room_id
AND room_members.user_id = auth.uid()
AND room_members.role = 'admin'
)
);
? 데이터베이스 인덱스 최적화
사용자가 늘어나면서 쿼리 속도가 느려지는 거 경험해보셨어요? 저는 처음에 인덱스를 안 걸어놔서 메시지가 1000개만 넘어가도 로딩이 엄청 느려졌었거든요.
-- 메시지 조회 최적화
CREATE INDEX idx_messages_room_created
ON messages(room_id, created_at DESC);
-- 사용자별 메시지 조회
CREATE INDEX idx_messages_user
ON messages(user_id, created_at DESC);
-- 읽음 상태 확인 최적화
CREATE INDEX idx_message_reads_composite
ON message_reads(message_id, user_id);
-- 채팅방 멤버 조회
CREATE INDEX idx_room_members_user
ON room_members(user_id, room_id);
근데 인덱스를 너무 많이 만들면 쓰기 성능이 떨어질 수 있어요. 실제로 자주 사용하는 쿼리 패턴에만 인덱스를 걸어주는 게 좋아요.
? 성능 모니터링 설정
배포하고 나서 제일 중요한 게 뭔지 아세요? 바로 모니터링이에요. Supabase 대시보드에서 기본 메트릭은 볼 수 있지만, 좀 더 상세한 정보가 필요하거든요.
| 모니터링 항목 | 확인 방법 | 권장 임계값 |
|---|---|---|
| Realtime 연결 수 | Supabase Dashboard → Reports | 무료: 200개 미만 |
| Database 용량 | Settings → Database | 무료: 500MB 미만 |
| API 요청 수 | Reports → API 섹션 | 무료: 50만/월 미만 |
| 평균 응답 시간 | PostgreSQL 슬로우 쿼리 로그 | 100ms 미만 |
Supabase SQL Editor에서
pg_stat_statements 뷰를 활용하면 느린 쿼리를 찾을 수 있어요. 정기적으로 체크하면서 최적화가 필요한 부분을 찾아보세요.
? 데이터베이스 백업 전략
사실은요, 처음엔 백업을 별로 신경 안 썼는데... 한 번 실수로 데이터를 날릴 뻔한 적이 있어서 그 이후로는 진짜 철저하게 관리하고 있어요.
- 자동 백업: Supabase Pro 플랜 이상에서는 일일 자동 백업 지원돼요
- 수동 백업: 중요한 업데이트 전에는 pg_dump로 수동 백업 추천해요
- Point-in-time Recovery: Pro 플랜에서는 7일간 복구 가능해요
- 주기적 체크: 월 1회 백업 복원 테스트 해보는 게 좋아요
? 비용 최적화 노하우
솔직히 말하자면, 무료 플랜만으로는 어느 정도 규모 이상 되면 한계가 있어요. 근데 유료 플랜으로 넘어가기 전에 최적화할 수 있는 부분들이 꽤 많거든요.
- 오래된 메시지 아카이빙: 3개월 이상 된 메시지는 별도 테이블로 이동시켜요
- 이미지 압축: Supabase Storage에 올리기 전에 클라이언트단에서 압축하세요
- 불필요한 구독 정리: 페이지 벗어날 때 channel.unsubscribe() 꼭 호출하기
- 연결 풀링: Supavisor 활용해서 DB 연결 수 최적화해요
- 캐싱 전략: React Query나 SWR로 API 호출 줄이기
저는 이렇게 최적화하니까 무료 플랜으로도 DAU 500명 정도까지는 버틸 수 있었어요. 물론 그 이상 가면 Pro 플랜($25/월)으로 업그레이드하는 게 나아요.
? 에러 처리와 장애 대응
배포하고 나면 예상치 못한 에러들이 진짜 많이 발생해요. 특히 실시간 연결이 끊겼을 때 사용자 경험을 어떻게 유지할지가 중요하거든요.
// 연결 상태 관리
const [connectionStatus, setConnectionStatus] = useState('connected')
useEffect(() => {
const channel = supabase.channel('room-1')
channel
.on('system', { event: 'error' }, () => {
setConnectionStatus('error')
toast.error('연결이 끊어졌습니다. 재연결 시도 중...')
})
.on('system', { event: 'disconnected' }, () => {
setConnectionStatus('disconnected')
})
.on('system', { event: 'connected' }, () => {
setConnectionStatus('connected')
toast.success('연결되었습니다!')
})
.subscribe((status) => {
if (status === 'CHANNEL_ERROR') {
// 3초 후 재연결 시도
setTimeout(() => channel.subscribe(), 3000)
}
})
return () => {
channel.unsubscribe()
}
}, [])
// UI에 연결 상태 표시
{connectionStatus !== 'connected' && (
연결 끊김 - 메시지가 실시간으로 전송되지 않을 수 있습니다
)}
이렇게 연결 상태를 시각적으로 보여주면 사용자들이 "왜 메시지가 안 가지?" 하면서 당황하지 않게 돼요. 진짜 중요해요!
배포 환경 최적화는 한 번에 끝나는 게 아니라 계속 개선해나가는 과정이에요. 특히 Supabase와 PostgreSQL을 활용한 실시간 채팅은 사용자가 늘어날수록 새로운 병목 지점이 생기거든요. 주기적으로 성능을 체크하고, 사용자 피드백을 반영하면서 점진적으로 개선해보세요!
❓ 자주 묻는 질문
대부분 RLS 정책이 너무 복잡하게 설정되어 있거나, 인덱스가 없어서 그래요. Supabase의 실시간 채팅 성능은 PostgreSQL 쿼리 최적화에 달려있거든요. created_at과 room_id에 복합 인덱스를 걸어주세요. 또 RLS 정책에서 EXISTS 대신 = auth.uid() 같은 단순 비교를 쓰면 훨씬 빨라져요. 저도 처음에는 복잡한 서브쿼리를 썼다가 평균 200ms나 지연됐었는데, 단순화하니까 30ms로 줄어들더라고요!
맞아요. 그래서 Supabase 실시간 채팅에서는 동적 구독 전환을 써야 해요. 현재 활성화된 채팅방만 구독하고, 다른 방으로 이동할 때 기존 구독을 unsubscribe()한 뒤 새 채널을 구독하는 방식이죠. 아니면 하나의 넓은 필터로 구독하고 클라이언트에서 room_id로 필터링하는 방법도 있어요. 근데 진짜 많은 방(100개 이상)이면 전자가 낫고, 적으면 후자가 편해요. 저는 보통 5개 이하일 때는 그냥 전체 구독하고 클라이언트에서 필터링해요.
엄청 많아요! Supabase 실시간 채팅에서 제일 흔한 실수죠. RLS 정책이 SELECT만 허용하고 있으면, PostgreSQL이 변경 이벤트는 보내는데 실제 데이터 조회가 안 돼서 빈 payload가 와요. 그래서 반드시 SELECT 정책도 같이 설정해줘야 해요. 또 USING 절에서 너무 복잡한 조인을 쓰면 권한 검사에서 실패할 수도 있어요. 저는 처음에 3단계 조인을 넣었다가 계속 안 와서 진짜 미칠 뻔했는데, 단순하게 바꾸니까 바로 해결됐어요.
Supabase 채팅 구현할 때 .range()랑 .order('created_at', { ascending: false })를 조합하면 돼요. 처음에 최신 20개를 불러오고, 스크롤 올릴 때마다 .lt('created_at', oldestMessageTimestamp)로 이전 메시지를 가져오면 되죠. React라면 IntersectionObserver로 맨 위 메시지가 보이면 다음 페이지를 로드하는 식으로요. 근데 주의할 점은 PostgreSQL에서 created_at 인덱스가 꼭 있어야 빠르다는 거예요. 안 그러면 데이터 많을 때 진짜 느려요.
완전 가능해요! Supabase 실시간 채팅에서 read_receipts 테이블을 따로 만들고, user_id, message_id, read_at 컬럼을 넣으면 돼요. 그리고 이 테이블도 실시간 구독해서 누가 읽었는지 즉시 반영하는 거죠. 근데 메시지가 엄청 많으면 각 메시지마다 읽음 레코드가 생기니까 데이터가 폭발적으로 늘어날 수 있어요. 그래서 실무에서는 보통 방 단위로 last_read_at만 저장하고, 그 시간보다 이후 메시지는 안 읽은 걸로 처리하는 방식을 많이 써요.
당연하죠! Supabase 채팅 배포할 때 로컬의 .env.local 파일은 Vercel에 자동으로 안 올라가요. Vercel 대시보드 → Settings → Environment Variables에서 NEXT_PUBLIC_SUPABASE_URL과 NEXT_PUBLIC_SUPABASE_ANON_KEY를 직접 입력해줘야 해요. 그리고 Production, Preview, Development 환경별로 다르게 설정할 수도 있는데요, 보통은 Production만 따로 설정하고 나머지는 같이 쓰는 경우가 많아요. 설정 후에는 반드시 재배포해야 반영되니까 주의하세요!
✨ 마무리하며
여기까지 2026년 Supabase로 실시간 채팅을 구현하는 전체 과정을 다뤄봤어요. PostgreSQL 테이블 설계부터 시작해서 RLS 보안 설정, 실시간 구독 코드 작성, 그리고 Vercel 배포까지 정말 다양한 내용이었죠? 솔직히 말하자면 처음에는 복잡해 보일 수도 있는데요, 한 번 구조를 이해하고 나면 Supabase 실시간 채팅이 얼마나 강력한지 느껴질 거예요.
특히 Firebase나 Socket.io를 쓸 때와 달리 PostgreSQL을 직접 다루니까 데이터 구조 설계의 자유도가 정말 높아요. 복잡한 비즈니스 로직도 SQL 함수로 처리할 수 있고요. 여러분도 이 글을 참고해서 나만의 채팅 서비스를 한번 만들어보세요. 막히는 부분 있으면 댓글로 언제든 물어봐 주시고요, 혹시 더 좋은 방법을 찾으셨다면 공유도 환영이에요!
2026년, 더 많은 분들이 Supabase로 멋진 실시간 채팅 서비스를 만들어보셨으면 좋겠네요. 읽어주셔서 감사합니다! ?
댓글 0개
첫 번째 댓글을 남겨보세요!