본문으로 건너뛰기
Kreath Archive
TechProjectsBooksAbout
TechProjectsBooksAbout

내비게이션

  • Tech
  • Projects
  • Books
  • About
  • Tags

카테고리

  • AI / ML
  • 웹 개발
  • 프로그래밍
  • 개발 도구

연결

  • GitHub
  • Email
  • RSS
© 2026 Kreath Archive. All rights reserved.Built with Next.js + MDX
홈TechProjectsBooksAbout
//
  1. 홈
  2. 테크
  3. 11장: 실전 프로젝트 - React 19 풀스택 앱 구축
2026년 2월 12일·웹 개발·

11장: 실전 프로젝트 - React 19 풀스택 앱 구축

React 19의 핵심 기능을 모두 활용한 풀스택 북마크 앱을 구축합니다. Server Components, Server Actions, 새로운 훅, Suspense 패턴을 실전에 적용합니다.

16분1,660자13개 섹션
reactnextjsperformancefrontendtypescript
공유
react19-rsc11 / 11
1234567891011
이전10장: React 18에서 19로 마이그레이션

10장까지 React 19의 모든 핵심 기능을 개별적으로 다루었습니다. 이 마지막 장에서는 이 모든 기능을 하나의 프로젝트에 통합합니다. 북마크 관리 앱을 처음부터 구축하면서 Server Components, Server Actions, use(), useActionState, useOptimistic, Suspense 스트리밍을 실전에서 어떻게 조합하는지 확인합니다.

프로젝트 개요

기능 요구사항

  • URL을 입력하면 자동으로 제목과 설명을 가져와 북마크로 저장
  • 태그 기반 분류 및 검색
  • 북마크 즐겨찾기(별표) 기능
  • 목록 정렬 (최신순, 제목순)

사용할 React 19 기능

기능활용
Server Components데이터 조회, 목록 렌더링, 레이아웃
Server Actions북마크 CRUD, 즐겨찾기 토글
useActionState폼 상태 관리, 에러 처리
useOptimistic즐겨찾기 즉시 반영
useFormStatus제출 버튼 상태
use()스트리밍 데이터 소비
Suspense점진적 로딩 UI
ref as prop입력 포커스 제어

기술 스택

  • Next.js 15+ (App Router)
  • React 19
  • TypeScript
  • Tailwind CSS
  • SQLite (better-sqlite3, 간편한 데모용)

프로젝트 구조

app/
  layout.tsx              # 루트 레이아웃 (Server Component)
  page.tsx                # 메인 페이지 (Server Component)
  loading.tsx             # Suspense fallback
components/
  BookmarkForm.tsx        # 북마크 추가 폼 (Client)
  BookmarkList.tsx        # 북마크 목록 (Server)
  BookmarkCard.tsx        # 개별 카드 (Client)
  FavoriteButton.tsx      # 즐겨찾기 버튼 (Client)
  SearchBar.tsx           # 검색 바 (Client)
  SubmitButton.tsx        # 범용 제출 버튼 (Client)
  TagFilter.tsx           # 태그 필터 (Client)
  BookmarkSkeleton.tsx    # 로딩 스켈레톤
actions/
  bookmark.ts             # Server Actions
lib/
  db.ts                   # 데이터베이스
  types.ts                # 타입 정의

데이터 모델과 타입

lib/types.ts
typescript
export interface Bookmark {
  id: string;
  url: string;
  title: string;
  description: string;
  tags: string[];
  isFavorite: boolean;
  createdAt: Date;
}
 
export interface BookmarkFormState {
  success: boolean;
  error: string | null;
  fieldErrors?: {
    url?: string[];
    tags?: string[];
  };
}

데이터 접근 계층

lib/db.ts
typescript
import { cache } from 'react';
import type { Bookmark } from './types';
 
// React cache로 요청 내 중복 쿼리 방지
export const getBookmarks = cache(
  async (options?: {
    search?: string;
    tag?: string;
    sort?: 'date' | 'title';
  }): Promise<Bookmark[]> => {
    let bookmarks = await db.select().from(bookmarksTable);
 
    if (options?.search) {
      const q = options.search.toLowerCase();
      bookmarks = bookmarks.filter(
        b =>
          b.title.toLowerCase().includes(q) ||
          b.description.toLowerCase().includes(q) ||
          b.url.toLowerCase().includes(q)
      );
    }
 
    if (options?.tag) {
      bookmarks = bookmarks.filter(b => b.tags.includes(options.tag!));
    }
 
    if (options?.sort === 'title') {
      bookmarks.sort((a, b) => a.title.localeCompare(b.title));
    } else {
      bookmarks.sort(
        (a, b) => b.createdAt.getTime() - a.createdAt.getTime()
      );
    }
 
    return bookmarks;
  }
);
 
