0080. Form 상태 관리 (`useState`, `useReducer`)
앞서 예제 코드에서 우리는 두 개의 필드(`name`, `email`)에 대해 각각 `useState`를 사용하여 상태를 관리했습니다. 필드 수가 적을 때는 이러한 방식이 간단하고 직관적이지만, 폼 필드가 많아지면 상태 관리 코드가 복잡해질 수 있습니다. 이번 편에서는 여러 폼 필드의 상태를 관리하는 다양한 방법과 각 접근 방식의 장단점을 살펴보겠습니다.
앞서 예제 코드에서 우리는 두 개의 필드(name, email)에 대해 각각 useState를 사용하여 상태를 관리했습니다. 필드 수가 적을 때는 이러한 방식이 간단하고 직관적이지만, 폼 필드가 많아지면 상태 관리 코드가 복잡해질 수 있습니다. 이번 편에서는 여러 폼 필드의 상태를 관리하는 다양한 방법과 각 접근 방식의 장단점을 살펴보겠습니다.
- 여러 개의
useState사용 - 하나의 상태 객체로 관리
useReducer훅을 사용한 관리
useState
위에서 작성한 SignUpFormStep1 컴포넌트는 여러 개의 useState를 사용한 예입니다. 이를 확장해서 비밀번호 필드를 추가한다고 가정해보겠습니다. 우선 useState를 필드마다 사용하는 방법으로 구현해봅니다.
// SignUpFormStep2.jsx - 여러 useState 사용
import { useState } from 'react';
function SignUpFormStep2() {
// 필드별 개별 상태
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const handleChange = (e) => {
const { name: fieldName, value } = e.target;
// fieldName에 따라 각각 다른 상태 업데이트
if (fieldName === 'name') setName(value);
if (fieldName === 'email') setEmail(value);
if (fieldName === 'password') setPassword(value);
};
const handleSubmit = (e) => {
e.preventDefault();
const formData = { name, email, password };
console.log("제출 데이터:", formData);
// 서버에 formData 전송 등 처리 로직
};
return (
);
}
export default SignUpFormStep2;
이번에는 handleChange 함수를 하나로 합쳤습니다. 각 <input>에 name 속성을 부여했기 때문에, 이벤트 객체 e.target.name을 이용하여 어떤 필드에서 변화가 일어났는지 판단하는 거죠. handleChange 내부에서 if문으로 fieldName에 따라 해당 상태를 업데이트하도록 했습니다. 이 방법은 상태 업데이트 로직을 하나로 모아서 코드량을 줄이는 효과가 있습니다. 하지만 여전히 useState를 세 번 호출하고, 상태 변수가 세 개입니다. 필드가 추가될 때마다 이러한 패턴을 반복해야 하므로, 유지보수 측면에서는 조금 번거로울 수 있습니다.
이 방식은 필드 수가 많아지면 관리해야 하는 state와 setter 함수들이 많아집니다. 코드가 장황해지고 실수로 잘못된 상태를 업데이트할 가능성도 있습니다. 또한 필드 이름이 동적으로 정해지는 경우 코드가 복잡해집니다.
물론, 장점도 있습니다. 각 필드가 개별 상태로 관리되므로 구현이 직관적이며, 한 필드의 변경이 다른 필드에 직접 영향 주지 않습니다. React는 상태가 변경될 때 해당 컴포넌트를 다시 렌더링하는데, 여러 상태를 갖고 있어도 컴포넌트는 하나이기 때문에 결국 한 번 리렌더링됩니다. 단, React는 동일 컴포넌트 함수 내 여러 setState 호출을 배치(batch)로 처리하므로 성능에 큰 문제는 없습니다.
다음으로, 하나의 상태 객체로 모든 필드 값을 관리하는 방법을 살펴보지요. 이는 상태를 객체 하나(formData 등)로 만들어 그 안에 여러 속성으로 필드 값을 담는 방식입니다.
// SignUpFormStep3.jsx - 하나의 객체 상태로 관리
import { useState } from 'react';
function SignUpFormStep3() {
const [formData, setFormData] = useState({
name: "",
email: "",
password: ""
});
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prevData => ({
...prevData,
[name]: value // 해당 name 속성에 대응하는 필드 업데이트
}));
};
const handleSubmit = (e) => {
e.preventDefault();
console.log("제출 데이터:", formData);
// formData 사용하여 처리 로직 수행
};
return (
);
}
export default SignUpFormStep3;
setFormData 호출 시 이전 상태 prevData를 복사(...prevData)하고, [name]: value 부분만 새로 설정하는 방식입니다. 이렇게 해야 상태가 불변성(immutability)을 유지하면서 업데이트됩니다. formData를 직접 수정하지 않고, 항상 새로운 객체를 만들어 상태를 교체하는 방식입니다.
React 상태 관리에서는 불변성을 지키는 것이 중요합니다. 불변성을 지켜야만 React가 변경 여부를 감지할 수 있고, 또 상태를 이전 값과 비교해 필요한 최소한의 UI 업데이트를 할 수 있습니다. 초보자의 실수 중 하나는 prevData[name] = value; setFormData(prevData);처럼 상태를 직접 변경하는 것입니다. 이렇게 하면 상태 변경이 제대로 감지되지 않거나, 예기치 않은 버그가 생길 수 있습니다. 항상 setState에는 새로운 객체/값을 넣는 것을 기억하세요.
객체로 상태를 관리하면 필드 추가/삭제 시 비교적 수월하게 변경할 수 있습니다. 예를 들어 폼에 "주소" 필드를 추가하려면 formData 객체에 address: ""를 추가하고, input 요소 하나 추가하면 끝입니다. 개별 useState 방식보다 코드 수정 범위가 적습니다.
그리고, 상태 구조가 폼 구조와 1:1로 매핑되므로 관리가 편합니다. 필드가 많아져도 상태 변수는 하나이므로, React Developer Tools 등으로 상태를 볼 때 한 눈에 폼 데이터를 파악할 수 있습니다. 또한 하나의 onChange 핸들러로 모든 필드 업데이트를 처리할 수 있어 코드가 비교적 단순해집니다.
하지만, 하나의 객체에 여러 값이 있으므로 상태 변경이 발생하면 폼 전체가 한 상태로 취급됩니다. 필드 하나만 바뀌어도 formData 객체 전체가 새로 생성되기 때문에, React 입장에서는 "formData가 변경되었네? -> 컴포넌트 리렌더"가 일어납니다. 하지만 앞의 경우와 마찬가지로 결국 컴포넌트 단위 렌더링이므로 큰 차이는 없습니다. 다만 매우 큰 폼 데이터 객체를 생성/비교하는 것이 성능에 영향을 줄 수는 있습니다. 또 다른 단점은, 상태를 한 객체로 묶으면 부분 업데이트 로직이 살짝 복잡해질 수 있다는 점입니다. 위 코드에서는 스프레드 연산자로 해결했지만, 깊은 중첩 구조가 있다면 더욱 신경써야 합니다.
useReducer
상태 관리의 세 번째 방법은 useReducer 훅을 사용하는 것입니다. useReducer는 Redux와 비슷한 개념으로, 리듀서 함수와 액션에 따라 상태를 업데이트합니다. 다소 코드가 늘어날 수 있지만, 상태 업데이트 로직을 컴포넌트 바깥으로 분리할 수 있고, 복잡한 업데이트 시나리오를 다루기 편합니다. 특히 여러 필드의 업데이트 뿐만 아니라 폼 리셋, 에러 설정 등 다양한 액션을 관리하기에 적합합니다.
예로, 폼의 상태와 에러 메시지를 함께 관리하는 리듀서를 만들어 보겠습니다. 아직 유효성 검사를 본격 도입한 것은 아니지만, 구조를 대비해서 errors도 상태에 포함하겠습니다.
// SignUpFormStep4.jsx - useReducer 사용 예
import { useReducer } from 'react';
const initialState = {
formData: {
name: "",
email: "",
password: ""
},
errors: {}
};
function formReducer(state, action) {
switch(action.type) {
case 'CHANGE_FIELD':
const { field, value } = action;
return {
...state,
formData: {
...state.formData,
[field]: value
}
// errors도 업데이트하려면 여기서 처리 가능 (예: 필드 변경 시 해당 에러 제거)
};
case 'SET_ERROR':
const { fieldName, errorMessage } = action;
return {
...state,
errors: {
...state.errors,
[fieldName]: errorMessage
}
};
case 'RESET_FORM':
return initialState;
default:
return state;
}
}
function SignUpFormStep4() {
const [state, dispatch] = useReducer(formReducer, initialState);
const { formData, errors } = state;
const handleChange = (e) => {
const { name, value } = e.target;
dispatch({ type: 'CHANGE_FIELD', field: name, value });
};
const handleSubmit = (e) => {
e.preventDefault();
// 간단한 유효성 검사 예: 이름이 비어있으면 에러
if (!formData.name) {
dispatch({ type: 'SET_ERROR', fieldName: 'name', errorMessage: '이름을 입력해주세요' });
return;
}
console.log("제출 데이터:", formData);
// 제출 후 폼 초기화
dispatch({ type: 'RESET_FORM' });
};
return (
);
}
export default SignUpFormStep4;

