제어 컴포넌트와 비제어 컴포넌트

se-een·2023년 6월 19일
1

React 탐구하기

목록 보기
5/7
post-thumbnail

지난편에서 ref 객체 (이하 ref)는 무엇인지, 어떻게 활용해볼 수 있는지에 대해서 알아보았습니다.

ref와 state를 비교하다보면 제어 컴포넌트와 비제어 컴포넌트에 대해 어렵지 않게 접할 수 있는데요. 이 두 컴포넌트의 차이는 무엇이고, 각각 언제 사용하면 좋을지 이번 글에서 알아보겠습니다. 😀

글의 초반에는 제어 컴포넌트와 비제어 컴포넌트가 무엇인지 설명하는 글입니다. 이 부분에 대해서 잘 아신다면 비교하기 파트로 넘어가 주시면 좋을 것 같습니다. 😀

신뢰 가능한 단일출처 (SSOT)

제어 컴포넌트와 비제어 컴포넌트에 대한 비교를 하려면 신뢰 가능한 단일출처 (Single Source Of Truth) 에 대한 내용을 짚고 넘어가야 하는데요.

신뢰 가능한 단일출처란 간략하게 말해서 하나의 상태는 한 곳에만 존재해야한다는 뜻입니다.

React에서 Form Element를 다루다보면 DOM 자체에서 상태를 들고 있게되는 경우가 있습니다. 예를 들어, input 태그는 사용자가 입력한 값을 DOM 자체에서 value 어트리뷰트로 담고 있죠.

여기서 제어 컴포넌트는 input 태그의 value 어트리뷰트 상태를 React의 상태로 만들어 관리하고, 비제어 컴포넌트는 전통적인 방식인 DOM 자체에서 상태를 관리하는 방식입니다.

React의 상태로 만들어서 관리하면 (제어 컴포넌트로 관리하면) React에서 Form에서 발생하는 사용자의 입력 값을 제어하므로 해당 상태가 신뢰 가능함을 React를 통해 보장받을 수 있게 되는 것입니다.

그러면 이제 제어 컴포넌트와 비제어 컴포넌트를 살펴볼까요? 먼저 비제어 컴포넌트부터 확인해보겠습니다. 🧐

비제어 컴포넌트

HTML Form Element에서 input, textarea, select와 같은 태그가 사용자의 입력을 자체적인 상태로 관리하고 업데이트하는 태그입니다.

비제어 컴포넌트는 각각의 DOM 자체에서 상태를 관리하도록 하므로, 해당 상태에 접근하기 위해서는 ref를 사용하여 DOM에 바인딩 후 특정 시점에서 값을 가져와야겠죠. (Pull 해온다고도 표현하더군요.)

export default function App() {
  const inputRef = useRef(null);

  const handleSubmitForm = (e) => {
    e.preventDefault();

    // 이 곳에 submit 로직 작성
    console.log(inputRef.current.value);
  };

  return (
    <form onSubmit={handleSubmitForm}>
      <input ref={inputRef} />
      <button>Submit</button>
    </form>
  );
}

위 예제 코드에선 input 태그만을 사용하였습니다.

물론 ref를 사용하지 않고 FormData, DOM Traversing 등의 방법으로도 input 태그 상태에 접근할 수 있습니다. 하지만 해당 방식으로 접근할 경우 코드 길이가 길어져 가독성을 해치는 경우가 잦으므로 ref를 사용하였습니다.

위 코드에서 console.log 대신 input에 입력한 값에서 한글이 있을 경우 alert를 띄우는 검증 로직을 작성해보겠습니다.

const VALIDATION_KOREAN = /[ㄱ-ㅎㅏ-ㅣ가-힣]/;

export default function App() {
  const inputRef = useRef(null);

  const handleSubmitForm = (e) => {
    e.preventDefault();

    if (VALIDATION_KOREAN.test(inputRef.current.value)) {
      alert('영어와 숫자만 입력해주세요.');
      return;
    }

    // 이 곳에 submit 로직 작성
  };

  return (
    <form onSubmit={handleSubmitForm}>
      <input ref={inputRef} />
      <button>Submit</button>
    </form>
  );
}

이제 input 태그에 한글이 포함되어 있는 경우, alert 경고창이 띄워지며 submit 되지 않습니다.

비제어 컴포넌트 방식으로도 꽤 괜찮은 수준으로 Form을 제어할 수 있어보입니다. 위 코드를 제어 컴포넌트 방식으로 변경하면 어떻게 될까요?

