Supabase 완전 정복 시리즈 16편 — 실전: Todo/협업툴 (실시간 + 낙관적 업데이트 + Drag & Drop)




시리즈 목차 1~12편 – Supabase 이론 완벽 가이드 13편 – 실전: 실시간 채팅 앱 14편 – 실전: SaaS 대시보드 15편 – 실전: AI 챗봇 서비스 16편 👉 실전: Todo/협업툴 (완결)


완성 기능 미리보기

✅ 인증 (Supabase Auth)
✅ 워크스페이스 / 프로젝트 / 태스크 3계층 구조
✅ 멤버 초대 & 역할(RBAC)
✅ 태스크 상태 관리 (Kanban 보드)
✅ Drag & Drop (dnd-kit)
✅ 낙관적 업데이트 (React useOptimistic)
✅ 실시간 동기화 (Postgres Changes)
✅ 태스크 담당자/마감일/우선순위/라벨
✅ 댓글 & 활동 로그
✅ RLS — 워크스페이스 멤버만 접근
my-collab-app/
├── src/
│   ├── app/
│   │   ├── (dashboard)/
│   │   │   ├── layout.tsx
│   │   │   ├── [workspaceId]/
│   │   │   │   ├── page.tsx          ← 프로젝트 목록
│   │   │   │   └── [projectId]/
│   │   │   │       ├── page.tsx      ← Kanban 보드
│   │   │   │       └── list/page.tsx ← 리스트 뷰
│   │   ├── api/
│   │   │   └── tasks/reorder/route.ts← Drag & Drop 순서 저장
│   │   └── auth/callback/route.ts
│   ├── components/
│   │   ├── kanban/
│   │   │   ├── KanbanBoard.tsx
│   │   │   ├── KanbanColumn.tsx
│   │   │   └── TaskCard.tsx
│   │   └── tasks/
│   │       ├── TaskDetail.tsx
│   │       └── TaskComments.tsx
│   ├── hooks/
│   │   ├── useTasks.ts       ← 실시간 + 낙관적 업데이트
│   │   └── useRealtimeSync.ts
│   └── lib/
│       ├── supabase/
│       └── database.types.ts
└── supabase/migrations/

1단계: 의존성 설치

npx create-next-app@latest my-collab-app --typescript --tailwind --app
cd my-collab-app

npm install @supabase/supabase-js @supabase/ssr
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
npm install date-fns

2단계: 데이터베이스 스키마

supabase migration new collab_schema
-- supabase/migrations/[timestamp]_collab_schema.sql

-- 워크스페이스
CREATE TABLE workspaces (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name        TEXT NOT NULL,
  slug        TEXT NOT NULL UNIQUE,
  owner_id    UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
  created_at  TIMESTAMPTZ DEFAULT NOW()
);

-- 워크스페이스 멤버
CREATE TABLE workspace_members (
  workspace_id UUID REFERENCES workspaces(id) ON DELETE CASCADE,
  user_id      UUID REFERENCES auth.users(id) ON DELETE CASCADE,
  role         TEXT DEFAULT 'member' CHECK (role IN ('owner','admin','member','viewer')),
  joined_at    TIMESTAMPTZ DEFAULT NOW(),
  PRIMARY KEY (workspace_id, user_id)
);

-- 프로필
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 projects (
  id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  name         TEXT NOT NULL,
  description  TEXT,
  color        TEXT DEFAULT '#6366f1',
  created_by   UUID REFERENCES auth.users(id) ON DELETE SET NULL,
  created_at   TIMESTAMPTZ DEFAULT NOW()
);

-- 태스크 상태 (커스터마이즈 가능한 컬럼)
CREATE TABLE task_statuses (
  id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  project_id   UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
  name         TEXT NOT NULL,           -- 'Todo', 'In Progress', 'Done'
  color        TEXT DEFAULT '#6b7280',
  position     INTEGER NOT NULL DEFAULT 0,
  created_at   TIMESTAMPTZ DEFAULT NOW()
);

-- 태스크
CREATE TABLE tasks (
  id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  project_id   UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
  status_id    UUID REFERENCES task_statuses(id) ON DELETE SET NULL,
  title        TEXT NOT NULL,
  description  TEXT,
  -- 담당자 (여러 명)
  assignee_ids UUID[] DEFAULT '{}',
  -- 메타데이터
  priority     TEXT DEFAULT 'medium' CHECK (priority IN ('urgent','high','medium','low')),
  due_date     DATE,
  labels       TEXT[] DEFAULT '{}',
  -- 정렬을 위한 position (컬럼 내 순서)
  position     FLOAT NOT NULL DEFAULT 0,
  created_by   UUID REFERENCES auth.users(id) ON DELETE SET NULL,
  created_at   TIMESTAMPTZ DEFAULT NOW(),
  updated_at   TIMESTAMPTZ DEFAULT NOW()
);

