본문으로 바로가기
리옵트 핸드북
리옵트 핸드북
AI 시대의 디자인 시스템

기반 설계

토큰 아키텍처컴포넌트 명세접근성 내장

AI 워크플로우

프롬프트 인터페이스DESIGN.md 인터페이스에이전틱 디자인 품질 제어워크플로우 전략AI 기반 DS 진화컨텍스트 주입

사용자 경험

일관성 패턴인터랙션 설계폼 & 데이터 입력

차세대 인터페이스

에이전트 UI 프로토콜생성형 UI공간·멀티모달 인터페이스

실전

실행 플레이북마이그레이션 전략거버넌스 & 협업사례 연구

운영

검증 체크리스트업데이트 로그
핸드북›AI 시대의 디자인 시스템›폼 & 데이터 입력
한국어English

폼 & 데이터 입력

FormField 합성, Zod 검증, onBlur 검증 타이밍, 에러 메시지·마이크로카피 표준화, 다단계·동적·조건부 폼 등 AI가 일관된 폼을 생성하기 위한 패턴.

핵심 요약

  • 모든 입력은 FormField(Label·Control·Description·Error) 합성으로 감싸 id·aria-describedby·aria-invalid를 자동 연결합니다.
  • Zod 스키마로 검증 규칙을 정의하고, 검증 타이밍은 UX 균형을 고려해 onBlur를 기본으로 권장합니다(검색·필터는 onChange+debounce).
  • 에러 메시지는 "문제 + 해결" 형태로 사용자를 탓하지 않게 쓰고 errorMessages/copy.errors에 모아서 정의합니다.
  • 버튼·레이블·플레이스홀더 같은 마이크로카피를 Copy Tokens로 데이터화하면, AI가 "새 문장 생성" 대신 "키 선택"을 하므로 톤 드리프트를 막습니다.
  • 다단계(Wizard), 동적 필드(useFieldArray), 조건부 필드(watch) 패턴으로 복잡한 폼을 합성합니다.

폼은 사용자와 시스템을 잇는 핵심 인터페이스입니다. AI가 일관된 폼을 만들려면 검증 패턴과 에러 처리를 체계적으로 정의해 둬야 합니다.

폼 아키텍처

폼 필드 컴포넌트

FormField 구조

// components/form/form-field.tsx

interface FormFieldProps {
  children: React.ReactNode
  /** 필드 이름 (form 상태 키) */
  name: string
  /** 필수 여부 */
  required?: boolean
}

interface FormFieldContextValue {
  name: string
  id: string
  error?: string
  required?: boolean
}

const FormFieldContext = createContext<FormFieldContextValue | null>(null)

/**
 * 폼 필드 래퍼
 * @ai-hint 모든 입력 필드는 FormField로 감싸야 함
 * Label, Input, Description, Error 자동 연결
 */
export function FormField({ children, name, required }: FormFieldProps) {
  const id = useId()
  const { errors } = useFormContext()
  const error = errors[name]?.message

  return (
    <FormFieldContext.Provider value={{ name, id, error, required }}>
      <div className="space-y-2">{children}</div>
    </FormFieldContext.Provider>
  )
}

// 하위 컴포넌트
FormField.Label = FormLabel
FormField.Control = FormControl
FormField.Description = FormDescription
FormField.Error = FormError

완성된 폼 필드 예시

// 사용 예시
<FormField name="email" required>
  <FormField.Label>이메일</FormField.Label>
  <FormField.Control>
    <Input type="email" placeholder="name@example.com" />
  </FormField.Control>
  <FormField.Description>비즈니스 이메일을 입력하세요.</FormField.Description>
  <FormField.Error />
</FormField>

컴포넌트 구현

// components/form/form-label.tsx

function FormLabel({ children }: { children: React.ReactNode }) {
  const { id, required, error } = useFormFieldContext()

  return (
    <Label
      htmlFor={id}
      className={cn(error && 'text-destructive')}
    >
      {children}
      {required && <span className="text-destructive ml-1">*</span>}
    </Label>
  )
}

// components/form/form-control.tsx

function FormControl({ children }: { children: React.ReactElement }) {
  const { id, name, error } = useFormFieldContext()
  const { register } = useFormContext()

  return cloneElement(children, {
    id,
    'aria-describedby': error ? `${id}-error` : undefined,
    'aria-invalid': !!error,
    ...register(name),
  })
}

// components/form/form-error.tsx

function FormError() {
  const { id, error } = useFormFieldContext()

  if (!error) return null

  return (
    <p
      id={`${id}-error`}
      role="alert"
      className="text-sm text-destructive"
    >
      {error}
    </p>
  )
}

검증 패턴

검증 스키마 (Zod)