제어 컴포넌트

React의 state를 신뢰 가능한 단일출처로 만들어 input 태그의 상태와 React의 상태, 두 요소를 결합할 수 있습니다.

그러면 Form을 렌더링하는 React 컴포넌트는 Form에 발생하는 사용자 입력값을 제어하게 됩니다.

export default function App() {
  const [input, setInput] = useState('');

  const handleSubmitForm = (e) => {
    e.preventDefault();

    // 이 곳에 submit 로직 작성
    console.log(input);
  };

  return (
    <form onSubmit={handleSubmitForm}>
      <input
        value={input}
        onChange={(e) => {
          setInput(e.target.value);
        }}
      />
      <button>Submit</button>
    </form>
  );
}

비제어 컴포넌트 방식으로 작성되었던 코드를 제어 컴포넌트 방식으로 변경해보았습니다. 차이점으로는 ref 대신 state를, input 태그의 value 어트리뷰트에 state를 할당하고, onChange 핸들러 함수를 달아준 것 같군요. 🧐

이제 input 태그의 상태는 React의 state를 통해 제어됩니다. 사용자가 input 태그에 값을 입력할 때마다 onChange 핸들러 함수가 동작하고 해당 함수는 state를 지속적으로 갱신한 뒤 리렌더링 되고 있습니다. 그리고 그 state를 input 태그의 value 속성에 할당함으로써 렌더링 된 state의 값을 input 태그에 보여주고 있죠.

한글 검증 로직을 추가해보겠습니다.

const VALIDATION_KOREAN = /[ㄱ-ㅎㅏ-ㅣ가-힣]/;

export default function App() {
  const [input, setInput] = useState('');

  const handleSubmitForm = (e) => {
    e.preventDefault();

    if (VALIDATION_KOREAN.test(input)) {
      alert('영어와 숫자만 입력해주세요.');
      return;
    }

    // 이 곳에 submit 로직 작성
  };
  return (
    <form onSubmit={handleSubmitForm}>
      <input
        value={input}
        onChange={(e) => {
          setInput(e.target.value);
        }}
      />
      <button>Submit</button>
    </form>
  );
}

이제 input 태그에 한글이 포함되어 있는 경우, alert 경고창이 띄워지며 submit 되지 않습니다.

비제어 컴포넌트와 별반 다를게 없어보입니다. 그리고 위와 같이 간단한 검증 로직은 input 태그의 pattern 속성만을 활용해서 다음과 같이 충분히 구현 가능한 수준입니다.

export default function App() {
  const handleSubmitForm = (e) => {
    e.preventDefault();

    // 이 곳에 submit 로직 작성
  };

  return (
    <form onSubmit={handleSubmitForm}>
      <input pattern="^[a-zA-Z0-9]+$" title="영어와 숫자만 입력해주세요." />
      <button>Submit</button>
    </form>
  );
}

어떤 경우에 제어 컴포넌트로 관리하면 이점이 있는 것일까요? 🧐

제어 컴포넌트와 비제어 컴포넌트 비교하기

대표적인 특징들로 비교하면 다음과 같습니다.

여기서 주목해야할 특징은 바로 동기화 (실시간성)입니다. 제어 컴포넌트는 상태가 React를 통해 관리되므로 지속적으로 동기화되며 이는 실시간으로 확인할 수 있습니다. 그에 반해 비제어 컴포넌트는 상태가 DOM에서 관리되므로 그 상태를 특정 시점 (submit 등) 에서 pull 할 때 확인할 수 있습니다. (엄밀히 말하면 onChange 이벤트를 ref로 받아 실시간으로 상태를 확인할 수 있지만, 그 이점이 없다고 생각합니다.)

따라서 동기화 (실시간성) 에 초점을 두어 다음 두 개의 상황을 비교해보고, 추가적으로 UX 측면에서 어떤지도 비교해보겠습니다.

  • 즉각적인(실시간) 필드 유효성 검사 및 특정 입력 형식 적용
  • 조건에 따른 제출 버튼 (비)활성화

즉각적인 유효성 검사 및 특정 입력 형식 적용

이름과 주소를 입력 받는 Form이 있다고 가정해보겠습니다. 이름은 영어로만, 주소는 영어와 숫자로만 입력할 수 있습니다.

위에서 살펴본 바로 비제어 컴포넌트로는 사용자가 모든 항목을 입력하고 '제출' 버튼을 눌러야만 유효성 검사를 시작할 수 있습니다. 또한 영어와 숫자 등과 같이 특정 입력 형식을 사용자가 입력하는 도중에 강제할 수도 없죠.

