
Server Actions: 폼 처리의 새로운 패러다임
API 라우트 만들고, fetch 호출하고, 로딩 상태 관리하고... 폼 하나 처리하는 데 너무 많은 코드가 필요했는데, Server Actions로 획기적으로 줄었다.

API 라우트 만들고, fetch 호출하고, 로딩 상태 관리하고... 폼 하나 처리하는 데 너무 많은 코드가 필요했는데, Server Actions로 획기적으로 줄었다.
습관적으로 모든 변수에 `useMemo`를 감싸고 있나요? 그게 오히려 성능을 망치고 있습니다. 메모이제이션 비용과 올바른 최적화 타이밍.

React나 Vue 프로젝트를 빌드해서 배포했는데, 홈 화면은 잘 나오지만 새로고침만 하면 'Page Not Found'가 뜨나요? CSR의 원리와 서버 설정(Nginx, Apache, S3)을 통해 이를 해결하는 완벽 가이드.

진짜 집을 부수고 다시 짓는 건 비쌉니다. 설계도(가상돔)에서 미리 그려보고 바뀐 부분만 공사하는 '똑똑한 리모델링'의 기술.

전역 상태 관리를 위해 Redux 대신 Context API를 선택했습니다. 하지만 `UserContext`에 모든 정보를 담자마자 앱 전체가 리렌더링되기 시작했습니다. Context 분리(Splitting) 전략.