-- 태스크 댓글
CREATE TABLE task_comments (
  id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  task_id    UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
  user_id    UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
  content    TEXT NOT NULL,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- 활동 로그
CREATE TABLE activity_logs (
  id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  task_id      UUID REFERENCES tasks(id) ON DELETE CASCADE,
  project_id   UUID REFERENCES projects(id) ON DELETE CASCADE,
  user_id      UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
  action       TEXT NOT NULL,  -- 'created' | 'status_changed' | 'assigned' | 'commented'
  metadata     JSONB DEFAULT '{}',
  created_at   TIMESTAMPTZ DEFAULT NOW()
);

-- 인덱스
CREATE INDEX idx_tasks_project_status ON tasks(project_id, status_id, position);
CREATE INDEX idx_tasks_assignees ON tasks USING GIN(assignee_ids);
CREATE INDEX idx_task_comments_task ON task_comments(task_id, created_at);
CREATE INDEX idx_activity_logs_task ON activity_logs(task_id, created_at DESC);

-- updated_at 트리거
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN NEW.updated_at = NOW(); RETURN NEW; END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER tasks_updated_at
  BEFORE UPDATE ON tasks FOR EACH ROW EXECUTE FUNCTION update_updated_at();

CREATE TRIGGER task_comments_updated_at
  BEFORE UPDATE ON task_comments FOR EACH ROW EXECUTE FUNCTION update_updated_at();

-- 신규 유저 프로필 자동 생성
CREATE OR REPLACE FUNCTION handle_new_user()
RETURNS TRIGGER AS $$
BEGIN
  INSERT INTO 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 tasks;
ALTER PUBLICATION supabase_realtime ADD TABLE task_comments;
ALTER PUBLICATION supabase_realtime ADD TABLE activity_logs;

RLS 정책

-- supabase/migrations/[timestamp]_collab_rls.sql

-- 워크스페이스 멤버 체크 헬퍼 함수
CREATE OR REPLACE FUNCTION is_workspace_member(ws_id UUID)
RETURNS BOOLEAN AS $$
  SELECT EXISTS (
    SELECT 1 FROM workspace_members
    WHERE workspace_id = ws_id AND user_id = (SELECT auth.uid())
  );
$$ LANGUAGE SQL SECURITY DEFINER STABLE;

-- workspaces
ALTER TABLE workspaces ENABLE ROW LEVEL SECURITY;
CREATE POLICY "멤버 조회"
  ON workspaces FOR SELECT TO authenticated
  USING (is_workspace_member(id));
CREATE POLICY "소유자 수정"
  ON workspaces FOR UPDATE TO authenticated
  USING (owner_id = (SELECT auth.uid()));

-- workspace_members
ALTER TABLE workspace_members ENABLE ROW LEVEL SECURITY;
CREATE POLICY "멤버 조회"
  ON workspace_members FOR SELECT TO authenticated
  USING (is_workspace_member(workspace_id));

-- projects: 워크스페이스 멤버
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
CREATE POLICY "멤버 조회"
  ON projects FOR SELECT TO authenticated
  USING (is_workspace_member(workspace_id));
CREATE POLICY "멤버 생성"
  ON projects FOR INSERT TO authenticated
  WITH CHECK (is_workspace_member(workspace_id));
CREATE POLICY "admin 이상 수정/삭제"
  ON projects FOR UPDATE TO authenticated
  USING (is_workspace_member(workspace_id));

-- task_statuses
ALTER TABLE task_statuses ENABLE ROW LEVEL SECURITY;
CREATE POLICY "프로젝트 멤버 CRUD"
  ON task_statuses FOR ALL TO authenticated
  USING (
    EXISTS (
      SELECT 1 FROM projects p
      WHERE p.id = project_id AND is_workspace_member(p.workspace_id)
    )
  );

-- tasks: 프로젝트 멤버
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
CREATE POLICY "프로젝트 멤버 CRUD"
  ON tasks FOR ALL TO authenticated
  USING (
    EXISTS (
      SELECT 1 FROM projects p
      WHERE p.id = project_id AND is_workspace_member(p.workspace_id)
    )
  )
  WITH CHECK (
    EXISTS (
      SELECT 1 FROM projects p
      WHERE p.id = project_id AND is_workspace_member(p.workspace_id)
    )
  );

-- task_comments
ALTER TABLE task_comments ENABLE ROW LEVEL SECURITY;
CREATE POLICY "프로젝트 멤버 조회/작성"
  ON task_comments FOR ALL TO authenticated
  USING (
    EXISTS (
      SELECT 1 FROM tasks t
      JOIN projects p ON p.id = t.project_id
      WHERE t.id = task_id AND is_workspace_member(p.workspace_id)
    )
  );
CREATE POLICY "본인 댓글 수정/삭제"
  ON task_comments FOR UPDATE TO authenticated
  USING (user_id = (SELECT auth.uid()));

3단계: 낙관적 업데이트 + 실시간 동기화 훅

// src/hooks/useTasks.ts
'use client'
import {
  useEffect,
  useOptimistic,
  useCallback,
  useState,
  useTransition,
} from 'react'
import { createClient } from '@/lib/supabase/client'
import type { Database } from '@/lib/database.types'

type Task = Database['public']['Tables']['tasks']['Row']
type TaskUpdate = Partial<Omit<Task, 'id' | 'created_at'>>

type OptimisticAction =
  | { type: 'update'; id: string; changes: TaskUpdate }
  | { type: 'delete'; id: string }
  | { type: 'add'; task: Task }
  | { type: 'reorder'; tasks: Task[] }

export function useTasks(projectId: string) {
  const supabase = createClient()
  const [serverTasks, setServerTasks] = useState<Task[]>([])
  const [isPending, startTransition] = useTransition()

  // React 19 useOptimistic — 낙관적 UI 상태
  const [optimisticTasks, addOptimistic] = useOptimistic(
    serverTasks,
    (current: Task[], action: OptimisticAction): Task[] => {
      switch (action.type) {
        case 'add':
          return [...current, action.task]
        case 'update':
          return current.map(t =>
            t.id === action.id ? { ...t, ...action.changes } : t
          )
        case 'delete':
          return current.filter(t => t.id !== action.id)
        case 'reorder':
          return action.tasks
        default:
          return current
      }
    }
  )

  // 초기 데이터 로드
  useEffect(() => {
    const load = async () => {
      const { data } = await supabase
        .from('tasks')
        .select('*')
        .eq('project_id', projectId)
        .order('position', { ascending: true })
      setServerTasks(data ?? [])
    }
    load()
  }, [projectId])

  // Realtime 구독 — 다른 사용자의 변경 실시간 수신
  useEffect(() => {
    const channel = supabase
      .channel(`tasks:${projectId}`)
      .on(
        'postgres_changes',
        {
          event: '*',
          schema: 'public',
          table: 'tasks',
          filter: `project_id=eq.${projectId}`,
        },
        (payload) => {
          setServerTasks(prev => {
            switch (payload.eventType) {
              case 'INSERT':
                // 이미 낙관적으로 추가된 경우 중복 방지
                if (prev.some(t => t.id === payload.new.id)) return prev
                return [...prev, payload.new as Task]

              case 'UPDATE':
                return prev.map(t =>
                  t.id === payload.new.id ? (payload.new as Task) : t
                )

              case 'DELETE':
                return prev.filter(t => t.id !== payload.old.id)

              default:
                return prev
            }
          })
        }
      )
      .subscribe()

    return () => { channel.unsubscribe() }
  }, [projectId])

  // 태스크 추가
  const addTask = useCallback(async (
    statusId: string,
    title: string,
    position: number
  ) => {
    const supabaseAuth = createClient()
    const { data: { user } } = await supabaseAuth.auth.getUser()

    const optimisticTask: Task = {
      id: `optimistic-${Date.now()}`,
      project_id: projectId,
      status_id: statusId,
      title,
      description: null,
      assignee_ids: [],
      priority: 'medium',
      due_date: null,
      labels: [],
      position,
      created_by: user?.id ?? null,
      created_at: new Date().toISOString(),
      updated_at: new Date().toISOString(),
    }

    startTransition(async () => {
      addOptimistic({ type: 'add', task: optimisticTask })

      const { data, error } = await supabase
        .from('tasks')
        .insert({
          project_id: projectId,
          status_id: statusId,
          title,
          position,
          created_by: user?.id,
        })
        .select()
        .single()

      if (error) {
        // 실패 시 서버 상태로 롤백 (optimistic 취소)
        console.error('[addTask] 실패, 롤백:', error.message)
      } else if (data) {
        // 실제 ID로 교체
        setServerTasks(prev =>
          prev.map(t => t.id === optimisticTask.id ? data : t)
        )
      }
    })
  }, [projectId])

  // 태스크 업데이트 (낙관적)
  const updateTask = useCallback(async (id: string, changes: TaskUpdate) => {
    startTransition(async () => {
      addOptimistic({ type: 'update', id, changes })

      const { error } = await supabase
        .from('tasks')
        .update(changes)
        .eq('id', id)

      if (error) {
        console.error('[updateTask] 실패:', error.message)
        // 서버에서 최신 데이터 다시 로드
        const { data } = await supabase
          .from('tasks')
          .select('*')
          .eq('project_id', projectId)
          .order('position')
        if (data) setServerTasks(data)
      }
    })
  }, [projectId])

  // 태스크 삭제 (낙관적)
  const deleteTask = useCallback(async (id: string) => {
    startTransition(async () => {
      addOptimistic({ type: 'delete', id })

      const { error } = await supabase
        .from('tasks')
        .delete()
        .eq('id', id)

      if (error) {
        console.error('[deleteTask] 실패:', error.message)
      }
    })
  }, [])

  // Drag & Drop 완료: 순서 일괄 업데이트
  const reorderTasks = useCallback(async (reorderedTasks: Task[]) => {
    startTransition(async () => {
      addOptimistic({ type: 'reorder', tasks: reorderedTasks })

      // position 값을 1000 간격으로 재할당 (lexorank 간소화)
      const updates = reorderedTasks.map((task, i) => ({
        id: task.id,
        position: (i + 1) * 1000,
        status_id: task.status_id,
      }))

      // 순서 변경은 API Route로 처리 (배치 upsert)
      await fetch('/api/tasks/reorder', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ updates }),
      })
    })
  }, [])

  return {
    tasks: optimisticTasks,
    isPending,
    addTask,
    updateTask,
    deleteTask,
    reorderTasks,
  }
}

