[WEB] 프론트엔드

[React] 제어 컴포넌트, 비제어 컴포넌트, 그리고 폼 다루기

[FE] Lighthouse 2025. 6. 1. 21:06

들어가며

React 애플리케이션은 컴포넌트 단위로 만들어집니다.

다양한 컴포넌트 중에서도 폼(Form) 컴포넌트가 존재합니다. 폼 컴포넌트는 사용자에게 값을 입력 받아 요청을 처리할 수 있는 중요한 역할을 하는 컴포넌트 입니다.

 

React에서 폼 컴포넌트를 다루기 위한 방법은 크게 두 가지가 있습니다. 제어 컴포넌트, 비제어 컴포넌트 입니다.

그럼 제어 컴포넌트는 무엇이고, 비제어 컴포넌트는 무엇일까요?

이번 게시글에서는 제어 컴포넌트, 비제어 컴포넌트의 차이를 알아보고 react-hook-form 라이브러리를 사용해 비제어 컴포넌트를 살펴보도록 하겠습니다.

 

잠깐! JavaScript 없이 form을 다루는 법을 아시나요?

JavaScript를 사용하지 않고 폼을 다루는 방법을 아시나요? 최근 면접에서 해당 질문을 받고 당황한 기억이 있습니다.

프론트엔드 개발자로 근무하며 JavaScript 없는 프론트엔드는 상상할 수 없었기 때문입니다.

JavaScript 없이 단순히 HTML 태그만을 사용해 폼을 다룰 수 있습니다.

<!DOCTYPE html>
<html>
  <head>
    <title>test-form</title>
  </head>
  <body>
    <form method="post" action="/submit">
      <input type="text" name="name" placeholder="Name" />
      <input type="email" name="email" placeholder="Email" />
      <input name="password" placeholder="Password" />
      <button type="submit">submit</button>
    </form>
  </body>
</html>

 

form 태그의 method, action에 집중해봅시다. method 속성은 어떤 메서드로 서버에 요청을 보낼지, action 속성은 폼 데이터(form data)를 서버로 보낼 때 해당 데이터가 도착할 URL을 명시합니다.

 

위 HTML 문서에서 각 input에 값을 입력한 후 submit button을 클릭하면 post 요청으로 /submit에 폼 데이터를 전송합니다. 그리고 서버는 application/x-www-form-urlencoded 혹은 multipart/form-data 형식으로 데이터를 받게 됩니다.

 

요즘은 입력된 값을 검증하거나 ajax(fetch를 사용한) 요청을 위해 JavaScript를 사용해 폼 컴포넌트를 만들고 있습니다. 그래도 HTML 태그만을 사용해 폼을 다룰수 있다는 것도 알고 있으면 좋을것 같습니다.

 

제어 컴포넌트

본론으로 돌아와 제어 컴포넌트와 비제어 컴포넌트에 대해서 알아봅시다.

단어만으로 유추해 봤을때 제어 컴포넌트는 통제를 할 수 있는 컴포넌트이고 비제어 컴포넌트는 통제를 할 수 없는 컴포넌트 라고 생각할 수 있습니다.

그럼 통제의 주체는 누구일까요? 바로 React 입니다.

React로 개발을 하다보면 무수히 많은 State와 Props를 사용합니다. 그리고 State, Props는 React 라이브러리에 의해 재조정(리랜더링)을 트리거 합니다.

"State, Props를 통해 컴포넌트가 React에 제어된다."라는 가설을 바탕으로 제어 컴포넌트가 무엇인지 추론을 할 수 있습니다.

 

그럼 간단하게 State를 사용해 폼을 만들어보도록 하겠습니다. 

interface Props extends React.InputHTMLAttributes<HTMLInputElement> {
  label: string;
}

const Input: React.FC<Props> = ({ label, ...inputProps }) => {
  return (
    <div>
      <label>{label}</label>
      <input {...inputProps} />
    </div>
  );
};

export default Input;
import { useState } from "react";

import Input from "./input";

