React 19의 핵심 기능을 모두 활용한 풀스택 북마크 앱을 구축합니다. Server Components, Server Actions, 새로운 훅, Suspense 패턴을 실전에 적용합니다.
10장까지 React 19의 모든 핵심 기능을 개별적으로 다루었습니다. 이 마지막 장에서는 이 모든 기능을 하나의 프로젝트에 통합합니다. 북마크 관리 앱을 처음부터 구축하면서 Server Components, Server Actions, use(), useActionState, useOptimistic, Suspense 스트리밍을 실전에서 어떻게 조합하는지 확인합니다.
| 기능 | 활용 |
|---|---|
| Server Components | 데이터 조회, 목록 렌더링, 레이아웃 |
| Server Actions | 북마크 CRUD, 즐겨찾기 토글 |
useActionState | 폼 상태 관리, 에러 처리 |
useOptimistic | 즐겨찾기 즉시 반영 |
useFormStatus | 제출 버튼 상태 |
use() | 스트리밍 데이터 소비 |
| Suspense | 점진적 로딩 UI |
| ref as prop | 입력 포커스 제어 |
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 # 타입 정의
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[];
};
}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에서 호출해도 쿼리는 한 번만 실행됩니다.
'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: '' };
}
}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로 전달합니다. 사용자는 폼과 필터를 먼저 보고, 북마크 목록은 준비되는 대로 스트리밍됩니다.
'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>
);
}'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 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>
);
}'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>
);
}'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의 응답을 기다리지 않고 즉시 별 아이콘을 변경합니다. 서버 요청이 실패하면 자동으로 원래 상태로 롤백됩니다.
'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 디바운스로 불필요한 요청을 방지합니다.
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 18에서 19로 안전하게 업그레이드하는 단계별 가이드입니다. 제거된 API, 타입 변경, 동작 변화, 자동 마이그레이션 도구를 다룹니다.
React 19 애플리케이션의 성능을 극대화하는 전략을 다룹니다. 번들 최적화, 렌더링 성능, Core Web Vitals 개선, 측정 도구 활용법을 배웁���다.
React 19의 DX 개선사항을 다룹니다. ref를 일반 props로 전달하는 방법, 컴포넌트 내 메타데이터 태그, 리소스 프리로딩 API를 살펴봅니다.