React

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

GoJay 2024. 11. 25. 21:46

제어 컴포넌트가 왜 필요할까?

'상태가 변경되면 화면(UI)을 업데이트한다'는 것은 리액트의 핵심 컨셉 중 하나다.

아래는 간단한 카운터 앱의 예시다. Counter 컴포넌트에 정의된 countbutton 태그의 onClick에 등록된 이벤트 핸들러 콜백 함수 setCount의 동작에 맞게 count를 변경하면 DOM이 리렌더링된다. 당연하게도 상태의 변경도 없고, 부모로부터 전달받은 props의 변경도 없으며, 부모 컴포넌트가 리렌더링 되지 않은 App 컴포넌트는 리렌더링 대상이 아니다. 따라서 App 컴포넌트의 h1 태그는 리렌더링되지 않는다.

import { useState } from 'react';
import './App.css';

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div id='counter'>
      <p>{count}</p>
      <div id='button-container'>
        <button onClick={() => setCount(count - 1)}>-</button>
        <button onClick={() => setCount(count + 1)}>+</button>
      </div>
    </div>
  );
}

function App() {
  return (
    <>
      <h1>Counter App</h1>
      <Counter />
    </>
  );
}

export default App;

count라는 상태가 setCount 호출로 변경되자 Counter 컴포넌트가 리렌더링된다. 이것이 리액트가 개발자로 하여금 복잡한 모던 웹 애플리케이션의 화면을 손쉽게 업데이트하고 처리할 수 있도록 해준 '선언형 UI 처리 방식'이다.

이러한 처리를 위해 리액트는 관리되어야 하는 상태(State)와, 해당 상태가 변경되었을 때 처리되어야 할 로직을 계속 추적하고 있다. useStateuseReducer 같은 훅(Hook)으로 상태를 정의하는 것은 어쩌면 리액트에게 '앞으로 이 상태를 관리해갈거야! 이 상태가 변경되면 화면을 업데이트해 줘!'라고 등록하는 과정이고, setStatedispatch에 등록된 함수는 '저번에 등록한 상태가 변경됐어! 변경된 상태 정보 줄테니 확인해서 화면 다시 그려줘'라고 요청하는 게 아닐까?(정확한 건 아니고 추론이지만, 대략 이런 목적을 가지고 동작할 것 같다).

그리고, 리액트는 DOM의 직접 수정을 지양하려는 컨셉을 가지고 있다. DOM을 교체하는 건 매우 비싼 작업이기 때문에, 리액트에서의 DOM 수정은 먼저 메모리 상에 존재하는 가상의 Virtual DOM을 업데이트하고, 이전-이후 Virtual DOM Tree의 상태를 비교한 후, 변경이 있는 곳을 탐색해 해당 변경이 있는 부분만 실제 DOM 트리에 반영하는 식으로 이루어진다. 그렇기 때문에, 만약에 사용자가 Virtual DOM을 거치지 않고 실제 DOM에 직접 접근해 값을 수정한다면 UI 상에 표시되는 내용과 리액트가 관리하고 있는 상태가 분리될 수 있다.

import { useRef, useState } from 'react';
import './App.css';

function App() {
  const ref = useRef();
  const [text, setText] = useState('');

  return (
    <>
      <input
        type='text'
        ref={ref} // useRef로 생성한 ref 객체로 DOM 요소 저장
        onBlur={() => (ref.current.value = 'focus가 input 창 밖으로 이동했습니다.')} // input 요소 밖으로 포커스가 이동하면 실행
        style={{ width: '300px' }}
        value={text} // state input 창에 기본값으로 표시
        onChange={(e) => {
          setText(e.target.value);
          console.log(text);
        }} // 사용자 입력 들어오면 해당 값으로 상태 최신화
      />
    </>
  );
}

export default App;

콘솔 창에 출력과 실제 input 창의 텍스트에 차이가 생겼다.

리액트는 '상태로 UI를 표현한다'라는 컨셉을 갖고 있기 때문에, 화면에 표시된 상태와 리액트에서 관리하고 있는 상태의 동기화가 깨진 것은 큰 문제가 된다. 물론 위 예제는 억지로 만들어낸 예제라 부자연스러운 면이 있지만, 아무튼 중요한 것은 사용자가 리액트로 개발하면서 직접 DOM의 요소를 제어하려고 하면 리액트에서 관리하는 상태와 현재 UI에 보이는 화면 사이의 동기화가 깨질 위험이 있다는 것이다.

리액트를 만든 똑똑한 분들은 이런 우려 사항들을 진작 인지하고 있었고, 사용자 입력으로 인해 전달되는 상태를 언제나 리액트가 관리하는 상태로 동기화하기 위한 방법 중 하나로 '제어 컴포넌트(Controlled Componenet)'라는 컨셉을 만들었다.

