본문으로 건너뛰기

안동민 개발노트

본문 시작

복합 폼 상태 관리

여러 입력과 필드 간 의존성을 useReducer와 커스텀 훅으로 구조화하고 폼 라이브러리가 필요한 기준을 판단합니다.

이번에는 더 복잡한 폼 상태를 효과적으로 관리하는 방법을 다룹니다.

폼 필드가 많아지거나 필드 간 상호 의존성이 생기면, 단순한 useState만으로는 관리가 어려워질 수 있습니다.

이럴 때 유용한 몇 가지 패턴과 훅을 살펴보겠습니다.

여러 입력 폼이 객체 상태에서 reducer, 커스텀 훅, 폼 라이브러리로 확장되는 기준과 각 단계가 맡는 책임

React · Form complexity ladder

폼 추상화는 필드 수가 아니라 변경·검증·제출 책임이 서로 얽히는 정도에 맞춰 한 단계씩 올립니다. 현재 단계의 계약이 충분하면 더 큰 도구를 먼저 도입하지 않습니다.

Growth path

작동하는 최소 모델에서 다음 책임으로

  1. 객체 상태

    name으로 key를 고르고 input 종류에 따라 value 또는 checked를 한 formData 객체에 병합합니다.

  2. useReducer

    CHANGE_VALUE, SET_ERRORS, RESET_FORM처럼 변경 이유가 다른 전이를 한 reducer에 모읍니다.

  3. 커스텀 훅

    values, errors, 제출 처리와 초기화를 재사용 가능한 API로 묶어 화면 컴포넌트를 얇게 만듭니다.

  4. 폼 라이브러리

    동적 필드, 스키마·비동기 검증, 방문·dirty 상태와 렌더 범위가 함께 커질 때 검증된 도구를 선택합니다.

현재 모델을 유지할 때와 다음 단계로 이동할 때
현재 모델유지해도 되는 조건다음 단계가 필요한 신호
객체 상태필드가 독립적이고 한 공통 변경 함수로 충분함종속 전이와 오류·초기화 규칙이 변경 함수에 섞임
useReducer한 폼 안에서 action과 다음 상태를 읽기 쉽게 설명할 수 있음여러 폼에서 같은 값·검증·제출 배선이 반복됨
커스텀 훅필드 구조와 검증·제출 계약이 작고 안정적임동적 배열, 비동기 검증, 세밀한 구독 최적화가 필요함
Object state

독립 필드와 공통 변경 함수

필드 간 전이가 단순하면 가장 작은 모델을 유지합니다.

Reducer

전이 이유를 action으로

변경, 오류 설정, 초기화를 한곳에서 추적합니다.

Custom hook

반복 계약을 재사용

값·검증·제출 배선을 폼 컴포넌트 밖으로 옮깁니다.

Library

상태 축과 동적 구조가 얽힐 때

필드 등록, 스키마, 배열, 구독 범위를 전용 모델에 맡깁니다.

각 단계는 앞 단계를 버리는 정답이 아니라 복잡성에 맞춘 책임 경계입니다. 작은 폼은 작은 모델로 시작하고 반복이나 상태 전이가 늘 때만 다음 층을 추가합니다.


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

지난 장에서 잠깐 다루었지만, 여러 개의 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.currentTarget;
    // 체크박스와 같은 특정 타입의 입력은 `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 htmlFor="firstName" style={{ display: 'block', marginBottom: '5px' }}>First Name:</label>
          <input id="firstName" type="text" name="firstName" value={formData.firstName} onChange={handleChange} style={{ width: '100%', padding: '8px' }} />
        </div>
        <div style={{ marginBottom: '15px' }}>
          <label htmlFor="lastName" style={{ display: 'block', marginBottom: '5px' }}>Last Name:</label>
          <input id="lastName" type="text" name="lastName" value={formData.lastName} onChange={handleChange} style={{ width: '100%', padding: '8px' }} />
        </div>
        <div style={{ marginBottom: '15px' }}>
          <label htmlFor="email" style={{ display: 'block', marginBottom: '5px' }}>Email:</label>
          <input id="email" type="email" name="email" value={formData.email} onChange={handleChange} style={{ width: '100%', padding: '8px' }} />
        </div>
        {/* 비밀번호 입력 */}
        <div style={{ marginBottom: '15px' }}>
          <label htmlFor="password" style={{ display: 'block', marginBottom: '5px' }}>Password:</label>
          <input id="password" type="password" name="password" value={formData.password} onChange={handleChange} style={{ width: '100%', padding: '8px' }} />
        </div>
        <div style={{ marginBottom: '15px' }}>
          <label htmlFor="confirmPassword" style={{ display: 'block', marginBottom: '5px' }}>Confirm Password:</label>
          <input id="confirmPassword" 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 htmlFor="country" style={{ display: 'block', marginBottom: '5px' }}>Country:</label>
          <select id="country" 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.currentTarget;
    dispatch({
      type: 'CHANGE_VALUE',
      field: name,
      value: type === 'checkbox' ? checked : value,
    });
  };

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

    return validationErrors;
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    const validationErrors = validateForm();
    dispatch({ type: 'SET_ERRORS', errors: validationErrors });

    if (Object.keys(validationErrors).length === 0) {
      const submittedValues = {
        username: formState.username,
        password: formState.password,
        comment: formState.comment,
        rememberMe: formState.rememberMe,
      };
      console.log('폼 제출됨 (useReducer):', submittedValues);
      alert('폼 데이터: ' + JSON.stringify(submittedValues, null, 2));
      dispatch({ type: 'RESET_FORM', initialState: initialFormState }); // 제출 후 폼 리셋
    } else {
      console.log('유효성 검사 실패:', validationErrors);
      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 htmlFor="reducer-username" style={{ display: 'block', marginBottom: '5px' }}>사용자 이름:</label>
          <input id="reducer-username" type="text" name="username" value={formState.username} onChange={handleChange} aria-invalid={Boolean(formState.errors.username)} aria-describedby={formState.errors.username ? 'reducer-username-error' : undefined} style={{ width: '100%', padding: '8px', border: formState.errors.username ? '1px solid red' : '1px solid #ccc' }} />
          {formState.errors.username && <p id="reducer-username-error" style={{ color: 'red', fontSize: '0.8em', marginTop: '5px' }}>{formState.errors.username}</p>}
        </div>
        <div style={{ marginBottom: '15px' }}>
          <label htmlFor="reducer-password" style={{ display: 'block', marginBottom: '5px' }}>비밀번호:</label>
          <input id="reducer-password" type="password" name="password" value={formState.password} onChange={handleChange} aria-invalid={Boolean(formState.errors.password)} aria-describedby={formState.errors.password ? 'reducer-password-error' : undefined} style={{ width: '100%', padding: '8px', border: formState.errors.password ? '1px solid red' : '1px solid #ccc' }} />
          {formState.errors.password && <p id="reducer-password-error" style={{ color: 'red', fontSize: '0.8em', marginTop: '5px' }}>{formState.errors.password}</p>}
        </div>
        <div style={{ marginBottom: '15px' }}>
          <label htmlFor="reducer-comment" style={{ display: 'block', marginBottom: '5px' }}>코멘트:</label>
          <textarea id="reducer-comment" 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를 사용하면 폼의 상태 변화 로직과 유효성 검사 로직을 리듀서 함수 내에 통합하여 관리할 수 있습니다.

이는 폼이 커질수록 코드의 응집성을 높여줍니다.


이어서 보기