export const getAllTags = cache(async (): Promise<string[]> => {
  const bookmarks = await db.select().from(bookmarksTable);
  const tagSet = new Set<string>();
  bookmarks.forEach(b => b.tags.forEach(t => tagSet.add(t)));
  return Array.from(tagSet).sort();
});
 
export const getBookmarkStats = cache(async () => {
  const bookmarks = await db.select().from(bookmarksTable);
  return {
    total: bookmarks.length,
    favorites: bookmarks.filter(b => b.isFavorite).length,
    tags: new Set(bookmarks.flatMap(b => b.tags)).size,
  };
});

핵심 포인트: cache()로 감싸면 같은 렌더링 내에서 동일 인자의 호출 결과가 재사용됩니다. getBookmarks를 여러 Server Component에서 호출해도 쿼리는 한 번만 실행됩니다.

Server Actions

actions/bookmark.ts
typescript
'use server';
 
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
import type { BookmarkFormState } from '@/lib/types';
 
const BookmarkSchema = z.object({
  url: z
    .string()
    .url('유효한 URL을 입력해주세요.')
    .max(2000, 'URL이 너무 깁니다.'),
  tags: z
    .string()
    .transform(s =>
      s
        .split(',')
        .map(t => t.trim().toLowerCase())
        .filter(Boolean)
    )
    .pipe(z.array(z.string().max(30)).max(10, '태그는 10개 이하여야 합니다.')),
});
 
export async function createBookmark(
  prevState: BookmarkFormState,
  formData: FormData
): Promise<BookmarkFormState> {
  const raw = {
    url: formData.get('url') as string,
    tags: formData.get('tags') as string,
  };
 
  const result = BookmarkSchema.safeParse(raw);
 
  if (!result.success) {
    return {
      success: false,
      error: null,
      fieldErrors: result.error.flatten().fieldErrors,
    };
  }
 
  try {
    // URL에서 메타데이터 가져오기
    const metadata = await fetchUrlMetadata(result.data.url);
 
    await db.insert(bookmarksTable).values({
      id: crypto.randomUUID(),
      url: result.data.url,
      title: metadata.title || result.data.url,
      description: metadata.description || '',
      tags: result.data.tags,
      isFavorite: false,
      createdAt: new Date(),
    });
 
    revalidatePath('/');
    return { success: true, error: null };
  } catch {
    return {
      success: false,
      error: '북마크 저장에 실패했습니다. 잠시 후 다시 시도해주세요.',
    };
  }
}
 
export async function toggleFavorite(id: string) {
  const bookmark = await db
    .select()
    .from(bookmarksTable)
    .where(eq(bookmarksTable.id, id))
    .get();
 
  if (!bookmark) return;
 
  await db
    .update(bookmarksTable)
    .set({ isFavorite: !bookmark.isFavorite })
    .where(eq(bookmarksTable.id, id));
 
  revalidatePath('/');
}
 
export async function deleteBookmark(id: string) {
  await db.delete(bookmarksTable).where(eq(bookmarksTable.id, id));
  revalidatePath('/');
}
 
