본문으로 건너뛰기
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. 3장: Server Actions로 서버-클라이언트 통합하기
2026년 1월 27일·웹 개발·

3장: Server Actions로 서버-클라이언트 통합하기

Server Actions의 동작 원리, 폼 처리 패턴, 데이터 뮤테이션, 에러 핸들링, 보안 고려사항을 실전 코드와 함께 다룹니다.

22분1,579자11개 섹션
reactnextjsperformancefrontendtypescript
공유
react19-rsc3 / 11
1234567891011
이전2장: React Server Components 아키텍처 심층 분석다음4장: use() API와 새로운 데이터 패턴

2장에서 Server Components가 데이터를 읽는 방법을 살펴보았습니다. 이번 장에서는 데이터를 쓰는(변경하는) 방법인 Server Actions를 다룹니다. Server Actions는 클라이언트에서 호출하면 서버에서 실행되는 비동기 함수로, 폼 제출, 데이터 변경, 파일 업로드 등의 서버 측 로직을 안전하고 간결하게 처리합니다.

Server Actions란 무엇인가

Server Actions(공식 명칭: Server Functions)는 'use server' 디렉티브로 표시된 비동기 함수입니다. 이 함수는 서버에서만 실행되지만, 클라이언트에서 일반 함수처럼 호출할 수 있습니다.

기본 Server Action
typescript
'use server';
 
export async function createArticle(formData: FormData) {
  const title = formData.get('title') as string;
  const content = formData.get('content') as string;
 
  const article = await db.articles.create({
    data: { title, content, authorId: getCurrentUserId() },
  });
 
  revalidatePath('/articles');
  return { success: true, id: article.id };
}
Info

'use server'와 'use client'의 비대칭성을 이해하는 것이 중요합니다.

  • 'use client'는 컴포넌트 모듈의 진입점을 표시합니다 (파일 최상단에 선언).
  • 'use server'는 함수의 서버 실행을 표시합니다 (파일 최상단 또는 개별 함수에 선언).

'use server'는 Server Component를 만드는 디렉티브가 아닙니다. Server Component에는 별도의 디렉티브가 필요 없습니다 (기본값).

Server Actions의 동작 원리

클라이언트에서 Server Action을 호출하면 내부적으로 어떤 일이 발생하는지 살펴봅시다.

컴파일 타임

번들러는 'use server'로 표시된 함수를 발견하면 다음을 수행합니다.

  1. 함수의 구현 코드를 서버 번들에만 포함합니다.
  2. 클라이언트 번들에는 함수의 참조(reference)만 포함합니다.
  3. 이 참조에는 서버에서 함수를 식별할 수 있는 고유 ID가 포함됩니다.
컴파일 결과 (개념적)
typescript
// 서버 번들: 실제 구현이 포함됨
async function createArticle(formData: FormData) {
  // ... 실제 로직
}
 
// 클라이언트 번들: 참조만 포함됨
const createArticle = {
  $$typeof: Symbol.for('react.server.reference'),
  $$id: 'actions.ts#createArticle',
};

런타임

클라이언트에서 이 참조를 "호출"하면 다음이 발생합니다.

Server Action 정의 방법

방법 1: 별도 파일에 정의

가장 권장되는 패턴입니다. 파일 최상단에 'use server'를 선언하면 해당 파일의 모든 export가 Server Action이 됩니다.

actions/article.ts
typescript
'use server';
 
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
 
export async function createArticle(formData: FormData) {
  const title = formData.get('title') as string;
  const content = formData.get('content') as string;
 
  await db.articles.create({
    data: { title, content },
  });
 
  revalidatePath('/articles');
  redirect('/articles');
}
 
export async function deleteArticle(id: string) {
  await db.articles.delete({ where: { id } });
  revalidatePath('/articles');
}
 
export async function updateArticle(id: string, formData: FormData) {
  const title = formData.get('title') as string;
  const content = formData.get('content') as string;
 
  await db.articles.update({
    where: { id },
    data: { title, content },
  });
 
  revalidatePath(`/articles/${id}`);
}

방법 2: Server Component 내 인라인 정의

Server Component 안에서 개별 함수에 'use server'를 선언할 수 있습니다. 이 방식은 해당 컴포넌트의 클로저에 접근할 수 있다는 장점이 있습니다.

