본문으로 건너뛰기

안동민 개발노트

본문 시작

폼 제출 및 데이터 처리

HTML form을 서버 액션에 연결하고 useFormStatus와 폼 상태 훅으로 제출 대기·결과·오류 UI를 처리합니다.

웹 애플리케이션에서 폼(Form)은 사용자 입력을 받아 서버로 전송하고 처리하는 핵심적인 요소입니다.

사용자 등록, 게시물 작성, 설정 변경 등 대부분의 중요한 상호작용은 폼을 통해 이루어집니다.

Next.js App Router는 서버 액션(Server Actions)과 결합하여 폼 제출 및 데이터 처리 과정을 이전보다 훨씬 간결하고 효율적으로 만들었습니다.

이 절에서는 formaction, useFormStatus와 React 19의 useActionState를 중심으로 폼 처리를 다룹니다.

데이터 저장은 앞 절의 로컬 단일 프로세스 배열을 그대로 사용하므로 폼 상태 흐름만 확인합니다.

운영 환경에서는 생성·조회 액션을 데이터베이스에 연결해야 재시작과 여러 서버 인스턴스에서도 결과가 유지됩니다.

폼은 입력부터 서버 결과까지 이어지는 상태 경계다

사용자가 다음 행동을 결정할 수 있도록 제출 대기와 검증 결과를 화면에 명시한다.

  1. Input

    name·required로 전송할 값과 기본 검증 정의

  2. Submit

    form action이 Server Function 호출

  3. Pending

    useFormStatus로 진행 중과 중복 클릭 표시

  4. Server result

    검증 오류·성공 메시지를 직렬화해 반환

  5. Next UI

    itemActions가 /server-action·/form-submit을 모두 재검증한 뒤 useActionState 결과 표시


HTML form 요소와 서버 액션의 결합

Next.js App Router의 주요 변화 중 하나는 HTML <form> 요소의 표준 action 속성에 서버 액션을 직접 할당할 수 있게 되었다는 점입니다.

이를 사용하면 클라이언트 측 JavaScript 코드를 줄이면서 폼 제출을 처리할 수 있습니다.

기본적인 폼 제출 과정

사용자가 폼에 데이터를 입력하고 제출 버튼을 클릭합니다.

브라우저는 폼의 action 속성에 지정된 서버 액션을 호출합니다.

서버 액션은 폼 데이터(FormData 객체)를 자동으로 인자로 받습니다.

서버 액션은 서버에서 실행되어 데이터를 처리하고 필요한 작업을 수행합니다.

서버 액션이 데이터 변경 뒤 revalidatePath, revalidateTag(tag, 'max'), updateTag 중 목적에 맞는 API를 명시적으로 호출하면 해당 캐시가 갱신됩니다.

실습: 간단한 할 일(Todo) 추가 폼

이전 itemActions.ts 파일을 재활용하여 할 일을 추가하는 폼을 만들어보겠습니다.

src/app/form-submit/page.tsx (서버 컴포넌트)
import { createItem, getItems } from '../actions/itemActions'; // 서버 액션 임포트

export const dynamic = 'force-dynamic'; // 요청마다 동적으로 렌더링하도록 지정

export default async function TodoPage() {
  const todos = await getItems(); // 서버에서 현재 할 일 목록을 가져옵니다.

  return (
    <div style={{ padding: '20px', maxWidth: '700px', margin: '20px auto', border: '1px solid #007bff', borderRadius: '10px', boxShadow: '0 4px 8px rgba(0,0,0,0.1)' }}>
      <h1 style={{ textAlign: 'center', color: '#007bff', marginBottom: '20px' }}>할 일 목록 (폼 제출 예제)</h1>

      <h2 style={{ color: '#333', marginBottom: '15px' }}>현재 할 일</h2>
      {todos.length > 0 ? (
        <ul style={{ listStyleType: 'decimal', paddingLeft: '20px', marginBottom: '30px' }}>
          {todos.map(todo => (
            <li key={todo.id} style={{ marginBottom: '8px', fontSize: '1.1em' }}>
              {todo.name}
            </li>
          ))}
        </ul>
      ) : (
        <p style={{ marginBottom: '30px', fontStyle: 'italic' }}>아직 할 일이 없습니다. 새 할 일을 추가해보세요!</p>
      )}

      <hr style={{ margin: '30px 0', borderColor: '#eee' }} />

      <h2 style={{ color: '#333', marginBottom: '15px' }}>새 할 일 추가</h2>
      {/* 폼의 action 속성에 서버 액션을 직접 바인딩 */}
      <form action={createItem} style={{ padding: '20px', border: '1px solid #ccc', borderRadius: '8px' }}>
        <label htmlFor="todoName" style={{ display: 'block', marginBottom: '10px', fontWeight: 'bold' }}>할 일 내용:</label>
        <input
          type="text"
          id="todoName"
          name="itemName"
          required
          placeholder="예: Next.js 폼 처리 배우기"
          style={{ width: 'calc(100% - 20px)', padding: '10px', marginBottom: '15px', borderRadius: '5px', border: '1px solid #ddd' }}
        />
        <button type="submit" style={{
          padding: '10px 20px',
          backgroundColor: '#28a745',
          color: 'white',
          border: 'none',
          borderRadius: '5px',
          cursor: 'pointer',
          transition: 'background-color 0.3s'
        }}>
          할 일 추가
        </button>
      </form>
    </div>
  );
}
실습 확인