const ControlledComponents = () => {
  const [form, setForm] = useState({
    id: "",
    pwd: "",
    name: "",
  });

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const { id, value } = e.currentTarget;
    setForm((prev) => ({ ...prev, [id]: value }));
  };

  return (
    <form style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
      <h1>Controlled Components</h1>
      <Input id="id" value={form.id} label="id" onChange={handleChange} />
      <Input
        id="pwd"
        value={form.pwd}
        label="pwd"
        type="password"
        onChange={handleChange}
      />
      <Input id="name" value={form.name} label="name" onChange={handleChange} />
    </form>
  );
};

export default ControlledComponents;

 

간단하게 id, password, name을 받는 폼 컴포넌트 입니다. state를 사용해 폼을 정의하고 각 값을 input 태그에 주입해 값을 추적, 관리 합니다.

 

만들어진 컴포넌트에서 값을 변경해봅시다. 

저는 React DevTools의 Components에서 랜더링 발생시 하이라이트 표시가 되는 옵션을 켜놨습니다.

 

위 캡쳐를 보면 알 수 있듯이 각 input을 변경할 때 마다 모든 컴포넌트에서 리랜더링이 발생하고 있습니다.

id라는 input을 변경했는데 state에 의존하고 있는 모든 id, pwd, name 필드에서 리랜더링이 발생하다니, 상당히 비효율적이라고 생각이 됩니다.

 

이런 불필요한 리랜더링이 발생하는 것이 싫어서 각 필드 값별로 State를 분리하고 Input 컴포넌트와 핸들링 함수를 메모이제이션 하기로 결정합니다.

import React from "react";

interface Props extends React.InputHTMLAttributes<HTMLInputElement> {
  label: string;
}

const Input: React.FC<Props> = ({ label, ...inputProps }) => {
  return (
    <div>
      <label>{label}</label>
      <input {...inputProps} />
    </div>
  );
};

export default React.memo(Input);
import { useCallback, useState } from "react";

import Input from "./input";

const ControlledComponents = () => {
  const [name, setName] = useState("");
  const [pwd, setPwd] = useState("");
  const [id, setId] = useState("");

  const handleChangeName = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      setName(e.currentTarget.value);
    },
    []
  );

  const handleChangePwd = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      setPwd(e.currentTarget.value);
    },
    []
  );

  const handleChangeId = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      setId(e.currentTarget.value);
    },
    []
  );

  return (
    <form style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
      <h1>Controlled Components</h1>
      <Input id="id" value={id} label="id" onChange={handleChangeId} />
      <Input
        id="pwd"
        value={pwd}
        label="pwd"
        type="password"
        onChange={handleChangePwd}
      />
      <Input id="name" value={name} label="name" onChange={handleChangeName} />
    </form>
  );
};

export default ControlledComponents;

 

코드가 상당히 복잡해졌습니다. 관리해야 할 State도 늘어났고 리랜더링 최적화를 위한 일까지 더해져 부모 컴포넌트가 이전의 코드보다 더 비대해진 것을 볼 수 있습니다. 실제 성능을 확인해봅시다.

변경 후 모든 필드에서 리랜더링이 발생하던 현상은 최적화에 성공했습니다. (부모 컴포넌트의 리랜더링은 통제할 수 없지만 말이죠.)

그렇지만 폼 컴포넌트를 만들고, 리랜더링 최적화까지의 과정을 살펴보면 일을 위한 일을 하는 느낌입니다.

 

폼 컴포넌트를 만들기 위해 코드를 작성했고, 불필요한 리랜더링을 최소화 하기 위해 랜더링 최적화 작업까지 진행.. 이 과정을 조금 더 쉽게 하거나 성능 좋은 폼 컴포넌트를 쉽게 다룰 수 있는 방법이 있을까요?

 

비제어 컴포넌트

비제어 컴포넌트에 대해서 알아봅시다. 위에서 제어 컴포넌트는 React에 의해 통제되는 컴포넌트라는 가설을 세웠습니다. 그럼 비제어 컴포넌트는 React에 의해 통제받지 않는 컴포넌트라고 가설을 세워봅시다.

 

