C
Next.js/렌더링/Lesson 06

Server Actions — API 라우트 없이 mutation 직접 처리

40분·theory
이 챕터
3/5
TypeScript

Server Actions — API 라우트 없이 mutation 직접 처리

💡 왜 배워야 할까요? — '폼 제출 = API 라우트 + fetch + useState' 시대의 종말

🎯 Pages Router 시절 폼 한 개 만들기: `pages/api/posts.ts` 만들고, 클라이언트에서 `fetch('/api/posts', {method:'POST', body})` 하고, useState 로 로딩·에러 관리, 성공 시 router.refresh.
💼 Server Actions: 함수 1개 만들고 `
` 에 직접 연결. API 라우트·fetch·loading state 전부 사라짐.
함수 내부에서 DB 직접 접근 OK (Server 코드라). 끝나면 `revalidatePath` 한 줄로 캐시 무효화.
🔗 JS 가 안 켜진 환경에서도 동작 — 그냥 표준 HTML form 으로 fallback 됨. Progressive Enhancement.
📈 React 19 의 `useActionState`·`useFormStatus` 가 폼 UX(로딩 표시·에러 표시) 를 hook 하나로 끝냄.
🏢 실무에서는
글 작성·댓글·좋아요·삭제 — 모든 mutation 이 Server Action 으로 단순해집니다. 이제 'POST API 라우트 만들기 → 클라이언트에서 fetch 호출 → loading state 관리 → optimistic update → revalidate' 라는 6단계가 '함수 하나 + form action' 으로 줄어듭니다.

'use server' · formAction · revalidatePath · useActionState

1. 'use server' 의 두 가지 위치

ts
// (a) 파일 최상단 — 그 파일의 모든 export 가 Server Action
// 📁 app/posts/actions.ts
'use server';

export async function createPost(formData: FormData) { ... }
export async function deletePost(id: number) { ... }
ts
// (b) 함수 본문 안 — 그 함수만 Server Action
// Server Component 안에서 인라인 선언
export default async function Page() {
  async function createPost(formData: FormData) {
    'use server';
    await db.post.create({ data: { title: formData.get('title') as string } });
  }
  return <form action={createPost}><input name="title" /><button>저장</button></form>;
}

인라인은 짧을 때만. 보통 별도 파일로 분리.

2. 의 시그니처

ts
async function createPost(formData: FormData): Promise<void> {
  'use server';
  const title = formData.get('title') as string;
  await db.post.create({ data: { title } });
  revalidatePath('/posts');
}

<form action={createPost}>
  <input name="title" />
  <button type="submit">저장</button>
</form>
  • 첫 인자는 항상 FormData.
  • 반환은 Promise 가 기본. (useActionState 와 함께 쓸 땐 state 반환)
  • 폼이 제출되면 브라우저는 서버로 POST 요청. Next.js 가 자동으로 받아서 함수 실행.

3. revalidatePath / revalidateTag — 캐시 무효화

ts
'use server';
import { revalidatePath, revalidateTag } from 'next/cache';

export async function createPost(formData: FormData) {
  await db.post.create({ data: { title: formData.get('title') as string } });
  revalidatePath('/posts');           // /posts 페이지의 fetch 캐시 무효화
  // revalidateTag('posts');           // tags: ['posts'] 가 붙은 fetch 무효화
}

새 글 작성 후 목록을 다시 보여줘야 하니까 캐시를 비워줘야 함. 이거 빼먹으면 '새 글 썼는데 안 보임' 버그.

4. useActionState — 폼 상태 hook (React 19)

tsx
'use client';
import { useActionState } from 'react';
import { createPost } from './actions';

export function PostForm() {
  const [state, formAction, isPending] = useActionState(createPost, {
    error: null as string | null,
    success: false,
  });

  return (
    <form action={formAction}>
      <input name="title" required />
      <button disabled={isPending}>
        {isPending ? '저장 중...' : '저장'}
      </button>
      {state.error && <p className="text-red-500">{state.error}</p>}
      {state.success && <p className="text-green-500">저장됨!</p>}
    </form>
  );
}

