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 작업에서는 확실히 코드량이 줄어드는 효과가 있었다.