← 목록으로

챕터 26: 폼과 사용자 입력 처리

서론 웹 애플리케이션에서 사용자와의 상호작용은 주로 폼을 통해 이루어집니다. 로그인, 회원가입, 문의하기, 주문하기 등 거의 모든 기능이 폼을 필요로 합니다. 하지만 단순히 입력을 받는 것만으로는 부족합니다. 유효성 검사, 에러 처리, 사용자 피드백 등이 모두 갖춰져야 좋은 사용자 경험을 제공할 수 있습니다.

이번 챕터에서는 React에서 폼을 다루는 다양한 패턴, 실시간 유효성 검사, 에러 메시지 표시, 그리고 사용자 친화적인 폼 디자인까지 모든 것을 다루어보겠습니다. 실제 프로젝트에서 바로 활용할 수 있는 완성도 높은 폼을 만들어보겠습니다.

본론 제어 컴포넌트 패턴 React에서 폼 입력을 다루는 가장 기본적인 패턴입니다:

jsx 'use client'

import { useState } from 'react'

export default function ControlledForm() { const [formData, setFormData] = useState({ username: '', email: '', password: '', confirmPassword: '', age: '', gender: '', country: '', interests: [], newsletter: false, terms: false })

const handleInputChange = (e) => { const { name, value, type, checked } = e.target

setFormData(prev => ({
  ...prev,
  [name]: type === 'checkbox' ? checked : value
}))

}

const handleInterestsChange = (interest) => { setFormData(prev => ({ ...prev, interests: prev.interests.includes(interest) ? prev.interests.filter(i => i !== interest) : [...prev.interests, interest] })) }

const handleSubmit = (e) => { e.preventDefault() console.log('폼 데이터:', formData) }

return (

제어 컴포넌트 폼

    <form onSubmit={handleSubmit} className="bg-white rounded-lg shadow-md p-8">
      {/* 텍스트 입력 */}
      <div className="mb-6">
        <label className="block text-gray-700 font-medium mb-2">
          사용자명
        </label>
        <input
          type="text"
          name="username"
          value={formData.username}
          onChange={handleInputChange}
          className="w-full border rounded-lg px-4 py-2 focus:outline-none focus:border-blue-500"
          placeholder="사용자명을 입력하세요"
        />
        <p className="text-sm text-gray-500 mt-1">
          현재 값: {formData.username}
        </p>
      </div>
      
      {/* 이메일 입력 */}
      <div className="mb-6">
        <label className="block text-gray-700 font-medium mb-2">
          이메일
        </label>
        <input
          type="email"
          name="email"
          value={formData.email}
          onChange={handleInputChange}
          className="w-full border rounded-lg px-4 py-2 focus:outline-none focus:border-blue-500"
          placeholder="email@example.com"
        />
      </div>
      
      {/* 비밀번호 입력 */}
      <div className="grid grid-cols-2 gap-4 mb-6">
        <div>
          <label className="block text-gray-700 font-medium mb-2">
            비밀번호
          </label>
          <input
            type="password"
            name="password"
            value={formData.password}
            onChange={handleInputChange}
            className="w-full border rounded-lg px-4 py-2 focus:outline-none focus:border-blue-500"
          />
        </div>
        <div>
          <label className="block text-gray-700 font-medium mb-2">
            비밀번호 확인
          </label>
          <input
            type="password"
            name="confirmPassword"
            value={formData.confirmPassword}
            onChange={handleInputChange}
            className="w-full border rounded-lg px-4 py-2 focus:outline-none focus:border-blue-500"
          />
        </div>
      </div>
      
      {/* 숫자 입력 */}
      <div className="mb-6">
        <label className="block text-gray-700 font-medium mb-2">
          나이
        </label>
        <input
          type="number"
          name="age"
          value={formData.age}
          onChange={handleInputChange}
          min="1"
          max="120"
          className="w-full border rounded-lg px-4 py-2 focus:outline-none focus:border-blue-500"
        />
      </div>
      
      {/* 라디오 버튼 */}
      <div className="mb-6">
        <label className="block text-gray-700 font-medium mb-2">
          성별
        </label>
        <div className="flex space-x-4">
          {['남성', '여성', '기타'].map(gender => (
            <label key={gender} className="flex items-center">
              <input
                type="radio"
                name="gender"
                value={gender}
                checked={formData.gender === gender}
                onChange={handleInputChange}
                className="mr-2"
              />
              {gender}
            </label>
          ))}
        </div>
      </div>
      
      {/* 셀렉트 박스 */}
      <div className="mb-6">
        <label className="block text-gray-700 font-medium mb-2">
          국가
        </label>
        <select
          name="country"
          value={formData.country}
          onChange={handleInputChange}
          className="w-full border rounded-lg px-4 py-2 focus:outline-none focus:border-blue-500"
        >
          <option value="">선택하세요</option>
          <option value="kr">한국</option>
          <option value="us">미국</option>
          <option value="jp">일본</option>
          <option value="cn">중국</option>
        </select>
      </div>
      
      {/* 체크박스 그룹 */}
      <div className="mb-6">
        <label className="block text-gray-700 font-medium mb-2">
          관심사
        </label>
        <div className="space-y-2">
          {['스포츠', '음악', '영화', '독서', '여행'].map(interest => (
            <label key={interest} className="flex items-center">
              <input
                type="checkbox"
                checked={formData.interests.includes(interest)}
                onChange={() => handleInterestsChange(interest)}
                className="mr-2"
              />
              {interest}
            </label>
          ))}
        </div>
      </div>
      
      {/* 단일 체크박스 */}
      <div className="mb-6 space-y-2">
        <label className="flex items-center">
          <input
            type="checkbox"
            name="newsletter"
            checked={formData.newsletter}
            onChange={handleInputChange}
            className="mr-2"
          />
          뉴스레터 수신 동의
        </label>
        <label className="flex items-center">
          <input
            type="checkbox"
            name="terms"
            checked={formData.terms}
            onChange={handleInputChange}
            className="mr-2"
          />
          이용약관 동의
        </label>
      </div>
      
      {/* 제출 버튼 */}
      <button
        type="submit"
        className="w-full bg-blue-600 text-white py-3 rounded-lg hover:bg-blue-700 transition"
      >
        제출하기
      </button>
    </form>
    
    {/* 폼 데이터 미리보기 */}
    <div className="mt-8 bg-gray-100 rounded-lg p-4">
      <h3 className="font-bold mb-2">폼 데이터:</h3>
      <pre className="text-sm overflow-x-auto">
        {JSON.stringify(formData, null, 2)}
      </pre>
    </div>
  </div>
</div>

) } 폼 유효성 검사 실시간 유효성 검사와 에러 메시지를 구현해봅시다:

jsx 'use client'

import { useState, useEffect } from 'react'

export default function FormValidation() { const [formData, setFormData] = useState({ email: '', password: '', phone: '', website: '' })

const [errors, setErrors] = useState({}) const [touched, setTouched] = useState({}) const [isSubmitting, setIsSubmitting] = useState(false)

// 유효성 검사 규칙 const validationRules = { email: { required: '이메일을 입력해주세요', pattern: { value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+.[A-Z]{2,}$/i, message: '올바른 이메일 형식이 아닙니다' } }, password: { required: '비밀번호를 입력해주세요', minLength: { value: 8, message: '비밀번호는 최소 8자 이상이어야 합니다' }, pattern: { value: /^(?=.[a-z])(?=.[A-Z])(?=.\d)(?=.[@$!%?&])[A-Za-z\d@$!%?&]/, message: '대소문자, 숫자, 특수문자를 포함해야 합니다' } }, phone: { required: '전화번호를 입력해주세요', pattern: { value: /^01[0-9]-?[0-9]{3,4}-?[0-9]{4}$/, message: '올바른 전화번호 형식이 아닙니다' } }, website: { pattern: { value: /^(https?://)?([\da-z.-]+).([a-z.]{2,6})([/\w .-])/?$/, message: '올바른 URL 형식이 아닙니다' } } }

// 개별 필드 유효성 검사 const validateField = (name, value) => { const rules = validationRules[name] if (!rules) return ''

if (rules.required && !value) {
  return rules.required
}

if (rules.minLength && value.length < rules.minLength.value) {
  return rules.minLength.message
}

if (rules.pattern && !rules.pattern.value.test(value)) {
  return rules.pattern.message
}

return ''

}

// 입력 변경 처리 const handleChange = (e) => { const { name, value } = e.target setFormData(prev => ({ ...prev, [name]: value }))

// 실시간 유효성 검사 (touched된 필드만)
if (touched[name]) {
  const error = validateField(name, value)
  setErrors(prev => ({ ...prev, [name]: error }))
}

}

// 포커스 아웃 처리 const handleBlur = (e) => { const { name, value } = e.target setTouched(prev => ({ ...prev, [name]: true }))

const error = validateField(name, value)
setErrors(prev => ({ ...prev, [name]: error }))

}

// 전체 폼 유효성 검사 const validateForm = () => { const newErrors = {} Object.keys(formData).forEach(key => { const error = validateField(key, formData[key]) if (error) newErrors[key] = error }) return newErrors }

// 폼 제출 const handleSubmit = async (e) => { e.preventDefault()

const formErrors = validateForm()
if (Object.keys(formErrors).length > 0) {
  setErrors(formErrors)
  setTouched(Object.keys(formData).reduce((acc, key) => ({
    ...acc,
    [key]: true
  }), {}))
  return
}

