Controlled and Uncontrolled Component Patterns
지금까지 프로그래밍을 하면서 거의 제어 컴포넌트만 썼다. 이유는 모두 실시간으로 검증이 필요해서였다. 비제어 컴포넌트를 선택했다면 어떤 이벤트 트리거로 검증을 했을까? onSubmit?
제어와 비제어 컴포넌트를 비교해보면, 제어 컴포넌트는 값과 변경 이벤트를 컴포넌트에 연결하고, 변경 이벤트를 줄 때마다 계속해서 Re-Rendering이 발생한다. 비제어 컴포넌트는 DOM 자체에 값을 저장하고 필요할 때 객체(ref)를 통해 직접 값을 가져온다.
실시간 필드 유효성 검사도 필요없고, 특정 입력 형식(input type)도 강제하지 않고 등등 사용자에게 완전 열여주고 싶으면 비제어 컴포넌트를 쓸 것 같다. 생각해보면, 네이버에서 로그인 할 때도 비제어 컴포넌트 같다.
// 제어 컴포넌트
const ControlledInput = () => {
const [value, setValue] = useState('');
const handleChange = ({ target: { value } }) => {
setValue(value);
};
return <input value={value} onChange={handleChange} />;
};
// 비제어 컴포넌트
const UnControlledInput = () => {
const inputRef = useRef(null);
const handleSubmit = (event) => {
event.preventDefault();
// inputRef.current.value
};
return (
<form onSubmit={handleSubmit}>
<input ref={inputRef} />
<button type="submit">Submit</button>
</form>
);
};
Compound Components Pattern
모달 모듈을 만드는 미션에서 사용했던 패턴이다. 만약 이 패턴을 안 쓰고 구현했다면, 왼쪽처럼 단일 컴포넌트에 여러가지 조건문을 통해 스타일링을 했을 것이다. 실제로 그렇게 구현한 크루는 prop 개수가 엄청 많았다. 요구사항이 복잡해질 수록 컴포넌트가 그 요구사항을 모두 내포하기 위해서 비대해질 수 밖에 없다. 합성 컴포넌트 패턴을 사용하면 이를 조립하는 형태로 해결할 수 있다.
위에서 말했지만, 여러 조건에 따라 조립해서 사용하는 형태의 컴포넌트를 구현할 때 유용할 것 같다.
합성 컴포넌트는 컴포넌트를 트리 형태로 조립해 쓸 수 있기 때문에, props drilling 문제를 줄여줄 수 있는 장점이 있다. 하지만 적절한 추상화 없이 컴포넌트만 조립해두면 오히려 구조가 복잡해서 흐름이 불명확해질 수도 있을 것 같다.
// on 일 때만 메시지 나옴
const ToggleOn = ({ children }) => {
const { on } = useContext(ToggleContext);
return on ? children : null;
};
// off 일 때만 메시지 나옴
const ToggleOff = ({ children }) => {
const { on } = useContext(ToggleContext);
return on ? null : children;
};
const ToggleButton = (props) => {
const { toggle } = useContext(ToggleContext);
return <button onClick={toggle} {...props} />;
};
const App = () => {
return (
<Toggle>
<ToggleOn>ON</ToggleOn>
<ToggleOff>OFF</ToggleOff>
<ToggleButton>Toggle</ToggleButton>
</Toggle>
);
};
Context API를 이용하니까 데이터에 신경 쓰지 않고, 사용자 입장에선 정말 컴포넌트만 조립해서 사용할 수 있게 되는구나 싶었다. 또 데이터를 보여줄지 말지를 어느 컴포넌트가 결정해야 할지 고민했는데, 처음엔 부모가 책임지는 게 나을까 싶다가도, 결국엔 각 컴포넌트가 스스로 책임지는 게 더 깔끔하구나 하는 생각이 들었다.
위에서는 ToggleOn, ToggleOff처럼 각 컴포넌트를 따로 작성했지만, Toggle 컴포넌트에 속성처럼 붙여서 Toggle.On, Toggle.Off 형태로도 작성할 수 있다. 몇몇 라이브러리에서 이런 방식을 쓰는데, 토스에서는 Toggle.On처럼 구성하는 걸 사용하더라. 장단점이 있다. 후자 방식의 장점은, Toggle 하나만 import하면 Toggle에 연결된 모든 하위 컴포넌트를 사용할 수 있다. 또, Toggle.까지만 입력해도 어떤 컴포넌트들이 있는지 자동완성으로 확인할 수 있어 사용하기 편하다. 하지만 단점도 있다. 이 방식은 모든 하위 컴포넌트를 함께 가져온다고 가정하기 때문에, 실제로 사용하지 않아도 전부 번들에 포함된다. 그래서 Tree Shaking이나 Code Splitting이 되지 않아 번들 사이즈가 커질 수 있다.
결국엔 개발자 경험과 성능 사이에서 균형을 잡아야 하는 선택이고, 프로젝트 상황에 따라 어떤 방식이 더 적절한지 판단해야 한다고 생각한다.
Toggle.On = ({ children }) => {
const { on } = useContext(ToggleContext);
return on ? children : null;
};
Toggle.Off = ({ children }) => {
const { on } = useContext(ToggleContext);
return on ? null : children;
};
Toggle.Button = (props) => {
const toggle = useContext(ToggleContext);
return <button onClick={toggle} {...props} />;
};
const App = () => {
return (
<Toggle>
<Toggle.On>OK</Toggle.On>
<Toggle.Off>OFF</Toggle.Off>
<Toggle.Button>토글하기</Toggle.Button>
</Toggle>
);
};
여러 컴포넌트 패턴에 대해 알아봤는데 생각보다 재밌었다. GitHub에서 이 패턴들을 잘 적용한 코드를 많이 찾아보고 싶다. 그리고 직접 적용해보면서 언제 어떤 패턴을 쓰는 게 자연스러운지 감을 잡아가고 싶다.
다만, 모든 상황에 패턴이 정답인 건 아니니까 괜히 억지로 적용하지는 말자. 패턴병
홀맨이 해준 조언: 과거 코드를 답습하기보단 이제는 계속 실무에서 실질적인 문제를 다뤄가면서 설계에서 발생하는 트레이드 오프 지점을 몸으로 느끼고 결정하는게 훨씬 중요할 거에요. 레퍼런스는 어차피 이상적인 특정 상황을 가정하고 만들어 둔거라 실무에서 너무 그 '모양'에만 집중해서 따라하려 하는건 오히려 독이 되기 쉽습니다. 개념적으로 방향을 이해했다면 이제는 실무 도메인에서 여러 가지 상황을 겪는게 제일 중요합니다. 절대 생각하는 방향으로 이상적으로 구조를 만들 수 없는 이유를 느끼는게 더 중요할 거 같네요. 그게 오히려 실력이 더 늘고 추후에 설계 관련된 책들을 다시 봤을 때 와닿는 느낌이 전혀 다를 겁니다.