회원가입이나 신청서처럼 입력 항목이 많은 경우, 멀티 스텝(Form Wizard) 폼은 이탈률을 낮추고 전환율을 높이는 데 효과적입니다. 이 글에서는 React로 가벼운 의존성으로 구현하는 실무형 멀티 스텝 폼 패턴을 소개하고, 검증, 진행 표시, 임시 저장, 접근성, 라이브러리 활용까지 단계별로 설명합니다.
1. 구현 목표와 설계
작게 시작합니다. 핵심은 스텝 정의, 상태 관리, 검증, 내비게이션입니다. 각 스텝은 필요한 필드만 검증하고, 다음으로 진행할 때만 현재 스텝을 통과합니다. 마지막에는 서버로 한번 제출합니다. 다음 요건을 목표로 합니다: 스텝 전환, 단계별 검증, 진행 표시, 임시 저장(localStorage), 접근성 속성, 최종 비동기 제출과 에러 처리.
2. 기본 구조와 상태 설계
스텝 메타데이터 배열을 두고, 현재 스텝 인덱스와 폼 데이터, 에러 상태를 useState로 관리합니다. 에러는 필드 키 기반 객체로 관리해 스텝별로 표시합니다. 임시 저장은 폼 데이터 변경 시 localStorage에 반영합니다.
3. 최소 구현 예시 (상태, 검증, 진행 표시, 임시 저장, 제출)
import React, { useEffect, useMemo, useState } from "react";
const steps = [
{ id: "account", title: "계정" },
{ id: "profile", title: "프로필" },
{ id: "confirm", title: "확인" },
];
const initialData = {
email: "",
password: "",
name: "",
age: "",
terms: false,
};
function validate(values, stepId) {
const errors = {};
if (stepId === "account") {
if (!values.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email)) {
errors.email = "올바른 이메일을 입력해주세요.";
}
if (!values.password || values.password.length < 8) {
errors.password = "비밀번호는 8자 이상이어야 합니다.";
}
}
if (stepId === "profile") {
if (!values.name.trim()) errors.name = "이름을 입력해주세요.";
const ageNum = Number(values.age);
if (!Number.isFinite(ageNum) || ageNum < 14) errors.age = "만 14세 이상만 가입 가능합니다.";
if (!values.terms) errors.terms = "약관에 동의해야 합니다.";
}
return errors;
}
export default function MultiStepForm() {
const [current, setCurrent] = useState(0);
const [values, setValues] = useState(() => {
try {
const draft = localStorage.getItem("signup_draft");
return draft ? { ...initialData, ...JSON.parse(draft) } : initialData;
} catch {
return initialData;
}
});
const [errors, setErrors] = useState({});
const [submitting, setSubmitting] = useState(false);
const step = steps[current];
const progress = useMemo(() => Math.round((current / (steps.length - 1)) * 100), [current]);
useEffect(() => {
localStorage.setItem("signup_draft", JSON.stringify(values));
}, [values]);
function setField(key, v) {
setValues((prev) => ({ ...prev, [key]: v }));
setErrors((prev) => ({ ...prev, [key]: undefined }));
}
function next() {
const v = validate(values, step.id);
setErrors(v);
if (Object.keys(v).length === 0) setCurrent((i) => Math.min(i + 1, steps.length - 1));
}
function back() {
setCurrent((i) => Math.max(i - 1, 0));
}
async function submitFinal(e) {
e.preventDefault();
// 전체 검증(마지막 제출 시 한 번 더)
const vAll = { ...validate(values, "account"), ...validate(values, "profile") };
setErrors(vAll);
if (Object.keys(vAll).length > 0) {
// 에러가 있으면 해당 스텝으로 이동
if (vAll.email || vAll.password) setCurrent(0);
else if (vAll.name || vAll.age || vAll.terms) setCurrent(1);
return;
}
try {
setSubmitting(true);
await new Promise((r) => setTimeout(r, 800)); // API 요청 대체
localStorage.removeItem("signup_draft");
alert("가입이 완료되었습니다!");
} catch (err) {
alert("제출 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.");
} finally {
setSubmitting(false);
}
}
return (
<form onSubmit={submitFinal} noValidate>
<div aria-hidden className="progress" style={{ height: 6, background: "#eee", borderRadius: 4, marginBottom: 12 }}>
<div style={{ width: `${progress}%`, height: "100%", background: "#4f46e5", transition: "width .2s ease" }} />
</div>
<p style={{ marginTop: 0 }}>{progress}% 진행됨</p>
{/* 스텝 헤더 */}
<h2>{current + 1}. {step.title}</h2>
{step.id === "account" && (
<fieldset>
<legend className="sr-only">계정 정보</legend>
<label>이메일<br />
<input
type="email"
value={values.email}
onChange={(e) => setField("email", e.target.value)}
aria-invalid={Boolean(errors.email) || undefined}
aria-describedby={errors.email ? "err-email" : undefined}
/>
</label>
{errors.email && <div id="err-email" role="alert" style={{ color: "#b91c1c" }}>{errors.email}</div>}
<label>비밀번호<br />
<input
type="password"
value={values.password}
onChange={(e) => setField("password", e.target.value)}
aria-invalid={Boolean(errors.password) || undefined}
aria-describedby={errors.password ? "err-password" : undefined}
/>
</label>
{errors.password && <div id="err-password" role="alert" style={{ color: "#b91c1c" }}>{errors.password}</div>}
</fieldset>
)}
{step.id === "profile" && (
<fieldset>
<legend className="sr-only">프로필 정보</legend>
<label>이름<br />
<input
type="text"
value={values.name}
onChange={(e) => setField("name", e.target.value)}
aria-invalid={Boolean(errors.name) || undefined}
aria-describedby={errors.name ? "err-name" : undefined}
/>
</label>
{errors.name && <div id="err-name" role="alert" style={{ color: "#b91c1c" }}>{errors.name}</div>}
<label>나이<br />
<input
type="number"
inputMode="numeric"
value={values.age}
onChange={(e) => setField("age", e.target.value)}
aria-invalid={Boolean(errors.age) || undefined}
aria-describedby={errors.age ? "err-age" : undefined}
/>
</label>
{errors.age && <div id="err-age" role="alert" style={{ color: "#b91c1c" }}>{errors.age}</div>}
<label style={{ display: "block", marginTop: 8 }}>
<input
type="checkbox"
checked={values.terms}
onChange={(e) => setField("terms", e.target.checked)}
aria-invalid={Boolean(errors.terms) || undefined}
aria-describedby={errors.terms ? "err-terms" : undefined}
/> 약관에 동의합니다
</label>
{errors.terms && <div id="err-terms" role="alert" style={{ color: "#b91c1c" }}>{errors.terms}</div>}
</fieldset>
)}
{step.id === "confirm" && (
<section aria-live="polite">
<p>입력 내용을 확인하세요.</p>
<ul>
<li>이메일: {values.email}</li>
<li>이름: {values.name}</li>
<li>나이: {values.age}</li>
<li>약관 동의: {values.terms ? "예" : "아니오"}</li>
</ul>
</section>
)}
<div style={{ display: "flex", gap: 8, marginTop: 16 }}>
<button type="button" onClick={back} disabled={current === 0 || submitting}>이전</button>
{current < steps.length - 1 ? (
<button type="button" onClick={next}>다음</button>
) : (
<button type="submit" disabled={submitting}>{submitting ? "제출 중..." : "제출"}</button>
)}
</div>
</form>
);
}
위 예시는 실무에서 바로 사용할 수 있는 최소 패턴입니다. 스텝마다 필요한 필드만 검증하고, 마지막에 전체 검증을 한 번 더 수행한 후 서버에 제출합니다. 초안은 localStorage에 저장해 새로고침에도 데이터가 유지됩니다.
4. 검증 전략 팁
스텝 수준의 국소 검증을 먼저 적용합니다. Next 버튼 클릭 시 현재 스텝 필드만 검증해 사용자 흐름을 끊지 않도록 합니다. 제출 단계에서는 전체 검증을 재실행해 경합상태나 미처리 필드를 방지합니다. 숫자 입력은 Number, z.coerce.number() 등을 활용해 문자열 입력을 안전하게 수용합니다.
5. 진행 상태와 피드백
퍼센트 바와 현재 스텝/총 스텝 텍스트를 같이 노출합니다. aria-live와 role="alert"를 이용해 스크린 리더 사용자에게 오류를 즉시 전달합니다. 버튼 상태는 submitting 동안 비활성화합니다.
6. 되돌아가기, 임시저장, 복구
Back 버튼으로 스텝을 자유롭게 이동할 수 있게 하고, 초안 저장은 변경 시마다 localStorage에 반영합니다. 제출 성공 시에는 초안을 삭제합니다. 제품 요구에 따라 자동 저장 주기를 debounce로 최적화할 수 있습니다.
7. React Hook Form + Zod로 확장
복잡한 폼에서는 react-hook-form과 zod/yup으로 선언적 검증과 퍼포먼스를 얻을 수 있습니다. 스텝별 필드만 trigger로 검증하고, 최종 제출 시에는 전체 trigger를 호출합니다.
import React, { useState } from "react";
import { useForm, FormProvider } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
name: z.string().min(1),
age: z.coerce.number().min(14),
terms: z.literal(true),
});
const steps = [
{ id: "account", fields: ["email", "password"] },
{ id: "profile", fields: ["name", "age", "terms"] },
{ id: "confirm", fields: [] },
];
export default function RHFWizard() {
const [current, setCurrent] = useState(0);
const methods = useForm({
resolver: zodResolver(schema),
mode: "onChange",
defaultValues: { email: "", password: "", name: "", age: "", terms: false },
});
const { register, trigger, handleSubmit, formState: { errors, isSubmitting }, getValues } = methods;
async function next() {
const ok = await trigger(steps[current].fields);
if (ok) setCurrent((i) => i + 1);
}
function back() { setCurrent((i) => Math.max(i - 1, 0)); }
async function onSubmit(data) {
// 전체 유효성 검증은 handleSubmit이 보장
await new Promise((r) => setTimeout(r, 600));
alert("제출 성공: " + JSON.stringify(data));
}
return (
<FormProvider {...methods}>
<form onSubmit={handleSubmit(onSubmit)}>
{current === 0 && (
<>
<input placeholder="이메일" {...register("email")} />
{errors.email && <p role="alert">{errors.email.message}</p>}
<input type="password" placeholder="비밀번호" {...register("password")} />
{errors.password && <p role="alert">{errors.password.message}</p>}
</>
)}
{current === 1 && (
<>
<input placeholder="이름" {...register("name")} />
{errors.name && <p role="alert">{errors.name.message}</p>}
<input type="number" placeholder="나이" {...register("age")} />
{errors.age && <p role="alert">{errors.age.message}</p>}
<label><input type="checkbox" {...register("terms")} /> 약관 동의</label>
{errors.terms && <p role="alert">{errors.terms.message}</p>}
</>
)}
{current === 2 && <pre>{JSON.stringify(getValues(), null, 2)}</pre>}
<div>
<button type="button" onClick={back} disabled={current === 0}>이전</button>
{current < steps.length - 1 ? (
<button type="button" onClick={next}>다음</button>
) : (
<button type="submit" disabled={isSubmitting}>제출</button>
)}
</div>
</form>
</FormProvider>
);
}
8. URL과 스텝 동기화
스텝을 쿼리스트링으로 노출하면 새로고침/공유에 유리합니다. 변경 시 pushState로 동기화하고, 초기 마운트에서 파싱해 current를 설정합니다.
import { useEffect } from "react";
function useStepQuery(current, setCurrent, total) {
useEffect(() => {
const sp = new URLSearchParams(window.location.search);
const s = Number(sp.get("step"));
if (Number.isFinite(s) && s >= 1 && s <= total) setCurrent(s - 1);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const sp = new URLSearchParams(window.location.search);
sp.set("step", String(current + 1));
window.history.replaceState({}, "", `${window.location.pathname}?${sp.toString()}`);
}, [current]);
}
9. 접근성 체크포인트
fieldset/legend로 그룹화하고, 오류 메시지에는 role="alert"와 aria-describedby를 연결합니다. 진행 상태는 텍스트로도 제공하며, 버튼 라벨은 명확하게 표기합니다. 키보드로만 모든 흐름이 가능해야 합니다.
10. 실무 체크리스트
다음 버튼은 현재 스텝 검증 통과 시에만 진행합니다. 서버 에러는 폼 상단 전역 경고와 필드 단위 에러로 동시에 안내합니다. 제출 중 중복 클릭 방지와 낙관적 UI를 고려합니다. 초안 보관 정책과 개인정보 보관 기간을 명확히 합니다.
11. 마무리
멀티 스텝 폼은 정보량을 분산해 완수율을 높입니다. 위 패턴을 시작점으로 필요에 따라 react-hook-form, zod, 전역 상태, URL 동기화를 결합해 확장하세요. 가장 중요한 것은 단계별 검증과 명확한 피드백, 그리고 끊김 없는 내비게이션입니다.
'React' 카테고리의 다른 글
| React에서 JSON Schema 기반 폼 생성하기 (0) | 2026.05.18 |
|---|---|
| React 앱에서 Toast/Notification 시스템 구축하기 (0) | 2026.05.16 |
| React 앱에서 클립보드 API 사용하기 (0) | 2026.05.15 |
| React에서 Lottie 애니메이션 통합하기 (0) | 2026.05.15 |
| React로 마이크로 프론트엔드 구현하기 (0) | 2026.05.14 |