본문 바로가기

복합 폼 상태 관리

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

이 글에서는 더 복잡한 폼 상태를 관리하는 방법을 다룹니다. 폼 필드가 많아지거나 필드끼리 서로 기대는 관계가 생기면 단순한 useState만으로는 관리하기 어려워질 수 있으므로, 이럴 때 쓸 수 있는 몇 가지 패턴과 훅을 살펴봅니다. 이 글에서 객체 상태와 useReducer를, 이어지는 폼 상태 커스텀 훅과 라이브러리에서 커스텀 훅과 폼 라이브러리를 다룹니다.

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

React · Form complexity ladder

폼의 전이·재사용·도구를 선택하는 기준

폼 추상화는 필드 수보다 변경·검증·제출 책임의 복잡성에 맞춰 선택합니다. 현재 모델의 계약이 충분하면 유지하고, 필요한 책임을 reducer·훅·라이브러리로 분리하거나 조합합니다.

Growth path

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

  1. 객체 상태

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

  2. useReducer

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

  3. 커스텀 훅

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

  4. 폼 라이브러리

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

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

독립 필드와 공통 변경 함수

유지: 독립 필드와 공통 변경 함수로 충분함.

변경 신호: 종속 전이·오류·초기화 규칙이 뒤섞임.

Reducer

전이 이유를 action으로

유지: 한 폼의 action과 다음 상태가 명확함.

변경 신호: 여러 폼에서 값·검증·제출 배선이 반복됨.

Custom hook

반복 계약을 재사용

유지: 필드 구조와 검증·제출 계약이 작고 안정적임.

변경 신호: 동적 배열·비동기 검증·세밀한 구독이 필요함.

Library

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

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

객체 상태·reducer·커스텀 훅·라이브러리는 조합하거나 필요한 선택으로 바로 이동할 수 있습니다. 반복과 상태 전이의 복잡성에 맞춰 책임 경계를 정합니다.

위 그림은 여러 입력을 가진 폼이 객체 상태에서 리듀서, 커스텀 훅, 폼 라이브러리로 커지는 단계와 각 단계로 옮겨 갈 기준을 보여 줍니다. 그림의 요약처럼 폼을 어떻게 추상화할지는 필드의 수보다 변경과 검증, 제출 규칙이 얼마나 복잡한지에 맞춰 고르며, 지금의 모델로 충분하다면 그대로 둡니다. 위쪽의 성장 경로에서 객체 상태는 name으로 키를 골라 입력 종류에 따라 value나 checked를 한 formData 객체에 합치고, useReducer는 CHANGE_VALUE, SET_ERRORS, RESET_FORM처럼 바뀌는 이유가 다른 전이를 한 리듀서에 모읍니다. 커스텀 훅은 values와 errors, 제출 처리와 초기화를 재사용할 수 있는 API로 묶어 화면 컴포넌트를 가볍게 하고, 폼 라이브러리는 동적 필드와 스키마·비동기 검증, 방문과 dirty 상태, 렌더 범위가 함께 커질 때 고릅니다.

가운데 표는 지금의 모델을 유지해도 되는 조건과 다음 단계가 필요하다는 신호를 나란히 놓습니다. 객체 상태는 필드가 서로 독립적이고 공통 변경 함수 하나로 충분할 때 유지하며, 종속 전이와 오류, 초기화 규칙이 변경 함수에 섞이기 시작하면 리듀서를 검토합니다. 리듀서는 한 폼 안에서 액션과 다음 상태를 읽기 쉽게 설명할 수 있을 때 유지하고, 여러 폼에서 같은 값과 검증, 제출의 연결이 되풀이되면 커스텀 훅으로 옮깁니다. 커스텀 훅은 필드 구조와 검증, 제출 규칙이 작고 안정적일 때 유지하고, 동적 배열이나 비동기 검증, 세밀한 구독 최적화가 필요해지면 라이브러리를 봅니다. 그림의 마지막 설명처럼 이 단계들은 차례로만 밟는 것이 아니라 서로 조합하거나 필요한 선택으로 곧바로 갈 수 있습니다.


여러 입력 필드 관리 패턴 다시 보기

앞 글에서 다룬 것처럼, 여러 input 필드를 하나의 useState 객체로 관리하는 패턴은 복잡한 폼 관리의 첫걸음입니다. 다음 컴포넌트도 JSX가 들어 있으므로 .jsx로 저장합니다.

src/components/MultipleInputsForm.jsx
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는 이벤트를 받은 입력의 name으로 키를 고르고, checkbox라면 checked를, 나머지는 value를 담아 이전 객체를 펼친 새 객체로 바꿉니다. 제출하면 alert에 일곱 필드가 JSON으로 보이며, 손대지 않은 필드는 초기값인 빈 문자열과 false, 'USA'로 남습니다. 이 패턴은 대부분의 경우에 잘 동작하지만, 폼 필드가 아주 많아지거나 필드마다 복잡한 유효성 검사가 필요해지면 handleChange가 커지고 컴포넌트 자체도 복잡해질 수 있습니다. 예를 들어 이 폼에는 아직 비밀번호와 비밀번호 확인이 같은지 검사하는 규칙이 없으며, 그런 규칙을 더하기 시작하면 변경 함수에 여러 일이 섞이게 됩니다.


useReducer를 이용한 폼 상태 관리

