웹개발/React.js

리액트 제어컴포넌트와 비제어컴포넌트 차이, Debounce적용한 검색창 컴포넌트 구현

조맹구 2024. 1. 31. 15:35

이제껏 개발을 하면서 컴포넌트가 둘로 구분되는지 모르고 있었는데 리액트 스터디를 통해 확실하게 개념을 정리해볼 수 있었다. 학습한 내용을 바탕으로 클론코딩 중인 코드에 Debounce를 적용해보았고 그 과정을 기록해보았다.

제어? 비제어 ? 컴포넌트

React의 폼데이터를 다루는 두 가지 방식 중 하나인 제어 컴포넌트 방식입니다.
폼 데이터를 다루는 데는 제어컴포넌트비제어 컴포넌트 방식 두 가지로 특징을 잘 알아야 상황에 따라 폼관리를 최적화할 수 있습니다.

이름 그대로 값이 제어되는 컴포넌트가 제어 컴포넌트, 제어되지 않는 컴포넌트를 비제어 컴포넌트라고 합니다.

순서대로 코드와 함께 개념을 살펴보겠습니다!

제어 컴포넌트

간단히 말해 React가 폼 데이터를 제어하는 방식입니다.

  • HTML에서 <input> <textarea> <select>와 같은 폼 요소는 일반적으로 사용자의 입력으르 기반으로 state를 관리하고 업데이트 합니다.
  • React에서는 변경할 수 있는 state가 일반적으로 컴포넌트의 state 속성에 유지되며 setState()에 의해 업데이트됩니다.
  • 다음은 제어 컴포넌트의 예시 코드입니다.
import React from 'react';
import { useEffect, useState } from 'react';

const ControlledForm = () => {
  const [name, setName] = useState();
  const [text, setText] = useState();
  useEffect(() => {
    console.log('name rendered', name);
  }, [name]);
  useEffect(() => {
    console.log('text rendered', text);
  }, [text]);
  return (
    <>
      <label for="name">이름: </label>
      <input
        name="name"
        type="text"
        value={name}
        onChange={(e) => {
          setName(e.target.value);
        }}
      />
      <label for="text">설명: </label>
      <input
        name="text"
        type="text"
        value={text}
        onChange={(e) => {
          setText(e.target.value);
        }}
      />
    </>
  );
};

export default ControlledForm;

  • useEffect로 각각의 input값이 재렌더링 될 때 콘솔을 찍어보면, 사용자가 입력할 때마다 렌더링이 발생하는 것을 볼 수 있습니다.
  • 제어컴포넌트는 사용자의 입력값과 저장되는 값이 실시간으로 동기화됩니다.

장점

  1. 동기화: 사용자 인터페이스와 상태 간의 실시간 데이터 동기화를 보장합니다.
  2. 예측 가능성: 데이터 흐름과 상태 관리가 예측 가능합니다.

이러한 장점으로 다음과 같을 때 사용됩니다.

  • 실시간 유효성 검사, 예를 들어 비밀번호 재입력이 비밀번호와 같은지 실시간으로 보여주고 싶을 때
  • 조건에 따라 버튼이 비활성화 되어야 하는 경우, 예를 들어 폼의 모든 입력 값을 받아야 제출 버튼을 활성화 시키고 싶을 때

문제점

코드의 결과이미지로 볼 수 있듯이, 데이터 동기화가 계속되기 때문에 불필요한 렌더링이 발생한다는 점이다. 이는 input 값의 입력이 바뀔 때마다 API호출을 하는 구현을 한다면 API 자원 낭비로 이어질 수 있다.

 

 

보완점 => 디바운싱과 쓰로틀링 (Debouncing & Throttling)

  1. 디바운싱: 연이어 호출되는 함수들 중 마지막 함수 또는 처음 함수만 호출되도록 함.
  2. 쓰로틀링: 마지막 함수가 호출된 후 일정 시간이 지나기 전에 닫시 호출되지 않도록 하는 것.

