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 Route | Server 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편.