React 폼 관리와 유효성 검사

2025. 4. 1. 01:08Frameworks/React

React 폼 관리와 유효성 검사

웹 애플리케이션에서 폼은 사용자로부터 데이터를 수집하는 핵심 요소입니다. React에서 폼을 효과적으로 관리하고 유효성을 검사하는 방법을 알아보겠습니다.

1. React의 기본 폼 처리

제어 컴포넌트 (Controlled Components)

React에서 폼 요소를 다루는 가장 기본적인 방식은 '제어 컴포넌트'를 사용하는 것입니다. 제어 컴포넌트는 React 상태(state)로 폼 요소의 값을 제어하는 방식입니다.

import React, { useState } from 'react';

function SimpleForm() {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');

  const handleSubmit = (e) => {
    e.preventDefault();
    console.log('제출된 데이터:', { name, email });
    // API 요청 등의 처리
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="name">이름:</label>
        <input
          type="text"
          id="name"
          value={name}
          onChange={(e) => setName(e.target.value)}
        />
      </div>
      
      <div>
        <label htmlFor="email">이메일:</label>
        <input
          type="email"
          id="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
        />
      </div>
      
      <button type="submit">제출</button>
    </form>
  );
}

비제어 컴포넌트 (Uncontrolled Components)

비제어 컴포넌트는 DOM 자체가 폼 데이터를 관리하는 방식입니다. React의 ref를 사용하여 필요할 때 폼 값에 접근합니다.

import React, { useRef } from 'react';

function UncontrolledForm() {
  const nameRef = useRef(null);
  const emailRef = useRef(null);

  const handleSubmit = (e) => {
    e.preventDefault();
    const formData = {
      name: nameRef.current.value,
      email: emailRef.current.value
    };
    console.log('제출된 데이터:', formData);
    // API 요청 등의 처리
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="name">이름:</label>
        <input
          type="text"
          id="name"
          ref={nameRef}
          defaultValue=""
        />
      </div>
      
      <div>
        <label htmlFor="email">이메일:</label>
        <input
          type="email"
          id="email"
          ref={emailRef}
          defaultValue=""
        />
      </div>
      
      <button type="submit">제출</button>
    </form>
  );
}

제어 vs 비제어 컴포넌트

특징 제어 컴포넌트 비제어 컴포넌트

데이터 관리 React 상태 DOM
즉시 유효성 검사 가능 어려움
폼 값 접근 state에서 바로 접근 ref를 통해 접근
조건부 제출 버튼 비활성화 쉬움 추가 로직 필요
사용 사례 대부분의 폼 파일 업로드, 라이브러리 통합

2. 복잡한 폼 상태 관리

객체로 폼 상태 관리

여러 입력 필드가 있는 폼의 경우, 각 필드마다 별도의 상태를 만드는 대신 객체로 관리하는 것이 효율적입니다.

import React, { useState } from 'react';

function RegistrationForm() {
  const [formData, setFormData] = useState({
    firstName: '',
    lastName: '',
    email: '',
    password: '',
    confirmPassword: '',
    age: '',
    gender: '',
    interests: []
  });

  const handleChange = (e) => {
    const { name, value, type, checked } = e.target;
    
    if (type === 'checkbox') {
      // 체크박스 처리 (다중 선택)
      const updatedInterests = [...formData.interests];
      if (checked) {
        updatedInterests.push(value);
      } else {
        const index = updatedInterests.indexOf(value);
        if (index !== -1) {
          updatedInterests.splice(index, 1);
        }
      }
      
      setFormData({
        ...formData,
        interests: updatedInterests
      });
    } else {
      // 일반 입력 필드 처리
      setFormData({
        ...formData,
        [name]: value
      });
    }
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    console.log('제출된 데이터:', formData);
    // API 요청 등의 처리
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="firstName">이름:</label>
        <input
          type="text"
          id="firstName"
          name="firstName"
          value={formData.firstName}
          onChange={handleChange}
        />
      </div>
      
      <div>
        <label htmlFor="lastName">성:</label>
        <input
          type="text"
          id="lastName"
          name="lastName"
          value={formData.lastName}
          onChange={handleChange}
        />
      </div>
      
      <div>
        <label htmlFor="email">이메일:</label>
        <input
          type="email"
          id="email"
          name="email"
          value={formData.email}
          onChange={handleChange}
        />
      </div>
      
      <div>
        <label htmlFor="password">비밀번호:</label>
        <input
          type="password"
          id="password"
          name="password"
          value={formData.password}
          onChange={handleChange}
        />
      </div>
      
      <div>
        <label htmlFor="confirmPassword">비밀번호 확인:</label>
        <input
          type="password"
          id="confirmPassword"
          name="confirmPassword"
          value={formData.confirmPassword}
          onChange={handleChange}
        />
      </div>
      
      <div>
        <label htmlFor="age">나이:</label>
        <input
          type="number"
          id="age"
          name="age"
          value={formData.age}
          onChange={handleChange}
        />
      </div>
      
      <div>
        <label>성별:</label>
        <select name="gender" value={formData.gender} onChange={handleChange}>
          <option value="">선택하세요</option>
          <option value="male">남성</option>
          <option value="female">여성</option>
          <option value="other">기타</option>
          <option value="prefer-not-to-say">답변하지 않음</option>
        </select>
      </div>
      
      <div>
        <label>관심사:</label>
        <div>
          <label>
            <input
              type="checkbox"
              name="interests"
              value="technology"
              checked={formData.interests.includes('technology')}
              onChange={handleChange}
            />
            기술
          </label>
          <label>
            <input
              type="checkbox"
              name="interests"
              value="sports"
              checked={formData.interests.includes('sports')}
              onChange={handleChange}
            />
            스포츠
          </label>
          <label>
            <input
              type="checkbox"
              name="interests"
              value="music"
              checked={formData.interests.includes('music')}
              onChange={handleChange}
            />
            음악
          </label>
          <label>
            <input
              type="checkbox"
              name="interests"
              value="art"
              checked={formData.interests.includes('art')}
              onChange={handleChange}
            />
            예술
          </label>
        </div>
      </div>
      
      <button type="submit">가입하기</button>
    </form>
  );
}

