useState로 폼을 만들었는데, 왜 찝찝할까? - react-hook-form을 도입하지 않은 이유와 그 결과

useState로 폼을 만들었는데, 왜 찝찝할까? - react-hook-form을 도입하지 않은 이유와 그 결과

2026년 2월 6일

React에서 폼을 만들 때, 대부분의 프로젝트는 두 갈래에 쓰게 됩니다.

useState로 직접 관리하거나, react-hook-form 같은 라이브러리를 도입하거나.

Recall 프로젝트에서는 전자를 선택했고, 지금도 그 선택을 유지하고 있다. 이 글은 그 판단의 근거와, 운영하면서 마주친 현실을 기록한 글입니다.


1. 문제 상황

Recall은 코딩 테스트 복습을 돕는 크롬 익스텐션이다. 폼을 사용하는 페이지가 여럿 있다.

1 2
  • ProblemDetailPage: 문제 등록/수정 폼 (제목, 링크, 사이트, 난이도, 태그, 메모, 상태)
  • SettingsPage: 복습 주기, 테마, AI 설정 폼
  • SessionSetupView: 라이브 코딩 세션 설정 폼

처음에는 각 페이지에서 useState를 흩뿌려 사용했다. 문제 등록 폼만 해도 7개 필드가 있고, 각각에 useStateonChange 핸들러가 필요했다.

const [title, setTitle] = useState("");
const [link, setLink] = useState("");
const [site, setSite] = useState("");
const [difficulty, setDifficulty] = useState("");
const [tags, setTags] = useState<string[]>([]);
const [memo, setMemo] = useState("");
const [status, setStatus] = useState<ProblemStatus>("active");

여기에 로딩 상태, 저장 상태, 유효성 검증, 초기값 로딩, 제출 핸들러까지 더하면 하나의 컴포넌트에 폼 로직이 100줄 넘게 쌓인다.

이 시점에서 자연스럽게 떠오른 질문이 있었다.

“react-hook-form을 쓰면 이걸 깔끔하게 정리할 수 있지 않을까?“

2. 내가 처음 한 생각

react-hook-form은 React 생태계에서 가장 널리 쓰이는 폼 라이브러리다. npm 주간 다운로드 700만 이상, GitHub 스타 42k 이상. 대부분의 React 프로젝트에서 “폼이 복잡해지면 react-hook-form”이라는 공식이 통용된다.

처음에는 도입을 진지하게 고려했다.

실제로 react-hook-form이 제공하는 것들을 나열해 보면

  • useForm 훅 하나로 폼 상태, 유효성 검증, 제출 핸들러를 통합
  • register로 DOM 요소에 직접 연결 — onChange 핸들러를 직접 작성할 필요 없음
  • Zod, Yup 등과 결합한 스키마 기반 유효성 검증
  • isDirty, isTouched, isValid 같은 폼 메타 상태를 자동 추적
  • 필드 단위 구독으로 불필요한 리렌더링 방지

이 중에서 특히 “리렌더링 최적화”가 눈에 띄었다.

react-hook-form의 핵심 설계를 들여다보면, useState 기반 폼과의 근본적인 차이가 보인다.

useState 방식에서는 필드 값이 바뀔 때마다 setState가 호출되고, 해당 컴포넌트 전체가 리렌더링된다. 7개 필드가 있는 폼에서 하나의 필드를 타이핑하면, 나머지 6개 필드를 포함한 전체 컴포넌트가 매 키 입력마다 리렌더링된다.

react-hook-form은 이 문제를 구조적으로 해결한다. 폼 값을 React 상태가 아닌 일반 JavaScript 객체(_formValues)에 저장하고, DOM 요소를 ref로 직접 캡처해서 네이티브 이벤트로 값을 읽는다.

핵심 로직은 https://github.com/react-hook-form/react-hook-form/blob/master/src/logic/createFormControl.ts 에 있다. 이 파일 하나에 register, setValue, handleSubmit, 유효성 검증, 구독 시스템이 모두 들어 있다.

// react-hook-form의 register가 반환하는 것 (간소화)
// 출처: https://github.com/react-hook-form/react-hook-form/blob/master/src/logic/createFormControl.ts
{
onChange: (event) => {
// event.target.value를 \_formValues 객체에 저장
// React setState를 호출하지 않음
},
onBlur: (event) => { /_ touched 상태 업데이트
_/ },
ref: (element) => { /_ DOM 요소 참조 저장 _/ },
name: "fieldName",
}

리렌더링을 제어하는 메커니즘은 두 가지다.

첫째, Observer 패턴으로 구독을 관리한다. https://github.com/react-hook-form/react-hook-form/blob/master/src/utils/createSubject.ts 에 구현된 미니 pub/sub 시스템이 있다. 폼 값이 바뀌면 모든 컴포넌트가 아니라, 해당 필드를 구독하고 있는 컴포넌트만 알림을 받는다.

