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

keemsebeen·2025년 4월 27일

공식문서를 읽다보니, State를 사용해 Input 다루기가 있었고 컴포넌트 간 State 공유하기를 보다보니 비제어가 있었다. 연관이 있지 않을까? 싶어 공식문서에 존재하는 input 설명으로 가봤더니 제어라는 단어가 43개나 등장함을 볼 수 있었다. 그 궁금증을 해소하기 위해 하나씩 찾다 이렇게 글을 쓰게 되었다.

리액트에서 제어 컴포넌트와 비제어 컴포넌트는 데이터를 관리하는 두 가지 패러다임을 나타냅니다. 이 두 패러다임을 이해하는 것은 중요하다고 생각합니다. 왜냐하면 단순한 구현 방식의 차이를 넘어 애플리케이션의 데이터 흐름, 상태 관리 전략, 그리고 성능에 직접적인 영향을 미치기 때문입니다.

제어 컴포넌트(Control Component)와 비제어 컴포넌트(Uncontrolled Component)의 차이는 상태 관리 방식을 기준으로 구분됩니다. 두 컴포넌트는 값을 어떻게 관리하고 처리하느냐에 차이가 있습니다. 이를 기준으로 글을 전개해보겠습니다.

제어 컴포넌트

제어 컴포넌트는 Single Source of Truth 원칙을 따릅니다. 즉, 컴포넌트의 상태(state)가 애플리케이션의 유일한 데이터 출처로 관리되며, 모든 데이터 변경은 리액트의 state를 통해 이루어집니다. 이렇게 상태를 하나의 출처에서 관리하면, 데이터 흐름이 명확해지고 예측 가능해지며, 그 변화를 추적하기 쉬워집니다.

function ParentComponent() {
  const [value, setValue] = useState('');

  const handleChange = (event) => {
    setValue(event.target.value);
  };

  return <input value={value} onChange={handleChange} />;
}

여기서 value는 부모 컴포넌트에서 관리되는 상태이고, input 요소는 이 값을 props로 받아서 그 값을 렌더링합니다. 상태가 변경될 때마다 ParentComponent는 리렌더링됩니다.

  • 상태는 부모 컴포넌트에서 관리하며 (상태와 이벤트 핸들러 선언이 부모 컴포넌트 내에 위치함), props를 통해 자식 컴포넌트로 전달됩니다. (예: onChange로 값을 전달하는 방식)
  • 상태 값은 부모 컴포넌트에 의해 제어되므로 부모가 컴포넌트의 값을 수정할 수 있습니다.
  • 상태 변경 시 컴포넌트가 리렌더링됩니다.

결론적으로, state를 사용해 값을 변경하는 것은 값을 제어하는 방식임을 알 수 있었습니다. 제어 컴포넌트는 선언적 프로그래밍 패러다임과 일치합니다. 이 방식에서는 무엇을(what) 해야 할지가 중요하며, 상태를 명시적으로 선언하면 UI는 그에 따라 자동으로 업데이트됩니다. 즉, 최종 결과가 어떻게 보여야 하는지를 선언하고, 상태에 따라 UI가 자동으로 변하게 됩니다.

비제어 컴포넌트

비제어 컴포넌트는 리액트의 state 대신 DOM 자체에 데이터를 저장합니다. 이러한 컴포넌트는 필요할 때만 DOM에서 값을 가져오기 위해 ref를 사용합니다.

function Panel() {
  const inputRef = useRef();

  const handleSubmit = () => {
    alert(`Input value: ${inputRef.current.value}`);
  };

  return (
    <div>
      <input ref={inputRef} />
      <button onClick={handleSubmit}>Submit</button>
    </div>
  );
}

여기서 Panel 컴포넌트는 ref를 사용하여 input 값을 관리하고 있으며, 부모는 이 상태에 접근하거나 제어할 수 없습니다.

  • DOM 값이 자식 컴포넌트에서 관리되며, 부모는 상태(state)를 제어할 수 없습니다.
  • DOM 값 변경 시 리액트의 컴포넌트의 상태 변경이 없어 자동 리렌더링이 일어나지 않습니다.

비제어 컴포넌트는 명령형 프로그래밍 패러다임에 가깝다고 생각했습니다. 어떻게(how)가 중요하며, 직접 DOM을 조작하고 필요할 때만 값을 가져오는 방식입니다. 일일이 조작하는 방식을 알려주고, 이걸 이렇게 저렇게 수동으로 조작해!라고 명령을 내리는 것과 비슷하다고 생각이 들었습니다.

실제 비교

제어 컴포넌트 코드

