제어 컴포넌트 vs 비제어 컴포넌트: React Hook Form이 렌더링을 줄이는 비결

크리스가 회원가입 페이지를 만들고 있다. 아이디, 비밀번호, 주소 등 입력할 게 많은 긴 폼(Form)이다.
그런데 타자를 칠 때마다 화면이 미세하게 버벅거리는 느낌이 든다. 리액트 개발자 도구의 'Highlight updates'를 켜보니, 아이디 한 글자를 칠 때마다 폼 전체와 전혀 상관없는 하단 푸터까지 번쩍거리며 리렌더링 되고 있었다.
"그냥 텍스트 입력하는 건데 왜 페이지 전체가 다시 그려지지?"
이것은 리액트의 정석이라 불리는 제어 컴포넌트(Controlled Component) 패턴이 가진 고질적인 성능 문제다. 오늘은 이 문제를 해결하기 위한 비제어 컴포넌트(Uncontrolled Component)의 개념과, 이를 활용해 렌더링을 획기적으로 줄여주는 React Hook Form의 원리를 알아본다.
1. 제어 컴포넌트 (Controlled): 리액트가 왕이다
우리가 흔히 쓰는 방식이다. 입력값의 주인(Source of Truth)은 리액트의 State다.
// ControlledForm.tsx
import { useState } from 'react';
function ControlledForm() {
const [value, setValue] = useState('');
// 1. 키보드를 누를 때마다(onChange)
// 2. 상태(State)가 바뀐다.
// 3. 상태가 바뀌었으니 컴포넌트 전체가 리렌더링 된다. 💥
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value);
};
return <input value={value} onChange={handleChange} />;
}2. 비제어 컴포넌트 (Uncontrolled): DOM이 왕이다
반면, 비제어 컴포넌트는 입력값을 리액트가 관리하지 않는다. 그냥 브라우저의 DOM에게 맡겨두고, 필요할 때(예: 제출 버튼 클릭) 값을 쏙 빼온다. 이때 ref를 사용한다.
// UncontrolledForm.tsx
import { useRef } from 'react';
function UncontrolledForm() {
const inputRef = useRef<HTMLInputElement>(null);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// 필요할 때만 DOM에 직접 접근해서 값을 가져온다.
console.log(inputRef.current?.value);
};
// 입력해도 리렌더링이 발생하지 않는다! (State 변경이 없으니까)
return (
<form onSubmit={handleSubmit}>
<input ref={inputRef} />
<button>제출</button>
</form>
);
}3. React Hook Form: 두 마리 토끼 잡기
"비제어 컴포넌트의 성능과 제어 컴포넌트의 사용성을 합칠 순 없을까?"
이 질문에서 출발한 라이브러리가 React Hook Form이다. 이 라이브러리의 핵심 전략은 "비제어 컴포넌트를 기본으로 하되, 상태 관찰이 필요한 경우에만 렌더링을 일으키는 것"이다.
내부 동작 원리
register 함수가 하는 일은 단순하다. 해당 input에 ref를 연결하고, onBlur나 onChange 이벤트 리스너를 몰래 등록하는 것이다.
// RHFExample.tsx
import { useForm, SubmitHandler } from 'react-hook-form';
type Inputs = {
example: string,
};
export default function App() {
// watch, formState 등의 구독 기능을 제공함
const { register, handleSubmit, formState: { errors } } = useForm<Inputs>();
const onSubmit: SubmitHandler<Inputs> = data => console.log(data);
console.log("렌더링 체크"); // 입력할 때는 로그가 안 찍힘!
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* register가 내부적으로 ref를 연결함 */}
<input {...register("example", { required: true })} />
{/* 에러가 발생했을 때만 해당 부분 리렌더링 */}
{errors.example && <span>필수 입력입니다.</span>}
<button type="submit">제출</button>
</form>
);
}React Hook Form은 입력 값이 변해도 컴포넌트 전체를 리렌더링 하지 않는다. 내부적으로 값을 추적하다가, 유효성 검사에 실패하거나 폼을 제출하는 순간에만 필요한 부분(Isolated Re-render)을 업데이트한다.
4. 언제 무엇을 써야 할까?
크리스는 이제 상황에 맞춰 도구를 선택한다.
| 비교 | 제어 컴포넌트 (useState) | 비제어 컴포넌트 (Ref) | React Hook Form |
| 렌더링 빈도 | 입력할 때마다 (많음) | 거의 없음 (적음) | 필요한 순간에만 (최적화) |
| 데이터 원천 | React State | DOM | DOM + Internal State |
| 추천 상황 | 입력값에 따라 UI가 즉시 변해야 할 때 (마스킹, 동적 필드) | 아주 간단한 폼, 외부 라이브러리 연동 | 대부분의 실무 폼, 성능이 중요할 때 |
핵심 정리
폼 입력을 마스터했으니, 이제 컴포넌트 간에 데이터를 공유하는 방법을 알아볼 차례다. Props Drilling의 지옥에서 벗어나기 위한 전역 상태 관리 도구들을 비교해본다.
"Context API vs Zustand: 보일러플레이트 없는 전역 상태 관리" 편에서 계속된다.