Next.js 교재 · 9편 · Server Actions

Server Actions — 서버 함수를 폼에서 바로

API 라우트 없이도 mutation. fetch·useState·loading 분기가 사라진다.

클라이언트 폼이 서버 함수를 바로 호출하는 컨셉 일러스트

8편에선 데이터 읽기를 봤다. 이번 편은 데이터 쓰기. 옛 React 에선 폼 제출하려면 — onSubmit 핸들러에 e.preventDefault() · fetch('/api/posts', {method:'POST'}) · 응답 받아 setState · 에러·로딩 분기. 10줄 넘는다.

App Router 의 답이 Server Actions. 'use server' 한 줄 적은 서버 함수를 클라이언트 폼·버튼이 직접 호출. API 엔드포인트 만들지 않고도 DB 에 쓰는 게 가능하다. 2024 년에 stable 됐고 2026 기준 Next 풀스택의 표준.

1. 가장 단순한 Server Action

두 가지 형태. 인라인 또는 별도 파일.

인라인 — 한 파일 안에서

// app/contact/page.tsx (서버 컴포넌트) import { redirect } from 'next/navigation'; import { db } from '@/lib/db'; export default function ContactPage() { async function submit(formData: FormData) { 'use server'; // ← 이 한 줄이 핵심 const email = formData.get('email'); const message = formData.get('message'); await db.contacts.create({ email, message }); redirect('/thank-you'); } return ( <form action={submit}> <input name="email" type="email" required /> <textarea name="message" required /> <button>보내기</button> </form> ); }

비결은 <form action={submit}>action 속성. HTML 표준이지만 Next 가 함수 참조까지 받아준다. 자바스크립트가 꺼진 브라우저에서도 동작한다 (progressive enhancement).

별도 파일 — 'use server' 를 최상단에

// lib/actions.ts 'use server'; import { db } from '@/lib/db'; export async function createPost(formData: FormData) { const title = formData.get('title'); const body = formData.get('body'); await db.posts.create({ title, body }); } // app/blog/new/page.tsx import { createPost } from '@/lib/actions'; export default function NewPostPage() { return ( <form action={createPost}> … </form> ); }

여러 페이지에서 같은 action 을 재사용할 때 별도 파일이 깔끔. 파일 최상단의 'use server' 가 파일 전체를 server-only 로 표시한다.

2. Server Action 의 정체

마법처럼 보이지만 실은 Next 가 자동으로 RPC 엔드포인트를 만들어준다. 빌드 타임에 함수 ID 가 발급되고, 클라이언트의 폼 제출은 그 ID 로 POST 호출이 자동으로 일어난다. 개발자는 그걸 의식 안 해도 됨.

구분옛 API RouteServer Action
코드 위치app/api/posts/route.ts아무 서버 파일
호출 방법클라이언트가 fetch<form action={fn}>
타입 안전성JSON 직렬화 후 손실함수 시그니처 그대로
JS 꺼졌을 때동작 안 함표준 폼으로 동작
로딩 상태useState 직접useFormStatus 또는 자동

3. useActionState — 결과·에러 처리

실전에선 성공·실패 메시지를 보여줘야 한다. useActionState 훅이 그 역할.

// app/contact/contact-form.tsx 'use client'; import { useActionState } from 'react'; import { submitContact } from './actions'; const initial = { ok: false, error: '' }; export default function ContactForm() { const [state, formAction, pending] = useActionState(submitContact, initial); return ( <form action={formAction}> <input name="email" required /> <textarea name="message" required /> <button disabled={pending}>{pending ? '전송 중…' : '보내기'}</button> {state.error && <p className="error">{state.error}</p>} {state.ok && <p className="ok">감사합니다!</p>} </form> ); } // app/contact/actions.ts 'use server'; export async function submitContact(prev: any, formData: FormData) { const email = formData.get('email'); if (!email) return { ok: false, error: '이메일을 입력하세요.' }; try { await db.contacts.create({ email }); return { ok: true, error: '' }; } catch (e) { return { ok: false, error: '저장 실패. 다시 시도해주세요.' }; } }

액션 함수의 시그니처가 (prev, formData) 로 바뀌는 게 포인트. 이전 상태를 받아 새 상태를 반환. pending 으로 진행 중 버튼 disable 같은 UX 까지 한 번에.

4. revalidatePath / redirect — mutation 뒤 처리

쓰기가 끝나면 보통 두 가지를 해야 한다 — 관련 캐시 무효화다른 페이지로 이동.

'use server'; import { revalidatePath } from 'next/cache'; import { redirect } from 'next/navigation'; export async function publishPost(formData: FormData) { const post = await db.posts.create({ title: formData.get('title'), body: formData.get('body'), }); revalidatePath('/blog'); // /blog 캐시 폐기 redirect(`/blog/${post.slug}`); // 새 글로 이동 }

revalidatePath 는 8편에서 다뤘던 fetch 캐시·페이지 캐시를 동시에 무효화. redirect 는 액션 끝난 뒤 클라이언트를 다른 URL 로 보낸다. 두 줄로 "썼다 → 갱신 → 이동" 흐름 끝.

5. 보안과 함정

Server Action 은 공개 엔드포인트 — 자동으로 RPC 가 만들어진다는 건, 누구나 그 URL 을 알면 호출할 수 있다는 뜻. 액션 안에서 직접 인증 검사를 해야 안전. 클라이언트 폼 안 거치고 cURL 로 호출하는 시나리오 항상 가정.
'use server'; import { auth } from '@/lib/auth'; export async function deletePost(id: string) { const session = await auth(); if (!session?.user.isAdmin) { throw new Error('권한 없음'); } await db.posts.delete({ id }); }

다른 흔한 함정:

  • FormData 의 값은 string 또는 File. 숫자는 직접 Number() 변환. Zod 같은 검증 라이브러리 권장.
  • 액션은 클라이언트로 절대 import 금지 — 자동 RPC 변환이 안 됨. 항상 props 또는 별도 import.
  • 예외는 클라이언트로 노출됨throw new Error('DB password wrong') 같은 디버그 메시지 던지면 그대로 클라이언트로 전달됨. 사용자용 메시지로 다듬을 것.

요약 — 9편 좌표

여기까지 정리. Server Actions'use server' 적힌 함수를 클라이언트 폼·버튼이 직접 호출. 인라인 또는 별도 파일. useActionState 로 결과·에러·pending 처리, revalidatePath·redirect 로 mutation 후 처리. 액션은 공개 엔드포인트라 인증·검증은 함수 안에서 직접. 다음 편에서 폼과 뮤테이션 패턴을 실전 예제로 묶는다.

다음 편 예고 — 폼과 뮤테이션 실전

useActionState·revalidatePath·낙관적 업데이트. 10편.

© 2026 주나이테크(주) @JUNAITECH