useReducer를 활용한 폼 상태 관리

useReducer를 사용하면 더 복잡한 폼 상태를 관리할 수 있습니다.

import React, { useReducer } from 'react';

// 초기 상태
const initialState = {
  firstName: '',
  lastName: '',
  email: '',
  password: '',
  confirmPassword: '',
  touched: {}, // 필드 터치 여부 추적
  errors: {}   // 유효성 검사 오류
};

// 폼 리듀서
function formReducer(state, action) {
  switch (action.type) {
    case 'CHANGE':
      return {
        ...state,
        [action.field]: action.value
      };
    case 'TOUCH':
      return {
        ...state,
        touched: {
          ...state.touched,
          [action.field]: true
        }
      };
    case 'VALIDATE':
      return {
        ...state,
        errors: action.errors
      };
    case 'RESET':
      return initialState;
    default:
      return state;
  }
}

function AdvancedForm() {
  const [state, dispatch] = useReducer(formReducer, initialState);
  
  // 입력값 변경 처리
  const handleChange = (e) => {
    const { name, value } = e.target;
    dispatch({
      type: 'CHANGE',
      field: name,
      value
    });
  };
  
  // 필드 포커스 해제 처리
  const handleBlur = (e) => {
    const { name } = e.target;
    dispatch({
      type: 'TOUCH',
      field: name
    });
    
    // 포커스 해제 시 유효성 검사
    validateForm();
  };
  
  // 폼 유효성 검사
  const validateForm = () => {
    const errors = {};
    
    // 이름 검사
    if (!state.firstName) {
      errors.firstName = '이름을 입력하세요';
    }
    
    // 이메일 검사
    if (!state.email) {
      errors.email = '이메일을 입력하세요';
    } else if (!/\S+@\S+\.\S+/.test(state.email)) {
      errors.email = '유효한 이메일 주소를 입력하세요';
    }
    
    // 비밀번호 검사
    if (!state.password) {
      errors.password = '비밀번호를 입력하세요';
    } else if (state.password.length < 8) {
      errors.password = '비밀번호는 8자 이상이어야 합니다';
    }
    
    // 비밀번호 확인 검사
    if (state.password !== state.confirmPassword) {
      errors.confirmPassword = '비밀번호가 일치하지 않습니다';
    }
    
    dispatch({
      type: 'VALIDATE',
      errors
    });
    
    return Object.keys(errors).length === 0;
  };
  
  const handleSubmit = (e) => {
    e.preventDefault();
    
    // 모든 필드 터치 처리
    const touchedFields = {};
    Object.keys(initialState).forEach(key => {
      if (key !== 'touched' && key !== 'errors') {
        touchedFields[key] = true;
      }
    });
    
    dispatch({
      type: 'TOUCH',
      field: Object.keys(touchedFields)
    });
    
    // 유효성 검사
    const isValid = validateForm();
    
    if (isValid) {
      console.log('제출된 데이터:', {
        firstName: state.firstName,
        lastName: state.lastName,
        email: state.email,
        password: state.password
      });
      
      // 폼 초기화
      dispatch({ type: 'RESET' });
    }
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="firstName">이름:</label>
        <input
          type="text"
          id="firstName"
          name="firstName"
          value={state.firstName}
          onChange={handleChange}
          onBlur={handleBlur}
        />
        {state.touched.firstName && state.errors.firstName && (
          <span className="error">{state.errors.firstName}</span>
        )}
      </div>
      
      <div>
        <label htmlFor="lastName">성:</label>
        <input
          type="text"
          id="lastName"
          name="lastName"
          value={state.lastName}
          onChange={handleChange}
          onBlur={handleBlur}
        />
      </div>
      
      <div>
        <label htmlFor="email">이메일:</label>
        <input
          type="email"
          id="email"
          name="email"
          value={state.email}
          onChange={handleChange}
          onBlur={handleBlur}
        />
        {state.touched.email && state.errors.email && (
          <span className="error">{state.errors.email}</span>
        )}
      </div>
      
      <div>
        <label htmlFor="password">비밀번호:</label>
        <input
          type="password"
          id="password"
          name="password"
          value={state.password}
          onChange={handleChange}
          onBlur={handleBlur}
        />
        {state.touched.password && state.errors.password && (
          <span className="error">{state.errors.password}</span>
        )}
      </div>
      
      <div>
        <label htmlFor="confirmPassword">비밀번호 확인:</label>
        <input
          type="password"
          id="confirmPassword"
          name="confirmPassword"
          value={state.confirmPassword}
          onChange={handleChange}
          onBlur={handleBlur}
        />
        {state.touched.confirmPassword && state.errors.confirmPassword && (
          <span className="error">{state.errors.confirmPassword}</span>
        )}
      </div>
      
      <button type="submit">가입하기</button>
    </form>
  );
}

