폼 디자인 UX 패턴: 에러 표시부터 인라인 검증까지

디자인

폼 디자인UX 패턴입력 검증접근성사용자 경험

이 글은 누구를 위한 것인가

  • 회원가입, 결제, 설정 폼의 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"으로 스크린 리더 사용자도 에러를 인지할 수 있게 한다.