만약 입력해야할 항목이 10개 가까이 되고, '제출' 버튼을 누른 뒤에야 사용자가 잘못 입력한 부분을 알려준다면, 사용자는 다시 위로 올라가 잘못 입력한 부분을 차례로 수정해야하는 과정을 거치게 될 것입니다.

다시 올라가 수정을 거쳐야 된다는 점이 UX 측면에 그리 좋지 못하다고 느껴지네요.

반면에, 제어 컴포넌트로 활용하면 다음과 같은 즉각적인 피드백이 가능합니다.

키보드로는 숫자 '0' 을 열심히 두드리고 있지만, 정작 input 태그에는 그 값이 적히지 않습니다. 대신 input 태그 아래에 빨간 글씨로 안내 메세지가 표시되죠.

사용자는 해당 안내 메세지를 통해 본인이 잘못된 형식을 입력하고 있음을 즉각적으로 인지할 수 있습니다. 따라서 제출 버튼을 누른 뒤 입력한 값을 재수정하는 일이 거의 없죠. 코드로 보면 다음과 같습니다.

const VALIDATION_NAME = /[^a-zA-Z]/;
const VALIDATION_ADDRESS = /[ㄱ-ㅎㅏ-ㅣ가-힣]/;

export default function App() {
  const [name, setName] = useState('');
  const [isNameError, setIsNameError] = useState(false);

  const [address, setAddress] = useState('');
  const [isAddressError, setIsAddressError] = useState(false);

  const handleChangeName = (e) => {
    if (VALIDATION_NAME.test(e.target.value)) {
      setIsNameError(true);
      return;
    }

    setIsNameError(false);
    setName(e.target.value);
  };

  const handleChangeAddress = (e) => {
    if (VALIDATION_ADDRESS.test(e.target.value)) {
      setIsAddressError(true);
      return;
    }

    setIsAddressError(false);
    setAddress(e.target.value);
  };

  const handleSubmitForm = (e) => {
    e.preventDefault();

    if (name.length === 0 || address.length === 0) {
      alert('빈 항목이 있습니다. 모든 빈 칸을 채워주세요.');
      return;
    }

    // 이 곳에 submit 로직 작성
    console.log('Submitted!');
  };

  return (
    <form onSubmit={handleSubmitForm}>
      <div>
        <p>이름</p>
        <input
          value={name}
          onChange={handleChangeName}
          placeholder="영어로만 입력해주세요."
        />
        {isNameError && <p>이름은 영어로만 입력해주세요.</p>}
      </div>
      <div>
        <p>주소</p>
        <input
          value={address}
          onChange={handleChangeAddress}
          placeholder="영어와 숫자로만 입력해주세요."
        />
        {isAddressError && <p>주소는 영어와 숫자로만 입력해주세요.</p>}
      </div>
      <button>제출</button>
    </form>
  );
}

이처럼 제어 컴포넌트를 활용하면 즉각적인 유효성 검사와 특정 입력 형식 적용을 통해 사용자에게 즉각적인 피드백과 더 나은 UX를 제공할 수 있다고 생각합니다.

조건에 따른 제출 버튼 (비)활성화

위 이름, 주소 제출 Form 에서 빈 항목이 하나라도 있을 경우 alert로 안내 메세지를 띄우고 있습니다. 괜찮은 방법이지만 조금 더 직관적인 방법은 무엇이 있을까요? 🧐

input 태그에 required 속성을 넣어줘도 되지만, 이는 사용자가 '제출' 버튼을 눌렀을 때 알아차릴 수 있습니다.

Form 에서 빈 항목이 하나라도 있을 경우 '제출' 버튼을 비활성화하면 되지 않을까요? (물론 현재의 상황은 input 항목이 적어서 바로 알아차릴 수 있지만, input 항목이 많아 스크롤 된 상황이라고 생각해주시면 되겠습니다.)

모든 항목을 입력했을 경우 비로소 '제출' 버튼이 활성화 된다면, 사용자는 모든 항목을 입력할 것입니다.

이전에는 사용자가 '제출' 버튼을 눌러야만 입력하지 않은 빈 항목을 체크할 수 있었지만, 이제는 '제출' 버튼을 누르지 않고도 입력하지 않은 빈 항목을 확인해볼 수 있습니다. 즉, 버튼을 누르는 행위를 절감함으로써 더 나은 UX를 제공할 수 있다고 생각합니다.

