[React] 직접 캘린더 구현하기

준성·2025년 5월 2일
2
post-thumbnail

⚡️ 왜 직접 캘린더를 구현했을까?

프로젝트를 진행하면서 사용자의 소비 기록을 한눈에 확인할 수 있는 월간 캘린더 UI가 필요했다.

처음에는 react-calendar 등의 라이브러리를 사용하려고 했지만, 불필요한 기능이 많고 디자인을 완전히 커스터마이징 해야 했다.

그래서 필요한 기능만 간결하게 구현하면서, 디자인에 맞추기 위해 캘린더를 직접 만들기로 결정했다.

⚡️ 주요 기능

캘린더에 필요한 기능은 다음과 같다.

  1. 월간 날짜 그리드 표시

  2. 현재 월 외의 날짜 구분 표시

  3. 오늘 날짜 하이라이트

  4. 주말과 평일 구분 표시

  5. 날짜 선택 시 모달 표시

⚡️ 날짜 계산 로직

날짜 계산은 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에서 날짜별 데이터 가져오기

캘린더의 데이터는 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 연동을 통해 날짜 데이터 연결과 상태 동기화도 경험해본 것 같다.

많은 기능이 필요한 게 아니라면 한 번쯤 직접 만들어보는 것도 좋은 것 같다.

profile
모든 건 기세다.

0개의 댓글