react-hook-form

react-hook-form 라이브러리는 React에서 폼을 쉽게 다룰 수 있게 도와주는 대표적인 라이브러리 입니다. react-hook-form은 비제어 컴포넌트 방식으로 폼을 다루고 있습니다. 자세한 내용은 아래 블로그 글과 공식 홈페이지를 참고해주세요.

react-hook-form 을 활용해 효과적으로 폼 관리하기
react-hook-form 공식 문서
 

react-hook-form 을 활용해 효과적으로 폼 관리하기 - 오픈소스컨설팅 테크블로그 %

오픈소스컨설팅 테크블로그 react-hook-form 을 활용해 효과적으로 폼 관리하기 오픈소스컨설팅에서 프론트엔드 개발을 하고 있는 강동희입니다. react-hook-form 을 도입한 경험을 공유합니다!

tech.osci.kr

 

react-hook-form 라이브러리를 사용해 똑같은 폼 컴포넌트를 만들어봅시다.

import Input from "./input";
import { useForm } from "react-hook-form";

const defaultValues = {
  id: "",
  pwd: "",
  name: "",
};

type Form = typeof defaultValues;

const UncontrolledComponents = () => {
  const formMethod = useForm<Form>({
    defaultValues,
  });

  const { register } = formMethod;

  return (
    <form style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
      <h1>Controlled Components</h1>
      <Input label="id" {...register("id")} />
      <Input label="pwd" {...register("pwd")} />
      <Input label="name" {...register("name")} />
    </form>
  );
};

export default UncontrolledComponents;

 

코드를 작성했으니 UI가 잘 동작하는지 확인해봅시다. 그리고 위에서 문제가 있었던 리랜더링에 대해서 살펴봅시다.

 

 

비제어 컴포넌트로 만들어진 폼 컴포넌트에 값을 입력할 경우 리랜더링이 발생하고 있지 않습니다.

react-hook-form은 내부적으로 react의 useRef를 활용해 값을 제어합니다. ref 객체는 JavaScript의 힙 영역에 저장되는 객체이며 랜더링 시 매번 같은 객체를 참조합니다. 메모리에서 참조되는 주소 값이 항상 동일하기에 실제 값이 바뀌어도 리랜더링이 되지 않습니다.

 

위 캡쳐에서 제어 컴포넌트와 비제어 컴포넌트를 비교해봅시다.

단순히 값을 입력했을 때 리랜더링이 발생하는 영역을 확인해보면 어떤 방식으로 폼 컴포넌트를 구현하는 것이 성능적으로 이점을 챙길수 있을지 명확하게 확인할 수 있습니다.

 

Ref를 사용해 폼 컨트롤 해보기

useRef를 사용한 값은 항상 동일한 참조를 유지하고 react-hook-form은 내부적으로 useRef를 사용해 값을 다룬다고 했습니다.

그럼 react-hook-form은 useRef를 사용해 어떻게 값을 다룰수 있을지 생각해봅시다!

import React from "react";

type ReturnType<T> = {
  formRef: React.Ref<HTMLInputElement | null>;
  getValue: () => T;
};

function useRefForm<T>(): ReturnType<T> {
  const formRef = React.useRef<HTMLInputElement | null>(null);

  const getValue = () => {
    return formRef.current?.value as T;
  };

  return {
    formRef,
    getValue,
  };
}

export default useRefForm;

 

 

먼저 input의 ref에 들어갈 ref를 선언합니다. getValue 메서드를 사용해 특성 시점에 ref의 value를 사용합니다.

import { useState } from "react";
import Input from "./input";
import useRefForm from "./use-ref-form";