3. 폼 유효성 검사 (Form Validation)

기본 유효성 검사

React에서 폼 유효성 검사는 주로 다음과 같은 방법으로 구현합니다:

  1. 입력값 변경 시 유효성 검사
  2. 폼 제출 시 유효성 검사
  3. 필드 포커스 해제(blur) 시 유효성 검사
import React, { useState } from 'react';

function ValidationForm() {
  const [formData, setFormData] = useState({
    username: '',
    email: '',
    password: ''
  });
  
  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});
  
  const validateField = (name, value) => {
    let errorMessage = '';
    
    switch (name) {
      case 'username':
        if (!value) {
          errorMessage = '사용자 이름을 입력하세요';
        } else if (value.length < 3) {
          errorMessage = '사용자 이름은 3자 이상이어야 합니다';
        }
        break;
        
      case 'email':
        if (!value) {
          errorMessage = '이메일을 입력하세요';
        } else if (!/\S+@\S+\.\S+/.test(value)) {
          errorMessage = '유효한 이메일 주소를 입력하세요';
        }
        break;
        
      case 'password':
        if (!value) {
          errorMessage = '비밀번호를 입력하세요';
        } else if (value.length < 8) {
          errorMessage = '비밀번호는 8자 이상이어야 합니다';
        } else if (!/[A-Z]/.test(value)) {
          errorMessage = '비밀번호에는 대문자가 포함되어야 합니다';
        } else if (!/[0-9]/.test(value)) {
          errorMessage = '비밀번호에는 숫자가 포함되어야 합니다';
        }
        break;
        
      default:
        break;
    }
    
    return errorMessage;
  };
  
  const handleChange = (e) => {
    const { name, value } = e.target;
    
    // 입력값 업데이트
    setFormData({
      ...formData,
      [name]: value
    });
    
    // 필드가 이미 터치되었다면 실시간 유효성 검사
    if (touched[name]) {
      const errorMessage = validateField(name, value);
      setErrors(prevErrors => ({
        ...prevErrors,
        [name]: errorMessage
      }));
    }
  };
  
  const handleBlur = (e) => {
    const { name, value } = e.target;
    
    // 필드 터치 상태 업데이트
    setTouched({
      ...touched,
      [name]: true
    });
    
    // 포커스 해제 시 유효성 검사
    const errorMessage = validateField(name, value);
    setErrors(prevErrors => ({
      ...prevErrors,
      [name]: errorMessage
    }));
  };
  
  const handleSubmit = (e) => {
    e.preventDefault();
    
    // 모든 필드 터치 처리
    const touchedFields = {};
    Object.keys(formData).forEach(key => {
      touchedFields[key] = true;
    });
    setTouched(touchedFields);
    
    // 모든 필드 유효성 검사
    const formErrors = {};
    Object.entries(formData).forEach(([name, value]) => {
      const errorMessage = validateField(name, value);
      if (errorMessage) {
        formErrors[name] = errorMessage;
      }
    });
    
    setErrors(formErrors);
    
    // 오류가 없으면 폼 제출
    if (Object.keys(formErrors).length === 0) {
      console.log('유효한 폼 데이터:', formData);
      // API 요청 등의 처리
    }
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="username">사용자 이름:</label>
        <input
          type="text"
          id="username"
          name="username"
          value={formData.username}
          onChange={handleChange}
          onBlur={handleBlur}
          className={touched.username && errors.username ? 'error-input' : ''}
        />
        {touched.username && errors.username && (
          <div className="error-message">{errors.username}</div>
        )}
      </div>
      
      <div>
        <label htmlFor="email">이메일:</label>
        <input
          type="text"
          id="email"
          name="email"
          value={formData.email}
          onChange={handleChange}
          onBlur={handleBlur}
          className={touched.email && errors.email ? 'error-input' : ''}
        />
        {touched.email && errors.email && (
          <div className="error-message">{errors.email}</div>
        )}
      </div>
      
      <div>
        <label htmlFor="password">비밀번호:</label>
        <input
          type="password"
          id="password"
          name="password"
          value={formData.password}
          onChange={handleChange}
          onBlur={handleBlur}
          className={touched.password && errors.password ? 'error-input' : ''}
        />
        {touched.password && errors.password && (
          <div className="error-message">{errors.password}</div>
        )}
      </div>
      
      <button type="submit">가입하기</button>
    </form>
  );
}

커스텀 훅을 사용한 폼 유효성 검사

폼 로직을 재사용 가능한 커스텀 훅으로 추출하여 사용할 수 있습니다.

