복잡한 폼 상태 관리하기
이번에는 더 복잡한 폼 상태를 효과적으로 관리하는 방법을 다룹니다.
폼 필드가 많아지거나 필드 간 상호 의존성이 생기면, 단순한 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등으로 감쌀 필요가 없어 성능 최적화에 도움이 될 수 있습니다.
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 커스텀 훅 예시
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 커스텀 훅 사용 예시
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나 직접 만든 useForm 커스텀 훅만으로도 한계에 부딪힐 수 있습니다. 특히 다음과 같은 상황에서는 “상태를 직접 들고 있을 수 있는가”보다 “필드 등록, 검증, 오류 노출, 제출 복구를 일관된 계약으로 유지할 수 있는가”를 봐야 합니다.
- 필드 수가 늘어 값, 오류, 방문 여부, dirty 상태를 따로 추적해야 한다.
- 조건부 렌더링 때문에 필드를 숨길 때 값을 유지할지 해제할지 결정해야 한다.
- 동적으로 추가/제거되는 필드 배열에서
key, 기본값, item-level error, array-level error를 분리해야 한다. - 클라이언트 스키마 검증과 서버 중복 확인 같은 비동기 검증이 함께 필요하다.
- 입력 하나가 큰 폼 전체를 다시 렌더링해 타이핑 지연이 보인다.
이러한 복잡성을 해결하기 위해 React Hook Form이나 Formik과 같은 전문적인 폼 관리 라이브러리들이 존재합니다. 이 라이브러리들은 폼 상태 관리, 유효성 검사, 제출 처리, 그리고 성능 최적화를 위한 다양한 기능을 제공합니다.
아래 다이어그램은 직접 구현한 훅에서 전문 폼 라이브러리로 넘어가야 하는 신호와 선택 기준을 정리한 것입니다.
-
React Hook Form
- 필드 등록 중심:
register와defaultValues로 네이티브 입력을 연결하고, 필요한 필드 상태만 구독해 리렌더링 범위를 줄입니다. - 제어형 컴포넌트 연결: UI 라이브러리의 날짜 선택기, 자동완성처럼 제어형 흐름이 필요한 입력은
Controller로 감쌉니다. 이 경우에는 해당 컴포넌트의 렌더 비용도 함께 봐야 합니다. - 동적 배열과 스키마 검증:
useFieldArray로 append/remove/reorder를 관리하고,resolver로 Yup, Zod 같은 스키마 유효성 검사 라이브러리와 통합합니다. - 폼 상태 분리:
formState의errors,dirtyFields,isSubmitting같은 값을 필요한 위치에서만 읽도록 설계합니다.
- 필드 등록 중심:
-
Formik
- 명시적인 상태 모델:
initialValues,values,errors,touched가 React 상태 흐름 안에 드러나므로 폼 상태를 읽고 디버깅하기 쉽습니다. - 검증 계약:
validate또는validationSchema로 검증 위치를 고정하고, 제출 오류와 필드 오류를 나눠 관리할 수 있습니다. - 컴포넌트 API:
Field,FieldArray,FastField를 활용할 수 있지만, 큰 폼에서는 갱신 범위를 의식해 컴포넌트를 나누어야 합니다.
- 명시적인 상태 모델:
이러한 라이브러리는 다음 절인 폼 유효성 검사에서 더 자세히 다룹니다. 이 절에서는 직접 훅, React Hook Form, Formik 중 무엇을 고를지 판단하기 위해 필드 등록 방식, 검증 위치, 동적 배열, 리렌더링 범위를 먼저 비교합니다.
9장 2절 복잡한 폼 상태 관리하기는 여기까지입니다. 이 장에서는 여러 입력 필드를 하나의 상태 객체로 관리하는 기본 패턴부터 시작하여, useReducer를 사용하여 상태 로직을 중앙 집중화하는 방법, 그리고 폼 관련 로직을 재사용 가능한 커스텀 훅으로 추상화하는 고급 패턴까지 살펴보았습니다. 마지막으로, 매우 복잡한 폼을 다룰 때 유용한 전문 폼 관리 라이브러리의 존재와 필요성에 대해서도 간략히 언급했습니다.
복잡한 폼은 값, 오류, 터치 여부를 분리해서 관리하면 흐름이 안정됩니다. 아래 다이어그램은 reducer 기반 폼 상태의 기준선을 보여줍니다.
폼이 커질수록 각 상태를 어디에서 소유할지 정해야 합니다. 아래 다이어그램은 컴포넌트, 훅, 라이브러리, 서버 사이의 경계를 나눕니다.
복잡한 폼은 여러 input을 모으는 문제가 아니라 값, 오류, 방문 여부, 제출 상태를 일관되게 갱신하는 문제입니다.
다수의 입력 필드 관리 패턴 다시 보기와 useReducer를 이용한 폼 상태 관리 중심으로 정리한 보조 다이어그램입니다.