그리고 action 시그니처도 state 받는 형태로:

ts
'use server';
export async function createPost(
  prevState: { error: string | null; success: boolean },
  formData: FormData,
) {
  const title = formData.get('title') as string;
  if (!title) return { error: '제목이 비었습니다', success: false };
  try {
    await db.post.create({ data: { title } });
    revalidatePath('/posts');
    return { error: null, success: true };
  } catch (e) {
    return { error: '저장 실패', success: false };
  }
}

⚠️ React 18 의 useFormState 는 React 19 에서 useActionState 로 리네임. 새 코드는 useActionState 사용.

5. useFormStatus — 자식 컴포넌트가 부모 form 상태 읽기

tsx
'use client';
import { useFormStatus } from 'react-dom';

export function SubmitButton() {
  const { pending } = useFormStatus();
  return <button disabled={pending}>{pending ? '저장 중...' : '저장'}</button>;
}

부모 <form> 의 제출 상태를 직접 hook 으로 받음. props drilling 불필요.

💻 🅰️ Pages Router — API 라우트 + 클라이언트 fetch + useState 보일러플레이트
// ❌ Pages Router — 폼 1개에 파일 3개 + 보일러플레이트

// 📁 pages/api/posts.ts — API 라우트
import type { NextApiRequest, NextApiResponse } from 'next';

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== 'POST') return res.status(405).end();
  const { title } = req.body;
  if (!title) return res.status(400).json({ error: '제목 필요' });
  try {
    const post = await db.post.create({ data: { title } });
    return res.status(200).json(post);
  } catch (e) {
    return res.status(500).json({ error: '저장 실패' });
  }
}

// 📁 components/PostForm.tsx — 클라이언트 폼
import { useState, type FormEvent } from 'react';
import { useRouter } from 'next/router';

export function PostForm() {
  const router = useRouter();
  const [title, setTitle] = useState('');
  const [pending, setPending] = useState(false);
  const [error, setError] = useState<string | null>(null);

  async function onSubmit(e: FormEvent) {
    e.preventDefault();
    setPending(true);
    setError(null);
    try {
      const res = await fetch('/api/posts', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ title }),
      });
      if (!res.ok) {
        const { error } = await res.json();
        throw new Error(error);
      }
      setTitle('');
      router.refresh();  // 목록 새로고침
    } catch (e) {
      setError(e instanceof Error ? e.message : '알 수 없는 에러');
    } finally {
      setPending(false);
    }
  }

  return (
    <form onSubmit={onSubmit}>
      <input value={title} onChange={(e) => setTitle(e.target.value)} required />
      <button disabled={pending}>{pending ? '저장 중...' : '저장'}</button>
      {error && <p>{error}</p>}
    </form>
  );
}

// 단점:
// - 파일 2개 + 60줄
// - 로딩·에러·캐시 무효화 손수
// - JS 안 켜진 환경에서 동작 X (onSubmit preventDefault)
// - 타입 안전성: API request/response 양쪽에 인터페이스 중복 정의
💻 🅱️ App Router — Server Action 한 함수로 통합
// ✅ App Router — Server Action + useActionState

// 📁 app/posts/actions.ts — Server Action 들
'use server';

import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';

export type FormState = {
  error: string | null;
  success: boolean;
};

export async function createPost(
  prevState: FormState,
  formData: FormData,
): Promise<FormState> {
  const title = (formData.get('title') as string)?.trim();
  if (!title) return { error: '제목이 비었습니다', success: false };

  try {
    await db.post.create({ data: { title } });
  } catch (e) {
    return { error: '저장 실패', success: false };
  }

  revalidatePath('/posts');           // 목록 캐시 무효화
  return { error: null, success: true };
}

export async function deletePost(id: number): Promise<void> {
  'use server';
  await db.post.delete({ where: { id } });
  revalidatePath('/posts');
}

// 📁 app/posts/PostForm.tsx — Client (form UX)
'use client';
import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';
import { createPost, type FormState } from './actions';

const initialState: FormState = { error: null, success: false };