// useForm.js
import { useState } from 'react';

function useForm(initialValues, validate) {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});
  
  const handleChange = (e) => {
    const { name, value, type, checked } = e.target;
    
    // 체크박스 처리
    const fieldValue = type === 'checkbox' ? checked : value;
    
    setValues({
      ...values,
      [name]: fieldValue
    });
    
    // 필드가 이미 터치되었다면 실시간 유효성 검사
    if (touched[name]) {
      const fieldErrors = validate({ ...values, [name]: fieldValue });
      setErrors(fieldErrors);
    }
  };
  
  const handleBlur = (e) => {
    const { name } = e.target;
    
    setTouched({
      ...touched,
      [name]: true
    });
    
    const fieldErrors = validate(values);
    setErrors(fieldErrors);
  };
  
  const handleSubmit = (onSubmit) => (e) => {
    e.preventDefault();
    
    // 모든 필드 터치 처리
    const touchedFields = {};
    Object.keys(values).forEach(key => {
      touchedFields[key] = true;
    });
    setTouched(touchedFields);
    
    // 유효성 검사
    const formErrors = validate(values);
    setErrors(formErrors);
    
    // 오류가 없으면 콜백 실행
    if (Object.keys(formErrors).length === 0) {
      onSubmit(values);
    }
  };
  
  const reset = () => {
    setValues(initialValues);
    setErrors({});
    setTouched({});
  };
  
  return {
    values,
    errors,
    touched,
    handleChange,
    handleBlur,
    handleSubmit,
    reset
  };
}

export default useForm;

사용 예:

import React from 'react';
import useForm from './useForm';

function SignupForm() {
  // 유효성 검사 함수
  const validateForm = (values) => {
    const errors = {};
    
    if (!values.username) {
      errors.username = '사용자 이름을 입력하세요';
    } else if (values.username.length < 3) {
      errors.username = '사용자 이름은 3자 이상이어야 합니다';
    }
    
    if (!values.email) {
      errors.email = '이메일을 입력하세요';
    } else if (!/\S+@\S+\.\S+/.test(values.email)) {
      errors.email = '유효한 이메일 주소를 입력하세요';
    }
    
    if (!values.password) {
      errors.password = '비밀번호를 입력하세요';
    } else if (values.password.length < 8) {
      errors.password = '비밀번호는 8자 이상이어야 합니다';
    }
    
    if (values.password !== values.confirmPassword) {
      errors.confirmPassword = '비밀번호가 일치하지 않습니다';
    }
    
    return errors;
  };
  
  const initialValues = {
    username: '',
    email: '',
    password: '',
    confirmPassword: '',
    terms: false
  };
  
  const {
    values,
    errors,
    touched,
    handleChange,
    handleBlur,
    handleSubmit,
    reset
  } = useForm(initialValues, validateForm);
  
  const onSubmit = (formData) => {
    console.log('폼 제출:', formData);
    // API 요청 등의 처리
    alert('가입이 완료되었습니다!');
    reset();
  };
  
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label htmlFor="username">사용자 이름:</label>
        <input
          type="text"
          id="username"
          name="username"
          value={values.username}
          onChange={handleChange}
          onBlur={handleBlur}
          className={touched.username && errors.username ? 'error-input' : ''}
        />
        {touched.username && errors.username && (
          <div className="error-message">{errors.username}</div>
        )}
      </div>
      
      <div>
        <label htmlFor="email">이메일:</label>
        <input
          type="email"
          id="email"
          name="email"
          value={values.email}
          onChange={handleChange}
          onBlur={handleBlur}
          className={touched.email && errors.email ? 'error-input' : ''}
        />
        {touched.email && errors.email && (
          <div className="error-message">{errors.email}</div>
        )}
      </div>
      
      <div>
        <label htmlFor="password">비밀번호:</label>
        <input
          type="password"
          id="password"
          name="password"
          value={values.password}
          onChange={handleChange}
          onBlur={handleBlur}
          className={touched.password && errors.password ? 'error-input' : ''}
        />
        {touched.password && errors.password && (
          <div className="error-message">{errors.password}</div>
        )}
      </div>
      
      <div>
        <label htmlFor="confirmPassword">비밀번호 확인:</label>
        <input
          type="password"
          id="confirmPassword"
          name="confirmPassword"
          value={values.confirmPassword}
          onChange={handleChange}
          onBlur={handleBlur}
          className={touched.confirmPassword && errors.confirmPassword ? 'error-input' : ''}
        />
        {touched.confirmPassword && errors.confirmPassword && (
          <div className="error-message">{errors.confirmPassword}</div>
        )}
      </div>
      
      <div>
        <label>
          <input
            type="checkbox"
            name="terms"
            checked={values.terms}
            onChange={handleChange}
          />
          이용약관에 동의합니다
        </label>
        {touched.terms && errors.terms && (
          <div className="error-message">{errors.terms}</div>
        )}
      </div>
      
      <div>
        <button type="submit" disabled={!values.terms}>
          가입하기
        </button>
        <button type="button" onClick={reset}>
          초기화
        </button>
      </div>
    </form>
  );
}

4. 폼 라이브러리

복잡한 폼을 더 쉽게 관리하기 위한 여러 라이브러리가 있습니다.