function ControlledComponent() {
  const [value, setValue] = useState('');

  const handleChange = (event: ChangeEvent<HTMLInputElement>): void => {
    setValue(event.target.value);
  };

  console.log('Controlled component rendered');

  return (
    <div>
      <h2>Controlled</h2>
      <input type="text" value={value} onChange={handleChange} />
    </div>
  );
}

비제어 컴포넌트 코드

function UncontrolledComponent() {
  const inputRef = useRef<HTMLInputElement>(null);

  const handleClick = () => {
    console.log('Input value (Uncontrolled):', inputRef.current?.value);
  };

  console.log('Uncontrolled component rendered');

  return (
    <div>
      <h2>Uncontrolled</h2>
      <input ref={inputRef} type="text" />
      <button onClick={handleClick}>로그 보기</button>
    </div>
  );
}

실제로 제어 컴포넌트와 비제어 컴포넌트를 비교했을 때, 렌더링 횟수 차이가 발생하는 이유는 상태 관리 방식에서 차이가 나기 때문입니다.

따라서 성능 관점에서 비제어 컴포넌트는 리렌더링을 최소화할 수 있어, 값 변경이 자주 일어나는 경우에 더 효율적일 수 있습니다. 반면에 제어 컴포넌트는 상태와 UI를 일관되게 유지할 수 있기 때문에, 값 변경에 따라 UI를 즉시 반영해야 하는 상황에서는 유리합니다.

결론적으로, 선택은 사용 사례에 따라 다릅니다. 값 변경이 자주 일어나고, 그에 따른 UI 반영이 필요 없다면 비제어 컴포넌트가 더 나을 수 있고, 상태와 UI를 엄격하게 일치시켜야 한다면 제어 컴포넌트가 더 적합합니다.

비제어 컴포넌트는 사용할 일이 없는건가요?

위의 이야기만 두고 본다면, 우리는 useState를 사용해 상태관리를 주로 하다보니, 비제어 컴포넌트를 사용할 일이 없겠는걸? 하는 생각이 자연스럽게 따라오게 됩니다.

비제어 컴포넌트는 모든 경우에 필요하지는 않지만 특정 상황에서 유용하게 사용될 수 있습니다. 주로 간단한 UI를 구현할 때나 성능 최적화가 중요한 경우에 적합하며, 상태 관리의 오버헤드를 줄이는데 도움이 됩니다.

비제어 컴포넌트를 사용하는 경우

  1. 간단한 UI 처리에서 상태를 부모와 공유하지 않고, 자식 컴포넌트에서 독립적으로 값을 관리할 때 유용합니다. 예를 들어, 폼 필드간단한 입력을 다룰 때, 부모와 상태를 동기화하는 대신 직접적인 DOM 조작을 통해 효율적으로 데이터를 처리할 수 있습니다.
  • 예를 들어, 사용자가 입력하는 동안 매번 API 호출을 하는 실시간 검색 기능을 구현하는 경우, 각 입력마다 상태를 업데이트하고 리렌더링을 발생시키지 않기 위해 비제어 컴포넌트를 사용할 수 있습니다. 상태를 업데이트하고 리렌더링하는 비용을 줄이기 위해 DOM에서 값을 직접 가져와 처리하는 방식이 유리할 수 있습니다.
  1. 성능 최적화가 필요할 때, 상태 변경에 따른 불필요한 리렌더링을 방지할 수 있습니다.
  • 예를 들어, 대량의 데이터를 처리하는 경우, 빈번한 입력 이벤트로 인한 불필요한 리렌더링이 성능에 영향을 미치는 경우, 비제어 컴포넌트를 고려할 수 있습니다. 물론 제어 컴포넌트에서도 디바운싱, 쓰로틀링, React.memo, useMemo 등의 최적화 기법을 사용하여 리렌더링을 효율적으로 관리할 수 있습니다.
  1. 외부 라이브러리제3자 DOM 조작을 사용하는 경우에도 유용합니다.
  • 예를 들어, 리액트 외의 라이브러리에서 DOM을 제어해야 하는 경우, useRef를 통해 DOM에 직접 접근하여 필요한 처리를 할 수 있습니다.

따라서, 비제어 컴포넌트는 복잡한 상태 관리가 필요 없거나, 간단한 데이터만 다루는 경우에 주로 사용됩니다.

차이점