async function fetchUrlMetadata(url: string) {
  try {
    const response = await fetch(url, {
      headers: { 'User-Agent': 'BookmarkBot/1.0' },
      signal: AbortSignal.timeout(5000),
    });
    const html = await response.text();
 
    const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
    const descMatch = html.match(
      /<meta[^>]*name=["']description["'][^>]*content=["']([^"']+)["']/i
    );
 
    return {
      title: titleMatch?.[1]?.trim() || '',
      description: descMatch?.[1]?.trim() || '',
    };
  } catch {
    return { title: '', description: '' };
  }
}

메인 페이지: Server Component

app/page.tsx
typescript
import { Suspense } from 'react';
import { getBookmarks, getAllTags, getBookmarkStats } from '@/lib/db';
import { BookmarkForm } from '@/components/BookmarkForm';
import { BookmarkList } from '@/components/BookmarkList';
import { SearchBar } from '@/components/SearchBar';
import { TagFilter } from '@/components/TagFilter';
import { BookmarkSkeleton } from '@/components/BookmarkSkeleton';
 
interface PageProps {
  searchParams: Promise<{
    q?: string;
    tag?: string;
    sort?: 'date' | 'title';
  }>;
}
 
export default async function HomePage({ searchParams }: PageProps) {
  const params = await searchParams;
 
  // 즉시 필요한 데이터
  const tags = await getAllTags();
  const stats = await getBookmarkStats();
 
  // 스트리밍할 데이터 (await하지 않음)
  const bookmarksPromise = getBookmarks({
    search: params.q,
    tag: params.tag,
    sort: params.sort,
  });
 
  return (
    <main className="mx-auto max-w-4xl px-4 py-8">
      <header className="mb-8">
        <h1 className="text-3xl font-bold">북마크</h1>
        <p className="mt-1 text-gray-600">
          {stats.total}개 북마크 / {stats.favorites}개 즐겨찾기 /
          {stats.tags}개 태그
        </p>
      </header>
 
      {/* 북마크 추가 폼 (Client Component) */}
      <section className="mb-8">
        <BookmarkForm />
      </section>
 
      {/* 검색과 필터 */}
      <div className="mb-6 flex flex-col gap-4 sm:flex-row">
        <SearchBar defaultValue={params.q} />
        <TagFilter tags={tags} selectedTag={params.tag} />
      </div>
 
      {/* 북마크 목록 (스트리밍) */}
      <Suspense fallback={<BookmarkSkeleton count={6} />}>
        <BookmarkList bookmarksPromise={bookmarksPromise} />
      </Suspense>
    </main>
  );
}

핵심 포인트: tags와 stats는 await로 즉시 가져오고, bookmarksPromise는 await 없이 Suspense 경계 안의 Client Component로 전달합니다. 사용자는 폼과 필터를 먼저 보고, 북마크 목록은 준비되는 대로 스트리밍됩니다.

북마크 폼: useActionState + useFormStatus

components/BookmarkForm.tsx
typescript
'use client';
 
import { useActionState, useRef, useEffect } from 'react';
import { createBookmark } from '@/actions/bookmark';
import { SubmitButton } from './SubmitButton';
import type { BookmarkFormState } from '@/lib/types';
 
const initialState: BookmarkFormState = {
  success: false,
  error: null,
};
 
export function BookmarkForm() {
  const [state, formAction, isPending] = useActionState(
    createBookmark,
    initialState
  );
  const formRef = useRef<HTMLFormElement>(null);
  const urlInputRef = useRef<HTMLInputElement>(null);
 
  // 성공 시 폼 초기화 및 포커스 이동
  useEffect(() => {
    if (state.success) {
      formRef.current?.reset();
      urlInputRef.current?.focus();
    }
  }, [state.success]);
 
  return (
    <form
      ref={formRef}
      action={formAction}
      className="rounded-lg border bg-white p-6 shadow-sm dark:border-gray-700 dark:bg-gray-800"
    >
      <h2 className="mb-4 text-lg font-semibold">새 북마크 추가</h2>
 
      <div className="space-y-4">
        <div>
          <label htmlFor="url" className="mb-1 block text-sm font-medium">
            URL
          </label>
          <input
            ref={urlInputRef}
            id="url"
            name="url"
            type="url"
            placeholder="https://example.com"
            required
            disabled={isPending}
            className="w-full rounded-md border px-3 py-2 text-sm
              focus:outline-none focus:ring-2 focus:ring-blue-500
              disabled:opacity-50 dark:border-gray-600 dark:bg-gray-700"
          />
          {state.fieldErrors?.url && (
            <p className="mt-1 text-sm text-red-500">
              {state.fieldErrors.url[0]}
            </p>
          )}
        </div>
 
        <div>
          <label htmlFor="tags" className="mb-1 block text-sm font-medium">
            태그 (쉼표로 구분)
          </label>
          <input
            id="tags"
            name="tags"
            type="text"
            placeholder="react, typescript, frontend"
            disabled={isPending}
            className="w-full rounded-md border px-3 py-2 text-sm
              focus:outline-none focus:ring-2 focus:ring-blue-500
              disabled:opacity-50 dark:border-gray-600 dark:bg-gray-700"
          />
          {state.fieldErrors?.tags && (
            <p className="mt-1 text-sm text-red-500">
              {state.fieldErrors.tags[0]}
            </p>
          )}
        </div>
 
        <div className="flex items-center justify-between">
          <SubmitButton>북마크 추가</SubmitButton>
 
          {state.error && (
            <p className="text-sm text-red-500">{state.error}</p>
          )}
          {state.success && (
            <p className="text-sm text-green-600">저장되었습니다!</p>
          )}
        </div>
      </div>
    </form>
  );
}
components/SubmitButton.tsx
typescript
'use client';
 
import { useFormStatus } from 'react-dom';
 
interface SubmitButtonProps {
  children: React.ReactNode;
}
 
export function SubmitButton({ children }: SubmitButtonProps) {
  const { pending } = useFormStatus();
 
  return (
    <button
      type="submit"
      disabled={pending}
      className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium
        text-white hover:bg-blue-700 disabled:opacity-50
        dark:bg-blue-500 dark:hover:bg-blue-600"
    >
      {pending ? '저장 중...' : children}
    </button>
  );
}

핵심 포인트: useActionState가 폼 상태(에러, 성공)와 pending을 관리합니다. SubmitButton은 useFormStatus로 부모 폼의 제출 상태를 읽어 버튼 텍스트를 변경합니다. ref는 일반 props로 전달됩니다(React 19).

북마크 목록: use()로 스트리밍 소비

components/BookmarkList.tsx
typescript
'use client';
 
import { use } from 'react';
import { BookmarkCard } from './BookmarkCard';
import type { Bookmark } from '@/lib/types';
 
interface BookmarkListProps {
  bookmarksPromise: Promise<Bookmark[]>;
}
 
export function BookmarkList({ bookmarksPromise }: BookmarkListProps) {
  const bookmarks = use(bookmarksPromise);
 
  if (bookmarks.length === 0) {
    return (
      <div className="py-12 text-center text-gray-500">
        <p className="text-lg">북마크가 없습니다</p>
        <p className="mt-1 text-sm">위 폼에서 URL을 추가해보세요.</p>
      </div>
    );
  }
 
  return (
    <div className="grid gap-4 sm:grid-cols-2">
      {bookmarks.map(bookmark => (
        <BookmarkCard key={bookmark.id} bookmark={bookmark} />
      ))}
    </div>
  );
}

북마크 카드: useOptimistic으로 즐겨찾기

components/BookmarkCard.tsx
typescript
'use client';
 
import { useTransition } from 'react';
import { FavoriteButton } from './FavoriteButton';
import { deleteBookmark } from '@/actions/bookmark';
import type { Bookmark } from '@/lib/types';
 
interface BookmarkCardProps {
  bookmark: Bookmark;
}
 
export function BookmarkCard({ bookmark }: BookmarkCardProps) {
  const [isDeleting, startTransition] = useTransition();
 
  function handleDelete() {
    startTransition(async () => {
      await deleteBookmark(bookmark.id);
    });
  }
 
  return (
    <div
      className={`rounded-lg border p-4 transition-opacity
        dark:border-gray-700 ${isDeleting ? 'opacity-40' : ''}`}
    >
      <div className="flex items-start justify-between">
        <div className="min-w-0 flex-1">
          <a
            href={bookmark.url}
            target="_blank"
            rel="noopener noreferrer"
            className="font-medium text-blue-600 hover:underline
              dark:text-blue-400"
          >
            {bookmark.title}
          </a>
          <p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
            {bookmark.url}
          </p>
          {bookmark.description && (
            <p className="mt-2 line-clamp-2 text-sm text-gray-600
              dark:text-gray-300">
              {bookmark.description}
            </p>
          )}
        </div>
 
        <FavoriteButton
          bookmarkId={bookmark.id}
          isFavorite={bookmark.isFavorite}
        />
      </div>
 
      <div className="mt-3 flex items-center justify-between">
        <div className="flex flex-wrap gap-1">
          {bookmark.tags.map(tag => (
            <span
              key={tag}
              className="rounded-full bg-gray-100 px-2 py-0.5
                text-xs text-gray-600 dark:bg-gray-700 dark:text-gray-300"
            >
              {tag}
            </span>
          ))}
        </div>
 
        <button
          onClick={handleDelete}
          disabled={isDeleting}
          className="text-sm text-gray-400 hover:text-red-500
            disabled:opacity-50"
        >
          삭제
        </button>
      </div>
    </div>
  );
}
components/FavoriteButton.tsx
typescript
'use client';
 
import { useOptimistic, useTransition } from 'react';
import { toggleFavorite } from '@/actions/bookmark';
 
interface FavoriteButtonProps {
  bookmarkId: string;
  isFavorite: boolean;
}
 
export function FavoriteButton({
  bookmarkId,
  isFavorite,
}: FavoriteButtonProps) {
  const [optimisticFavorite, setOptimisticFavorite] =
    useOptimistic(isFavorite);
  const [, startTransition] = useTransition();
 
  function handleToggle() {
    startTransition(async () => {
      setOptimisticFavorite(!optimisticFavorite);
      await toggleFavorite(bookmarkId);
    });
  }
 
  return (
    <button
      onClick={handleToggle}
      aria-label={optimisticFavorite ? '즐겨찾기 해제' : '즐겨찾기 추가'}
      aria-pressed={optimisticFavorite}
      className="ml-2 text-xl transition-transform hover:scale-110"
    >
      {optimisticFavorite ? '★' : '☆'}
    </button>
  );
}

핵심 포인트: useOptimistic은 toggleFavorite Server Action의 응답을 기다리지 않고 즉시 별 아이콘을 변경합니다. 서버 요청이 실패하면 자동으로 원래 상태로 롤백됩니다.

검색과 필터

components/SearchBar.tsx
typescript
'use client';
 
import { useRouter, useSearchParams } from 'next/navigation';
import { useTransition, useRef, useCallback } from 'react';
 
interface SearchBarProps {
  defaultValue?: string;
}
 
export function SearchBar({ defaultValue }: SearchBarProps) {
  const router = useRouter();
  const searchParams = useSearchParams();
  const [isPending, startTransition] = useTransition();
  const timerRef = useRef<ReturnType<typeof setTimeout>>(null);
 
  const handleSearch = useCallback(
    (value: string) => {
      if (timerRef.current) clearTimeout(timerRef.current);
 
      timerRef.current = setTimeout(() => {
        startTransition(() => {
          const params = new URLSearchParams(searchParams.toString());
          if (value) {
            params.set('q', value);
          } else {
            params.delete('q');
          }
          router.push(`/?${params.toString()}`);
        });
      }, 300);
    },
    [router, searchParams, startTransition]
  );
 
  return (
    <div className="relative flex-1">
      <input
        type="search"
        defaultValue={defaultValue}
        onChange={(e) => handleSearch(e.target.value)}
        placeholder="검색..."
        className={`w-full rounded-md border px-3 py-2 text-sm
          focus:outline-none focus:ring-2 focus:ring-blue-500
          dark:border-gray-600 dark:bg-gray-700
          ${isPending ? 'opacity-60' : ''}`}
      />
      {isPending && (
        <div className="absolute right-3 top-1/2 -translate-y-1/2">
          <div className="h-4 w-4 animate-spin rounded-full
            border-2 border-gray-300 border-t-blue-500" />
        </div>
      )}
    </div>
  );
}

핵심 포인트: 검색어 변경은 URL 파라미터를 업데이트합니다. startTransition으로 감싸서 검색 중에도 UI가 반응적입니다. 300ms 디바운스로 불필요한 요청을 방지합니다.

로딩 스켈레톤

components/BookmarkSkeleton.tsx
typescript
interface BookmarkSkeletonProps {
  count?: number;
}
 
export function BookmarkSkeleton({ count = 4 }: BookmarkSkeletonProps) {
  return (
    <div className="grid gap-4 sm:grid-cols-2">
      {Array.from({ length: count }).map((_, i) => (
        <div
          key={i}
          className="animate-pulse rounded-lg border p-4
            dark:border-gray-700"
        >
          <div className="h-5 w-3/4 rounded bg-gray-200 dark:bg-gray-700" />
          <div className="mt-2 h-3 w-full rounded bg-gray-200 dark:bg-gray-700" />
          <div className="mt-3 h-3 w-2/3 rounded bg-gray-200 dark:bg-gray-700" />
          <div className="mt-4 flex gap-2">
            <div className="h-5 w-12 rounded-full bg-gray-200 dark:bg-gray-700" />
            <div className="h-5 w-16 rounded-full bg-gray-200 dark:bg-gray-700" />
          </div>
        </div>
      ))}
    </div>
  );
}

아키텍처 복습

이 프로젝트에서 Server/Client 경계가 어떻게 설계되었는지 정리합니다.

page.tsx (Server Component)
├── BookmarkForm (Client) ─── useActionState, useFormStatus
│   └── SubmitButton (Client) ─── useFormStatus
├── SearchBar (Client) ─── useTransition
├── TagFilter (Client)
├── [Suspense boundary]
│   └── BookmarkList (Client) ─── use()
│       └── BookmarkCard (Client) ─── useTransition
│           └── FavoriteButton (Client) ─── useOptimistic
└── BookmarkSkeleton (Server) ─── fallback UI

Server Component 역할: 데이터 조회(getBookmarks, getAllTags, getBookmarkStats), 레이아웃 구성, Promise 생성 및 전달

Client Component 역할: 사용자 인터랙션(폼 입력, 검색, 필터, 즐겨찾기, 삭제), 상태 관리, 낙관적 업데이트

경계 설계 원칙: page.tsx가 Server Component로서 데이터를 조회하고, 인터랙션이 필요한 최소 단위만 Client Component로 분리했습니다.

시리즈를 마치며

이 시리즈에서 React 19의 핵심 기능을 모두 다루었습니다. 최종 정리로 각 기능과 그 역할을 한눈에 정리합니다.

기능역할핵심 가치
Server Components서버 전용 렌더링번들 크기 감소, 직접 데이터 접근
Server Actions서버 사이드 뮤테이션안전한 폼 처리, Progressive Enhancement
use()Promise/Context 소비조건부 호출, 스트리밍 통합
useActionState액션 상태 관리폼 상태 + 펜딩 + 에러 통합
useFormStatus폼 제출 상태재사용 가능한 폼 UI 컴포넌트
useOptimistic낙관적 업데이트즉각적 UI 피드백, 자동 롤백
Suspense 스트리밍점진적 렌더링빠른 초기 표시, 부분 로딩
React Compiler자동 메모이제이션수동 최적화 제거
ref as prop간소화된 ref 전달forwardRef 불필요
메타데이터/프리로딩리소스 관리네이티브 head 관리, 성능 최적화

React 19는 단순한 기능 추가가 아닌, React 애플리케이션을 설계하는 방식 자체의 전환입니다. 서버와 클라이언트의 역할을 명확히 분리하고, 각각의 장점을 극대화하는 아키텍처를 구축할 수 있게 되었습니다. 이 시리즈가 React 19의 새로운 패러다임을 이해하고 실전에 적용하는 데 도움이 되었기를 바랍니다.

이 글이 도움이 되셨나요?

관련 주제 더 보기

#react#nextjs#performance#frontend#typescript

관련 글

웹 개발

10장: React 18에서 19로 마이그레이션

React 18에서 19로 안전하게 업그레이드하는 단계별 가이드입니다. 제거된 API, 타입 변경, 동작 변화, 자동 마이그레이션 도구를 다룹니다.

2026년 2월 10일·14분
웹 개발

9장: 성능 최적화 전략과 베스트 프랙티스

React 19 애플리케이션의 성능을 극대화하는 전략을 다룹니다. 번들 최적화, 렌더링 성능, Core Web Vitals 개선, 측정 도구 활용법을 배웁���다.

2026년 2월 8일·15분
웹 개발

8장: ref 개선, 메타데이터, 리소스 로딩 API

React 19의 DX 개선사항을 다룹니다. ref를 일반 props로 전달하는 방법, 컴포넌트 내 메타데이터 태그, 리소스 프리로딩 API를 살펴봅니다.

2026년 2월 6일·16분
이전 글10장: React 18에서 19로 마이그레이션

댓글

목차

약 16분 남음
  • 프로젝트 개요
    • 기능 요구사항
    • 사용할 React 19 기능
    • 기술 스택
  • 프로젝트 구조
  • 데이터 모델과 타입
  • 데이터 접근 계층
  • Server Actions
  • 메인 페이지: Server Component
  • 북마크 폼: useActionState + useFormStatus
  • 북마크 목록: use()로 스트리밍 소비
  • 북마크 카드: useOptimistic으로 즐겨찾기
  • 검색과 필터
  • 로딩 스켈레톤
  • 아키텍처 복습
  • 시리즈를 마치며