시리즈 목차 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 대안을 넘어, 완전한 오픈소스 백엔드 플랫폼입니다. 시리즈를 통해 배운 패턴들을 활용해 여러분만의 제품을 만들어 보세요! 🚀