거대한 컴포넌트, 복합 컴포넌트 패턴으로 깔끔하게 정리하기
안녕하세요:) 오늘은 거대한 컴포넌트를 복합 컴포넌트 패턴으로 리팩토링한 경험을 공유하고자 합니다.
문제상황
해결 과정을 설명하기 전에 어떤 문제점이 있었는지 살펴보겠습니다. 제가 리팩토링한 컴포넌트는 Input이었는데요.
우리는 더 나은 사용자 경험을 제공하기 위해 input에 정해진 패턴의 값만 입력할 수 있는 기능을 구현합니다. 특히 카드번호나 휴대폰 번호와 같은 숫자 입력 시 자동으로 하이픈(-)이 삽입되도록 하는 경우가 많습니다.
이러한 기능을 편리하게 제공하면서도 공통된 스타일과 로직(clear 버튼 등)을 적용하기 위해 Input 컴포넌트는 점점 더 복잡해졌습니다.
interface InputProps<T> {
type: 'text' | 'number' | 'pattern',
value: T
handleChange: () => void;
}
const Input = (props: InputProps) => {
const { type, ...rest } = props;
return (
<CommonInputContainer>
{
type === 'pattern' ? <InputPattern {...rest}/>
: type === 'number' ? <InputNumber {...rest}/>
: <InputText {...rest}/>
}
</CommonInputContainer>
)
}
이는 아주 간단한 예시지만, 더 많은 props를 전달해야 한다면 어떻게 될까요? Input의 props는 점점 더 커질 것입니다.
또한 아래 예시처럼 특정 컴포넌트에만 사용되는 props를 전달하면, 어떤 props가 어떤 컴포넌트에 종속되어 있는지 점점 더 모호해집니다.
interface InputProps<T> {
type: 'text' | 'number' | 'pattern';
value: T;
handleChange: () => void;
pattern?: string; //InputPattern에만 사용됨
suffix?: React.ReactNode; //InputPattern에만 사용됨
}
const Input = <T>(props: InputProps<T>) => {
const { type, pattern, suffix ...rest } = props;
return (
<CommonInputContainer>
{
type === 'pattern' ? <InputPattern pattern={pattern} suffix={suffix} {...rest}/>
: type === 'number' ? <InputNumber {...rest}/>
: <InputText {...rest}/>
}
</CommonInputContainer>
)
}
function App(){
// pattern 속성이 올바른 컴포넌트에 작성되게 보장할 수가 없음
return <>
<Input type="pattern" />
<Input type="text" pattern={'###-####-####'} />
</>
}
복합 컴포넌트 패턴이란?
복합 컴포넌트 패턴이란 여러 컴포넌트가 공통된 상태를 공유하고 함께 작동하도록 설계된 패턴으로, 부모 컴포넌트가 자식 컴포넌트를 속성으로 가지고 있어 ParentComponent.ChildComponent와 같은 형태로 사용할 수 있게 하는 방식입니다.
UI 라이브러리로 유명한 Ant Design에서도 이런 패턴을 사용하고 있습니다
<Radio.Group value={tabPosition} onChange={changeTabPosition}>
<Radio.Button value="top">top</Radio.Button>
<Radio.Button value="bottom">bottom</Radio.Button>
<Radio.Button value="left">left</Radio.Button>
<Radio.Button value="right">right</Radio.Button>
</Radio.Group>
복합 컴포넌트 패턴을 사용하면 서로 관련된 컴포넌트들의 관계를 명확히 표현할 수 있고, 부모 컴포넌트가 내부 상태를 관리하며 자식 컴포넌트들이 이를 공유할 수 있습니다.
리팩토링 목표
제가 최종적으로 원하는 형태는 아래와 같았습니다
// AS-IS
<Input
type="pattern"
onChange={}
value={""}
suffix="phone"
pattern="###-####-####"
message={message}
/>
// TO-BE
<Input>
<Input.Pattern pattern={'###-####-####'} suffix="phone" onChange={} value={''}/>
<Input.ErrorMessage message={message}/>
</Input>
이렇게 하면 각 컴포넌트가 어떤 props를 받아야 하는지 훨씬 명확해지지 않나요?
구현해보기
차근차근 구현해보도록 하겠습니다. 이해를 돕기 위해 아주 간단한 버전으로 알려드리고 아래에는 제가 실제로 작업했던 전체 코드를 공유드리겠습니다.
복합 컴포넌트 패턴을 적용하기 위해서는 먼저 Main 컴포넌트가 필요합니다.<Input.Number/>
와 같은 형태를 만들 수 있게 하위 컴포넌트들을 자식으로 받는 wrapper 컴포넌트입니다.
export const InputContext = createContext<InputContextProps>({
// 자식 컴포넌트들과 공유할 속성
});
function InputMain(props: InputMainProps) {
const { children } = props;
// 객체 참조값 변경으로 리렌더링이 발생하지 않도록 하기 위해 useMemo로 contextValue 정의
const contextValue = useMemo(
() => ({ }),
[],
);
return (
<InputContext.Provider value={contextValue}>
<StyledInputMain>{children}</StyledInputMain>
</InputContext.Provider>
);
}
위처럼 InputMain이라는 컴포넌트를 정의해줍니다. 이곳에서 context API를 통해 자식 컴포넌트들과 공유할 값들을 담아 내려줄 수 있습니다.
다음으로는 하위 컴포넌트들을 묶어주는 작업이 필요합니다.
import InputPattern from '';
import InputText from '';
import ErrorMessage from '';
function InputMain(props: InputMainProps) { ... }
export const Input = Object.assign(InputMain, {
Text: InputText,
Pattern: InputPattern,
ErrorMessage,
});
이렇게 Object.assign을 사용하면 InputMain 컴포넌트에 하위 컴포넌트들을 속성으로 추가할 수 있습니다. 이제 아래와 같이 직관적으로 사용할 수 있습니다.
<Input>
<Input.Pattern pattern="###-####-####" />
<Input.ErrorMessage />
</Input>
리팩토링 결과
위 패턴을 적용함으로써 얻은 장점은 다음과 같습니다
1. 타입 안정성 향상: 각 입력 타입에 필요한 속성이 해당 컴포넌트에 명확하게 정의됩니다.
2. 코드 가독성 개선: 어떤 입력 타입을 사용할지 선언적으로 표현할 수 있습니다.
3. props 감소: 정말 길었던 Input 컴포넌트의 props를 30%나 줄일 수 있었습니다.
최종코드
코드가 궁금하신 분들이 있을 것 같아 간단 버전을 공유드리도록 하겠습니다 :)
저희는 react-hook-form과 함께 사용중이었는데 이를 지운 버전을 공유해드리는거라 혹시 어색한 부분이 있다면 댓글로 남겨주세요! 공유드리겠습니다 :)
export const InputContext = createContext<InputContextProps>({
// 자식 컴포넌트들과 공유할 속성
});
type InputMainProps = PropsWithChildren<{}>;
function InputMain(props: InputMainProps) {
const { children } = props;
const contextValue = useMemo(
() => ({ }),
[],
);
return (
<InputContext.Provider value={contextValue}>
<StyledInputMain>{children}</StyledInputMain>
</InputContext.Provider>
);
}
export const useInputContext = () => {
return useContext(InputContext);
};
// 저희는 react-hook-form과 form 컴포넌트들을 연동하기 위해 Input 컴포넌트 안에서 컴포넌트들을 재정의해주었습니다.
const TextComponent = (props: Omit<InputTextProps, InputMainOmittedProps>) => {
const { } = useInputContext();
return (
<InputText {...props} />
);
};
const TextareaComponent = (
props: Omit<TextareaProps, InputMainOmittedProps>,
) => {
const { isTouched } = useInputContext();
return (
<Textarea {...props} />
);
};
const NumberComponent = (
props: Omit<InputNumberProps, InputMainOmittedProps>,
) => {
return (
<InputNumber {...props} />
);
};
const PatternComponent = (
props: Omit<
InputPatternProps,
'onChange' | 'value' | 'isValid' | 'isTouched'
>,
) => {
return (
<InputPattern {...props} />
);
};
const FileComponent = (
props: Omit<
InputFileProps,
'onChange' | 'value' | 'isValid' | 'isTouched' | 'multiple'
>,
) => {
return (
<InputFile {...props} />
);
};
const ErrorMessage = () => {
// 저희는 react-hook-form controller랑 함께 사용중이었어서 filedState가 있는데 해당 로직은 제외했습니다.
return <CommonErrorMessage errorMessage={fieldState.error.message} />;
};
const SuccessMessage = ({ message }: InputSuccessMessageProps) => {
return isTouched && isValid ? (
<InputSuccessMessage message={message} />
) : (
<></>
);
};
export const Input = Object.assign(InputMain, {
Text: TextComponent,
Textarea: TextareaComponent,
Number: NumberComponent,
Pattern: PatternComponent,
File: FileComponent,
SuccessMessage,
ErrorMessage,
});
const StyledInputMain = styled.div`
position: relative;
width: 100%;
overflow: hidden;
`;