모던 리액트 + Next.js #6 Server Actions와 폼 (마무리)

20 분 소요

지난 시간에는 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' 디렉티브가 붙은 비동기 함수입니다. 클라이언트에서 호출하면 자동으로 서버에서 실행됩니다.

src/app/actions.js
'use server';
 
export async function createComment(text) {
  // 이 코드는 항상 서버에서 실행됨
  await db.query('INSERT INTO comments (text) VALUES ($1)', [text]);
}
src/app/CommentForm.jsx
'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)

src/app/actions.js
'use server';
 
export async function createPost(formData) { /* ... */ }
export async function deletePost(id) { /* ... */ }
export async function updatePost(id, data) { /* ... */ }

2. 함수 안 (Server Component 안에 인라인 정의)

src/app/posts/page.js (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 후에는 화면이 새 데이터를 반영해야 합니다. 단순 새로고침이 아니라, 변경된 페이지의 캐시를 무효화하고 다시 가져오게 하는 거죠.

src/app/posts/actions.js
'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가 이걸 도와줍니다.

src/app/CommentForm.jsx
'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>
  );
}
src/app/actions.js
'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 훅으로 알 수 있습니다.

src/app/SubmitButton.jsx
'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을 폼 안 어디에 두든, 그 폼이 제출 중이면 pendingtrue가 돼요.

비슷한 효과를 useActionStateisPending(세 번째 반환값)으로도 낼 수 있는데, useFormStatus는 별도 컴포넌트에서 폼 상태를 구독할 수 있어서 재사용 가능한 SubmitButton 같은 패턴에 유용합니다.

Optimistic UI — useOptimistic

mutation의 응답을 기다리는 동안 화면을 미리 갱신해 즉시 반영된 것처럼 보이게 하는 패턴입니다. useOptimistic 훅이 도와줍니다.

src/app/posts/PostList.jsx
'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 (메모리 저장소):

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:

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:

src/app/guestbook/page.js (Server Component)
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:

src/app/guestbook/MessageForm.jsx (Client)
'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 안에 있어 로딩 중엔 fallback
  • MessageForm (Client Component) — 폼, useActionState로 서버 상태 받기, useFormStatus로 제출 중 표시
  • postMessage (Server Action) — 서버에서 검증 + 저장 + revalidatePath
  • removeMessage (Server Action) — 인라인으로 작성, 폼의 action에 직접 연결

API 엔드포인트는 한 줄도 안 만들었습니다. 검증도 서버에서 하니 클라이언트에서 우회 불가, 데이터는 서버 메모리에 안전하게 보관, 화면은 mutation 후 자동 갱신.

/guestbook으로 이동해 동작을 확인해보세요. 메시지를 등록하고, 빈 입력으로 제출해보고(에러), 200자 넘게 입력해보고(에러), 삭제 버튼도 눌러보세요. 페이지를 새로고침해도 (서버가 안 죽었다면) 메시지가 그대로 있을 겁니다 — 메모리 저장소에서요.

시리즈 회고

이 시리즈에서 우리는 모던 리액트의 멘탈 모델 전환을 다뤘습니다.

#주제핵심
1왜 Next.js + Server ComponentsCSR/SSR/RSC 차이, motivation
2App Router파일 기반 라우팅, layout, 동적 경로
3Server vs Client Components'use client', 경계, children 패턴
4데이터 페칭과 캐싱async 컴포넌트, fetch 옵션, 병렬
5Suspense와 use()streaming, loading.js, skeleton
6Server Actionsmutation, useActionState, useFormStatus

가장 중요한 멘탈 모델 두 가지를 다시 강조하면:

  1. "이 코드는 어디서 실행되나?"를 항상 의식한다
  2. 기본은 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. 이 시리즈에서 배운 건 그 펀더멘털이고, 그 위에 새 도구들이 계속 쌓일 거예요. 새 도구가 나와도 본질을 알면 빠르게 익힐 수 있다는 자신감이 가장 큰 수확입니다.

직접 프로젝트를 만들어 보면서 자기만의 작은 성공 경험을 쌓아가세요. 즐거운 리액트 여정 되세요!