radix 혼합 패턴 (controllable component)

Rosevillage·2025년 10월 10일

최근 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이라는 점과 세부적인 로직을 빼면 위의 코드와 비슷한 모습을 가진다.

혼합 패턴 컴포넌트 특징

  • 호출부에서 필요에 따라 제어 / 비제어 컴포넌트로 사용 가능
  • 하나의 컴포넌트로 두개의 컴포넌트를 커버할 수 있음

이러한 특징이 다음과 같은 상황에서 혼합 패턴의 장점을 부곽시킨다.

  1. 단순 입력 컴포넌트

    • <Input>, <Checkbox>, <Switch> 등 단일 값만 관리하는 UI요소
    • 외부제어가 필요할 수도, 내부 관리가 더 자연스러울 수도 있기 때문에
      혼합 패턴의 형태가 API 단순화에 도움을 준다.
  2. 공용 디자인 시스템 컴포넌트

    • 다양한 프로젝트나 팀이 공통으로 사용하는 입력 컴포넌트라면,
      제어형과 비제어형을 모두 지원해야 하는 요구가 자주 발생한다.
  3. 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라는 추가적인 상태를 저장한다. 이론적으로 일반적인 제어 컴포넌트보다 더 많은 메모리를 소비하는 것이다.

하지만 그렇다고 해서 실제 메모리 사용량이 두배가 되는 것은 아니다.
그 이유는 다음과 같다.

  1. hook #2로 선언된 inner state는 대부분의 경우 저장하는 값이 작다.
  2. 부모가 제어하는 동안 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}/>
}

위 코드를 통해 알 수 있는 구조적인 문제 다음과 같다.

  1. 상태관리의 이중화

    • 내부상태와 외부 상태가 공존하므로 실제 데이터의 단일 출처가 불명확해진다.
      이로 인해 상태간 동기화나 라이프사이클 관리가 어려줘지고, 디버깅 포인트가 증가한다.
    • 부모가 value를 제어하다가 undefined를 넘기는 순간
      컴포넌트가 제어형에서 비제어형으로 전환되는 위험성이 존재한다.(제어 / 비제어를 모두 지원하려는 컴포넌트가 가지게 되는 고질적인 위험성)
  2. 렌더링 흐름의 복잡성 증가

    • 렌더링마다 isControlled 여부를 판단해야 하므로 분기 로직이 늘어난다.
      이러한 조건문이 여러 훅 내부에도 퍼지면서 유지보수가 어려워진다.
    • 값 변경 시점이 부모 혹은 내부 상태에 따라 달라지므로 렌더링 타이밍의 일관성이 깨질 염려가 있다.
  3. 확장성 저하

    • 단순 입력 필드 수준에서는 유용하지만 검증, 포맷팅, 비동기 요청 같은 로직이 추가되면
      각 분기마다 제어 / 비제어 처리를 모두 고려해야 한다.
      이로 인해 로직이 기하급수적으로 복잡해지고, 테스트 범위가 늘어난다.

결론

위와 같은 이유로 사용처에 따라 구분을 두면, 혼합 패턴은 상당히 유용한 방식이라 생각된다.
그래서 나는 다음과 같은 기준으로 구분하기로 했다.

  1. 혼합 패턴 컴포넌트로 유연성을 챙기는 것이 득이 되는 경우

    • 단순 입력 컴포넌트
    • 공용 디자인 시스템 컴포넌트
  2. 혼합 패턴 컴포넌트를 사용하면 유지보수가 힘들어지는 경우

    • 단일 출처 및 책임을 고려할 정도의 복잡성을 지니는 컴포넌트
    • 단순 입력 컴포넌트를 포함한 더 큰 사이즈의 공용 디자인 시스템 컴포넌트

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

radix-react-use-controllable-state

naver-d2-React 파이버 아키텍처 분석

react-fiber-hooks

react-internal-types

0개의 댓글