icon
8장 : 상태 관리 및 폼 처리

폼 제출 및 데이터 처리

웹 애플리케이션에서 폼(Form) 은 사용자 입력을 받아 서버로 전송하고 처리하는 핵심적인 요소입니다. 사용자 등록, 게시물 작성, 설정 변경 등 대부분의 중요한 상호작용은 폼을 통해 이루어집니다. Next.js App Router는 서버 액션(Server Actions) 과 결합하여 폼 제출 및 데이터 처리 과정을 이전보다 훨씬 간결하고 효율적으로 만들었습니다.

이 절에서는 Next.js App Router 환경에서 폼을 제출하고 데이터를 처리하는 방법을 form 요소의 action 속성, useFormStatus 훅, 그리고 useFormState 훅을 중심으로 상세히 다루겠습니다.


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

Next.js App Router의 가장 큰 혁신 중 하나는 HTML <form> 요소의 표준 action 속성에 서버 액션을 직접 할당할 수 있게 되었다는 점입니다. 이는 클라이언트 측 JavaScript 코드를 최소화하면서 폼 제출을 처리할 수 있게 해줍니다.

기본적인 폼 제출 과정

  1. 사용자가 폼에 데이터를 입력하고 제출 버튼을 클릭합니다.
  2. 브라우저는 폼의 action 속성에 지정된 서버 액션을 호출합니다.
  3. 서버 액션은 폼 데이터(FormData 객체)를 자동으로 인자로 받습니다.
  4. 서버 액션은 서버에서 실행되어 데이터를 처리하고 필요한 작업을 수행합니다.
  5. 서버 액션이 완료되면, Next.js는 revalidatePath, revalidateTag 등을 통해 캐시를 자동으로 재검증하여 UI를 최신 상태로 만듭니다.

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

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

src/app/form-submit/page.tsx
// src/app/form-submit/page.tsx (서버 컴포넌트)

import { createItem, getItems } from '../actions/itemActions'; // 서버 액션 임포트

export const dynamic = 'force-dynamic'; // 항상 SSR로 동작하여 최신 목록을 가져오도록 강제

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" // FormData에서 이 이름으로 값을 가져옵니다.
          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>
  );
}

실습 확인

  1. src/app/form-submit 폴더를 만들고 그 안에 page.tsx 파일을 위 내용으로 생성합니다. (이전 2절의 src/app/actions/itemActions.ts 파일이 필요합니다.)
  2. 개발 서버(npm run dev)를 실행한 후, http://localhost:3000/form-submit으로 접속합니다.
  3. "할 일 내용" 입력 필드에 새로운 할 일을 입력하고 "할 일 추가" 버튼을 클릭합니다.
    • 페이지가 새로고침되면서 itemActions.tscreateItem 서버 액션이 실행되고, 터미널에 로그가 찍힙니다.
    • 폼 데이터가 성공적으로 처리되면, revalidatePath('/form-submit')에 의해 현재 페이지의 캐시가 재검증되어 최신 할 일 목록이 화면에 반영됩니다.

폼 상태 관리: useFormStatus

폼이 제출되는 동안 사용자에게 피드백을 제공하는 것은 좋은 사용자 경험의 핵심입니다. Next.js와 React DOM은 폼의 제출 상태를 추적할 수 있는 훅인 useFormStatus 를 제공합니다. 이 훅은 클라이언트 컴포넌트에서만 사용할 수 있습니다.

useFormStatus 훅은 폼 제출 상태를 나타내는 객체를 반환합니다. 가장 유용한 속성은 pending으로, 폼이 제출 중일 때 true가 됩니다.

useFormStatus 사용법

src/app/form-submit/SubmitButton.tsx
// 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
// 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에서 폼을 제출할 때 "할 일 추가" 버튼의 텍스트와 스타일이 제출 중에는 "추가 중..."으로 바뀌고 비활성화되는 것을 확인할 수 있습니다. 이는 서버 액션이 실행되는 동안 사용자에게 시각적인 피드백을 제공합니다.


폼 상태 및 결과 처리: useFormState

단순히 폼 제출 상태뿐만 아니라, 폼 제출 후 서버 액션의 결과(예: 성공/실패 메시지, 유효성 검사 에러)를 클라이언트 컴포넌트에서 받아 UI에 반영해야 할 때가 있습니다. 이때 useFormState을 사용합니다. useFormStateuseState와 유사하지만, 폼 액션의 결과를 기반으로 상태를 관리하는 데 특화되어 있습니다. 이 훅 또한 클라이언트 컴포넌트에서만 사용할 수 있습니다.