export function PostForm() {
  const [state, formAction] = useActionState(createPost, initialState);

  return (
    <form action={formAction}>
      <input name="title" required placeholder="제목" />
      <SubmitButton />
      {state.error && <p className="text-red-500">{state.error}</p>}
      {state.success && <p className="text-green-500">저장됨!</p>}
    </form>
  );
}

// 자식 컴포넌트에서 부모 form 의 pending 상태 직접 읽기
function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button disabled={pending}>
      {pending ? '저장 중...' : '저장'}
    </button>
  );
}

// 📁 app/posts/page.tsx — Server Component
import { PostForm } from './PostForm';
import { deletePost } from './actions';

export default async function Posts() {
  const posts = await db.post.findMany({ orderBy: { id: 'desc' } });
  return (
    <div>
      <PostForm />
      <ul>
        {posts.map((p) => (
          <li key={p.id}>
            {p.title}
            {/* Server Action 을 form action 으로 직접 — JS 없어도 작동 */}
            <form action={deletePost.bind(null, p.id)}>
              <button>삭제</button>
            </form>
          </li>
        ))}
      </ul>
    </div>
  );
}

// 장점:
// - 파일 1개 + 함수 1개
// - 캐시 무효화 1줄 (revalidatePath)
// - JS 안 켜진 환경에서도 폼 제출 동작 (Progressive Enhancement)
// - 타입: action 의 시그니처가 곧 계약 — 클라이언트·서버 양쪽 자동 추론

💡 💡 Server Actions 실전 5

1. mutation 후 무조건 revalidate

ts
revalidatePath('/posts');     // 경로 단위
revalidateTag('posts');       // 태그 단위 (fetch 의 tags 옵션과 짝)

빼먹으면 "새 글 썼는데 안 보임" 1등 버그.

2. 인자 바인딩으로 id 전달

tsx
<form action={deletePost.bind(null, postId)}>
  <button>삭제</button>
</form>

첫 인자에 id, 두 번째에 formData. hidden input 안 써도 됨.

3. useActionState 가 React 19 의 표준 (구 useFormState 대체)

tsx
import { useActionState } from 'react';
const [state, formAction, isPending] = useActionState(action, initialState);

useFormState (react-dom) 는 deprecated.

4. Server Action 안에서 redirect() 호출 OK

ts
import { redirect } from 'next/navigation';
export async function createPost(formData: FormData) {
  'use server';
  const post = await db.post.create({ ... });
  redirect(`/posts/${post.id}`);
}

throw 처럼 흐름 종료 — 그 아래 코드 실행 안 됨.

5. 보안 — Server Action 도 입력 검증 필수

클라이언트에서 폼 검증해도 누군가 직접 POST 요청 보낼 수 있음. action 안에서 zod 같은 검증 한 번 더:

ts
const schema = z.object({ title: z.string().min(1).max(200) });
const parsed = schema.safeParse(Object.fromEntries(formData));
if (!parsed.success) return { error: '유효하지 않은 입력', success: false };

⚡ 직접 실행해보기 — Server Action 흐름 시뮬레이션

Server Action 의 폼 제출 → mutation → 캐시 무효화 흐름을 시뮬레이션합니다.
✏️ JS 코드
📟 콘솔 출력
▶ 실행 버튼을 눌러보세요
⚠️ 브라우저 샌드박스에서 실행 — console.log()만 지원, alert/fetch 불가

확인 퀴즈

Server Action 안에서 mutation 후 `revalidatePath('/posts')` 를 호출하지 않으면?
💡 Next.js 의 fetch 는 기본 force-cache 라 같은 URL 의 데이터를 캐시합니다. mutation 후 `revalidatePath` 또는 `revalidateTag` 를 호출하지 않으면 캐시가 그대로 남아 사용자는 옛 데이터를 봅니다. 이게 "DB 에는 저장됐는데 화면에 안 보임" 버그의 80% 원인. mutation 끝에 무조건 revalidate 호출이 패턴.
Server Actions — API 라우트 없이 mutation - Next.js