디바운싱은 주로 검색 api 호출 시에 사용합니다.

  • 예를 들어, "테스트"라는 글자를 입력할 시, setState함수를 'ㅌ','테','텟','테스','테슽','테스트'총 6번 호출합니다.
  • 위와 같이 호출을 하게 될 경우, '텟'이나 '테슽'에 대한 결과가 없을 수 있으며 불필요한 렌더링을 하게됩니다.
  • 디바운싱은 이를 '테스트' 즉, 마지막 함수만 실행되도록 해줍니다.

쓰로틀링은 주로 스크롤을 올리거나 내릴 때 사용합니다. 이벤트 함수가 스크롤이벤트를 계속 듣고있어 이벤트가 너무 많이 발생합니다. 쓰로틀링을 적용한다면 일정 시간 간격을 두어 실행에 제한을 둘 수 있습니다.

 

 

디바운스 적용 예시: 검색창 컴포넌트, URL 연동

다음 코드는 디바운스를 적용하지 않고 제어 컴포넌트를 사용했을 경우입니다.

디바운스 미적용 SearchInput컴포넌트

'use client';
import { IoIosSearch } from 'react-icons/io';
import useDebounce from 'hooks/useDebounce';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';

function SearchInput() {
  const [inputValue, setInputValue] = useState('');
  const router = useRouter();

  useEffect(() => {
    const url = new URL(location.href);
    if (inputValue.length > 0) url.searchParams.set('keyword', inputValue);
    else {
      url.searchParams.delete('keyword');
    }
    router.replace(url.toString(), undefined);
  }, [inputValue]);
  return (
    <div className="my-3 bg-white overflow-hidden py-3 flex items-center border rounded border-box-border focus-within:border-elice-purple ">
      <IoIosSearch className="mx-4 text-text-black" size="16px" />
      <input
        className="flex-1 text-sm placeholder:text-[gray] outline-none"
        type="text"
        placeholder="배우고 싶은 언어, 기술을 검색해보세요"
        onChange={e => setInputValue(e.target.value)}
      />
    </div>
  );
}

export default SearchInput;

해당 코드는 inputValue값이 변할 때마다 useEffect로 감지한 후, inputValue를 URL에 연동시켜주는 코드입니다.

결과물처럼 값을 입력할 때마다 실시간으로 URL에 연동된 것을 볼 수 있습니다.

 

 

 


이를 디바운스 hook을 만들어준 후, 사용자의 입력이 다 끝났을 때 요청을 보내주도록 만들겠습니다.
먼저 결과물입니다.

타입스크립트 코드로 보겠습니다.

useDebounce.ts 훅

'use client';
import { useState, useEffect } from 'react';

export default function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState<T>(value); //입력된 값을 디바운스 된 최종 값으로 저장

  useEffect(() => {
    const handler = setTimeout(() => {
       console.log("setTimer",value) 
      setDebouncedValue(value);
    }, delay);
    // 입력값이 변경될 때마다 delay 시간 후 debouncedValue 업데이트
    return () => {
      clearTimeout(handler);
      console.log("Time out")
    };
  }, [value, delay]);

  return debouncedValue;
}

SearchInput.tsx

'use client';
import { IoIosSearch } from 'react-icons/io';
import useDebounce from 'hooks/useDebounce';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';

function SearchInput() {
  const [inputValue, setInputValue] = useState('');
  const debouncedSearch = useDebounce(inputValue, 300); //훅 호출
  const router = useRouter();

  useEffect(() => {
    const url = new URL(location.href);
    if (inputValue.length > 0) url.searchParams.set('keyword', inputValue);
    else {
      console.log(inputValue);
      url.searchParams.delete('keyword');
    }
    router.replace(url.toString(), undefined);
  }, [debouncedSearch]);
  return (
    <div className="my-3 bg-white overflow-hidden py-3 flex items-center border rounded border-box-border focus-within:border-elice-purple ">
      <IoIosSearch className="mx-4 text-text-black" size="16px" />
      <input
        className="flex-1 text-sm placeholder:text-[gray] outline-none"
        type="text"
        placeholder="배우고 싶은 언어, 기술을 검색해보세요"
        onChange={e => setInputValue(e.target.value)}
      />
    </div>
  );
}

