제어의 역전 (Inversion of Control): 유연한 컴포넌트 설계

**제어의 역전(Inversion of Control, IoC)**은 “컴포넌트가 모든 것을 스스로 결정하지 말고, 사용하는 쪽에 결정권을 넘겨라”는 원칙입니다. 라이브러리나 공통 컴포넌트를 만들 때 특히 중요합니다.

문제: 지나치게 결정하는 컴포넌트

버튼 클릭 횟수를 표시하는 컴포넌트를 만든다고 가정합니다.

// 버전 1: 모든 것을 스스로 결정
function CounterButton() {
  const [count, setCount] = useState(0);
 
  return (
    <button onClick={() => setCount(prev => prev + 1)}>
      클릭 수: {count}
    </button>
  );
}

그런데 요구사항이 생깁니다.

  • “최대 10번만 클릭 가능하게 해주세요”
  • “클릭할 때 API도 호출해야 해요”
  • “카운트를 다른 컴포넌트와 공유해야 해요”

이 요구사항들을 모두 CounterButton 안에 넣으면 컴포넌트가 점점 비대해집니다.

// 버전 2: 특수 케이스들이 쌓인 컴포넌트
function CounterButton({ max, onCountChange, onApiCall, sharedCount }) {
  const [localCount, setLocalCount] = useState(0);
  const count = sharedCount ?? localCount;
 
  function handleClick() {
    if (max && count >= max) return;
    const next = count + 1;
    setLocalCount(next);
    onCountChange?.(next);
    onApiCall?.();
  }
 
  return <button onClick={handleClick}>클릭 수: {count}</button>;
}

props가 늘어날수록 내부 로직도 복잡해집니다.

제어를 역전시키기

대신 컴포넌트가 핵심 기능만 제공하고, 동작을 사용하는 쪽에서 제어하게 합니다.

// 제어를 역전: 상태와 핸들러를 밖에서 받음
function CounterButton({ count, onClick }) {
  return (
    <button onClick={onClick}>
      클릭 수: {count}
    </button>
  );
}
 
// 사용하는 쪽에서 모든 것을 결정
function App() {
  const [count, setCount] = useState(0);
 
  function handleClick() {
    if (count >= 10) return; // 최대값 로직
    setCount(prev => prev + 1);
    callApi(); // API 호출
  }
 
  return <CounterButton count={count} onClick={handleClick} />;
}

CounterButton은 이제 단순히 “숫자와 클릭 핸들러를 받아 렌더링하는” 역할만 합니다. 비즈니스 로직은 사용하는 쪽에 있습니다.

제어 컴포넌트 패턴 (Controlled Component)

폼 입력에서 가장 많이 볼 수 있는 IoC 패턴입니다.

// 비제어 컴포넌트: 자신의 상태를 스스로 관리
function UncontrolledInput() {
  const [value, setValue] = useState('');
  return <input value={value} onChange={e => setValue(e.target.value)} />;
}
 
// 제어 컴포넌트: 값과 변경 핸들러를 밖에서 받음
function ControlledInput({ value, onChange }) {
  return <input value={value} onChange={onChange} />;
}
 
// 사용하는 쪽에서 제어
function Form() {
  const [name, setName] = useState('');
  return (
    <ControlledInput
      value={name}
      onChange={e => setName(e.target.value)}
    />
  );
}

제어 컴포넌트는 부모가 항상 최신 값을 알고 있으므로, 검증, 형식 변환 등을 부모에서 자유롭게 처리할 수 있습니다.

State Reducer 패턴

더 진화된 IoC 패턴입니다. 컴포넌트가 기본 동작을 제공하되, 사용자가 reducer를 오버라이드해서 동작을 바꿀 수 있게 합니다.

function useToggle({ reducer = (state, action) => action.changes } = {}) {
  const [state, dispatch] = useReducer(
    (currentState, action) => {
      const changes = toggleReducer(currentState, action); // 기본 로직
      return reducer(currentState, { ...action, changes }); // 오버라이드 가능
    },
    { isOn: false }
  );
 
  const toggle = () => dispatch({ type: 'TOGGLE', changes: { isOn: !state.isOn } });
 
  return { isOn: state.isOn, toggle };
}
 
// 기본 사용
const { isOn, toggle } = useToggle();
 
// 최대 5번만 토글 가능하도록 제어 역전
let clickCount = 0;
const { isOn, toggle } = useToggle({
  reducer(state, action) {
    if (action.type === 'TOGGLE' && clickCount >= 5) {
      return state; // 기본 동작을 막음
    }
    clickCount++;
    return action.changes; // 기본 동작 허용
  }
});

Prop Getter 패턴

접근성 속성이나 이벤트 핸들러 등을 한 번에 묶어서 넘겨주는 패턴입니다.

function useToggle() {
  const [isOn, setIsOn] = useState(false);
 
  function getToggleProps(extraProps = {}) {
    return {
      'aria-pressed': isOn,
      onClick: () => setIsOn(prev => !prev),
      ...extraProps, // 추가 props도 병합
    };
  }
 
  return { isOn, getToggleProps };
}
 
// 사용
function App() {
  const { isOn, getToggleProps } = useToggle();
 
  return (
    <button {...getToggleProps({ className: 'my-button' })}>
      {isOn ? 'ON' : 'OFF'}
    </button>
  );
}

정리

제어의 역전은 “컴포넌트를 얼마나 유연하게 만들 것인가”의 문제입니다.

컴포넌트 제어 수준특징
높음 (비제어)사용하기 쉽지만 커스터마이징 어려움
낮음 (제어)사용하기 복잡하지만 완전한 제어 가능

범용으로 쓰이는 컴포넌트일수록 제어를 사용하는 쪽에 더 많이 위임하세요. 단, 너무 많은 것을 외부에 위임하면 사용하기 불편해지므로 적절한 균형이 중요합니다.