React Server Actions로 폼과 뮤테이션 다루기: 쓰기 절반을 채우는 법
지난 글에서 async 서버 컴포넌트 덕분에 데이터 읽기가 어떻게 단순해졌는지를 정리했습니다. 그런데 막상 화면을 만들다 보면 절반만 해결된 느낌이 듭니다. 목록을 보여 주는 건 깔끔해졌는데, 댓글을 등록하고 좋아요를 누르는 쓰기는 여전히 예전 그대로이기 때문입니다.
// 쓰기는 아직도 이런 모습입니다
const res = await fetch('/api/comments', {
method: 'POST',
body: JSON.stringify({ text }),
});
if (!res.ok) setError('등록 실패');
// 그리고 다시 목록을 새로고침…API 라우트를 따로 만들고, 클라이언트에서 fetch로 호출하고, 로딩과 에러 상태를 손으로 관리하고, 성공하면 목록을 다시 불러옵니다. 읽기에서 덜어 낸 번거로움이 쓰기에 고스란히 남아 있는 셈입니다.
이 글은 그 나머지 절반을 채우는 이야기입니다. Server Action이 무엇이고, 폼과 어떻게 연결되며, React 19의 액션 훅 세 가지가 로딩·에러·낙관적 UI를 어떻게 흡수하는지, 그리고 실무에서 무엇을 조심해야 하는지를 정리하겠습니다.
한눈에 보면
- Server Action은
'use server'로 표시한, 서버에서 실행되는 async 함수입니다. 클라이언트에서 평범한 함수처럼 호출하면 됩니다. <form action={serverAction}>으로 폼에 직접 연결할 수 있고, 자바스크립트가 없거나 아직 로드되기 전에도 폼이 동작하는 점진적 향상이 따라옵니다.- React 19의
useActionState는 폼의 결과·대기 상태를,useFormStatus는 자식에서의 제출 상태를,useOptimistic은 낙관적 UI를 맡아 줍니다. - 뮤테이션 뒤 서버 컴포넌트의 데이터를 다시 그리려면 Next.js의
revalidatePath·revalidateTag를 씁니다. - Server Action은 편한 함수처럼 보이지만 사실상 공개 엔드포인트라, 입력 검증과 인가는 서버 쪽에서 직접 해야 합니다.
- 모든 쓰기를 Server Action으로 옮길 필요는 없습니다. Route Handler, React Query와 역할을 나누는 기준이 필요합니다.
Server Action이란
Server Action은 함수 맨 위(또는 모듈 맨 위)에 'use server'를 붙인 async 함수입니다. 이 표시가 붙으면 프레임워크가 그 함수를 서버에서 실행되도록 묶고, 클라이언트에는 그 함수를 가리키는 참조만 전달합니다. 그래서 클라이언트 컴포넌트는 마치 로컬 함수를 부르듯 호출하지만, 실제 코드는 서버에서만 돕니다.
// app/posts/[id]/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
export async function createComment(formData: FormData) {
const text = String(formData.get('text') ?? '').trim();
const postId = String(formData.get('postId') ?? '');
await db.comment.create({ data: { postId, text } });
revalidatePath(`/posts/${postId}`);
}여기서 헷갈리기 쉬운 지점을 짚고 가면, 'use server'는 서버 컴포넌트를 위한 표시가 아닙니다. 지난 글에서 봤듯 서버 컴포넌트는 별도 디렉티브 없이 기본값으로 동작하고, 클라이언트 경계만 'use client'로 표시합니다. 'use server'는 오직 Server Action을 표시하는 용도입니다.
폼과 연결: action prop과 점진적 향상
React 19부터 <form>의 action 속성에 함수를 그대로 넘길 수 있습니다. 폼이 제출되면 그 함수가 FormData를 받아 실행됩니다.
import { createComment } from './actions';
export function CommentForm({ postId }: { postId: string }) {
return (
<form action={createComment}>
<input type="hidden" name="postId" value={postId} />
<textarea name="text" required />
<button type="submit">댓글 등록</button>
</form>
);
}이 방식의 진짜 장점은 점진적 향상입니다. action에 함수를 넘긴 폼은 자바스크립트가 아직 로드되지 않았거나 비활성화된 상태에서도 일반 HTML 폼처럼 제출됩니다. 자바스크립트가 준비되면 React가 그 위에 비동기 처리·상태 관리를 얹어 줄 뿐입니다. 제출이 성공하면 uncontrolled 폼은 자동으로 초기화되므로, 입력값을 비우는 코드도 따로 쓰지 않아도 됩니다.
다만 위 예시에는 아직 로딩 표시도, 에러 메시지도 없습니다. 그 부분을 React 19의 액션 훅이 채웁니다.
React 19 액션 훅 세 가지
useActionState: 결과와 대기 상태를 한 번에
useActionState는 액션을 감싸서, 액션이 돌려준 결과와 진행 중 여부(isPending)를 함께 돌려줍니다. 액션 함수의 시그니처가 (이전 상태, formData)로 바뀌는 점만 기억하면 됩니다.
'use client';
import { useActionState } from 'react';
import { createComment } from './actions';
const initialState = { ok: false, message: '' };
export function CommentForm({ postId }: { postId: string }) {
const [state, formAction, isPending] = useActionState(createComment, initialState);
return (
<form action={formAction}>
<input type="hidden" name="postId" value={postId} />
<textarea name="text" required />
<button type="submit" disabled={isPending}>
{isPending ? '등록 중…' : '댓글 등록'}
</button>
{state.message && <p role="alert">{state.message}</p>}
</form>
);
}이때 액션은 결과를 반환하도록 바꿔 줍니다.
'use server';
export async function createComment(prevState: State, formData: FormData) {
const text = String(formData.get('text') ?? '').trim();
if (!text) {
return { ok: false, message: '내용을 입력해 주세요.' };
}
const postId = String(formData.get('postId') ?? '');
await db.comment.create({ data: { postId, text } });
revalidatePath(`/posts/${postId}`);
return { ok: true, message: '' };
}로딩 플래그를 useState로 만들고 finally에서 내리던 작업이 isPending 하나로, 에러 처리는 액션의 반환값으로 정리됩니다.
useFormStatus: 자식에서 제출 상태 읽기
제출 버튼을 별도 컴포넌트로 빼면, 폼 상태를 prop으로 내려 줄 필요 없이 useFormStatus로 바로 읽을 수 있습니다. 이 훅은 반드시 <form>의 자식 컴포넌트 안에서 호출해야 합니다.
'use client';
import { useFormStatus } from 'react-dom';
export function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? '등록 중…' : '댓글 등록'}
</button>
);
}공용 버튼 컴포넌트가 자기 폼의 상태를 스스로 아는 구조라, 디자인 시스템 차원에서 재사용하기 좋습니다.
useOptimistic: 응답을 기다리지 않고 먼저 보여 주기
네트워크 응답을 기다리는 동안 결과를 미리 반영해 두면 체감 속도가 크게 올라갑니다. useOptimistic은 현재 상태와 "낙관적으로 어떻게 바꿀지"를 받아, 실제 응답이 오기 전까지 보여 줄 임시 상태를 만들어 줍니다. 액션이 실패하면 자동으로 원래 값으로 되돌아갑니다.
'use client';
import { useOptimistic } from 'react';
import { createComment } from './actions';
export function CommentList({ comments, postId }: Props) {
const [optimisticComments, addOptimistic] = useOptimistic(comments, (current, text: string) => [
...current,
{ id: 'temp', text, sending: true },
]);
async function action(formData: FormData) {
addOptimistic(String(formData.get('text') ?? ''));
await createComment(formData);
}
return (
<>
<ul>
{optimisticComments.map((c) => (
<li key={c.id} style={{ opacity: c.sending ? 0.5 : 1 }}>
{c.text}
</li>
))}
</ul>
<form action={action}>
<input type="hidden" name="postId" value={postId} />
<textarea name="text" required />
<SubmitButton />
</form>
</>
);
}뮤테이션 후 데이터 갱신
쓰기가 끝나면 화면에 보이는 데이터도 최신으로 맞춰야 합니다. 서버 컴포넌트가 그린 목록이라면, 클라이언트에서 다시 fetch하는 대신 서버에 "이 데이터는 이제 낡았다"고 알려 주는 편이 자연스럽습니다. 이때 쓰는 게 Next.js의 revalidatePath와 revalidateTag입니다.
revalidatePath('/posts/123')— 특정 경로의 캐시를 무효화합니다.revalidateTag('comments')— 해당 태그로 묶인fetch결과를 경로와 무관하게 한꺼번에 무효화합니다.
두 함수 모두 Server Action·Route Handler·서버 컴포넌트처럼 서버 환경에서만 호출할 수 있고, 클라이언트 컴포넌트에서는 쓸 수 없습니다. 무효화가 일어나면 다음 요청에서 해당 서버 컴포넌트가 다시 실행되어 새 데이터로 그려집니다.
sequenceDiagram
participant U as 사용자
participant F as 폼(클라이언트)
participant A as Server Action
participant DB as 데이터베이스
U->>F: 댓글 입력 후 제출
F->>F: useOptimistic으로 즉시 반영
F->>A: action 호출(FormData)
A->>DB: 댓글 저장
A->>A: revalidatePath('/posts/123')
A-->>F: 결과 반환
Note over F: 서버 컴포넌트 데이터가 최신으로 교체보안: Server Action은 사실상 공개 엔드포인트다
가장 자주 놓치는 부분입니다. Server Action은 코드상으로는 평범한 함수처럼 보이지만, 빌드되면 누구나 호출할 수 있는 HTTP 엔드포인트가 됩니다. 폼 UI에 버튼이 없다고 해서 호출이 막히는 게 아닙니다.
그래서 클라이언트에서 넘어온 값은 신뢰하지 않는다는 전제로 다뤄야 합니다.
- 입력값은 액션 안에서 직접 검증합니다.
FormData는 어떤 값이든 담겨 올 수 있습니다. - 인증·인가를 액션 안에서 확인합니다. "이 사용자가 이 작업을 할 권한이 있는가"는 UI가 아니라 서버에서 판단해야 합니다.
- 클라이언트가 보낸
userId같은 값을 그대로 믿지 말고, 서버 세션에서 다시 확인합니다.
'use server';
export async function deleteComment(formData: FormData) {
const user = await getCurrentUser(); // 서버 세션 기준
if (!user) {
return { ok: false, message: '로그인이 필요합니다.' };
}
const commentId = String(formData.get('commentId') ?? '');
const comment = await db.comment.findUnique({ where: { id: commentId } });
if (comment?.authorId !== user.id) {
return { ok: false, message: '삭제 권한이 없습니다.' };
}
await db.comment.delete({ where: { id: commentId } });
revalidatePath(`/posts/${comment.postId}`);
return { ok: true, message: '' };
}언제 Server Action을 쓰고, 언제 다른 걸 쓸까
Server Action이 모든 쓰기를 대체하지는 않습니다. 역할을 나누는 기준을 표로 정리하면 이렇습니다.
| 구분 | Server Action | Route Handler (route.ts) |
React Query mutation |
|---|---|---|---|
| 실행 위치 | 서버 | 서버 | 클라이언트 |
| 폼 직접 연결 | <form action>로 바로 |
불가 (fetch 필요) | 불가 (JS 필수) |
| 점진적 향상 | 지원 | 미지원 | 미지원 |
| 데이터 갱신 | revalidatePath·revalidateTag |
수동 | 클라이언트 쿼리 캐시 무효화 |
| 잘 맞는 상황 | 폼 제출, 서버 컴포넌트와 한 몸인 뮤테이션 | 외부 공개 API, 웹훅, 폼이 아닌 엔드포인트 | 복잡한 클라이언트 캐시·재시도·낙관적 업데이트가 많은 화면 |
대략의 감각은 이렇습니다. 폼 기반의 쓰기와 서버 컴포넌트 데이터 갱신이 한 흐름이라면 Server Action이 가장 잘 맞습니다. 외부에서 호출하는 공개 API나 웹훅은 Route Handler가 맞고, 클라이언트 주도로 캐시를 정교하게 다뤄야 하는 화면이라면 React Query가 여전히 제 몫을 합니다. 셋은 경쟁 관계라기보다 쓰기의 결이 다를 뿐입니다.
정리하면
Server Action은 "쓰기"를 서버 렌더링 모델 안으로 끌어들인 장치입니다. 읽기를 async 서버 컴포넌트가 단순하게 만들었듯, 쓰기는 Server Action과 액션 훅이 맡습니다.
'use server'함수를<form action>에 연결하면 점진적 향상까지 따라온다.useActionState로 결과·대기 상태를,useFormStatus로 자식의 제출 상태를,useOptimistic으로 낙관적 UI를 처리한다.- 뮤테이션 뒤에는
revalidatePath·revalidateTag로 서버 컴포넌트 데이터를 갱신한다. - Server Action은 공개 엔드포인트이므로 검증과 인가는 반드시 서버에서 한다.
- 폼·서버 컴포넌트와 묶인 쓰기는 Server Action, 외부 API는 Route Handler, 정교한 클라이언트 캐시는 React Query로 나눈다.
읽기 글과 묶어서 보면, React 18 이후의 흐름은 결국 "어디서 무엇이 실행되는지를 더 또렷하게 나누는" 방향이라는 게 다시 한번 드러납니다.
버전에 따라 세부 API와 동작이 달라질 수 있으니, 도입 전에는 사용하는 React·Next.js 버전의 공식 문서로 한 번 더 확인하기를 권합니다. (이 글은 React 19, Next.js App Router 기준입니다.)
