- #Project
- #Burgerput
버거풋에 react-hook-form 사용하기
Prolip
2024-06-06
react-hook-form으로 input 관리하기.. (끔찍한 코드 개선하기)
시작..
우선 제 Burgerput 프로젝트 회고 포스팅에 어떤 배경에서 이 프로젝트가 등장했는지, 어떤 문제점을 해결하고자 했는지가 나옵니다..
위의 배경으로 만들어진 사무 자동화 시스템이 바로 버거풋입니다.
우선 오기입 방지 기능을 수행하기 위해선 값을 검증할 입력 폼이 필요하겠죠..?
그 과정에서 제가 퍼질러놓았던 코드들을 어떻게 개선했는지 작성해보려고 합니다..
코드의 탈을 쓴 쓰레기…
우선 이 온도 입력이라는 기능을 수행하기 위해 거치는 프로세스에 대해 말씀드리겠습니다.
- 사용자는 매장에서 사용하는 기기 및 식품의 온도만을 입력하기 위해 제품 선택 페이지에서 제품을 선택한다.
- 온도 입력 페이지에서 사용자가 선택한 기기 및 식품의 데이터를 서버로부터 받아온다.
- 해당 데이터를 이용해 입력 폼을 구성한다.
- 사용자가 입력한 온도가 각 제품의 최소, 최대 온도 사이에 위치하는지 검증한다.
- 사용자가 제출 버튼을 클릭하면 사용자가 입력한 온도 값과 해당 제품의 정보를 서버로 전달한다.
- 서버에선 웹 크롤러(셀레니움)를 사용해 클라이언트에서 받은 데이터로 기존 페이지에 값을 입력한 뒤 제출 후 마무리한다.
이 포스팅에선 2번부터의 기능을 제가 어떻게 구현했었고, 이후에 어떻게 개선했는지를 기록합니다.
우선 기본적인 코드만 구성해서 기존의 코드 꼬라지를 기록해보자면..
export function InputMachineTemp() { const [products, setProducts] = useState([]); const { data } = useQuery(["customMachines"], getCustomMachines()); const handleSubmit = () => { // 작성된 값을 이용한 제출 로직. } useEffect(() => { setProducts(data?.customMachines); }, [data]); return ( <form onSubmit={handleSubmit}> <ul> {products && products.map((product) => ( <InputProduct product={products} /> )) </ul> <button>제출</button> </form> }; export function InputProduct({product}) { const { id, name, min, max } = product; const [temp, setTemp] = useState(null); const handleChange = (e) => { // min, max 값을 이용한 검증 로직. ... // setTemp(e.target.value); }; useEffect(() => { product.temp = temp; }, [temp]); return ( <li key={id}> <p>{name} : {min} ~ {max}</p> <input type="number" value={temp} onChange={hanelChange} /> </li> ) }
우선 첫 번째 컴포넌트는 기기 온도 입력 페이지로 사용자가 선택한 기기 데이터를 서버로부터 받아오고, 사용자에게 표시, 온도 입력 후 제출하는 페이지입니다.
제가 구현한 이 페이지의 기능은 다음과 같았습니다.
- 사용자가 선택한 기기 목록을 서버로부터 받아온다.
- 데이터를 받아온 뒤 useEffect을 사용해 products state에 값을 할당해준다.
- products를 순회하며 InputProduct 컴포넌트로 각 제품을 전달한다.
- 이후 사용자가 모든 온도를 입력한 뒤 제출 버튼을 클릭하면 제출 이벤트를 처리한다.
아래의 컴포넌트는 사용자가 온도를 입력하는 Input 요소로 이 컴포넌트의 기능은 다음과 같았습니다.
- 페이지 컴포넌트에서 전달 받은 product 객체를 사용해 사용자에게 각 제품 요소를 표시한다.
- 사용자는 화면에 표시된 최소, 최대 온도를 참고해 온도 값을 Input 태그를 사용해 입력한다.
- handleChange 함수가 실행될 때 입력 값을 검증해 값이 정상 범위라면 컴포넌트의 temp 상태를 업데이트한다.
- 컴포넌트의 temp 상태값이 업데이트 될 때 기존 product 객체에 temp 필드를 생성해 값을 입력한다.
이 코드를 만약 누군가에게 보여줬을 때 위와 같은 기능을 수행하는 컴포넌트인 것을 쉽게 알아차릴 수 있었을까요..?
저는 분명 이 코드를 이해하는게 쉽지 않을 것 같다고 생각했습니다.
서버에서 오는 데이터의 구조와 제출 양식을 살펴보며 제가 코드를 왜 이렇게 작성하게 되었는지 변명을 조금 해보겠습니다..
- 서버에서 오는 제품 데이터
{ "customMachine": [ { "id": "5", "name": "무언가1", "min": "46", "max": "66" }, { "id": "23", "name": "무언가2", "min": "12", "max": "23" }, ], }
위 값은 서버에서 넘어오는 데이터의 예시로 id, name, min, max 각 제품의 고유 아이디와 이름, 입력되어야 할 온도 값의 최소, 최대 범위가 담겨있습니다.
- 제출에 필요한 데이터
[ "mgrName": "매니저이름", "customMachine": [ { id: "22", name: "무언가", max: "80", min: "10", temp: "75", }, … ], "time": "AM", ]
당시 제출에 필요한 값은 다음과 같았는데 customMachine 배열을 살펴보면 전달 받은 데이터 그대로에 temp 값이 추가적으로 필요한 상황이었습니다.
예.. 그냥 제가 모자랐습니다.
그래서 어떻게 개선하셨는지?
저는 react-hook-form을 사용했습니다.
우선 react-hook-form의 동작 방식을 설명할 필요가 있는데, 리액트는 Virtual DOM을 사용합니다.
가상의 돔을 이용해 실제 DOM에 반영하기 전에 변경된 부분만을 확인해 그 부분만 업데이트하는 방식이죠??
Virtual DOM의 동작 방식
리액트의 가상 DOM은 상태가 변경될 때마다 새로운 가상 DOM 트리를 생성하고 그 트리와 이전의 가상 DOM 트리를 비교하는 diffing 알고리즘을 사용하게 됩니다.
이 과정을 통해서 변경된 부분만 실제 DOM에 반영하게 되는데 이렇게 함으로써 전체 DOM을 직접적으로 수정하는 것보다 더 효율적으로 변경된 사항을 처리하게 됩니다.
하지만 이 가상 DOM을 사용하는 것에도 비용이 발생합니다.
- 상태가 변경되면 새로운 가상 DOM을 생성하고 이전 가상 DOM과 비교하는 과정이 추가적인 연산을 요구합니다.
- 이 비교 과정 이후에 실제 DOM을 업데이트하는 단계를 필요로 합니다.
그렇기 때문에 가상 DOM을 통한 변경은 실제 DOM을 직접 조작하는 것보다는 간접적이며 중간 단계가 추가된다는 점에서 오버헤드가 발생할 수 있습니다.
그럼 react-hook-form에서 실제 DOM을 사용하는 이유는?
react-hook-form은 비제어 컴포넌트 방식을 사용해 상태를 관리하는 대신 DOM에서 직접 데이터를 가져옵니다.
여기서 비제어 컴포넌트란 리액트의 상태(state)를 사용하지 않고 DOM에서 직접 값을 관리하는 방식입니다.
일반적으로 비제어 컴포넌트는 ref를 통해 DOM에 접근해 값을 읽어오거나 설정합니다.
이 방식은 가상 DOM의 diffing 과정이나 리액트의 상태 관리와 같은 중간 단계를 거치지 않고 실제 DOM 요소에 직접 접근해 데이터를 처리하기 때문에 여러 이점이 존재합니다.
- 리렌더링이 없기 때문에 성능 향샹에 이점이 있다.
가상 DOM을 사용한 제어 컴포넌트 방식에서는 input 값이 변경될 때마다 state 값이 업데이트되고 이에 가상 DOM이 변경되어 diffig 과정이 일어나고 실제 DOM이 업데이트 됩니다.
하지만 react-hook-form은 비제어 컴포넌트 방식을 사용하므로 input 필드의 값이 변경될 때마다 리렌더링이 발생하지 않습니다.
즉 가상 DOM의 diffing 및 업데이트 과정 자체가 없으니 더 빠른 것입니다.
- 가상 DOM의 diffing 비용을 회피
물론 가상 DOM의 diffing 과정은 성능에 이점이 있지만 작은 단위의 입력 필드 같은 경우 오히려 불필요한 오버헤드가 될 수 있습니다.
예를 들어 우리 프로젝트의 경우 최소 15개에서 20개까지의 input 필드가 존재하며 사용자가 데이터를 입력할 때마다 입력 값 검증 등의 이유로 계쏙해서 가상 DOM이 변경되고 diffing을 거친 후 실제 DOM에 반영되는 비용이 발생하게 됩니다.
하지만 react-hook-form은 이런 diffing 과정을 건너뛰고 실제 DOM에서 직접적으로 값을 가져오고 처리하니 이러한 추가적인 연산 비용을 줄일 수 있게 됩니다.
- 실시간 입력 데이터 처리
react-hook-form은 비제어 컴포넌트를 사용하기 때문에 사용자가 입력한 데이터가 바로 DOM에 저장됩니다.
이후 폼 제출 시점에 DOM에서 값을 직접 추출하는데 리액트가 모든 상태 업데이트를 가상 DOM을 통해 처리하는 방식과 달리 실제 DOM에 남아있는 데이터를 한 번에 처리하는게 가능합니다.
왜 실제 DOM 조작이 더 빠를까요?
react-hook-form은 가상 DOM과 비교하는 연산이나 리렌더링 과정을 거치지 않고 DOM에서 직접 값을 추출합니다.
이는 state를 사용하는 제어 컴포넌트 방식에 비해 훨씬 간단한 데이터 흐름을 제공합니다.
리렌더링이 발생하지 않기 때문에 많은 input 요소가 있는 폼에서 성능 저하를 방지할 수 있게 됩니다. 우리 프로젝트와 같이 폼 필드가 많을수록 이 방식이 유리해집니다.
input 필드에서의 데이터 추출 및 상태 관리를 DOM에서 직접 처리하므로 불필요한 메모리 사용을 줄이고 이로 인해 성능을 개선할 수 있게 됩니다.
그럼 무조건 비제어 컴포넌트가 좋을까요?
위와 같은 장점이 있다고 비제어 컴포넌트가 항상 최선의 선택이 되는 것은 아닙니다.
특히 동적인 사용자 인터랙션을 처리하거나 UI를 실시간으로 업데이트해야 하는 경우에는 제어 컴포넌트가 더 적합합니다.
state를 사용해 값과 UI를 동기화할 수 있기 때문입니다.
비제어 컴포넌트의 한계
비제어 컴포넌트는 DOM에 직접 접근하기 때문에 폼 제출 시점에 데이터를 한 번에 수집하거나 scrollHeight 등 직접적인 DOM 요소에 대한 값에 접근하는데 유리할 수 있으나 다음과 같은 상황에서는 한계가 있을 수 있습니다.
- 실시간 UI 업데이트
비제어 컴포넌트는 값이 변경될 때마다 상태가 업데이트되지 않기 때문에 사용자가 입력할 때마다 바로바로 UI를 업데이트하기 어렵습니다.
- 복잡한 상호작용
사용자의 입력에 따라 다른 UI 요소를 동적으로 변경할 경우에도 비제어 컴포넌트는 적절하지 않습니다.
예를 들자면 사용자가 입력한 값에 따라 다른 필드를 활성화, 비활성화하는 로직을 구현하고 싶다면 제어 컴포넌트가 더 적합하겠죠?
- 상태 관리
비제어 컴포넌트는 위에서 설명했듯 DOM에 직접 접근하기 때문에 값을 추적하기 어려울 수 있습니다.
반면 제어 컴포넌트는 리액트의 상태를 통해 값이 일관되게 유지되므로 다른 컴포넌트에서 그 값을 필요로 하거나 UI를 일관되게 유지해야 할 때는 제어 컴포넌트가 더 적합합니다.
react-hook-form
네 그리하여 저는 결국 react-hook-form 라이브러리를 사용하게 되었습니다. 사실 처음에는 해당 라이브러리를 사용할 필요가 과연 있을까? 싶었습니다.
하지만 제가 이 라이브러리를 사용하게된 계기는 다음과 같습니다.
- 리렌더링
우선 제가 이 라이브러리를 선택한 이유 중 가장 큰 이유는 1번 불필요한 리렌더링을 최소화한다 였습니다.
이 프로젝트에선 input 요소가 단순히 로그인 기능에 쓰이는 2개 혹은 3개 수준이 아닌 최소 15개에서 20개 정도로 충분히 사용할만하다고 생각이 들어 사용하게 되었습니다.
- register를 이용한 간단한 입력 값 검증
다음으로 마음에 들었던 기능은 간편한 필드 값 검증입니다.
기존의 입력 값 검증 방법은 다음과 같았습니다.
const validateRange = (temp) => { const normalRange = temp < max && temp > min; if(!normalRange) { setWarning("온도를 다시 확인해주세요."); setTemp(null); return; } } export const useDebounce = (callback, delay) => { let timer; return (...args) => { clearTimeout(timer); timer = setTimeout(() => callback(...args), delay); }; };
이런 함수들을 사용해 사용자가 입력하는 값을 검증하고 있었습니다.
하지만 react-hook-form을 사용한다면 더 간단하게 입력 값 검증 기능을 사용할 수 있습니다.
<input {...register("products", { required: "온도는 필수 입력 사항입니다.", valueAsNumber: true, min: { value: min, message: `온도를 ${min} 이상으로 기입해주세요.` }, max: { value: max, message: `온도를 ${max} 이상으로 기입해주세요.` )}, />
이렇게 react-hook-form을 사용한다면 더 간단하며 직관적인 유효성 검사가 가능합니다.
- defaultValues
defaultValues를 사용하면 폼이 렌더링될 때 입력 필드가 비어있지 않고 defalutValues에 설정된 값으로 기본값이 채워지게 됩니다.
이 값은 useForm 훅에서 초기화되며 처음 렌더링할 때 이 값들이 입력 필드에 자동으로 할당됩니다.
저는 이 defaultValues를 사용해 기존에 받아온 data를 이용해 폼 데이터의 구조를 명시했는데 이후 handleSubmit을 통해 제출할 때 이 제출 데이터의 구조가 자동으로 결정됩니다.
const { register, handleSubmit, setValue, formState: { errors }, } = useForm({ mode: "onSubmit", defaultValues: { products: products.map(({ id, name, min, max }) => ({ id, name, min, max, temp: "", })) });
그리하여..
export default function InputTempForm({ onSubmit, products, pageLocation }) { const { register, handleSubmit, setValue, formState: { errors }, } = useForm({ mode: "onSubmit", defaultValues: { products: products.map(({ id, name, min, max }) => ({ id, name, min, max, temp: "", })), }, }); if (products.length === 0) { return ( <section> <p>먼저 사용할 제품을 선택해주세요.</p> <Link to={`/select/${pageLocation}`}> 선택하러 가기 </Link> </section> ); } return ( <form id="inputForm" onSubmit={handleSubmit(onSubmit)} > <ul> {products.map((product, idx) => ( <li key={idx}> <InputTemp product={product} register={register} setValue={setValue} idx={idx} /> {errors.products && errors.products[idx] && ( <small> {errors.products[idx].temp.message} </small> )} </li> ))} </ul> </form> ); } export default function InputTemp({ product, register, setValue, idx }) { const [disabled, setDisabled] = useState(false); const { id, name, min, max } = product; const handleDisabled = () => { setDisabled((prev) => { const newDisabledState = !prev; if (newDisabledState) { setValue(`products[${idx}].temp`, 999); } else { setValue(`products[${idx}].temp`, ""); } return newDisabledState; }); }; return ( <label htmlFor={id}> <article> <p>{name}</p> <p>({min} ~ {max}ºF)</p> </article> <article> <button type="button" onClick={handleDisabled}> 결품 </button> <input id={id} type="number" disabled={disabled} {...register(`products[${idx}].temp`, { required: !disabled && "온도는 필수 입력 사항입니다.", valueAsNumber: true, min: { value: disabled ? null : min, message: `온도를 ${min} 이상으로 기입해주세요.`, }, max: { value: disabled ? null : max, message: `온도를 ${max} 이하로 기입해주세요.`, }, })} /> <p>ºF</p> </article> </label> ); }
- 복잡한 검증 과정 없이 register를 이용해 각 제품의 최소, 최대 온도를 이용해 값을 검증합니다.
- react-hook-form의 자체적인 handleSubmit 함수를 이용하면 제출 이벤트에 폼 데이터가 전달되는데 제가 이전에 설정한 defaultValues 구조에 맞게 데이터를 받을 수 있게 됩니다. 아래와 같이 말이죠!
{ "products": [ { "id": "2", "name": "무언가 1", "min": "31", "max": "33", "temp": 31 }, { "id": "54", "name": "무언가 2", "min": "31", "max": "33", "temp": 999 }, ... ] }
- formState의 errors를 이용하면 사용자가 입력 중에 발생한 검증 오류에 대한 메세지를 화면에 표시할 수 있습니다.
- 이 에러 메세지는 react-hook-form의 입력 값 검증에 의해 발생하는데 이는 처음에 설정한 mode에 따라 다릅니다. 전 onSubmit 즉 제출시에 폼을 검증하기에 제출 이전엔 메세지가 발생하지 않습니다.
마치며..
사실 react-hook-form이 아니었어도 기존 코드는 개선할 수 있었지만 입력 요소가 많고, 쉽게 검증이 가능하며 이후에 작성할 포스팅인 rc-slider와 연동이 쉬운 이유도 있어 사용하게 되었습니다..
네.. rc-slider를 사용한 입력 폼에도 적용했는데 이건 다음 포스팅에서 정리해보겠습니다..
.
.
.뿅..