대규모 폼은 필드 수가 많고 동적 배열과 다양한 UI 컴포넌트를 포함하여 렌더링과 검증 비용이 빠르게 커집니다. react-hook-form은 구독 기반의 언컨트롤드 접근으로 리렌더를 최소화해 대규모 폼에서도 성능을 안정적으로 유지합니다. 실무에서 바로 적용할 수 있는 최적화 전략과 코드 예시를 정리합니다.
1. 왜 react-hook-form이 대규모 폼에 유리한가
react-hook-form은 입력값을 DOM에 유지하고 필요 상태만 구독하는 구조로 컴포넌트 전체 리렌더를 피합니다. register로 연결된 기본 입력은 리렌더가 거의 없고, useWatch와 useFormState를 통해 필요한 변경만 구독하여 섬세하게 성능을 제어할 수 있습니다. Controller는 꼭 필요한 제어 컴포넌트에만 제한적으로 사용합니다.
2. 기본 아키텍처: FormProvider로 섹션화
대규모 폼은 섹션 단위로 컴포넌트를 분리하고 FormProvider로 컨텍스트를 공유합니다. 각 섹션은 useFormContext로 필요한 기능만 가져와 결합합니다.
import React from 'react';
import {
useForm,
FormProvider,
useFormContext,
Controller,
useFieldArray,
useWatch,
useFormState,
} from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
const schema = z.object({
profile: z.object({
name: z.string().min(1, '이름은 필수입니다'),
age: z.number().int().min(0, '나이는 0 이상'),
}),
items: z
.array(
z.object({
title: z.string().min(1, '제목 필수'),
qty: z.number().min(1, '1 이상'),
}),
)
.max(1000, '항목은 최대 1000개까지'),
});
function pickError(errors, name) {
return name.split('.').reduce((acc, key) => (acc ? acc[key] : undefined), errors);
}
function App() {
const methods = useForm({
resolver: zodResolver(schema),
mode: 'onSubmit',
reValidateMode: 'onBlur',
defaultValues: {
profile: { name: '', age: 0 },
items: [{ title: '', qty: 1 }],
},
shouldUnregister: true, // 섹션 언마운트 시 메모리 누수 방지
});
const onSubmit = methods.handleSubmit(async (data) => {
await fetch('/api/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
});
return (
<FormProvider {...methods}>
<form id="main-form" onSubmit={onSubmit} noValidate>
<ProfileSection />
<ItemsSection />
<PriceSummary />
<button type="submit">저장</button>
</form>
</FormProvider>
);
}
function ProfileSection() {
return (
<fieldset>
<legend>프로필</legend>
<InputText name="profile.name" label="이름" />
<InputNumber name="profile.age" label="나이" />
</fieldset>
);
}
const InputText = React.memo(function InputText({ name, label }) {
const { register } = useFormContext();
const { errors } = useFormState({ name }); // 해당 필드만 구독
const error = pickError(errors, name);
return (
<label>
<span>{label}</span>
<input type="text" aria-invalid={!!error} {...register(name)} />
</label>
);
});
const InputNumber = React.memo(function InputNumber({ name, label }) {
const { register } = useFormContext();
const { errors } = useFormState({ name });
const error = pickError(errors, name);
return (
<label>
<span>{label}</span>
<input type="number" aria-invalid={!!error} {...register(name, { valueAsNumber: true })} />
</label>
);
});
function ItemsSection() {
const { control } = useFormContext();
const { fields, append, remove } = useFieldArray({ control, name: 'items', keyName: 'key' });
const addItem = React.useCallback(() => append({ title: '', qty: 1 }), [append]);
return (
<fieldset>
<legend>항목</legend>
{fields.map((f, idx) => (
<ItemRow key={f.key} index={idx} onRemove={() => remove(idx)} />
))}
<button type="button" onClick={addItem}>항목 추가</button>
</fieldset>
);
}
const ItemRow = React.memo(function ItemRow({ index, onRemove }) {
const { register } = useFormContext();
return (
<div>
<input placeholder="제목" {...register(`items.${index}.title`)} />
<input type="number" placeholder="수량" {...register(`items.${index}.qty`, { valueAsNumber: true })} />
<button type="button" onClick={onRemove}>삭제</button>
</div>
);
});
function PriceSummary() {
const items = useWatch({ name: 'items' }); // 선택 구독
const totalQty = React.useMemo(() => (items || []).reduce((sum, i) => sum + (i?.qty || 0), 0), [items]);
return <p>총 수량: {totalQty}</p>;
}
위 구조는 섹션별 컴포넌트를 분리하고 메모화하여 각 입력이 독립적으로 동작하게 만듭니다. useWatch를 활용해 합계와 같은 파생 상태만 재계산하며 폼 전체 리렌더를 막습니다.
3. register vs Controller 성능 전략
가능하면 register를 사용한 언컨트롤드 입력을 선택합니다. 외부 UI 라이브러리처럼 값/이벤트를 제어해야 하는 컴포넌트만 Controller를 사용해 연결합니다. Controller는 해당 필드만 리렌더하도록 유지하되 컴포넌트 자체는 React.memo로 감싸 레퍼런스 변경을 억제합니다.
const ControlledSelect = React.memo(function ControlledSelect({ name, label, options }) {
const { control } = useFormContext();
return (
<Controller
name={name}
control={control}
render={({ field }) => (
<label>
<span>{label}</span>
<select {...field}>
{options.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</label>
)}
/>
);
});
Controller 사용 시 onChange 핸들러는 useCallback으로 고정하고, options 등 대형 프로퍼티는 상위에서 메모화해 불필요한 리렌더를 줄입니다.
4. useFieldArray로 대량 항목 처리
대규모 리스트는 useFieldArray로 키를 안정적으로 관리하고 행 컴포넌트를 React.memo로 감싸면 각 행만 독립적으로 업데이트됩니다. append, remove는 useCallback으로 감싸 레퍼런스 안정성을 높입니다. 수천 개 이상의 행을 다루는 경우 화면에 보이는 구간만 렌더하는 가상 스크롤 라이브러리와 결합하는 것을 권장합니다.
5. useWatch와 useFormState로 선택적 구독
파생 데이터(합계, 유효성 단계 표시 등)는 useWatch로 필요한 필드만 구독합니다. 에러 상태는 useFormState에 name 옵션을 주어 필드 단위로 구독하면 폼 전역 상태 변경으로 인한 광범위 리렌더를 피할 수 있습니다. 접근성을 위해 aria-invalid를 설정하고 label 연계로 스크린리더 대응을 강화합니다.
6. 검증 최적화: 스키마와 validationMode
스키마 기반 검증을 사용하되 모드는 onSubmit으로 설정해 입력 중 검증 비용을 줄이고, reValidateMode는 onBlur로 필요 시에만 재검증합니다. Zod/Yup의 refine, lazy 검증을 활용해 동적 섹션의 불필요한 경로를 건너뜁니다.
// 검증 모드와 setValue 플래그 제어 예시
const methods = useForm({ resolver: zodResolver(schema), mode: 'onSubmit', reValidateMode: 'onBlur' });
// 프로그래매틱 업데이트 시 비용 제어
methods.setValue('items.0.qty', 5, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: false,
});
초기값이 서버 의존이면 defaultValues를 비동기로 로드하여 초기 렌더 전 준비하거나, 로딩 후 reset으로 주입합니다.
// 비동기 초기값 로드
const methods = useForm({
defaultValues: async () => {
const res = await fetch('/api/defaults');
return await res.json();
},
});
// 혹은 나중에 reset
React.useEffect(() => {
(async () => {
const res = await fetch('/api/defaults');
methods.reset(await res.json());
})();
}, []);
7. 렌더링 최적화 팁
입력 컴포넌트는 React.memo로 감싸고, onClick과 같은 핸들러는 useCallback으로 고정하여 프롭 변경을 최소화합니다. 대규모 배열에서 key는 useFieldArray의 keyName을 활용해 안정적으로 관리합니다. Controller 사용 범위를 줄이고, register 중심의 언컨트롤드 패턴을 유지합니다.
// 핸들러 안정화 예시
const addItem = React.useCallback(() => append({ title: '', qty: 1 }), [append]);
8. 상태 관리 세부 옵션
shouldUnregister를 true로 설정하면 탭/아코디언 등 언마운트된 섹션의 필드가 메모리와 검증 대상에서 빠져 성능과 정확성이 좋아집니다. setValue, reset에는 shouldDirty, keepErrors 등 플래그를 적절히 조합해 불필요한 검증과 리렌더를 줄입니다.
9. 대규모 폼 UX: 단계적 로딩과 가상화
필드가 수백 개 이상이면 단계별 폼으로 분할하고, 비가시 섹션은 렌더를 지연합니다. 리스트에 수천 행이 있을 경우 react-window 같은 가상 스크롤로 화면에 보이는 항목만 렌더하여 렌더 트리를 작게 유지합니다. 에러 메시지는 구체적이고 짧게 구성하고, 포커스 이동으로 사용자 피로도를 줄입니다.
10. 제출 처리와 서버 통신 최적화
handleSubmit 내부에서 네트워크 요청을 배치하고 중복 요청을 방지합니다. 대규모 페이로드는 서버에서 단계적 검증을 수행하고, 응답을 필드 경로별로 매핑해 부분 에러만 반영합니다.
const onSubmit = methods.handleSubmit(async (data) => {
// 파일/대용량 데이터는 분할 업로드 고려
const res = await fetch('/api/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
const result = await res.json();
// 서버 에러를 필드별로 반영하려면 setError 사용
// methods.setError('items.3.title', { type: 'server', message: '중복 제목' });
});
11. 디버깅과 성능 측정
React DevTools로 렌더 카운트를 확인하고, useWatch 구독 범위를 줄여 영향도를 테스트합니다. 대규모 폼은 "어디가 리렌더되는지"를 꾸준히 관찰하는 것이 핵심입니다. 폼 상태 사용 위치를 최소화하고, 이름 기반 구독을 적극 활용하면 리렌더 폭을 좁힐 수 있습니다.
정리하면, 언컨트롤드 입력 중심의 설계, FormProvider 기반 섹션화, useFieldArray와 useWatch를 이용한 선택적 구독, 스키마 검증의 모드 최적화, 핸들러/프롭 안정화가 대규모 폼 성능의 핵심입니다. 위 패턴을 일관되게 적용하면 필드 수가 매우 많아도 사용자 경험을 매끄럽게 유지할 수 있습니다.
'React' 카테고리의 다른 글
| React에서 Resize Observer API로 반응형 컴포넌트 만들기 (0) | 2026.07.23 |
|---|---|
| React 앱에서 Undo/Redo 상태 히스토리 기능 구현하기 (0) | 2026.07.22 |
| React 앱에서 Command Palette 인터페이스 구현하기 (1) | 2026.07.21 |
| React에서 브라우저 Geolocation API 활용하기 (0) | 2026.07.21 |
| React와 tRPC를 활용한 타입 안전 API 통신 구축하기 (0) | 2026.07.16 |