React Uncontrolled & Controlled Component, 프로처럼 사용하기
2022-12-19 14:14
React Uncontrolled & Controlled Component에 대해서 제대로 알아봅시다. 실제 예시도 살펴보면서 이제 진짜 프로처럼 React Component를 사용해보세요.
개요
보통 서비스를 개발하다보면 input을 활용한 UI를 구현하게 된다. 간단하게는 계정과 비밀번호를 입력하는 로그인 페이지부터 회원가입이나 서비스를 신청하는 등록 페이지 등, input을 사용하는 페이지는 무궁무진하다. 이런 페이지에서 요구하는 기능은 단순히 값 만을 입력하는게 전부가 아니기 때문에, 우리는 당연히 벨리데이션이나 액션 제어, 에러 메시지 등 복잡한 기능을 구현해야 한다.
Uncontrolled, Controlled Component의 기본적인 개념을 살펴보면서 앞에서 이야기한 기능이 이들과 어떤 관계가 있고, 또 어떻게 활용해야 하는지 알아보자. 이 글에서 이야기하는 내용을 잘 따라가다보면 우리가 실제로 만나게 되는 다양한 상태 문제를 어떻게 해결할지 본인만의 아이디어를 떠올릴 수 있게 될 것이다.
Uncontrolled Component
우리는 Uncontrolled, 즉 관리하지 않는다는 의미를, Input 컴포넌트를 React 관점에서 관리하지 않겠다는 의미로 이해해도 좋다. 이 관점에서 Uncontrolled를 한마디로 표현하자면, state를 사용하지 않는 방식이라고 말할 수 있다.
state를 사용하지 않겠다는 의미는 곧 input에 사용자 입력 이벤트가 발생해도 그 값을 React 시스템 안에 저장하지 않겠다는 것이기 때문에 Input의 변경을 추적하지 않는다. 따라서, 값이 변경되어도 렌더링이 트리거되지 않는다는 특징이 있다.
Input 초기 값이 존재한다면, defaultValue
를 활용해서 초기 값을 설정할 수 있다. 초기 값 설정 시 value
를 설정해버리면 값이 고정되어 버리기 때문에 주의한다. value
를 설정할 때는 반드시 이벤트 핸들러를 통해서 다시 value
를 업데이트할 수 있는 방법을 제공해야 한다. (이 부분은 아래 쪽에서 더 살펴보자)
function UncontrolledInput({ initialValue }) {
return <input type="text" defaultValue={initialValue} />
}
Uncontrolled Component 값 참조하기
Uncontrolled Input을 사용하는 경우, 사용자의 입력 값을 React 시스템 안에 보관하지 않기 때문에 우리는 직접 DOM으로부터 값을 조회해야 한다. React에서 직접 DOM을 조회하기 위한 방법으로 보통 Ref
를 사용한다. 만약에 사용하고자 하는 Input이 컴포넌트로 구현되어 있다면 forwardRef
를 사용할 수 있다.
Input에 연결된 Ref
는 실제 DOM 노드이기 때문에 value를 이용해서 값을 조회하거나 혹은 원하는 값을 설정할 수 있다. 다만, Ref
는 Reactivity가 없기 때문에 값을 변경하더라도 컴포넌트의 렌더링을 트리거하지 않는다는 점을 기억해야 한다.
const ForwaredInput = forwardRef(({ initialValue, ref }) => {
return <input type="text" defaultValue={initialValue} ref={ref} />
});
function App({ initialValue }) {
const inputRef = useRef(null);
const forwardedRef= useRef(null);
// Input의 값을 임의의 값으로 변경한다.
const handleConfirm = () => {
if (inputRef.current) {
inputRef.current.value = '임의의 값';
}
if (forwardedRef.current) {
forwardedRef.current.value = '임의의 값';
}
};
// 값을 조회한다.
const handleConfirm = () => {
console.log(inputRef.current?.value);
console.log(forwardedRef.current?.value);
};
return (
<>
<input type="text" defaultValue={initialValue} ref={inputRef} />
<ForwaredInput defaultValue={initialValue} ref={inputRef} />
<button type="button" onClick={handleModify}>값 변경하기</button>
<button type="button" onClick={handleConfirm}>값 확인하기</button>
</>
);
}
Controlled Component
Controlled Component에서는 Input의 값을 state를 이용해서 동기화한다. 사용자 입력을 React 시스템에서 관리하겠다는 의미로, 사용자 입력 시 렌더링을 트리거하는 특징이 있다.
state를 이용해서 value
를 제어하기 때문에 항상 onChange와 같은 이벤트 핸들러를 통해서 state를 업데이트해주어야 한다. 초기 값의 경우 state의 초기 값을 넣어주는 것으로 쉽게 설정할 수 있다.
function ControlledInput({ initialValue }) {
const [value, setValue] = useState(initialValue);
const handleChange = (e) => {
setValue(e.target?.value)
}
return <input type="text" value={value} onChange={handleChange} />
}
Controlled Component 값 사용하기
Controlled의 경우 이미 Input의 값이 state와 동기화되어 있기 때문에 그 값을 state에서 직관적으로 조회해서 사용할 수 있다. 값의 변경 역시 state 변경을 통해 쉽게 처리할 수 있다. state가 변경되는 경우, 리렌더링이 트리거되며 설정한 값이 Input에 반영된다.
function App({ initialValue }) {
const [value, setValue] = useState(initialValue);
const handleChange = (e) => {
setValue(e.target?.value)
}
// Input의 값을 임의의 값으로 변경한다.
const handleConfirm = () => {
setValue('임의의 값');
};
// 값을 조회한다.
const handleConfirm = () => {
console.log(value);
};
return (
<>
<input type="text" value={value} onChange={handleChange} />
<button type="button" onClick={handleModify}>값 변경하기</button>
<button type="button" onClick={handleConfirm}>값 확인하기</button>
</>
);
}
왜 구분해야 할까?
Uncontrolled와 Controlled의 가장 직접적인 차이는 state 사용하느냐에 있다. state와 동기화한다는 것은 우리가 Input을 구현할 때 2가지 큰 차이를 가져온다. 첫번째는 Input에 설정된 값을 사용하는 시점이고, 두번째는 리렌더링의 트리거 유무이다. 이 차이는 기본적으로 컴포넌트 구현 방향에 영향을 미치고, 더 복잡한 컴포넌트를 만들 때 중요한 기준이 된다.
Controlled처럼 state와 Input을 동기화하면, 우리는 state 값으로 쉽게 Input의 값을 참조할 수 있다. state는 React 시스템 안에서 렌더링과 함께 유지되는 값이기 때문에 어떤 시점에서도 현재 Input의 값을 사용할 수 있다는 장점이 있다. 우리는 이 값을 컴포넌트, useEffect
내부, 이벤트 핸들러 내부 등 어느 곳에서 사용하더라도 현재 설정된 Input의 value
와 동일하는 것을 보장 받을 수 있다.
Uncontrolled처럼 Ref
를 통해 DOM 노드를 참조하는 경우, React 시스템과 Input의 value
는 분리되어 있기 때문에 일부 시점에서 value
가 최신 값이라고 보장할 수 없다. 또한, state처럼 리렌더링을 트리거하지 않기 때문에 input의 값이 변경되었을 때 즉각적으로 다른 컴포넌트의 상태를 변경하고 싶을 경우 적절히 대응할 수 없다. (ex. input 값의 벨리데이션에 실패했을 경우 버튼을 disable 처리하기)
Controlled의 지나칠 수 없는 문제
언뜻보면 state와 동기화하는 Controlled가 훨씬 더 효과적인 구현 방식 보인다. value
를 쉽게 조회할 수 있고, 또한 렌더링에 따른 시점의 영향도 고려할 필요 없기 때문이다. 그러나 우리가 놓쳐서는 안되는 중요한 문제가 남아 있다. 바로 잦은 리렌더링에 따른 성능 이슈이다.
리렌더링에 따른 성능 이슈
우리는 항상 간단한 Input만을 구현하는 것이 아니다. Input에 복잡한 벨리데이션이 추가되기도 하고 Input과 함께 다양한 UI를 함께 표시하기도 한다. 이 경우 리렌더링에 필요한 비용이 늘어난다는 의미이다. 하지만 다행히도 대부분의 경우 이 비용은 무시할 수 있을 정도로 작다. 그렇다면 우리는 언제 성능 이슈를 경험하게 되는지 알아보자.
Change 이벤트
사용자의 입력을 통해 트리거되는 이벤트는 타입에 따라 빠른 속도로 연속하여 실행되는 경우가 있다. 대표적으로 scroll
, resize
, change
와 같은 이벤트가 있으며 React는 이런 이벤트 처리에 문제가 없도록 충분히 좋은 성능을 보장한다. 그러나 복잡한 컴포넌트와 레이아웃 등 리렌더링에 필요한 비용이 커지게 되면 잦은 이벤트 트리거는 사용자 입력에 영향을 줄만큼 성능 이슈를 일으킨다.
간단하지만 효과적인 대안
잦은 이벤트 트리거로 인한 성능 이슈는 대부분은 간단한 테크닉을 통해 극복할 수 있다. 물론 복잡한 코드 전개와 컴포넌트 구조를 가진 경우에는 이보다 더 과감한 방법으로 대응해야 한다. 그럼 우선 Controlled의 성능 이슈를 개선하기 위한 가장 낮은 수준의 대응 방법을 알아보자.
debounce 적용
잦은 이벤트 트리거에 대한 가장 원초적이고 강력한 대응은 이벤트 트리거를 인위적으로 조정해주는 방법이다. 일반적인 경우, 사용자 입력마다 트리거되는 이벤트에 맞춰 핸들러를 실행하는 것과 달리 일정 시간(time window)안에 발생하는 이벤트를 모아서 한번만 실행하는 debounce를 적용하는 것이다.
debounce는 이전 이벤트 트리거 후 일정 시간 안에 다시 트리거되지 않으면 핸들러를 실행하지 않는데, 설정한 시간 간격에 따라 사용자는 딜레이를 느낄 수도 있다. 경험적으로 change
이벤트의 경우 100ms ~ 150ms 정도가 사용자 경험의 저하를 최소화하면서 성능을 개선할 수 있는 효과적인 시간이다.
- debounce 구현은
setTimeout
을 이용하여 쉽게 구현 가능하며,lodash-es
를 사용할 수도 있다. - 아래 예시에서는 하나의 state만 관리하고 있으나, 다수의 props와 state로 인해 리렌더링 트리거가 많을 때는
useCallback
으로 핸들러를 저장해주는 것을 고려해볼 수 있다.
import debounce from 'lodash-es/debounce';
function App({ initialValue }) {
const [value, setValue] = useState(initialValue);
const handleChange = debounce((e) => {
setValue(e.target?.value)
}, 150);
return <input type="text" value={value} onChange={handleChange} />;
}
memoization 적용
트리거 횟수를 제어하는 것만으로 충분하지 않다면, 리렌더링 과정을 개선하는 방법도 있다. 보통의 경우, 멀티 컴포넌트로 구현된 구조에서 효과적인 방법으로, 변수나 함수, 컴포넌트를 memoization하여 리렌더링의 영향을 최소화하는 것이다. 그러나, memoization은 이전 값과 현재 값의 차이를 비교하기 때문에 계산 비용이 추가되는 trade-off가 있다. 따라서 이 방식은 성능을 다소 향상시켜주는 것은 사실이지만 그 효과의 한계가 존재하는 방법이다.
- 이벤트에 의해 state가 갱신되었을 때, 하위 컴포넌트에 전달되는 변수나 함수를
useMemo
나useCallback
를 사용하여 메모리 참조를 유지한다. - 컴포넌트에
React.memo
를 사용하여 전달되는 props에 변경이 없다면 리렌더링이 전파되지 않도록 처리한다.
function Button({ disabled = false, onClick, children }) {
return (
<button type="button" disabled={disabled} onClick={onClick}>
{children}
</button>
);
}
const MemoButton = React.memo(Button);
// -----------
function App({ initialValue }) {
const [value, setValue] = useState(initialValue);
const isDisabled = useMemo(() => {
return !value;
}, [value]);
const handleClick = useCallback(() => {
console.log('버튼을 클릭합니다');
}, []);
const handleChange = (e) => {
setValue(e.target?.value)
};
return (
<>
<input type="text" value={value} onChange={handleChange} />
<MemoButton disabled={isDisabled} onClick={handleClick}>
버튼
</MemoButton>
</>
);
}
아직 끝나지 않은 이야기
대부분의 경우, 앞서 살펴본 간단한 성능 튜닝만으로도 충분한 결과를 낼 수 있다. 그러나, 컴포넌트의 복잡도가 올라가고 더 많은 컴포넌트가 사용될 수록 리렌더링의 비용은 크게 올라가기 때문에 결국엔 이 방법만으로는 대응이 어렵다. 이를 위해 우리는 조금 더 추상화된 Form과 Input의 상태 관리 방안이 필요하며, 대표적인 예가 react-hook-form
(https://react-hook-form.com) 이라고 할 수 있다. 아래는 일반적인 Form 사용 시 흔히 발생하는 성능 및 구현 이슈이다.
- Form 페이지에 사용되는 컴포넌트가 많아질 수록 Input 성능에 지연이 발생한다.
- Input의 개수가 많아지면서 Input 성능 이슈가 발생한다.
- 일원화된 상태 관리를 위해서 Form 페이지부터 하위 Input 컴포넌트까지 3-4 단계의 prop-drilling이 필요하다.
- 복잡한 Form 페이지에서 하나의 Input 상태가 변경되면 다른 컴포넌트의 상태가 변경되어야 하거나 벨리데이션이 실행되어야 한다.
이제 다음 시리즈에서는 react-hook-form
과 같은 라이브러리의 기본적인 구현 전략을 확인해보고, 이를 기반으로 확장성 높은 form을 구현하는 방법을 알아보겠다. 모든 상태 관리가 React 시스템 안에서 동작해야만 하는 것은 아니라는 것을 이해하면 더 좋은 컴포넌트를 설계할 수 있을 것이다.