React 19 Server Actions와 useTransition 실전 적용기

배경

사내 관리자 대시보드를 Next.js 15 기반으로 마이그레이션하면서 React 19의 Server Actions를 본격적으로 도입했다. 기존에는 폼 제출 시 API 라우트를 호출하고 클라이언트에서 로딩 상태를 관리했는데, Server Actions로 전환하면서 코드량이 눈에 띄게 줄었다.

기존 방식의 문제점

// 기존 코드
const [isLoading, setIsLoading] = useState(false);

const handleSubmit = async (e: FormEvent) => {
  e.preventDefault();
  setIsLoading(true);
  try {
    const response = await fetch('/api/users', {
      method: 'POST',
      body: JSON.stringify(formData)
    });
    // 에러 처리, 성공 처리...
  } finally {
    setIsLoading(false);
  }
};

매번 로딩 상태를 직접 관리하고, API 라우트 파일을 별도로 만들어야 했다.

Server Actions 적용

// actions.ts
'use server';

export async function createUser(formData: FormData) {
  const name = formData.get('name');
  await db.user.create({ data: { name } });
  revalidatePath('/users');
}

// component.tsx
'use client';

import { useTransition } from 'react';
import { createUser } from './actions';

export function UserForm() {
  const [isPending, startTransition] = useTransition();

  const handleSubmit = (formData: FormData) => {
    startTransition(() => createUser(formData));
  };

  return (
    <form action={handleSubmit}>
      <input name="name" disabled={isPending} />
      <button disabled={isPending}>
        {isPending ? '생성 중...' : '생성'}
      </button>
    </form>
  );
}

얻은 이점

  1. 코드 간소화: API 라우트 없이 서버 로직을 직접 작성할 수 있다
  2. 타입 안정성: 클라이언트-서버 간 타입이 자연스럽게 공유된다
  3. 자동 로딩 상태: useTransition의 isPending으로 로딩 처리가 간결해진다
  4. Revalidation 통합: revalidatePath로 캐시 갱신이 명확하다

주의할 점

  • 민감한 로직은 서버에서 권한 검증 필수
  • FormData 기반이라 복잡한 객체 전달 시 직렬화 고려
  • 에러 처리는 try-catch와 useActionState 조합 권장

아직 러닝커브가 있지만, 폼 위주 화면에서는 확실히 생산성이 올라간다.