캘린더 컴포넌트 리팩토링: Context api와 Compound Component 패턴을 활용한 코드 개선
by 담담이담
개요
저는 5월부터 지하아이돌 공연 예약 및 캘린더 어플 아지토(https://azito.kr/)를 만들고 있습니다. 해당 서비스는 7월에 출시되어 현재까지 약 650명의 유저가 가입했으며, 매일 약 200명이 꾸준히 이용하고 있습니다. 지하 아이돌 공연은 “겐바(現場)”라고 부르며 현장이라는 뜻을 가지고 있습니다. 이해를 돕기 위해 Next.js App Router와 Typescript, Jotai를 이용해 개발하고 있음을 말씀드립니다. 또한, 회사의 유능한 프론트엔드 개발자분의 도움을 받아 코드를 개선해나갔습니다.
1. 배경
저희 서비스의 주된 기능은 “캘린더로 공연 일정 보여주기”입니다. 처음에는 캘린더 페이지에서만 보여지던 캘린더 컴포넌트가 서비스가 커지면서, 여러 페이지에서 사용되기 시작했습니다.
공통 요구사항:
- 각 겐바 클릭 시 겐바 상세 페이지로 이동
- 오늘 버튼 클릭 시 오늘 날짜로 이동
페이지별 요구사항:
기능 | 캘린더 페이지 | 나의 겐바 페이지 | 아이돌 페이지 |
뷰 종류 | 월간뷰, 주간뷰 | 월간뷰, 주간뷰 | 월간뷰만 제공 |
보여지는 겐바 | 모든 겐바 일정 | 좋아요를 누른 겐바만 | 해당 아이돌 그룹의 겐바만 |
추가 기능 | 날짜 클릭 시 주간뷰로 이동 | 겐바 포토뷰(포스터뷰)/텍스트뷰 토글 가능, 날짜 클릭 시 주간뷰로 이동 |
|
예시 | https://azito.kr/calendar | https://azito.kr/my-genba | https://azito.kr/team?id=17 |
위에서 볼 수 있듯 각 페이지마다 공통 요구사항도 존재했지만, 상이한 요구사항도 존재했습니다. 이를 하나의 컴포넌트에서 처리하려다보니 아래와 같은 문제가 발생하기 시작했습니다.
2. 문제점
- 조건문의 남용
- 초기에는 하나의 캘린더 컴포넌트에서 모든 로직을 처리하려 했습니다. 그러다보니 페이지별 다른 요구사항을 충족하기 위해 pathname이 무엇이냐에 따라 로직을 처리했습니다. 결과적으로 하나의 컴포넌트가 지나치게 복잡해졌고, 특정 페이지에서 버그가 발생하더라도 알지 않아도 되는 부분의 코드까지 읽게 되어 유지보수가 어려워졌습니다.
- props의 남용
- 하나의 캘린더 컴포넌트를 사용하다 보니, 페이지별 다른 요구사항 충족하기 위해서 새로운 prop을 추가하여 해결하려 했습니다.
- 모든 페이지에서 선택된 날짜를 일관되게 참조하고, 현재 유저가 선택한 날짜 등 캘린더의 속성을 업데이트하는 동작을 수행하기 위해 calendar와 setCalendar를 계속 하위 컴포넌트에 전달했습니다. ⇒ props drilling 문제 발생
3. 문제 해결
- Props Drilling 문제 해결: Context API 도입
- calendar와 setCalendar prop을 모든 하위 컴포넌트에 전달되고 있었던 props drilling 문제를 해결하기 위해 Context API를 도입하였습니다. 이를 통해 중간 컴포넌트들을 거치지 않고 필요한 컴포넌트에서 직접적으로 상태와 업데이트 함수를 참조할 수 있도록 함으로써 불필요한 props 전달을 줄였습니다.
export const [CalendarContextProvider, useCalendarContext] = createSafeContext<{
calendar: CalendarType;
onCalendarChange: Dispatch<SetStateAction<CalendarType>>;
}>(null);
export const [CarouselContextProvider, useCarouselContext] = createSafeContext<UseEmblaCarouselType>(null);
export const [MonthlyViewContextProvider, useMonthlyViewContext] = createSafeContext<{ calendarIsMonthlyView: boolean }>(null);
const CalendarProvider = (
props: PropsWithChildren & { calendarIsMonthlyView: boolean; calendar?: CalendarType; onCalendarChange?: Dispatch<SetStateAction<CalendarType>> },
) => {
const { children, calendar: _calendar, onCalendarChange: _onCalendarChange, calendarIsMonthlyView } = props;
const calendarAtom = useMemo(
() =>
atom<CalendarType>({
calendarSelectedDate: new Date().toISOString(),
calendarViewDate: new Date().toISOString(),
calendarIsPhotoView: false,
}),
[],
);
const [_internal, _setInternal] = useAtom(calendarAtom);
const calendar = _calendar ?? _internal;
const onCalendarChange = _onCalendarChange ?? _setInternal;
return (
<CalendarContextProvider value={{ calendar, onCalendarChange }}>
<MonthlyViewContextProvider value={{ calendarIsMonthlyView }}>{children}</MonthlyViewContextProvider>
</CalendarContextProvider>
);
- CalendarContextProvider
- CalendarContextProvider는 calendar와 onCalendarChange라는 상태와 상태 변경 함수를 제공합니다. 이 상태는 현재 선택된 날짜, 캘린더 뷰 날짜, 포토 뷰 옵션 등 캘린더의 핵심 상태 정보를 포함합니다.
- CarouselContextProvider
- CarouselContextProvider는 캘린더의 슬라이드 기능을 관리하는 UseEmblaCarouselType을 제공합니다. 캘린더의 월간 뷰나 주간 뷰에서 슬라이드나 캐러셀과 같은 인터랙션이 필요할 때, Carousel Context를 통해 해당 기능을 사용할 수 있게 합니다.
- MonthlyViewContextProvider
- MonthlyViewContextProvider는 캘린더의 월간 뷰인지 여부를 관리하는 calendarIsMonthlyView 상태를 제공합니다.
2. 페이지마다 다른 요구사항 반영: Compound Component 패턴 도입
- 캘린더를 여러 부분으로 쪼개어서 기본 틀을 제공하는 UI 컴포넌트를 만들고, 각 페이지에서 이를 조립하여 사용할 수 있도록 했습니다. 즉, 비즈니스 로직과 UI를 구분하였습니다.
- 저희 서비스의 캘린더는 이전 달과 다음 달을 캐러셀로 넘겨볼 수 있습니다. 즉, 하나의 달을 보여주기 위해 그 달만을 렌더링하지 않습니다. 여러 달(months)부터 시작해서, 각 달의 여러 주(weeks)를, 각 주의 하루 하루(day)를 렌더링합니다.
- 캘린더의 기본 틀을 구성하는 CarouselLayout, Header, Body, Row, Cell 같은 개별 컴포넌트들을 독립적으로 관리하면서도, 상위 컴포넌트에서 조립하여 사용할 수 있게 됩니다.
컴포넌트 구조 설명
export const CarouselLayout = ({
children,
}: {
children: ((props: { calendar: CalendarType; onCalendarChange: Dispatch<SetStateAction<CalendarType>> }) => ReactNode) | ReactNode;
}) => {
const { calendar, onCalendarChange } = useCalendarContext();
const [emblaRef] = useCarouselContext();
return (
<section className={monthlyStyles.embla}>
<DefaultCarousel ref={emblaRef}> {typeof children === "function" ? children({ calendar, onCalendarChange }) : children}</DefaultCarousel>
</section>
);
};
export const Layout = ({ children }: PropsWithChildren) => {
return <div className={monthlyStyles.embla__slide}>{children}</div>;
};
export const Header = ({ children }: PropsWithChildren) => {
return <div className=" flex flex-row px-10pxr py-8pxr">{children}</div>;
};
export const Body = ({ children }: PropsWithChildren) => {
return <div>{children}</div>;
};
export const Row = ({ children }: PropsWithChildren) => {
return <div className="flex flex-row border-t border-neutral-200 px-[10px]">{children}</div>;
};
export const Cell = ({
children,
onClick,
variant,
isBlur,
bottomSlot,
}: ComponentPropsWithoutRef<"div"> &
VariantProps<typeof DateVariants> & { onClick: MouseEventHandler<HTMLButtonElement>; isBlur: boolean; bottomSlot: ReactNode }) => {
return (
<div onClick={onClick} className={`relative flex min-h-56pxr w-[calc(100%/7)] flex-col ${isBlur ? "opacity-20" : ""} items-center`}>
<button className={BackVariants({ variant: variant })}>
<p className={DateVariants({ variant: variant })}>{children}</p>
</button>
{bottomSlot}
</div>
);
};
export const MonthCalendar = {
CarouselLayout,
Layout,
Header,
Body,
Row,
Cell,
};
- CarouselLayout
- 이 컴포넌트는 전체 달(months) 뷰를 담당합니다. DefaultCarousel을 사용해 캐러셀 슬라이더로 구성되어 있어, 각 달을 슬라이드 형태로 이동하면서 볼 수 있습니다.
- Layout
- Layout은 개별적인 한 달을 감싸는 상위 레이아웃입니다. 각 달을 하나의 슬라이드로 표시하며, 그 내부에 Header와 Body 컴포넌트를 배치해 달력의 전체적인 틀을 만들어 줍니다.
- 쉽게 말해, 달력을 한 달 단위로 구분해서 보여주는 역할을 합니다.
- Header
- Header는 각 달의 요일 헤더 부분을 담당합니다. 요일이 가로로 나열되어 주간 뷰의 상단에 요일이 표시되도록 하며, 일반적으로 월요일부터 일요일까지 요일 이름을 나타냅니다.
- Body
- Body는 각 달의 날짜들이 나열되는 부분입니다. 즉, 캘린더의 메인 콘텐츠로서, 해당 달의 모든 날짜가 포함되는 곳입니다. 여기에서 Row 컴포넌트가 map을 통해 주별로 렌더링됩니다.
- 따라서 Body에서는 각 주를 그룹화하며, Row 컴포넌트를 통해 한 줄에 한 주가 표시됩니다.
- Row
- Row는 한 주의 날짜를 나타내는 행입니다.
- 각 Row는 Cell을 통해 그 주의 각 날짜를 렌더링합니다.
- Cell
- Cell은 각 날짜를 나타내는 컴포넌트입니다. 캘린더의 개별 날짜를 나타내며, 오늘 날짜, 주말, 일반 날짜 등 variant에 따라 스타일을 다르게 적용합니다.
- 각 Cell에 있는 bottomSlot은 추가적인 정보를 표시하기 위한 공간으로, 겐바(Genba) 정보가 들어갑니다.
- 예를 들어, 포토뷰에서는 포스터 이미지가 포함된 컴포넌트가 들어가고, 텍스트뷰에서는 겐바 이름이 표시된 텍스트 컴포넌트가 들어가도록 설정할 수 있습니다.
- 이를 통해, 각 페이지에서 bottomSlot에 어떠한 값을 넣을지를 선택할 수 있습니다.
import { Calendar, useCalendarContext } from "@/features/calendar/components/calendar";
import DayOfWeek from "@/features/calendar/components/day-of-week";
import GenbaView from "@/features/calendar/components/month/genba-view";
import { MouseEvent } from "react";
import { usePathname, useRouter } from "next/navigation";
import { makeMonthlyViewSlideList } from "@/features/calendar/legacy/monthly-view.container";
import { GenbaType } from "@/entities/genba/model/genba.model";
import monthCalendarUtils from "@/features/calendar/utils/month-calendar-util";
import MonthEmptySlide from "@/features/calendar/components/month/month-empty-slide";
import calendarUtil from "@/features/calendar/utils/calendar-util";
import { useGetFavoriteTeamNameList } from "@/features/calendar/hooks/use-get-love-team-list";
import { $Routes } from "@/shared/constant/route";
interface MonthlyViewProps {
genbaList: GenbaType[];
}
const MyGenbaMonthCalendar = ({ genbaList }: MonthlyViewProps) => {
const router = useRouter();
const pathname = usePathname();
const { calendar, onCalendarChange } = useCalendarContext();
const favoriteIdolNameList = useGetFavoriteTeamNameList();
const { handleStopPropagation, shouldRenderSlide, isBlur, createCalendar } = monthCalendarUtils;
const { updatedCalendar, getGenbaListForDate, getDateVariant, checkIsFavoriteTeamAppearing } = calendarUtil;
const changeMonthlyView = (calendarIsMonthlyView: boolean) => {
router.push($Routes.myGenba.path({ query: { calendarIsMonthlyView } }));
};
const handleCellClick = (date: Date) => {
onCalendarChange(updatedCalendar(date, calendar));
changeMonthlyView(false);
};
const handleGenbaClick = (e: MouseEvent<HTMLButtonElement>, genbaId: number, date: Date) => {
router.push(`/genba?id=${genbaId}&from=${pathname}`);
handleStopPropagation(e);
onCalendarChange(updatedCalendar(date, calendar));
};
return (
<Calendar.MonthProvider>
<Calendar.Header left={<Calendar.Navigation />} right={<Calendar.토글버튼 />} />
<Calendar.CarouselLayout>
{months.map((slide, index) => {
if (!shouldRenderSlide) {
return <MonthEmptySlide />;
}
const month = createCalendar(slide);
return (
<Calendar.CalendarLayout key={index}>
<Calendar.Header>
<DayOfWeek />
</Calendar.Header>
<Calendar.Body>
{month.map((weeks, weekIndex) => (
<Calendar.Row key={weekIndex}>
{weeks.map((date, dateIndex) => {
const variant = getDateVariant(date, calendar);
const genbaListForDate = getGenbaListForDate(genbaList, date);
const BottomSlot = (
<>
{genbaListForDate.map((genba, index) => {
const { genbaId } = genba;
const isLastItem = index === genbaListForDate.length - 1;
const isFavoriteTeamAppearing = checkIsFavoriteTeamAppearing(genba.genbaCastList, favoriteIdolNameList);
return (
<GenbaView
index={index}
status={calendar.calendarIsPhotoView ? "photo" : isFavoriteTeamAppearing ? "special-text" : "text"}
handleGenbaClick={(e) => handleGenbaClick(e, genbaId, date)}
key={genbaId}
genba={genba}
isLastItem={isLastItem}
date={date}
/>
);
})}
</>
);
return (
<Calendar.Cell
key={dateIndex}
variant={variant}
isBlur={isBlur(calendar.calendarViewDate, date)}
onClick={() => handleCellClick(date)}
bottomSlot={BottomSlot}
>
<> {date.getDate()}</>
</Calendar.Cell>
);
})}
</Calendar.Row>
))}
</Calendar.Body>
</Calendar.CalendarLayout>
);
})}
</Calendar.CarouselLayout>
</Calendar.MonthProvider>
);
};
const months = makeMonthlyViewSlideList(2024, 1, 2024, 12);
export default MyGenbaMonthCalendar;
- 해당 코드는 나의 겐바 페이지에서 적용된 예시입니다.
- 적용 이후 달라진 점 위주로 설명해보자면 다음과 같습니다.
- 페이지 맞춤 이벤트 핸들링
- 페이지 마다 달랐던 Cell(날짜)의 클릭 이벤트를 페이지별로 기능을 삽입할 수 있습니다.
- 공통 로직의 유틸화
- 페이지와 무관하게 캘린더에서 공통적으로 필요한 로직은 유틸 함수로 분리해, 중복을 줄이고 코드의 재사용성을 높였습니다.
- 커스터마이징 가능한 bottomSlot
- Cell 컴포넌트의 bottomSlot을 통해 페이지 요구사항에 맞는 겐바의 뷰(포토뷰로 보여주는 겐바, 텍스트 뷰로 보여주는 겐바 등)를 각 페이지에 맞게 삽입할 수 있습니다.
- 페이지 맞춤 이벤트 핸들링
=> 이전에는 페이지마다 다른 이벤트나 겐바 뷰(bottomSlot)를 하나의 컴포넌트에서 분기 처리했으나, 위와 같이 페이지에서 직접 요구 사항을 삽입할 수 있는 형태로 변경되면서 유지보수가 수월해졌고, 버그가 발생했을 때에도 어느 부분을 봐야 할지 명확해졌습니다.
4. 마무리
이렇게 캘린더 컴포넌트를 Compound Component 패턴과 Context API를 통해 재구성함으로써, 페이지별로 다양한 요구사항을 충족하면서도 유연성과 확장성을 갖춘 구조를 만들 수 있었습니다.
이 방식으로 캘린더 컴포넌트는 여러 페이지에서 일관된 UI를 제공하면서도, 각 페이지의 특성에 맞는 기능을 쉽게 삽입할 수 있게 되었고, 앞으로 새로운 페이지나 기능을 추가할 때에도 손쉽게 대응할 수 있게 되었습니다.
이번 개선을 통해 서비스 전반에 걸친 코드의 가독성, 유지보수성, 그리고 유연성을 모두 높일 수 있었으며, 앞으로도 이러한 구조적 접근을 바탕으로 서비스를 발전시켜 나갈 계획입니다.
블로그의 정보
유명한 담벼락
담담이담