제어 컴포넌트란?

'제어 컴포넌트'에 대한 리액트 공식 문서(기존 버전)의 설명은 다음과 같다.

폼을 렌더링하는 React 컴포넌트는 폼에 발생하는 사용자 입력값을 제어합니다. 이러한 방식으로 React에 의해 값이 제어되는 입력 폼 엘리먼트를 “제어 컴포넌트 (controlled component)“라고 합니다.

먼저, 제어 컴포넌트의 개념에 '폼(Form)'이라는 개념이 나왔다. 폼은 무엇을 얘기하는 것일까?

웹 애플리케이션이 고도화되면서 점차 HTML과 CSS로 작성된 문서를 사용자에게 보여주는 정도가 아니라, 사용자와의 다양한 인터랙션이 기능으로 추가되어 갔다. 모던 웹은 점점 더 사용자의 많은 입력이 허용되고 있으며, 그중에서도 단순 행동 기반 이벤트가 아니라 사용자에게 어떠한 입력 값을 받을 수도 있게 됐다. 대표적으로 회원 가입 페이지가 있다. 회원 가입 페이지를 보면 유저에게 '어떠한 아이디와 비밀번호를 설정하고 싶니?'라고 물어보고, 사용자가 직접 입력한 값으로 회원가입이 이뤄질 수 있게 처리해 준다.

회원 가입 창에서 입력해야 하는 모든 요소가 Form으로 구현 가능한 것들이다

이렇게 폼은 사용자의 입력을 받고, 사용자 입력으로 들어온 데이터들을 다루는 데 사용되는 다양한 HTML 태그들을 의미하며, 대표적으로 아래와 같은 것들이 있다.

  • <form>: 하위 요소들이 폼 태그임을 나타냄
  • <input>: 사용자 입력을 받을 수 있는 Input 창. text, checkbox, date, file 등 타입 설정에 따라 다양한 형식의 입력을 받을 수 있다.
  • <select><option>: 드롭다운 형식으로 선택지 내에서 선택을 받을 수 있다.
  • <textbox>: input보다 조금 더 긴 글을 입력받을 수 있는 창.

이외에도 다양한 폼 태그들과 여러 유용한 속성들이 엄청나게 많이 존재한다. 하지만, 포스트의 주제에서 살짝 벗어나는 관계로 더 자세한 내용은 일단 생략하겠다.

다시 리액트 공식 문서에 나온 제어 컴포넌트의 정의를 살펴보면, '폼에 발생하는 사용자 입력값을 제어합니다'라는 내용이 있다. 그 의미를 생각해 보면, 사용자가 폼 태그로 만들어진 창에 어떠한 값을 입력하고 해당 값을 제출(Submit)하면, 뒷단에서 처리되는 시점에서 그 값을 관리하는 주체가 리액트가 된다는 뜻일 것이다.

이는 개발자가 useState를 이용해서 count라는 변수를 생성했을 때 '앞으로 이 count라는 값의 변경 여부 파악이랑 리렌더링은 리액트 네가 다 알아서 해줘'라고 하는 것과 유사하지 않나 싶다. 만약 제어 컴포넌트인 input 요소에 어떠한 입력이 들어오면 리액트는 곧바로 '입력창에 새로운 상태가 들어왔다 상태를 업데이트하고 값을 업데이트하자'라고 알 수 있다(또는, 리액트가 그렇게 동작할 수 있도록 어떠한 조치를 요구한다).

value와 입력 값-상태 동기와

기본적으로 리액트에 있는 다양한 폼 요소는 디폴트가 비제어 컴포넌트(Uncontrolled Componenet)이며, 제어 컴포넌트가 되는 조건은 폼 태그의 value 속성에 값을 지정하는 것이다. valueinput 창 등의 초기값, 특정한 입력이 없을 때 갖게 되는 값을 의미하며, 아래와 같이 사용한다.

import './App.css';

function App() {
  return (
    <form>
      <input value={'input1'} />
      <textarea value={'textarea1'} />
      <select value={'orange'}>
        <option>사과</option>
        <option>바나나</option>
        <option>오렌지</option>
      </select>
    </form>
  );
}

export default App;

input 창에 value에 전달한 값이 잘 들어가있다.

이렇게, value 속성을 입력하면 폼 태그들은 제어 컴포넌트가 된다. 이제 해당 폼 태그 값들이 들어오면 리액트는 해당 값들을 제어하려고 할 것이다.

하지만, 아직은 리액트에서 특정 훅에 등록된 변수가 아닌데 인풋으로 입력을 받았다고 해서 바로 상태로 처리해버리진 않는다(바로 리렌더링 시키진 않는다). 대신, 리액트에선 value 속성의 값을 useState로 생성한 상태(state)로 할 것을 권한다.