// schemas/user.ts

import { z } from 'zod'

export const userSchema = z
  .object({
    email: z.string().min(1, '이메일을 입력하세요.').email('올바른 이메일 형식이 아닙니다.'),

    password: z
      .string()
      .min(1, '비밀번호를 입력하세요.')
      .min(8, '비밀번호는 8자 이상이어야 합니다.')
      .regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, '영문 대/소문자와 숫자를 포함해야 합니다.'),

    confirmPassword: z.string().min(1, '비밀번호 확인을 입력하세요.'),

    name: z
      .string()
      .min(1, '이름을 입력하세요.')
      .min(2, '이름은 2자 이상이어야 합니다.')
      .max(50, '이름은 50자 이하여야 합니다.'),

    phone: z
      .string()
      .optional()
      .refine(
        (val) => !val || /^01[0-9]-?\d{4}-?\d{4}$/.test(val),
        '올바른 전화번호 형식이 아닙니다.'
      ),

    birthDate: z
      .date()
      .optional()
      .refine((val) => !val || val <= new Date(), '미래 날짜는 선택할 수 없습니다.'),

    agreeTerms: z.literal(true, {
      errorMap: () => ({ message: '약관에 동의해주세요.' }),
    }),
  })
  .refine((data) => data.password === data.confirmPassword, {
    message: '비밀번호가 일치하지 않습니다.',
    path: ['confirmPassword'],
  })

export type UserFormData = z.infer<typeof userSchema>

검증 타이밍

// hooks/use-form-validation.ts

type ValidationMode = 'onChange' | 'onBlur' | 'onSubmit' | 'all'

interface UseFormValidationOptions<T extends FieldValues> {
  schema: z.ZodSchema<T>
  mode?: ValidationMode
  reValidateMode?: 'onChange' | 'onBlur'
}

/**
 * 폼 검증 훅
 * @ai-hint 기본 mode는 'onBlur' 권장
 * - 회원가입/복잡한 폼: mode='onBlur'
 * - 검색/필터: mode='onChange' + debounce
 * - 간단한 폼: mode='onSubmit'
 */
export function useFormValidation<T extends FieldValues>({
  schema,
  mode = 'onBlur',
  reValidateMode = 'onChange',
}: UseFormValidationOptions<T>) {
  return useForm<T>({
    resolver: zodResolver(schema),
    mode,
    reValidateMode,
  })
}

에러 메시지 시스템

에러 메시지 가이드라인

// constants/error-messages.ts

/**
 * 에러 메시지 작성 규칙
 * 1. 무엇이 잘못되었는지 명확히
 * 2. 어떻게 수정하는지 안내
 * 3. 사용자 탓하지 않기
 * 4. 기술 용어 피하기
 */

export const errorMessages = {
  required: (field: string) => `${field}을(를) 입력하세요.`,
  minLength: (field: string, min: number) => `${field}은(는) ${min}자 이상이어야 합니다.`,
  maxLength: (field: string, max: number) => `${field}은(는) ${max}자 이하여야 합니다.`,
  email: '올바른 이메일 형식이 아닙니다. (예: name@example.com)',
  phone: '올바른 전화번호 형식이 아닙니다. (예: 010-1234-5678)',
  password: {
    weak: '비밀번호가 너무 약합니다. 영문 대/소문자, 숫자를 포함하세요.',
    mismatch: '비밀번호가 일치하지 않습니다.',
  },
  date: {
    future: '미래 날짜는 선택할 수 없습니다.',
    past: '과거 날짜는 선택할 수 없습니다.',
    invalid: '올바른 날짜 형식이 아닙니다.',
  },
  file: {
    size: (max: string) => `파일 크기는 ${max} 이하여야 합니다.`,
    type: (types: string[]) => `${types.join(', ')} 파일만 업로드할 수 있습니다.`,
  },
  network: '네트워크 오류가 발생했습니다. 잠시 후 다시 시도하세요.',
  server: '서버 오류가 발생했습니다. 문제가 지속되면 고객센터에 문의하세요.',
  unauthorized: '세션이 만료되었습니다. 다시 로그인하세요.',
} as const

에러 표시 패턴

// 폼 레벨 에러 표시
function FormError({ error }: { error?: string }) {
  if (!error) return null

  return (
    <Alert variant="destructive" className="mb-4">
      <AlertCircle className="h-4 w-4" />
      <AlertTitle>오류</AlertTitle>
      <AlertDescription>{error}</AlertDescription>
    </Alert>
  )
}

// 사용 예시
;<form onSubmit={handleSubmit}>
  <FormError error={formError} />

  <FormField name="email">{/* ... */}</FormField>

  <Button type="submit" loading={isSubmitting}>
    제출
  </Button>
