본문으로 건너뛰기
Advertisement

11.3 Server Actions 타입 — "use server"와 폼 액션

Server Actions란?

Server Actions는 서버에서 실행되는 비동기 함수입니다. 클라이언트에서 서버 로직을 직접 호출할 수 있어 별도의 API 라우트가 필요 없습니다.

// app/actions.ts
'use server';

import { db } from '@/lib/db';
import { revalidatePath } from 'next/cache';

export async function createPost(title: string, content: string) {
const post = await db.post.create({
data: { title, content },
});
revalidatePath('/blog');
return post;
}

폼과 Server Actions

기본 폼 액션

// app/actions.ts
'use server';

import { redirect } from 'next/navigation';
import { z } from 'zod';

const CreatePostSchema = z.object({
title: z.string().min(1, '제목은 필수입니다').max(100),
content: z.string().min(10, '내용은 10자 이상이어야 합니다'),
published: z.boolean().optional(),
});

type CreatePostInput = z.infer<typeof CreatePostSchema>;

interface ActionResult {
success: boolean;
error?: string;
fieldErrors?: Partial<Record<keyof CreatePostInput, string[]>>;
}

export async function createPost(
prevState: ActionResult | null,
formData: FormData
): Promise<ActionResult> {
// FormData 파싱
const rawData = {
title: formData.get('title'),
content: formData.get('content'),
published: formData.get('published') === 'on',
};

// 유효성 검사
const result = CreatePostSchema.safeParse(rawData);
if (!result.success) {
return {
success: false,
fieldErrors: result.error.flatten().fieldErrors,
};
}

try {
await db.post.create({ data: result.data });
revalidatePath('/blog');
redirect('/blog');
} catch (error) {
return {
success: false,
error: '게시글 생성에 실패했습니다.',
};
}
}

useActionState (React 19 / Next.js 15+)

// app/blog/new/page.tsx
'use client';

import { useActionState } from 'react';
import { createPost } from '../actions';

export default function NewPostPage() {
const [state, formAction, isPending] = useActionState(createPost, null);

return (
<form action={formAction}>
<div>
<label>제목</label>
<input name="title" required />
{state?.fieldErrors?.title && (
<span className="error">{state.fieldErrors.title[0]}</span>
)}
</div>

<div>
<label>내용</label>
<textarea name="content" rows={10} />
{state?.fieldErrors?.content && (
<span className="error">{state.fieldErrors.content[0]}</span>
)}
</div>

<label>
<input type="checkbox" name="published" />
즉시 발행
</label>

{state?.error && (
<div className="error-banner">{state.error}</div>
)}

<button type="submit" disabled={isPending}>
{isPending ? '저장 중...' : '저장'}
</button>
</form>
);
}

useFormStatus

폼 제출 상태를 자식 컴포넌트에서 접근합니다.

// components/SubmitButton.tsx
'use client';

import { useFormStatus } from 'react-dom';

interface SubmitButtonProps {
label?: string;
loadingLabel?: string;
}

export function SubmitButton({
label = '저장',
loadingLabel = '저장 중...',
}: SubmitButtonProps) {
const { pending } = useFormStatus();

return (
<button type="submit" disabled={pending}>
{pending ? loadingLabel : label}
</button>
);
}

// 사용 — 폼 안에 있어야 동작
<form action={serverAction}>
<input name="name" />
<SubmitButton label="제출" />
</form>

프로그래머틱 Server Action 호출

'use client';

import { useState } from 'react';
import { deletePost } from '@/app/actions';

function DeleteButton({ postId }: { postId: string }) {
const [isDeleting, setIsDeleting] = useState(false);

const handleDelete = async () => {
if (!confirm('정말 삭제하시겠습니까?')) return;

setIsDeleting(true);
try {
await deletePost(postId); // Server Action 직접 호출
} catch (error) {
alert('삭제에 실패했습니다.');
} finally {
setIsDeleting(false);
}
};

return (
<button onClick={handleDelete} disabled={isDeleting}>
{isDeleting ? '삭제 중...' : '삭제'}
</button>
);
}

Server Action 타입 패턴

제네릭 액션 결과 타입

// types/actions.ts
export type ActionState<T = void> =
| { status: 'idle' }
| { status: 'success'; data: T }
| { status: 'error'; message: string; fieldErrors?: Record<string, string[]> };

// 사용
export async function updateProfile(
prevState: ActionState<User>,
formData: FormData
): Promise<ActionState<User>> {
// ...
}

파일 업로드 액션

// actions/upload.ts
'use server';

export async function uploadAvatar(
prevState: { url: string | null; error: string | null },
formData: FormData
) {
const file = formData.get('avatar') as File | null;

if (!file || file.size === 0) {
return { url: null, error: '파일을 선택해주세요.' };
}

if (file.size > 5 * 1024 * 1024) {
return { url: null, error: '파일 크기는 5MB 이하이어야 합니다.' };
}

if (!file.type.startsWith('image/')) {
return { url: null, error: '이미지 파일만 업로드 가능합니다.' };
}

try {
const url = await uploadToStorage(file);
return { url, error: null };
} catch {
return { url: null, error: '업로드에 실패했습니다.' };
}
}

고수 팁

1. Server Actions에서 인증 검사

'use server';

import { auth } from '@/lib/auth';

export async function protectedAction(formData: FormData) {
const session = await auth();

if (!session?.user) {
throw new Error('인증이 필요합니다.');
}

// 권한 검사
if (session.user.role !== 'admin') {
throw new Error('관리자 권한이 필요합니다.');
}

// 실제 로직
}

2. 낙관적 업데이트 (useOptimistic)

'use client';

import { useOptimistic, useActionState } from 'react';

function TodoList({ todos }: { todos: Todo[] }) {
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(state: Todo[], newTodo: Todo) => [...state, newTodo]
);

const handleAdd = async (formData: FormData) => {
const title = formData.get('title') as string;

// 즉시 UI 업데이트 (서버 응답 전)
addOptimisticTodo({ id: 'temp', title, completed: false });

// 서버에 저장
await createTodo(formData);
};

return (
<div>
<form action={handleAdd}>
<input name="title" />
<button type="submit">추가</button>
</form>
<ul>
{optimisticTodos.map(todo => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
</div>
);
}
Advertisement