React|리액트 동작 원리와 상태 관리

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

9
제어 컴포넌트 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} />;
}
  • 장점: 입력값에 따른 유효성 검사(Validation)나 UI 변경을 실시간으로 할 수 있다. (예: 10글자 넘으면 빨간색 표시)
  • 단점: 글자 하나 칠 때마다 컴포넌트 전체 함수가 다시 실행된다. 폼이 크다면 심각한 성능 저하를 일으킨다.
  • 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>
      );
    }
  • 장점: 타자를 쳐도 리렌더링이 0번이다. 성능이 매우 좋다.
  • 단점: 값이 바뀔 때마다 즉각적인 반응(실시간 에러 메시지 등)을 구현하기가 까다롭다.
  • 3. React Hook Form: 두 마리 토끼 잡기

    "비제어 컴포넌트의 성능과 제어 컴포넌트의 사용성을 합칠 순 없을까?"

    이 질문에서 출발한 라이브러리가 React Hook Form이다. 이 라이브러리의 핵심 전략은 "비제어 컴포넌트를 기본으로 하되, 상태 관찰이 필요한 경우에만 렌더링을 일으키는 것"이다.

    내부 동작 원리

    register 함수가 하는 일은 단순하다. 해당 inputref를 연결하고, onBluronChange 이벤트 리스너를 몰래 등록하는 것이다.

    // 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 StateDOMDOM + Internal State
    추천 상황입력값에 따라 UI가 즉시 변해야 할 때 (마스킹, 동적 필드)아주 간단한 폼, 외부 라이브러리 연동대부분의 실무 폼, 성능이 중요할 때

    핵심 정리

  • 제어 컴포넌트: 리액트가 모든 입력을 감시한다. 구현은 쉽지만, 불필요한 렌더링이 많이 발생한다.
  • 비제어 컴포넌트: ref를 사용해 DOM에 값을 저장한다. 렌더링 성능은 좋지만, 실시간 제어가 어렵다.
  • React Hook Form: 비제어 컴포넌트(ref) 방식을 기반으로, 렌더링을 최소화하면서도 폼 검증과 상태 관리를 쉽게 도와주는 "치트키" 같은 라이브러리다.
  • 폼 입력을 마스터했으니, 이제 컴포넌트 간에 데이터를 공유하는 방법을 알아볼 차례다. Props Drilling의 지옥에서 벗어나기 위한 전역 상태 관리 도구들을 비교해본다.

    "Context API vs Zustand: 보일러플레이트 없는 전역 상태 관리" 편에서 계속된다.


    🔗 참고 링크

  • Controlled vs Uncontrolled Components - React Docs
  • React Hook Form - Get Started
  • Performance Comparison
  • 댓글 (0)

    0/1000
    댓글을 불러오는 중...