Supabase 완전 정복 시리즈 13편 — 실전: 실시간 채팅 앱 처음부터 끝까지




시리즈 목차 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 제품에 필요한 패턴을 다룹니다.




댓글 남기기