4단계: 순서 저장 API Route

// src/app/api/tasks/reorder/route.ts
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'

export async function POST(req: Request) {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()
  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })

  const { updates }: {
    updates: { id: string; position: number; status_id: string }[]
  } = await req.json()

  // 배치 업데이트 (각 태스크의 position과 status_id 갱신)
  const promises = updates.map(({ id, position, status_id }) =>
    supabase
      .from('tasks')
      .update({ position, status_id })
      .eq('id', id)
  )

  await Promise.all(promises)
  return NextResponse.json({ success: true })
}

5단계: Kanban 보드 컴포넌트

// src/components/kanban/KanbanBoard.tsx
'use client'
import { useState } from 'react'
import {
  DndContext,
  DragOverlay,
  PointerSensor,
  useSensor,
  useSensors,
  closestCorners,
  type DragStartEvent,
  type DragEndEvent,
  type DragOverEvent,
} from '@dnd-kit/core'
import { arrayMove } from '@dnd-kit/sortable'
import { KanbanColumn } from './KanbanColumn'
import { TaskCard } from './TaskCard'
import { useTasks } from '@/hooks/useTasks'
import type { Database } from '@/lib/database.types'

type Task = Database['public']['Tables']['tasks']['Row']
type TaskStatus = Database['public']['Tables']['task_statuses']['Row']

