폼 제출 및 데이터 처리
HTML form을 서버 액션에 연결하고 useFormStatus와 폼 상태 훅으로 제출 대기·결과·오류 UI를 처리합니다.
웹 애플리케이션에서 폼(form)은 사용자 입력을 받아 서버로 보내고 처리하는 핵심 요소입니다. 사용자 등록, 게시물 작성, 설정 변경처럼 대부분의 중요한 상호작용이 폼으로 이루어집니다. Next.js App Router는 서버 액션과 결합해 폼 제출과 데이터 처리 과정을 이전보다 훨씬 간결하고 효율적으로 만들었습니다. 이 글에서는 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의 버튼만 별도의 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는 사용자의 입력 실수를 줄이는 장치일 뿐, 서버 액션에 직접 요청하는 다른 클라이언트의 검증을 대신하지 않습니다.
실습: 간단한 할 일(Todo) 추가 폼
앞 글의 itemActions.ts를 다시 써서 할 일을 추가하는 폼을 만들어 보겠습니다. 첫 버전은 클라이언트 컴포넌트 없이 서버 컴포넌트인 페이지 하나로 만듭니다. <form>의 action에 서버 액션 createItem을 그대로 넘기면, 제출할 때 폼의 입력값이 FormData로 묶여 이 함수에 전달됩니다. createItem은 결과를 돌려주지 않으므로, 이 버전에서는 성공 메시지 없이 다시 그려진 목록으로 결과를 확인합니다.
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를 위 내용으로 만듭니다. 앞 글의 src/app/actions/itemActions.ts 파일이 필요합니다. 개발 서버(npm run dev)를 실행한 뒤 http://localhost:3000/form-submit에 접속해 "할 일 내용" 입력 필드에 새 할 일을 넣고 "할 일 추가" 버튼을 누릅니다.
itemActions.ts의 createItem 서버 액션이 실행되고 터미널에 로그가 찍힙니다. JavaScript가 준비된 상태에서는 화면 전체를 새로고침하지 않고 갱신될 수 있으므로, 목록이 갱신되는 것과 문서가 다시 로드되는 것을 구분합니다. 폼 데이터가 처리되면 revalidatePath('/form-submit')가 현재 페이지의 캐시를 재검증해 최신 할 일 목록이 화면에 반영됩니다.
폼 상태 관리: useFormStatus 훅
폼을 제출하는 동안 사용자에게 피드백을 주는 것은 좋은 사용자 경험의 핵심입니다. React DOM은 폼의 제출 상태를 추적하는 useFormStatus 훅을 제공합니다. 이 훅은 클라이언트 컴포넌트에서 쓰며, 그 컴포넌트가 부모 form 안에 렌더링되어야 합니다. form을 만드는 바로 그 컴포넌트에서 호출하면 그 form의 제출 상태를 읽지 못합니다. useFormStatus는 폼의 제출 상태를 나타내는 객체를 돌려주며, 가장 유용한 속성은 폼을 제출하는 동안 true가 되는 pending입니다.
"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>
);
}useActionState는 액션이 돌려준 값을 다음 state로 저장하므로, 서버 액션이 돌려준 message가 폼 아래에 나타나고 success에 따라 색이 정해집니다. 처음에는 초기 상태의 message가 빈 문자열이라 메시지 문단이 그려지지 않습니다. 다음으로 기존의 src/app/form-submit/page.tsx에서 <form> 태그를 TodoForm 컴포넌트로 바꿉니다. 목록을 그리는 부분은 그대로 두고, 폼 자리에 TodoForm을 넣으며 뒤에서 추가할 createItemWithState를 넘깁니다.
// ...
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가 자동으로 넘기는 이전 상태입니다. 다음 어댑터를 itemActions.ts에 추가합니다.
interface TodoFormState {
success: boolean;
message: string;
}
export async function createItemWithState(
_previousState: TodoFormState,
formData: FormData,
): Promise<TodoFormState> {
return createItemWithResult(formData);
}처음에는 상태가 success: false, message: ''이므로 아직 결과 문구가 없습니다. 폼의 name="itemName" 입력에 공백을 제출하면, formAction이 이전 상태를 첫 인자로, 새 FormData를 두 번째 인자로 넘깁니다. createItemWithState는 이 예제에서 이전 상태를 쓰지 않고 FormData를 createItemWithResult로 넘기며, 서버의 trim 검증이 돌려준 실패 메시지가 다음 상태가 되어 화면에 표시됩니다.
그 뒤에 유효한 할 일을 제출하면 첫 인자는 앞의 실패 결과지만, 두 번째 인자는 새 입력입니다. 입력을 처리하는 데 쓰는 것은 이 새 FormData입니다. 반환 결과의 success는 표시할 색상과 메시지를 정하고, 대기 상태는 지금 처리 중인지를 나타냅니다. useActionState의 인수 순서는 form action에 직접 연결할 때 받는 FormData 하나와 구분해야 합니다.
결과가 남아 있는 동안에도 다음 제출은 대기 중일 수 있습니다. 첫 실패 메시지가 표시된 상태에서 다음 요청을 보내면, 처리하는 동안 앞의 결과 메시지가 남아 있으면서 isPending이 true일 수 있습니다. success: false는 가장 최근에 끝난 업무의 결과이고 isPending: true는 아직 끝나지 않은 요청이 있다는 뜻이므로, 실패 메시지를 대기 여부로 쓰면 두 상태를 혼동하게 됩니다. 이 예제의 aria-busy는 대기를 나타내고, 메시지와 색상은 반환 결과를 나타냅니다.
useFormStatus는 자신을 감싼 form의 제출을 읽습니다. 나란히 있는 두 form에서 A가 제출 중이라고 B 안의 버튼까지 A의 pending을 읽지는 않습니다. useActionState의 formAction은 지금 코드처럼 form의 action에 연결하면 제출의 Action 문맥에서 호출됩니다. 일반 버튼 이벤트에서 직접 호출하도록 바꾸려면 startTransition 같은 Action 문맥을 따로 마련해야 합니다.
prevState는 이전 화면의 결과일 뿐, 서버가 보증한 사용자 신원이나 권한 정보가 아닙니다. 여기에 userId나 role을 추가하는 것만으로 인증과 인가를 대신할 수 없습니다. 지금의 서버 어댑터가 이전 상태를 무시하고 새 FormData를 검증하는 것도, 입력과 표시 상태의 역할이 다르기 때문입니다.
http://localhost:3000/form-submit에서 폼을 제출하면 "할 일 추가" 버튼 아래에 성공이나 실패 메시지가 표시됩니다. 비어 있는 입력은 required가 브라우저에서 제출을 막으므로, 서버의 이름 검증을 확인하려면 공백만 입력합니다. 공백은 서버에서 trim한 뒤 빈 값이 되어 실패 메시지가 돌아오고, 유효한 이름은 성공 메시지가 돌아옵니다.
입력 검증·제출 대기·액션 결과를 구분합니다
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의 useActionState 예제에서 입력 검증, 제출 대기, 액션 결과를 구분해 보여 줍니다. 입력은 세 경로로 나뉩니다. 빈 값은 required에서 요청 없이 끝나 서버 액션이 실행되지 않고 이전 상태가 유지됩니다. 공백은 서버까지 제출되지만 trim 뒤에 빈 값이므로 success: false와 실패 메시지를 정상적으로 돌려주며 items는 바뀌지 않습니다. 유효한 이름만 items에 추가되고 두 예제 경로를 재검증한 뒤 success: true와 메시지를 돌려줍니다. 그림에서 아래쪽에 놓인 "요청 없음"과 "검증 실패" 노드는 실행 순서가 아니라 각각의 대안입니다.
그림의 오른쪽은 대기 표시와 결과 표시가 별개라는 점을 정리합니다. 제출하면 form 안의 SubmitButton이 useFormStatus로 대기를 읽어 비활성화되고, 반환되면 useActionState가 반환값을 다음 상태로 연결해 TodoForm이 메시지와 색상을 표시합니다. 정상적으로 끝나면 함수형 form action이 비제어 입력을 초기화하는데, 이 초기화는 success: true만을 조건으로 하지 않습니다. 그림의 단계도 입력의 제출 여부, 대기와 서버 검사, 유효한 값의 변경과 재검증, 반환 뒤의 UI 순서입니다.
입력 초기화와 초안 보존
함수형 form action이 정상적으로 반환하면 React가 비제어 입력을 초기화합니다. 이 기준은 반환 객체의 success 값과 다르므로, 검증 실패를 정상적으로 반환한 경우에도 입력이 초기화될 수 있습니다. 입력을 남겨 두어야 한다면 제어 입력이나 반환한 필드 값으로 따로 설계하며, 자세한 규칙은 React form 문서에서 확인합니다.
지금의 input에는 value가 없으므로 비제어 입력입니다. 검증에 실패한 뒤에도 초안을 남기려면, 예를 들어 클라이언트의 문자열 상태를 value와 onChange에 연결하고, 성공 결과를 확인했을 때만 그 문자열을 비우는 제어 입력으로 설계할 수 있습니다. 초기 문자열은 ''처럼 정해 두고, 입력할 때마다 상태를 갱신합니다. React의 비제어 입력 자동 초기화가 이 상태 문자열까지 대신 비우지는 않습니다.
이렇게 바꿀 때는 입력 코드와 성공한 뒤의 상태 변경을 함께 바꿔야 합니다. 원래 예제에 결과의 success 검사만 더한다고 비제어 입력의 자동 초기화가 멈추지는 않습니다. 사용자가 대기 중에도 편집할 수 있게 한다면, 앞 제출의 성공이 나중에 작성한 새 초안을 지울지에 대한 정책도 정해야 합니다. React의 제어 입력 설명을 참고하세요.
입력 검증의 실패는 위처럼 정상적으로 반환해 메시지로 다루지만, 저장소 장애 등으로 액션이 처리되지 않은 예외를 던지면 같은 state.message 갱신 경로를 거치지 않습니다. 가장 가까운 Error Boundary(error.js)나 호출 경로에 맞는 오류 처리를 마련해야 하며, 화면의 메시지만으로 서버 저장이 성공했는지 판단하지 않습니다.
더 깊이: JavaScript가 준비되기 전의 제출과 이어지는 제출
useActionState에 서버 함수를 넘기면, 하이드레이션이 끝나기 전에 제출한 폼의 서버 응답도 화면에 보여 줄 수 있습니다. 여기에 쓰는 것이 세 번째 인자 permalink입니다. 이 값은 그 폼이 바꾸는 페이지의 고유한 URL을 나타내며, JavaScript 번들이 로드되기 전에 폼이 제출되면 브라우저는 현재 페이지 대신 이 URL로 이동합니다. 피드처럼 내용이 바뀌는 페이지에서 JavaScript 없이도 폼이 동작하게 하는 점진적 향상(progressive enhancement)을 위한 옵션이고, 이동한 페이지에도 같은 액션과 permalink를 쓰는 같은 폼 컴포넌트가 렌더링되어야 상태가 전달됩니다. 페이지가 상호작용할 수 있게 된 뒤에는 이 인자가 아무 효과가 없으며, 서버 함수와 함께 쓸 때 initialState는 직렬화할 수 있는 값이어야 합니다(React 문서의 useActionState).
같은 폼을 여러 번 제출했을 때의 순서도 정해져 있습니다. React는 액션 함수 호출을 대기열에 넣어 차례로 실행하고, 각 호출은 바로 앞 호출의 결과를 첫 인자로 받습니다. 그래서 위의 어댑터가 받는 prevState는 직전에 끝난 제출의 결과입니다. 대기 중인 호출 가운데 하나가 예외를 던지면 React는 남은 호출을 모두 취소하고 가장 가까운 에러 바운더리를 보여 줍니다.
폼 처리 워크플로우 요약
Next.js App Router의 폼 제출과 데이터 처리 흐름은 다음과 같이 정리할 수 있습니다. 먼저 데이터를 처리할 서버 로직을 itemActions.ts처럼 별도의 파일이나 특정 함수에 "use server" 지시어로 정의합니다. 이 함수는 FormData를 인자로 받거나, useActionState와 함께 쓸 때는 (prevState, formData) 시그니처를 가집니다. 다음으로 서버 컴포넌트에서 <form> 요소를 렌더링하고 action 속성에 서버 액션 함수를 직접 연결합니다.
상태와 피드백이 필요하다면 클라이언트 컴포넌트를 더합니다. useFormStatus는 폼을 제출하는 동안의 로딩 상태를 UI에 표시할 때 써서 제출 버튼을 비활성화하거나 스피너를 보여 주고, useActionState는 서버 액션의 결과와 대기 상태를 클라이언트 UI에 연결해 검증 오류나 성공 메시지를 표시하는 데 씁니다. 비제어 입력의 초기화는 useActionState가 하는 일이 아니라, 함수형 form action이 정상적으로 끝났을 때 React가 success 값과 관계없이 하는 동작입니다. 마지막으로 경로는 revalidatePath, 태그의 stale-while-revalidate 갱신은 revalidateTag(tag, 'max'), 같은 서버 액션에서 곧바로 최신 값을 읽어야 할 때는 updateTag로 명시적으로 재검증합니다.
이런 폼 처리 방식은 개발 복잡도를 줄이고 성능을 높이며 사용자에게 더 나은 경험을 줍니다. 클라이언트와 서버 사이에 Route Handler를 명시적으로 호출하지 않고도 데이터 변경 로직을 구현할 수 있다는 점이 Next.js App Router의 장점입니다.
예제를 확인할 때는 결과를 나누어 봅니다. 빈 값으로는 브라우저의 required 검증이 요청을 막는지, 공백만 넣어서는 서버 trim 검증의 실패 메시지와 입력 초기화를, 유효한 이름으로는 대기 표시와 반환 메시지, 목록 갱신을 확인합니다. 연속으로 제출할 때 버튼의 비활성화는 현재 UI의 대기 표시일 뿐이며, 서버의 중복 요청 방지는 이 예제에 구현되어 있지 않습니다. 프로세스를 다시 시작해 보면 메모리 배열의 수명과 지속 저장의 차이도 확인할 수 있습니다.
정리
<form>의action에 서버 액션을 직접 연결하면, 서버 액션이FormData를 받아 서버에서 처리합니다.- 서버가 읽는 필드 이름은
id가 아니라name이며, 브라우저의required는 서버 검증을 대신하지 않습니다. useFormStatus는 자신을 감싼form의 대기 상태를 읽으므로,form안에 렌더링되는 별도의 컴포넌트에서 호출합니다.useActionState의 액션은 이전 상태와 새FormData를 차례로 받으며, 반환값이 다음 상태가 되고 대기 여부는 따로 알려 줍니다.- 함수형
formaction이 정상적으로 끝나면 비제어 입력이 초기화되므로, 초안을 남기려면 제어 입력으로 설계합니다.
연습 문제
1. 서버에 도달한 횟수와 저장한 횟수 세기
최종 TodoForm 버전에서 빈 문자열, 공백 두 칸, "읽기 연습"이라는 세 입력을 차례로 따로 제출한다고 가정합니다. required를 유지하고, 브라우저 스크립트와 네트워크는 정상이며, 초기 아이템 하나 외에 다른 요청은 없습니다. 어느 입력이 서버에 도달하고, 어느 입력이 배열과 다음 ID를 바꿀까요? 목록의 개수와 마지막 메시지, 공백을 정상적으로 실패 반환한 뒤의 입력 상태를 적어 보세요.
해설 보기
빈 문자열은 브라우저의 required가 막으므로 이 경로에서는 서버 액션이 실행되지 않습니다. 공백은 비어 있지 않아 제출되지만, 서버에서 trim한 뒤 빈 값이 되어 실패 결과를 돌려주며 배열과 다음 ID는 그대로입니다. 마지막의 유효한 입력만 저장되므로 최종 목록은 초기 아이템과 새 id=2의 두 개이고, 마지막 state.message는 성공 문구입니다.
공백의 success: false는 예외가 아니라 함수가 정상적으로 반환한 결과입니다. 이 비제어 입력은 React의 정상 완료 초기화 규칙을 따르므로, 공백이 남아 있다고 가정하지 않습니다. 서버가 예외를 던지는 경로는 이 실패 메시지의 정상 반환과 다르며 별도의 오류 처리가 필요합니다. 또한 SubmitButton의 useFormStatus는 자신을 감싼 form의 대기를 읽으므로, form 바깥으로 옮기면 같은 제출 상태를 읽지 못합니다.
2. name을 바꾼 입력
input의 id는 'todoName' 그대로 두고 name만 'taskName'으로 바꿨습니다. 서버 코드는 get('itemName')을 그대로 씁니다. TodoForm의 어댑터로 제출하면 저장과 결과 메시지는 어떻게 될까요? 다른 입력은 없습니다.
해설 보기
get('itemName')은 null을 돌려줍니다. 문자열이 아니므로 빈 이름으로 처리되어, 저장과 재검증을 하지 않고 실패 메시지를 정상적으로 돌려줍니다. id와 서버가 읽는 필드 이름은 서로 다른 역할을 합니다.
3. 실패 메시지와 대기 상태가 함께 보일 때
앞 제출의 실패 메시지가 남아 있는 상태에서 다음 유효한 입력을 보냈고, 아직 액션이 끝나지 않았습니다. state.success가 false이고 isPending이 true인 것은 모순일까요? 이때 액션이 받는 첫 번째와 두 번째 인자도 설명해 보세요.
해설 보기
모순이 아닙니다. success는 가장 최근에 끝난 실패 결과이고, isPending은 다음 요청이 처리 중이라는 상태입니다. 다음 액션은 이전 실패 결과를 첫 인자로, 이번 제출의 FormData를 두 번째 인자로 받습니다. 이 어댑터는 첫 인자를 표시 이력으로만 두고, 두 번째 인자의 새 입력을 처리합니다.
4. 제어 입력으로 초안 남기기
input을 제어 입력으로 바꾸고 초안을 문자열 상태에 보관합니다. 실패를 돌려받으면 상태를 그대로 두고, 성공 결과를 확인했을 때만 setDraft('')를 실행합니다. 다른 입력 변경 없이 실패와 성공을 차례로 받았다면 초안은 언제 비워질까요?
해설 보기
실패를 돌려받은 뒤에는 상태 문자열이 그대로 남고, 성공 결과를 확인해 setDraft('')를 실행할 때 비워집니다. 제어 상태는 비제어 입력의 자동 초기화와 별개이며, 이 답은 문제에서 정한 상태 갱신 정책에 따른 결과입니다.
5. 나란히 놓인 두 폼
나란히 놓인 form A와 B에 각각 SubmitButton이 있습니다. A만 제출 중이고 B는 요청하지 않았습니다. B의 useFormStatus가 읽는 대상은 무엇이고, 두 버튼의 비활성화가 서버의 중복 방지를 보장하는지 적어 보세요.
해설 보기
B의 버튼은 자신을 감싼 form B를 읽으므로, A의 대기를 보고 비활성화되지 않습니다. 각 대기 버튼은 현재 폼의 UI를 제어할 뿐이며, 다른 탭이나 직접 요청, 재시도까지 포함한 서버의 업무 중복 판정을 보장하지 않습니다.
다음 글 상태 관리 도구에서는 여러 컴포넌트가 상태를 함께 쓸 때 Context API와 상태 관리 라이브러리를 어떻게 고르는지 알아봅니다.