src/app/form-submit 폴더를 만들고 그 안에 page.tsx 파일을 위 내용으로 생성합니다. (이전 2절의 src/app/actions/itemActions.ts 파일이 필요합니다.)

개발 서버(npm run dev)를 실행한 후, http://localhost:3000/form-submit으로 접속합니다.

할 일 내용 입력 필드에 새로운 할 일을 입력하고 할 일 추가 버튼을 클릭합니다.

  • 페이지가 새로고침되면서 itemActions.tscreateItem 서버 액션이 실행되고, 터미널에 로그가 찍힙니다.
  • 폼 데이터가 성공적으로 처리되면, revalidatePath('/form-submit')에 의해 현재 페이지의 캐시가 재검증되어 최신 할 일 목록이 화면에 반영됩니다.

폼 상태 관리: useFormStatus

서버 액션 폼은 idle에서 결과까지 상태를 순환한다

pending 표시와 서버 결과를 분리하면 중복 제출과 조용한 실패를 줄일 수 있다.

  1. SUBMIT
    Idle → Pending

    버튼을 잠그고 입력값을 서버로 전달

  2. VALID
    Pending → Saving

    서버가 권한·schema를 검증하고 변경

  3. INVALID
    Saving → Result

    직렬화한 필드 오류와 입력을 다시 표시

  4. SUCCESS
    Saving → Idle

    revalidatePath·updateTag·revalidateTag(tag, 'max') 중 목적에 맞는 API를 호출한 뒤 복귀

폼이 제출되는 동안 사용자에게 피드백을 제공하는 것은 좋은 사용자 경험의 핵심입니다.

Next.js와 React DOM은 폼의 제출 상태를 추적할 수 있는 훅인 useFormStatus를 제공합니다.

이 훅은 클라이언트 컴포넌트에서만 사용할 수 있습니다.

useFormStatus 훅은 폼 제출 상태를 나타내는 객체를 반환합니다.

가장 유용한 속성은 pending으로, 폼이 제출 중일 때 true가 됩니다.

useFormStatus 사용법
src/app/form-submit/SubmitButton.tsx (새로 생성할 클라이언트 컴포넌트)
"use client";

import { useFormStatus } from 'react-dom'; // 'react-dom'에서 임포트

export default function SubmitButton() {
  const { pending } = useFormStatus(); // 폼의 제출 상태를 가져옵니다.

  return (
    <button
      type="submit"
      disabled={pending}
      style={{
        padding: '10px 20px',
        backgroundColor: pending ? '#a0a0a0' : '#28a745', // 제출 중일 때 색상 변경
        color: 'white',
        border: 'none',
        borderRadius: '5px',
        cursor: pending ? 'not-allowed' : 'pointer',
        transition: 'background-color 0.3s'
      }}
    >
      {pending ? '추가 중...' : '할 일 추가'} {/* 제출 중일 때 텍스트 변경 */}
    </button>
  );
}
page.tsxSubmitButton 적용
src/app/form-submit/page.tsx (기존 파일 수정)
import SubmitButton from './SubmitButton'; // SubmitButton 임포트