useReducer 사용 패턴을 보면 다음 과정을 거칩니다.
initialState로 모든 필요한 상태 값을 객체로 정의. 여기서는formData와errors두 가지를 한 곳에 넣음.formReducer함수는action.type에 따라 새로운 상태를 반환. 예제에서는 필드 변경, 에러 설정, 폼 리셋 세 가지 액션을 정의.- 컴포넌트 내에서
useReducer(formReducer, initialState)를 호출하여state와dispatch를 얻음. handleChange에서는dispatch({ type: 'CHANGE_FIELD', field: name, value })를 보내어 리듀서가 해당 필드 값을 업데이트.handleSubmit에서는 간단히 이름 필드가 비었으면 에러를 설정하고, 그렇지 않으면 제출 처리를 한 후 폼을 초기화하는 액션을 보냄.- JSX에서
errors.name && <span>...</span>부분은 해당 필드에 에러 메시지가 있을 경우 화면에 출력.
useState와 useReducer 비교
실무에서는 폼의 복잡도에 따라 적절한 방식을 선택합니다. 대체로 입력 필드가 2~3개 정도인 간단한 폼은 그냥 useState 여러 개로도 충분합니다. 그러나 회원가입 같이 필드가 많고 입력 간 상호 관계도 있는 폼이라면 useReducer와 같은 상태와 로직을 체계적으로 관리하는 도구를 고려해 유지보수하기 좋게 하는 판단을 하기도 합니다. 특히 멀티스텝 폼(예: 단계별로 입력)이나 조건부 렌더링되는 필드(어떤 질문에 "예"라고 답하면 추가 입력 필드 노출 등)에서는 useReducer 구조가 유리합니다. 일종의 상태 머신(FSM)처럼 사용하는 거지요.
또한, 에러 상태나 로딩 상태(폼 제출 후 응답 대기 등)를 함께 관리하려면 useReducer로 통합 관리하는 편이 편리합니다. 앞서 예시에서도 에러 메시지를 errors 객체로 함께 관리한 것을 볼 수 있습니다. errors처럼 여러 필드의 에러를 하나의 객체로 관리하면, 필드 개수와 상관없이 일관된 방식으로 에러를 제어할 수 있습니다.
덧붙이자면, React에서 폼 상태 관리를 할 때 불필요한 리렌더링에 주의해야 합니다. 폼이 있는 컴포넌트가 너무 많은 상태를 갖고 있거나, 상위 컴포넌트의 상태 변화로 인해 자주 리렌더링되면 입력 중인 폼이 버벅거리거나 커서가 튀는 등의 문제가 생길 수 있습니다. 따라서 가능하면 폼은 독립된 컴포넌트로 분리하고, 폼 내부에서는 필요한 최소한의 state만 관리하는 것이 좋습니다.
지금까지는 폼 상태를 React 컴포넌트 상태로 관리하는 방법들을 살펴봤습니다. 다음 편에서는 폼 제어를 관리하는 두 가지 철학적 접근, 즉 제어 컴포넌트(Controlled)와 비제어 컴포넌트(Uncontrolled)의 개념과 차이를 알아보겠습니다.