icon
9장 : 폼 처리와 유효성 검사

복잡한 폼 상태 관리하기


이번에는 더 복잡한 폼의 상태를 어떻게 효과적으로 관리할 수 있는지에 대해 깊이 있게 다루겠습니다. 폼 필드가 많아지거나, 필드 간의 상호 의존성이 생길 때, 단순한 useState만으로는 관리가 어려워질 수 있습니다. 이럴 때 유용한 몇 가지 패턴과 훅을 살펴보겠습니다.


다수의 입력 필드 관리 패턴 다시 보기

지난 장에서 잠깐 다루었지만, 여러 개의 input 필드를 하나의 useState 객체로 관리하는 패턴은 복잡한 폼 관리에 있어 첫걸음입니다.

import React, { useState } from 'react';

function MultipleInputsForm() {
  const [formData, setFormData] = useState({
    firstName: '',
    lastName: '',
    email: '',
    password: '',
    confirmPassword: '',
    newsletter: false,
    country: 'USA',
  });

  const handleChange = (e) => {
    const { name, value, type, checked } = e.target;
    // 체크박스와 같은 특정 타입의 입력은 `checked` 속성을 사용
    setFormData(prevData => ({
      ...prevData,
      [name]: type === 'checkbox' ? checked : value,
    }));
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    console.log('Form Submitted:', formData);
    alert('폼 데이터: ' + JSON.stringify(formData, null, 2));
  };

  return (
    <div style={{ maxWidth: '600px', margin: '30px auto', padding: '25px', border: '1px solid #ddd', borderRadius: '8px', boxShadow: '0 2px 10px rgba(0,0,0,0.05)', backgroundColor: '#fff' }}>
      <h2 style={{ textAlign: 'center', color: '#2c3e50', marginBottom: '30px' }}>다수의 입력 필드 폼</h2>
      <form onSubmit={handleSubmit}>
        {/* 일반 텍스트 입력 */}
        <div style={{ marginBottom: '15px' }}>
          <label style={{ display: 'block', marginBottom: '5px' }}>First Name:</label>
          <input type="text" name="firstName" value={formData.firstName} onChange={handleChange} style={{ width: '100%', padding: '8px' }} />
        </div>
        <div style={{ marginBottom: '15px' }}>
          <label style={{ display: 'block', marginBottom: '5px' }}>Last Name:</label>
          <input type="text" name="lastName" value={formData.lastName} onChange={handleChange} style={{ width: '100%', padding: '8px' }} />
        </div>
        <div style={{ marginBottom: '15px' }}>
          <label style={{ display: 'block', marginBottom: '5px' }}>Email:</label>
          <input type="email" name="email" value={formData.email} onChange={handleChange} style={{ width: '100%', padding: '8px' }} />
        </div>
        {/* 비밀번호 입력 */}
        <div style={{ marginBottom: '15px' }}>
          <label style={{ display: 'block', marginBottom: '5px' }}>Password:</label>
          <input type="password" name="password" value={formData.password} onChange={handleChange} style={{ width: '100%', padding: '8px' }} />
        </div>
        <div style={{ marginBottom: '15px' }}>
          <label style={{ display: 'block', marginBottom: '5px' }}>Confirm Password:</label>
          <input type="password" name="confirmPassword" value={formData.confirmPassword} onChange={handleChange} style={{ width: '100%', padding: '8px' }} />
        </div>
        {/* 체크박스 */}
        <div style={{ marginBottom: '15px' }}>
          <input type="checkbox" id="newsletter" name="newsletter" checked={formData.newsletter} onChange={handleChange} style={{ marginRight: '8px' }} />
          <label htmlFor="newsletter">Subscribe to Newsletter</label>
        </div>
        {/* Select 박스 */}
        <div style={{ marginBottom: '20px' }}>
          <label style={{ display: 'block', marginBottom: '5px' }}>Country:</label>
          <select name="country" value={formData.country} onChange={handleChange} style={{ width: '100%', padding: '8px' }}>
            <option value="USA">United States</option>
            <option value="Canada">Canada</option>
            <option value="UK">United Kingdom</option>
            <option value="Korea">South Korea</option>
          </select>
        </div>
        <button type="submit" className="button" style={{ width: '100%', padding: '10px' }}>Submit</button>
      </form>
    </div>
  );
}