interface Props {
  projectId: string
  statuses: TaskStatus[]
}

export function KanbanBoard({ projectId, statuses }: Props) {
  const { tasks, isPending, addTask, updateTask, deleteTask, reorderTasks } =
    useTasks(projectId)

  const [activeTask, setActiveTask] = useState<Task | null>(null)

  const sensors = useSensors(
    useSensor(PointerSensor, {
      activationConstraint: { distance: 8 },  // 8px 이동 후 드래그 시작
    })
  )

  // 컬럼별 태스크 그룹화
  const tasksByStatus = statuses.reduce<Record<string, Task[]>>(
    (acc, status) => {
      acc[status.id] = tasks
        .filter(t => t.status_id === status.id)
        .sort((a, b) => a.position - b.position)
      return acc
    },
    {}
  )

  const handleDragStart = ({ active }: DragStartEvent) => {
    setActiveTask(tasks.find(t => t.id === active.id) ?? null)
  }

  const handleDragOver = ({ active, over }: DragOverEvent) => {
    if (!over) return
    const activeId = active.id as string
    const overId = over.id as string

    // 다른 컬럼으로 이동 시 status_id 즉시 변경 (낙관적)
    const activeTask = tasks.find(t => t.id === activeId)
    const overTask = tasks.find(t => t.id === overId)
    const overStatus = statuses.find(s => s.id === overId)

    if (!activeTask) return

    const newStatusId = overStatus?.id ?? overTask?.status_id
    if (newStatusId && newStatusId !== activeTask.status_id) {
      updateTask(activeId, { status_id: newStatusId })
    }
  }

  const handleDragEnd = ({ active, over }: DragEndEvent) => {
    setActiveTask(null)
    if (!over) return

    const activeId = active.id as string
    const overId = over.id as string
    if (activeId === overId) return

    const activeTask = tasks.find(t => t.id === activeId)
    const overTask = tasks.find(t => t.id === overId)
    if (!activeTask) return

    const statusId = overTask?.status_id ?? activeTask.status_id
    const columnTasks = tasks
      .filter(t => t.status_id === statusId)
      .sort((a, b) => a.position - b.position)

    const oldIndex = columnTasks.findIndex(t => t.id === activeId)
    const newIndex = columnTasks.findIndex(t => t.id === overId)

    if (oldIndex === -1 || newIndex === -1) return

    const reordered = arrayMove(columnTasks, oldIndex, newIndex)

    // 해당 컬럼 외의 태스크들과 합쳐서 전체 순서 저장
    const otherTasks = tasks.filter(t => t.status_id !== statusId)
    reorderTasks([...otherTasks, ...reordered])
  }

  return (
    <DndContext
      sensors={sensors}
      collisionDetection={closestCorners}
      onDragStart={handleDragStart}
      onDragOver={handleDragOver}
      onDragEnd={handleDragEnd}
    >
      <div className="flex gap-4 h-full overflow-x-auto pb-4">
        {statuses.map(status => (
          <KanbanColumn
            key={status.id}
            status={status}
            tasks={tasksByStatus[status.id] ?? []}
            onAddTask={(title) => {
              const posMax = Math.max(
                0,
                ...(tasksByStatus[status.id] ?? []).map(t => t.position)
              )
              addTask(status.id, title, posMax + 1000)
            }}
            onUpdateTask={updateTask}
            onDeleteTask={deleteTask}
            isPending={isPending}
          />
        ))}
      </div>

      {/* 드래그 중인 태스크 오버레이 (마우스 커서 위치에 표시) */}
      <DragOverlay>
        {activeTask && (
          <TaskCard
            task={activeTask}
            onUpdate={() => {}}
            onDelete={() => {}}
            isDragging
          />
        )}
      </DragOverlay>
    </DndContext>
  )
}