</form>

마이크로카피 (콘텐츠 시스템)

AI가 UI를 만들 때 가장 먼저 무너지는 것 중 하나가 “텍스트의 일관성”입니다. 버튼/레이블/플레이스홀더/에러 문구는 디자인 토큰처럼 표준화할수록 품질이 올라갑니다.

원칙 (최소 규칙)

  • 동사 + 목적어로 시작: 저장, 추가, 삭제, 업데이트
  • 같은 의미는 같은 단어: 확인/완료/저장을 섞어 쓰지 않기
  • 문제 + 해결 형태의 에러: 비밀번호가 일치하지 않습니다. 다시 확인해주세요.
  • 사용자 탓 금지: 잘못 입력했습니다 ❌ → 입력을 확인해주세요 ✅

표준 레이블(예시)

의도권장피하기
기본 확인확인OK
취소취소닫기(의미가 다름)
저장저장완료(모호)
삭제삭제제거(혼용 금지)
재시도다시 시도Retry

Copy Tokens (선택: 코드로 관리)

문구를 데이터로 관리하면 AI나 도구가 “새 문장 생성” 대신 “키 선택”을 하게 됩니다.

// copy/ko.ts

export const copy = {
  actions: {
    save: '저장',
    cancel: '취소',
    confirm: '확인',
    delete: '삭제',
    retry: '다시 시도',
  },
  labels: {
    email: '이메일',
    password: '비밀번호',
    phone: '전화번호',
  },
  placeholders: {
    email: 'name@example.com',
    search: '검색어를 입력하세요',
  },
  errors: {
    required: (field: string) => `${field}을(를) 입력하세요.`,
    invalidEmail: '올바른 이메일 형식이 아닙니다. (예: name@example.com)',
  },
} as const

AI 컨텍스트 주입 팁

  • CLAUDE.md에 “마이크로카피” 섹션을 넣고 표준 문구 우선 사용을 강제
  • 에러 메시지는 가능한 한 errorMessages/copy.errors 같은 중앙 정의를 사용
  • 신규 문구가 필요하면 PR에서 “키 추가”로 리뷰 포인트를 만들기 (문구가 drift 되지 않게)

복잡한 폼 합성

다단계 폼 (Wizard)

// components/form/multi-step-form.tsx

interface Step {
  id: string
  title: string
  description?: string
  schema: z.ZodSchema
  component: React.ComponentType<{ control: Control }>
}

interface MultiStepFormProps {
  steps: Step[]
  onComplete: (data: Record<string, any>) => Promise<void>
}

/**
 * 다단계 폼
 * @ai-hint 긴 폼을 논리적 단계로 분할할 때 사용
 */
export function MultiStepForm({ steps, onComplete }: MultiStepFormProps) {
  const [currentStep, setCurrentStep] = useState(0)
  const [formData, setFormData] = useState<Record<string, any>>({})

  const currentStepConfig = steps[currentStep]
  const isLastStep = currentStep === steps.length - 1

  const form = useForm({
    resolver: zodResolver(currentStepConfig.schema),
    defaultValues: formData,
  })

  const handleNext = async (data: Record<string, any>) => {
    const newData = { ...formData, ...data }
    setFormData(newData)

    if (isLastStep) {
      await onComplete(newData)
    } else {
      setCurrentStep((prev) => prev + 1)
    }
  }

  const handleBack = () => {
    setCurrentStep((prev) => prev - 1)
  }

  const StepComponent = currentStepConfig.component

  return (
    <div>
      {/* Progress indicator */}
      <nav aria-label="Progress">
        <ol className="flex items-center gap-4">
          {steps.map((step, index) => (
            <li key={step.id} className="flex items-center">
              <span
                className={cn(
                  'flex h-8 w-8 items-center justify-center rounded-full',
                  index < currentStep && 'bg-primary text-primary-foreground',
                  index === currentStep && 'border-2 border-primary',
                  index > currentStep && 'border-2 border-muted'
                )}
              >
                {index < currentStep ? (
                  <CheckIcon className="h-4 w-4" />
                ) : (
                  index + 1
                )}
              </span>
              <span className="ml-2 text-sm">{step.title}</span>
            </li>
          ))}
        </ol>
      </nav>

      {/* Form */}
      <Form {...form}>
        <form onSubmit={form.handleSubmit(handleNext)} className="mt-8">
          <StepComponent control={form.control} />

          <div className="mt-8 flex justify-between">
            <Button
              type="button"
              variant="outline"
              onClick={handleBack}
              disabled={currentStep === 0}
            >
              이전
            </Button>
            <Button type="submit">
              {isLastStep ? '완료' : '다음'}
            </Button>
          </div>
        </form>
      </Form>
    </div>
  )
}

