master-project
CHAPTER 17 / 50
읽기 약 2분
FUNCTION
CRUD UI: 목록/상세/생성/수정/삭제
핵심 개념
Generation 엔터티 CRUD — Server Action·Optimistic UI·낙관적 업데이트·삭제 확인 모달.
본문
목록 (List)
// src/app/(app)/history/page.tsx
import { createClient } from '@/lib/supabase/server'
import { GenerationCard } from './generation-card'
export default async function HistoryPage() {
const supabase = await createClient()
const { data: gens } = await supabase
.from('generations')
.select('*')
.order('created_at', { ascending: false })
.limit(50)
return (
<div className="space-y-4">
<h1 className="text-2xl font-bold">히스토리</h1>
<div className="grid gap-3">
{gens?.map(g => <GenerationCard key={g.id} gen={g} />)}
</div>
</div>
)
}상세 (Detail)
// src/app/(app)/history/[id]/page.tsx
import { notFound } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'
export default async function DetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const supabase = await createClient()
const { data: gen } = await supabase
.from('generations')
.select('*')
.eq('id', id)
.single()
if (!gen) notFound()
return (
<article className="max-w-2xl">
<h1 className="text-xl font-bold mb-2">{gen.prompt}</h1>
<time className="text-sm text-muted-foreground">{gen.created_at}</time>
<div className="mt-6 prose dark:prose-invert">{gen.result}</div>
</article>
)
}생성 (Create)
// src/app/(app)/generate/actions.ts
'use server'
import { z } from 'zod'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'
const schema = z.object({ prompt: z.string().min(1).max(500) })
export async function createGeneration(formData: FormData) {
const parsed = schema.safeParse({ prompt: formData.get('prompt') })
if (!parsed.success) return { error: '입력값 오류' }
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: '로그인 필요' }
// AI 호출은 CH.35
const result = await callAI(parsed.data.prompt)
const { data, error } = await supabase
.from('generations')
.insert({ user_id: user.id, prompt: parsed.data.prompt, result })
.select()
.single()
if (error) return { error: error.message }
revalidatePath('/history')
redirect(`/history/${data.id}`)
}수정 (Update) — Optimistic UI
'use client'
import { useOptimistic } from 'react'
export function EditableTitle({ gen }: { gen: Generation }) {
const [optimisticGen, updateOptimistic] = useOptimistic(gen)
const handleSave = async (newPrompt: string) => {
updateOptimistic({ ...gen, prompt: newPrompt }) // 즉시 UI 업데이트
await updateAction(gen.id, newPrompt) // 서버 호출
}
return <input defaultValue={optimisticGen.prompt} onBlur={e => handleSave(e.target.value)} />
}삭제 (Delete) — 확인 모달
'use client'
import { useTransition } from 'react'
import { Dialog, DialogContent, DialogTrigger } from '@/components/ui/dialog'
import { Trash } from 'lucide-react'
import { deleteGeneration } from './actions'
export function DeleteButton({ id }: { id: string }) {
const [pending, startTransition] = useTransition()
return (
<Dialog>
<DialogTrigger className="text-red-500"><Trash className="h-4 w-4" /></DialogTrigger>
<DialogContent>
<h3 className="font-semibold mb-2">정말 삭제하시겠습니까?</h3>
<p className="text-sm text-muted-foreground mb-4">되돌릴 수 없습니다.</p>
<div className="flex gap-2 justify-end">
<button>취소</button>
<button
disabled={pending}
onClick={() => startTransition(() => deleteGeneration(id))}
className="bg-red-500 text-white px-3 py-1.5 rounded"
>
{pending ? '삭제 중...' : '삭제'}
</button>
</div>
</DialogContent>
</Dialog>
)
}
// actions.ts
'use server'
export async function deleteGeneration(id: string) {
const supabase = await createClient()
await supabase.from('generations').delete().eq('id', id)
revalidatePath('/history')
}페이지네이션 (Cursor 기반)
// src/app/(app)/history/page.tsx
const cursor = searchParams.cursor
let q = supabase.from('generations').select('*').order('created_at', { ascending: false }).limit(20)
if (cursor) q = q.lt('created_at', cursor)
const { data: gens } = await q
const nextCursor = gens?.[gens.length - 1]?.created_at다음 챕터
CH.18 "검색 + 필터 + 정렬 + 페이지네이션".
AI 프롬프트
🤖 AI에게 잘 물어보는 법 — 모델·전략별 프롬프트
Claude
무료: Sonnet 4.6 / Pro $20/mo: Opus 4.6
내 마스터 프로젝트의 CRUD UI 구현 부분을 분석해서 실전 적용 + 개선 우선순위 3가지를 알려줘.
ChatGPT
무료: GPT-5.5 / Plus $20/mo: GPT-5.5 Pro
CRUD UI 구현 관련 모범 사례·안티패턴 5개를 비교 분석해서 실전 적용를 위한 추천 방안을 알려줘.
Gemini
무료: 2.5 Flash / Pro $19.99/mo: 3.1 Pro
내 프로젝트 전체에서 CRUD UI 구현 최적화 가능 위치와 리스크를 보고해줘.
Grok
무료: Grok 4.1 / SuperGrok $30/mo
2026년 한국 1인 개발자 시장의 CRUD UI 구현 트렌드와 차별화 포인트를 정리해줘.
⭐ 이것만 기억하세요
CRUD UI: 목록/상세/생성/수정/삭제는 이 3가지만 확실히 잡으세요
1.Server Action + revalidatePath = SSR CRUD
2.useOptimistic = 즉시 UI 업데이트
3.다음 챕터에서 검색·필터
공유하기
진행도 17 / 50