둘째, Proxy로 실제 접근한 상태만 추적한다. https://github.com/react-hook-form/react-hook-form/blob/master/src/logic/getProxyFormState.ts 에서 formState를 Proxy로 감싼다. 컴포넌트가 formState.isDirty를 읽으면, “이 컴포넌트는 isDirty에 관심이 있다”고 기록한다. 이후 상태가 바뀔 때, https://github.com/react-hook-form/react-hook-form/blob/master/src/logic/shouldRenderFormState.ts 가 실제로 읽힌 속성이 바뀌었을 때만 리렌더링을 트리거한다.

이런 설계 덕분에, 필드 하나를 타이핑해도 React 리렌더링이 0회일 수 있다. errors나 isDirty처럼 컴포넌트가 구독한 상태가 실제로 변할 때만 리렌더링이 발생한다.

이론적으로는 완벽해 보였다.

하지만 Recall의 상황을 다시 들여다봤다.

3. 실제로 해 본 선택

react-hook-form을 도입하지 않고, 커스텀 훅으로 폼을 분리하는 방식을 선택했다.

이유는 두 가지였다.

  1. 폼의 규모가 작다

Recall의 가장 큰 폼은 ProblemDetailPage의 7개 필드다. Settings 폼은 사실상 2개 필드(복습 주기, 테마), LiveCoding 설정도 2개 필드(AI 제공자, API 키).

react-hook-form의 리렌더링 최적화가 의미를 가지려면, 필드가 수십 개이거나, 깊은 중첩 구조이거나, 실시간 유효성 검증이 빈번해야 한다. 7개 필드 폼에서 매 키 입력마다 컴포넌트가 리렌더링되는 것은, 실제로 성능 문제가 아니다. 이미 충분히 정리된 구조가 있다고 생각했다.

그리고 컴포넌트에서 폼 로직이 뒤섞이는 게 문제였다면, 그 해결책은 라이브러리 도입이 아니라 관심사 분리라고 보았다.

실제로 적용한 패턴은 이렇다.

페이지 컴포넌트 (ProblemDetailPage)
↓ 폼 로직을 위임
커스텀 훅 (useProblemForm)
↓ 상태 관리
useState + useCallback
↓ 데이터 연동
useProblems (CRUD 훅)

useProblemForm 훅이 폼의 모든 상태와 로직을 캡슐화한다.

// 핵심 로직: 상태 관리와 업데이트의 추상화
export function useProblemForm(id?: string) {
  const [form, setForm] = useState<ProblemForm>(INITIAL_FORM);
  const [saving, setSaving] = useState(false);

  // 1. 타입 안전성을 보장하는 제네릭 업데이트 함수
  const updateField = useCallback(
    <K extends keyof ProblemForm>(key: K, value: ProblemForm[K]) => {
      setForm((prev) => ({ ...prev, [key]: value }));
    },
    [],
  );

  // 2. 비즈니스 로직이 포함된 제출 핸들러
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!validate(form)) return; // 자체 검증 로직

    setSaving(true);
    try {
      isNew ? await addProblem(form) : await editProblem(id, form);
      navigate("/problems");
    } finally {
      setSaving(false);
    }
  };

  return { form, updateField, handleSubmit, saving };
}

페이지 컴포넌트는 이 훅의 반환값만 사용한다.

export function ProblemDetailPage() {
  const { form, updateField, handleSubmit } = useProblemForm(id);

  return (
    <form onSubmit={handleSubmit}>
      <Input
        label="문제 제목"
        value={form.title}
        onChange={(e) => updateField("title", e.target.value)}
      />
      <TagInput
        label="태그"
        value={form.tags}
        onChange={(tags) => updateField("tags", tags)}
      />
      <Button type="submit">저장</Button>
    </form>
  );
}
  1. 추가 의존성을 피하고 싶었다.

크롬 익스텐션은 번들 크기에 민감하다. react-hook-form은 gzip 기준 약 9KB로 작은 편이지만, 필요하지 않은 의존성은 0KB가 이상적이다.

더 중요한 이유는, 의존성은 “추가하는 비용”보다 “유지하는 비용”이 크다는 점이다. 메이저 버전 업그레이드, API 변경, React 버전 호환성 등 이런 것들이 장기적으로 쌓인다.

4. 결과

React 폼 관리 방식 비교: 커스텀 훅 vs react-hook-form

비교 관점useState + 커스텀 훅react-hook-form
학습 비용없음 (순수 React 지식만 필요)큼 (Register, Controller 등 API 숙지 필요)
의존성0KB (추가 비용 없음)약 9KB + Resolver 패키지 (선택 사항)
리렌더링필드 입력 시 전체 리렌더링 발생필드 단위 최적화 (비제어 방식 기반)
유효성 검증직접 구현 (로직이 파편화되기 쉬움)스키마 기반(Zod, Yup 등)의 일관된 관리
메타 상태추적 로직 직접 구현 필요isDirty, isValid, isSubmitting 등 자동 제공

다만 운영하면서 아쉬운 지점이 두 가지 있었다.

  1. 유효성 검증이 산발적이다.