Server Component 내 인라인 Action
typescript
async function ArticlePage({ params }: { params: { id: string } }) {
  const article = await getArticle(params.id);
 
  async function publishArticle() {
    'use server';
    // params.id를 클로저로 캡처
    await db.articles.update({
      where: { id: params.id },
      data: { published: true, publishedAt: new Date() },
    });
    revalidatePath(`/articles/${params.id}`);
  }
 
  return (
    <article>
      <h1>{article.title}</h1>
      <div>{article.content}</div>
      {!article.published && (
        <form action={publishArticle}>
          <button type="submit">게시하기</button>
        </form>
      )}
    </article>
  );
}
Warning

인라인 Server Action에서 클로저로 캡처된 변수는 직렬화되어 클라이언트로 전송된 후 서버 호출 시 다시 서버로 전달됩니다. 민감한 데이터(비밀번호, 토큰 등)를 클로저로 캡처하지 않도록 주의해야 합니다. Next.js에서는 taintUniqueValue를 사용하여 민감한 값의 클라이언트 전송을 방지할 수 있습니다.

폼과 Server Actions

Server Actions의 가장 자연스러운 사용처는 HTML 폼입니다. React 19에서는 <form>의 action 속성에 함수를 직접 전달할 수 있습니다.

기본 폼 제출

기본 폼 + Server Action
typescript
// actions/contact.ts
'use server';
 
export async function submitContactForm(formData: FormData) {
  const name = formData.get('name') as string;
  const email = formData.get('email') as string;
  const message = formData.get('message') as string;
 
  // 유효성 검사
  if (!name || !email || !message) {
    return { error: '모든 필드를 입력해주세요.' };
  }
 
  // 이메일 전송
  await sendEmail({
    to: 'admin@example.com',
    subject: `문의: ${name}`,
    body: `${name} (${email}):\n\n${message}`,
  });
 
  return { success: true };
}
폼 컴포넌트
typescript
import { submitContactForm } from '@/actions/contact';
 
function ContactPage() {
  return (
    <form action={submitContactForm}>
      <label>
        이름
        <input name="name" required />
      </label>
      <label>
        이메일
        <input name="email" type="email" required />
      </label>
      <label>
        메시지
        <textarea name="message" required />
      </label>
      <button type="submit">전송</button>
    </form>
  );
}

Progressive Enhancement

Server Actions를 <form action>에 사용하면 Progressive Enhancement가 자동으로 적용됩니다. JavaScript가 로드되기 전에도 폼이 동작합니다.

점진적 향상이 적용된 폼
typescript
// Client Component에서 useActionState와 함께 사용
'use client';
 
import { useActionState } from 'react';
import { submitContactForm } from '@/actions/contact';
 
function ContactForm() {
  const [state, formAction, isPending] = useActionState(
    submitContactForm,
    { error: null, success: false }
  );
 
  return (
    <form action={formAction}>
      <input name="name" required disabled={isPending} />
      <input name="email" type="email" required disabled={isPending} />
      <textarea name="message" required disabled={isPending} />
 
      <button type="submit" disabled={isPending}>
        {isPending ? '전송 중...' : '전송'}
      </button>
 
      {state.error && <p className="text-red-500">{state.error}</p>}
      {state.success && <p className="text-green-500">전송되었습니다!</p>}
    </form>
  );
}

JavaScript가 로드되기 전에 사용자가 폼을 제출하면, 브라우저는 기본 폼 제출 방식(POST 요청)으로 Server Action을 실행합니다. JavaScript가 로드된 후에는 fetch로 비동기 제출하여 페이지 이동 없이 처리합니다.

폼이 아닌 곳에서의 Server Actions

Server Actions는 폼 외에도 다양한 곳에서 사용할 수 있습니다.

이벤트 핸들러에서 호출

이벤트 핸들러 + Server Action
typescript
'use client';
 
import { useTransition } from 'react';
import { toggleFavorite } from '@/actions/favorite';
 
