프로젝트를 진행하면서 사용자의 소비 기록을 한눈에 확인할 수 있는 월간 캘린더 UI가 필요했다.
처음에는 react-calendar 등의 라이브러리를 사용하려고 했지만, 불필요한 기능이 많고 디자인을 완전히 커스터마이징 해야 했다.
그래서 필요한 기능만 간결하게 구현하면서, 디자인에 맞추기 위해 캘린더를 직접 만들기로 결정했다.
캘린더에 필요한 기능은 다음과 같다.
월간 날짜 그리드 표시
현재 월 외의 날짜 구분 표시
오늘 날짜 하이라이트
주말과 평일 구분 표시
날짜 선택 시 모달 표시
날짜 계산은 date-fns
라이브러리를 사용했다.
date-fns
는 JavaScript의 기본 Date
객체를 사용해 기존 코드와 쉽게 통합할 수 있고, 필요한 기능만 골라 쓸 수 있어서 번들 크기도 줄일 수 있다. 게다가 TypeScript도 지원해서 타입 안정성을 높일 수 있어 사용하게 되었다.
import { addDays, endOfMonth, format, getDay, startOfMonth } from "date-fns";
// ...
const startOfCurrentMonth = startOfMonth(currentDate);
const endOfCurrentMonth = endOfMonth(currentDate);
// 캘린더의 시작일 계산 (현재 월의 1일이 속한 주의 일요일)
const startDate = addDays(startOfCurrentMonth, -getDay(startOfCurrentMonth));
// 5주(35일) 캘린더 그리드 생성
const days = Array.from({ length: 35 }, (_, i) => addDays(startDate, i));
addDays(date, n)
는 기준 날짜에서 n일을 더하거나 뺀다. (addDays(오늘, 5)
는 5일 후, addDays(오늘, -3)
는 3일 전 날짜)
getDay(date)
는 해당 날짜가 무슨 요일인지 숫자로 반환한다. (일요일은 0, 월요일은 1, ... 토요일은 6)
우리가 구현하려는 캘린더는 한 주를 일요일부터 시작하도록 설계했기 때문에, startDate
를 구할 때는 현재 달의 첫날이 포함된 주의 '일요일'부터 시작할 수 있도록 -getDay(...)
를 사용했다.
이렇게 하면 첫 줄이 일요일로 깔끔하게 시작되도록 만들 수 있다.
캘린더의 데이터는 API에서 데이터를 가져와 연동했다.
import { useCallback, useEffect, useState } from "react";
import { getVirtualItemCalendar } from "@/lib/apis/virtualItems";
import { CalendarData } from "@/types/virtualItems";
// ...
const [calendarData, setCalendarData] = useState<CalendarData | null>(null);
const fetchCalendarData = useCallback(async () => {
try {
const year = parseInt(format(currentDate, "yyyy"));
const month = parseInt(format(currentDate, "M"));
const data = await getVirtualItemCalendar(year, month);
setCalendarData(data);
} catch (error) {
console.error("Failed to fetch calendar data:", error);
}
}, [currentDate]);
useEffect(() => {
fetchCalendarData();
}, [fetchCalendarData]);
useState
를 사용해 캘린더 데이터를 상태로 관리useCallback
으로 fetchCalendarData
함수를 메모이제이션해 불필요한 함수 재생성을 방지useEffect
를 사용해 컴포넌트 마운트 시와 currentDate
변경 시 데이터를 다시 불러옴format
함수로 현재 날짜에서 연도와 월을 추출사용자가 날짜를 클릭하면, 해당 날짜의 상세 정보를 모달로 보여주는 기능도 구현했다.
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
const handleModalClose = () => {
setIsModalOpen(false);
setSelectedDate(null);
fetchCalendarData(); // 모달에서 데이터가 변경되었을 수 있으므로 다시 로드
};
// 렌더링 부분
return (
<>
{/* 캘린더 컨텐츠 */}
{isModalOpen && selectedDate && (
<CalendarModal
date={selectedDate}
day={parseInt(format(selectedDate, "d"))}
onClose={handleModalClose}
onUpdate={fetchCalendarData}
/>
)}
</>
);
classnames/bind
를 사용해 조건부 클래스를 적용했다.
<div className={cn("date", {
current: isCurrentMonth,
other: !isCurrentMonth,
today: isToday,
sunday: dayOfWeek === 0,
saturday: dayOfWeek === 6,
})}
>
current
: 현재 월에 속하는 날짜other
: 다른 월에 속하는 날짜today
: 오늘 날짜sunday/saturday
: 주말 표시캘린더 라이브러리를 사용했다면 디자인 변경에 더 많은 시간을 썼을 것 같은데, 직접 만들면서 날짜 계산 로직도 구현해봤고, API 연동을 통해 날짜 데이터 연결과 상태 동기화도 경험해본 것 같다.
많은 기능이 필요한 게 아니라면 한 번쯤 직접 만들어보는 것도 좋은 것 같다.