안녕하세요!
구글 캘린더를 클론 코딩하며 유연하고 확장 가능한 구조를 고민해봤습니다.
강하게 결합된 구조보다는 외부로부터 주입받아 사용하는 느슨한 결합 방식, 즉 Ioc (제어의 역전)을 사용했습니다.
많은 관심 부탁드립니다.
IoC이란?
제어의 흐름을 개발자가 아닌 외부로 넘기는 설계 원칙입니다.
전통적으로 컴포넌트가 직접 의존성을 생성하거나 처리했다면, IoC에서는 필요한 것들을 외부에서 주입받아 사용합니다.
코드 간 결합도를 낮추고, 더 유연하고 확장 가능한 구조를 만들 수 있습니다.
컴포넌트 주입
이 방법은 리액트에서 가장 간단한 IoC 구현 방식으로, 컴포넌트가 자신의 내부 로직 대신 외부에서 전달받은 UI를 렌더링하도록 제어권을 넘깁니다.
props로 컴포넌트를 전달하는 방법은 크게 두 가지가 있습니다:
props 을 사용하는 방법, children 로 전달하는 방법입니다.
(props로 컴포넌트 전달)
props로 컴포넌트를 전달할 때는 주로 특정 슬롯이나 위치에 맞춰 UI를 구성할 때 사용합니다.
어떤 UI 컴포넌트가 어느 위치에 들어갈지 명확하게 나타낼 수 있고, 넘겨주는 컴포넌트에 따라 다른 화면 구성을 할 수 있기 때문입니다.
여기서 레이아웃을 잡을 때 사용했습니다.
const Layout = ({ header, sidebar, children }: BaseLayoutProps) => {
return (
<div className="">
<header className="h-[64px]">{header}</header>
<div className="flex h-[calc(100vh-64px)]">
<div className="flex flex-col w-[256px] bg-transparent h-[100%]">
{sidebar}
</div>
<main className="shadow-md bg-white flex-1 rounded-[20px] mb-[16px]">
{children}
</main>
<aside className="w-[56px] bg-transparent">
<RightSideMenu />
</aside>
</div>
</div>
);
};
export default Layout;
// 사용 컴포넌트
const Calendar = () => {
const mode = useSelector((state: RootState) => state.mode.mode);
return (
<Layout
header={
<HeaderProvider>
<Header />
</HeaderProvider>
}
sidebar={<SideBar />}
>
{mode !== MODE.MONTH ? <TodoWithRowCells /> : <TodoWithCell />}
</Layout>
);
};
구글 캘린더의 Layout은 크게 Header, Sidebar, Content 영역으로 구성됩니다.
이러한 레이아웃 UI 요소들을 각각 props로 전달하면, 원하는 콘텐츠를 각 영역에 유연하게 배치할 수 있습니다.
(children으로 컴포넌트 전달)
children은 컴포넌트를 전달할 땐, 주로 컴포넌트 내부에 하나의 주된 콘텐츠를 감싸서 전달할 때 주로 사용합니다.
구글 캘린더 데이트 피커
여기서는 DatePicker 컴포넌트에 children으로 Title, ContentHeader, Content를 감싸서 전달합니다.
이렇게 하면 DatePicker는 레이아웃만 담당하고, 실제 표시할 내용은 외부에서 자유롭게 구성할 수 있습니다.
만약 "일, 월, 화, ... 토"와 같은 요일이 없는 형태의 DatePicker가 요구된다면, ContentHeader 컴포넌트를 전달하지 않으면 됩니다.
또한 Title 영역의 UI를 바꾸고 싶다면, 기존 Title 대신 원하는 컴포넌트를 children으로 전달해 교체할 수 있습니다.
코드
const SideBar = () => {
return (
<>
<DatePickerProvider>
<DatePicker>
<DatePicker.Title />
<DatePicker.ContentHeader />
<DatePicker.Content />
</DatePicker>
</DatePickerProvider>
<Footer/>
</>
);
};
export default SideBar;
합성 컴포넌트 패턴(Compound Component Pattern)
합성 컴포넌트 패턴은 리액트의 Context/Provider를 사용하여 여러 종류의 컴포넌트가 하나의 로직을 공유할 수 있게 하는 방법입니다.
사실 위에 DatePicker도 합성 컴포넌트 패턴으로 개발했는데요.
DatePicker의 구성요소별로 별개의 컴포넌트로 분리하였습니다.
이로서유연하게 컴포넌트의 UI를 컨트롤할 수 있고, 어떠한 UI 변경 사항이 발생하더라고 손쉽게 해결할 수 있게 되었습니다.
또한 각각의 분리된 컴포넌트들은 Datepicker라는 도메인에 더 이상 종속되지 않고 각각 본연의 기능과 역할(SRP: Single Responsibility Principle)로도 사용할 수 있게 되었습니다.
만약 Content.Data 부분에 디자인 요청이 들어온다면,
기존 코드는 전혀 수정하지 않고 해당 부분만 새로운 컴포넌트로 교체하면 됩니다.
마찬가지로 ContentHeader 영역에 대한 디자인 변경 요청이 있어도,
기존 구조는 그대로 둔 채 변경된 UI 컴포넌트만 교체하면 됩니다.
(Title, Content, ContentHeader)
const Title: FC<DatePickerTitleProps> = () => {
const { left, right } = useDatePicker();
return (
<div className="grid gap-2 grid-cols-7 items-center mb-4">
<div className="col-span-5 pl-2">{left}</div>
<div className="col-span-2 flex justify-end">{right}</div>
</div>
);
};
const Content: ContentComponent = ({ children }) => {
const { days } = useDatePicker();
return (
<div className="grid grid-cols-7 gap-2 place-items-center">
{children || days.map((day, idx) => <Content.Data key={idx} day={day} />)}
</div>
);
};
const ContentHeader: ContentHeaderComponent = ({ children }) => {
const { weekDays } = useDatePicker();
return (
<div className="grid grid-cols-7 gap-2 text-center font-semibold mb-2">
{children || weekDays.map((dayName, idx) => (
<ContentHeader.Data key={idx} dayName={dayName} />
))}
</div>
);
};
(ContentData)
const ContentData: FC<ContentDataProps> = ({ day }) => {
const { selectedDate, setDate, now } = useDatePicker();
const isToday = day === now;
const isSelected = day === selectedDate;
const isCurrentMonth = dayjs(day).month() === dayjs(selectedDate).month();
const type = isToday ? "primary" : isSelected ? "light" : "none";
const textColor = isToday ? "white" : isCurrentMonth ? "black" : "gray";
return (
<Button
onClick={() => setDate(day)}
type={type}
className="text-center rounded-full w-[24px] h-[24px] flex items-center justify-center text-sm"
>
<Text
size="xs"
color={textColor}
weight={isToday ? "bold" : "normal"}
>
{dayjs(day).format("D")}
</Text>
</Button>
);
};
Content.Data = ContentData;
(ContentHeaderData)
const ContentHeaderData: FC<ContentHeaderDataProps> = ({ dayName }) => {
return (
<div className="text-gray-600 text-center">
<Text size="xs">{dayName}</Text>
</div>
);
};
ContentHeader.Data = ContentHeaderData;
정리하자면 합성 컴포넌트는
각 컴포넌트를 분리하고, 어떠한 UI 변경사항이 오더라도 기존 코드는 수정없이 손쉽게 해결할 수 있다는 장점이 있습니다. 그리고 페이지 내 수많은 데이터가 한눈에 읽히는 장점이 있습니다.
반면, 합성 컴포넌트만을 사용하는 것이 좋다고만 볼 수도 없습니다. 추가적으로 Context/Provider 패턴을 구성해야 하며, 불필요한 렌더링이 발생할 수도 있습니다.
'project > google-calendar-clone' 카테고리의 다른 글
커스텀 훅으로 로직 재사용하기 (0) | 2025.06.08 |
---|---|
Atomic 디자인 (0) | 2025.06.07 |