특징 제어 컴포넌트 (Controlled) 비제어 컴포넌트 (Uncontrolled)
상태 관리 React 상태(useState)로 관리 DOM에 직접 저장 (ref)
렌더링 상태 변경 시마다 리렌더링 발생 상태 변경 시 리렌더링 없음 (DOM 값만 변경)
상태 접근 상태가 부모 컴포넌트에 의해 제어되고 접근됨 ref를 통해 자식 컴포넌트의 DOM 값에 접근 가능 (부모는 직접 제어 불가)
주요 사용 사례 - **양방향 데이터 바인딩** 필요 (예: 폼)
- **상태 추적** 필요 (예: 폼 데이터 검증, 제출 전 확인)
- 간단한 입력값 관리 (값이 바뀔 때마다 리렌더링을 피하고 싶을 때)
- 값 변경이 드물거나 UI 갱신이 중요한 요소가 아닐 때
성능 측면 - 상태 변화가 자주 일어나면 렌더링이 많아져 성능 저하 가능 - 값 변경 시 렌더링이 없어서 성능에 더 효율적
예시 - 입력 필드, 폼 검증, 드롭다운 선택값 - 단순한 입력값을 다룰 때 (예: 비밀번호 입력, 파일 업로드 등)
UI와 상태 일관성 UI와 상태가 항상 일치 (상태 변화에 따라 UI가 자동으로 업데이트됨) UI와 상태의 일관성 필요 없고, DOM에 직접 값을 저장하여 관리
상태 처리 부모가 상태를 관리하여 자식 컴포넌트에 전달하고, 자식에서 수정 시 부모가 업데이트 자식 컴포넌트가 상태를 관리하고, 부모는 직접 제어하지 않음

useRef

비제어를 공부하다 보니 ref가 궁금해졌어요.

useRef는 DOM 요소에 직접 접근하거나, 컴포넌트 인스턴스의 값을 저장할 때 사용됩니다. 이 훅은 값이 변경되어도 리렌더링을 일으키지 않는 객체를 반환합니다. 이를 통해 불필요한 리렌더링을 피할 수 있어 성능 최적화에 유리합니다.

DOM 요소에 직접 접근하기

리액트 컴포넌트 내에서 DOM 요소를 직접 조작하는 것은 일반적으로 권장되지 않지만, 특정 상황에서는 DOM에 직접 접근하는 것이 필요할 때가 있습니다. 이때 useRef를 사용하면, 리액트에서 해당 DOM 요소에 직접 접근할 수 있는 참조를 만들 수 있습니다.

예를 들어, 포커스를 설정하거나, 애니메이션을 실행하거나, 외부 라이브러리와 연동할 때 유용합니다.

import { useRef } from 'react';

function InputComponent() {
  const inputRef = useRef<HTMLInputElement>(null);

  const focusInput = () => {
    inputRef.current?.focus();
  };

  return (
    <div>
      <input ref={inputRef} type="text" placeholder="Click the button to focus me" />
      <button onClick={focusInput}>Focus the input</button>
    </div>
  );
}

위 예시에서는 inputRef를 사용하여 input 요소를 참조하고, focusInput 함수에서 inputRef.current?.focus()를 통해 해당 입력 필드에 포커스를 설정합니다. useRef를 사용하면 리액트가 관리하지 않는 DOM 요소에 직접 접근할 수 있기 때문에 성능을 최적화할 수 있고, 컴포넌트 리렌더링을 일으키지 않습니다.

렌더링 간에 값 유지하기

useRef는 렌더링 간에 값이 유지되는 객체를 생성합니다. 상태와 달리 값이 변경되어도 컴포넌트가 리렌더링되지 않기 때문에, 단순히 값을 저장하는 용도로 사용됩니다.

import { useState, useRef, useEffect } from 'react';

