안동민 개발노트

안동민 개발노트

React 상태 관리 기초서버 액션폼 제출 및 데이터 처리상태 관리 도구
본문 시작
  1. 홈
  2. 문서
  3. Next.js
  4. 8장 : 상태 관리 및 폼 처리
  5. 폼 제출 및 데이터 처리
  1. Next.js
  2. 폼 제출 및 데이터 처리

폼 제출 및 데이터 처리

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

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

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

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

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

데이터 저장은 앞 절의 로컬 단일 프로세스 배열을 그대로 사용하므로 폼 상태 흐름만 확인합니다. force-dynamic을 사용하는 예제는 Cache Components를 활성화하지 않은 구성을 전제로 합니다.

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


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.ts의 createItem 서버 액션이 실행되고 터미널에 로그가 찍힙니다. JavaScript가 준비된 상태에서는 화면 전체 새로고침 없이 갱신될 수 있으므로 목록 갱신과 문서 재로딩을 구분합니다.
  • 폼 데이터가 성공적으로 처리되면, revalidatePath('/form-submit')에 의해 현재 페이지의 캐시가 재검증되어 최신 할 일 목록이 화면에 반영됩니다.

폼 상태 관리: useFormStatus 훅

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

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

이 훅은 클라이언트 컴포넌트에서 사용하며, 해당 컴포넌트가 부모 form 안에 렌더링되어야 합니다. form을 만드는 같은 컴포넌트에서 호출하면 그 form의 제출 상태를 읽지 못합니다.

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.tsx에 SubmitButton 적용

다음 코드는 교체 위치를 보여 주는 발췌입니다. 앞 예제의 createItem·getItems import와 페이지 나머지 내용은 유지합니다.

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는 액션 함수와 초기 상태를 받습니다.

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

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

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

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

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

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

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

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

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

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

import { useFormStatus } from 'react-dom';
import { useActionState } 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: '',
  });
  // 함수형 form action의 정상 완료 뒤 비제어 입력은 React가 초기화합니다.

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

비어 있는 입력은 required가 브라우저에서 제출을 막습니다. 서버의 이름 검증을 확인하려면 공백만 입력합니다. 공백은 서버의 trim 뒤 빈 값이 되어 실패 메시지가 반환되고, 유효한 이름은 성공 메시지를 반환합니다.

함수형 form action이 정상 반환하면 React가 비제어 입력을 초기화합니다. 이 기준은 반환 객체의 success 값과 다르므로 검증 실패값을 정상 반환한 경우에도 입력이 초기화될 수 있습니다. 입력 보존이 필요하면 제어 입력이나 반환한 필드값으로 별도 설계합니다. React form 문서를 기준으로 확인합니다.


폼 처리 워크플로우 요약

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

예제를 확인할 때는 다음 결과를 나누어 봅니다.

  • 빈 값: 브라우저 required 검증이 요청을 막는지 확인합니다.
  • 공백만 입력: 서버 trim 검증의 실패 메시지와 입력 초기화를 확인합니다.
  • 유효한 이름: pending 표시, 반환 메시지와 목록 갱신을 확인합니다.
  • 연속 제출: 버튼 비활성화는 현재 UI의 대기 표시이며, 서버의 중복 요청 방지 구현은 이 예제에 없습니다.
  • 프로세스 재시작: 메모리 배열의 수명과 지속 저장의 차이를 확인합니다.

서버 액션

이전 페이지

상태 관리 도구

다음 페이지

이 페이지의 목차

HTML form 요소와 서버 액션의 결합폼 상태 관리: useFormStatus 훅폼 상태 및 결과 처리: useActionState 훅폼 처리 워크플로우 요약