OPEN HYPER STEP
← 목록으로 (master-project)
MASTER-PROJECT · 17 / 50
master-project
CHAPTER 17 / 50
읽기 약 2
FUNCTION

CRUD UI: 목록/상세/생성/수정/삭제


핵심 개념

Generation 엔터티 CRUD — Server Action·Optimistic UI·낙관적 업데이트·삭제 확인 모달.

본문

목록 (List)

TSX📋 코드 (21줄)
// 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)

TSX📋 코드 (23줄)
// 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)

TSX📋 코드 (31줄)
// 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

TSX📋 코드 (13줄)
'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) — 확인 모달

TSX📋 코드 (37줄)
'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 기반)

TSX📋 코드 (7줄)
// 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