export default async function TodoPage() {
  // ... (생략) ...

  return (
    <div style={{ padding: '20px', maxWidth: '700px', margin: '20px auto', border: '1px solid #007bff', borderRadius: '10px', boxShadow: '0 4px 8px rgba(0,0,0,0.1)' }}>
      {/* ... (생략) ... */}

      <h2 style={{ color: '#333', marginBottom: '15px' }}>새 할 일 추가</h2>
      <form action={createItem} style={{ padding: '20px', border: '1px solid #ccc', borderRadius: '8px' }}>
        <label htmlFor="todoName" style={{ display: 'block', marginBottom: '10px', fontWeight: 'bold' }}>할 일 내용:</label>
        <input
          type="text"
          id="todoName"
          name="itemName"
          required
          placeholder="예: Next.js 폼 처리 배우기"
          style={{ width: 'calc(100% - 20px)', padding: '10px', marginBottom: '15px', borderRadius: '5px', border: '1px solid #ddd' }}
        />
        {/* SubmitButton 컴포넌트 사용 */}
        <SubmitButton />
      </form>
    </div>
  );
}

실습 확인: http://localhost:3000/form-submit에서 폼을 제출할 때 할 일 추가 버튼의 텍스트와 스타일이 제출 중에는 추가 중...으로 바뀌고 비활성화되는 것을 확인할 수 있습니다.

이는 서버 액션이 실행되는 동안 사용자에게 시각적인 피드백을 제공합니다.


폼 상태 및 결과 처리: useActionState

단순히 폼 제출 상태뿐 아니라, 폼 제출 후 서버 액션의 결과(예: 성공/실패 메시지, 유효성 검사 에러)를 클라이언트 컴포넌트에서 받아 UI에 반영해야 할 때가 있습니다.

이때 React 19의 useActionState을 사용합니다.

useActionState는 액션의 반환값을 다음 렌더의 상태로 연결합니다.

이 훅 또한 클라이언트 컴포넌트에서만 사용할 수 있습니다.

useActionState는 액션 함수와 초기 상태를 받습니다.

액션 함수: 폼 제출 시 호출될 서버 액션 함수.

초기 상태: 폼 상태의 초기값.

상태, 폼에 연결할 액션과 대기 여부를 배열로 반환합니다.

상태 값: 현재 폼의 상태 (서버 액션의 반환값).

새로운 액션 함수: 이 함수를 formaction으로 사용해야 합니다.

대기 상태: 액션 실행 중에는 true가 됩니다.

실습: 할 일 추가 폼에 결과 메시지 표시

할 일 추가 후 성공·실패 메시지를 폼 아래에 표시하도록 useActionState를 활용해 보겠습니다.

src/app/form-submit/TodoForm.tsx (새로 생성할 클라이언트 컴포넌트): useActionState 훅을 사용하여 폼의 상태와 메시지를 관리합니다.

src/app/form-submit/TodoForm.tsx
"use client";

import { useFormStatus } from 'react-dom';
import { useActionState, useEffect, useRef } from 'react';

// SubmitButton 컴포넌트 (이전과 동일)
function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending} style={{
      padding: '10px 20px',
      backgroundColor: pending ? '#a0a0a0' : '#28a745',
      color: 'white',
      border: 'none',
      borderRadius: '5px',
      cursor: pending ? 'not-allowed' : 'pointer',
      transition: 'background-color 0.3s'
    }}>
      {pending ? '추가 중...' : '할 일 추가'}
    </button>
  );
}

interface TodoFormState {
  success: boolean;
  message: string;
}

interface TodoFormProps {
  createItemAction: (
    prevState: TodoFormState,
    formData: FormData,
  ) => Promise<TodoFormState>;
}

export default function TodoForm({ createItemAction }: TodoFormProps) {
  // useActionState 훅 사용:
  // - [state, formAction, isPending]: 상태, 폼 액션, 실행 여부
  // - createItemAction: 서버 액션 함수
  // - { success: false, message: '' }: 초기 폼 상태
  const [state, formAction, isPending] = useActionState(createItemAction, {
    success: false,
    message: '',
  });
  const formRef = useRef<HTMLFormElement>(null);

  // 폼 제출 성공 시 입력 필드를 초기화합니다.
  useEffect(() => {
    if (state.success) {
      formRef.current?.reset();
    }
  }, [state.success]);

  return (
    <form
      ref={formRef}
      action={formAction}
      aria-busy={isPending}
      style={{ padding: '20px', border: '1px solid #ccc', borderRadius: '8px' }}
    >
      <label htmlFor="todoName" style={{ display: 'block', marginBottom: '10px', fontWeight: 'bold' }}>할 일 내용:</label>
      <input
        type="text"
        id="todoName"
        name="itemName"
        required
        placeholder="예: Next.js 폼 처리 배우기"
        style={{ width: 'calc(100% - 20px)', padding: '10px', marginBottom: '15px', borderRadius: '5px', border: '1px solid #ddd' }}
      />
      <SubmitButton />

      {/* 서버 액션 결과 메시지 표시 */}
      {state.message && (
        <p style={{
          marginTop: '15px',
          color: state.success ? '#28a745' : '#dc3545',
          fontWeight: 'bold'
        }}>
          {state.message}
        </p>
      )}
    </form>
  );
}