Formik

Formik은 React에서 가장 인기 있는 폼 관리 라이브러리 중 하나입니다.

npm install formik

기본 사용법:

import React from 'react';
import { Formik, Form, Field, ErrorMessage } from 'formik';
import * as Yup from 'yup'; // 유효성 검사 라이브러리

// 유효성 검사 스키마
const validationSchema = Yup.object({
  firstName: Yup.string()
    .max(15, '15자 이하여야 합니다')
    .required('이름을 입력하세요'),
  lastName: Yup.string()
    .max(20, '20자 이하여야 합니다')
    .required('성을 입력하세요'),
  email: Yup.string()
    .email('유효한 이메일 주소를 입력하세요')
    .required('이메일을 입력하세요'),
  password: Yup.string()
    .min(8, '비밀번호는 8자 이상이어야 합니다')
    .required('비밀번호를 입력하세요'),
  confirmPassword: Yup.string()
    .oneOf([Yup.ref('password'), null], '비밀번호가 일치하지 않습니다')
    .required('비밀번호 확인을 입력하세요'),
  terms: Yup.boolean()
    .oneOf([true], '이용약관에 동의해야 합니다')
});

function FormikExample() {
  return (
    <Formik
      initialValues={{
        firstName: '',
        lastName: '',
        email: '',
        password: '',
        confirmPassword: '',
        terms: false
      }}
      validationSchema={validationSchema}
      onSubmit={(values, { setSubmitting, resetForm }) => {
        setTimeout(() => {
          console.log('폼 제출 값:', values);
          alert('폼이 제출되었습니다!');
          resetForm();
          setSubmitting(false);
        }, 500);
      }}
    >
      {({ isSubmitting, touched, errors }) => (
        <Form>
          <div>
            <label htmlFor="firstName">이름:</label>
            <Field
              type="text"
              id="firstName"
              name="firstName"
              className={touched.firstName && errors.firstName ? 'error-input' : ''}
            />
            <ErrorMessage name="firstName" component="div" className="error-message" />
          </div>
          
          <div>
            <label htmlFor="lastName">성:</label>
            <Field
              type="text"
              id="lastName"
              name="lastName"
              className={touched.lastName && errors.lastName ? 'error-input' : ''}
            />
            <ErrorMessage name="lastName" component="div" className="error-message" />
          </div>
          
          <div>
            <label htmlFor="email">이메일:</label>
            <Field
              type="email"
              id="email"
              name="email"
              className={touched.email && errors.email ? 'error-input' : ''}
            />
            <ErrorMessage name="email" component="div" className="error-message" />
          </div>
          
          <div>
            <label htmlFor="password">비밀번호:</label>
            <Field
              type="password"
              id="password"
              name="password"
              className={touched.password && errors.password ? 'error-input' : ''}
            />
            <ErrorMessage name="password" component="div" className="error-message" />
          </div>
          
          <div>
            <label htmlFor="confirmPassword">비밀번호 확인:</label>
            <Field
              type="password"
              id="confirmPassword"
              name="confirmPassword"
              className={touched.confirmPassword && errors.confirmPassword ? 'error-input' : ''}
            />
            <ErrorMessage name="confirmPassword" component="div" className="error-message" />
          </div>
          
          <div>
            <label>
              <Field type="checkbox" name="terms" />
              이용약관에 동의합니다
            </label>
            <ErrorMessage name="terms" component="div" className="error-message" />
          </div>
          
          <button type="submit" disabled={isSubmitting}>
            {isSubmitting ? '처리 중...' : '가입하기'}
          </button>
        </Form>
      )}
    </Formik>
  );
}

React Hook Form

React Hook Form은 성능에 중점을 둔 더 가벼운 폼 라이브러리입니다.

npm install react-hook-form

기본 사용법:

import React from 'react';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as yup from 'yup';

// 유효성 검사 스키마
const schema = yup.object({
  firstName: yup.string().required('이름을 입력하세요'),
  lastName: yup.string().required('성을 입력하세요'),
  email: yup.string().email('유효한 이메일을 입력하세요').required('이메일을 입력하세요'),
  password: yup.string().min(8, '비밀번호는 8자 이상이어야 합니다').required('비밀번호를 입력하세요'),
  confirmPassword: yup.string()
    .oneOf([yup.ref('password'), null], '비밀번호가 일치하지 않습니다')
    .required('비밀번호 확인을 입력하세요'),
  terms: yup.boolean().oneOf([true], '이용약관에 동의해야 합니다')
});