setIsSubmitting(true)

// API 호출 시뮬레이션
await new Promise(resolve => setTimeout(resolve, 2000))

console.log('제출된 데이터:', formData)
setIsSubmitting(false)

// 성공 메시지
alert('폼이 성공적으로 제출되었습니다!')

}

// 폼 유효성 상태 const isFormValid = Object.keys(errors).length === 0 && Object.values(formData).some(value => value !== '')

return (

폼 유효성 검사

    <form onSubmit={handleSubmit} className="bg-white rounded-lg shadow-md p-8">
      {/* 이메일 필드 */}
      <div className="mb-6">
        <label className="block text-gray-700 font-medium mb-2">
          이메일 *
        </label>
        <input
          type="email"
          name="email"
          value={formData.email}
          onChange={handleChange}
          onBlur={handleBlur}
          className={`w-full border rounded-lg px-4 py-2 focus:outline-none ${
            errors.email && touched.email
              ? 'border-red-500 bg-red-50'
              : 'focus:border-blue-500'
          }`}
          placeholder="email@example.com"
        />
        {errors.email && touched.email && (
          <p className="text-red-500 text-sm mt-1">{errors.email}</p>
        )}
      </div>
      
      {/* 비밀번호 필드 */}
      <div className="mb-6">
        <label className="block text-gray-700 font-medium mb-2">
          비밀번호 *
        </label>
        <input
          type="password"
          name="password"
          value={formData.password}
          onChange={handleChange}
          onBlur={handleBlur}
          className={`w-full border rounded-lg px-4 py-2 focus:outline-none ${
            errors.password && touched.password
              ? 'border-red-500 bg-red-50'
              : 'focus:border-blue-500'
          }`}
          placeholder="********"
        />
        {errors.password && touched.password && (
          <p className="text-red-500 text-sm mt-1">{errors.password}</p>
        )}
        
        {/* 비밀번호 강도 표시기 */}
        {formData.password && (
          <PasswordStrength password={formData.password} />
        )}
      </div>
      
      {/* 전화번호 필드 */}
      <div className="mb-6">
        <label className="block text-gray-700 font-medium mb-2">
          전화번호 *
        </label>
        <input
          type="tel"
          name="phone"
          value={formData.phone}
          onChange={handleChange}
          onBlur={handleBlur}
          className={`w-full border rounded-lg px-4 py-2 focus:outline-none ${
            errors.phone && touched.phone
              ? 'border-red-500 bg-red-50'
              : 'focus:border-blue-500'
          }`}
          placeholder="010-0000-0000"
        />
        {errors.phone && touched.phone && (
          <p className="text-red-500 text-sm mt-1">{errors.phone}</p>
        )}
      </div>
      
      {/* 웹사이트 필드 (선택) */}
      <div className="mb-6">
        <label className="block text-gray-700 font-medium mb-2">
          웹사이트 (선택)
        </label>
        <input
          type="url"
          name="website"
          value={formData.website}
          onChange={handleChange}
          onBlur={handleBlur}
          className={`w-full border rounded-lg px-4 py-2 focus:outline-none ${
            errors.website && touched.website
              ? 'border-red-500 bg-red-50'
              : 'focus:border-blue-500'
          }`}
          placeholder="https://example.com"
        />
        {errors.website && touched.website && (
          <p className="text-red-500 text-sm mt-1">{errors.website}</p>
        )}
      </div>
      
      {/* 제출 버튼 */}
      <button
        type="submit"
        disabled={!isFormValid || isSubmitting}
        className={`w-full py-3 rounded-lg font-medium transition ${
          isFormValid && !isSubmitting
            ? 'bg-blue-600 text-white hover:bg-blue-700'
            : 'bg-gray-300 text-gray-500 cursor-not-allowed'
        }`}
      >
        {isSubmitting ? '제출 중...' : '제출하기'}
      </button>
    </form>
  </div>
</div>

) }

// 비밀번호 강도 컴포넌트 function PasswordStrength({ password }) { const getStrength = () => { let strength = 0 if (password.length >= 8) strength++ if (password.length >= 12) strength++ if (/[a-z]/.test(password) && /[A-Z]/.test(password)) strength++ if (/\d/.test(password)) strength++ if (/[@$!%*?&]/.test(password)) strength++ return strength }

const strength = getStrength() const strengthText = ['매우 약함', '약함', '보통', '강함', '매우 강함'][strength] const strengthColor = ['red', 'orange', 'yellow', 'lime', 'green'][strength]

return (

{[...Array(5)].map((_, i) => ( <div key={i} className={h-1 flex-1 rounded ${ i < strength ? bg-${strengthColor}-500 : 'bg-gray-200' }} /> ))}
<p className={text-xs text-${strengthColor}-600}> 비밀번호 강도: {strengthText}

) } 동적 폼 필드 조건에 따라 필드가 변경되는 동적 폼을 구현해봅시다:

