
원티드 프리온보딩을 통해 비즈니스 로직에 대해 배우던 중 compound components pattern에 대해 알게 되었다. 부모 컴포넌트에서 자식 컴포넌트의 제어할 수 있다는 것이 내가 제일 흥미롭게 생각한 점이었다. 자식 컴포넌트는 동작과 상태를 포함하고 있지만 부모컴포넌트에서 제어할 수 있어 조금 더 직관적으로 코드를 구성할 수 있겠다고 생각했다.
현재 진행 중인 프로젝트에서 부모 컴포넌트의 제어권이 낮아지고 자식 컴포넌트에서 코드를 한 눈에 알아보기 힘들어 고민하던 중 CCP에 대해 접하게 되었다.
적용하기에 앞서 compound components pattern(이하 ccp로 칭함)에 대해 알아보고자한다.
ccp는 여러 개의 컴포넌트가 함께 작동하여 하나의 부모 컴포넌트 안에서 상호작용할 수 있도록 하는 디자인 패턴이다. 추상화를 통해 기능을 분리하고 디자인 시스템을 내부적으로 적용할 수 있다.
나는 input을 ccp로 만들어보았다. 해당 프로젝트 내에서 input 타입이 다양하게 쓰이는 만큼 스타일 및 disabled, required, valid 등 에러 메세지 등 통일된 디자인 컴포넌트를 만들기 적합하다고 생각했다.
<label htmlFor='id'>아이디</label>
<input
type={type}
value={value}
id={name}
name={name}
onChange={onChange}
className='...'
required
disabled={disabled}
{...props}
/>
disabled, focus 되었을 때 등 label과 input의 디자인이 변화가 있다. 그리고 한 페이지 내에서 최대 5 종류의 input type이 들어가기도 해서 코드가 난잡해지고 있었다,,,^_^
그리고 디자인과 개발 작업이 동시다발적으로 진행되다보니 디자인 시스템이나 색상 등 변경되는 상황이 잦았다.
위와 같은 상황이 지속되다 보니 ccp를 빠르게 적용시켜야겠다는 생각이 들었다.
Input 내부에서 상태를 공유하기 위한 context를 생성해준다.
// @/hooks/context/useInputContext
import { InputContext } from '@/components/Input';
import { useContext } from 'react';
const useInputContext = () => {
const context = useContext(InputContext);
if (context === undefined) {
throw new Error('<Input /> 태그 안에서 사용해주세요.');
}
return context;
};
export default useInputContext;
생성한 context를 사용해 Input 컴포넌트를 생성하고, provider를 통해 상태를 공유할 수 있도록 내려준다.
// @/components/Input
export const InputContext =
createContext<InputContextProps>(defaultInputContext);
const Input = ({ children, ...props }: InputProps) => {
return (
<InputContext.Provider value={{ ...props }}>
<div className='...'}>{children}</div>
</InputContext.Provider>
);
};
// @/components/Input
const Text = ({ type, value, onChange, error, ...rest }: TextInputProps) => {
const { isRequired, isDisabled } = useInputContext();
return (
<label htmlFor={name}>
<input
type={type}
value={value}
id={name}
name={name}
onChange={onChange}
className='...'
required={isRequired}
disabled={isDisabled}
{...rest}
/>
</label>
);
};
Input.Text = Text;
실제 적용된 코드
// @/components/LoginForm
function App() {
const handleChange = (e) => {
const { value, name } = e.target;
setFormValue((prev) => ({...prev, [name]: value}))
};
return (
...
<form onSubmit={onSubmit}>
<Input isRequired name='userId'>
<Input.Label>아이디</Input.Label>
<Input.Text
value={formValue.id}
placeholder='아이디를 입력해 주세요'
onChange={handleChange}
/>
</Input>
...
</form>
);
}
export default App;

실질적으로 코드의 길이가 대폭 줄어들진 않았지만, 확실히 코드의 가독성이 이전보다 높아진 것을 느낄 수 있었다. 이번 경험을 통해 코드 가독성에 대해 다시 한 번 생각해보는 계기가 되었던 것 같다. 그리고 추상화를 통해 기능을 분리하는 게 처음엔 힘들었는데 여러 번의 실패를 통해,,ㅎ 감각적으로 더욱 더 다가간 느낌이 든다.