export default MultipleInputsForm;

이 패턴은 대부분의 경우에 잘 작동하지만, 폼 필드가 매우 많아지거나, 필드마다 복잡한 유효성 검사 로직이 필요해지면 handleChange 함수가 비대해지고, 컴포넌트 자체가 복잡해질 수 있습니다.


useReducer를 이용한 폼 상태 관리

useState 대신 useReducer 훅을 사용하면 여러 개의 상태 업데이트 로직을 한 곳에 모아 관리할 수 있습니다. 이는 특히 상태 전이(state transitions)가 복잡하거나, 다음 상태가 이전 상태에 의존하는 경우에 유용합니다. 폼 상태 관리는 이러한 경우에 해당할 수 있습니다.

useReducer의 장점

  • 상태 로직 중앙 집중화: 모든 상태 업데이트 로직이 리듀서 함수 내에 존재하므로, 관련 로직을 한눈에 파악하기 쉽습니다.
  • 복잡한 상태 전이 관리: 여러 필드의 유효성 검사나 종속적인 필드 업데이트 등 복잡한 상태 변화를 깔끔하게 처리할 수 있습니다.
  • 성능 최적화: dispatch 함수는 한 번 생성되면 변하지 않으므로, 자식 컴포넌트에 dispatch를 넘겨줄 때 useCallback 등으로 감쌀 필요가 없어 성능 최적화에 도움이 될 수 있습니다.

구현 예시

src/components/ReducerForm.js
import React, { useReducer } from 'react';

// 폼 상태를 관리할 리듀서 함수
function formReducer(state, action) {
  switch (action.type) {
    case 'CHANGE_VALUE':
      return {
        ...state,
        [action.field]: action.value,
        // 필드마다 유효성 검사 로직을 리듀서 내부에 추가할 수도 있습니다.
        // 예를 들어: email 필드에 대한 유효성 검사
        // emailError: action.field === 'email' && !action.value.includes('@') ? '유효하지 않은 이메일' : '',
      };
    case 'RESET_FORM':
      return action.initialState; // 초기 상태로 리셋
    case 'SET_ERRORS': // 유효성 검사 오류를 설정하는 액션
      return {
        ...state,
        errors: action.errors,
      };
    default:
      return state;
  }
}

const initialFormState = {
  username: '',
  password: '',
  comment: '',
  rememberMe: false,
  errors: {}, // 에러 상태도 여기에 포함
};

function ReducerForm() {
  const [formState, dispatch] = useReducer(formReducer, initialFormState);

  const handleChange = (e) => {
    const { name, value, type, checked } = e.target;
    dispatch({
      type: 'CHANGE_VALUE',
      field: name,
      value: type === 'checkbox' ? checked : value,
    });
  };

  const validateForm = () => {
    const newErrors = {};
    if (!formState.username.trim()) {
      newErrors.username = '사용자 이름을 입력해주세요.';
    }
    if (formState.password.length < 6) {
      newErrors.password = '비밀번호는 6자 이상이어야 합니다.';
    }
    // 추가적인 유효성 검사 규칙...

    dispatch({ type: 'SET_ERRORS', errors: newErrors });
    return Object.keys(newErrors).length === 0; // 에러가 없으면 true 반환
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    if (validateForm()) {
      console.log('폼 제출됨 (useReducer):', formState);
      alert('폼 데이터: ' + JSON.stringify(formState, null, 2));
      dispatch({ type: 'RESET_FORM', initialState: initialFormState }); // 제출 후 폼 리셋
    } else {
      console.log('유효성 검사 실패:', formState.errors);
      alert('폼 입력값을 확인해주세요.');
    }
  };

  return (
    <div style={{ maxWidth: '600px', margin: '30px auto', padding: '25px', border: '1px solid #ddd', borderRadius: '8px', boxShadow: '0 2px 10px rgba(0,0,0,0.05)', backgroundColor: '#fff' }}>
      <h2 style={{ textAlign: 'center', color: '#2c3e50', marginBottom: '30px' }}>`useReducer`를 이용한 폼</h2>
      <form onSubmit={handleSubmit}>
        <div style={{ marginBottom: '15px' }}>
          <label style={{ display: 'block', marginBottom: '5px' }}>사용자 이름:</label>
          <input type="text" name="username" value={formState.username} onChange={handleChange} style={{ width: '100%', padding: '8px', border: formState.errors.username ? '1px solid red' : '1px solid #ccc' }} />
          {formState.errors.username && <p style={{ color: 'red', fontSize: '0.8em', marginTop: '5px' }}>{formState.errors.username}</p>}
        </div>
        <div style={{ marginBottom: '15px' }}>
          <label style={{ display: 'block', marginBottom: '5px' }}>비밀번호:</label>
          <input type="password" name="password" value={formState.password} onChange={handleChange} style={{ width: '100%', padding: '8px', border: formState.errors.password ? '1px solid red' : '1px solid #ccc' }} />
          {formState.errors.password && <p style={{ color: 'red', fontSize: '0.8em', marginTop: '5px' }}>{formState.errors.password}</p>}
        </div>
        <div style={{ marginBottom: '15px' }}>
          <label style={{ display: 'block', marginBottom: '5px' }}>코멘트:</label>
          <textarea name="comment" value={formState.comment} onChange={handleChange} rows="4" style={{ width: '100%', padding: '8px', border: '1px solid #ccc' }} />
        </div>
        <div style={{ marginBottom: '20px' }}>
          <input type="checkbox" id="rememberMe" name="rememberMe" checked={formState.rememberMe} onChange={handleChange} style={{ marginRight: '8px' }} />
          <label htmlFor="rememberMe">로그인 정보 저장</label>
        </div>
        <button type="submit" className="button" style={{ width: '100%', padding: '10px' }}>Submit</button>
      </form>
    </div>
  );
}