jsx 'use client'

import { useState } from 'react'

export default function DynamicForm() { const [formType, setFormType] = useState('personal') const [formData, setFormData] = useState({}) const [fields, setFields] = useState([])

// 동적 필드 추가 const addField = () => { const newField = { id: Date.now(), label: '', type: 'text', value: '' } setFields([...fields, newField]) }

// 필드 제거 const removeField = (id) => { setFields(fields.filter(field => field.id !== id)) }

// 필드 업데이트 const updateField = (id, key, value) => { setFields(fields.map(field => field.id === id ? { ...field, [key]: value } : field )) }

return (

동적 폼

    {/* 폼 타입 선택 */}
    <div className="bg-white rounded-lg shadow-md p-6 mb-8">
      <h2 className="text-xl font-semibold mb-4">폼 타입 선택</h2>
      <div className="flex space-x-4">
        <button
          onClick={() => setFormType('personal')}
          className={`px-4 py-2 rounded ${
            formType === 'personal'
              ? 'bg-blue-600 text-white'
              : 'bg-gray-200'
          }`}
        >
          개인 정보
        </button>
        <button
          onClick={() => setFormType('business')}
          className={`px-4 py-2 rounded ${
            formType === 'business'
              ? 'bg-blue-600 text-white'
              : 'bg-gray-200'
          }`}
        >
          사업자 정보
        </button>
        <button
          onClick={() => setFormType('custom')}
          className={`px-4 py-2 rounded ${
            formType === 'custom'
              ? 'bg-blue-600 text-white'
              : 'bg-gray-200'
          }`}
        >
          커스텀 폼
        </button>
      </div>
    </div>
    
    {/* 조건부 폼 렌더링 */}
    {formType === 'personal' && <PersonalForm />}
    {formType === 'business' && <BusinessForm />}
    {formType === 'custom' && (
      <div className="bg-white rounded-lg shadow-md p-6">
        <h2 className="text-xl font-semibold mb-4">커스텀 폼 빌더</h2>
        
        {/* 동적 필드 목록 */}
        <div className="space-y-4 mb-6">
          {fields.map((field) => (
            <div key={field.id} className="flex gap-2">
              <input
                type="text"
                placeholder="필드 레이블"
                value={field.label}
                onChange={(e) => updateField(field.id, 'label', e.target.value)}
                className="flex-1 border rounded px-3 py-2"
              />
              <select
                value={field.type}
                onChange={(e) => updateField(field.id, 'type', e.target.value)}
                className="border rounded px-3 py-2"
              >
                <option value="text">텍스트</option>
                <option value="number">숫자</option>
                <option value="email">이메일</option>
                <option value="date">날짜</option>
                <option value="select">선택</option>
              </select>
              <button
                onClick={() => removeField(field.id)}
                className="text-red-500 hover:text-red-700"
              >
                삭제
              </button>
            </div>
          ))}
        </div>
        
        <button
          onClick={addField}
          className="bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600"
        >
          + 필드 추가
        </button>
        
        {/* 미리보기 */}
        {fields.length > 0 && (
          <div className="mt-8 pt-8 border-t">
            <h3 className="font-semibold mb-4">폼 미리보기</h3>
            <form className="space-y-4">
              {fields.map((field) => (
                <div key={field.id}>
                  <label className="block text-gray-700 mb-1">
                    {field.label || '레이블 없음'}
                  </label>
                  <input
                    type={field.type}
                    className="w-full border rounded px-3 py-2"
                    placeholder={`${field.type} 입력`}
                  />
                </div>
              ))}
            </form>
          </div>
        )}
      </div>
    )}
  </div>
</div>

) }

// 개인 정보 폼 function PersonalForm() { return (

개인 정보

) }

// 사업자 정보 폼 function BusinessForm() { return (

사업자 정보

) } 결론 폼과 사용자 입력 처리는 웹 애플리케이션의 핵심 기능입니다. 이번 챕터에서 우리는 제어 컴포넌트 패턴, 실시간 유효성 검사, 에러 처리, 그리고 동적 폼까지 다양한 패턴을 구현해보았습니다.

좋은 폼을 만들기 위해서는 사용자 경험을 최우선으로 고려해야 합니다. 명확한 레이블, 도움말 텍스트, 즉각적인 피드백, 그리고 친절한 에러 메시지가 모두 중요합니다. 또한 유효성 검사는 클라이언트와 서버 모두에서 수행되어야 하며, 접근성도 항상 고려해야 합니다.

다음 챕터에서는 API 라우트를 만들어 실제로 폼 데이터를 처리하는 방법을 배워보겠습니다. 백엔드 API를 구축하고 데이터베이스와 연동하는 방법을 알아보겠습니다!