function ReactHookFormExample() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
    reset
  } = useForm({
    resolver: yupResolver(schema)
  });
  
  const onSubmit = (data) => {
    return new Promise((resolve) => {
      setTimeout(() => {
        console.log('폼 제출 값:', data);
        alert('폼이 제출되었습니다!');
        reset();
        resolve();
      }, 500);
    });
  };
  
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label htmlFor="firstName">이름:</label>
        <input
          id="firstName"
          {...register('firstName')}
          className={errors.firstName ? 'error-input' : ''}
        />
        {errors.firstName && (
          <div className="error-message">{errors.firstName.message}</div>
        )}
      </div>
      
      <div>
        <label htmlFor="lastName">성:</label>
        <input
          id="lastName"
          {...register('lastName')}
          className={errors.lastName ? 'error-input' : ''}
        />
        {errors.lastName && (
          <div className="error-message">{errors.lastName.message}</div>
        )}
      </div>
      
      <div>
        <label htmlFor="email">이메일:</label>
        <input
          id="email"
          type="email"
          {...register('email')}
          className={errors.email ? 'error-input' : ''}
        />
        {errors.email && (
          <div className="error-message">{errors.email.message}</div>
        )}
      </div>
      
      <div>
        <label htmlFor="password">비밀번호:</label>
        <input
          id="password"
          type="password"
          {...register('password')}
          className={errors.password ? 'error-input' : ''}
        />
        {errors.password && (
          <div className="error-message">{errors.password.message}</div>
        )}
      </div>
      
      <div>
        <label htmlFor="confirmPassword">비밀번호 확인:</label>
        <input
          id="confirmPassword"
          type="password"
          {...register('confirmPassword')}
          className={errors.confirmPassword ? 'error-input' : ''}
        />
        {errors.confirmPassword && (
          <div className="error-message">{errors.confirmPassword.message}</div>
        )}
      </div>
      
      <div>
        <label>
          <input type="checkbox" {...register('terms')} />
          이용약관에 동의합니다
        </label>
        {errors.terms && (
          <div className="error-message">{errors.terms.message}</div>
        )}
      </div>
      
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? '처리 중...' : '가입하기'}
      </button>
    </form>
  );
}

5. 다양한 폼 요소 다루기

1. 선택 필드 (Select)

import React, { useState } from 'react';

function SelectExample() {
  const [country, setCountry] = useState('');
  
  const handleChange = (e) => {
    setCountry(e.target.value);
  };
  
  return (
    <div>
      <label htmlFor="country">국가:</label>
      <select
        id="country"
        value={country}
        onChange={handleChange}
      >
        <option value="">선택하세요</option>
        <option value="kr">한국</option>
        <option value="us">미국</option>
        <option value="jp">일본</option>
        <option value="cn">중국</option>
        <option value="uk">영국</option>
      </select>
      
      {country && <p>선택한 국가: {country}</p>}
    </div>
  );
}

2. 체크박스 그룹

import React, { useState } from 'react';

function CheckboxGroupExample() {
  const [selectedLanguages, setSelectedLanguages] = useState([]);
  
  const languages = [
    { id: 'js', name: 'JavaScript' },
    { id: 'py', name: 'Python' },
    { id: 'java', name: 'Java' },
    { id: 'csharp', name: 'C#' },
    { id: 'go', name: 'Go' }
  ];
  
  const handleChange = (e) => {
    const { value, checked } = e.target;
    
    if (checked) {
      // 항목 추가
      setSelectedLanguages([...selectedLanguages, value]);
    } else {
      // 항목 제거
      setSelectedLanguages(selectedLanguages.filter(lang => lang !== value));
    }
  };
  
  return (
    <div>
      <p>관심 있는 프로그래밍 언어를 선택하세요:</p>
      
      <div className="checkbox-group">
        {languages.map(language => (
          <label key={language.id}>
            <input
              type="checkbox"
              value={language.id}
              checked={selectedLanguages.includes(language.id)}
              onChange={handleChange}
            />
            {language.name}
          </label>
        ))}
      </div>
      
      {selectedLanguages.length > 0 && (
        <p>
          선택한 언어: {selectedLanguages.map(id => {
            const language = languages.find(lang => lang.id === id);
            return language ? language.name : id;
          }).join(', ')}
        </p>
      )}
    </div>
  );
}

3. 라디오 버튼 그룹

import React, { useState } from 'react';

function RadioGroupExample() {
  const [experience, setExperience] = useState('');
  
  const experienceLevels = [
    { id: 'beginner', label: '입문자 (1년 미만)' },
    { id: 'intermediate', label: '중급자 (1-3년)' },
    { id: 'advanced', label: '고급자 (3-5년)' },
    { id: 'expert', label: '전문가 (5년 이상)' }
  ];
  
  const handleChange = (e) => {
    setExperience(e.target.value);
  };
  
  return (
    <div>
      <p>개발 경력을 선택하세요:</p>
      
      <div className="radio-group">
        {experienceLevels.map(level => (
          <label key={level.id}>
            <input
              type="radio"
              name="experience"
              value={level.id}
              checked={experience === level.id}
              onChange={handleChange}
            />
            {level.label}
          </label>
        ))}
      </div>
      
      {experience && <p>선택한 경력: {experience}</p>}
    </div>
  );
}

4. 다중 선택 (Multi-select)

import React, { useState } from 'react';

