React 19 Server Actions와 form 처리 패턴 정리
배경
Next.js 15와 React 19를 새 프로젝트에 도입하면서 Server Actions를 본격적으로 사용하게 됐다. 기존에는 form 제출 시 API 라우트를 호출하는 패턴을 썼는데, Server Actions로 전환하면서 몇 가지 패턴을 정리할 필요가 있었다.
기본 패턴
가장 간단한 형태는 이렇다.
// app/actions.ts
'use server'
export async function submitForm(formData: FormData) {
const email = formData.get('email') as string
const result = await db.user.create({ data: { email } })
return { success: true, userId: result.id }
}
// app/page.tsx
import { submitForm } from './actions'
export default function Page() {
return (
<form action={submitForm}>
<input name="email" type="email" required />
<button type="submit">Submit</button>
</form>
)
}
useFormStatus와 pending 상태
제출 중 상태 처리는 useFormStatus 훅으로 해결된다.
'use client'
import { useFormStatus } from 'react-dom'
function SubmitButton() {
const { pending } = useFormStatus()
return (
<button type="submit" disabled={pending}>
{pending ? '처리 중...' : '제출'}
</button>
)
}
검증과 에러 처리
zod와 조합하면 타입 안전한 검증이 가능하다.
'use server'
import { z } from 'zod'
const schema = z.object({
email: z.string().email(),
})
export async function submitForm(formData: FormData) {
const parsed = schema.safeParse({
email: formData.get('email'),
})
if (!parsed.success) {
return { error: parsed.error.flatten() }
}
// 처리 로직
}
실무에서 느낀 점
장점:
- API 라우트 파일이 줄어들어 구조가 단순해짐
- form과 로직이 가까워져 코드 파악이 쉬움
- 자동 직렬화로 Date, BigInt 등 처리가 편함
주의점:
- Server Action은 POST만 가능. GET 요청이 필요하면 여전히 API 라우트 필요
- 반환값이 직렬화 가능해야 함 (함수, Symbol 등 불가)
- 에러 처리를 명시적으로 해야 함. throw하면 500 에러로 노출됨
아직 모든 form을 전환하진 않았지만, 점진적으로 마이그레이션 중이다. 특히 간단한 CRUD 작업에서는 확실히 코드량이 줄어드는 효과가 있었다.