useFormState는 두 가지 인자를 받습니다.

  1. 액션 함수: 폼 제출 시 호출될 서버 액션 함수.
  2. 초기 상태: 폼 상태의 초기값.

그리고 두 가지 값을 배열로 반환합니다.

  1. 상태 값: 현재 폼의 상태 (서버 액션의 반환값).
  2. 새로운 액션 함수: 이 함수를 formaction으로 사용해야 합니다.

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

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

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

    src/app/form-submit/TodoForm.tsx
    // src/app/form-submit/TodoForm.tsx
    "use client";
    
    import { useFormStatus, useFormState } from 'react-dom';
    import { useRef, useEffect } 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 TodoFormProps {
      createItemAction: (prevState: any, formData: FormData) => Promise<{ success: boolean; message: string }>;
    }
    
    export default function TodoForm({ createItemAction }: TodoFormProps) {
      // useFormState 훅 사용:
      // - [state, formAction]: 현재 폼 상태와 폼에 바인딩할 액션 함수
      // - createItemAction: 서버 액션 함수
      // - { success: false, message: '' }: 초기 폼 상태
      const [state, formAction] = useFormState(createItemAction, { success: false, message: '' });
      const formRef = useRef<HTMLFormElement>(null);
    
      // 폼 제출 성공 시 입력 필드를 초기화합니다.
      useEffect(() => {
        if (state.success) {
          formRef.current?.reset();
        }
      }, [state.success]);
    
      return (
        <form
          ref={formRef}
          action={formAction} // useFormState가 반환한 formAction을 사용
          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>
      );
    }
  2. src/app/form-submit/page.tsx (기존 파일 수정): <form> 태그를 TodoForm 컴포넌트로 대체합니다.

    src/app/form-submit/page.tsx
    // src/app/form-submit/page.tsx
    // ...
    import { createItem, 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={createItem} />
        </div>
      );
    }

    참고: itemActions.tscreateItem 함수가 useFormState와 호환되도록 (formData: FormData) 대신 (prevState: any, formData: FormData) 시그니처를 사용해야 합니다. 첫 번째 prevState 인자는 useFormState가 자동으로 전달하는 이전 상태입니다.

    src/app/actions/itemActions.ts
    // src/app/actions/itemActions.ts (수정)
    // ... (생략) ...
    
    // createItem 함수의 시그니처를 수정합니다.
    export async function createItem(prevState: any, formData: FormData) { // prevState 인자 추가
      // ... (기존 로직 동일) ...
    }
    // ... (생략) ...

실습 확인: http://localhost:3000/form-submit에서 폼을 제출할 때, "할 일 추가" 버튼 아래에 성공/실패 메시지가 표시되는 것을 확인합니다. 입력 필드가 비어있을 때는 에러 메시지가, 유효한 입력일 때는 성공 메시지가 나타날 것입니다. 폼 제출 성공 시 입력 필드도 자동으로 초기화됩니다.


폼 처리 워크플로우 요약

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

  1. 서버 액션 정의: 데이터를 처리할 서버 측 로직을 itemActions.ts와 같이 별도의 파일 또는 특정 함수에 "use server" 지시어를 사용하여 정의합니다. 이 함수는 FormData를 인자로 받거나, useFormState와 함께 사용될 경우 (prevState, formData) 시그니처를 가집니다.
  2. 폼 렌더링: 서버 컴포넌트에서 <form> 요소를 렌더링하고, action 속성에 서버 액션 함수를 직접 바인딩합니다.
  3. 상태 및 피드백 (선택 사항, 클라이언트 컴포넌트):
    • useFormStatus: 폼 제출 중 로딩 상태를 UI에 표시할 때 사용합니다. 제출 버튼을 비활성화하거나 스피너를 보여주는 등에 활용됩니다.
    • useFormState: 서버 액션의 결과(성공/실패 메시지, 유효성 검사 에러 등)를 클라이언트에서 받아 UI에 표시할 때 사용합니다. 폼 필드 초기화 등 후속 클라이언트 로직을 트리거할 수도 있습니다.
  4. 자동 재검증: 서버 액션이 완료되면, revalidatePath 또는 revalidateTag를 사용하여 데이터가 변경된 경로나 태그를 재검증하여 UI를 자동으로 업데이트합니다.

이러한 폼 처리 방식은 개발 복잡성을 크게 줄이고, 성능을 최적화하며, 사용자에게 더 나은 경험을 제공합니다. 클라이언트-서버 간의 명시적인 API 라우트 호출 없이도 강력한 데이터 변경 로직을 구현할 수 있다는 점이 Next.js App Router의 큰 장점입니다.