const RefComponent = () => {
  const [formValue, setFormValue] = useState({
    name: "",
    pwd: "",
    id: "",
  });
  const { formRef: name, getValue: getName } = useRefForm<string>();
  const { formRef: pwd, getValue: getPwd } = useRefForm<string>();
  const { formRef: id, getValue: getId } = useRefForm<string>();

  return (
    <form
      style={{ display: "flex", flexDirection: "column", gap: "1rem" }}
      onSubmit={(e) => {
        e.preventDefault();
        const nameValue = getName();
        const idValue = getId();
        const pwdValue = getPwd();

        const data = {
          name: nameValue,
          id: idValue,
          pwd: pwdValue,
        };

        setFormValue(data);
      }}
    >
      <h1>Ref Components</h1>
      <Input ref={name} label="id" />
      <Input ref={pwd} label="pwd" />
      <Input ref={id} label="name" />
      <button type="submit">submit</button>
      <p style={{ display: "flex", gap: "1rem" }}>
        <span>{formValue.name}</span>
        <span>{formValue.id}</span>
        <span>{formValue.pwd}</span>
      </p>
    </form>
  );
};

export default RefComponent;

 

 

훅을 사용하는 폼 컴포넌트 입니다. submit 시점에 form을 만들어 state에 넣어줍니다. 값을 잘 추적하고 있는지 확인하기 UI에서 확인하고 싶어 state를 사용했습니다.

 

단순히 ref 객체만 사용해 폼을 다뤄봤습니다. react-hook-form 라이브러리를 사용했을 때 처럼 값을 변경해도 리랜더링이 발생하지 않으며 값을 확인하려고 하는 순간(submit)에만 State가 변경되어 랜더링이 발생하는 것을 확인할 수 있습니다.

 

마치며

폼 컴포넌트를 통해 제어 컴포넌트와 비제어 컴포넌트에 대해서 살펴봤습니다.

폼 컴포넌트를 구현하며 React에 의해 값이 제어되는(랜더링이 발생하는) 컴포넌트를 제어 컴포넌트라고 부른다는 것을 확인할 수 있었습니다.

 

제어 컴포넌트가 리랜더링을 유발한다고 하여 반드시 비제어 컴포넌트만을 사용해야 하는 것은 아닙니다. 사용자의 인터렉션에 반응해야 하는 케이스는 제어 컴포넌트를 사용할 수 밖에 없습니다.

이런 경우 제어 컴포넌트를 사용하되 랜더링 퍼포먼스에 유의하는 것이 최선의 선택이며, 퍼포먼스를 고려하여 비제어 컴포넌트를 사용할 수 있고, 폼 같은 경우에는 비제어 컴포넌트를 쉽게 다룰 수 있는 react-hook-form 라이브러리가 있다는 정도만 기억하면 좋을 것 같습니다.

 

PS. 번외로 react-hook-form 라이브러리도 내부적으로 폼의 상태를 관리하기 위해 State를 사용하고 있습니다.

// react-hook-form 레포지토리의 useForm 발췌

export function useForm<
  TFieldValues extends FieldValues = FieldValues,
  TContext = any,
  TTransformedValues = TFieldValues,
>(
  props: UseFormProps<TFieldValues, TContext, TTransformedValues> = {},
): UseFormReturn<TFieldValues, TContext, TTransformedValues> {
  const _formControl = React.useRef<
    UseFormReturn<TFieldValues, TContext, TTransformedValues> | undefined
  >(undefined);
  const _values = React.useRef<typeof props.values>(undefined);
  const [formState, updateFormState] = React.useState<FormState<TFieldValues>>({
    isDirty: false,
    isValidating: false,
    isLoading: isFunction(props.defaultValues),
    isSubmitted: false,
    isSubmitting: false,
    isSubmitSuccessful: false,
    isValid: false,
    submitCount: 0,
    dirtyFields: {},
    touchedFields: {},
    validatingFields: {},
    errors: props.errors || {},
    disabled: props.disabled || false,
    isReady: false,
    defaultValues: isFunction(props.defaultValues)
      ? undefined
      : props.defaultValues,
  });
  
  // 생략

 

 

State를 잘 다루는 방법에 대해서 고민이 있으시다면 아래 포스팅을 참고해주시면 감사하겠습니다.

[React] 상태(State)에 대하여

 

읽어봐주셔서 감사합니다.