function FavoriteButton({ articleId, isFavorited }: {
  articleId: string;
  isFavorited: boolean;
}) {
  const [isPending, startTransition] = useTransition();
 
  function handleClick() {
    startTransition(async () => {
      await toggleFavorite(articleId);
    });
  }
 
  return (
    <button
      onClick={handleClick}
      disabled={isPending}
      aria-label={isFavorited ? '즐겨찾기 해제' : '즐겨찾기 추가'}
    >
      {isFavorited ? '★' : '☆'}
      {isPending && <span className="animate-spin">...</span>}
    </button>
  );
}

useTransition으로 감싸면 Action이 완료될 때까지 UI가 블로킹되지 않고, isPending 상태로 로딩 피드백을 제공할 수 있습니다.

useEffect에서 호출

useEffect + Server Action
typescript
'use client';
 
import { useEffect } from 'react';
import { trackPageView } from '@/actions/analytics';
 
function PageTracker({ pageId }: { pageId: string }) {
  useEffect(() => {
    trackPageView(pageId);
  }, [pageId]);
 
  return null;
}

에러 핸들링

반환값을 통한 에러 처리

Server Action에서 에러를 throw하는 대신 결과 객체를 반환하는 패턴이 권장됩니다.

결과 객체 패턴
typescript
'use server';
 
type ActionResult =
  | { success: true; data: Article }
  | { success: false; error: string; field?: string };
 
export async function createArticle(
  prevState: ActionResult,
  formData: FormData
): Promise<ActionResult> {
  const title = formData.get('title') as string;
  const content = formData.get('content') as string;
 
  // 유효성 검사
  if (!title || title.length < 3) {
    return {
      success: false,
      error: '제목은 3자 이상이어야 합니다.',
      field: 'title',
    };
  }
 
  if (!content || content.length < 10) {
    return {
      success: false,
      error: '내용은 10자 이상이어야 합니다.',
      field: 'content',
    };
  }
 
  try {
    const article = await db.articles.create({
      data: { title, content },
    });
 
    revalidatePath('/articles');
    return { success: true, data: article };
  } catch (error) {
    return {
      success: false,
      error: '게시물 생성에 실패했습니다. 잠시 후 다시 시도해주세요.',
    };
  }
}
클라이언트에서 결과 처리
typescript
'use client';
 
import { useActionState } from 'react';
import { createArticle } from '@/actions/article';
 
function CreateArticleForm() {
  const [state, formAction, isPending] = useActionState(
    createArticle,
    { success: false, error: '' }
  );
 
  return (
    <form action={formAction}>
      <div>
        <input name="title" placeholder="제목" />
        {!state.success && state.field === 'title' && (
          <p className="text-red-500 text-sm">{state.error}</p>
        )}
      </div>
 
      <div>
        <textarea name="content" placeholder="내용" />
        {!state.success && state.field === 'content' && (
          <p className="text-red-500 text-sm">{state.error}</p>
        )}
      </div>
 
      <button type="submit" disabled={isPending}>
        {isPending ? '저장 중...' : '저장'}
      </button>
 
      {!state.success && !state.field && state.error && (
        <p className="text-red-500">{state.error}</p>
      )}
    </form>
  );
}

Error Boundary를 통한 에러 처리

예상치 못한 에러는 Error Boundary에서 잡을 수 있습니다.

Error Boundary 활용
typescript
// app/articles/new/error.tsx
'use client';
 
function ArticleCreateError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div>
      <h2>문제가 발생했습니다</h2>
      <p>게시물을 생성하는 중 오류가 발생했습니다.</p>
      <button onClick={reset}>다시 시도</button>
    </div>
  );
}
 
export default ArticleCreateError;

낙관적 업데이트

Server Action의 응답을 기다리지 않고 UI를 먼저 업데이트하면 사용자 경험이 크게 개선됩니다.

낙관적 업데이트 패턴
typescript
'use client';
 
import { useOptimistic, useTransition } from 'react';
import { toggleLike } from '@/actions/like';
 
interface LikeState {
  count: number;
  isLiked: boolean;
}
 
