
나같은 새싹 프론트엔드 개발자들은 컴포넌트를 만들땐 수월해서 이건 완벽한 코드야 ! 라며 만족하며 개발을 해봤을 것이다.
나는 여러 요구사항이 추가되었을 때 코드를 다시 보면서
내가 유지보수와 확장성을 생각하지 않았구나
라는 생각을 했다.
그래서 이번 포스팅은 현재 진행중인 프로젝트를 기반으로 내가 유지보수와 확장성을 위해서 컴포넌트를 어떤 시선으로 바라보며 어떻게 설계를 했는지 지극히 내 개인적인 의견을 포스팅하려고한다.
추가로 컴포넌트에 대해서 더 깊게 알고 싶다면 아래의 블로그를 추천한다.
https://ms3864.tistory.com/433

개발중인 투두리스트 기능을 하는 UI이다.
컴포넌트를 나눌 때 나만의 기준이 있는데
- 재사용이 가능한가?
- 하나의 컴포넌트가 여러 일을 하지 않는가?
- 비즈니스 로직과 뷰 로직이 잘 분리될 수 있는가?
라는 생각을 가지고 나눴을 때 아래와 같았다.

초록색 박스를 합성 컴포넌트의 자식 컴포넌트로 만들었다.
왜냐하면 유연하게 UI구조를 바꿀 수 있고 요구사항이 생길때마다 props를 이용해 유무를 컨트롤 할 필요가 없어서 일반적으로 컴포넌트로 만들었을 때 처럼 요구사항이 추가될 떄마다 무거워지고 유지보수가 힘들어지는 상황을 방지할 수 있다.
또한 핑크색 박스를 부모 컴포넌트, 빨간색 박스를 자식컴포넌트로 다시 합성컴포넌트를 이용해 분리해줬다. 이유는 위에는 지우기버튼만 있고 다른 곳은 완료, 지우기 버튼을 가지고 있다. 추가로 다른 버튼이 생길 수 있다고 생각했기 때문에 유연하게 상황을 대처하기위해서 합성컴포넌트를 이용했다.
합성컴포넌트는 부모컴포넌트안에 여러 컴포넌트를 조합해서 유연하게 쓸 수 있어서
컴포넌트의 확장성과 유지보수를 올려줄 수 있는 패턴중에 하나이다.
- 컨텍스트 생성
- 부모 컴포넌트 코드를 작성
- 자식 컴포넌트 코드를 작성
- 자식 컴포넌트를 부모컴포넌트의 프로퍼티로 할당
type Props = {
children: React.ReactNode;
};
type TodoContext = {
weather: WEATHER_TYPE | null;
handleWeatherClick: (e: React.MouseEvent<HTMLImageElement>) => void;
};
const todoContext = createContext<TodoContext | null>(null);
//부모 컴포넌트
const Todo = ({ children }: Props) => {
const { weather, handleWeatherClick } = useTodos();
return (
<todoContext.Provider value={{ weather, handleWeatherClick }}>
<section className={cx("wrapper")}>{children}</section>
</todoContext.Provider>
);
};
export default Todo;
// 조립을 위한 자식 컴포넌트들
Todo.Day = Day;
Todo.Progressive = Progressive;
Todo.Schedule = Schedule;
export const useTodo = () => {
const value = useContext(todoContext) as TodoContext;
if (!value) {
alert("TodoContext is not found");
}
return value;
};
type Props = {
children: React.ReactNode;
};
const cx = classNames.bind(styles);
const TodoLists = ({ children }: Props) => {
return <div className={cx("todo-lists")}>{children}</div>;
};
TodoLists.TodoData = TodoData;
TodoLists.ClearButton = ClearButton;
TodoLists.DeleteButton = DeleteButton;
export default TodoLists;
Todo라는 부모 컴포넌트 밑에 조립을 위한 Day, Progressive, Schedule 컴포넌트를 만들어서 나누었다. 이제 컴포넌트의 위치를 바꾸면서 유연하게 컴포넌트를 수정할 수 있고 어떠한 props를 넘겨줄 때도 꼭 부모컴포넌트에 넘겨주지않아도 되기때문에 관심사 분리가 된다.
부모컴포넌트에서는 context api를 썻는데 이유는 자식컴포넌트에서의 props drilling을 방지하고 전역으로 상태를 공유하기위해서 썻다.
그 밑에 또 하나의 TodoLists라는 합성컴포넌트를 썻는데 Todo에서 context api를 쓰기 떄문에 따로 상태관리를 하지 않았다.
이제 설계를 맡쳤으니 합성컴포넌트를 써보자.
<Todo>
<Todo.Day />
<Todo.Progressive />
<Todo.Schedule />
</Todo>
<TodoLists>
<TodoData />
<ClearButton />
<DeleteButton />
</TodoLists>
위 처럼 부모 합성 컴포넌트안에 자식컴포넌트를 조립해 레이아웃을 자유롭게 바꿀 수 있고 자식컴포넌트를 뺴고 넣을 수 있다. 기획이 바뀌어도, 기능이 확장되어도 유연하게 대처가 가능하다는 뜻이다.
단점이라면 JSX코드가 길어지고 큰 이유없이 쓴다면 컴포넌트의 개수가 쓸데없이 많이질 수 있다.
위의 단점을 최소화하기 전에 합성 컴포넌트를 도입하기전에 생각해봐야한다.
- 이 컴포넌트는 요구사항이 추가될 UI인가?
- 컴포넌트 재사용을 위해서 쓰이는 자식컴포넌트의 개수가 몇개인가?
코드에는 정답이 없다. 개발자마다의 성향에 따라 다르기 때문이다.
나는 요구사항이 추가된다는 사실을 안다면 UI를 보고 합성 컴포넌트를 고려해보고 재사용 가능 여부를 파악할 것이다.
하지만 모든 코드에는 정답이 없듯이 모든지 적절한 상황에 최적의 패턴을 쓰는게 중요한 것같다.
export default function TodoLists({ width = 100, children }: InputBoxProps) {
const inputs = Children.toArray(children).every((child) => {
return (
(isValidElement(child) && child.type === TodoData) ||
(isValidElement(child) && child.type === ClearButton)||
(isValidElement(child) && child.type === DeleteButton)
);
});
if (!inputs) {
throw new Error(
"TodoLists 컴포넌트는 TodoData, ClearButton, DeleteButton 컴포넌트만 받을 수 있습니다."
);
}
const TodoLists = ({ children }: Props) => {
return <div className={cx("todo-lists")}>{children}</div>;
};
TodoLists.TodoData = TodoData;
TodoLists.ClearButton = ClearButton;
TodoLists.DeleteButton = DeleteButton;
export default TodoLists;
}
부모 컴포넌트가 children props 패턴으로 구현되어있어서 내가 원하는 데이터외의 데이터를 넣는 실수를 할 수 있다.
만약 내가 지정한 컴포넌트만 넣는걸 원한다면 children 속성에 접근해서 필터링을 해주자.
모달창을 구현하다보면 모달창의 온/오프 여부를 밖에서 선언해줘야하는 상황이 생기는데 그렇게하면 반복되는 코드가 생기기마련이다.
이번에는 합성 컴포넌트를 사용해서 기존의 모달창보다 좀 더 유연하고 토글여부를 합성 컴포넌트 내부에서 컨트롤할 수 있는 방법을 알아봤다.
type ModalProps = {
children: React.ReactNode;
};
type ModalContextType = {
open: boolean;
handleToggle: () => void;
};
const ModalContext = React.createContext<ModalContextType | null>(null);
const Modal = ({ children }: ModalProps) => {
const inputs = Children.toArray(children).every((child) => {
return (
(isValidElement(child) && child.type === Button) ||
(isValidElement(child) && child.type === Title) ||
(isValidElement(child) && child.type === Content)
);
});
if (!inputs) {
alert("children must be Button, Title, Content");
}
const { open, handleToggle } = useModals(true);
return (
<ModalContext.Provider value={{ open, handleToggle }}>
{children}
</ModalContext.Provider>
);
};
export const Button = ({ children }: { children: React.ReactElement }) => {
const { handleToggle } = useContext(ModalContext) as ModalContextType;
return <div>{cloneElement(children, { onClick: () => handleToggle() })}</div>;
};
export const Title = () => {
const { open } = useContext(ModalContext) as ModalContextType;
if (!open) return null;
return <h1>Title</h1>;
};
export const Content = () => {
const { open } = useContext(ModalContext) as ModalContextType;
if (!open) return null;
return <p>Content</p>;
};
Modal.Button = Button;
Modal.Title = Title;
Modal.Content = Content;
export default Modal;
여기서 유심히 봐야하는건 cloneElement이다. cloneElement의 props로 모달창을 컨트롤 할 수 있는 속성을 넣어줘서 밖에서 모달창을 관리하는 state를 만들지않아도 된다.
즉, 모달창의 관심사의 분리가 됐다는 뜻이다.
밖에서는 이렇게 쓰면된다.
<>
<Modal>
<Modal.Button>
<button>열기/닫기</button>
</Modal.Button>
<Modal.Title />
<Modal.Content />
</Modal>
</>
이제 반복적으로 modal창을 구현하기 위해서 외부 컴포넌트에서
const [open, setOpen] = useState(false);
const handleOpen = () => {
setOpen((pre) => !pre);
};
이 코드를 반복적으로 생성하지 않아도 된다.

https://ms3864.tistory.com/433
https://fe-developers.kakaoent.com/2022/220731-composition-component/