function MultiSelectExample() {
  const [selectedFruits, setSelectedFruits] = useState([]);
  
  const fruits = [
    { id: 'apple', name: '사과' },
    { id: 'banana', name: '바나나' },
    { id: 'orange', name: '오렌지' },
    { id: 'grape', name: '포도' },
    { id: 'strawberry', name: '딸기' }
  ];
  
  const handleChange = (e) => {
    const values = Array.from(
      e.target.selectedOptions,
      option => option.value
    );
    
    setSelectedFruits(values);
  };
  
  return (
    <div>
      <label htmlFor="fruits">좋아하는 과일을 선택하세요 (여러 개 선택 가능):</label>
      <select
        id="fruits"
        multiple
        value={selectedFruits}
        onChange={handleChange}
        style={{ height: '150px' }}
      >
        {fruits.map(fruit => (
          <option key={fruit.id} value={fruit.id}>
            {fruit.name}
          </option>
        ))}
      </select>
      
      {selectedFruits.length > 0 && (
        <p>
          선택한 과일: {selectedFruits.map(id => {
            const fruit = fruits.find(f => f.id === id);
            return fruit ? fruit.name : id;
          }).join(', ')}
        </p>
      )}
    </div>
  );
}

5. 파일 업로드

import React, { useState } from 'react';

function FileUploadExample() {
  const [file, setFile] = useState(null);
  const [preview, setPreview] = useState('');
  
  const handleFileChange = (e) => {
    const selectedFile = e.target.files[0];
    
    if (selectedFile) {
      setFile(selectedFile);
      
      // 이미지 미리보기 생성
      if (selectedFile.type.startsWith('image/')) {
        const reader = new FileReader();
        reader.onload = () => {
          setPreview(reader.result);
        };
        reader.readAsDataURL(selectedFile);
      } else {
        setPreview('');
      }
    }
  };
  
  const handleSubmit = (e) => {
    e.preventDefault();
    
    if (!file) {
      alert('파일을 선택하세요');
      return;
    }
    
    // 여기서 파일 업로드 처리 (예: FormData 사용)
    const formData = new FormData();
    formData.append('file', file);
    
    // API 요청 예시
    console.log('업로드할 파일:', file);
    console.log('FormData:', formData);
    
    // 실제 API 요청 코드 (예시)
    // fetch('/api/upload', {
    //   method: 'POST',
    //   body: formData
    // })
    // .then(response => response.json())
    // .then(data => console.log('성공:', data))
    // .catch(error => console.error('에러:', error));
    
    alert('파일 업로드가 처리되었습니다');
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="file">파일 선택:</label>
        <input
          type="file"
          id="file"
          onChange={handleFileChange}
          accept="image/*" // 이미지 파일만 허용 (선택 사항)
        />
      </div>
      
      {file && (
        <div>
          <p>선택한 파일: {file.name} ({Math.round(file.size / 1024)} KB)</p>
        </div>
      )}
      
      {preview && (
        <div>
          <h4>이미지 미리보기:</h4>
          <img
            src={preview}
            alt="미리보기"
            style={{ maxWidth: '300px', maxHeight: '200px' }}
          />
        </div>
      )}
      
      <button type="submit" disabled={!file}>
        업로드
      </button>
    </form>
  );
}

6. 동적 폼 필드

import React, { useState } from 'react';

function DynamicFormExample() {
  const [formFields, setFormFields] = useState([
    { name: '', email: '' }
  ]);
  
  const handleFormChange = (index, event) => {
    const data = [...formFields];
    data[index][event.target.name] = event.target.value;
    setFormFields(data);
  };
  
  const addFields = () => {
    setFormFields([...formFields, { name: '', email: '' }]);
  };
  
  const removeFields = (index) => {
    const data = [...formFields];
    data.splice(index, 1);
    setFormFields(data);
  };
  
  const handleSubmit = (e) => {
    e.preventDefault();
    console.log('폼 데이터:', formFields);
    alert('폼이 제출되었습니다!');
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <h3>팀원 정보 입력</h3>
      
      {formFields.map((field, index) => (
        <div key={index} className="form-row">
          <div>
            <label htmlFor={`name-${index}`}>이름:</label>
            <input
              type="text"
              id={`name-${index}`}
              name="name"
              value={field.name}
              onChange={event => handleFormChange(index, event)}
              required
            />
          </div>
          
          <div>
            <label htmlFor={`email-${index}`}>이메일:</label>
            <input
              type="email"
              id={`email-${index}`}
              name="email"
              value={field.email}
              onChange={event => handleFormChange(index, event)}
              required
            />
          </div>
          
          {formFields.length > 1 && (
            <button
              type="button"
              className="remove-btn"
              onClick={() => removeFields(index)}
            >
              제거
            </button>
          )}
        </div>
      ))}
      
      <div className="form-buttons">
        <button type="button" onClick={addFields}>
          팀원 추가
        </button>
        <button type="submit">제출</button>
      </div>
    </form>
  );
}

6. 폼 스타일링과 접근성

폼 스타일링 예시