function LikeSection({ articleId, initialState }: {
  articleId: string;
  initialState: LikeState;
}) {
  const [isPending, startTransition] = useTransition();
  const [optimisticState, setOptimisticState] = useOptimistic(
    initialState,
    (current: LikeState) => ({
      count: current.isLiked ? current.count - 1 : current.count + 1,
      isLiked: !current.isLiked,
    })
  );
 
  function handleToggle() {
    startTransition(async () => {
      // 즉시 UI 업데이트
      setOptimisticState(optimisticState);
      // 서버에 요청 (실패하면 자동 롤백)
      await toggleLike(articleId);
    });
  }
 
  return (
    <button onClick={handleToggle}>
      {optimisticState.isLiked ? '★' : '☆'}
      {optimisticState.count}
    </button>
  );
}

useOptimistic은 Action이 완료(성공 또는 실패)되면 자동으로 실제 상태(initialState)로 돌아갑니다. 서버 응답이 성공이면 initialState가 업데이트된 새 값으로 바뀌므로 UI가 유지되고, 실패하면 원래 값으로 롤백됩니다.

파일 업로드

Server Actions로 파일 업로드도 자연스럽게 처리할 수 있습니다.

파일 업로드 Server Action
typescript
'use server';
 
export async function uploadImage(formData: FormData) {
  const file = formData.get('image') as File;
 
  if (!file || file.size === 0) {
    return { error: '파일을 선택해주세요.' };
  }
 
  // 파일 크기 제한 (5MB)
  if (file.size > 5 * 1024 * 1024) {
    return { error: '파일 크기는 5MB 이하여야 합니다.' };
  }
 
  // 허용된 MIME 타입 검사
  const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
  if (!allowedTypes.includes(file.type)) {
    return { error: 'JPEG, PNG, WebP 형식만 허용됩니다.' };
  }
 
  const bytes = await file.arrayBuffer();
  const buffer = Buffer.from(bytes);
 
  // S3 업로드 또는 로컬 저장
  const key = `uploads/${Date.now()}-${file.name}`;
  await uploadToStorage(key, buffer, file.type);
 
  return { success: true, url: `/api/images/${key}` };
}
파일 업로드 폼
typescript
'use client';
 
import { useActionState } from 'react';
import { uploadImage } from '@/actions/upload';
 
function ImageUploader() {
  const [state, formAction, isPending] = useActionState(
    uploadImage,
    { error: null, url: null }
  );
 
  return (
    <form action={formAction}>
      <input
        type="file"
        name="image"
        accept="image/jpeg,image/png,image/webp"
      />
      <button type="submit" disabled={isPending}>
        {isPending ? '업로드 중...' : '업로드'}
      </button>
 
      {state.error && <p className="text-red-500">{state.error}</p>}
      {state.url && (
        <img src={state.url} alt="업로드된 이미지" className="mt-4 max-w-xs" />
      )}
    </form>
  );
}

보안 고려사항

Server Actions는 공개된 HTTP 엔드포인트로 노출되므로 보안에 주의해야 합니다.

인증과 권한 검사

인증 미들웨어 패턴
typescript
'use server';
 
import { auth } from '@/lib/auth';
 
// 인증이 필요한 모든 Action에서 호출
async function requireAuth() {
  const session = await auth();
  if (!session?.user) {
    throw new Error('인증이 필요합니다.');
  }
  return session.user;
}
 
export async function createArticle(formData: FormData) {
  const user = await requireAuth();
 
  // 권한 검사
  if (!user.roles.includes('author')) {
    throw new Error('게시물 작성 권한이 없습니다.');
  }
 
  const title = formData.get('title') as string;
  const content = formData.get('content') as string;
 
  await db.articles.create({
    data: { title, content, authorId: user.id },
  });
 
  revalidatePath('/articles');
}
 
export async function deleteArticle(id: string) {
  const user = await requireAuth();
 
  // 소유자 확인
  const article = await db.articles.findUnique({ where: { id } });
  if (article?.authorId !== user.id && !user.roles.includes('admin')) {
    throw new Error('삭제 권한이 없습니다.');
  }
 
  await db.articles.delete({ where: { id } });
  revalidatePath('/articles');
}

입력 유효성 검사

클라이언트 유효성 검사는 UX를 위한 것이고, 서버 유효성 검사는 보안을 위한 것입니다. 항상 Server Action에서 입력을 검증해야 합니다.

Zod를 활용한 서버 유효성 검사
typescript
'use server';
 