src/app/form-submit/page.tsx (기존 파일 수정): <form> 태그를 TodoForm 컴포넌트로 대체합니다.

src/app/form-submit/page.tsx
// ...
import { createItemWithState, getItems } from '../actions/itemActions';
import TodoForm from './TodoForm'; // TodoForm 임포트

export const dynamic = 'force-dynamic';

export default async function TodoPage() {
  const todos = await getItems();

  return (
    <div style={{ padding: '20px', maxWidth: '700px', margin: '20px auto', border: '1px solid #007bff', borderRadius: '10px', boxShadow: '0 4px 8px rgba(0,0,0,0.1)' }}>
      <h1 style={{ textAlign: 'center', color: '#007bff', marginBottom: '20px' }}>할 일 목록 (폼 제출 예제)</h1>

      <h2 style={{ color: '#333', marginBottom: '15px' }}>현재 할 일</h2>
      {todos.length > 0 ? (
        <ul style={{ listStyleType: 'decimal', paddingLeft: '20px', marginBottom: '30px' }}>
          {todos.map(todo => (
            <li key={todo.id} style={{ marginBottom: '8px', fontSize: '1.1em' }}>
              {todo.name}
            </li>
          ))}
        </ul>
      ) : (
        <p style={{ marginBottom: '30px', fontStyle: 'italic' }}>아직 할 일이 없습니다. 새 할 일을 추가해보세요!</p>
      )}

      <hr style={{ margin: '30px 0', borderColor: '#eee' }} />

      <h2 style={{ color: '#333', marginBottom: '15px' }}>새 할 일 추가</h2>
      {/* TodoForm 컴포넌트 사용 */}
      <TodoForm createItemAction={createItemWithState} />
    </div>
  );
}

참고: 직접 <form action={createItem}>에 연결하는 기존 액션은 FormData 하나만 받습니다.

useActionState에는 이전 상태를 첫 인자로 받는 별도 어댑터 액션을 연결해 두 사용 방식을 함께 유지합니다.

첫 번째 prevState 인자는 useActionState가 자동으로 전달하는 이전 상태입니다.

src/app/actions/itemActions.ts (추가)
interface TodoFormState {
  success: boolean;
  message: string;
}

export async function createItemWithState(
  _previousState: TodoFormState,
  formData: FormData,
): Promise<TodoFormState> {
  return createItemWithResult(formData);
}

실습 확인: http://localhost:3000/form-submit에서 폼을 제출할 때, 할 일 추가 버튼 아래에 성공/실패 메시지가 표시되는 것을 확인합니다.

입력 필드가 비어있을 때는 에러 메시지가, 유효한 입력일 때는 성공 메시지가 나타날 것입니다.

폼 제출 성공 시 입력 필드도 자동으로 초기화됩니다.

폼 action은 서버 신뢰 경계를 여는 POST다

클라이언트 표시보다 서버의 인증·검증·변경·재검증 순서가 데이터 무결성을 결정한다.

  1. 입력
    FormData

    name이 있는 필드를 서버로 전달

  2. 경계
    인증·인가

    직접 POST될 수 있으므로 사용자와 권한 재확인

  3. 검증
    Schema

    형식과 업무 규칙을 통과한 값만 남김

  4. 변경
    Mutation

    DB와 외부 시스템에 검증된 값만 반영

  5. 갱신
    캐시 동기화

    path·즉시 tag·SWR tag에 맞는 API를 호출하고 결과로 연결


폼 처리 워크플로우 요약

Next.js App Router의 폼 제출 및 데이터 처리 워크플로우는 다음과 같이 정리할 수 있습니다.