/* Form.css */
.form-container {
  max-width: 500px;
  margin: 0 auto;
  padding: 20px;
  background-color: #f9f9f9;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}

.form-group {
  margin-bottom: 20px;
}

label {
  display: block;
  margin-bottom: 5px;
  font-weight: 500;
  color: #333;
}

input[type="text"],
input[type="email"],
input[type="password"],
input[type="number"],
select,
textarea {
  width: 100%;
  padding: 10px;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 16px;
  transition: border-color 0.3s;
}

input:focus,
select:focus,
textarea:focus {
  outline: none;
  border-color: #4a90e2;
  box-shadow: 0 0 0 2px rgba(74, 144, 226, 0.2);
}

.error-input {
  border-color: #e53935;
}

.error-message {
  color: #e53935;
  font-size: 14px;
  margin-top: 5px;
}

button {
  background-color: #4a90e2;
  color: white;
  border: none;
  border-radius: 4px;
  padding: 10px 20px;
  font-size: 16px;
  cursor: pointer;
  transition: background-color 0.3s;
}

button:hover {
  background-color: #3a7bc8;
}

button:disabled {
  background-color: #cccccc;
  cursor: not-allowed;
}

.checkbox-group,
.radio-group {
  display: flex;
  flex-direction: column;
  gap: 10px;
}

/* 반응형 디자인 */
@media (max-width: 600px) {
  .form-container {
    padding: 15px;
  }
  
  input, select, textarea, button {
    font-size: 14px;
    padding: 8px;
  }
}

접근성 향상 기법

import React, { useState } from 'react';
import './Form.css';

function AccessibleForm() {
  const [formData, setFormData] = useState({
    name: '',
    email: '',
    message: ''
  });
  const [errors, setErrors] = useState({});
  
  const handleChange = (e) => {
    const { name, value } = e.target;
    setFormData({
      ...formData,
      [name]: value
    });
    
    // 입력 시 오류 메시지 제거
    if (errors[name]) {
      setErrors({
        ...errors,
        [name]: ''
      });
    }
  };
  
  const validate = () => {
    const newErrors = {};
    
    if (!formData.name.trim()) {
      newErrors.name = '이름을 입력하세요';
    }
    
    if (!formData.email.trim()) {
      newErrors.email = '이메일을 입력하세요';
    } else if (!/\S+@\S+\.\S+/.test(formData.email)) {
      newErrors.email = '유효한 이메일 주소를 입력하세요';
    }
    
    if (!formData.message.trim()) {
      newErrors.message = '메시지를 입력하세요';
    }
    
    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  };
  
  const handleSubmit = (e) => {
    e.preventDefault();
    
    if (validate()) {
      console.log('폼 제출:', formData);
      alert('메시지가 전송되었습니다!');
      setFormData({ name: '', email: '', message: '' });
    }
  };
  
  return (
    <div className="form-container">
      <h2 id="form-title">문의하기</h2>
      <p id="form-description">
        질문이나 의견이 있으시면 아래 양식을 작성해 주세요.
      </p>
      
      <form
        onSubmit={handleSubmit}
        aria-labelledby="form-title"
        aria-describedby="form-description"
      >
        <div className="form-group">
          <label htmlFor="name">
            이름:
            {errors.name && <span className="sr-only">오류: {errors.name}</span>}
          </label>
          <input
            type="text"
            id="name"
            name="name"
            value={formData.name}
            onChange={handleChange}
            aria-invalid={errors.name ? 'true' : 'false'}
            aria-describedby={errors.name ? 'name-error' : undefined}
            className={errors.name ? 'error-input' : ''}
          />
          {errors.name && (
            <div id="name-error" className="error-message">
              {errors.name}
            </div>
          )}
        </div>
        
        <div className="form-group">
          <label htmlFor="email">
            이메일:
            {errors.email && <span className="sr-only">오류: {errors.email}</span>}
          </label>
          <input
            type="email"
            id="email"
            name="email"
            value={formData.email}
            onChange={handleChange}
            aria-invalid={errors.email ? 'true' : 'false'}
            aria-describedby={errors.email ? 'email-error' : undefined}
            className={errors.email ? 'error-input' : ''}
          />
          {errors.email && (
            <div id="email-error" className="error-message">
              {errors.email}
            </div>
          )}
        </div>
        
        <div className="form-group">
          <label htmlFor="message">
            메시지:
            {errors.message && <span className="sr-only">오류: {errors.message}</span>}
          </label>
          <textarea
            id="message"
            name="message"
            rows="5"
            value={formData.message}
            onChange={handleChange}
            aria-invalid={errors.message ? 'true' : 'false'}
            aria-describedby={errors.message ? 'message-error' : undefined}
            className={errors.message ? 'error-input' : ''}
          ></textarea>
          {errors.message && (
            <div id="message-error" className="error-message">
              {errors.message}
            </div>
          )}
        </div>
        
        <button type="submit">전송하기</button>
      </form>
    </div>
  );
}

export default AccessibleForm;

결론

React에서 폼 관리는 사용자 입력을 처리하는 중요한 부분입니다. 간단한 폼은 React의 기본 기능만으로도 충분히 처리할 수 있지만, 복잡한 폼의 경우 상태 관리, 유효성 검사, 오류 처리 등이 까다로울 수 있습니다.

자신만의 커스텀 폼 로직을 구현하거나 Formik, React Hook Form과 같은 라이브러리를 활용하면 복잡한 폼을 더 쉽게 관리할 수 있습니다. 또한, 스타일링과 접근성에 주의를 기울여 모든 사용자가 쉽게 사용할 수 있는 폼을 만드는 것이 중요합니다.

다양한 폼 요소와 패턴을 조합하여 애플리케이션의 요구사항에 맞는 최적의 폼 솔루션을 구현하세요.