function PreviousValue() {
  const [count, setCount] = useState(0);
  const previousCountRef = useRef(0);

  // count 값이 변경될 때마다 이전 값을 추적
  useEffect(() => {
    previousCountRef.current = count; // 이전 값 업데이트
  }, [count]); // count가 변경될 때마다 실행

  return (
    <div>
      <h1>Current Count: {count}</h1>
      <h2>Previous Count: {previousCountRef.current}</h2>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

useRef는 previousCountRef 객체를 사용하여 이전 count 값을 저장합니다. useEffect는 count가 변경될 때마다 previousCountRef.current에 이전 값을 저장합니다. UI는 count 값만 업데이트되지만, previousCountRef.current는 리렌더링 없이 계속 추적되므로 이전 값을 렌더링할 수 있습니다.

useImperativeHandle

비슷한 역할을 하는 useImperativeHandle도 궁금해졌어요.

useImperativeHandle은 자식 컴포넌트가 부모에게 제공할 메서드나 속성을 선택적으로 노출할 수 있도록 도와주는 훅입니다. 기본적으로 ref를 사용하면 부모 컴포넌트가 자식 컴포넌트의 DOM이나 상태에 직접 접근할 수 있습니다. 하지만 이는 자식 컴포넌트의 구현에 대한 직접적인 접근을 허용하게 되어, 자식의 상태나 동작을 부모가 제어할 수 있게 됩니다.

이를 해결하기 위해 useImperativeHandle을 사용하면, 부모가 자식 컴포넌트에 접근할 수 있는 메서드나 속성을 명시적으로 제한할 수 있습니다. 이로 인해 자식 컴포넌트의 내부 구현을 보호하면서도 필요한 기능만 부모에게 제공할 수 있습니다.

useImperativeHandle 사용 이유

  • 컴포넌트의 캡슐화 유지: 부모 컴포넌트가 자식의 내부 구현을 직접 제어하는 대신, 자식은 필요한 기능만을 제공하고 그 외의 동작은 숨길 수 있습니다.
  • 상호작용을 명확하게 제한: 자식 컴포넌트에서 부모가 접근할 수 있는 메서드와 속성을 명확하게 정의함으로써, 예상치 못한 방식으로 자식 컴포넌트의 상태나 동작이 변경되는 것을 방지할 수 있습니다.
  • 비제어 컴포넌트와 결합 가능: useImperativeHandle은 비제어 컴포넌트와 잘 결합됩니다. 자식 컴포넌트가 ref를 통해 부모에게 특정 제어권만 제공할 수 있게 하여, 효율적으로 상태를 관리하면서도 DOM에 접근할 수 있는 유연성을 제공합니다.

예시

import React, { useRef, useImperativeHandle, forwardRef } from 'react';

const CustomInput = forwordRef((props, ref) => {
  const inputRef = useRef();

  useImperativeHandle(ref, () => ({   // 부모에게 노출할 메서드를 정의
    focus: () => {
      inputRef.current.focus();  // 부모가 호출할 수 있는 메서드
    },
    getValue: () => {
      return inputRef.current.value;  // 부모가 호출할 수 있는 메서드
    }
  }));

  return <input ref={inputRef} />;
});

// 부모 컴포넌트
const ParentComponent = () => {
  const inputRef = useRef();

  const handleFocus = () => { 
    inputRef.current.focus(); // 자식 컴포넌트의 focus 메서드를 호출
  };

  const handleGetValue = () => { 
    alert(inputRef.current.getValue()); // 자식 컴포넌트의 getValue 메서드를 호출
  };

  return (
    <div>
      <CustomInput ref={inputRef} />
      <button onClick={handleFocus}>Focus Input</button>
      <button onClick={handleGetValue}>Get Input Value</button>
    </div>
  );
};

export default ParentComponent;

차이점

특징useRefuseImperativeHandle
용도DOM 요소나 값에 대한 참조를 생성부모 컴포넌트가 자식 컴포넌트의 메서드나 값을 호출할 수 있게 해줌
렌더링과의 관계값이 변경되어도 리렌더링을 발생시키지 않음자식 컴포넌트가 부모에게 특정 메서드를 노출시키는 데 사용
사용 예시DOM에 직접 접근하거나 값을 저장하고 유지부모가 자식의 특정 메서드나 프로퍼티에 접근할 수 있게 함
사용 방식useRef는 단순히 참조를 반환useImperativeHandle은 자식 컴포넌트의 ref에 특정 메서드를 정의

마치며

지금까지 글을 쓰면서 비제어 컴포넌트가 언제 쓰여야 할지, 그 필요성에 대해 많은 고민을 했습니다. 아무래도 ref를 활용한 최적화 경험이 부족했기 때문일지도 모르겠습니다. 하지만, 리액트에서 제어 컴포넌트와 비제어 컴포넌트는 그 특성과 용도에 맞게 적절히 사용해야 한다는 점을 점점 더 실감하게 되었어요.

그리고 리액트가 hook에서도 명령형과 선언형 프로그래밍을 녹였다는 걸 다시 한 번 체감하면서, 리액트가 얼마나 순수함수와 선언형 프로그래밍을 지향하는지도 새삼 한 번 더 느꼈습니다.

이 글을 통해 얻은 인사이트를 바탕으로, 앞으로 실제 프로젝트에서 비제어 컴포넌트와 ref를 적절히 활용해보려 합니다. 아직 갈 길이 멀지만, 하나하나 경험을 쌓으며 계속 성장해가고 싶습니다. 🥹

끗.


참고
https://ko.react.dev/learn/sharing-state-between-components
https://ko.react.dev/reference/react-dom/components/input
https://legacy.reactjs.org/docs/forms.html
https://legacy.reactjs.org/docs/uncontrolled-components.html

profile
프론트엔드 개발자 김세빈입니다. 👩🏻‍💻

3개의 댓글

comment-user-thumbnail
2025년 4월 27일

좋은데용

답글 달기
comment-user-thumbnail
2025년 4월 28일

좋은데용

답글 달기
comment-user-thumbnail
2025년 5월 3일

좋은데용

답글 달기