[이제와서 시작하는 Next.js 마스터하기 #5] Server Actions으로 폼 처리하기
[이제와서 시작하는 Next.js 마스터하기 #5] Server Actions으로 폼 처리하기
“API Route 없이 폼 제출?” - Server Actions로 백엔드 코드를 프론트엔드처럼 쉽게 작성하세요!
🎯 이 글에서 배울 내용
- Server Actions가 무엇인지
- 폼 처리하는 3가지 방법
- 유효성 검사와 에러 처리
- Progressive Enhancement
- useActionState와 useFormStatus
예상 소요 시간: 45분
🤔 Server Actions가 뭔가요?
전통적인 방식 vs Server Actions
전통적인 방식:
1
2
3
4
5
1. 폼 작성 (클라이언트)
2. API Route 만들기 (/api/posts)
3. fetch로 API 호출
4. API Route에서 데이터 처리
5. 응답 받기
Server Actions:
1
2
3
1. 폼 작성
2. Server Action 함수 작성
3. 끝! (API Route 불필요)
📝 기본 폼 처리
1. 가장 간단한 예제
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// app/contact/page.js
async function submitForm(formData) {
'use server'; // ← 서버에서 실행!
const name = formData.get('name');
const email = formData.get('email');
// 서버 콘솔에 출력 (터미널에서 확인 가능!)
console.log('📩 폼 제출:', { name, email });
// 여기서 실제로는 데이터베이스에 저장하거나
// 이메일을 보내는 등의 작업을 합니다
// 예: await prisma.contact.create({ data: { name, email } });
// 간단한 시뮬레이션: 1초 대기
await new Promise(resolve => setTimeout(resolve, 1000));
console.log('✅ 저장 완료!');
}
export default function ContactPage() {
return (
<div className="max-w-md mx-auto p-6">
<h1 className="text-2xl font-bold mb-4">문의하기</h1>
<form action={submitForm} className="space-y-4">
<div>
<label className="block mb-1">이름</label>
<input
name="name"
placeholder="홍길동"
required
className="w-full p-2 border rounded"
/>
</div>
<div>
<label className="block mb-1">이메일</label>
<input
name="email"
type="email"
placeholder="hong@example.com"
required
className="w-full p-2 border rounded"
/>
</div>
<button
type="submit"
className="w-full bg-blue-600 text-white p-2 rounded hover:bg-blue-700"
>
제출
</button>
</form>
<p className="mt-4 text-sm text-gray-600">
💡 Tip: 폼을 제출하면 터미널(서버 콘솔)에 데이터가 출력됩니다!
</p>
</div>
);
}
핵심:
'use server'= 서버에서 실행formData.get('name')= 폼 데이터 가져오기- API Route 불필요!
2. 별도 파일로 분리
Server Actions는 별도 파일로 분리해서 여러 페이지에서 재사용할 수 있습니다!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// app/actions/posts.js
'use server';
import { redirect } from 'next/navigation';
export async function createPost(formData) {
const title = formData.get('title');
const content = formData.get('content');
// 제목과 내용 검증
if (!title || title.length < 3) {
return { error: '제목은 3자 이상이어야 합니다' };
}
if (!content || content.length < 10) {
return { error: '내용은 10자 이상이어야 합니다' };
}
// 서버 콘솔에 출력
console.log('📝 새 게시물:', { title, content });
// 실제로는 여기서 데이터베이스에 저장합니다
// 예: await prisma.post.create({ data: { title, content, published: true } });
// 시뮬레이션: 저장 중...
await new Promise(resolve => setTimeout(resolve, 1000));
console.log('✅ 게시물 저장 완료!');
// 블로그 페이지로 이동
redirect('/blog');
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// app/new-post/page.js
import { createPost } from '@/app/actions/posts';
export default function NewPostPage() {
return (
<div className="max-w-2xl mx-auto p-6">
<h1 className="text-3xl font-bold mb-6">새 게시물 작성</h1>
<form action={createPost} className="space-y-4">
<div>
<label className="block mb-1 font-semibold">제목</label>
<input
name="title"
placeholder="게시물 제목을 입력하세요"
required
className="w-full p-3 border rounded-lg"
/>
</div>
<div>
<label className="block mb-1 font-semibold">내용</label>
<textarea
name="content"
placeholder="게시물 내용을 입력하세요"
rows={10}
required
className="w-full p-3 border rounded-lg"
/>
</div>
<button
type="submit"
className="w-full bg-green-600 text-white p-3 rounded-lg hover:bg-green-700 font-semibold"
>
게시하기
</button>
</form>
</div>
);
}
핵심 포인트:
- ✅ Server Action을
app/actions/폴더에 모아두면 관리가 편함 - ✅
redirect()로 페이지 이동 가능 - ✅ 여러 페이지에서 같은 액션 재사용 가능
✅ 유효성 검사와 에러 처리
1. Zod로 유효성 검사
1
npm install zod
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// app/actions.js
'use server';
import { z } from 'zod';
import { redirect } from 'next/navigation';
const PostSchema = z.object({
title: z.string().min(3, '제목은 3자 이상이어야 합니다'),
content: z.string().min(10, '내용은 10자 이상이어야 합니다'),
});
export async function createPost(formData) {
// 유효성 검사
const validatedFields = PostSchema.safeParse({
title: formData.get('title'),
content: formData.get('content'),
});
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
};
}
const { title, content } = validatedFields.data;
// 검증된 데이터 출력
console.log('✅ 유효성 검사 통과:', { title, content });
// 실제로는 여기서 데이터베이스에 저장
// 예: await prisma.post.create({ data: { title, content } });
// 시뮬레이션
await new Promise(resolve => setTimeout(resolve, 500));
redirect('/blog');
}
2. 에러 표시하기
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// app/new-post/page.js
'use client';
import { useActionState } from 'react';
import { createPost } from '@/app/actions';
export default function NewPostPage() {
const [state, formAction, isPending] = useActionState(createPost, {});
return (
<form action={formAction}>
<div>
<input name="title" placeholder="제목" />
{state?.errors?.title && (
<p className="text-red-600">{state.errors.title}</p>
)}
</div>
<div>
<textarea name="content" placeholder="내용" />
{state?.errors?.content && (
<p className="text-red-600">{state.errors.content}</p>
)}
</div>
<button disabled={isPending}>
{isPending ? '제출 중...' : '게시'}
</button>
</form>
);
}
🔄 useActionState - 폼 상태 관리
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
'use client';
import { useActionState } from 'react';
async function updateProfile(prevState, formData) {
'use server';
const name = formData.get('name');
// 서버에서 처리 시뮬레이션 (1초 대기)
await new Promise(resolve => setTimeout(resolve, 1000));
// 실제로는 데이터베이스 업데이트
// 예: await prisma.user.update({ where: { id: 1 }, data: { name } });
console.log('✅ 프로필 업데이트:', name);
return { message: '프로필이 업데이트되었습니다!' };
}
export default function ProfileForm() {
const [state, formAction, isPending] = useActionState(updateProfile, {
message: ''
});
return (
<form action={formAction}>
<input name="name" placeholder="이름" />
<button disabled={isPending}>
{isPending ? '저장 중...' : '저장'}
</button>
{state?.message && (
<p className="text-green-600">{state.message}</p>
)}
</form>
);
}
⏳ useFormStatus - 제출 상태 표시
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
'use client';
import { useFormStatus } from 'react-dom';
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button disabled={pending}>
{pending ? (
<span>
<Spinner /> 제출 중...
</span>
) : (
'제출'
)}
</button>
);
}
export default function MyForm({ action }) {
return (
<form action={action}>
<input name="email" type="email" />
<SubmitButton />
</form>
);
}
🔄 Revalidation - 데이터 갱신
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
'use server';
import { revalidatePath, revalidateTag } from 'next/cache';
import { redirect } from 'next/navigation';
export async function createPost(formData) {
const title = formData.get('title');
const content = formData.get('content');
// 실제로는 데이터베이스에 저장하고 ID를 받아옵니다
// 예: const post = await prisma.post.create({ data: { title, content } });
console.log('📝 게시물 생성:', { title, content });
// 시뮬레이션: 생성된 게시물 ID
const newPostId = Date.now();
// 캐시 무효화: /blog 페이지의 캐시를 새로고침
revalidatePath('/blog');
// 태그 기반 캐시 무효화: 'posts' 태그가 붙은 모든 캐시 새로고침
revalidateTag('posts');
// 새로 생성된 포스트로 이동
redirect(`/blog/${newPostId}`);
}
export async function deletePost(id) {
// 실제로는 데이터베이스에서 삭제
// 예: await prisma.post.delete({ where: { id } });
console.log('🗑️ 게시물 삭제:', id);
// /blog 페이지의 캐시 무효화
revalidatePath('/blog');
}
Revalidation이란?
Server Component에서 가져온 데이터는 캐싱됩니다. 빠르지만, 데이터가 변경되어도 화면이 업데이트 안 될 수 있어요.
1
2
3
4
5
6
7
사용자가 /blog 방문
↓
서버에서 게시물 목록 가져오기
↓
결과를 캐시에 저장 (빠른 재방문을 위해)
↓
다음 방문 시 캐시된 데이터 사용 (빠름! ⚡)
문제: 새 게시물을 작성해도 /blog 페이지에 바로 표시 안 됨!
해결: revalidatePath()로 캐시 무효화!
1
2
// 게시물 작성 후
revalidatePath('/blog'); // ← /blog의 캐시를 삭제하고 새로 가져옴!
🔍 자주 묻는 질문 (FAQ)
Q1: Server Actions vs API Routes?
Server Actions (추천):
- ✅ 코드가 간단함
- ✅ TypeScript 타입 안전
- ✅ Progressive Enhancement
1
2
3
4
5
6
7
8
9
10
11
12
13
// Server Action - 간단!
async function submit(formData) {
'use server';
const name = formData.get('name');
console.log('저장:', name);
// await prisma.user.create({ data: { name } });
}
// 폼에서 바로 사용
<form action={submit}>
<input name="name" />
<button>저장</button>
</form>
API Routes:
- ✅ REST API 필요할 때
- ✅ 외부에서 호출할 때 (모바일 앱 등)
- ✅ Webhook 등
1
2
3
4
5
6
7
8
9
10
11
12
13
// app/api/users/route.js
export async function POST(request) {
const data = await request.json();
console.log('API 호출:', data);
// await prisma.user.create({ data });
return Response.json({ success: true });
}
// 사용할 때
fetch('/api/users', {
method: 'POST',
body: JSON.stringify({ name: '홍길동' })
});
언제 뭘 쓸까요?
- 폼 제출: Server Actions (더 간단!)
- 외부 API 제공: API Routes
- 복잡한 로직: 둘 다 가능, 상황에 맞게
Q2: JavaScript 비활성화 시에도 작동하나요?
답: 네! Progressive Enhancement의 장점입니다.
1
2
3
4
5
// JavaScript 없어도 작동!
<form action={serverAction}>
<input name="email" />
<button>제출</button>
</form>
- JS 있을 때: AJAX로 부드럽게 제출
- JS 없을 때: 전통적인 form submit으로 작동
🎯 오늘 배운 내용 정리
- Server Actions
- ‘use server’로 서버 함수 생성
- API Route 불필요
- 간단한 폼 처리
- 유효성 검사
- Zod로 스키마 정의
- safeParse로 검증
- 에러 메시지 표시
- React Hooks
- useActionState: 폼 상태 관리
- useFormStatus: 제출 상태 확인
- Revalidation
- revalidatePath: 경로 갱신
- revalidateTag: 태그 갱신
- redirect: 페이지 이동
📚 시리즈 네비게이션
“Server Actions로 풀스택 개발이 훨씬 쉬워집니다!” 🚀
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.