export default ReducerForm;

useReducer를 사용하면 폼의 상태 변화 로직과 유효성 검사 로직을 리듀서 함수 내에 통합하여 관리할 수 있습니다. 이는 폼이 커질수록 코드의 응집성을 높여줍니다.


커스텀 훅을 이용한 폼 상태 추상화

useReducer를 사용하더라도, 매 폼마다 리듀서와 handleChange 로직을 작성하는 것은 여전히 반복적인 작업일 수 있습니다. 이때 커스텀 훅(Custom Hook) 을 사용하면 이러한 폼 상태 관리 로직을 추상화하여 재사용성을 극대화할 수 있습니다.

useForm과 같은 커스텀 훅은 다음과 같은 기능을 제공할 수 있습니다.

  • 폼 데이터 상태 관리 (useState 또는 useReducer 기반)
  • 모든 input 필드에 적용할 수 있는 범용 handleChange 함수
  • 폼 제출(handleSubmit) 시 데이터를 처리하는 로직
  • 유효성 검사 로직 및 에러 상태 관리

useForm 커스텀 훅 예시

src/hooks/useForm.js
import { useState, useCallback } from 'react';

/**
 * 폼 상태와 유효성 검사를 관리하는 커스텀 훅
 * @param {object} initialValues - 폼 필드의 초기 값
 * @param {function} validate - 폼 데이터 객체를 받아 에러 객체를 반환하는 유효성 검사 함수
 * @returns {{ values, errors, handleChange, handleSubmit, resetForm }}
 */
const useForm = (initialValues, validate) => {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});
  const [isSubmitting, setIsSubmitting] = useState(false); // 제출 중 상태 (선택 사항)

  const handleChange = useCallback((e) => {
    const { name, value, type, checked } = e.target;
    setValues(prevValues => ({
      ...prevValues,
      [name]: type === 'checkbox' ? checked : value,
    }));
    // 입력 시 실시간 유효성 검사를 하고 싶다면 여기에 validate 로직을 추가할 수 있습니다.
    // 하지만 보통은 제출 시점에 모든 유효성을 검사하는 것이 일반적입니다.
  }, []);

  const handleSubmit = useCallback((callback) => async (e) => {
    e.preventDefault();
    setIsSubmitting(true);
    const validationErrors = validate(values); // 유효성 검사 실행
    setErrors(validationErrors);

    if (Object.keys(validationErrors).length === 0) {
      // 에러가 없으면 콜백 함수 실행
      try {
        await callback(values); // 비동기 콜백을 지원하기 위해 await
      } catch (submitError) {
        // 콜백 함수에서 발생한 에러 처리 (예: API 제출 실패)
        console.error("폼 제출 콜백 오류:", submitError);
        setErrors(prevErrors => ({
          ...prevErrors,
          submit: submitError.message || '폼 제출 중 오류가 발생했습니다.'
        }));
      }
    }
    setIsSubmitting(false);
  }, [values, validate]);

  const resetForm = useCallback(() => {
    setValues(initialValues);
    setErrors({});
    setIsSubmitting(false);
  }, [initialValues]);

  return {
    values,
    errors,
    isSubmitting,
    handleChange,
    handleSubmit,
    resetForm,
  };
};

