회사에서 개인의 기술 성장을 위하여 3주에 1번씩 사내 기술 세션 발표가 열리는데, 비록 2주차였지만 더욱 몰두하고자 자원하여 발표한 사내 기술 세션 발표 내용입니다.
최근에 회사에서 새로운 기능을 개발하면서, 자연스럽게 react-hook-form을 처음 접하게 되었습니다. 처음엔 단순히 폼 상태를 다루기 위한 도구 정도로 생각했지만, 실제로 기능을 만들다 보니 생각보다 많은 개념들이 녹아 있었고, 특히 "렌더링 최적화" 관점에서 매우 중요한 포인트들이 존재한다는 걸 알게 되었습니다.
이 최적화를 진행한 배경
이 기능은 고객에게 직접적으로 맞닿는 인터페이스가 아니었기 때문에 백오피스 성능이 상대적으로 중요하지는 않았습니다. 하지만 react-hook-form 라이브러리를 사용하면서 고객의 데이터를 입력한 후 조회하는 폼을 최적화하는 것이 필요하지 않을까? 라는 마음에 진행하게 되었습니다.
특히 실제 서비스에서는 더 복잡한 폼이 추가될 가능성이 높고, 렌더링 최적화 패턴을 미리 학습해두면 향후 고객 대면 기능을 개발할 때 도움이 될 것이라고 판단했습니다.
React Hook Form의 두 가지 접근 방식: Register vs Control
React Hook Form을 사용하는 방법은 크게 두 가지로 나뉩니다.
1. Register 방식 (Uncontrolled Components)
// 기본 HTML 인풋과 함께 사용하는 방식
const { register, handleSubmit } = useForm();
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("amount", { required: true })} />
<input {...register("bankCode")} />
<button type="submit">Submit</button>
</form>
);
Register 방식은 React Hook Form의 기본적인 사용법으로, 일반적인 HTML 인풋 요소와 직접 연결됩니다.
- 성능이 뛰어남: DOM을 직접 참조하므로 불필요한 리렌더링이 발생하지 않습니다
- 구현이 간단함: {...register("fieldName")} 한 줄로 폼 필드 연결이 완료됩니다
- 번들 크기가 작음: 추가적인 래퍼 컴포넌트가 필요하지 않습니다
2. Control 방식 (Controlled Components)
// Controller나 useController와 함께 사용하는 방식
const { control, handleSubmit } = useForm();
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Controller
name="amount"
control={control}
render={({ field }) => <TextField {...field} />}
/>
</form>
);
Control 방식은 외부 UI 라이브러리와 함께 사용할 때 필요한 방식입니다. 이미 프로젝에서는 MUI 를 사용했기 때문에 Control 방식으로 구현되어 있었습니다.
Control 방식의 트레이드오프
const MyForm = () => {
const { control } = useForm();
// 필드 값이 변경될 때마다 이 컴포넌트가 리렌더링됨
return (
<Controller
name="amount"
control={control}
render={({ field }) => {
console.log('리렌더링 발생');// 값 변경 시마다 출력
return <TextField {...field} />;
}}
/>
);
};
성능상 고려사항
- Register 방식 대비 더 많은 리렌더링 발생합니다.
- Controller의 render prop이 매번 새로운 함수를 생성합니다.
- 복잡한 폼에서는 성능 최적화가 더욱 중요해집니다.
이런 이유로 이번 프로젝트에서 watch와 useWatch를 활용한 렌더링 최적화가 중요하다고 생각했습니다.
문제의 시작: watch로 인한 불필요한 렌더링
export function TransferRequestForm({
..
}: Props) {
...
const methods = useForm<FormValues>({
defaultValues: { amount: 0, bankCode: null, accountNo: null },
});
const { control, handleSubmit, watch } = methods;
const 변경된출금액 = watch('amount');
...
return (
<>
<FormProvider {...methods}>
<form onSubmit={handleSubmit(handleFormSubmit)}>
<Flex gap={'12px'} justifyContent={'space-evenly'}>
...
<ControlledTextField
...
control={control}
...
/>
</Flex>
...
</form>
</FormProvider>
{/* {transferRequest ? ( */}
<TransferRequestSnapShot
...
변경된출금액={변경된출금액}
/>
{/* ) : null} */}
</>
);
}
처음에는 watch()를 이용해서 amount 값을 실시간으로 감시하고 있었습니다. 출금액이 바뀌면 버튼의 비활성화 여부를 판단해야 했고, 그걸 위해 watch('amount')를 TransferRequestForm 컴포넌트 안에 사용했습니다. 그런데 출금액을 바꿀 때마다 TransferRequestForm은 물론이고, 그 하위에 있는 TransferRequestSnapShot, ControlledTextField 같은 컴포넌트들까지 전부 리렌더링되고 있었습니다.
watch API가 앱 또는 폼의 루트에서 리렌더링을 트리거하기 때문에 공식 문서에서도 성능 문제가 발생하는 경우 useWatch API 사용을 권장하고 있습니다.
출금액이 바뀌면 버튼의 비활성화 여부를 판단해야 했고, 그걸 위해 watch('amount')를 TransferRequestForm 컴포넌트 안에 사용했습니다. 그런데 출금액을 바꿀 때마다 TransferRequestForm은 물론이고, 그 하위에 있는 TransferRequestSnapShot, ControlledTextField 같은 컴포넌트들까지 전부 리렌더링되고 있었습니다.