현재 Recall의 유효성 검증은 각 훅마다 다른 방식으로 구현되어 있다.

  • useProblemForm: handleSubmit 내부에서 if (!form.title.trim()) return으로 조건 체크
  • useSettingsForm: 별도의 validate() 함수를 만들어 호출부에서 사용
  • useLiveCodingForm: handleTestApi 내부에서 if (!apiKey) 체크 후 에러 메시지 설정

패턴이 통일되어 있지 않다. 폼이 3개일 때는 괜찮지만, 더 늘어나면 일관성 문제가 될 수 있다.

react-hook-form에서는 이 문제를 https://github.com/react-hook-form/react-hook-form/blob/master/src/typesresolvers.ts 으로 해결한다. 유효성 검증 로직을 Zod 같은 스키마로 선언하고, https://github.com/react-hook-form/react-hook-form/blob/master/src/logic/validateField.ts 에서 일관된 방식으로 실행한다.

// react-hook-form + Zod 조합 예시
const schema = z.object({
  title: z.string().min(1, "제목을 입력하세요"),
  link: z.string().url("올바른 URL을 입력하세요"),
  difficulty: z.enum(["쉬움", "보통", "어려움"]).optional(),
});

const {
  register,
  handleSubmit,
  formState: { errors },
} = useForm({
  resolver: zodResolver(schema),
});

이 방식이라면 검증 규칙이 폼 로직과 분리되고, 어떤 필드가 어떤 규칙을 갖는지 한눈에 보인다.

  1. 폼 메타 상태(dirty, touched)를 추적하지 않는다.

현재는 “이 폼이 수정되었는가”, “이 필드를 사용자가 건드렸는가”를 추적하지 않는다. 그래서 아래와 같은 UX를 구현하기 어렵다.

  • 수정하지 않은 폼에서 “저장” 버튼 비활성화
  • 페이지를 떠날 때 “변경 사항이 저장되지 않았습니다” 경고
  • 사용자가 건드린 필드에만 에러 메시지 표시 (touched 필드에만)

react-hook-form은 이런 메타 상태를 자동으로 관리한다. https://github.com/react-hook-form/react-hook-form/blob/master/src/logic/getDirtyFields.ts 에서 현재 값과 기본값을 재귀적으로 비교하고, https://github.com/react-hook-form/react-hook-form/blob/master/src/useForm.ts 에서 formState.isDirty, formState.touchedFields를 자동추적한다.

직접 구현하려면, INITIAL_FORM과 현재 form을 deep equal로 비교하는 로직을 추가해야 한다. 지금은 필요하지 않지만, “수정 사항 있을 때만 저장 활성화” 같은 기능이 요구되면 작업이 늘어날 것이다.

5. 지금 다시 해본다면?

이 경험에서 얻은 결론은, 폼 라이브러리 도입 여부는 “폼이 복잡한가”가 아니라 “어떤 문제를 풀고 있는가”로 판단해야 한다는 것이다.

react-hook-form은 훌륭한 라이브러리다. 하지만 그 설계는 특정 문제를 해결하기 위해 존재한다.

  • 성능: 수십 개 필드, 동적 필드 배열, 실시간 유효성 검증이 있는 폼에서 리렌더링을 최소화
  • 일관성: 여러 개발자가 다양한 폼을 만들 때, register + resolver 패턴으로 통일된 구조 강제
  • 폼 메타 상태: dirty, touched, isValid 같은 상태를 선언 없이 자동 추적

이 문제들이 실제로 발생하고 있다면, react-hook-form은 분명한 해결책이다.

  • useState + 커스텀 훅이 유리한 경우:

    • 폼 필드가 10개 미만일 때
    • 번들 크기에 민감한 환경(익스텐션 등)일 때
    • 팀 규모가 작고 빠른 직관성이 중요할 때
  • react-hook-form 도입이 절실한 경우:

    • 필드가 수십 개이거나 동적으로 늘어날 때 (성능 이슈 발생)
    • “수정 시에만 버튼 활성화” 등 복잡한 폼 상태 추적이 필요할 때
    • 여러 개발자가 협업하며 폼 규격을 통일해야 할 때

다만, 만약 Recall에 폼이 더 추가되거나, 유효성 검증이 복잡해진다면 아래 순서로 점진적 개선을 고려할 것이다.

  1. 지금: useState + 커스텀 훅 (현재 상태)
  2. 검증이 복잡해지면: Zod 스키마를 도입하되,react-hook-form 없이 직접 사용
  3. 폼 메타 상태가 필요해지면: react-hook-form 도입 + Zod resolver 연동

핵심은, 도구를 먼저 도입하고 문제를 찾는 게 아니라, 문제를 먼저 겪고 도구를 선택하는 것이라고 생각한다.

react-hook-form의 https://github.com/react-hook-form/react-hook-form/blob/master/src/logic/createFormControl.ts 1000줄을 읽어보면, 이 라이브러리가 얼마나 정교하게 성능 문제를 해결하고 있는지 알 수 있다. 그리고 동시에, 그 정교함이 필요 없는 프로젝트가 있다는 것도 알 수 있다.

참고