최근 radix ui와 그 코드를 살펴볼 일이 생겨, 코드와 문서를 읽어보면서 제어 / 비제어 로직이 한 곳에 존재하는 혼합 패턴(controllable)을 접하게 되었다. 이를 사용해 본 감상과 혼합 패턴에 대해 든 생각에 대해 정리해 본다.
우리가 제어 / 비제어 컴포넌트의 개념을 접하게 되는 계기는 보통 useRef 혹은 form을 논하게 될 때이다.
간단하게 표현해보자면
// controlled
function ControlledComponent() {
const [value, setValue] = useState<string>('');
return (
<>
<input value={value} onChange={(e)=>setValue(e.target.value)}/>
<button
onClick={()=>{alert(`input value is ${value}`)}}
>click</button>
</>
)
}
// uncontrolled
function UncontrolledComponent() {
// use ref
const valueRef = useRef<HTMLInputElement | null>(null);
return (
<>
<input ref={valueRef}/>
<button
onClick={()=>alert(`input value is ${valueRef.current.value}`)}
>click</button>
</>
)
// use form
const handleOnSubmit = (e) => {
e.preventDefault();
const form = new FormData(e.currentTarget)
alert(`input value is ${form.get('name')}`)
}
return (
<form onSubmit={handleOnSubmit}>
<input name="name"/>
<button type="submit">click</button>
</form>
)
}
이런식으로 표현할 수 있다. 보통 state를 통해서 컴포넌트의 값이나 ui를 지속적으로 제어할 수 있는지 여부에 따라 제어형인지 비제어형인지가 구분된다. 제어 / 비제어 컴포넌트의 개념을 처음 접하게 되면 기본적으로 state를 선언해서 사용하는지 여부에 따라 구분하게된다.
하지만 제어 / 비제어 컴포넌트는 state의 사용 여부에 따라서만 구분되지는 않는다. 본질적으로는 해당 컴포넌트의 제어권을 가지느냐 못가지느냐가 핵심이기 때문이다.
따라서 위 제어 컴포넌트 예시가 확실해지려면 다음과 같아야 한다.
function Parent() {
const [inputValue, setInputValue] = useState<string>('');
const handleChange = (v: string) => {
setInputValue(v)
}
const handleClick = () => {
alert(`input value is ${value}`)
}
return <ControlledComponent
value={inputValue}
handleChange={handleChange}
handleClick={handleClick}
/>
}
function ControlledComponent({value, handleChange, handleClick}:{
value: string;
handleChange: (v: string) => void;
handleClick: () => void;
}) {
return (
<>
<input
value={value}
onChange={(e)=>handleChange(e.target.value)}
/>
<button
onClick={handleClick}
>click</button>
</>
)
}
여기서 표현하는 혼합패턴은 다음과 같이 하나의 컴포넌트가 조건에따라 제어 / 비제어 컴포넌트의 역할을 하는 방식을 말한다.
function HybridComponent({value, onChange, defaultValue = '', ...props}: Omit<ComponentProps<'input'>, 'value' | 'onChange'> & {
value?: string;
onChange?: (v: string) => void;
}) {
const [innerValue, setInnerValue] = useState<string>(defaultValue);
const isControlled = value !== undefined;
const currentValue = isControlled ? value : innerValue;
const handleChange = (v: string) => {
if(!isControlled) setInnerValue(v);
onChange?.(v)
}
return (
<>
<input
value={currentValue}
onChange={(e)=>handleChange(e.target.value)}
{...props}
/>
<button
onClick={()=>alert(`input value is ${currentValue}`)}
>click</button>
</>
)
}
radix의 경우 useControllableState라는 hook을 통해서 지원하는데 hook이라는 점과 세부적인 로직을 빼면 위의 코드와 비슷한 모습을 가진다.
이러한 특징이 다음과 같은 상황에서 혼합 패턴의 장점을 부곽시킨다.
단순 입력 컴포넌트
<Input>, <Checkbox>, <Switch> 등 단일 값만 관리하는 UI요소공용 디자인 시스템 컴포넌트
UI 라이브러리 구현 레벨
이러한 장점들 때문에 처음 혼합 패턴 방식의 컴포넌트를 접했을 때 여기저기 사용하게 되지만, 이 방식이 마냥 좋기만 한걸까 라는 생각이들어 고려해야 할 점이 있는지 찾아보게 되었다.
가장 먼저 생각하게 되는 부분은 크게 두 가지다.
1. 부모가 제어하는 경우 state 등의 훅을 더 사용하는 만큼 메모리 사용량이 높은가
2. 유지보수가 불편한가
state의 관점에서 메모리 사용량을 살펴보면
혼합 패턴은 기본적으로 일반적인 제어 컴포넌트보다 상대적으로 메모리 사용량이 높다.
컴포넌트가 마운트될 때 추가적인 state를 선언하기 때문이다. 이는 메모리 사용량 측면에서 불리할 수 밖에 없다.
state는 fiber node의 memoizedState속성에 저장된다.
이 값은 각 hook이 연결된 단일 연결 리스트의 형태로 관리된다.
FiberNode = {
/...
memoizedState: {
// hook #1
memoizedState: 'parent', // parent state
queue: {
pending: null
},
next: {
//hook #2
memoizedState: "inner", // inner state
queue: {
pending: null
},
next: null
}
}
}
혼합 패턴 컴포넌트는 위와 같이 항상 inner라는 추가적인 상태를 저장한다. 이론적으로 일반적인 제어 컴포넌트보다 더 많은 메모리를 소비하는 것이다.
하지만 그렇다고 해서 실제 메모리 사용량이 두배가 되는 것은 아니다.
그 이유는 다음과 같다.
hook #2로 선언된 inner state는 대부분의 경우 저장하는 값이 작다.inner state는 업데이트가 진행되지 않는다.즉, 추가적으로 state객체가 더 존재하더라도 그 값의 크기나 변경 빈도가 성능에 실질적인 영향을 준다고 보기 어렵다.
또한 radix와 같은 라이브러리에서 혼합 패턴을 훅을 제공하는 것을 보면 inner state가 소비하는 메모리 크기가 렌더링을 방해한다고 보기 어렵다. 실제로 영향을 준다면 이미 해당 이슈로 상당히 시끄러웠을 것이다.
결론적으로 메모리 사용량이 증가하는 것은 사실이지만 그 수준이 미미하고, 일반적인 애플리케이션 수준에서는 무시 가능한 수준으로 판단된다.
우리가 개발 공부를 하면서 많이 들어봤을 얘기 중 하나인 단일 책임 원칙(Single Responsibility Principle) 에 관련되는 내용이다.
단일 책임 원칙은 하나의 모듈(혹은 하나의 함수)는 하나의 기능만을 수행해야 한다는 개념으로 코드의 유지보수와 확장성을 높이는 원칙이다
하지만 혼합 패턴의 컴포넌트는 제어와 비제어라는 두가지 역할을 동시에 수행하는 구조이다. 이는 유연성을 제공한다는 장점을 지니지만, 컴포넌트의 책임이 모호해져 유지보수가 어려워지는 문제를 야기할 수 있다.
일반적으로 혼합 패턴 컴포넌트는 다음과 같은 모습을 한다.
function ControllableComponent({value, onChange, defaultValue, ...props}: Omit<ComponentProps<'input'>, 'value' | 'onChange'> & {
value?: string;
onChange?: (v: string) => void;
}) {
const [innerValue, setInnerValue] = useState<string>(defaultValue);
const isControlled = value !== undefined;
const currentValue = isControlled ? value : innerValue;
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
if(!isControlled) setInnerValue(e.target.value);
onChange?.(e.target.value);
}
if(process.env.NODE_ENV !== "production") {
// useControllableState가 사용하는 방식
// controlled - uncontrolled 변환 가드
const isControlledRef = useRef(value !== undefined)
useEffect(() => {
const wasControlled = isControlledRef.current
if(wasControlled !== isControlled) {
const from = wasContolled ? 'controlled' : 'uncontrolled';
const to = isContolled ? 'controlled' : 'uncontrolled';
console.warn(`input is changing from ${from} to ${to}`)
}
},[isControlled, value]);
}
return <input value={currentValue} onChange={handleChange}/>
}
위 코드를 통해 알 수 있는 구조적인 문제 다음과 같다.
상태관리의 이중화
value를 제어하다가 undefined를 넘기는 순간렌더링 흐름의 복잡성 증가
isControlled 여부를 판단해야 하므로 분기 로직이 늘어난다.확장성 저하
위와 같은 이유로 사용처에 따라 구분을 두면, 혼합 패턴은 상당히 유용한 방식이라 생각된다.
그래서 나는 다음과 같은 기준으로 구분하기로 했다.
혼합 패턴 컴포넌트로 유연성을 챙기는 것이 득이 되는 경우
혼합 패턴 컴포넌트를 사용하면 유지보수가 힘들어지는 경우
2번에 해당하는 경우는 혼합 패턴 컴포넌트를 사용하는 것보단 제어 컴포넌트와 비제어 컴포넌트를 따로 선언하는 것이 효율적이다.
필요에 따라 컴포넌트를 따로 선언하는 방법 중 하나는 프록시 컴포넌트를 활용하는 것이다.
function ProxyInput(props: ComponentProps<'input'>) {
const isControlled = props.value !== undefined;
return isControlled
? <ControlledInput {...props} />
: <UnControlledInput {...props} />
}
여기에 제어 / 비제어 전환을 방지하기 위해서 다음처럼 안정성을 높일 수도 있다.
function SafeProxyInput(props: ComponentProps<'input'>) {
const [isControlled] = useState(props.value !== undefined)
const value = isControlled ? props.value ?? "" : undefined;
useEffect(()=>{
if(isControlled && value === undefined) {
console.warn('the value undefined in a controlled input.')
}
},[value])
return isControlled
? <ControlledInput {...props} input={value} />
: <UnControlledInput {...props} />
}
useState를 사용해 마운트 시점에 제어 상태를 고정value가 undefined면 빈 문자열로 처리이 방식은 퍼포먼스보다는 예측 가능성과 유지보수성에 초점을 둔 방식으로, 혼합 패턴 컴포넌트보다 뛰어나다고 할 수는 없다.
계층이 한 단계 늘어나고, 제어 / 비제어 전환 방지를 위한 코드를 제거 하지 못했지만,
컴포넌트 경계의 명확성과 일관성을 확보해 가독성 및 유지보수에 이점을 가질 수 있다.
Reference