"왜 이렇게 다 같이 리렌더링이 되는 걸까?" 하고 들여다보니, watch는 해당 값을 구독하는 모든 컴포넌트의 렌더링에 영향을 준다는 걸 알게 됐습니다. 다시 말하면, 한 번 watch()를 호출한 순간 그 값을 사용하는 모든 곳이 상태가 바뀔 때마다 함께 리렌더링된다는 겁니다.
지금 당장은 이 폼이 단순해서 큰 문제가 없어 보일 수 있지만, 폼이 점점 커지고 상태가 많아지면 성능 저하가 눈에 띄게 나타날 수밖에 없습니다. 이걸 방치하면 앞으로 더 많은 상태가 생겼을 때 감당할 수 없을 것 같다는 생각이 들었습니다.
useWatch 도입: 하지만 여전히 문제는 있었다
// const 변경된출금액 = watch('amount');
...
return (
<>
<FormProvider {...methods}>
<form onSubmit={handleSubmit(handleFormSubmit)}>
...
</form>
{/* {transferRequest ? ( */}
<TransferRequestSnapShot
...
// 변경된출금액={변경된출금액}
/>
{/* ) : null} */}
</FormProvider>
</>
);
}
FormProvider를 사용하고 있었기 때문에, TransferRequestSnapShot 컴포넌트는 반드시 FormProvider 내부에 위치해야 했습니다. 이렇게 해야 useFormContext()를 통해 control 객체에 접근할 수 있고, useWatch를 이용해 별도의 props 전달 없이도 폼 필드의 상태를 직접 구독할 수 있습니다.
export function TransferRequestSnapShot({
...
}: Props) {
const { control } = useFormContext();
const 변경된출금액 = useWatch({ control, name: 'amount' });
const buttonDisabled =
...
Number(변경된출금액) !== transferRequest.transferAmount;
...
return (
<Flex alignItems={'center'} justifyContent={'space-evenly'} gap={'12px'}>
<div>TransferRequestSnapShot 컴포넌트</div>
<BasicTextField
...
<BasicButton
disabled={buttonDisabled}
...
>
이체요청
</BasicButton>
</Flex>
);
}
그래서 이 문제를 해결해보려고 useWatch()로 바꿔봤습니다. useWatch()는 공식 문서에서도 성능을 위해 watch()보다 사용을 권장하는 훅이라 특정 필드의 값만 구독하고, 해당 값이 바뀔 때에만 그 훅을 사용하는 컴포넌트만 리렌더링되도록 보장합니다. 설명만 보면 완벽한 해결책처럼 느껴졌습니다.
하지만 문제는 여전히 남아 있었습니다. TransferRequestSnapShot이라는 컴포넌트 안에서 useWatch()로 출금액을 감시하니까, 결국 그 컴포넌트 전체가 리렌더링되고 있었던 겁니다. useWatch()를 사용하는 컴포넌트 자체가 리렌더링되기 때문입니다.

