본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
8장 : 상태 관리 및 폼 처리

폼 제출 및 데이터 처리

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

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

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

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

이 절에서는 formaction, useFormStatus와 React 19의 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

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

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에서 폼을 제출할 때, 할 일 추가 버튼 아래에 성공/실패 메시지가 표시되는 것을 확인합니다.

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

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


폼 처리 워크플로우 요약

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의 장점입니다.

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

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