Eun_Frontend
  • [React] 제어 컴포넌트 VS 비제어 컴포넌트
    2025년 03월 04일 14시 41분 48초에 업로드 된 글입니다.
    작성자: 동혁이

     

    제어 컴포넌트 VS 비제어 컴포넌트

     

     

    제어 컴포넌트

    HTML에서 <input>, <textarea>, <select>와 같은 폼 엘리먼트는 일반적으로 사용자의 입력을 기반으로 자신의 state를 관리하고 업데이트합니다. React에서는 변경할 수 있는 state가 일반적으로 컴포넌트의 state 속성에 유지되며 setState()에 의해 업데이트됩니다.

     

    우리는 React state를 “신뢰 가능한 단일 출처 (single source of truth)“로 만들어 두 요소를 결합할 수 있습니다. 그러면 폼을 렌더링하는 React 컴포넌트는 폼에 발생하는 사용자 입력값을 제어합니다. 이러한 방식으로 React에 의해 값이 제어되는 입력 폼 엘리먼트를 “제어 컴포넌트 (controlled component)“라고 합니다.

    (공식 문서)

     

    이해를 돕기 위해 예시로 회원가입 폼을 제어 컴포넌트로 작성하고 어디서 렌더링이 발생하는지 폼의 기본적인 동작들이 동작하는지 알아보겠습니다.

    작성 방법 1

    export default function JoinForm() {
      const [name, setName] = useState("");
      const [email, setEmail] = useState("");
      const [phone, setPhone] = useState("");
    
      const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
        e.preventDefault();
        console.log({ name, email, phone });
      };
    
      return (
        <form onSubmit={handleSubmit}>
          <div>
            <Input
              id="name"
              name="name"
              type="text"
              required
              value={name}
              onChange={(e) => setName(e.target.value)}
            />
          </div>
          <div>
            <Input
              id="email"
              name="email"
              type="email"
              required
              value={email}
              onChange={(e) => setEmail(e.target.value)}
            />
          </div>
          <div>
            <Input
              id="phone"
              name="phone"
              type="tel"
              required
              value={phone}
              onChange={(e) => setPhone(e.target.value)}
            />
          </div>
          <div>
            <button type="submit">제출</button>
            <button type="reset">초기화</button>
          </div>
        </form>
      );
    }
    
    function Input({ id, name, ...props }: InputHTMLAttributes<HTMLInputElement>) {
      return (
        <>
          <label htmlFor={id}>{name}</label>
          <input type="text" id={id} name={name} {...props} />
        </>
      );
    }

     

    Input에 값을 넣게 되면 React Dev Tools를 통해 알 수 있듯이 전체 컴포넌트가 렌더링 되고 있다.

    Form의 모든 값을 useState를 통해 관리하게 해줬기 때문에 당연한 결과다.

     

     

    제출 버튼을 누르면 콘솔에 찍히도록 했기 때문에 정상적으로 나오는 모습이다.

     

    하지만 이 상태에서 초기화 버튼을 눌러도 값이 초기화가 안되는 모습을 볼 수 있다.

    현재는 form에 의해서 데이터가 제어되는 상황이 아니라 React의 state에 의해 데이터가 제어되고 있는 상황이기 때문이다.

     

     

     

    작성 방법 2

    - 하나의 state가 변경될 때 마다 모든 컴포넌트가 렌더링 되는 상황이기 때문에 state를 Input 컴포넌트로 내려서 렌더링 범위를 줄여보겠습니다.

    - 이렇게 작성했을 경우 Input의 경우 state를 가지고 있으므로 제어 컴포넌트라고 할 수 있고 JoinForm은 state를 가지고 있지 않기 때문에 비제어 컴포넌트라고 할 수 있다.

     

    export default function JoinForm() {
      const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
        e.preventDefault();
        console.log({
          name: (e.currentTarget.elements.namedItem("name") as HTMLInputElement)
            .value,
          email: (e.currentTarget.elements.namedItem("email") as HTMLInputElement)
            .value,
          phone: (e.currentTarget.elements.namedItem("phone") as HTMLInputElement)
            .value,
        });
      };
    
      return (
        <form onSubmit={handleSubmit}>
          <div>
            <Input id="name" name="name" type="text" required />
          </div>
          <div>
            <Input id="email" name="email" type="email" required />
          </div>
          <div>
            <Input id="phone" name="phone" type="tel" required />
          </div>
          <div>
            <button type="submit">제출</button>
            <button type="reset">초기화</button>
          </div>
        </form>
      );
    }
    
    function Input({
      id,
      name,
      ...props
    }: Omit<InputHTMLAttributes<HTMLInputElement>, "value" | "onChange">) {
      const [value, setValue] = useState("");
    
      return (
        <>
          <label htmlFor={id}>{name}</label>
          <input
            type="text"
            id={id}
            name={name}
            {...props}
            value={value}
            onChange={(e) => setValue(e.target.value)}
          />
        </>
      );
    }

     

    어떻게 동작할까?

    - Input으로 state를 내렸기 때문에 해당하는 Input만 발생하는 모습을 볼 수 있습니다.

     

     

    마찬가지로 데이터를 잘 가지고 오는 것도 확인할 수 있는데 기존 state를 사용하는 코드보다 더 복잡해졌습니다.

     

    리셋 버튼을 눌렀을 때도 마찬가지로 state를 통해 input의 값이 관리되고 있어서 초기화가 동작하지 않고 있습니다.

     

     

    비제어 컴포넌트

    비제어 컴포넌트는 기존의 바닐라 자바스크립트와 크게 다르지 않은 방식이다. 우리는 바닐라 자바스크립트를 사용할 때 폼을 제출할때 (submit button)을 클릭할 때 요소 내부의 값을 얻어왔다. 

     

    비제어 컴포넌트 또한 이와 유사한 방식으로 사용된다. 비제어 컴포넌트 방식을 사용할 땐, 제어 컴포넌트 방식에서 사용한 setState()를 쓰지 않고 ref를 사용해서 값을 얻는다.

     

    비제어 컴포넌트로 변경해 렌더링은 어디서 발생하는지 폼의 기본적인 동작들이 동작하는지 알아보겠습니다.

     

    비제어 컴포넌트로 작성하기 위해서는 input html에 직접 접근 할 수 있도록 React에서 제공하는 useRef를 이용해 ref를 추가해주겠습니다.

    +) 자식 컴포넌트에서 ref를 받으려면 forwardRef HOC를 사용해줘야 한다.

     

    아래와 같이 JoinForm, Input 모두 비제어 컴포넌트로 작성했습니다.

    export default function JoinForm() {
      const nameRef = useRef<HTMLInputElement>(null);
      const emailRef = useRef<HTMLInputElement>(null);
      const phoneRef = useRef<HTMLInputElement>(null);
    
      const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
        e.preventDefault();
        console.log({
          name: (nameRef.current as HTMLInputElement).value,
          email: (emailRef.current as HTMLInputElement).value,
          phone: (phoneRef.current as HTMLInputElement).value,
        });
      };
    
      return (
        <form onSubmit={handleSubmit}>
          <div>
            <Input id="name" name="name" type="text" required ref={nameRef} />
          </div>
          <div>
            <Input id="email" name="email" type="email" required ref={emailRef} />
          </div>
          <div>
            <Input id="phone" name="phone" type="tel" required ref={phoneRef} />
          </div>
          <div>
            <button type="submit">제출</button>
            <button type="reset">초기화</button>
          </div>
        </form>
      );
    }
    
    const Input = forwardRef(function Input(
      { id, name, ...props }: InputHTMLAttributes<HTMLInputElement>,
      ref: ForwardedRef<HTMLInputElement>
    ) {
      return (
        <>
          <label htmlFor={id}>{name}</label>
          <input type="text" id={id} name={name} ref={ref} {...props} />
        </>
      );
    });

     

    state로 input의 값을 관리하고 있지 않기 때문에 리렌더링이 발생하지 않는 모습을 볼 수 있습니다.

     

     

    제출 버튼을 눌렀을 때 값이 제대로 전송되는 모습도 볼 수 있다.

     

     

    마지막으로 초기화 버튼을 눌렀을 때 폼의 기본 동작이 정상적으로 작동하는 모습을 볼 수 있다.

     

     

    이렇게 비제어 컴포넌트로 작성하게 되면 기존 state로 관리해서 리렌더링이 발생하는 문제도 제거할 수 있고 성능적으로 이득을 얻을 수 있고 폼의 기본동작 또한 전부 사용가능 합니다.

     

     

     

     

    그럼 제어 컴포넌트는 언제 사용하는게 좋을까?

    1. 유효성 검사

    2. 유효한 데이터가 없는 경우 전송 버튼의 상태를 disabled로 표시하기

    3. 신용카드와 같은 특정 입력 방식 적용하기

     

    하지만 항상 제어 컴포넌트의 방식이 사용하기 좋다는건 아니기 때문에, 이는 개발자가 유연하게 사고해 방식을 선택하는게 맞다고 생각한다.

     

     

    제어 컴포넌트를 사용했을 때의 문제점

    제어 컴포넌트는 UI의 입력한 데이터 상태와 저장한 데이터의 상태가 항상 일치하는 것을 알 수 있다.

    그러나 이 말을 다시 생각하자면, 사용자가 입력하는 모든 데이터가 동기화 된다 라는 의미가 된다.

     

    이는 불필요한 리렌더링, 불필요한 api요청으로 인한 자원 낭비 문제로도 연결 될 수 있다.

     

    이러한 불필요한 방법을 막기 위해선 스로틀링이나 디바운싱 (throttle&debounce)을 사용할 수 있지만 이 방법 또한 좋은 방법이 아니다. (참고: 클릭)

     

    즉각적으로, 실시간으로 값에 대한 피드백이 필요하다 > 제어 컴포넌트 사용 

    즉각적인 피드백이 불필요하고 제출시에만 값이 필요하다, 불필요한 렌더링과 값 동기화가 싫다 > 비제어 컴포넌트 사용

     

     

    결론

    직접 동작하는 모습을 확인하니 확실하게 알 수 있었다.

     

    제어 컴포넌트

    제어 컴포넌트의 값은 항상 최신값을 유지한다.

    새로운 입력 값이 생길때 마다 상태를 새롭게 갱신한다. 이는 데이터와 UI에서 입력한 값이 항상 동기화됨을 알 수 있다.

     

    비제어 컴포넌트

    필드에서 값을 트리거 해야 값을 얻을 수 있다.

    사진에선 [제출] 버튼을 클릭하면 console에 값이 찍힌다. [제출] 버튼을 클릭해 트리거 하기 전까지의 값은 변경되지 않는다.

    댓글