시리즈 목차 1~12편 – Supabase 이론 완벽 가이드 13편 👉 실전: 실시간 채팅 앱 (현재 글) 14편 – 실전: SaaS 대시보드 15편 – 실전: AI 챗봇 서비스 16편 – 실전: Todo/협업툴
완성 기능 미리보기
이번 편에서 만들 채팅 앱의 기능입니다.
- 인증: 이메일/소셜 로그인 (Google)
- 채널: 여러 채팅방 생성·참여
- 실시간 메시지: Broadcast + Postgres Changes
- 온라인 상태: Presence로 접속 중인 유저 표시
- 타이핑 표시기: “홍길동 님이 입력 중…” 인디케이터
- 파일 첨부: Storage로 이미지 업로드
- 읽음 확인: 메시지별 읽은 유저 추적
- RLS 보안: 채널 멤버만 메시지 조회 가능
my-chat-app/
├── src/
│ ├── app/
│ │ ├── (auth)/login/page.tsx
│ │ ├── (chat)/
│ │ │ ├── layout.tsx ← 채널 사이드바
│ │ │ ├── page.tsx ← 채널 선택 안내
│ │ │ └── [channelId]/
│ │ │ └── page.tsx ← 채팅 화면
│ │ ├── auth/callback/route.ts
│ │ └── layout.tsx
│ ├── components/
│ │ ├── chat/
│ │ │ ├── MessageList.tsx
│ │ │ ├── MessageInput.tsx
│ │ │ ├── TypingIndicator.tsx
│ │ │ └── OnlineUsers.tsx
│ │ └── sidebar/
│ │ └── ChannelList.tsx
│ ├── hooks/
│ │ ├── useChat.ts
│ │ ├── usePresence.ts
│ │ └── useTyping.ts
│ └── lib/
│ ├── supabase/
│ │ ├── client.ts
│ │ └── server.ts
│ └── database.types.ts
└── supabase/
├── migrations/
└── seed.sql
1단계: 프로젝트 세팅
npx create-next-app@latest my-chat-app --typescript --tailwind --app
cd my-chat-app
npm install @supabase/supabase-js @supabase/ssr
# 로컬 Supabase 초기화
supabase init
supabase start
# .env.local
NEXT_PUBLIC_SUPABASE_URL=http://localhost:54321
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-local-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-local-service-role-key
2단계: 데이터베이스 스키마
supabase migration new init_chat_schema
-- supabase/migrations/[timestamp]_init_chat_schema.sql
-- 프로필 테이블
CREATE TABLE profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
avatar_url TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 채널 테이블
CREATE TABLE channels (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE,
description TEXT,
created_by UUID REFERENCES profiles(id) ON DELETE SET NULL,
is_private BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 채널 멤버 테이블
CREATE TABLE channel_members (
channel_id UUID REFERENCES channels(id) ON DELETE CASCADE,
user_id UUID REFERENCES profiles(id) ON DELETE CASCADE,
role TEXT DEFAULT 'member' CHECK (role IN ('owner', 'admin', 'member')),
joined_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (channel_id, user_id)
);
-- 메시지 테이블
CREATE TABLE messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
content TEXT,
file_url TEXT,
file_type TEXT, -- 'image' | 'file'
reply_to UUID REFERENCES messages(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT content_or_file CHECK (content IS NOT NULL OR file_url IS NOT NULL)
);
-- 읽음 추적 테이블
CREATE TABLE message_reads (
message_id UUID REFERENCES messages(id) ON DELETE CASCADE,
user_id UUID REFERENCES profiles(id) ON DELETE CASCADE,
read_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (message_id, user_id)
);
-- 인덱스
CREATE INDEX idx_messages_channel_created ON messages(channel_id, created_at DESC);
CREATE INDEX idx_channel_members_user ON channel_members(user_id);
-- updated_at 자동 갱신 트리거
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN NEW.updated_at = NOW(); RETURN NEW; END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER messages_updated_at
BEFORE UPDATE ON messages
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
-- 신규 유저 프로필 자동 생성 트리거
CREATE OR REPLACE FUNCTION handle_new_user()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO public.profiles (id, name, avatar_url)
VALUES (
NEW.id,
COALESCE(NEW.raw_user_meta_data->>'name', split_part(NEW.email, '@', 1)),
NEW.raw_user_meta_data->>'avatar_url'
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION handle_new_user();
-- Realtime 활성화
ALTER PUBLICATION supabase_realtime ADD TABLE messages;
ALTER PUBLICATION supabase_realtime ADD TABLE channel_members;
RLS 정책
-- supabase/migrations/[timestamp]_chat_rls.sql
-- profiles: 모든 인증 유저가 프로필 조회 가능
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
CREATE POLICY "프로필 조회"
ON profiles FOR SELECT TO authenticated USING (true);
CREATE POLICY "본인 프로필 수정"
ON profiles FOR UPDATE TO authenticated
USING ((SELECT auth.uid()) = id)
WITH CHECK ((SELECT auth.uid()) = id);
-- channels: 퍼블릭 채널은 모두 조회, 프라이빗은 멤버만
ALTER TABLE channels ENABLE ROW LEVEL SECURITY;
CREATE POLICY "퍼블릭 채널 조회"
ON channels FOR SELECT TO authenticated
USING (
is_private = false
OR EXISTS (
SELECT 1 FROM channel_members
WHERE channel_id = id AND user_id = (SELECT auth.uid())
)
);
CREATE POLICY "채널 생성"
ON channels FOR INSERT TO authenticated
WITH CHECK ((SELECT auth.uid()) = created_by);
-- channel_members
ALTER TABLE channel_members ENABLE ROW LEVEL SECURITY;
CREATE POLICY "멤버 조회"
ON channel_members FOR SELECT TO authenticated
USING (
EXISTS (
SELECT 1 FROM channel_members cm
WHERE cm.channel_id = channel_id
AND cm.user_id = (SELECT auth.uid())
)
);
CREATE POLICY "채널 참여"
ON channel_members FOR INSERT TO authenticated
WITH CHECK (user_id = (SELECT auth.uid()));
-- messages: 채널 멤버만 조회/작성
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
CREATE POLICY "메시지 조회"
ON messages FOR SELECT TO authenticated
USING (
EXISTS (
SELECT 1 FROM channel_members
WHERE channel_id = messages.channel_id
AND user_id = (SELECT auth.uid())
)
);
CREATE POLICY "메시지 작성"
ON messages FOR INSERT TO authenticated
WITH CHECK (
user_id = (SELECT auth.uid())
AND EXISTS (
SELECT 1 FROM channel_members
WHERE channel_id = messages.channel_id
AND user_id = (SELECT auth.uid())
)
);
CREATE POLICY "본인 메시지 수정/삭제"
ON messages FOR UPDATE TO authenticated
USING (user_id = (SELECT auth.uid()));
CREATE POLICY "본인 메시지 삭제"
ON messages FOR DELETE TO authenticated
USING (user_id = (SELECT auth.uid()));
-- message_reads
ALTER TABLE message_reads ENABLE ROW LEVEL SECURITY;
CREATE POLICY "읽음 기록 삽입"
ON message_reads FOR INSERT TO authenticated
WITH CHECK (user_id = (SELECT auth.uid()));
3단계: Supabase 클라이언트 설정
// src/lib/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr'
import type { Database } from '@/lib/database.types'
export function createClient() {
return createBrowserClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
}
// src/lib/supabase/server.ts
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
import type { Database } from '@/lib/database.types'
export async function createClient() {
const cookieStore = await cookies()
return createServerClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: () => cookieStore.getAll(),
setAll: (cs) => {
try { cs.forEach(({ name, value, options }) => cookieStore.set(name, value, options)) }
catch {}
},
},
}
)
}
// src/middleware.ts
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function middleware(request: NextRequest) {
let supabaseResponse = NextResponse.next({ request })
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: () => request.cookies.getAll(),
setAll: (cs) => {
cs.forEach(({ name, value }) => request.cookies.set(name, value))
supabaseResponse = NextResponse.next({ request })
cs.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
)
},
},
}
)
const { data: { user } } = await supabase.auth.getUser()
if (!user && !request.nextUrl.pathname.startsWith('/login')) {
return NextResponse.redirect(new URL('/login', request.url))
}
return supabaseResponse
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|auth).*)'],
}
4단계: 핵심 훅 — useChat
// src/hooks/useChat.ts
'use client'
import { useEffect, useRef, useState, useCallback } from 'react'
import { createClient } from '@/lib/supabase/client'
import type { Database } from '@/lib/database.types'
type Message = Database['public']['Tables']['messages']['Row'] & {
profiles: { name: string; avatar_url: string | null } | null
}
export function useChat(channelId: string, userId: string) {
const supabase = createClient()
const [messages, setMessages] = useState<Message[]>([])
const [loading, setLoading] = useState(true)
const channelRef = useRef<ReturnType<typeof supabase.channel> | null>(null)
// 초기 메시지 로드
useEffect(() => {
const loadMessages = async () => {
const { data } = await supabase
.from('messages')
.select(`
*,
profiles (name, avatar_url)
`)
.eq('channel_id', channelId)
.order('created_at', { ascending: true })
.limit(50)
setMessages((data as Message[]) ?? [])
setLoading(false)
}
loadMessages()
}, [channelId])
// Realtime 구독
useEffect(() => {
const channel = supabase.channel(`chat:${channelId}`, {
config: { private: true },
})
channelRef.current = channel
channel
// Postgres Changes: 새 메시지 실시간 수신
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'messages',
filter: `channel_id=eq.${channelId}`,
},
async (payload) => {
// 보낸 사람 프로필 조회
const { data: profile } = await supabase
.from('profiles')
.select('name, avatar_url')
.eq('id', payload.new.user_id)
.single()
const newMsg = {
...payload.new,
profiles: profile,
} as Message
setMessages(prev => [...prev, newMsg])
}
)
// Broadcast: 타이핑 인디케이터 (별도 훅에서 처리)
.subscribe()
return () => {
channel.unsubscribe()
}
}, [channelId])
// 메시지 전송
const sendMessage = useCallback(async (
content: string,
fileUrl?: string,
fileType?: string,
replyTo?: string
) => {
const { error } = await supabase.from('messages').insert({
channel_id: channelId,
user_id: userId,
content: content || null,
file_url: fileUrl,
file_type: fileType,
reply_to: replyTo,
})
if (error) throw error
}, [channelId, userId])
return { messages, loading, sendMessage }
}
5단계: Presence 훅 (온라인 상태)
// src/hooks/usePresence.ts
'use client'
import { useEffect, useState } from 'react'
import { createClient } from '@/lib/supabase/client'
interface OnlineUser {
userId: string
name: string
avatarUrl: string | null
onlineAt: string
}
export function usePresence(channelId: string, currentUser: {
id: string
name: string
avatarUrl: string | null
}) {
const supabase = createClient()
const [onlineUsers, setOnlineUsers] = useState<OnlineUser[]>([])
useEffect(() => {
const channel = supabase.channel(`presence:${channelId}`, {
config: { presence: { key: currentUser.id } },
})
channel
.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState<OnlineUser>()
const users = Object.values(state)
.flat()
.filter((u): u is OnlineUser => !!u.userId)
setOnlineUsers(users)
})
.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
await channel.track({
userId: currentUser.id,
name: currentUser.name,
avatarUrl: currentUser.avatarUrl,
onlineAt: new Date().toISOString(),
})
}
})
return () => { channel.unsubscribe() }
}, [channelId, currentUser.id])
return { onlineUsers }
}
6단계: 타이핑 인디케이터 훅
// src/hooks/useTyping.ts
'use client'
import { useEffect, useRef, useState, useCallback } from 'react'
import { createClient } from '@/lib/supabase/client'
interface TypingUser {
userId: string
name: string
}
export function useTyping(channelId: string, currentUser: { id: string; name: string }) {
const supabase = createClient()
const [typingUsers, setTypingUsers] = useState<TypingUser[]>([])
const typingTimerRef = useRef<NodeJS.Timeout | null>(null)
const channelRef = useRef<ReturnType<typeof supabase.channel> | null>(null)
useEffect(() => {
const channel = supabase.channel(`typing:${channelId}`)
channelRef.current = channel
channel
.on('broadcast', { event: 'typing' }, ({ payload }) => {
if (payload.userId === currentUser.id) return
setTypingUsers(prev => {
const exists = prev.some(u => u.userId === payload.userId)
if (payload.isTyping && !exists) {
return [...prev, { userId: payload.userId, name: payload.name }]
} else if (!payload.isTyping) {
return prev.filter(u => u.userId !== payload.userId)
}
return prev
})
// 3초 후 자동 제거
if (payload.isTyping) {
setTimeout(() => {
setTypingUsers(prev => prev.filter(u => u.userId !== payload.userId))
}, 3000)
}
})
.subscribe()
return () => { channel.unsubscribe() }
}, [channelId, currentUser.id])
// 타이핑 이벤트 발송
const broadcastTyping = useCallback(() => {
channelRef.current?.send({
type: 'broadcast',
event: 'typing',
payload: { userId: currentUser.id, name: currentUser.name, isTyping: true },
})
// 2초 후 타이핑 중지 브로드캐스트
if (typingTimerRef.current) clearTimeout(typingTimerRef.current)
typingTimerRef.current = setTimeout(() => {
channelRef.current?.send({
type: 'broadcast',
event: 'typing',
payload: { userId: currentUser.id, name: currentUser.name, isTyping: false },
})
}, 2000)
}, [currentUser.id, currentUser.name])
const typingText = typingUsers.length === 0
? null
: typingUsers.length === 1
? `${typingUsers[0].name} 님이 입력 중...`
: `${typingUsers.map(u => u.name).join(', ')} 님이 입력 중...`
return { typingText, broadcastTyping }
}
7단계: UI 컴포넌트
메시지 목록
// src/components/chat/MessageList.tsx
'use client'
import { useEffect, useRef } from 'react'
import Image from 'next/image'
interface Message {
id: string
content: string | null
file_url: string | null
file_type: string | null
created_at: string
user_id: string
profiles: { name: string; avatar_url: string | null } | null
}
interface Props {
messages: Message[]
currentUserId: string
}
export function MessageList({ messages, currentUserId }: Props) {
const bottomRef = useRef<HTMLDivElement>(null)
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages.length])
return (
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map((msg) => {
const isMe = msg.user_id === currentUserId
const time = new Date(msg.created_at).toLocaleTimeString('ko-KR', {
hour: '2-digit',
minute: '2-digit',
})
return (
<div key={msg.id} className={`flex gap-3 ${isMe ? 'flex-row-reverse' : ''}`}>
{/* 아바타 */}
<div className="w-9 h-9 rounded-full bg-indigo-500 flex items-center justify-center text-white text-sm font-bold shrink-0">
{msg.profiles?.avatar_url
? <Image src={msg.profiles.avatar_url} alt="" width={36} height={36} className="rounded-full" />
: msg.profiles?.name[0].toUpperCase()
}
</div>
<div className={`max-w-xs lg:max-w-md ${isMe ? 'items-end' : 'items-start'} flex flex-col`}>
{/* 이름 + 시간 */}
{!isMe && (
<span className="text-xs text-gray-500 mb-1">
{msg.profiles?.name} · {time}
</span>
)}
{/* 메시지 버블 */}
{msg.content && (
<div className={`px-4 py-2 rounded-2xl text-sm ${
isMe
? 'bg-indigo-500 text-white rounded-tr-none'
: 'bg-gray-100 text-gray-800 rounded-tl-none'
}`}>
{msg.content}
</div>
)}
{/* 이미지 첨부 */}
{msg.file_url && msg.file_type === 'image' && (
<div className="mt-1 rounded-xl overflow-hidden max-w-xs">
<Image
src={msg.file_url}
alt="첨부 이미지"
width={300}
height={200}
className="object-cover"
/>
</div>
)}
{isMe && (
<span className="text-xs text-gray-400 mt-1">{time}</span>
)}
</div>
</div>
)
})}
<div ref={bottomRef} />
</div>
)
}
메시지 입력창
// src/components/chat/MessageInput.tsx
'use client'
import { useRef, useState } from 'react'
import { createClient } from '@/lib/supabase/client'
interface Props {
channelId: string
onSend: (content: string, fileUrl?: string, fileType?: string) => Promise<void>
onTyping: () => void
}
export function MessageInput({ channelId, onSend, onTyping }: Props) {
const [content, setContent] = useState('')
const [uploading, setUploading] = useState(false)
const fileRef = useRef<HTMLInputElement>(null)
const supabase = createClient()
const handleSend = async () => {
const trimmed = content.trim()
if (!trimmed) return
setContent('')
await onSend(trimmed)
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setUploading(true)
try {
const ext = file.name.split('.').pop()
const path = `${channelId}/${Date.now()}.${ext}`
const { error } = await supabase.storage
.from('chat-attachments')
.upload(path, file)
if (error) throw error
const { data: { publicUrl } } = supabase.storage
.from('chat-attachments')
.getPublicUrl(path)
const fileType = file.type.startsWith('image/') ? 'image' : 'file'
await onSend('', publicUrl, fileType)
} finally {
setUploading(false)
if (fileRef.current) fileRef.current.value = ''
}
}
return (
<div className="p-4 border-t border-gray-200 bg-white">
<div className="flex items-end gap-2 bg-gray-50 rounded-2xl px-4 py-3">
{/* 파일 첨부 버튼 */}
<button
onClick={() => fileRef.current?.click()}
disabled={uploading}
className="text-gray-400 hover:text-indigo-500 transition-colors mb-0.5"
>
{uploading ? (
<div className="w-5 h-5 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" />
) : (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
</svg>
)}
</button>
<input ref={fileRef} type="file" accept="image/*,application/pdf" className="hidden" onChange={handleFileUpload} />
{/* 텍스트 입력 */}
<textarea
value={content}
onChange={(e) => { setContent(e.target.value); onTyping() }}
onKeyDown={handleKeyDown}
placeholder="메시지를 입력하세요... (Enter 전송, Shift+Enter 줄바꿈)"
rows={1}
className="flex-1 bg-transparent resize-none outline-none text-sm text-gray-800 placeholder-gray-400 max-h-32"
style={{ height: 'auto' }}
onInput={(e) => {
const t = e.currentTarget
t.style.height = 'auto'
t.style.height = `${t.scrollHeight}px`
}}
/>
{/* 전송 버튼 */}
<button
onClick={handleSend}
disabled={!content.trim()}
className="bg-indigo-500 text-white rounded-xl p-2 disabled:opacity-40 hover:bg-indigo-600 transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
</button>
</div>
</div>
)
}
온라인 유저 목록
// src/components/chat/OnlineUsers.tsx
interface OnlineUser {
userId: string
name: string
avatarUrl: string | null
}
export function OnlineUsers({ users }: { users: OnlineUser[] }) {
return (
<div className="w-56 border-l border-gray-200 p-4 bg-white">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3">
온라인 — {users.length}명
</h3>
<ul className="space-y-2">
{users.map((user) => (
<li key={user.userId} className="flex items-center gap-2">
<div className="relative">
<div className="w-8 h-8 rounded-full bg-indigo-400 flex items-center justify-center text-white text-xs font-bold">
{user.name[0].toUpperCase()}
</div>
{/* 온라인 초록 점 */}
<span className="absolute bottom-0 right-0 w-2.5 h-2.5 bg-green-400 rounded-full border-2 border-white" />
</div>
<span className="text-sm text-gray-700 truncate">{user.name}</span>
</li>
))}
</ul>
</div>
)
}
타이핑 인디케이터
// src/components/chat/TypingIndicator.tsx
export function TypingIndicator({ text }: { text: string | null }) {
if (!text) return null
return (
<div className="px-4 py-1 flex items-center gap-2">
{/* 애니메이션 점 3개 */}
<div className="flex gap-0.5">
{[0, 1, 2].map((i) => (
<span
key={i}
className="w-1.5 h-1.5 bg-gray-400 rounded-full animate-bounce"
style={{ animationDelay: `${i * 0.15}s` }}
/>
))}
</div>
<span className="text-xs text-gray-500">{text}</span>
</div>
)
}
8단계: 채팅 페이지 조립
// src/app/(chat)/[channelId]/page.tsx
import { redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'
import { ChatRoom } from '@/components/chat/ChatRoom'
interface Props {
params: Promise<{ channelId: string }>
}
export default async function ChannelPage({ params }: Props) {
const { channelId } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/login')
// 채널 멤버인지 확인
const { data: member } = await supabase
.from('channel_members')
.select()
.eq('channel_id', channelId)
.eq('user_id', user.id)
.single()
// 멤버가 아니면 자동 참여
if (!member) {
await supabase.from('channel_members').insert({
channel_id: channelId,
user_id: user.id,
role: 'member',
})
}
const { data: profile } = await supabase
.from('profiles')
.select()
.eq('id', user.id)
.single()
const { data: channel } = await supabase
.from('channels')
.select('name, description')
.eq('id', channelId)
.single()
return (
<ChatRoom
channelId={channelId}
channelName={channel?.name ?? ''}
currentUser={{
id: user.id,
name: profile?.name ?? user.email ?? '',
avatarUrl: profile?.avatar_url ?? null,
}}
/>
)
}
// src/components/chat/ChatRoom.tsx
'use client'
import { useChat } from '@/hooks/useChat'
import { usePresence } from '@/hooks/usePresence'
import { useTyping } from '@/hooks/useTyping'
import { MessageList } from './MessageList'
import { MessageInput } from './MessageInput'
import { TypingIndicator } from './TypingIndicator'
import { OnlineUsers } from './OnlineUsers'
interface Props {
channelId: string
channelName: string
currentUser: { id: string; name: string; avatarUrl: string | null }
}
export function ChatRoom({ channelId, channelName, currentUser }: Props) {
const { messages, loading, sendMessage } = useChat(channelId, currentUser.id)
const { onlineUsers } = usePresence(channelId, currentUser)
const { typingText, broadcastTyping } = useTyping(channelId, currentUser)
return (
<div className="flex h-screen">
{/* 메인 채팅 영역 */}
<div className="flex flex-col flex-1 min-w-0">
{/* 헤더 */}
<div className="px-6 py-4 border-b border-gray-200 bg-white">
<h2 className="font-semibold text-gray-800"># {channelName}</h2>
<p className="text-xs text-gray-500">{onlineUsers.length}명 온라인</p>
</div>
{/* 메시지 목록 */}
{loading ? (
<div className="flex-1 flex items-center justify-center text-gray-400">
메시지를 불러오는 중...
</div>
) : (
<MessageList messages={messages} currentUserId={currentUser.id} />
)}
{/* 타이핑 인디케이터 */}
<TypingIndicator text={typingText} />
{/* 입력창 */}
<MessageInput
channelId={channelId}
onSend={sendMessage}
onTyping={broadcastTyping}
/>
</div>
{/* 온라인 유저 사이드바 */}
<OnlineUsers users={onlineUsers} />
</div>
)
}
9단계: 채널 사이드바
// src/app/(chat)/layout.tsx
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import { ChannelSidebar } from '@/components/sidebar/ChannelSidebar'
export default async function ChatLayout({ children }: { children: React.ReactNode }) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/login')
const { data: channels } = await supabase
.from('channels')
.select('id, name, description')
.eq('is_private', false)
.order('name')
return (
<div className="flex h-screen bg-gray-900">
<ChannelSidebar channels={channels ?? []} userId={user.id} />
<main className="flex-1 bg-white">
{children}
</main>
</div>
)
}
// src/components/sidebar/ChannelSidebar.tsx
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { createClient } from '@/lib/supabase/client'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
interface Channel { id: string; name: string; description: string | null }
export function ChannelSidebar({ channels, userId }: { channels: Channel[], userId: string }) {
const pathname = usePathname()
const router = useRouter()
const supabase = createClient()
const [newName, setNewName] = useState('')
const [creating, setCreating] = useState(false)
const createChannel = async () => {
if (!newName.trim()) return
const { data } = await supabase
.from('channels')
.insert({ name: newName.trim(), created_by: userId })
.select()
.single()
if (data) {
await supabase.from('channel_members').insert({
channel_id: data.id,
user_id: userId,
role: 'owner',
})
setNewName('')
setCreating(false)
router.push(`/${data.id}`)
router.refresh()
}
}
const logout = async () => {
await supabase.auth.signOut()
router.push('/login')
}
return (
<div className="w-64 bg-gray-800 text-gray-300 flex flex-col">
{/* 워크스페이스 헤더 */}
<div className="px-4 py-4 border-b border-gray-700">
<h1 className="font-bold text-white">💬 My Chat</h1>
</div>
{/* 채널 목록 */}
<div className="flex-1 overflow-y-auto py-4">
<div className="px-4 mb-2 flex items-center justify-between">
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wide">채널</span>
<button onClick={() => setCreating(v => !v)} className="text-gray-400 hover:text-white text-lg leading-none">+</button>
</div>
{creating && (
<div className="px-4 mb-3">
<input
value={newName}
onChange={e => setNewName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && createChannel()}
placeholder="채널명..."
className="w-full bg-gray-700 text-white text-sm rounded px-3 py-1.5 outline-none"
autoFocus
/>
</div>
)}
<ul className="space-y-0.5 px-2">
{channels.map((ch) => (
<li key={ch.id}>
<Link
href={`/${ch.id}`}
className={`block px-3 py-1.5 rounded text-sm transition-colors ${
pathname === `/${ch.id}`
? 'bg-gray-600 text-white'
: 'hover:bg-gray-700 text-gray-300'
}`}
>
# {ch.name}
</Link>
</li>
))}
</ul>
</div>
{/* 로그아웃 */}
<div className="p-4 border-t border-gray-700">
<button onClick={logout} className="w-full text-left text-sm text-gray-400 hover:text-white">
로그아웃
</button>
</div>
</div>
)
}
10단계: Storage 버킷 설정
-- supabase/migrations/[timestamp]_chat_storage.sql
-- chat-attachments 버킷 RLS
INSERT INTO storage.buckets (id, name, public)
VALUES ('chat-attachments', 'chat-attachments', true);
CREATE POLICY "채널 멤버 업로드"
ON storage.objects FOR INSERT TO authenticated
WITH CHECK (
bucket_id = 'chat-attachments'
AND EXISTS (
SELECT 1 FROM channel_members
WHERE channel_id = (storage.foldername(name))[1]::UUID
AND user_id = (SELECT auth.uid())
)
);
11단계: 시드 데이터
-- supabase/seed.sql
-- 테스트 채널
INSERT INTO channels (id, name, description, is_private)
VALUES
('11111111-0000-0000-0000-000000000001', 'general', '전체 공지 채널', false),
('11111111-0000-0000-0000-000000000002', 'random', '자유 대화', false),
('11111111-0000-0000-0000-000000000003', 'dev', '개발 이야기', false)
ON CONFLICT DO NOTHING;
로컬 실행 및 테스트
# 마이그레이션 적용
supabase db reset
# TypeScript 타입 재생성
npm run types
# 개발 서버 실행
npm run dev
# http://localhost:3000 접속
두 개의 브라우저 탭(또는 시크릿 창)에서 각각 다른 계정으로 로그인하면, 양방향 실시간 채팅이 작동하는 것을 확인할 수 있습니다.
배포 체크리스트
✅ .env.local → Vercel Environment Variables 설정
✅ supabase db push → 원격 DB에 마이그레이션 배포
✅ 대시보드 → Auth → URL Configuration → Site URL 및 Redirect URLs 업데이트
✅ 대시보드 → Storage → chat-attachments 버킷 확인
✅ 대시보드 → Database → Replication → supabase_realtime publication에 messages 테이블 포함 확인
마치며
약 400줄의 코드로 완전한 기능을 갖춘 실시간 채팅 앱을 완성했습니다. Supabase의 Realtime(Broadcast + Presence + Postgres Changes), Auth, Storage, RLS가 유기적으로 결합되어 별도 WebSocket 서버 없이 실시간 기능을 구현했습니다.
다음 14편에서는 SaaS 대시보드를 구축합니다. 플랜별 기능 제어, Stripe 결제 연동, 사용량 제한, 팀 멤버 관리 등 실제 SaaS 제품에 필요한 패턴을 다룹니다.