Kanban 컬럼

// src/components/kanban/KanbanColumn.tsx
'use client'
import { useState } from 'react'
import {
  SortableContext,
  verticalListSortingStrategy,
  useDroppable,
} from '@dnd-kit/sortable'
import { TaskCard } from './TaskCard'
import type { Database } from '@/lib/database.types'

type Task = Database['public']['Tables']['tasks']['Row']
type TaskStatus = Database['public']['Tables']['task_statuses']['Row']

interface Props {
  status: TaskStatus
  tasks: Task[]
  onAddTask: (title: string) => void
  onUpdateTask: (id: string, changes: Partial<Task>) => void
  onDeleteTask: (id: string) => void
  isPending: boolean
}

export function KanbanColumn({
  status, tasks, onAddTask, onUpdateTask, onDeleteTask, isPending
}: Props) {
  const [isAdding, setIsAdding] = useState(false)
  const [newTitle, setNewTitle] = useState('')

  // 컬럼 자체를 드롭 대상으로 등록
  const { setNodeRef, isOver } = useDroppable({ id: status.id })

  const handleAdd = () => {
    if (!newTitle.trim()) return
    onAddTask(newTitle.trim())
    setNewTitle('')
    setIsAdding(false)
  }

  return (
    <div
      ref={setNodeRef}
      className={`flex flex-col w-72 shrink-0 rounded-xl ${
        isOver ? 'bg-indigo-50' : 'bg-gray-100'
      } transition-colors`}
    >
      {/* 컬럼 헤더 */}
      <div className="flex items-center justify-between px-4 py-3">
        <div className="flex items-center gap-2">
          <span
            className="w-3 h-3 rounded-full"
            style={{ backgroundColor: status.color }}
          />
          <h3 className="font-semibold text-sm text-gray-700">{status.name}</h3>
          <span className="text-xs text-gray-400 bg-gray-200 rounded-full px-2 py-0.5">
            {tasks.length}
          </span>
        </div>
        <button
          onClick={() => setIsAdding(true)}
          className="text-gray-400 hover:text-indigo-600 text-lg leading-none"
        >
          +
        </button>
      </div>

      {/* 태스크 목록 */}
      <div className="flex-1 overflow-y-auto px-3 pb-3 space-y-2">
        <SortableContext
          items={tasks.map(t => t.id)}
          strategy={verticalListSortingStrategy}
        >
          {tasks.map(task => (
            <TaskCard
              key={task.id}
              task={task}
              onUpdate={(changes) => onUpdateTask(task.id, changes)}
              onDelete={() => onDeleteTask(task.id)}
            />
          ))}
        </SortableContext>

        {/* 새 태스크 입력 */}
        {isAdding && (
          <div className="bg-white rounded-xl shadow-sm p-3">
            <textarea
              value={newTitle}
              onChange={e => setNewTitle(e.target.value)}
              onKeyDown={e => {
                if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleAdd() }
                if (e.key === 'Escape') { setIsAdding(false); setNewTitle('') }
              }}
              placeholder="태스크 제목..."
              rows={2}
              className="w-full text-sm outline-none resize-none"
              autoFocus
            />
            <div className="flex gap-2 mt-2">
              <button
                onClick={handleAdd}
                className="bg-indigo-500 text-white rounded-lg px-3 py-1.5 text-xs hover:bg-indigo-600"
              >
                추가
              </button>
              <button
                onClick={() => { setIsAdding(false); setNewTitle('') }}
                className="text-gray-400 hover:text-gray-600 text-xs px-2"
              >
                취소
              </button>
            </div>
          </div>
        )}
      </div>
    </div>
  )
}