즉, watch()를 쓰던 useWatch()를 쓰던, 감시 중인 필드 값이 바뀌면 그 값을 구독하고 있는 컴포넌트는 무조건 리렌더링이 일어나게 됩니다. 다만 useWatch()는 해당 값을 사용하지 않는 다른 형제 컴포넌트들까지 같이 리렌더링되지는 않는다는 점에서 확실히 더 나은 선택이긴 합니다.
중요한 건, 필요한 컴포넌트만 구독하도록 쪼개는 것
그래서 "내가 진짜로 출금액 값이 바뀔 때 반응하고 싶은 건 뭐지?" 라고 생각해보았습니다.
결론은 TransferRequestSnapShot 전체가 아니라, 버튼 하나의 상태만 바뀌면 되는 거였습니다. 이체 요청 버튼을 활성화/비활성화할지 여부를 판단하는 용도로 출금액을 감시하고 있었기 때문입니다.
export function ButtonArea({ hasSent, refetch }: { hasSent: boolean; refetch: () => void }) {
const { control } = useFormContext();
const 변경된출금액 = useWatch({ control, name: 'amount' });
...
return <BasicButton sx={{ width: 180 }}>test버튼!!</BasicButton>;
}
export function TransferRequestSnapShot({
...
}: Props) {
...
return (
<Flex alignItems={'center'} justifyContent={'space-evenly'} gap={'12px'}>
<div>TransferRequestSnapShot 컴포넌트</div>
...
<ButtonArea
hasSent={hasSent}
refetch={refetch}
/>
</Flex>
);
}
그래서 useWatch()를 사용하는 로직을 따로 버튼 컴포넌트로 분리했습니다. ButtonArea라는 하위 컴포넌트를 새로 만들고, 거기서만 useWatch()를 써서 출금액 상태를 감시하게 만들었습니다. 이러면 정말 버튼만 리렌더링되게 되는 거죠. 나머지 상위 컴포넌트들, 특히 스냅샷 전체가 불필요하게 리렌더링되는 일이 사라졌습니다.
useWatch를 쓰는 것만으로는 성능 최적화가 끝나는 게 아니고, 그 값을 구독해야 하는 "컴포넌트의 위치"를 신중하게 설계해야 진짜 효과가 나타납니다.
FormProvider와 useFormContext의 역할
이 프로젝트에선 FormProvider로 폼의 컨텍스트를 상위에 공유하고 있었기 때문에, 하위의 어떤 컴포넌트에서도 useFormContext()를 통해 control 객체를 꺼내 쓸 수 있었습니다.
덕분에 ButtonArea나 TransferRequestSnapShot처럼 완전히 분리된 컴포넌트에서도 useWatch({ control, name: 'amount' })로 값을 감시할 수 있었고, 그걸 기반으로 유연한 컴포넌트 분리가 가능했던 거죠. FormProvider를 잘 활용하면 상태를 props drilling 없이도 공유할 수 있고, 그 상태를 각 컴포넌트가 적절한 수준에서 구독만 하면 되니까 성능상 이점이 굉장히 큽니다.
Context Provider와의 차이점에 대한 의문
여기서 한 가지 의문이 들었습니다. FormProvider가 Context API와 유사한 방식이라면, 폼 데이터가 변경될 때마다 전체 하위 컴포넌트가 리렌더링되는 것 아닌가? 라는 생각이었습니다.
// 일반적인 Context 패턴
const FormContext = createContext();
const FormProvider = ({ children }) => {
const [formData, setFormData] = useState({});
return (
<FormContext.Provider value={{ formData, setFormData }}>
{children} {/* formData 변경 시 전체 리렌더링 */}
</FormContext.Provider>
);
};
React Hook Form FormProvider
// FormProvider의 실제 동작 방식
<FormProvider {...methods}> {/* methods 객체 자체는 변하지 않음 */}
<MyForm />
</FormProvider>
// Context에 전달되는 값
const methods = useForm();// 이 객체는 리렌더링되어도 동일한 참조 유지
Context에 담긴 값의 특성
// 일반 Context: 실제 데이터가 Context에 저장
const value = { amount: 100, bankCode: 'KB' };// 값이 바뀌면 새 객체
// FormProvider: 메서드 객체만 Context에 저장
const methods = useForm();// 이 객체는 안정적, 데이터는 내부 시스템에서 관리
구독 방식의 차이
// 일반 Context: 전체 값 구독
const { amount, bankCode } = useContext(FormContext);// 모든 변경에 반응
// FormProvider: 선택적 구독
const amount = useWatch({ name: 'amount' });// 원하는 필드만 구독
실제 상태 저장소의 위치
// React Hook Form의 상태는 Context가 아닌 별도의 내부 시스템에서 관리
// Context는 단지 "접근 통로" 역할만 수행
const { control } = useFormContext();// control 객체로 내부 시스템에 접근
const amount = useWatch({ control, name: 'amount' });// 내부 구독 시스템 활용
이런 설계 덕분에 FormProvider를 사용해도 일반적인 Context API의 성능 문제(전체 리렌더링)가 발생하지 않습니다. Context는 단순히 폼 제어 메서드들에 대한 접근을 제공할 뿐이고, 실제 폼 상태 변화에 대한 구독은 React Hook Form의 내부 최적화된 시스템을 통해 이루어집니다.
FormProvider를 잘만 활용하면 상태를 props drilling 없이도 공유할 수 있고, 그 상태를 각 컴포넌트가 적절한 수준에서 구독만 하면 되니까 성능상 이점이 굉장히 큽니다.
이번 경험은 단순히 react-hook-form을 처음 접하면서, MUI라는 외부 라이브러리와의 연동부터 시작해서, 겉으로 보면 쉬운 폼 상태 관리였지만, 실제 렌더링 흐름과 리액트의 렌더링 최적화 관점을 이해하지 않으면 놓치기 쉬운 포인트가 숨어 있었습니다.
특히 UI 라이브러리를 사용할 때는 성능과 편의성 사이의 균형을 찾는 것이 개발자의 중요한 역량이라는 것을 다시 한번 깨달았습니다. 또한 때로는 "더 나은 코드"보다 "적절한 수준의 코드"가 비즈니스 관점에서 더 가치 있을 수 있다는 점도 배울 수 있었습니다.
발표 당일 2일 전에 분석, 습득한 내용으로 발표했더니 좀 더 react-hook-form을 사용해보고, 더 많은 기능을 안 상태로 발표했으면 하는 아쉬움이 있었다. 리액트의 렌더링 과정도 다시 복습하고, react-hook-form 라이브러리를 새로 써보면서 기능 하나씩 체득한 것 같아 나름 성취감이 있었다. 실무 코드를 보면서 학습하니까 더 재밌게 개발하는 것 같다.!!
'Programming > React' 카테고리의 다른 글
React 프로젝트 의존성 버전 업그레이드 하기 - axios (0.18.0 → 1.7.7) (1) | 2024.11.21 |
---|---|
Kakao 소셜 로그인: Local 환경에서는 되고, S3 + Cloudfront 배포 후는 안되는 문제 해결 (1) | 2024.09.23 |
카카오 소셜 로그인 (4) | 2024.09.15 |
AWS S3로 프론트엔드 배포하기 (0) | 2024.09.07 |
다크모드 GlobalStyle 적용하기 : [Cannot read properties of undefined (reading 'body'), The above error occurred in the <l2> component] (1) | 2024.08.29 |