import { z } from 'zod';
 
const ArticleSchema = z.object({
  title: z
    .string()
    .min(3, '제목은 3자 이상이어야 합니다.')
    .max(200, '제목은 200자 이하여야 합니다.')
    .trim(),
  content: z
    .string()
    .min(10, '내용은 10자 이상이어야 합니다.')
    .max(50000, '내용은 50,000자 이하여야 합니다.'),
  tags: z
    .string()
    .transform(s => s.split(',').map(t => t.trim()).filter(Boolean))
    .pipe(z.array(z.string()).max(10, '태그는 10개 이하여야 합니다.')),
});
 
export async function createArticle(
  prevState: FormState,
  formData: FormData
): Promise<FormState> {
  const raw = {
    title: formData.get('title'),
    content: formData.get('content'),
    tags: formData.get('tags'),
  };
 
  const result = ArticleSchema.safeParse(raw);
 
  if (!result.success) {
    const errors = result.error.flatten().fieldErrors;
    return {
      success: false,
      errors,
      values: raw as Record<string, string>,
    };
  }
 
  try {
    await db.articles.create({ data: result.data });
    revalidatePath('/articles');
    return { success: true };
  } catch {
    return {
      success: false,
      errors: { _form: ['게시물 생성에 실패했습니다.'] },
      values: raw as Record<string, string>,
    };
  }
}

Rate Limiting

Server Actions에도 Rate Limiting을 적용해야 합니다.

Rate Limiting 패턴
typescript
'use server';
 
import { headers } from 'next/headers';
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
 
const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(10, '1 m'), // 1분에 10회
});
 
export async function submitForm(formData: FormData) {
  const headersList = await headers();
  const ip = headersList.get('x-forwarded-for') ?? '127.0.0.1';
 
  const { success } = await ratelimit.limit(ip);
  if (!success) {
    return { error: '요청이 너무 많습니다. 잠시 후 다시 시도해주세요.' };
  }
 
  // ... 실제 로직
}

실전 패턴: CRUD 완성

지금까지 다룬 내용을 종합하여 완전한 CRUD 패턴을 구성합니다.

actions/todo.ts - 완전한 CRUD
typescript
'use server';
 
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
 
const TodoSchema = z.object({
  title: z.string().min(1).max(200).trim(),
});
 
type TodoState = {
  errors?: Record<string, string[]>;
  message?: string;
};
 
export async function createTodo(
  prevState: TodoState,
  formData: FormData
): Promise<TodoState> {
  const result = TodoSchema.safeParse({
    title: formData.get('title'),
  });
 
  if (!result.success) {
    return { errors: result.error.flatten().fieldErrors };
  }
 
  await db.todos.create({ data: result.data });
  revalidatePath('/todos');
  return { message: '할 일이 추가되었습니다.' };
}
 
export async function toggleTodo(id: string) {
  const todo = await db.todos.findUnique({ where: { id } });
  if (!todo) return;
 
  await db.todos.update({
    where: { id },
    data: { completed: !todo.completed },
  });
 
  revalidatePath('/todos');
}
 
export async function deleteTodo(id: string) {
  await db.todos.delete({ where: { id } });
  revalidatePath('/todos');
}
 
export async function updateTodo(id: string, formData: FormData) {
  const result = TodoSchema.safeParse({
    title: formData.get('title'),
  });
 
  if (!result.success) {
    return { errors: result.error.flatten().fieldErrors };
  }
 
  await db.todos.update({
    where: { id },
    data: result.data,
  });
 
  revalidatePath('/todos');
  return { message: '수정되었습니다.' };
}
TodoList 컴포넌트
typescript
// app/todos/page.tsx (Server Component)
import { TodoForm } from '@/components/TodoForm';
import { TodoItem } from '@/components/TodoItem';
 
async function TodosPage() {
  const todos = await db.todos.findMany({
    orderBy: { createdAt: 'desc' },
  });
 
  return (
    <div>
      <h1>할 일 목록</h1>
      <TodoForm />
      <ul>
        {todos.map(todo => (
          <TodoItem key={todo.id} todo={todo} />
        ))}
      </ul>
    </div>
  );
}
TodoItem 컴포넌트
typescript
// components/TodoItem.tsx
'use client';
 
