Server Actions와 폼
Server Actions로 API 라우트 없이 mutation을 처리하고, React 19의 `useActionState` · `useFormStatus` · `useOptimistic`으로 UX를 다듬는 패턴. 4부 마무리 미니 프로젝트 (방명록)까지.
26장에서 Suspense와 use()로 점진적 로딩을 만들었습니다. 지금까지 본 모든 코드는 데이터를 읽기만 했습니다. 본 챕터에서는 사용자가 데이터를 변경하는 작업 — mutation — 을 Next.js의 새 무기인 Server Actions로 어떻게 다루는지 정리하겠습니다.
본 챕터는 9장 (제어 폼)과 19장 (이벤트와 폼 타이핑)에서 만들었던 클라이언트 사이드 폼 패턴이 RSC 시대에 어떻게 단순해지는지 정면으로 보여 줍니다. 그리고 본 챕터에서 다루는 useActionState / useFormStatus / useOptimistic은 28장 (리액트 19 신기능 정리)에서 다시 한번 카탈로그로 만나게 되고, 34장 (풀스택 Todo 캡스톤)의 mutation 토대가 됩니다.
전통적인 mutation의 복잡함 #
지금까지의 프론트엔드 mutation 패턴을 떠올려 보세요. 9장에서 만든 제어 폼과 19장에서 입힌 타입을 합치면 대략 이런 모양입니다.
'use client';
import { useState, type FormEvent } from 'react';
export default function CommentForm() {
const [text, setText] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
setSubmitting(true);
setError(null);
try {
const res = await fetch('/api/comments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
});
if (!res.ok) throw new Error('제출 실패');
setText('');
} catch (err) {
setError(err instanceof Error ? err.message : '알 수 없는 에러');
} finally {
setSubmitting(false);
}
}
return (
<form onSubmit={handleSubmit}>
<input value={text} onChange={e => setText(e.target.value)} />
<button disabled={submitting}>{submitting ? '제출 중...' : '제출'}</button>
{error && <p>{error}</p>}
</form>
);
}매번 반복되는 보일러플레이트.
- API 엔드포인트 하나 만들어야 함 (
/api/comments) - JSON 직렬화 / 역직렬화
- 클라이언트에서 fetch 핸들링
- 로딩 state, 에러 state
- 성공 후 데이터 다시 가져오기 (목록 갱신)
API 엔드포인트와 클라이언트 사이의 이 왕복 모두를 하나의 함수 호출처럼 표현할 수 있다면 어떨까요? Server Actions가 그걸 가능하게 합니다.
Server Action 기본 #
Server Action은 'use server' 디렉티브가 붙은 비동기 함수입니다. 클라이언트에서 호출하면 자동으로 서버에서 실행됩니다.
'use server';
export async function createComment(text: string) {
// 이 코드는 항상 서버에서 실행됨
await db.query('INSERT INTO comments (text) VALUES ($1)', [text]);
}'use client';
import { createComment } from './actions';
export default function CommentForm() {
async function handleSubmit(formData: FormData) {
const text = formData.get('text');
if (typeof text === 'string') {
await createComment(text);
}
}
return (
<form action={handleSubmit}>
<input name="text" />
<button>제출</button>
</form>
);
}핵심 변화.
- API 라우트 안 만듦 —
createComment를 그냥 함수처럼 import 해 호출 - JSON 직렬화 안 함 — Next.js가 알아서 처리
<form action={fn}>— 폼 제출을 직접 함수에 연결 (브라우저 네이티브 form 사용)- 디렉티브로 보안 경계 명확.
'use server'가 붙은 함수만 클라이언트에서 호출 가능
겉보기엔 함수를 하나 호출하는 것처럼 보이지만, 내부적으로 Next.js가 RPC (Remote Procedure Call)을 자동으로 만들어 줍니다. 클라이언트는 함수 ID와 인자를 서버에 보내고, 서버가 실제 함수를 실행한 뒤 결과를 돌려주는 것입니다. 24장에서 “직렬화 제약의 우아한 예외"라고 말한 바로 그 메커니즘이 본 챕터의 주인공입니다.
Progressive Enhancement #
<form action={fn}> 형태는 자바스크립트가 비활성화되어 있어도 동작 합니다. 브라우저의 기본 폼 제출이 일어나고, 서버가 그 요청을 받아 Server Action을 실행합니다. 자바스크립트가 로드되면 그 위에 부드러운 클라이언트 사이드 전환과 pending UI가 얹힙니다.
이게 progressive enhancement의 정확한 의미입니다. 기본 동작은 HTML 표준만으로 보장되고, 향상은 자바스크립트가 있을 때 추가로 일어납니다. 9장에서 만든 onSubmit 기반 폼은 자바스크립트가 죽으면 같이 죽습니다. Server Actions의 <form action={fn}>은 그렇지 않습니다.
검증은 dev tools에서 Disable JavaScript를 켜고 폼을 제출해 보면 됩니다. 같은 URL로 POST가 가고, 서버가 처리한 뒤 페이지가 새로 그려집니다.
디렉티브 위치 — 파일 단위 vs 함수 단위 #
'use server'는 두 가지 방식으로 쓸 수 있습니다.
1. 파일 맨 위 (그 파일의 모든 export가 Server Action) #
'use server';
export async function createPost(formData: FormData) { /* ... */ }
export async function deletePost(id: string) { /* ... */ }
export async function updatePost(id: string, data: FormData) { /* ... */ }2. 함수 안 (Server Component 안에 인라인 정의) #
import PostForm from './PostForm';
export default function PostsPage() {
async function createPost(formData: FormData) {
'use server';
const title = formData.get('title');
if (typeof title === 'string') {
await db.insertPost(title);
}
}
return <PostForm onCreate={createPost} />;
}함수 인라인 방식은 Server Component의 클로저 (상위 변수)에 접근할 수 있어 편리합니다. 다만 매 렌더링마다 새 함수가 생성되니, 리스트의 각 항목에서 무차별로 쓰면 효율이 떨어질 수 있습니다.
규모가 커지면 보통 별도 파일 (actions.ts)에 모아 두는 쪽이 유지보수에 좋습니다.
페이지 갱신 — revalidatePath / revalidateTag #
mutation 후에는 화면이 새 데이터를 반영해야 합니다. 단순 새로고침이 아니라, 변경된 페이지의 캐시를 무효화하고 다시 가져오게 하는 것입니다. 25장의 next.tags / revalidate 옵션이 본 챕터의 무효화 함수들과 짝을 이룹니다.
'use server';
import { revalidatePath } from 'next/cache';
export async function createPost(formData: FormData) {
const title = formData.get('title');
if (typeof title !== 'string') return;
await db.insertPost(title);
revalidatePath('/posts'); // /posts 페이지의 캐시를 무효화
}revalidatePath('/posts')를 호출하면 다음 /posts 방문 시 새로 렌더링됩니다. 이미 그 페이지에 있는 사용자라면 화면이 자동으로 갱신됩니다 (Server Action을 실행한 뒤 Next.js가 라우트의 캐시를 갱신합니다).
revalidateTag는 25장에서 본 next.tags 옵션과 짝을 이룹니다.
// 페칭 쪽 (25장)
const posts = await fetch(url, { next: { tags: ['posts'] } });
// Action 쪽 (본 챕터)
revalidateTag('posts'); // 'posts' 태그가 붙은 모든 fetch를 무효화
여러 페이지에서 같은 데이터를 쓸 때 한 번에 무효화할 수 있어 편리합니다.
useActionState — 상태가 있는 Action #
Action의 결과 (성공 / 실패 메시지, 검증 에러 등)를 폼 화면에 표시해야 할 때가 많습니다. 리액트 19의 새 훅 useActionState가 이걸 도와줍니다.
'use client';
import { useActionState } from 'react';
import { createComment } from './actions';
type State = { message: string };
export default function CommentForm() {
const [state, formAction] = useActionState<State, FormData>(createComment, { message: '' });
return (
<form action={formAction}>
<input name="text" />
<button>제출</button>
{state.message && <p>{state.message}</p>}
</form>
);
}'use server';
type State = { message: string };
export async function createComment(prevState: State, formData: FormData): Promise<State> {
const text = formData.get('text');
if (typeof text !== 'string' || !text.trim()) {
return { message: '내용을 입력해 주세요' };
}
await db.insertComment(text);
return { message: '제출 완료!' };
}useActionState(action, initialState)는.
- 첫 번째 반환값 (
state): Action의 마지막 반환값 (또는 초기 state) - 두 번째 반환값 (
formAction):<form action={...}>에 넘길 래핑된 함수 - (세 번째 반환값
isPending도 있어서 로딩 표시에 활용 가능)
Action 함수의 첫 인자는 이전 state, 두 번째가 FormData입니다 (위 actions.ts의 시그니처가 그래서 (prevState, formData)).
이 패턴 덕에 검증 에러를 화면에 표시하거나, 성공 메시지를 보여 주는 게 자연스럽게 됩니다. 19장에서 입힌 폼 타입 위에 본 챕터의 State 제네릭이 한 겹 더 얹히는 모양입니다.
useFormStatus — 제출 중 표시 #
폼이 제출 중인지 (pending)는 useFormStatus 훅으로 알 수 있습니다.
'use client';
import { useFormStatus } from 'react-dom';
import type { ReactNode } from 'react';
export default function SubmitButton({ children }: { children: ReactNode }) {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? '제출 중...' : children}
</button>
);
}<form action={formAction}>
<input name="text" />
<SubmitButton>제출</SubmitButton>
</form>useFormStatus는 부모 폼의 상태를 알려 줍니다. 그래서 SubmitButton을 폼 안 어디에 두든, 그 폼이 제출 중이면 pending이 true가 됩니다.
비슷한 효과를 useActionState의 isPending (세 번째 반환값)으로도 낼 수 있는데, useFormStatus는 별도 컴포넌트에서 폼 상태를 구독할 수 있어서 재사용 가능한 SubmitButton 같은 패턴에 유용합니다.
Optimistic UI — useOptimistic #
mutation의 응답을 기다리는 동안 화면을 미리 갱신해 즉시 반영된 것처럼 보이게 하는 패턴입니다. useOptimistic 훅이 도와줍니다.
'use client';
import { useOptimistic } from 'react';
import { deletePost } from './actions';
type Post = { id: string; title: string };
export default function PostList({ posts }: { posts: Post[] }) {
const [optimisticPosts, deleteOptimistic] = useOptimistic<Post[], string>(
posts,
(state, postId) => state.filter(p => p.id !== postId),
);
async function handleDelete(id: string) {
deleteOptimistic(id); // 즉시 UI에서 제거
await deletePost(id); // 실제 서버 호출
}
return (
<ul>
{optimisticPosts.map(post => (
<li key={post.id}>
{post.title}
<button onClick={() => handleDelete(post.id)}>삭제</button>
</li>
))}
</ul>
);
}useOptimistic(state, reducer)는 낙관적인 임시 state와 그것을 변경하는 함수를 반환합니다. 클릭 즉시 UI에서 제거하고 서버 호출을 시작하고, 서버 응답으로 진짜 state가 갱신되면 자연스럽게 동기화됩니다. 만약 서버 호출이 실패하면 자동으로 원래 state로 롤백됩니다.
체감 속도가 극적으로 좋아지는 강력한 패턴인데, 데이터가 일관성 있게 표시되는지 검증이 필요해 보통 학습 후반부에 다룹니다. 본 챕터에서는 개념만 짚고 넘어가고, 34장의 캡스톤 Todo 앱에서 실제 사용 예를 다시 살펴봅니다.
직접 해보기 — 방명록 미니 프로젝트 #
지금까지 배운 걸 모두 합친 작은 앱을 만들어 봅시다. 메모리에 저장되는 단순 방명록입니다 (실제 DB 연동은 별도 주제라 생략).
src/app/data.ts (메모리 저장소):
export type Message = {
id: string;
name: string;
text: string;
createdAt: string;
};
const messages: Message[] = [
{ id: '1', name: '관리자', text: '환영합니다 :)', createdAt: new Date().toISOString() },
];
export async function getMessages(): Promise<Message[]> {
await new Promise(r => setTimeout(r, 200)); // 가짜 지연
return [...messages].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}
export async function addMessage(name: string, text: string) {
messages.push({
id: crypto.randomUUID(),
name,
text,
createdAt: new Date().toISOString(),
});
}
export async function deleteMessage(id: string) {
const idx = messages.findIndex(m => m.id === id);
if (idx >= 0) messages.splice(idx, 1);
}src/app/guestbook/actions.ts:
'use server';
import { revalidatePath } from 'next/cache';
import { addMessage, deleteMessage } from '../data';
export type PostState = { error?: string; success?: boolean };
export async function postMessage(prevState: PostState, formData: FormData): Promise<PostState> {
const name = (formData.get('name') ?? '').toString().trim();
const text = (formData.get('text') ?? '').toString().trim();
if (!name) return { error: '이름을 입력해 주세요' };
if (!text) return { error: '메시지를 입력해 주세요' };
if (text.length > 200) return { error: '메시지는 200자 이내로' };
await addMessage(name, text);
revalidatePath('/guestbook');
return { success: true };
}
export async function removeMessage(id: string) {
await deleteMessage(id);
revalidatePath('/guestbook');
}src/app/guestbook/page.tsx:
import { Suspense } from 'react';
import { getMessages } from '../data';
import MessageForm from './MessageForm';
import { removeMessage } from './actions';
export default function GuestbookPage() {
return (
<div style={{ padding: '24px', maxWidth: '600px', margin: '0 auto' }}>
<h1>방명록</h1>
<MessageForm />
<Suspense fallback={<p>메시지 불러오는 중...</p>}>
<MessageList />
</Suspense>
</div>
);
}
async function MessageList() {
const messages = await getMessages();
if (messages.length === 0) {
return <p>아직 메시지가 없습니다. 첫 메시지를 남겨 보세요!</p>;
}
return (
<ul style={{ listStyle: 'none', padding: 0, marginTop: '16px' }}>
{messages.map(msg => (
<li key={msg.id} style={{ padding: '12px', borderBottom: '1px solid #eee' }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<strong>{msg.name}</strong>
<small style={{ color: '#888' }}>
{new Date(msg.createdAt).toLocaleString('ko-KR')}
</small>
</div>
<p style={{ margin: '4px 0' }}>{msg.text}</p>
<form action={async () => {
'use server';
await removeMessage(msg.id);
}}>
<button style={{ fontSize: '12px', color: '#888' }}>삭제</button>
</form>
</li>
))}
</ul>
);
}src/app/guestbook/MessageForm.tsx:
'use client';
import { useActionState, useEffect, useRef } from 'react';
import { useFormStatus } from 'react-dom';
import { postMessage, type PostState } from './actions';
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending} style={{ padding: '6px 16px' }}>
{pending ? '등록 중...' : '등록'}
</button>
);
}
const initialState: PostState = {};
export default function MessageForm() {
const [state, formAction] = useActionState(postMessage, initialState);
const formRef = useRef<HTMLFormElement>(null);
useEffect(() => {
if (state.success) {
formRef.current?.reset();
}
}, [state]);
return (
<form
ref={formRef}
action={formAction}
style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginBottom: '16px' }}
>
<input name="name" placeholder="이름" required style={{ padding: '6px' }} />
<textarea name="text" placeholder="메시지" rows={3} required style={{ padding: '6px' }} />
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<SubmitButton />
{state.error && <span style={{ color: 'tomato', fontSize: '14px' }}>{state.error}</span>}
{state.success && <span style={{ color: 'green', fontSize: '14px' }}>등록 완료!</span>}
</div>
</form>
);
}이게 전부입니다. 이 작은 앱에서 일어나는 일을 정리하면.
GuestbookPage(Server Component): 페이지 껍데기MessageList(Server Component): 메시지 목록 렌더, Suspense 안에 있어 로딩 중엔 fallbackMessageForm(Client Component): 폼, useActionState로 서버 상태 받기, useFormStatus로 제출 중 표시postMessage(Server Action): 서버에서 검증 + 저장 + revalidatePathremoveMessage(Server Action): 인라인으로 작성, 폼의 action에 직접 연결
API 엔드포인트는 한 줄도 안 만들었습니다. 검증도 서버에서 하니 클라이언트에서 우회 불가, 데이터는 서버 메모리에 안전하게 보관, 화면은 mutation 후 자동 갱신.
/guestbook으로 이동해 동작을 확인해 보세요. 메시지를 등록하고, 빈 입력으로 제출해 보고 (에러), 200자 넘게 입력해 보고 (에러), 삭제 버튼도 눌러 보세요. 페이지를 새로고침해도 (서버가 안 죽었다면) 메시지가 그대로 있을 겁니다 — 메모리 저장소에서요.
연습문제 #
- progressive enhancement 검증. dev tools에서 자바스크립트를 비활성화한 뒤 방명록 폼으로 메시지를 등록해 보세요. 폼 제출이 일반 POST 요청으로 일어나고, 페이지가 새로 그려지면서 메시지가 추가되는지 확인합니다. 9장의
onSubmit기반 폼은 같은 조건에서 동작하지 않는 이유를 한 문장으로 설명해 보세요. useOptimistic적용. 위 방명록의 삭제 버튼에useOptimistic을 적용해 보세요. 클릭 즉시 UI에서 메시지가 사라지고, 서버 응답이 도착해도 화면이 그대로 유지되어야 합니다. 그 후deleteMessage안에throw new Error('실패')를 일부러 넣어 보고, 화면이 원래 상태로 자동 롤백되는지 확인합니다.- 태그 기반 무효화 실험.
getMessages를fetch(url, { next: { tags: ['guestbook'] } })로 외부 API를 호출하도록 바꾸고 (mock으로 간단히),postMessage/removeMessage의revalidatePath('/guestbook')을revalidateTag('guestbook')으로 교체합니다. 25장의next.tags와 본 챕터의revalidateTag가 짝을 이루는 흐름을 직접 손에 익혀 보세요.
한 줄 요약: Server Action은
'use server'가 붙은 비동기 함수로, 클라이언트에서 import 해 호출하면 자동으로 서버에서 실행된다.<form action={fn}>으로 progressive enhancement가 보장되고,useActionState로 검증 결과를,useFormStatus로 제출 중 상태를,useOptimistic으로 즉시 반영 UX를 입힌다. mutation 뒤에는revalidatePath/revalidateTag로 25장의 캐시와 동기화한다. API 엔드포인트와 JSON 직렬화 보일러플레이트가 사라지고, 9 / 19장에서 만든 클라이언트 사이드 폼이 RSC 시대의 폼으로 진화한다.
다음 챕터 #
4부의 본 내용은 본 챕터로 마무리되지만, 한 챕터가 더 남았습니다. 다음 28장 리액트 19 신기능 정리에서 이 4부 곳곳에서 만난 리액트 19 신기능들 — use 훅, Actions API (useActionState · useFormStatus · useOptimistic), ref as prop, 그리고 React Compiler — 을 한곳에 모아 카탈로그로 정리합니다. 4부에서 흩어져 있던 조각들이 한 챕터 안에서 큰 그림으로 묶이는 챕터이고, 4부의 진짜 마무리입니다. 28장 이후 29장부터 5부 (운영 · 테스트 · 배포)가 시작됩니다.