태스크 카드

// src/components/kanban/TaskCard.tsx
'use client'
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { format } from 'date-fns'
import { ko } from 'date-fns/locale'
import { useState } from 'react'
import type { Database } from '@/lib/database.types'

type Task = Database['public']['Tables']['tasks']['Row']

const PRIORITY_CONFIG = {
  urgent: { label: '긴급', color: 'text-red-500',    icon: '🔴' },
  high:   { label: '높음', color: 'text-orange-500', icon: '🟠' },
  medium: { label: '중간', color: 'text-yellow-500', icon: '🟡' },
  low:    { label: '낮음', color: 'text-green-500',  icon: '🟢' },
} as const

interface Props {
  task: Task
  onUpdate: (changes: Partial<Task>) => void
  onDelete: () => void
  isDragging?: boolean
}

export function TaskCard({ task, onUpdate, onDelete, isDragging }: Props) {
  const [showMenu, setShowMenu] = useState(false)

  const {
    attributes,
    listeners,
    setNodeRef,
    transform,
    transition,
    isDragging: isSortableDragging,
  } = useSortable({ id: task.id })

  const style = {
    transform: CSS.Transform.toString(transform),
    transition,
    opacity: isSortableDragging ? 0.4 : 1,
  }

  const priority = PRIORITY_CONFIG[task.priority as keyof typeof PRIORITY_CONFIG]
  const isOverdue = task.due_date && new Date(task.due_date) < new Date()

  return (
    <div
      ref={setNodeRef}
      style={style}
      className={`bg-white rounded-xl shadow-sm border border-gray-100 p-3 cursor-grab active:cursor-grabbing select-none ${
        isDragging ? 'shadow-lg ring-2 ring-indigo-300' : 'hover:shadow-md'
      } transition-shadow`}
      {...attributes}
      {...listeners}
    >
      {/* 우선순위 + 메뉴 */}
      <div className="flex items-start justify-between mb-2">
        <span className="text-xs">{priority?.icon} {priority?.label}</span>
        <div className="relative">
          <button
            onPointerDown={e => e.stopPropagation()}
            onClick={() => setShowMenu(v => !v)}
            className="text-gray-300 hover:text-gray-600 text-sm px-1"
          >
            ···
          </button>
          {showMenu && (
            <div className="absolute right-0 top-6 bg-white border rounded-xl shadow-lg z-10 py-1 w-28">
              <button
                onPointerDown={e => e.stopPropagation()}
                onClick={() => { onDelete(); setShowMenu(false) }}
                className="w-full text-left px-3 py-1.5 text-xs text-red-500 hover:bg-red-50"
              >
                삭제
              </button>
            </div>
          )}
        </div>
      </div>

      {/* 제목 */}
      <p className="text-sm font-medium text-gray-800 mb-2 leading-snug">
        {task.title}
      </p>

      {/* 라벨 */}
      {task.labels && task.labels.length > 0 && (
        <div className="flex flex-wrap gap-1 mb-2">
          {task.labels.map(label => (
            <span key={label} className="text-xs bg-indigo-50 text-indigo-600 rounded-full px-2 py-0.5">
              {label}
            </span>
          ))}
        </div>
      )}

      {/* 마감일 */}
      {task.due_date && (
        <div className={`flex items-center gap-1 text-xs mt-1 ${
          isOverdue ? 'text-red-500' : 'text-gray-400'
        }`}>
          <span>📅</span>
          <span>{format(new Date(task.due_date), 'M월 d일', { locale: ko })}</span>
          {isOverdue && <span className="font-medium">(기한 초과)</span>}
        </div>
      )}
    </div>
  )
}

