모던 리액트 + Next.js #6 Server Actions와 폼 (마무리)
지난 시간에는 Suspense와 use()로 점진적 로딩을 다뤘습니다. 지금까지는 데이터를 읽기만 했죠. 이번 글이자 시리즈 마지막에서는 사용자가 데이터를 변경하는 작업을 어떻게 다루는지 — Next.js의 가장 새로운 무기인 Server Actions를 살펴보고, 그동안 배운 걸 모두 합친 작은 미니 프로젝트로 시리즈를 마무리합니다.
전통적인 mutation의 복잡함
지금까지의 프론트엔드 mutation 패턴을 떠올려보세요.
'use client';
function CommentForm() {
const [text, setText] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(null);
async function handleSubmit(e) {
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.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) {
// 이 코드는 항상 서버에서 실행됨
await db.query('INSERT INTO comments (text) VALUES ($1)', [text]);
}'use client';
import { createComment } from './actions';
export default function CommentForm() {
async function handleSubmit(formData) {
const text = formData.get('text');
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와 인자를 서버에 보내고, 서버가 실제 함수를 실행한 뒤 결과를 돌려주는 거예요.
디렉티브 위치 — 파일 단위 vs 함수 단위
'use server'는 두 가지 방식으로 쓸 수 있습니다.
1. 파일 맨 위 (그 파일의 모든 export가 Server Action)
'use server';
export async function createPost(formData) { /* ... */ }
export async function deletePost(id) { /* ... */ }
export async function updatePost(id, data) { /* ... */ }2. 함수 안 (Server Component 안에 인라인 정의)
import PostForm from './PostForm';
export default function PostsPage() {
async function createPost(formData) {
'use server';
const title = formData.get('title');
await db.insertPost(title);
}
return <PostForm onCreate={createPost} />;
}함수 인라인 방식은 Server Component의 클로저(상위 변수)에 접근할 수 있어 편리합니다. 다만 매 렌더링마다 새 함수가 생성되니, 리스트의 각 항목에서 무차별로 쓰면 효율이 떨어질 수 있어요.
규모가 커지면 보통 별도 파일(actions.js)에 모아두는 쪽이 유지보수에 좋습니다.
페이지 갱신 — revalidatePath / revalidateTag
mutation 후에는 화면이 새 데이터를 반영해야 합니다. 단순 새로고침이 아니라, 변경된 페이지의 캐시를 무효화하고 다시 가져오게 하는 거죠.
'use server';
import { revalidatePath } from 'next/cache';
export async function createPost(formData) {
const title = formData.get('title');
await db.insertPost(title);
revalidatePath('/posts'); // /posts 페이지의 캐시를 무효화
}revalidatePath('/posts')를 호출하면 다음 /posts 방문 시 새로 렌더링됩니다. 이미 그 페이지에 있는 사용자라면 화면이 자동으로 갱신돼요 (Server Action을 실행한 뒤 Next.js가 라우트의 캐시를 갱신하니까요).
revalidateTag는 #4에서 본 next.tags 옵션과 짝을 이룹니다.
// 페칭 쪽
const posts = await fetch(url, { next: { tags: ['posts'] } });
// Action 쪽
revalidateTag('posts'); // 'posts' 태그가 붙은 모든 fetch를 무효화여러 페이지에서 같은 데이터를 쓸 때 한 번에 무효화할 수 있어 편리합니다.
useActionState — 상태가 있는 Action
Action의 결과(성공/실패 메시지, 검증 에러 등)를 폼 화면에 표시해야 할 때가 많습니다. React 19의 새 훅 useActionState가 이걸 도와줍니다.
'use client';
import { useActionState } from 'react';
import { createComment } from './actions';
export default function CommentForm() {
const [state, formAction] = useActionState(createComment, { message: '' });
return (
<form action={formAction}>
<input name="text" />
<button>제출</button>
{state.message && <p>{state.message}</p>}
</form>
);
}'use server';
export async function createComment(prevState, formData) {
const text = formData.get('text');
if (!text?.trim()) {
return { message: '내용을 입력해주세요' };
}
await db.insertComment(text);
return { message: '제출 완료!' };
}useActionState(action, initialState)는:
- 첫 번째 반환값(
state): Action의 마지막 반환값 (또는 초기 state) - 두 번째 반환값(
formAction):<form action={...}>에 넘길 래핑된 함수 - (세 번째 반환값
isPending도 있어서 로딩 표시에 활용 가능)
Action 함수의 첫 인자는 이전 state, 두 번째가 FormData입니다 (위 actions.js의 시그니처가 그래서 (prevState, formData)).
이 패턴 덕에 검증 에러를 화면에 표시하거나, 성공 메시지를 보여주는 게 자연스럽게 됩니다.
useFormStatus — 제출 중 표시
폼이 제출 중인지(pending)는 useFormStatus 훅으로 알 수 있습니다.
'use client';
import { useFormStatus } from 'react-dom';
export default function SubmitButton({ children }) {
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';
export default function PostList({ posts }) {
const [optimisticPosts, deleteOptimistic] = useOptimistic(
posts,
(state, postId) => state.filter(p => p.id !== postId)
);
async function handleDelete(id) {
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로 롤백됩니다.
체감 속도가 극적으로 좋아지는 강력한 패턴인데, 데이터가 일관성 있게 표시되는지 검증이 필요해 학습 후반부에 익히는 게 보통입니다. 이 시리즈에서는 개념만 짚고 넘어갑니다.
미니 프로젝트 — 간단한 방명록
지금까지 배운 걸 모두 합친 작은 앱을 만들어봅시다. 메모리에 저장되는 단순 방명록입니다 (실제 DB는 #4 캐싱 글에서 짚었듯이 본격적으로 들어가면 별도 주제라 생략).
src/app/data.js (메모리 저장소):
const messages = [
{ id: '1', name: '관리자', text: '환영합니다 :)', createdAt: new Date().toISOString() },
];
export async function getMessages() {
await new Promise(r => setTimeout(r, 200)); // 가짜 지연
return [...messages].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}
export async function addMessage(name, text) {
messages.push({
id: crypto.randomUUID(),
name, text,
createdAt: new Date().toISOString(),
});
}
export async function deleteMessage(id) {
const idx = messages.findIndex(m => m.id === id);
if (idx >= 0) messages.splice(idx, 1);
}src/app/guestbook/actions.js:
'use server';
import { revalidatePath } from 'next/cache';
import { addMessage, deleteMessage } from '../data';
export async function postMessage(prevState, formData) {
const name = formData.get('name')?.trim();
const text = formData.get('text')?.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) {
await deleteMessage(id);
revalidatePath('/guestbook');
}src/app/guestbook/page.js:
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.jsx:
'use client';
import { useActionState, useEffect, useRef } from 'react';
import { useFormStatus } from 'react-dom';
import { postMessage } from './actions';
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending} style={{ padding: '6px 16px' }}>
{pending ? '등록 중...' : '등록'}
</button>
);
}
export default function MessageForm() {
const [state, formAction] = useActionState(postMessage, {});
const formRef = useRef(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자 넘게 입력해보고(에러), 삭제 버튼도 눌러보세요. 페이지를 새로고침해도 (서버가 안 죽었다면) 메시지가 그대로 있을 겁니다 — 메모리 저장소에서요.
시리즈 회고
이 시리즈에서 우리는 모던 리액트의 멘탈 모델 전환을 다뤘습니다.
| # | 주제 | 핵심 |
|---|---|---|
| 1 | 왜 Next.js + Server Components | CSR/SSR/RSC 차이, motivation |
| 2 | App Router | 파일 기반 라우팅, layout, 동적 경로 |
| 3 | Server vs Client Components | 'use client', 경계, children 패턴 |
| 4 | 데이터 페칭과 캐싱 | async 컴포넌트, fetch 옵션, 병렬 |
| 5 | Suspense와 use() | streaming, loading.js, skeleton |
| 6 | Server Actions | mutation, useActionState, useFormStatus |
가장 중요한 멘탈 모델 두 가지를 다시 강조하면:
- "이 코드는 어디서 실행되나?"를 항상 의식한다
- 기본은 Server, 필요한 것만 Client
이 두 가지 감각이 잡히면 모던 리액트 작업이 자연스러워집니다. 처음에는 어색하지만, 한 번 익숙해지고 나면 다시 클라이언트 사이드만의 사고로 돌아가기 어려울 정도예요.
지금까지의 큰 그림
블로그 시리즈 전체로 보면 이 글이 26번째 리액트 글입니다.
- 리액트 기초 강좌 #1~#15 — 클라이언트 사이드 React 펀더멘털
- 리액트로 Todo 앱 만들기 #1~#5 — 기초 위에 작은 실전 빌드
- 모던 리액트 + Next.js #1~#6 — Server Components와 모던 패러다임
이걸 다 따라 오신 분이라면 React 생태계의 거의 모든 핵심 흐름을 한 번씩은 만나본 셈입니다. 이제 본인이 만들고 싶은 앱을 시작하기에 충분한 베이스가 갖춰졌어요.
다음 학습 추천
이 시리즈를 마치셨다면 이런 주제들로 갈 수 있습니다.
- TypeScript + 리액트 — 큰 코드베이스 안전성 ↑. Next.js + TS는 기본값 수준
- 테스팅 — Vitest + React Testing Library, Playwright (E2E)
- 상태 관리 라이브러리 — Zustand, Jotai, Redux Toolkit (큰 앱)
- 데이터 페칭 라이브러리 — TanStack Query (Server Action만으로 부족할 때)
- 인증 — NextAuth.js / Clerk / Lucia
- DB 연동 — Prisma, Drizzle, Supabase (실제 영속화)
- 배포 — Vercel, Cloudflare Pages, 또는 셀프 호스팅
- 본인의 진짜 프로젝트 — 결국 이게 가장 빠른 학습. 위 도구들을 필요한 만큼만 도입해가며 작은 앱 → 큰 앱
마무리
여기까지 따라와주셔서 정말 감사합니다. 클라이언트 사이드 리액트의 첫 컴포넌트에서 시작해, Server Components와 Server Actions로 풀스택 React 앱을 만드는 곳까지 왔습니다.
리액트는 빠르게 진화하는 라이브러리지만, 변하지 않는 펀더멘털이 있습니다 — 컴포넌트 단위 사고, 단방향 데이터 흐름, 선언적 UI. 이 시리즈에서 배운 건 그 펀더멘털이고, 그 위에 새 도구들이 계속 쌓일 거예요. 새 도구가 나와도 본질을 알면 빠르게 익힐 수 있다는 자신감이 가장 큰 수확입니다.
직접 프로젝트를 만들어 보면서 자기만의 작은 성공 경험을 쌓아가세요. 즐거운 리액트 여정 되세요!