import { useTransition, useOptimistic } from 'react';
import { toggleTodo, deleteTodo } from '@/actions/todo';
 
interface Todo {
  id: string;
  title: string;
  completed: boolean;
}
 
function TodoItem({ todo }: { todo: Todo }) {
  const [isPending, startTransition] = useTransition();
  const [optimisticTodo, setOptimisticTodo] = useOptimistic(
    todo,
    (current: Todo) => ({ ...current, completed: !current.completed })
  );
 
  function handleToggle() {
    startTransition(async () => {
      setOptimisticTodo(todo);
      await toggleTodo(todo.id);
    });
  }
 
  function handleDelete() {
    startTransition(async () => {
      await deleteTodo(todo.id);
    });
  }
 
  return (
    <li className={isPending ? 'opacity-50' : ''}>
      <label>
        <input
          type="checkbox"
          checked={optimisticTodo.completed}
          onChange={handleToggle}
        />
        <span className={optimisticTodo.completed ? 'line-through' : ''}>
          {todo.title}
        </span>
      </label>
      <button onClick={handleDelete} disabled={isPending}>
        삭제
      </button>
    </li>
  );
}
 
export { TodoItem };

핵심 요약

  • Server Actions는 'use server' 디렉티브로 표시된 비동기 함수로, 클라이언트에서 호출하면 서버에서 실행됩니다.
  • <form action>에 직접 전달하여 Progressive Enhancement를 지원하는 폼을 만들 수 있습니다.
  • 폼 외에도 이벤트 핸들러, useEffect 등에서 useTransition과 함께 호출할 수 있습니다.
  • 에러 처리는 결과 객체 반환 패턴이 권장되며, 예상치 못한 에러는 Error Boundary에서 처리합니다.
  • useOptimistic과 결합하여 즉각적인 UI 피드백을 제공할 수 있습니다.
  • Server Actions는 공개 HTTP 엔드포인트이므로 인증, 권한 검사, 입력 유효성 검사, Rate Limiting이 필수입니다.

다음 장에서는 React 19의 새로운 use() API를 살펴보고, Promise와 Context를 소비하는 새로운 패턴을 다룹니다.

이 글이 도움이 되셨나요?

관련 주제 더 보기

#react#nextjs#performance#frontend#typescript

관련 글

웹 개발

4장: use() API와 새로운 데이터 패턴

React 19의 use() API로 Promise와 Context를 조건부로 소비하는 방법, 서버-클라이언트 스트리밍 패턴, 기존 훅과의 차이를 다룹니다.

2026년 1월 29일·15분
웹 개발

2장: React Server Components 아키텍처 심층 분석

React Server Components의 동작 원리, 직렬화 프로토콜, 번들 전략, 합성 규칙을 심층적으로 분석합니다. 서버와 클라이언트의 경계를 이해합니다.

2026년 1월 25일·23분
웹 개발

5장: 새로운 훅 - useActionState, useFormStatus, useOptimistic

React 19의 새로운 훅 3종을 심층 분석합니다. 폼 상태 관리, 제출 상태 추적, 낙관적 UI 업데이트의 실전 패턴을 다룹니다.

2026년 1월 31일·16분
이전 글2장: React Server Components 아키텍처 심층 분석
다음 글4장: use() API와 새로운 데이터 패턴

댓글

목차

약 22분 남음
  • Server Actions란 무엇인가
  • Server Actions의 동작 원리
    • 컴파일 타임
    • 런타임
  • Server Action 정의 방법
    • 방법 1: 별도 파일에 정의
    • 방법 2: Server Component 내 인라인 정의
  • 폼과 Server Actions
    • 기본 폼 제출
    • Progressive Enhancement
  • 폼이 아닌 곳에서의 Server Actions
    • 이벤트 핸들러에서 호출
    • useEffect에서 호출
  • 에러 핸들링
    • 반환값을 통한 에러 처리
    • Error Boundary를 통한 에러 처리
  • 낙관적 업데이트
  • 파일 업로드
  • 보안 고려사항
    • 인증과 권한 검사
    • 입력 유효성 검사
    • Rate Limiting
  • 실전 패턴: CRUD 완성
  • 핵심 요약