useState 대신 useReducer 훅을 쓰면 여러 상태 갱신 로직을 한곳에 모아 관리할 수 있습니다. 이는 상태 전이(state transitions)가 복잡하거나 다음 상태가 이전 상태에 기대는 경우에 특히 유용하며, 폼 상태 관리가 바로 그런 경우일 수 있습니다.

모든 상태 갱신 로직이 리듀서 함수 안에 있으므로 관련 로직을 한눈에 파악하기 쉽고, 여러 필드의 유효성 검사나 서로 엮인 필드의 갱신 같은 복잡한 상태 변화도 깔끔하게 처리할 수 있습니다. 또한 dispatch 함수는 렌더가 바뀌어도 같은 함수로 유지되므로, 자식 컴포넌트에 넘길 때 useCallback으로 감쌀 필요가 없습니다(React 문서의 useReducer). 다음 예제도 .jsx로 저장합니다.

src/components/ReducerForm.jsx
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;

handleChange는 직접 state를 바꾸지 않고 어느 필드에 어떤 값이 들어왔는지를 CHANGE_VALUE 액션으로 보내며, 리듀서가 이전 상태를 펼친 새 객체를 돌려줍니다. 제출하면 validateForm이 오류 객체를 만들어 SET_ERRORS로 저장하고, 오류가 없을 때만 값을 보여 준 뒤 RESET_FORM으로 처음 상태로 되돌립니다. 오류 메시지는 aria-invalid와 aria-describedby로 해당 입력과 이어지므로, 화면 낭독기도 어느 입력이 왜 잘못되었는지 읽을 수 있습니다.

이렇게 useReducer를 쓰면 폼의 상태 변화 로직과 유효성 검사의 결과를 리듀서 안에 모아 관리할 수 있고, 폼이 커질수록 관련 코드가 한곳에 모입니다. 다만 이 예제의 CHANGE_VALUE는 오류를 건드리지 않으므로, 한 번 생긴 오류 메시지는 값을 고쳐도 다음 제출까지 남습니다. 입력하는 도중에 오류를 지우려면 CHANGE_VALUE에서 그 필드의 오류도 함께 비우는 전이를 정해야 합니다.


정리

  • 여러 입력은 하나의 객체 상태와 name 기반의 공통 변경 함수로 시작하고, 필드가 독립적인 동안에는 이것으로 충분합니다.
  • 종속 전이와 오류, 초기화 규칙이 섞이기 시작하면 useReducer로 바뀌는 이유마다 액션을 나누어 한 리듀서에 모읍니다.
  • dispatch는 렌더가 바뀌어도 같은 함수이므로 자식에게 넘길 때 따로 메모하지 않아도 됩니다.
  • 오류를 언제 만들고 언제 지울지도 전이의 하나로 정해야 합니다.
  • 객체 상태, 리듀서, 커스텀 훅, 폼 라이브러리는 차례로만 밟는 단계가 아니라 필요에 따라 조합해 고르는 선택지입니다.

연습 문제

1. 검사에 실패한 제출

ReducerForm의 사용자 이름에 공백 두 칸을, 비밀번호에 12345를 넣고 제출합니다. 화면에는 무엇이 보이고 입력값은 어떻게 될까요? 이어서 사용자 이름에 mina를 입력하면 사용자 이름의 오류 메시지는 사라질까요?

해설 보기

trim()한 사용자 이름이 빈 문자열이고 비밀번호가 다섯 자이므로 두 오류가 모두 생깁니다. SET_ERRORS가 두 메시지를 저장해 두 입력 아래에 빨간 오류가 보이고, “폼 입력값을 확인해주세요.” alert가 뜨며, RESET_FORM은 실행되지 않으므로 입력값은 그대로 남습니다.

mina를 입력해도 CHANGE_VALUE는 errors를 바꾸지 않으므로 사용자 이름의 오류 메시지는 다음 제출까지 남습니다. 비밀번호를 여섯 자 이상으로 고친 뒤 다시 제출하면 검사가 통과해 오류가 빈 객체가 되고, 값을 보여 준 뒤 폼이 처음 상태로 돌아갑니다.

2. 비밀번호 확인 규칙 더하기

MultipleInputsForm에 비밀번호와 비밀번호 확인이 다르면 제출을 막는 규칙을 더하려고 합니다. 이 규칙을 handleChange에 넣는 것과 제출 처리 함수에 넣는 것은 어떻게 다를까요?

해설 보기

handleChange에 넣으면 두 필드 가운데 하나가 바뀔 때마다 다른 필드의 현재 값과 비교해야 하므로, 공통 변경 함수가 특정 필드의 이름을 알게 되고 검사 결과를 담을 상태도 새로 필요해집니다. 입력하는 도중에 피드백을 줄 수 있지만 변경 함수에 여러 일이 섞입니다.

제출 처리 함수에 넣으면 제출하는 순간의 formData로 한 번만 비교하므로 단순하지만, 사용자는 제출해야 오류를 알게 됩니다. 이런 규칙이 늘어나 변경 함수가 복잡해진다면 그 신호에 따라 useReducer로 전이를 나누는 것을 검토합니다.

다음 글 폼 상태 커스텀 훅과 라이브러리에서는 폼의 값과 오류, 제출 처리를 재사용할 수 있는 커스텀 훅으로 묶고, 폼 라이브러리가 필요해지는 기준을 알아봅니다.