포스트

[이제와서 시작하는 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으로 작동

🎯 오늘 배운 내용 정리

  1. Server Actions
    • ‘use server’로 서버 함수 생성
    • API Route 불필요
    • 간단한 폼 처리
  2. 유효성 검사
    • Zod로 스키마 정의
    • safeParse로 검증
    • 에러 메시지 표시
  3. React Hooks
    • useActionState: 폼 상태 관리
    • useFormStatus: 제출 상태 확인
  4. Revalidation
    • revalidatePath: 경로 갱신
    • revalidateTag: 태그 갱신
    • redirect: 페이지 이동

📚 시리즈 네비게이션


“Server Actions로 풀스택 개발이 훨씬 쉬워집니다!” 🚀

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.