서버 액션 정의: 데이터를 처리할 서버 측 로직을 itemActions.ts와 같이 별도의 파일 또는 특정 함수에 "use server" 지시어를 사용하여 정의합니다.

이 함수는 FormData를 인자로 받거나, useActionState와 함께 사용될 경우 (prevState, formData) 시그니처를 가집니다.

폼 렌더링: 서버 컴포넌트에서 <form> 요소를 렌더링하고, action 속성에 서버 액션 함수를 직접 바인딩합니다.

상태 및 피드백 (선택 사항, 클라이언트 컴포넌트)
  • useFormStatus: 폼 제출 중 로딩 상태를 UI에 표시할 때 사용합니다. 제출 버튼을 비활성화하거나 스피너를 보여주는 등에 활용됩니다.
  • useActionState: 서버 액션의 결과와 대기 상태를 클라이언트 UI에 연결합니다. 검증 오류 표시와 성공 후 폼 초기화에 사용할 수 있습니다.

명시적 재검증: 경로는 revalidatePath, 태그의 SWR 갱신은 revalidateTag(tag, 'max'), 같은 Server Action에서 즉시 최신 값을 읽어야 할 때는 updateTag를 사용합니다.

이러한 폼 처리 방식은 개발 복잡성을 줄이고, 성능을 최적화하며, 사용자에게 더 나은 경험을 제공합니다.

클라이언트-서버 간의 명시적인 Route Handler 호출 없이도 데이터 변경 로직을 구현할 수 있다는 점이 Next.js App Router의 장점입니다.

폼 피드백은 제출 전·중·후·동기화까지 이어진다

각 단계에서 사용자가 현재 상태와 다음 행동을 알 수 있게 한다.

  1. before
    입력 검증

    required·pattern으로 누락과 형식 오류를 먼저 안내

  2. pending
    진행 표시

    중복 클릭을 막고 버튼 문구를 바꿈

  3. result
    서버 결과

    필드 오류와 성공 메시지를 폼 가까이에 표시

  4. sync
    목록 갱신

    공유 저장소를 읽는 /server-action과 /form-submit을 모두 revalidatePath로 동기화

폼을 완성할 때는 제출 전 검증, 제출 중 피드백, 서버 처리, 캐시 갱신, 배포 후 장애 대응까지 하나의 운영 흐름으로 묶어 확인합니다.

폼 제출은 중복·오류·재검증·복구까지 운영 기준으로 닫는다

성공 한 번만 확인하지 않고 느린 네트워크와 반복 제출에서도 데이터가 일관적인지 검증한다.

  1. G1
    입력 gate

    client 안내와 server schema 검증 일치

  2. G2
    권한 gate

    대상 resource에 대한 현재 사용자 권한 확인

  3. G3
    중복 gate

    pending 잠금과 idempotency 정책 적용

  4. G4
    cache gate

    변경된 목록·상세의 무효화 범위 확인

  5. G5
    회복 gate

    필드 오류·서버 실패·재시도 경로 제공

폼 제출은 복구 가능한 상태 전이로 설계한다

draft·pending·검증 실패·성공·예상 밖 실패를 분리하면 중복 저장과 길 잃은 오류를 막을 수 있다.

  1. SUBMIT
    Draft → Pending

    입력을 보존하며 반복 제출 UI를 막음

  2. INVALID
    Pending → Invalid

    필드 오류를 값 가까이에 연결하고 재입력 허용

  3. COMMIT
    Pending → Success

    재검증된 결과·메시지·redirect로 종료

  4. THROW
    Pending → Failure

    예상 밖 예외는 오류 경계와 관측 시스템으로 전달

마지막으로 HTML form, 서버 액션, useFormStatus, useActionState가 폼 제출 경험을 어떻게 나누는지 정리합니다.

form과 상태 훅은 한 제출 흐름의 서로 다른 경계다

form은 전송을 맡고 상태 훅은 필요한 클라이언트 리프에 pending과 결과만 보탠다.

  1. 1
    form action

    FormData로 Server Function을 POST 호출

  2. 2
    useFormStatus

    부모 form의 pending을 제출 버튼에서 읽음

  3. 3
    Server Action

    인증·검증·변경을 서버에서 실행

  4. 4
    useActionState

    검증 오류와 성공 결과를 UI state로 연결

  5. 5
    새 서버 UI

    revalidatePath·updateTag·revalidateTag(tag, 'max') 중 목적에 맞는 API를 호출한 뒤 결과 표시