동적 필드 (Field Array)

// components/form/dynamic-fields.tsx

interface DynamicFieldsProps {
  name: string
  maxItems?: number
  minItems?: number
  renderField: (index: number, remove: () => void) => React.ReactNode
  addLabel?: string
}

/**
 * 동적 필드 배열
 * @ai-hint 반복 가능한 입력 그룹에 사용 (연락처, 주소 등)
 */
export function DynamicFields({
  name,
  maxItems = 10,
  minItems = 1,
  renderField,
  addLabel = '추가',
}: DynamicFieldsProps) {
  const { fields, append, remove } = useFieldArray({ name })

  const canAdd = fields.length < maxItems
  const canRemove = fields.length > minItems

  return (
    <div className="space-y-4">
      {fields.map((field, index) => (
        <div key={field.id} className="flex gap-4">
          {renderField(index, () => canRemove && remove(index))}
        </div>
      ))}

      {canAdd && (
        <Button
          type="button"
          variant="outline"
          size="sm"
          onClick={() => append({})}
        >
          <PlusIcon className="mr-2 h-4 w-4" />
          {addLabel}
        </Button>
      )}
    </div>
  )
}

조건부 필드

// 조건부 필드 패턴
function ConditionalFieldExample() {
  const { watch } = useFormContext()
  const contactMethod = watch('contactMethod')

  return (
    <>
      <FormField name="contactMethod">
        <FormField.Label>연락 방법</FormField.Label>
        <FormField.Control>
          <Select>
            <SelectItem value="email">이메일</SelectItem>
            <SelectItem value="phone">전화</SelectItem>
            <SelectItem value="both">둘 다</SelectItem>
          </Select>
        </FormField.Control>
      </FormField>

      {(contactMethod === 'email' || contactMethod === 'both') && (
        <FormField name="email">
          <FormField.Label>이메일</FormField.Label>
          <FormField.Control>
            <Input type="email" />
          </FormField.Control>
        </FormField>
      )}

      {(contactMethod === 'phone' || contactMethod === 'both') && (
        <FormField name="phone">
          <FormField.Label>전화번호</FormField.Label>
          <FormField.Control>
            <Input type="tel" />
          </FormField.Control>
        </FormField>
      )}
    </>
  )
}

체크리스트

참고 자료

  • React Hook Form
  • Zod
  • Radix Form
  • Web Form Best Practices

관련 문서

디버깅 습관 리셋

개발자 언러닝 · console.log·디버거의 수동 추적에서 벗어나, 증상을 구조화해 AI에게 확률 순 가설을 받고 검증하는 디버깅으로 전환하는 방법과 실전 케이스

API 문서 & 스펙

에이전틱 시대의 문서화 혁신 · OpenAPI 3.2, tool schema, MCP Resource까지 연결하는 agent-first API 문서

컴포넌트 명세

Props 스키마, 상태 머신, 슬롯 합성, 접근성을 명시하고 ComponentMeta를 단일 소스로 두어 AI가 정확한 코드를 생성하게 하는 컴포넌트 명세 설계법.

컨텍스트 관리: 새로운 핵심 역량

개발자 언러닝 · 컨텍스트 윈도우를 작업 기억으로 이해하고, 프로젝트·세션·프롬프트 3계층으로 정보를 나눠 CLAUDE.md와 컨텍스트 예산으로 AI 출력 품질을 끌어올리는 법

AI 시대의 코드 리뷰

개발자 언러닝 · 직접 작성한 코드 리뷰에서 AI 생성 코드를 검증하고 판단하는 리뷰로의 전환

인터랙션 설계

상태 피드백, 마이크로인터랙션, duration·easing·애니메이션 프리셋의 토큰화와 prefers-reduced-motion 지원으로 AI가 일관된 인터랙션을 구현하게 하는 설계.

에이전트 UI 프로토콜

MCP·A2A·UI(A2UI·AG-UI) 3계층 프로토콜로 AI 에이전트가 UI를 선언적으로 구성하고 실시간 스트리밍하는 방법을 다룹니다.

On this page

폼 아키텍처폼 필드 컴포넌트FormField 구조완성된 폼 필드 예시컴포넌트 구현검증 패턴검증 스키마 (Zod)검증 타이밍에러 메시지 시스템에러 메시지 가이드라인에러 표시 패턴마이크로카피 (콘텐츠 시스템)원칙 (최소 규칙)표준 레이블(예시)Copy Tokens (선택: 코드로 관리)AI 컨텍스트 주입 팁복잡한 폼 합성다단계 폼 (Wizard)동적 필드 (Field Array)조건부 필드체크리스트구조검증에러 처리복잡한 폼접근성참고 자료