export default useForm;

useForm 커스텀 훅 사용 예시

src/components/UserRegistrationForm.js
import React from 'react';
import useForm from '../hooks/useForm'; // 커스텀 훅 임포트

// 유효성 검사 함수 (useForm에 전달될 콜백)
const validateUserInfo = (values) => {
  const errors = {};
  if (!values.username.trim()) {
    errors.username = '사용자 이름은 필수입니다.';
  } else if (values.username.length < 3) {
    errors.username = '사용자 이름은 3자 이상이어야 합니다.';
  }
  if (!values.email.trim()) {
    errors.email = '이메일은 필수입니다.';
  } else if (!/\S+@\S+\.\S+/.test(values.email)) {
    errors.email = '유효한 이메일 주소를 입력해주세요.';
  }
  if (!values.password) {
    errors.password = '비밀번호는 필수입니다.';
  } else if (values.password.length < 6) {
    errors.password = '비밀번호는 6자 이상이어야 합니다.';
  }
  if (values.password !== values.confirmPassword) {
    errors.confirmPassword = '비밀번호가 일치하지 않습니다.';
  }
  return errors;
};

function UserRegistrationForm() {
  const {
    values,
    errors,
    isSubmitting,
    handleChange,
    handleSubmit,
    resetForm,
  } = useForm(
    { username: '', email: '', password: '', confirmPassword: '' }, // 초기값
    validateUserInfo // 유효성 검사 함수
  );

  // 폼 제출 로직 (useForm의 handleSubmit에 전달될 콜백)
  const onSubmit = async (formData) => {
    // 실제 API 호출 로직을 여기에 작성
    console.log('폼 데이터 제출 시작:', formData);
    try {
      // 가상 API 호출 지연
      await new Promise(resolve => setTimeout(resolve, 1000)); 
      console.log('폼 데이터 제출 완료:', formData);
      alert('회원가입이 완료되었습니다!');
      resetForm(); // 제출 성공 후 폼 초기화
    } catch (error) {
      console.error('회원가입 실패:', error);
      alert('회원가입에 실패했습니다.');
      throw error; // useForm 훅에서 에러를 잡을 수 있도록 다시 던짐
    }
  };

  return (
    <div style={{ maxWidth: '600px', margin: '30px auto', padding: '25px', border: '1px solid #ddd', borderRadius: '8px', boxShadow: '0 2px 10px rgba(0,0,0,0.05)', backgroundColor: '#fff' }}>
      <h2 style={{ textAlign: 'center', color: '#2c3e50', marginBottom: '30px' }}>회원가입 폼 (커스텀 훅)</h2>
      <form onSubmit={handleSubmit(onSubmit)}> {/* handleSubmit에 실제 제출 함수를 전달 */}
        <div style={{ marginBottom: '15px' }}>
          <label style={{ display: 'block', marginBottom: '5px' }}>사용자 이름:</label>
          <input
            type="text"
            name="username"
            value={values.username}
            onChange={handleChange}
            style={{ width: '100%', padding: '8px', border: errors.username ? '1px solid red' : '1px solid #ccc' }}
          />
          {errors.username && <p style={{ color: 'red', fontSize: '0.8em', marginTop: '5px' }}>{errors.username}</p>}
        </div>
        <div style={{ marginBottom: '15px' }}>
          <label style={{ display: 'block', marginBottom: '5px' }}>이메일:</label>
          <input
            type="email"
            name="email"
            value={values.email}
            onChange={handleChange}
            style={{ width: '100%', padding: '8px', border: errors.email ? '1px solid red' : '1px solid #ccc' }}
          />
          {errors.email && <p style={{ color: 'red', fontSize: '0.8em', marginTop: '5px' }}>{errors.email}</p>}
        </div>
        <div style={{ marginBottom: '15px' }}>
          <label style={{ display: 'block', marginBottom: '5px' }}>비밀번호:</label>
          <input
            type="password"
            name="password"
            value={values.password}
            onChange={handleChange}
            style={{ width: '100%', padding: '8px', border: errors.password ? '1px solid red' : '1px solid #ccc' }}
          />
          {errors.password && <p style={{ color: 'red', fontSize: '0.8em', marginTop: '5px' }}>{errors.password}</p>}
        </div>
        <div style={{ marginBottom: '15px' }}>
          <label style={{ display: 'block', marginBottom: '5px' }}>비밀번호 확인:</label>
          <input
            type="password"
            name="confirmPassword"
            value={values.confirmPassword}
            onChange={handleChange}
            style={{ width: '100%', padding: '8px', border: errors.confirmPassword ? '1px solid red' : '1px solid #ccc' }}
          />
          {errors.confirmPassword && <p style={{ color: 'red', fontSize: '0.8em', marginTop: '5px' }}>{errors.confirmPassword}</p>}
        </div>
        {errors.submit && <p style={{ color: 'red', fontSize: '0.9em', marginTop: '10px' }}>{errors.submit}</p>} {/* 제출 오류 */}
        <button type="submit" className="button" disabled={isSubmitting} style={{ width: '100%', padding: '10px' }}>
          {isSubmitting ? '등록 중...' : '회원가입'}
        </button>
      </form>
    </div>
  );
}