export default SearchInput;

동작의 이해를 돕기위해 콘솔로 찍어보았습니다.

 

  • 타자를 치는 동안 (input이벤트 발생-> value값 변경) 마다 useEffect로 setTimeout()으로 타이머를 설정합니다.
  • delay시간 후에 debouncedValue 상태를 업데이트 합니다. 만약, 'value'값이 변경되어 useEffect가 실행된다면 useEffect의 clean-up 함수 내의 clearTimeout을 실행하여 이전 타이머를 취소한 후, setTimeout()으로 타이머를 설정합니다.
  • 위의 예시에 적용한다면. "테스트용"을 연속으로 입력하는 동안 각각 "ㅌ", "테", "텟" ... 입력에 대해 value값이 변화하기 때문에 useEffect가 트리거됩니다.
  • useEffect 내 클린업 함수가 실행되며 이전 타이머를 취소합니다 -> console.log("Time out")
  • useEffect 내 새로운 setTimeout 이 설정됩니다.
  • delay 시간 내 사용자 입력이 일어난다면 setTimeout 내 콜백함수는 동작하지 않고 useEffect가 트리거되면서 클린업함수로 이전 타이머가 취소된 후 새로운 타이머가 설정됩니다.
  • 더 이상의 입력이 없고, delay 시간이 지나면 마지막으로 설정된 setTimeout 내 콜백함수가 동작하여 debouncedValue값이 업데이트 됩니다.
  • 최종적으로 useDebounce 훅을 사용하는 컴포넌트에 debouncedValue값이 반환됩니다.

 

비제어 컴포넌트

비제어 컴포넌트는 React가 폼 데이터 상태를 직접적으로 관리하지않고, DOM 자체가 폼 데이터를 관리합니다.
React의 ref를 사용하여 직접 DOM 요소에 접근하고, 필요할 때 그 값들을 추출합니다.

화면처럼 입력 때마다 동기화되지도 않습니다. 즉, 값이 업데이트 될 때마다 리렌더링이 되지 않고 버튼을 눌렀을 때만 API를 호출할 수 있도록 하여 성능상에 이점을 가져다 줄 수 있습니다.

import React, { useRef } from 'react';

const UnControlledForm = () => {
  // 입력 필드에 대한 ref를 생성합니다.
  const nameRef = useRef(null);
  const textRef = useRef(null);

  // 폼 제출 핸들러
  const handleSubmit = (event) => {
    // 폼의 기본 제출 동작을 방지합니다.
    event.preventDefault();

    // 각 입력 필드의 현재 값에 접근합니다.
    const name = nameRef.current.value;
    const text = textRef.current.value;

    // 입력된 값을 콘솔에 출력합니다.
    console.log('이름:', name);
    console.log('설명:', text);
  };

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="name">이름: </label>
      <input ref={nameRef} name="name" type="text" />
      <br />
      <label htmlFor="text">설명: </label>
      <input ref={textRef} name="text" type="text" />
      <br />
      <button type="submit">제출</button>
    </form>
  );
};

export default UnControlledForm;

비교 표

기능 제어 컴포넌트 비제어 컴포넌트
일회성 정보 검색 (예: 제출) O O
제출 시 값 검증 O O
실시간으로 필드값의 유효성 검사 O X
조건부로 제출 버튼 비활성화 (disabled) O X
실시간으로 입력 형식 적용하기 (숫자만 가능하게 등) O X
동적 입력 O X

 

 

 

 

 

인용

리액트 공식문서

https://velog.io/@yukyung/React-%EC%A0%9C%EC%96%B4-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8%EC%99%80-%EB%B9%84%EC%A0%9C%EC%96%B4-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8%EC%9D%98-%EC%B0%A8%EC%9D%B4%EC%A0%90-%ED%86%BA%EC%95%84%EB%B3%B4%EA%B8%B0

https://www.zerocho.com/category/JavaScript/post/59a8e9cb15ac0000182794fa

https://velog.io/@hwisaac/Debounce-%ED%9B%85