폼 제출 및 데이터 처리
HTML form을 서버 액션에 연결하고 useFormStatus와 폼 상태 훅으로 제출 대기·결과·오류 UI를 처리합니다.
웹 애플리케이션에서 폼(Form)은 사용자 입력을 받아 서버로 전송하고 처리하는 핵심적인 요소입니다.
사용자 등록, 게시물 작성, 설정 변경 등 대부분의 중요한 상호작용은 폼을 통해 이루어집니다.
Next.js App Router는 서버 액션(Server Actions)과 결합하여 폼 제출 및 데이터 처리 과정을 이전보다 훨씬 간결하고 효율적으로 만들었습니다.
이 절에서는 form의 action, useFormStatus와 React 19의 useActionState를 중심으로 폼 처리를 다룹니다.
데이터 저장은 앞 절의 로컬 단일 프로세스 배열을 그대로 사용하므로 폼 상태 흐름만 확인합니다.
운영 환경에서는 생성·조회 액션을 데이터베이스에 연결해야 재시작과 여러 서버 인스턴스에서도 결과가 유지됩니다.
HTML form 요소와 서버 액션의 결합
Next.js App Router의 주요 변화 중 하나는 HTML <form> 요소의 표준 action 속성에 서버 액션을 직접 할당할 수 있게 되었다는 점입니다.
이를 사용하면 클라이언트 측 JavaScript 코드를 줄이면서 폼 제출을 처리할 수 있습니다.
기본적인 폼 제출 과정사용자가 폼에 데이터를 입력하고 제출 버튼을 클릭합니다.
브라우저는 폼의 action 속성에 지정된 서버 액션을 호출합니다.
서버 액션은 폼 데이터(FormData 객체)를 자동으로 인자로 받습니다.
서버 액션은 서버에서 실행되어 데이터를 처리하고 필요한 작업을 수행합니다.
서버 액션이 데이터 변경 뒤 revalidatePath, revalidateTag(tag, 'max'), updateTag 중 목적에 맞는 API를 명시적으로 호출하면 해당 캐시가 갱신됩니다.
이전 itemActions.ts 파일을 재활용하여 할 일을 추가하는 폼을 만들어보겠습니다.
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서버 액션이 실행되고, 터미널에 로그가 찍힙니다. - 폼 데이터가 성공적으로 처리되면,
revalidatePath('/form-submit')에 의해 현재 페이지의 캐시가 재검증되어 최신 할 일 목록이 화면에 반영됩니다.
폼 상태 관리: useFormStatus 훅
폼이 제출되는 동안 사용자에게 피드백을 제공하는 것은 좋은 사용자 경험의 핵심입니다.
Next.js와 React DOM은 폼의 제출 상태를 추적할 수 있는 훅인 useFormStatus를 제공합니다.
이 훅은 클라이언트 컴포넌트에서만 사용할 수 있습니다.
useFormStatus 훅은 폼 제출 상태를 나타내는 객체를 반환합니다.
가장 유용한 속성은 pending으로, 폼이 제출 중일 때 true가 됩니다.
useFormStatus 사용법
"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 적용
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 훅을 사용하여 폼의 상태와 메시지를 관리합니다.
"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 컴포넌트로 대체합니다.
// ...
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가 자동으로 전달하는 이전 상태입니다.
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가 폼 제출 경험을 어떻게 나누는지 정리합니다.