export default UserRegistrationForm;

useForm 커스텀 훅은 폼 컴포넌트의 로직을 훨씬 간결하고 재사용 가능하게 만듭니다. 이제 각 폼 컴포넌트는 오직 자신의 폼 필드와 유효성 검사 규칙만 정의하면 됩니다.


폼 라이브러리 활용

실제 프로덕션 환경에서 매우 복잡하고 큰 폼을 다룰 때는 useReducer나 커스텀 훅만으로도 한계에 부딪힐 수 있습니다. 특히 다음과 같은 상황에서 더욱 그렇습니다.

  • 수십 개의 입력 필드
  • 조건부 렌더링되는 필드
  • 동적으로 추가/제거되는 필드 (예: 여러 개의 전화번호 입력 필드)
  • 복잡한 유효성 검사 규칙 (비동기 유효성 검사 포함)
  • 성능 최적화 (불필요한 리렌더링 방지)

이러한 복잡성을 해결하기 위해 React Hook Form이나 Formik과 같은 전문적인 폼 관리 라이브러리들이 존재합니다. 이 라이브러리들은 폼 상태 관리, 유효성 검사, 제출 처리, 그리고 성능 최적화를 위한 다양한 기능을 제공합니다.

  • React Hook Form
    • 성능 중심: ref를 사용하여 비제어 컴포넌트 방식을 기본으로 채택하여 불필요한 리렌더링을 최소화합니다.
    • 간단한 API: 비교적 적은 코드와 간단한 API로 강력한 기능을 제공합니다.
    • 스키마 기반 유효성 검사: Yup, Zod 등의 스키마 유효성 검사 라이브러리와 쉽게 통합됩니다.
  • Formik
    • 포괄적인 기능: 제어 컴포넌트 방식을 기반으로 하며, 폼 상태, 유효성 검사, 에러 메시지, 터치된 필드 등 폼과 관련된 모든 상태를 관리합니다.
    • 쉬운 통합: JSX와 함께 사용하기 편리한 컴포넌트와 훅을 제공합니다.

이러한 라이브러리들은 다음 장인 "폼 유효성 검사"에서 더 자세히 다루겠지만, 복잡한 폼을 관리할 때 고려해야 할 강력한 도구라는 점을 미리 알아두는 것이 좋습니다.


9장 2절 "복잡한 폼 상태 관리하기"는 여기까지입니다. 이 장에서는 여러 입력 필드를 하나의 상태 객체로 관리하는 기본 패턴부터 시작하여, useReducer를 사용하여 상태 로직을 중앙 집중화하는 방법, 그리고 폼 관련 로직을 재사용 가능한 커스텀 훅으로 추상화하는 고급 패턴까지 살펴보았습니다. 마지막으로, 매우 복잡한 폼을 다룰 때 유용한 전문 폼 관리 라이브러리의 존재와 필요성에 대해서도 간략히 언급했습니다.