
프론트엔드 개발을 처음 배울 때 폼은 보통 “사용자의 값을 입력받아 전송하는 요소”라고 배운다.
function ReservationForm() {
return (
<form>
<input name="guestName" />
<input name="guestCount" type="number" />
<button type="submit">예약하기</button>
</form>
);
}
이 설명은 input, form, button이 어떻게 함께 동작하는지 이해하기에 충분하다.
하지만 실제 예약 서비스를 개발하면 입력창을 배치하는 것보다 더 많은 판단이 필요하다.
폼을 단순한 입력창의 집합으로 보면 이 문제들은 각각 나중에 붙이는 예외 처리가 된다. 반대로 폼을 하나의 사용자 흐름으로 보면 입력, 검증, 오류, 제출과 복구가 처음부터 연결된 설계 대상이 된다.
실제 서비스에서 폼은 입력창을 모아놓은 화면이 아니라, 사용자의 의도를 유효한 서비스 요청으로 바꾸는 흐름이다.
크리스가 레스토랑 예약 화면에서 이름, 날짜, 시간과 인원수를 입력한다고 생각해 보자.
const formValues = {
guestName: "Chris",
reservationDate: "2026-09-20",
reservationTime: "19:00",
guestCount: "4",
};
브라우저에서 수집한 값은 모두 문자열에 가깝다. 그러나 서비스가 다루려는 것은 네 개의 독립된 문자열이 아니다. “크리스가 2026년 9월 20일 오후 7시에 네 명의 자리를 예약하려 한다”는 하나의 의도다.
따라서 입력값을 그대로 API에 전달하는 것만으로는 폼의 책임이 끝나지 않는다.
const command = {
guestName: formValues.guestName.trim(),
startsAt: `${formValues.reservationDate}T${formValues.reservationTime}:00`,
guestCount: Number(formValues.guestCount),
};
이 코드는 화면에 흩어진 값을 서버가 이해할 수 있는 예약 요청으로 변환한다. 공백을 정리하고, 날짜와 시간을 하나의 시작 시각으로 결합하며, 문자열로 들어온 인원수를 숫자로 바꾼다.
이때 새로운 질문이 생긴다. 숫자로 변환할 수 있다고 해서 그 값이 실제 예약에 유효한 것은 아니다. 0, -1, 1000도 JavaScript에서는 숫자다.
폼 설계는 값의 수집에서 끝나지 않는다. 사용자의 표현을 서비스가 처리할 수 있는 의미로 변환해야 한다.
다음 코드는 값이 존재하는지만 확인한다.
function validateReservation(values: ReservationFormValues) {
if (!values.guestName || !values.guestCount) {
return "필수 값을 입력해 주세요.";
}
return null;
}
이 검증은 빈 입력을 발견할 수 있지만 어떤 필드가 잘못되었는지, 어떻게 수정해야 하는지 설명하지 못한다. 날짜가 과거인지, 선택한 시간이 영업시간 안인지, 인원수가 허용 범위인지도 확인하지 않는다.
필드별로 사용자가 수정할 수 있는 정보를 반환하는 편이 낫다.
type ReservationErrors = Partial<
Record<keyof ReservationFormValues, string>
>;
function validateReservation(
values: ReservationFormValues
): ReservationErrors {
const errors: ReservationErrors = {};
if (values.guestName.trim().length < 2) {
errors.guestName =
"이름은 두 글자 이상 입력해 주세요.";
}
const guestCount = Number(values.guestCount);
if (
!Number.isInteger(guestCount) ||
guestCount < 1 ||
guestCount > 12
) {
errors.guestCount =
"인원수는 1명에서 12명 사이로 입력해 주세요.";
}
if (!values.reservationDate) {
errors.reservationDate =
"예약 날짜를 선택해 주세요.";
}
if (!values.reservationTime) {
errors.reservationTime =
"예약 시간을 선택해 주세요.";
}
return errors;
}
이제 오류는 폼 전체에 붙는 막연한 실패 메시지가 아니라 특정 입력을 수정하기 위한 정보가 된다. 사용자는 무엇을 바꿔야 하는지 알 수 있고, UI는 오류가 발생한 필드 가까이에 메시지를 표시할 수 있다.
하지만 클라이언트 검증만으로는 충분하지 않다. 브라우저의 코드는 우회할 수 있고, 오래 열린 화면의 예약 가능 정보는 이미 달라졌을 수 있다.
외부에서 들어오는 값은 검증을 통과하기 전까지 신뢰할 수 없다.
async function createReservation(
input: CreateReservationInput
) {
const parsed =
reservationSchema.safeParse(input);
if (!parsed.success) {
return {
ok: false,
code: "INVALID_INPUT",
fieldErrors:
parsed.error.flatten().fieldErrors,
};
}
const isAvailable =
await reservationRepository.isAvailable(
parsed.data.startsAt,
parsed.data.guestCount
);
if (!isAvailable) {
return {
ok: false,
code: "SLOT_UNAVAILABLE",
message:
"선택한 시간은 더 이상 예약할 수 없다.",
};
}
return reservationRepository.create(
parsed.data
);
}
서버는 값의 형식뿐 아니라 현재 좌석 상황과 같은 서비스 규칙도 다시 확인한다.
클라이언트 검증은 빠른 피드백을 위한 사용자 경험이고, 서버 검증은 데이터의 무결성을 지키는 최종 경계다.
모든 필드가 비어 있는 첫 화면에서 곧바로 빨간 오류를 보여주면 사용자는 아직 실수하지 않았는데도 실패한 것처럼 느낄 수 있다.
반대로 제출할 때까지 아무것도 알려주지 않으면 여러 필드를 한꺼번에 다시 수정해야 한다.
const shouldShowGuestCountError =
touched.guestCount &&
Boolean(errors.guestCount);
이 코드는 사용자가 인원수 필드를 한 번 확인한 뒤에만 해당 오류를 보여준다.
touched는 값이 유효한지를 나타내는 상태가 아니라, 오류를 언제 노출할지 결정하는 상호작용 상태다.
제출을 한 번 시도한 뒤에는 아직 방문하지 않은 필드의 오류도 보여줄 수 있다.
const shouldShowError = (
field: keyof ReservationFormValues
) => {
return (
Boolean(errors[field]) &&
(touched[field] || submitCount > 0)
);
};
이제 폼은 두 가지 상황을 구분한다.
작성 중에는 사용자가 다룬 필드에만 필요한 피드백을 주고, 제출이 막혔을 때는 해결해야 할 모든 문제를 보여준다.
검증 시점에 하나의 정답이 있는 것은 아니다.
이메일 형식처럼 입력 중에도 안내하기 좋은 규칙이 있고, 이름 길이처럼 포커스를 벗어날 때 확인해도 되는 규칙이 있으며, 예약 가능 여부처럼 서버에 제출해야 확정할 수 있는 규칙도 있다.
중요한 것은 검증 함수를 만드는 데 그치지 않고, 사용자가 방해받지 않으면서 문제를 발견하고 수정할 수 있는 시점을 설계하는 일이다.
서버가 실패를 하나의 문자열로만 반환한다고 생각해 보자.
return {
ok: false,
message: "예약에 실패했다.",
};
사용자는 이름을 고쳐야 하는지, 다른 시간을 선택해야 하는지, 잠시 후 다시 시도해야 하는지 알 수 없다.
오류를 해결 방법에 따라 구분하면 폼이 더 적절하게 반응할 수 있다.
type ReservationResult =
| {
ok: true;
reservationId: string;
}
| {
ok: false;
code: "INVALID_INPUT";
fieldErrors: ReservationErrors;
}
| {
ok: false;
code: "SLOT_UNAVAILABLE";
message: string;
}
| {
ok: false;
code: "SERVICE_UNAVAILABLE";
message: string;
};
INVALID_INPUT은 각 입력 가까이에 표시할 수 있다.
SLOT_UNAVAILABLE은 시간 선택 영역으로 사용자를 이동시키고 대체 시간을 제안할 수 있다.
SERVICE_UNAVAILABLE은 입력값을 유지한 채 다시 시도할 수 있도록 안내해야 한다.
오류는 개발자를 위한 실패 기록과 사용자에게 보여줄 수정 안내로도 나뉜다. 데이터베이스 연결 오류나 내부 스택 트레이스를 그대로 화면에 노출해서는 안 된다.
서버는 운영에 필요한 상세 정보를 로그에 남기고, 폼에는 사용자가 다음 행동을 결정할 수 있는 안전한 메시지를 전달해야 한다.
좋은 오류 메시지는 실패했다는 사실만 알리지 않는다. 무엇을 유지하고, 무엇을 수정하며, 다음에 무엇을 할 수 있는지 알려준다.
다음 구현은 버튼을 누를 때 요청을 보낸다.
async function handleSubmit() {
await createReservation(values);
}
요청에 시간이 걸리는 동안 사용자가 버튼을 다시 누르면 같은 예약이 여러 번 전송될 수 있다.
요청이 실패해도 화면이 아무 반응을 보이지 않을 수 있고, 성공하기 전에 페이지를 이동할 수도 있다.
폼은 제출 과정을 명시적인 상태로 표현해야 한다.
type SubmitStatus =
| "idle"
| "validating"
| "submitting"
| "succeeded"
| "failed";
이 상태는 폼이 현재 무엇을 할 수 있는지를 결정한다.
검증 중인지, 서버 응답을 기다리는지, 성공했는지, 다시 시도할 수 있는지를 UI가 구분할 수 있다.
async function handleSubmit(
event: React.FormEvent<HTMLFormElement>
) {
event.preventDefault();
if (submitStatus === "submitting") {
return;
}
const nextErrors =
validateReservation(values);
if (Object.keys(nextErrors).length > 0) {
setErrors(nextErrors);
setSubmitStatus("failed");
return;
}
setSubmitStatus("submitting");
const result =
await submitReservation(values);
if (!result.ok) {
applyReservationError(result);
setSubmitStatus("failed");
return;
}
setSubmitStatus("succeeded");
router.push(
`/reservations/${result.reservationId}`
);
}
이 구현은 이미 제출 중인 요청을 UI에서 다시 시작하지 않고, 클라이언트 오류가 있으면 서버 요청 전에 멈추며, 서버 결과에 따라 실패와 성공 흐름을 나눈다.
<button
type="submit"
disabled={submitStatus === "submitting"}
>
{submitStatus === "submitting"
? "예약하는 중…"
: "예약하기"}
</button>
버튼은 제출 중이라는 사실을 사용자에게 보여주고 중복 상호작용을 줄인다.
다만 버튼 비활성화만으로 중복 생성을 완전히 막을 수는 없다. 네트워크 재시도나 서로 다른 탭에서 같은 요청이 도착할 수 있기 때문이다.
중복이 중요한 결제나 예약 작업은 서버에서도 동일한 요청을 안전하게 처리하도록 설계해야 한다.
크리스가 19:00을 선택했다고 해서 서버에 오후 7시 예약이 존재하는 것은 아니다.
폼 안의 값은 아직 제출되지 않은 초안이다.
const reservationDraft = {
reservationDate: "2026-09-20",
reservationTime: "19:00",
guestCount: "4",
};
이 객체는 사용자가 바꿀 수 있는 임시 상태다.
서버가 생성한 예약만이 확정된 사실이다.
const confirmedReservation = {
id: "res_8f31",
startsAt:
"2026-09-20T19:00:00+10:00",
guestCount: 4,
status: "confirmed",
};
확정된 예약에는 서버가 부여한 ID, 시간대가 포함된 시작 시각과 상태가 있다.
성공 응답을 받기 전에는 폼의 초안을 확정된 예약처럼 취급해서는 안 된다.
이 구분은 Source of Truth를 명확하게 만든다.
성공 후에도 기존 초안을 그대로 확정 데이터로 사용하면 서버가 보정한 시간대, 가격, 상태나 식별자를 놓칠 수 있다.
성공 화면은 서버가 반환한 결과 또는 다시 조회한 예약을 기준으로 표시해야 한다.
예약 값은 입력창에서 데이터베이스로 한 번에 이동하지 않는다.
flowchart LR
A[사용자 입력] --> B[폼 초안]
B --> C[클라이언트 검증]
C --> D[예약 요청]
D --> E[서버 검증]
E --> F[예약 확정]
사용자 입력은 먼저 수정 가능한 폼 초안이 된다.
클라이언트는 빠르게 확인할 수 있는 오류를 알려준다. 서버는 입력 형식과 최신 예약 가능 여부를 다시 검증한다.
모든 규칙을 통과하고 데이터베이스에 저장된 뒤에야 예약이 확정된다.
각 경계에는 다른 책임이 있다.
| 경계 | 주요 책임 |
|---|---|
| 입력 요소 | 값을 입력하고 수정할 수 있게 한다 |
| 폼 상태 | 제출 전 초안과 상호작용 상태를 유지한다 |
| 클라이언트 검증 | 빠르게 수정 가능한 오류를 안내한다 |
| 요청 변환 | 화면 값을 서버 계약에 맞게 변환한다 |
| 서버 검증 | 신뢰 경계에서 형식과 서비스 규칙을 확인한다 |
| 데이터베이스 | 확정된 예약을 서비스의 사실로 보존한다 |
폼 라이브러리, 스키마 검증 도구와 서버 액션은 이 흐름을 구현하는 수단이다.
어떤 도구를 고르기 전에 각 단계의 책임과 실패가 다음 단계에 어떻게 전달되는지를 정해야 한다.
다음 상태만으로도 입력값은 보관할 수 있다.
const [values, setValues] = useState({
guestName: "",
reservationDate: "",
reservationTime: "",
guestCount: "2",
});
하지만 실제 폼의 사용자 경험을 표현하려면 값 이외의 상태도 필요할 수 있다.
type ReservationFormState = {
values: ReservationFormValues;
touched: Partial<
Record<
keyof ReservationFormValues,
boolean
>
>;
fieldErrors: ReservationErrors;
formError: string | null;
submitStatus: SubmitStatus;
submitCount: number;
};
values는 사용자가 작성한 초안이고, touched는 어떤 입력과 상호작용했는지를 기록한다.
fieldErrors와 formError는 수정 위치가 다른 실패를 나누며, submitStatus와 submitCount는 제출 흐름을 표현한다.
그렇다고 모든 폼에 이 상태를 직접 만들 필요는 없다.
검색창처럼 값 하나를 즉시 URL에 반영하는 폼과 여러 단계의 예약 폼은 복잡도가 다르다. 폼 상태 관리 도구는 반복되는 상태 전이와 검증 연결을 줄여줄 수 있지만, 도구가 어떤 상태를 대신 관리하는지 이해해야 한다.
폼의 복잡도는 입력창 개수로만 결정되지 않는다.
검증 규칙, 비동기 확인, 단계 이동, 임시 저장, 오류 복구와 제출 이후의 행동이 복잡도를 만든다.
간단한 예약 폼은 화면을 떠나면 값을 버려도 된다.
긴 단체 예약 신청서는 실수로 새로고침해도 작성 내용을 복구할 필요가 있을 수 있다.
useEffect(() => {
sessionStorage.setItem(
"reservation-draft",
JSON.stringify(values)
);
}, [values]);
이 코드는 현재 탭의 세션 동안 초안을 복구할 수 있게 한다.
그러나 모든 폼 값을 브라우저 저장소에 넣는 것이 기본 선택은 아니다.
연락처, 건강 정보, 결제 정보처럼 민감한 값은 저장 자체가 위험을 늘릴 수 있다. 여러 기기에서 이어 작성해야 한다면 인증된 사용자의 서버 초안으로 관리하는 편이 적절할 수 있다.
짧고 쉽게 다시 입력할 수 있는 값이라면 화면 상태로만 유지하는 편이 단순하다.
| 초안의 성격 | 적합한 관리 위치 |
|---|---|
| 현재 화면에서만 필요한 짧은 입력 | 컴포넌트 또는 폼 상태 |
| 같은 탭의 새로고침에서만 복구 | Session Storage |
| 브라우저를 다시 열어도 복구 | Local Storage, 민감도 검토 필요 |
| 여러 기기에서 이어서 작성 | 인증된 서버 초안 |
| 확정되어 다른 사용자와 공유 | Database의 확정 데이터 |
저장 위치는 편리함만으로 결정하지 않는다.
값의 민감도, 필요한 생명주기, 공유 범위와 삭제 시점을 함께 판단해야 한다.
입력창 위에 텍스트가 보인다고 해서 브라우저가 그 텍스트를 라벨로 이해하는 것은 아니다.
<div>
<span>예약 인원</span>
<input type="number" />
<span>인원수를 확인해 주세요.</span>
</div>
이 구조는 시각적으로는 이해할 수 있지만, 입력과 이름 및 오류의 관계가 프로그램적으로 연결되어 있지 않다.
<div>
<label htmlFor="guestCount">
예약 인원
</label>
<input
id="guestCount"
name="guestCount"
type="number"
min={1}
max={12}
aria-invalid={
Boolean(errors.guestCount)
}
aria-describedby={
errors.guestCount
? "guestCount-error"
: undefined
}
/>
{errors.guestCount && (
<p
id="guestCount-error"
role="alert"
>
{errors.guestCount}
</p>
)}
</div>
label은 입력의 이름을 연결한다.
aria-invalid는 현재 값에 오류가 있음을 알리고, aria-describedby는 오류 메시지를 해당 입력과 연결한다.
사용자는 마우스를 사용하지 않아도 입력의 목적과 문제를 이해할 수 있다.
제출에 실패했을 때 첫 번째 오류 필드로 포커스를 옮기거나 폼 상단에 오류 요약을 제공하는 것도 도움이 된다.
색상만으로 오류를 구분해서는 안 되며, 기본 form 제출 동작을 유지하면 Enter 키와 보조 기술의 기대에도 더 잘 맞는다.
접근성은 완성된 폼에 속성을 몇 개 추가하는 마무리 작업이 아니다.
입력하고, 오류를 발견하고, 수정하고, 제출하는 흐름을 다양한 사용자가 수행할 수 있게 만드는 설계다.
예약 폼이 날짜 선택, 인원 정보, 연락처 확인의 세 단계로 길어질 수 있다.
type ReservationStep =
| "schedule"
| "guestDetails"
| "review";
단계를 나누는 것은 화면을 여러 장으로 분리하는 일이 아니다.
각 단계가 언제 완료되며, 뒤로 이동할 때 무엇을 유지하고, 어느 시점에 서버 확인이 필요한지를 정하는 일이다.
function canContinue(
step: ReservationStep,
values: ReservationFormValues
) {
if (step === "schedule") {
return Boolean(
values.reservationDate &&
values.reservationTime &&
values.guestCount
);
}
if (step === "guestDetails") {
return (
values.guestName.trim().length >= 2
);
}
return true;
}
이 코드는 단계별 진행 조건을 분리한다.
다만 값이 존재한다는 사실만으로 좌석이 확보되는 것은 아니므로, 일정 단계에서 다음으로 이동할 때 서버의 최신 가능 여부를 확인할 수도 있다.
마지막 검토 화면에서는 원본 입력을 복사해 또 다른 상태로 만들기보다 같은 초안에서 요약을 계산하는 편이 안전하다.
동일한 정보를 여러 상태에 복제하면 앞 단계에서 값을 수정했을 때 검토 화면과 달라질 수 있다.
const form = useForm();
도구를 도입해도 검증 시점, 서버 오류의 위치, 초안의 생명주기와 제출 이후의 흐름은 자동으로 결정되지 않는다.
먼저 폼이 표현해야 할 상태와 전이를 정한 뒤, 반복 구현을 줄여주는 도구를 선택한다.
<input
name="guestCount"
min={1}
max={12}
/>
HTML 제약은 사용자 실수를 줄이지만 요청 자체를 신뢰할 수 있게 만들지는 않는다.
서버는 모든 외부 입력의 형식과 서비스 규칙을 다시 검증해야 한다.
{error && (
<p>입력값이 잘못되었다.</p>
)}
어느 값을 어떻게 고쳐야 하는지 알 수 없다.
필드에서 해결할 수 있는 오류는 해당 입력과 연결하고, 전체 흐름의 오류는 폼 수준에서 설명한다.
setValues(initialValues);
await submitReservation(values);
요청이 실패하면 사용자는 작성한 내용을 잃는다.
서버가 성공을 확정한 뒤 초기화하거나 다음 화면으로 이동한다.
<button disabled={isSubmitting}>
예약하기
</button>
UI의 중복 클릭은 줄일 수 있지만 네트워크 재시도나 여러 탭에서 들어오는 동일 요청까지 막지는 못한다.
중복 생성의 비용이 큰 작업은 서버에서도 동일한 요청을 안전하게 처리해야 한다.
setReservation(values);
입력값은 사용자의 의도이고, 예약 데이터는 서버가 규칙을 확인한 뒤 확정한 사실이다.
성공 응답 전에는 초안을 확정 데이터로 사용하지 않는다.
<input
className={
hasError
? "border-red"
: "border-gray"
}
/>
색을 구분하기 어렵거나 화면을 보지 않는 사용자는 오류의 존재와 이유를 알 수 없다.
텍스트 메시지, 입력과의 연결, 포커스 이동과 의미 있는 상태를 함께 제공한다.
폼은 사용자가 값을 입력하고 서버로 전송하기 위한 HTML 구조다.
<form onSubmit={handleSubmit}>
<input name="guestName" />
<button type="submit">
예약하기
</button>
</form>
이 정의는 폼의 기본 동작을 이해하기에 충분하다.
하지만 실제 서비스의 폼은 다음 흐름을 함께 설계해야 한다.
좋은 폼은 입력창을 많이 배치한 화면이 아니다.
사용자가 지금 무엇을 입력해야 하는지 이해하게 한다. 잘못된 값이 있다면 적절한 시점에 구체적으로 알려준다.
제출 중에는 진행 상태를 보여주고, 실패하더라도 작성한 내용을 잃지 않은 채 다음 행동을 선택할 수 있게 한다.
서버가 확정하기 전의 초안과 확정된 데이터를 구분하며, 신뢰할 수 없는 외부 입력은 서버의 경계에서 다시 검증한다.
결국 폼을 설계한다는 것은 다음 질문에 답하는 일이다.
사용자의 불완전한 입력이 어떤 검증과 오류 복구를 거쳐 신뢰할 수 있는 서비스 요청이 되는가?
폼은 입력창을 모아놓은 화면이 아니다.
사용자의 의도를 유효한 서비스 요청으로 바꾸는 흐름이다.