폼 제출 및 데이터 처리
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를 명시적으로 호출하면 해당 캐시가 갱신됩니다.
이 절의 세 버전을 따라가는 방법
먼저 앞 절의 itemActions.ts를 준비하고 page.tsx의 직접 form 버전을 확인합니다. 다음은 같은 form의 button만 별도 SubmitButton으로 바꾸는 단계입니다. 마지막은 TodoForm과 createItemWithState를 추가해 그 form을 교체합니다. 세 페이지 코드를 같은 파일에 차례로 덧붙이거나 export default를 세 개 만드는 실습이 아닙니다.
마지막 page.tsx를 적용하기 전에는 아래 itemActions.ts의 추가 어댑터까지 준비하세요. createItemWithState가 아직 없는 상태에서 그 이름만 import하면 폼 동작을 보기 전에 모듈 연결 단계에서 실패합니다. 원 createItem과 createItemWithResult도 함께 유지합니다.
화면의 ID와 서버로 보내는 필드 이름은 다릅니다
id="todoName"은 label의 htmlFor와 입력을 연결합니다. 서버의 formData.get('itemName')이 찾는 이름은 name="itemName"입니다. id와 placeholder만 남기고 name을 지우면 이 필드를 FormData에서 찾을 수 없습니다. 같은 이름을 가진 입력이 여러 개이면 get()은 첫 값을 읽고, 여러 값을 처리하려면 getAll()과 그에 맞는 검증 정책이 필요합니다.
FormData.get()의 결과는 문자열·File·null 중 하나일 수 있습니다. 앞 액션은 문자열인지 먼저 확인한 뒤 trim하므로, 누락된 값이나 파일을 이름으로 저장하지 않습니다. 브라우저 required는 사용자의 입력 실수를 줄이는 장치이고 서버 액션을 직접 요청하는 다른 클라이언트의 검증을 대신하지 않습니다.
이전 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 서버 액션이 실행되고 터미널에 로그가 찍힙니다. JavaScript가 준비된 상태에서는 화면 전체 새로고침 없이 갱신될 수 있으므로 목록 갱신과 문서 재로딩을 구분합니다.
- 폼 데이터가 성공적으로 처리되면,
revalidatePath('/form-submit')에 의해 현재 페이지의 캐시가 재검증되어 최신 할 일 목록이 화면에 반영됩니다.
폼 상태 관리: useFormStatus 훅
폼이 제출되는 동안 사용자에게 피드백을 제공하는 것은 좋은 사용자 경험의 핵심입니다.
Next.js와 React DOM은 폼의 제출 상태를 추적할 수 있는 훅인 useFormStatus를 제공합니다.
이 훅은 클라이언트 컴포넌트에서 사용하며, 해당 컴포넌트가 부모 form 안에 렌더링되어야 합니다. form을 만드는 같은 컴포넌트에서 호출하면 그 form의 제출 상태를 읽지 못합니다.
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 적용
다음 코드는 교체 위치를 보여 주는 발췌입니다. 앞 예제의 createItem·getItems import와 페이지 나머지 내용은 유지합니다.
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 } 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 컴포넌트로 대체합니다.
// ...
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가 자동으로 전달하는 이전 상태입니다.
예시
처음에는 state가 success:false·message:''이므로 아직 결과 문구가 없습니다. 폼의 name="itemName" 입력에 공백을 제출하면 formAction이 이전 상태를 첫 인자로, 새 FormData를 두 번째 인자로 전달합니다. createItemWithState는 이 예제에서 이전 상태를 사용하지 않고 FormData를 createItemWithResult로 넘깁니다. 서버의 trim 검증이 반환한 실패 메시지는 다음 state가 되고 화면에 표시됩니다.
그 뒤 유효한 할 일을 제출하면 첫 인자는 앞 실패 결과지만, 두 번째 인자는 새 입력입니다. 입력 처리에 쓰는 것은 이 새 FormData입니다. 반환 결과의 success는 표시 색상·메시지를 정하며, pending은 지금 처리 중인지를 정합니다. useActionState의 인수 순서는 직접 form action의 FormData 하나와 구분해야 합니다.
결과가 남아 있는 동안에도 다음 제출은 대기 중일 수 있습니다
첫 실패 메시지가 표시된 상태에서 다음 요청을 보내면, 처리 중에는 앞 결과 메시지가 남아 있으면서 isPending이 true일 수 있습니다. success:false는 최근 완료한 업무 결과이고 pending:true는 아직 완료하지 않은 요청이 있다는 뜻입니다. 실패 메시지를 대기 여부로 사용하면 두 상태를 혼동합니다. 이 예제의 aria-busy는 대기를 표현하고, 메시지·색상은 반환 결과를 표현합니다.
useFormStatus는 자신을 감싼 form의 제출을 읽습니다. 서로 나란한 두 form에서 A가 제출 중이라고 B 안의 버튼도 A의 pending을 읽는 것은 아닙니다. useActionState의 formAction은 현재 코드처럼 form의 action에 연결하면 제출의 Action 문맥에서 호출됩니다. 일반 버튼 이벤트에서 직접 호출하도록 바꾸려면 startTransition 등의 Action 문맥을 별도로 마련해야 합니다.
prevState는 이전 화면 결과이지 서버가 보증한 사용자 신원이나 권한 정보가 아닙니다. 여기에 userId나 role을 추가하는 것만으로 인증·인가를 대신할 수 없습니다. 현재 서버 어댑터가 이전 상태를 무시하고 새 FormData를 검증하는 이유도 입력과 표시 상태의 역할이 다르기 때문입니다.
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 문서를 기준으로 확인합니다.
검증 실패 뒤 초안을 남기려면 입력 상태를 직접 관리합니다
현재 input에는 value가 없으므로 비제어 입력입니다. 실패 후에도 초안을 유지하려면 예를 들어 클라이언트의 문자열 상태를 value와 onChange에 연결하고, 성공 결과를 확인했을 때만 그 문자열을 비우는 제어 입력으로 설계할 수 있습니다. 초기 문자열은 ''처럼 정해 두고, 입력하는 즉시 상태를 갱신합니다. React의 비제어 입력 자동 초기화가 이 상태 문자열까지 대신 비우지는 않습니다.
이 변경을 적용할 때는 입력 코드와 성공 후 상태 변경도 함께 바꿔야 합니다. 원 예제에 결과의 success 검사만 추가한다고 비제어 입력의 자동 초기화가 중단되지는 않습니다. 사용자가 대기 중에도 편집할 수 있게 한다면, 앞 제출의 성공이 나중에 작성한 새 초안을 지울지에 대한 정책도 정하세요. React의 제어 입력 설명을 참고합니다.
입력 검증 실패는 위와 같이 정상 반환해 메시지로 다루지만, 저장소 장애 등으로 액션이 처리되지 않은 예외를 던지면 같은 state.message 갱신 경로가 아닙니다. 오류 경계나 호출 경로에 맞는 오류 처리를 마련해야 하며, 화면의 메시지와 서버 저장 성공 여부를 같은 증거로 취급하지 않습니다.
입력 검증·제출 대기·액션 결과를 구분합니다
HTML required 거부는 제출 전이고, 공백은 서버 trim 실패로 정상 반환하며 유효 입력은 항목 변경·revalidate 뒤 성공합니다. submit 자식의 pending과 useActionState message의 전후 상태를 함께 추적할 수 있습니다.
NEXT · CH8-3
입력 검증·제출 대기·액션 결과를 구분합니다
TodoForm의 useActionState 예제입니다. formAction에 제출하고 createItemWithState가 이전 상태와 FormData를 받은 뒤 createItemWithResult로 처리합니다.
입력별로 어디까지 진행하나요?
- 빈 입력 · 브라우저
required가 제출을 막습니다. 이 경로에서는 서버 액션을 호출하지 않습니다.- 공백만 입력 · 서버
- trim 뒤 빈 값이므로
success:false와 실패 메시지를 반환합니다. items 추가·revalidatePath는 실행하지 않습니다. - 유효한 이름 · 서버
- items에 추가하고 두 예제 경로를 재검증한 뒤
success:true와 메시지를 반환합니다.
대기 표시와 결과 표시는 별개입니다
제출 → pending
form 안의 SubmitButton이 useFormStatus로 대기를 읽고 비활성화합니다.
반환 → state.message
useActionState가 반환값을 다음 상태로 연결하고, TodoForm이 메시지와 success 색상을 표시합니다.
정상 완료 → 입력 초기화
함수형 form action의 비제어 입력 초기화는 success:true만을 조건으로 하지 않습니다.
검증 실패값의 정상 반환과 예외 발생은 구분합니다. 입력 보존은 별도 설계가 필요합니다. 버튼 비활성화는 현재 UI의 대기 표시이며 서버의 중복 방지가 아닙니다. 메모리 배열은 재시작·여러 인스턴스에서 보존되지 않습니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 원 TodoForm의 세 입력 경로입니다. 빈 값은 required에서 요청 없이 끝납니다. 공백은 서버까지 제출되고 trim 뒤 빈 값이어서 정상 실패 결과를 반환합니다. 유효한 값만 items와 경로 재검증을 바꾸고 성공 결과를 반환합니다. 제출된 두 경로의 정상 반환은 useActionState의 메시지와 색상을 갱신하며 함수형 action의 비제어 입력을 초기화합니다. pending은 제출 처리의 대기 상태로, 원 SubmitButton이 자신 위의 form에서 읽습니다. 배열의 지속 저장·업무 중복 방지·예외 경로는 별도입니다. 배치상 아래의 요청 없음과 실패 노드는 실행 순서가 아니라 각각의 대안입니다.
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 입력의 제출 경계 빈 값은 HTML required에서 막히고 공백·유효값은 서버에 제출합니다. | 빈 값: 요청 없음 · 공백·유효값: 제출 | 입력에 따라 네트워크 호출 유무가 갈립니다. |
| pending과 서버 검사 SubmitButton이 자신의 form pending을 읽고 서버는 trim한 값을 확인합니다. | Client: pending · 공백: 정상 실패 결과 · 유효값: 업무 진행 | 제출 대기와 서버 입력 판정이 진행됩니다. |
| 유효값 변경과 재검증 유효값만 items를 변경하고 경로를 revalidate한 뒤 성공 결과를 반환합니다. | items: 유효값만 변경 · cache: revalidate · 결과: 성공 | 이 성공 분기에서만 서버 예제 자료가 바뀝니다. |
| 반환 후 UI 정상 반환이 useActionState 메시지·색상을 바꾸고 비제어 입력을 초기화합니다. | state: 반환 message · 입력: 정상 반환 후 초기화 · 지속성·중복 방지: 별도 | 제출 결과가 Client UI에 표시됩니다. |
입력 검증·제출 대기·액션 결과를 구분합니다
HTML required 거부는 제출 전이고, 공백은 서버 trim 실패로 정상 반환하며 유효 입력은 항목 변경·revalidate 뒤 성공합니다. submit 자식의 pending과 useActionState message의 전후 상태를 함께 추적할 수 있습니다.
NEXT · CH8-3
입력 검증·제출 대기·액션 결과를 구분합니다
TodoForm의 useActionState 예제입니다. formAction에 제출하고 createItemWithState가 이전 상태와 FormData를 받은 뒤 createItemWithResult로 처리합니다.
입력별로 어디까지 진행하나요?
- 빈 입력 · 브라우저
required가 제출을 막습니다. 이 경로에서는 서버 액션을 호출하지 않습니다.- 공백만 입력 · 서버
- trim 뒤 빈 값이므로
success:false와 실패 메시지를 반환합니다. items 추가·revalidatePath는 실행하지 않습니다. - 유효한 이름 · 서버
- items에 추가하고 두 예제 경로를 재검증한 뒤
success:true와 메시지를 반환합니다.
대기 표시와 결과 표시는 별개입니다
제출 → pending
form 안의 SubmitButton이 useFormStatus로 대기를 읽고 비활성화합니다.
반환 → state.message
useActionState가 반환값을 다음 상태로 연결하고, TodoForm이 메시지와 success 색상을 표시합니다.
정상 완료 → 입력 초기화
함수형 form action의 비제어 입력 초기화는 success:true만을 조건으로 하지 않습니다.
검증 실패값의 정상 반환과 예외 발생은 구분합니다. 입력 보존은 별도 설계가 필요합니다. 버튼 비활성화는 현재 UI의 대기 표시이며 서버의 중복 방지가 아닙니다. 메모리 배열은 재시작·여러 인스턴스에서 보존되지 않습니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 원 TodoForm의 세 입력 경로입니다. 빈 값은 required에서 요청 없이 끝납니다. 공백은 서버까지 제출되고 trim 뒤 빈 값이어서 정상 실패 결과를 반환합니다. 유효한 값만 items와 경로 재검증을 바꾸고 성공 결과를 반환합니다. 제출된 두 경로의 정상 반환은 useActionState의 메시지와 색상을 갱신하며 함수형 action의 비제어 입력을 초기화합니다. pending은 제출 처리의 대기 상태로, 원 SubmitButton이 자신 위의 form에서 읽습니다. 배열의 지속 저장·업무 중복 방지·예외 경로는 별도입니다. 배치상 아래의 요청 없음과 실패 노드는 실행 순서가 아니라 각각의 대안입니다.
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 입력의 제출 경계 빈 값은 HTML required에서 막히고 공백·유효값은 서버에 제출합니다. | 빈 값: 요청 없음 · 공백·유효값: 제출 | 입력에 따라 네트워크 호출 유무가 갈립니다. |
| pending과 서버 검사 SubmitButton이 자신의 form pending을 읽고 서버는 trim한 값을 확인합니다. | Client: pending · 공백: 정상 실패 결과 · 유효값: 업무 진행 | 제출 대기와 서버 입력 판정이 진행됩니다. |
| 유효값 변경과 재검증 유효값만 items를 변경하고 경로를 revalidate한 뒤 성공 결과를 반환합니다. | items: 유효값만 변경 · cache: revalidate · 결과: 성공 | 이 성공 분기에서만 서버 예제 자료가 바뀝니다. |
| 반환 후 UI 정상 반환이 useActionState 메시지·색상을 바꾸고 비제어 입력을 초기화합니다. | state: 반환 message · 입력: 정상 반환 후 초기화 · 지속성·중복 방지: 별도 | 제출 결과가 Client UI에 표시됩니다. |
서버에 도달한 횟수와 저장한 횟수 세기
최종 TodoForm 버전에서 빈 문자열, 공백 두 칸, 읽기 연습이라는 세 입력을 순서대로 각각 제출한다고 가정합니다. required를 유지하고, 브라우저 스크립트와 네트워크는 정상이며 초기 아이템 하나 외에 다른 요청은 없습니다. 어느 입력이 서버에 도달하고, 어느 입력이 배열과 다음 ID를 바꿀까요? 목록 개수와 마지막 메시지, 공백의 정상 실패 반환 뒤 입력의 상태를 먼저 적어 보세요.
브라우저 검증과 서버 결과의 해설
빈 문자열은 브라우저 required가 막으므로 이 경로에서는 서버 액션이 실행되지 않습니다. 공백은 비어 있지 않아 제출되지만 서버 trim 뒤 빈 값이 되어 실패 결과를 반환합니다. 배열과 다음 ID는 그대로입니다. 마지막 유효 입력만 저장하므로 최종 목록은 초기 아이템과 새 id=2의 두 개이고, 마지막 state.message는 성공 문구입니다.
공백의 success:false는 예외를 던진 것이 아니라 함수가 정상 반환한 결과입니다. 이 비제어 input은 React의 정상 완료 초기화 규칙을 따르므로 공백이 보존된다고 가정하지 않습니다. 서버가 예외를 던지는 경로는 이 실패 메시지의 정상 반환과 다르며 별도의 오류 처리가 필요합니다. SubmitButton의 useFormStatus는 자신을 감싼 form의 대기를 읽기 때문에 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의 대기 표시이며, 서버의 중복 요청 방지 구현은 이 예제에 없습니다.
- 프로세스 재시작: 메모리 배열의 수명과 지속 저장의 차이를 확인합니다.
연습: 입력·이전 결과·대기의 관계
- input의 id는
'todoName'그대로지만 name을'taskName'으로 바꿨습니다. 서버 코드는get('itemName')을 유지합니다.TodoForm어댑터를 통해 제출했을 때 저장과 결과 메시지는 어떻게 되나요? 다른 입력은 없습니다. - 앞 제출의 실패 메시지가 있고, 다음 유효한 입력을 보내 아직 액션이 끝나지 않았습니다.
state.success와isPending이 각각 false·true인 것이 모순일까요? 첫 번째와 두 번째 액션 인자도 설명하세요. - input을 제어 입력으로 바꾸고 초안을 문자열 상태에 보관합니다. 실패 반환에는 상태를 유지하고 성공 결과를 확인했을 때만
setDraft('')을 실행합니다. 다른 입력 변경 없이 실패와 성공을 차례로 받은 경우 초안은 언제 비워지나요? - 서로 나란한 form A와 B에 각각 SubmitButton이 있습니다. A만 제출 중이고 B는 요청하지 않았습니다. B의 useFormStatus가 읽는 대상과, 버튼 두 개의 비활성화가 서버 중복 방지를 보장하는지 적어 보세요.
필드 이름·이전 상태·입력 보존 풀이
get('itemName')은 null을 반환합니다. 문자열이 아니므로 빈 이름으로 처리되어 저장과 재검증을 하지 않고 실패 메시지를 정상 반환합니다. id와 서버의 필드 이름은 다른 역할입니다.- 모순이 아닙니다. success는 최근 완료한 실패 결과이고 pending은 다음 요청이 처리 중인 상태입니다. 다음 액션은 이전 실패 결과를 첫 인자로, 이번 제출의 FormData를 두 번째 인자로 받습니다. 이 어댑터는 첫 인자를 표시 이력으로만 두고 두 번째 인자의 새 입력을 처리합니다.
- 실패 반환 뒤에는 상태 문자열을 그대로 유지합니다. 성공 결과를 확인하고
setDraft('')을 실행할 때 비워집니다. 제어 상태는 비제어 input의 자동 초기화와 별도이며, 이 답은 문제에 명시한 상태 갱신 정책에 따른 결과입니다. - B는 자신 위의 form B를 읽으므로 A의 대기를 보고 비활성화되지 않습니다. 각 pending 버튼은 현재 폼의 UI를 제어할 뿐, 다른 탭·직접 요청·재시도를 포함한 서버의 업무 중복 판정을 보장하지 않습니다.