이 글은 누구를 위한 것인가
- 회원가입, 결제, 설정 폼의 UX를 개선하려는 팀
- 에러 메시지와 검증 타이밍을 올바르게 설계하려는 개발자
- 접근성 기준을 충족하는 폼을 구현하려는 팀
들어가며
"제출 버튼을 눌렀더니 빨간 에러가 와르르" — 최악의 폼 UX다. 인라인 검증은 사용자가 필드를 떠날 때(blur) 실행하고, 에러 메시지는 해당 필드 바로 아래에 표시해야 한다. 제출 전 에러는 스크롤 없이 모두 보여야 한다.
이 글은 bluefoxdev.kr의 폼 UX 디자인 패턴 가이드 를 참고하여 작성했습니다.
1. 폼 UX 설계 원칙
[에러 타이밍]
Bad: 타이핑 중 즉시 에러 표시
Bad: 제출 후에만 에러 표시
Good: blur(포커스 이탈) 시 검증
Good: 수정 후 실시간 재검증
[에러 위치]
Bad: 폼 상단에 에러 목록만
Good: 해당 필드 바로 아래
Good: 제출 실패 시 첫 에러로 포커스 이동
[에러 문구]
Bad: "유효하지 않은 이메일"
Good: "이메일 형식이 올바르지 않습니다 (예: name@example.com)"
Bad: "필수 항목"
Good: "이메일 주소를 입력해주세요"
[필수/선택 표시]
Option 1: 필수 필드에 * (범례 설명 필요)
Option 2: 선택 필드에 "(선택)" 텍스트
권장: Option 2 (더 명확)
[멀티 스텝 폼]
진행률 표시 (1/3 또는 프로그레스바)
이전 단계 데이터 유지
각 단계 검증 후 다음 단계 진행
브라우저 뒤로가기 처리
2. 폼 UX 컴포넌트 구현
import { useState, useId } from 'react';
// 접근성 친화 폼 필드
interface FormFieldProps {
label: string;
name: string;
type?: string;
required?: boolean;
optional?: boolean;
error?: string;
hint?: string;
value: string;
onChange: (value: string) => void;
onBlur?: () => void;
}
function FormField({ label, name, type = 'text', required, optional, error, hint, value, onChange, onBlur }: FormFieldProps) {
const id = useId();
const errorId = `${id}-error`;
const hintId = `${id}-hint`;
return (
<div className="form-field">
<label htmlFor={id} className="form-label">
{label}
{optional && <span className="optional-badge">(선택)</span>}
{required && <span className="required-indicator" aria-hidden>*</span>}
</label>
{hint && <p id={hintId} className="form-hint">{hint}</p>}
<input
id={id}
name={name}
type={type}
value={value}
onChange={e => onChange(e.target.value)}
onBlur={onBlur}
required={required}
aria-required={required}
aria-invalid={!!error}
aria-describedby={[hint ? hintId : '', error ? errorId : ''].filter(Boolean).join(' ') || undefined}
className={`form-input ${error ? 'form-input--error' : ''}`}
autoComplete={getAutoComplete(name)}
/>
{error && (
<p id={errorId} role="alert" className="form-error" aria-live="polite">
<svg aria-hidden width={16} height={16}><use href="#icon-error" /></svg>
{error}
</p>
)}
</div>
);
}
// 비밀번호 강도 표시
function PasswordStrength({ password }: { password: string }) {
const strength = calculateStrength(password);
const labels = ['', '약함', '보통', '강함', '매우 강함'];
const colors = ['', '#ef4444', '#f59e0b', '#3b82f6', '#10b981'];
if (!password) return null;
return (
<div className="password-strength" aria-label={`비밀번호 강도: ${labels[strength]}`}>
<div className="strength-bars">
{[1, 2, 3, 4].map(level => (
<div
key={level}
className="strength-bar"
style={{ background: level <= strength ? colors[strength] : '#e2e8f0' }}
/>
))}
</div>
<span style={{ color: colors[strength], fontSize: 12 }}>{labels[strength]}</span>
</div>
);
}
function calculateStrength(password: string): number {
let score = 0;
if (password.length >= 8) score++;
if (/[A-Z]/.test(password)) score++;
if (/[0-9]/.test(password)) score++;
if (/[^A-Za-z0-9]/.test(password)) score++;
return score;
}
function getAutoComplete(name: string): string {
const map: Record<string, string> = {
email: 'email', password: 'current-password', name: 'name',
phone: 'tel', address: 'street-address', city: 'address-level2',
};
return map[name] ?? 'off';
}
// 멀티 스텝 폼
function MultiStepForm() {
const [step, setStep] = useState(1);
const totalSteps = 3;
return (
<div>
{/* 진행률 */}
<div role="progressbar" aria-valuenow={step} aria-valuemin={1} aria-valuemax={totalSteps}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<span>단계 {step} / {totalSteps}</span>
<span>{Math.round((step / totalSteps) * 100)}%</span>
</div>
<div style={{ height: 4, background: '#e2e8f0', borderRadius: 2 }}>
<div style={{ height: '100%', width: `${(step / totalSteps) * 100}%`, background: '#3b82f6', borderRadius: 2, transition: 'width 0.3s' }} />
</div>
</div>
{step === 1 && <Step1 onNext={() => setStep(2)} />}
{step === 2 && <Step2 onNext={() => setStep(3)} onBack={() => setStep(1)} />}
{step === 3 && <Step3 onBack={() => setStep(2)} />}
</div>
);
}
function Step1({ onNext }: { onNext: () => void }) { return <button onClick={onNext}>다음</button>; }
function Step2({ onNext, onBack }: { onNext: () => void; onBack: () => void }) { return <><button onClick={onBack}>이전</button><button onClick={onNext}>다음</button></>; }
function Step3({ onBack }: { onBack: () => void }) { return <><button onClick={onBack}>이전</button><button type="submit">완료</button></>; }
마무리
폼 UX의 핵심은 "에러를 예방하고, 발생 시 즉시 명확하게 알리는 것"이다. blur 시 인라인 검증, 필드 바로 아래 에러 표시, 구체적인 에러 문구가 기본이다. aria-invalid, aria-describedby, role="alert"으로 스크린 리더 사용자도 에러를 인지할 수 있게 한다.