회원가입 폼을 만들고 있었다. 이메일, 비밀번호, 이름 받아서 서버에 저장하는 기능. 누가 봐도 간단한 기능 아닌가?
그런데 실제로 작성해야 할 코드를 보고 멘붕이 왔다. /api/signup 라우트 파일을 만들고, POST 메서드 핸들러를 작성하고, 클라이언트에서는 useState로 폼 상태 관리하고, onSubmit에서 fetch 호출하고, 로딩 상태 따로 관리하고, 에러 상태 따로 관리하고...
// app/api/signup/route.ts
export async function POST(request: Request) {
const body = await request.json();
// validation, business logic...
return Response.json({ success: true });
}
// components/SignupForm.tsx
export default function SignupForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
try {
const res = await fetch('/api/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!res.ok) throw new Error('Failed');
// handle success...
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit}>
{/* form fields... */}
</form>
);
}
보일러플레이트가 너무 많다는 생각이 들었다. 이 모든 게 "서버에 데이터 저장하기"라는 하나의 목적을 위한 것인데, 왜 이렇게 파일도 많고 코드도 많고 신경 써야 할 것도 많을까?
더 큰 문제는 이게 끝이 아니라는 것. 자바스크립트가 로드되지 않으면 폼이 작동하지 않는다. 접근성도 떨어진다. Optimistic UI를 구현하려면 또 다른 상태 관리가 필요하다.
결국 이거였다. 폼 처리라는 웹의 가장 기본적인 기능이 현대 웹 개발에서는 너무 복잡해졌다.
Server Actions를 처음 봤을 때의 충격을 잊을 수 없다.
// app/actions.ts
'use server';
export async function signup(formData: FormData) {
const email = formData.get('email');
const password = formData.get('password');
// validation, business logic...
await db.user.create({ email, password });
redirect('/dashboard');
}
// components/SignupForm.tsx
import { signup } from '@/app/actions';
export default function SignupForm() {
return (
<form action={signup}>
<input name="email" type="email" required />
<input name="password" type="password" required />
<button type="submit">Sign Up</button>
</form>
);
}
이게 끝이다. API 라우트 없다. fetch 호출 없다. useState 없다. 로딩 상태 관리 코드 없다.
처음엔 "이게 어떻게 작동하는 거지?"라는 의구심이 들었다. 클라이언트 컴포넌트에서 서버 함수를 직접 호출한다고? 이게 가능해?
알고 보니 'use server' 디렉티브가 마법을 부린다. Next.js가 이 함수를 자동으로 POST 엔드포인트로 변환한다. 클라이언트에서 이 함수를 호출하면 내부적으로 fetch 요청이 간다. 하지만 개발자는 그냥 함수를 호출하는 것처럼 코드를 작성하면 된다.
이해가 와닿았다. RPC(Remote Procedure Call) 패턴이다. 서버 함수를 마치 로컬 함수처럼 호출할 수 있게 만든 것. 복잡한 HTTP 통신 레이어는 프레임워크가 추상화해준다.
더 놀라운 건 Progressive Enhancement다. 자바스크립트가 비활성화되어 있어도 이 폼은 작동한다. 왜냐하면 HTML의 기본 <form> 동작을 활용하기 때문이다. 브라우저가 알아서 POST 요청을 보낸다.
옛날 PHP나 Rails로 웹 개발하던 때의 단순함이 돌아온 느낌이었다. 하지만 현대적인 React의 장점은 그대로 유지하면서.
Server Actions를 쓰면서 가장 편했던 건 useFormState와 useFormStatus 훅이었다.
이전 방식에서는 서버 응답을 받아서 상태를 업데이트하는 로직을 직접 작성해야 했다. 성공 메시지, 에러 메시지, 폼 리셋... 다 수동이었다.
'use server';
export async function createPost(prevState: any, formData: FormData) {
const title = formData.get('title') as string;
if (!title || title.length < 3) {
return { error: 'Title must be at least 3 characters' };
}
try {
await db.post.create({ title });
revalidatePath('/posts');
return { success: 'Post created!' };
} catch (err) {
return { error: 'Failed to create post' };
}
}
'use client';
import { useFormState } from 'react-dom';
import { createPost } from './actions';
export default function CreatePostForm() {
const [state, formAction] = useFormState(createPost, null);
return (
<form action={formAction}>
<input name="title" required />
{state?.error && <p className="error">{state.error}</p>}
{state?.success && <p className="success">{state.success}</p>}
<button type="submit">Create</button>
</form>
);
}
useFormState는 Server Action의 반환값을 자동으로 상태로 관리해준다. 첫 번째 인자는 이전 상태, 두 번째 인자는 FormData다. 이 패턴 덕분에 validation 에러나 성공 메시지를 쉽게 표시할 수 있다.
더 신기한 건 useFormStatus다. 폼이 제출 중인지 자동으로 알려준다.
'use client';
import { useFormStatus } from 'react-dom';
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? 'Creating...' : 'Create Post'}
</button>
);
}
export default function CreatePostForm() {
return (
<form action={createPost}>
<input name="title" required />
<SubmitButton />
</form>
);
}
pending 상태를 직접 관리할 필요가 없다. useFormStatus는 현재 폼 컨텍스트를 자동으로 감지한다. 주의할 점은 이 훅을 폼 내부의 자식 컴포넌트에서 사용해야 한다는 것.
이 패턴이 와닿은 이유는 명확한 책임 분리 때문이다. Server Action은 비즈니스 로직과 validation에만 집중하고, UI 상태 관리는 React 훅이 자동으로 처리한다.
Server Actions에서 가장 중요한 건 validation이다. 클라이언트에서 오는 모든 데이터는 신뢰할 수 없다.
'use server';
import { z } from 'zod';
const SignupSchema = z.object({
email: z.string().email('Invalid email'),
password: z.string().min(8, 'Password must be at least 8 characters'),
name: z.string().min(2, 'Name must be at least 2 characters'),
});
export async function signup(prevState: any, formData: FormData) {
const validatedFields = SignupSchema.safeParse({
email: formData.get('email'),
password: formData.get('password'),
name: formData.get('name'),
});
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
};
}
const { email, password, name } = validatedFields.data;
// proceed with validated data...
}
Zod와 Server Actions의 조합은 완벽했다. 타입 안전성을 유지하면서 런타임 validation도 처리한다. safeParse로 에러를 객체로 받아서 필드별로 에러 메시지를 표시할 수 있다.
클라이언트에서도 같은 스키마를 재사용해서 즉각적인 피드백을 줄 수 있다. 서버는 최종 방어선, 클라이언트는 UX 개선용.
Server Actions의 진짜 파워는 useOptimistic과 결합했을 때 나온다.
좋아요 버튼을 생각해보자. 사용자가 클릭하면 서버 응답을 기다릴 필요 없이 즉시 UI를 업데이트하고 싶다. 실패하면 그때 되돌리면 된다.
'use client';
import { useOptimistic } from 'react';
import { toggleLike } from './actions';
export default function LikeButton({ postId, initialLikes }: Props) {
const [optimisticLikes, setOptimisticLikes] = useOptimistic(
initialLikes,
(state, newLikes: number) => newLikes
);
async function handleLike() {
setOptimisticLikes(optimisticLikes + 1);
await toggleLike(postId);
}
return (
<button onClick={handleLike}>
{optimisticLikes} Likes
</button>
);
}
'use server';
export async function toggleLike(postId: string) {
await db.like.create({ postId });
revalidateTag(`post-${postId}`);
}
useOptimistic은 "낙관적 상태"를 관리한다. 사용자 액션에 즉시 반응하는 임시 상태. 실제 서버 응답이 오면 자동으로 실제 데이터로 교체된다.
이 패턴이 좋은 이유는 사용자 경험 때문이다. 네트워크 지연을 기다리지 않고 즉각적인 피드백을 준다. 실패는 드물고, 성공이 대부분인 액션에 완벽하다.
메타포로 설명하면 카페에서 주문하는 것과 같다. 주문하자마자 영수증을 받는다(optimistic update). 실제 커피가 나오는 건 나중이지만(server response), 고객은 주문이 처리되고 있다는 확신을 즉시 받는다.
Server Actions 이후에는 관련된 데이터를 다시 가져와야 한다. Next.js는 이를 위해 두 가지 방법을 제공한다.
'use server';
import { revalidatePath, revalidateTag } from 'next/cache';
export async function createPost(formData: FormData) {
await db.post.create({ title: formData.get('title') });
// Option 1: Revalidate by path
revalidatePath('/posts');
// Option 2: Revalidate by tag
revalidateTag('posts-list');
}
revalidatePath는 특정 경로의 캐시를 무효화한다. 간단하고 직관적이다.
revalidateTag는 더 세밀한 제어를 제공한다. 데이터를 fetch할 때 태그를 붙이고, 나중에 그 태그로 무효화할 수 있다.
// app/posts/page.tsx
export default async function PostsPage() {
const posts = await fetch('https://api.example.com/posts', {
next: { tags: ['posts-list'] }
});
return <PostsList posts={posts} />;
}
이 방식이 마음에 든 이유는 명시적이기 때문이다. "이 액션은 posts-list를 변경한다"는 의도가 코드에 드러난다. 복잡한 앱에서 데이터 일관성을 유지하는 데 중요하다.
Server Actions에서 에러를 처리하는 방법은 두 가지다.
'use server';
// Option 1: Return error state
export async function updateProfile(formData: FormData) {
try {
await db.user.update({ name: formData.get('name') });
return { success: true };
} catch (error) {
return { error: 'Failed to update profile' };
}
}
// Option 2: Throw error (caught by error boundary)
export async function deleteAccount() {
const session = await getSession();
if (!session) {
throw new Error('Unauthorized');
}
await db.user.delete({ id: session.userId });
}
첫 번째 방식은 예상 가능한 에러(validation 실패, 비즈니스 로직 에러)에 적합하다. useFormState와 함께 사용해서 폼 아래에 에러 메시지를 표시한다.
두 번째 방식은 예상치 못한 에러(인증 실패, 데이터베이스 오류)에 적합하다. Error Boundary가 잡아서 전체 에러 페이지를 보여준다.
Server Actions는 자동으로 POST 엔드포인트가 되고, Next.js가 CSRF 토큰을 관리한다. 개발자가 신경 쓸 필요 없다.
하지만 인증은 직접 확인해야 한다.
'use server';
import { auth } from '@/lib/auth';
export async function deletePost(postId: string) {
const session = await auth();
if (!session) {
throw new Error('Unauthorized');
}
const post = await db.post.findUnique({ where: { id: postId } });
if (post.authorId !== session.userId) {
throw new Error('Forbidden');
}
await db.post.delete({ where: { id: postId } });
revalidatePath('/posts');
}
모든 Server Action의 시작은 인증 확인이다. 클라이언트에서 오는 모든 요청은 의심하고, 서버에서 권한을 검증한다.
Server Actions를 알게 된 후 "그럼 API Routes는 언제 쓰지?"라는 질문이 생겼다.
결론은 간단했다:
Server Actions 사용:실제로는 80%는 Server Actions로 충분했다. 대부분의 폼 처리와 mutation은 Server Actions가 더 간결하고 타입 안전하다.
API Routes는 "외부와의 계약"이 필요할 때만 사용하게 됐다.
Server Actions는 폼 처리를 근본적으로 재정의했다.
핵심 깨달음:단순함의 회복: 웹의 기본(HTML form)으로 돌아가되, React의 장점은 유지한다.
추상화의 힘: 복잡한 네트워크 레이어는 프레임워크가 처리하고, 개발자는 비즈니스 로직에 집중한다.
Progressive Enhancement: 자바스크립트 없이도 작동하는 폼. 접근성과 성능의 기본.
타입 안전성: 서버-클라이언트 경계를 넘어서는 end-to-end 타입 안전성.
상태 관리 자동화: useFormState, useFormStatus, useOptimistic이 보일러플레이트를 제거한다.
코드가 줄어든 것도 좋지만, 더 중요한 건 인지 부하의 감소다. API 엔드포인트 URL을 기억할 필요 없다. fetch 옵션을 고민할 필요 없다. 상태 동기화를 걱정할 필요 없다.
그냥 서버에서 실행될 함수를 작성하고, 폼 action에 넘기면 된다. 나머지는 프레임워크가 해결한다.
폼 처리가 다시 즐거워졌다. 이게 Server Actions가 가져온 가장 큰 변화다.