6단계: Kanban 보드 페이지

// src/app/(dashboard)/[workspaceId]/[projectId]/page.tsx
import { createClient } from '@/lib/supabase/server'
import { redirect, notFound } from 'next/navigation'
import { KanbanBoard } from '@/components/kanban/KanbanBoard'

interface Props {
  params: Promise<{ workspaceId: string; projectId: string }>
}

export default async function KanbanPage({ params }: Props) {
  const { workspaceId, projectId } = await params
  const supabase = await createClient()

  const { data: { user } } = await supabase.auth.getUser()
  if (!user) redirect('/login')

  // 프로젝트 + 상태 목록 조회
  const [{ data: project }, { data: statuses }] = await Promise.all([
    supabase
      .from('projects')
      .select('name, description, color')
      .eq('id', projectId)
      .single(),
    supabase
      .from('task_statuses')
      .select('*')
      .eq('project_id', projectId)
      .order('position'),
  ])

  if (!project) notFound()

  return (
    <div className="flex flex-col h-screen">
      {/* 프로젝트 헤더 */}
      <div className="px-6 py-4 border-b bg-white flex items-center gap-3">
        <span
          className="w-4 h-4 rounded-full"
          style={{ backgroundColor: project.color ?? '#6366f1' }}
        />
        <div>
          <h1 className="font-bold text-lg">{project.name}</h1>
          {project.description && (
            <p className="text-sm text-gray-500">{project.description}</p>
          )}
        </div>
      </div>

      {/* Kanban 보드 */}
      <div className="flex-1 overflow-hidden p-6">
        <KanbanBoard
          projectId={projectId}
          statuses={statuses ?? []}
        />
      </div>
    </div>
  )
}

7단계: 태스크 댓글 + 활동 로그

// src/components/tasks/TaskComments.tsx
'use client'
import { useEffect, useState, useRef } from 'react'
import { createClient } from '@/lib/supabase/client'
import type { Database } from '@/lib/database.types'

type Comment = Database['public']['Tables']['task_comments']['Row'] & {
  profiles: { name: string; avatar_url: string | null } | null
}

export function TaskComments({ taskId }: { taskId: string }) {
  const supabase = createClient()
  const [comments, setComments] = useState<Comment[]>([])
  const [input, setInput] = useState('')
  const bottomRef = useRef<HTMLDivElement>(null)

  useEffect(() => {
    // 댓글 로드
    supabase
      .from('task_comments')
      .select('*, profiles(name, avatar_url)')
      .eq('task_id', taskId)
      .order('created_at')
      .then(({ data }) => setComments((data as Comment[]) ?? []))

    // 실시간 구독
    const channel = supabase
      .channel(`comments:${taskId}`)
      .on(
        'postgres_changes',
        { event: 'INSERT', schema: 'public', table: 'task_comments',
          filter: `task_id=eq.${taskId}` },
        async (payload) => {
          const { data: profile } = await supabase
            .from('profiles')
            .select('name, avatar_url')
            .eq('id', payload.new.user_id)
            .single()

          setComments(prev => [...prev, { ...payload.new, profiles: profile } as Comment])
        }
      )
      .subscribe()

    return () => { channel.unsubscribe() }
  }, [taskId])

  useEffect(() => {
    bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
  }, [comments.length])

  const addComment = async () => {
    if (!input.trim()) return
    const { data: { user } } = await supabase.auth.getUser()
    await supabase.from('task_comments').insert({
      task_id: taskId,
      user_id: user!.id,
      content: input.trim(),
    })
    setInput('')
  }

  return (
    <div className="mt-6">
      <h3 className="font-semibold text-sm text-gray-700 mb-3">
        💬 댓글 {comments.length > 0 && `(${comments.length})`}
      </h3>

      <div className="space-y-3 max-h-64 overflow-y-auto mb-3">
        {comments.map(c => (
          <div key={c.id} className="flex gap-3">
            <div className="w-7 h-7 rounded-full bg-indigo-400 flex items-center justify-center text-white text-xs shrink-0">
              {c.profiles?.name[0].toUpperCase()}
            </div>
            <div className="bg-gray-50 rounded-xl px-3 py-2 flex-1">
              <p className="text-xs font-medium text-gray-600 mb-1">{c.profiles?.name}</p>
              <p className="text-sm text-gray-800">{c.content}</p>
            </div>
          </div>
        ))}
        <div ref={bottomRef} />
      </div>

      <div className="flex gap-2">
        <input
          value={input}
          onChange={e => setInput(e.target.value)}
          onKeyDown={e => e.key === 'Enter' && addComment()}
          placeholder="댓글 추가..."
          className="flex-1 border rounded-xl px-3 py-2 text-sm outline-none focus:border-indigo-400"
        />
        <button
          onClick={addComment}
          className="bg-indigo-500 text-white rounded-xl px-4 text-sm hover:bg-indigo-600"
        >
          등록
        </button>
      </div>
    </div>
  )
}