추가적으로, 제어 컴포넌트에 value라는 속성만 작성하면 사용자가 아무리 인풋창을 통해 상태를 바꾸려고 해도 화면이 그대로이다. 다시 화면을 그릴 때마다 input value={value}에 넣어둔 최초의 value 값이 계속 새로 초기화되기 때문에, 사용자가 보기엔 처음 값이 바뀌지 않고 계속 화면에 남아있는 것처럼 보이기 때문이다.

따라서, 제어 컴포넌트에 value 속성을 넣어줬다면 함께 onChange 이벤트 핸들러를 달아주고, 이벤트 핸들러엔 value에 전달한 state를 input 창에 추가된 값으로 변경해 주는 setState 함수를 설정해줘야 한다. 그래야 사용자가 입력 액션을 할 때마다 초기값 value의 상태를 onChange 핸들러 내부에 있는 setState 함수로 업데이트하고, 화면을 리렌더링 할 수 있다(사용자 입력에 따라 입력값 '상태'가 변하면 화면이 '리렌더링' 된다. 리액트의 철학에 정말 잘 어울리는 작동 방식이다).

import './App.css';
import { useState } from 'react';

function App() {
  const [text, setText] = useState('');

  const handleChange = (e) => {
    setText(e.target.value);
  };

  return (
    <form>
      <input type={text} value={text} onChange={handleChange} />
    </form>
  );
}

export default App;

정리하면, 폼 태그는 UI와 리액트가 관리하는 상태의 괴리를 만들 여지가 있으며, 그렇기 때문에 리액트의 여러 문서를 보면 폼 태그를 제어 컴포넌트(value에 상태, onChange에 상태 업데이트 함수를 추가)로 만들길 권하고 있다. 하지만, 모든 경우에 input으로 들어온 값을 상태로 관리해야만 하는 건 아니고, 무엇보다도 onChange는 너무 빈번히 발생하는 이벤트다. 만약에 블로그 같은 서비스라고 했을 때 하나의 글을 적는 데에만 많게는 수백 수천 번의 onChange 이벤트들이 발생하고, 화면은 쉬지 않고 리렌더링 될 거다.

컴포넌트의 구조를 잘 설계해서 폼 요소의 onChange 이벤트에서 정확하게 그 폼 요소만 바뀌게 된다면 덜 우려되지만(그렇더라도, 해당 컴포넌트의 로직이 크고 복잡하면 문제가 커질 수 있다), 복잡한 컴포넌트 구조로 얽혀 있다면 발생 빈도가 높은 onChange 이벤트로 이해 서비스 전체의 성능이 눈에 띄게 저하될 수 있다. 이런 점을 고려해서, 제어 컴포넌트를 쓸지에 대해선 고민이 어느 정도 필요한 것 같다(누군가의 사견일 순 있긴 하지만, 어떤 블로그에선 아예 제어 컴포넌트를 사용하지 말라고 다소 급진적인? 의견을 써둔 곳도 있었다).

defaultValue, formData, readOnly, ref

리액트의 공식 입장은 제어 컴포넌트 사용을 권하고 있지만, 경우에 따라서는 비제어 컴포넌트로 폼 태그를 관리하고 싶을 수 있다. 그럴 땐 아래 내용들을 참고해 두면 좋을 것 같다.

defaultValue

만약 value 속성을 사용하려는 이유가 단지 초기값을 세팅하기 위한 것이라면 defaultValue를 사용할 수 있다. defaultValue는 표현 그대로 폼 요소에 처음 들어가 있어야 하는 값을 나타낸다. 만약에 input type='checkbox'라면 checked의 여부를 원하는 방식대로 초기화할 수 있다.

function App() {
  return (
    <form>
      <input type={text} defaultValue={'Default Value'} /> // defaultValue기 때문에 굳이 상태로 초기값을 정하고 onChange로 업데이트 할 필요 없음
    </form>
  );
}

defaultValue를 사용한 태그(컴포넌트)는 비제어 컴포넌트가 된다. 때문에, 굳이 onChange 이벤트 핸들러를 달아줄 필요 없이 원하는 형태로 이후 작업을 제어하면 된다.

formData

만약에 input 태그로 들어온 입력 값을 실제로 서비스 내에서 상태로 지속 관리해야 한다면 제어 컴포넌트를 적극 활용하는 게 좋을 수 있다(입력 데이터를 상태로 좀 더 손쉽게 관리할 수 있다). 하지만, 값을 지속적으로 받으면서 상태 변화를 많이 가져가는 게 아니라면 적은 빈도의 작업을 위해 useState를 불러오고, 상태를 정의하고, 핸들러 함수를 작성해서 붙이고, 변경 시 리렌더링이 되도록 하는 게 아까울 수 있다.