이를 코드로 보면 다음과 같습니다.

const VALIDATION_NAME = /[^a-zA-Z]/;
const VALIDATION_ADDRESS = /[ㄱ-ㅎㅏ-ㅣ가-힣]/;

export default function App() {
  const [name, setName] = useState('');
  const [isNameError, setIsNameError] = useState(false);

  const [address, setAddress] = useState('');
  const [isAddressError, setIsAddressError] = useState(false);

  const handleChangeName = (e) => {
    if (VALIDATION_NAME.test(e.target.value)) {
      setIsNameError(true);
      return;
    }

    setIsNameError(false);
    setName(e.target.value);
  };

  const handleChangeAddress = (e) => {
    if (VALIDATION_ADDRESS.test(e.target.value)) {
      setIsAddressError(true);
      return;
    }

    setIsAddressError(false);
    setAddress(e.target.value);
  };

  const isEveryInputFilled = () => {
    return name.length !== 0 && address.length !== 0;
  };

  const handleSubmitForm = (e) => {
    e.preventDefault();

    // 이 곳에 submit 로직 작성
    console.log('Submitted!');
  };

  return (
    <form onSubmit={handleSubmitForm}>
      <div>
        <p>이름</p>
        <input
          value={name}
          onChange={handleChangeName}
          placeholder="영어로만 입력해주세요."
        />
        {isNameError && <p>이름은 영어로만 입력해주세요.</p>}
      </div>
      <div>
        <p>주소</p>
        <input
          value={address}
          onChange={handleChangeAddress}
          placeholder="영어와 숫자로만 입력해주세요."
        />
        {isAddressError && <p>주소는 영어와 숫자로만 입력해주세요.</p>}
      </div>
      <button disabled={!isEveryInputFilled()}>제출</button>
    </form>
  );
}

이름과 주소 input이 채워져있거나 비워져있는 상태는 이미 기존의 state로 확인할 수 있으므로 isEveryInputFilled 메서드를 만들어 disabled 속성 조건을 지정해주었습니다.

이메일 형식과 같은 검증이 복잡한 input 태그가 있다면 disabled 속성을 관리하는 state를 따로 선언해주는 것도 괜찮을 것 같습니다. 😀

제어 컴포넌트의 단점

예시를 들어 제어 컴포넌트와 비제어 컴포넌트를 비교해보았는데요. React에서도 웬만하면 제어 컴포넌트 방식을 사용할 것을 권장하지만, 제어 컴포넌트가 장점만 있는 것은 아닌데요. 대표적인 단점은 다음과 같습니다. (각 항목을 반대로 읽으면 비제어 컴포넌트의 장점이 되겠네요.)

  • 사용자 입력 값이 변경될 때마다 리렌더링 발생
  • 모든 Form Element에 React 상태를 연결
  • non-React 코드로 작성된 Form Element 통합의 어려움

우아한테크코스 페이먼츠 미션을 진행하면서 위 상황 중 non-React 코드를 제외하고 모두 경험해보았습니다. 카드 정보를 입력 받는 Form 을 제어 컴포넌트 방식으로 진행했었는데, input 태그도 많고 입력 형식도 제한할 사항이 많아 state 관리에 굉장히 애를 먹었던 기억이 있네요. 리렌더링 최적화도 거의 못했구요. 🥲

카드 정보 입력 Form 정도면 state 형태 자체도 좀 복잡하고, 검증 로직 양도 상당하기에 useState 대신 useReducer 사용하여 모든 Form Element에 React 상태를 연결하는 귀찮음을 조금은 덜 수 있을 것 같습니다.

또한 앱이 너무 복잡하고 무겁지 않다면, Form 입력 시 발생하는 리렌더링이 앱 전체가 버벅일 정도로 악영향을 미칠 것 같지는 않다고 생각이 드네요. (React Chrome Extension 에서는 입력할 때 마다 리렌더링이 되므로 Form 요소들이 하이라이트 되지만, React가 실제로 페인팅하지는 않을 것 같습니다. 🧐) 성능적인 부분은 정확하지 않아서 React Form 라이브러리들을 좀 뜯어보고, 추후 최적화 글에서도 실험해보겠습니다.

긴 글 읽어주셔서 감사드립니다. 잘못된 내용이 있다면 댓글로 지적 부탁드리겠습니다! 🙇

P.S.

해당 내용으로 우아한테크코스 테코톡을 발표하였습니다.

profile
woowacourse 5th FE

0개의 댓글