8단계: 시드 데이터

-- supabase/seed.sql

-- 기본 태스크 상태 생성 (프로젝트 생성 시 트리거로 자동 생성하거나 수동 삽입)
-- 실제로는 프로젝트 생성 시 DB 함수로 자동 생성하는 패턴 권장

CREATE OR REPLACE FUNCTION create_default_statuses(p_project_id UUID)
RETURNS VOID AS $$
BEGIN
  INSERT INTO task_statuses (project_id, name, color, position)
  VALUES
    (p_project_id, '할 일',     '#6b7280', 0),
    (p_project_id, '진행 중',   '#3b82f6', 1),
    (p_project_id, '검토 중',   '#f59e0b', 2),
    (p_project_id, '완료',      '#10b981', 3);
END;
$$ LANGUAGE plpgsql;

-- 프로젝트 생성 시 기본 상태 자동 생성 트리거
CREATE OR REPLACE FUNCTION on_project_created()
RETURNS TRIGGER AS $$
BEGIN
  PERFORM create_default_statuses(NEW.id);
  RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

CREATE TRIGGER project_created
  AFTER INSERT ON projects
  FOR EACH ROW EXECUTE FUNCTION on_project_created();

낙관적 업데이트 패턴 정리

이번 편에서 구현한 낙관적 업데이트의 핵심 흐름입니다.

사용자 액션
    ↓
① addOptimistic() — 즉시 UI 반영 (낙관적 상태)
    ↓
② 서버 요청 (비동기)
    ↓
   성공 → serverTasks 업데이트 → optimistic 상태와 병합
   실패 → serverTasks 그대로 → optimistic 롤백
// 핵심 원칙
// 1. optimistic 상태는 사용자에게 즉시 보임
// 2. 서버 응답 후 serverTasks 업데이트 → 자동으로 동기화
// 3. 실패 시 serverTasks 리로드로 자동 롤백
// 4. Realtime으로 다른 사용자 변경사항 수신

배포 체크리스트

✅ supabase db push — 마이그레이션 배포
✅ npm run types — 타입 재생성
✅ Realtime 활성화 확인 (Dashboard → Database → Replication)
✅ RLS 정책 테스트 (다른 워크스페이스 접근 차단 확인)
✅ Vercel 환경변수 설정

시리즈 완결: 배운 것들 돌아보기

총 16편을 통해 Supabase의 모든 핵심 기능을 이론과 실전으로 완벽하게 익혔습니다.

**이론편 (1~12편)**에서는 PostgreSQL + REST/GraphQL API, Auth(JWT/MFA/SAML), RLS 보안, Realtime(Broadcast/Presence), Storage, Edge Functions, pgvector + RAG, Cron Jobs + Queues, MCP 서버 연동, CLI + 마이그레이션, Analytics Buckets까지 Supabase의 모든 기능을 다뤘습니다.

**실전편 (13~16편)**에서는 이 모든 기능이 실제 제품에서 어떻게 조합되는지 보여줬습니다.

핵심 기술 스택
13편 채팅 앱Broadcast + Presence + Postgres Changes + RLS
14편 SaaS 대시보드Stripe Webhook + 플랜 제한 + RBAC
15편 AI 챗봇pgvector + RAG + 스트리밍 + Edge Functions
16편 협업툴낙관적 업데이트 + Drag & Drop + Realtime

Supabase는 단순한 Firebase 대안을 넘어, 완전한 오픈소스 백엔드 플랫폼입니다. 시리즈를 통해 배운 패턴들을 활용해 여러분만의 제품을 만들어 보세요! 🚀




댓글 남기기