그럴 땐 입력 이벤트에 대한 FormData 객체를 적절하게 사용하면 데이터에 쉽게 접근 및 가공할 수 있다.

formData에 대해서는 해당 포스트에 좀 더 자세히 남겨놓았기 때문에 상세 설명은 생략한다.

readOnly

(많이 있는 경우는 아니지만) 입력 창에 입력을 아예 받지 않고 값을 보여주기만 하는 readOnly 창으로 사용하는 경우가 간혹 있다. 예를 들어, 블로그의 포스트 기능에서 포스트 내용을 작성하는 화면과 조회하는 화면이 거의 유사한 경우가 있을 수 있다. 그럴 경우, 동일한 컴포넌트를 사용해 UI를 구현하고, 대신 조회만 필요한 경우를 대비에 조건부로 readOnly로 넣어줄 수 있다.

readOnly는 아래와 같이 속성만 지정해 줘도 괜찮고

return (
  <form>
      <input type={text} defaultValue={'Default Value'} readOnly />
  </form>
);

아니면 ture, false 값을 지정해 줄 수도 있다. 아래와 같이 조건부로 read를 지정하는 것도 물론 가능하다.

return (
  <form>
      <input type={text} defaultValue={'Default Value'} readOnly={condition ? true : false} />
  </form>
);

ref

ref는 모든 컴포넌트와 태그에 붙일 수 있는 특별한 prop이다. ref는 리액트 컴포넌트에서 DOM을 직접 제어할 수 있도록 해준다. 사용 방법은 먼저 useRef로 컴포넌트 생명 주기에 영향을 받지 않는 변수를 하나 만들어주고, ref로 저장할 HTML 태그를 설정한다(꼭 제어 컴포넌트로 동작할 수 있는 폼 태그여야 하는 게 아니라, 모든 태그에서 사용 가능하다).

import { useRef } from 'react';

...

function App() {
  const inputRef = useRef()

  return (
      <div>
      <input ref={inputRef} /> 
    </div>
  )
}

위와 같이 inputRefref 속성의 값으로 전달하면 inputRef.current에는 input 태그가 등록된다(자세한 동작에 대해 일단 생략하겠다).

해당 값을 사용하면 원하는 이벤트를 손쉽게 캐치하는 것도 가능하며, 또는 input에 입력된 입력값을 상태가 아니라 자신이 원하는 형식의 식별자로도 받을 수 있게 된다.

import './App.css';
import { useRef } from 'react';

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

  const handleClick = (e) => {
    e.preventDefault();
    inputRef.current.value -= 100;
  };

  return (
    <form>
      <div>
        <input type='text' ref={inputRef} />
        <button onClick={handleClick}>-100</button>
      </div>
    </form>
  );
}

export default App;

위의 예시에서 inputRef.current<input type='text' ref={inputRef} /> 태그를 가리키며, inputRef.current.value -= 100을 해줘서 버튼을 클릭할 때마다 input에 적어둔 숫자에 100을 빼도록 해줬다. 위의 예시는 적상적으로 작동하지만, 리액트가 관리하는 상태와 UI의 연결이 끊어졌기 때문에(상태 변경 없이도 UI를 변경할 수 있기 때문에) 리액트스럽지 못한 방식이다.

리액트로 프로젝트를 진행 중이라면 가급적 리액트에서 권장하는 방식을 따라는 게 좋고, ref prop의 사용을 줄이는 게 좋다고 한다. 하지만 종종 포커스 제어, Disabled 처리 등을 할 때 활용이 될 수는 있으니, 기억은 꼭 해두자.

결론

제어 컴포넌트에 대해 알아봤다. 시작은 input에 초기값을 value로 넣고 작업을 하는데, 이상하게 input창에 텍스트 작성이 안 되는 거였다. 왜 그러는지를 찾아보니 value에 대응되는 onChange 핸들러를 누락해서 그랬고, 해당 내용은 리액트 공식 문서의 에도 나와있다.

힘든 걸 알지만, 그래도 앞으로 공식 문서 조금만 꼼꼼히 읽고, 다양한 케이스 잘 겪어보면서 빠르게 성장하자.

'React' 카테고리의 다른 글

useState 상태 업데이트와 useEffect 실행 시점  (2) 2024.12.06
BrowserRouter와 createBrowserRouter  (1) 2024.12.03
JSX란?  (3) 2024.11.21
리액트 Virtual DOM(가상 돔)  (0) 2024.11.20
createRoot와 createRoot.render  (0) 2024.11.18