본문 바로가기

Front

무한 스크롤 캘린더 만들기 (with. FullCalendar)

 

평소 애용하는 일정관리 서비스의 캘린더 부분이 무한 스크롤로 되어 있는데, 사이드 프로젝트로 만들고 있는 가계부 서비스의 수입/지출 캘린더에도 동일한 무한 스크롤 기능을 적용하고 싶었다.

 

달력 컴포넌트와 무한 스크롤 기능은 FullCalendar 라이브러리의 MultiMonth 그리드를 사용해서 생각보다 빠르게 해결 되었으나, 달력의 헤더 영역과 달력 영역에서 스크롤 할 때 달력 사이에 월 정보가 보여지는 부분은 내가 생각한 것과 달라서 수정할 필요가 있었다.

 

아래는 FullCalendar 적용과 최종적으로 어떻게 수정 하였는지 과정을 기록한 내용이다.

 

 

FullCalendar 라이브러리 추가

 

FullCalendar 는 Month, Week, Day 등 다양한 View 를 지원하고 일정(이벤트) 등록과 같은 기능도 지원해주는 라이브러리이다.

 

나의 경우 리액트를 사용하고 있어서 fullcalendar/react 를 필수로 설치해주었고, 그 외에 멀티 달력(=무한 스크롤 캘린더) View 를 사용하기 위해 multimonth 와 core 를 설치해줬다. 연습용으로 월별 달력인 daygrid도 같이 설치해주었다.

npm install @fullcalendar/core @fullcalendar/react @fullcalendar/multimonth @fullcalendar/daygrid
"dependencies": {
  "@fullcalendar/core": "^6.1.20",
  "@fullcalendar/daygrid": "^6.1.20",
  "@fullcalendar/multimonth": "^6.1.20",
  "@fullcalendar/react": "^6.1.20",
}

 

 

daygrid 적용

 

먼저 가장 기본인 월별 달력인 daygrid 를 사용해보기 위해 아래와 같이 컴포넌트를 생성하고 화면을 확인해보았다.

import FullCalendar from '@fullcalendar/react';
import dayGridPlugin from '@fullcalendar/daygrid';

const MonthlyCalendar = () => {
  return (
    <div>
      <FullCalendar
        plugins={[dayGridPlugin]}
        initialView="dayGridMonth"
        headerToolbar={{ left: 'title', center: '', right: 'today prev,next' }}
      />
    </div>
  );
};

export default MonthlyCalendar;
  • fullCalendar는 Month, Week, Day 등 원하는 달력 view 형식을 정할 수 있으며, view 플러그인을 import 하여 plugins 속성에 추가해주면 된다.
  • initialView 속성에는 초기에 보여질 view 형태를 지정해준다.
  • headerToolbar 속성은 달력 상단에 보여질 항목들을 추가해준다.

 

위와 같이 월별 달력이 간단하게 만들어진걸 확인 가능하다.

 

 

multiMonth 적용

 

다음으로 멀티 달력을 사용해보았다.

import FullCalendar from '@fullcalendar/react';
import multiMonthPlugin from '@fullcalendar/multimonth';

import '@/components/customUi/monthly-calendar.css';

const MonthlyCalendar = () => {
  return (
    <div className="">
      <FullCalendar
        plugins={[multiMonthPlugin]}
        initialView="multiMonthYear"
        multiMonthMaxColumns={1}
        customButtons={{
          customToday: {
            text: '오늘',
          },
        }}
        headerToolbar={{ left: 'title', right: 'customToday' }}
      />
    </div>
  );
};

export default MonthlyCalendar;
  • multiMonth 플러그인을 추가하여 plugins와 initialView를 변경해주었다.
  • multiMonthMaxColumns 속성은 한 행에 몇개의 월이 보이게 할 것인지 설정하는 부분이다.
  • headerToolbar 에 추가되는 버튼을 커스텀 하고 싶은 경우 위와 같이 customButtons 속성을 사용해 수정 가능하다.
  • 별도의 css 파일을 작성하여 기본 스타일이 재정의 될 수 있도록 하였다.

 

위와 같이 스크롤 하면 이전/다음 달력을 확인 가능하다.

 

 

상단 타이틀과 기본 CSS 커스텀

 

기존 FullCalendar 의 멀티 달력은 스크롤 시 각 달력 사이에 월에 대한 정보와 요일에 대한 영역이 보여지고 있었는데, 이부분을 상단에 보이도록 수정하고 싶었다.

 

문제가 되는 영역을 안 보이게 하는 것은 간단하게 해당 요소를 display: none; 해주는 걸로 해결되었다.

/* 각 월 제목 영역 숨김 */
.fc .fc-multimonth-title {
  display: none;
}

/* 요일 헤더 숨김 */
.fc .fc-multimonth-header-table {
  display: none;
}

 

다음으로 MonthlyCalendar 컴포넌트 안에 FullCalendar 컴포넌트만 있었는데 이 구조를 수정하여 헤더 영역을 별도로 만들어 주었다.

const WEEK_DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];

const MonthlyCalendar = () => {
	return (
	  <div className="flex flex-col h-full justify-center">
	    {/* 캘린더 헤더 영역 */}
	    <div className="flex justify-between mx-4 px-2 pb-4">
	      <div className="customTitle">2026.03</div>
	      <button
	        type="button"
	        className="customToday-Btn shadow-md"
	        onClick={() => alert("today")}
	      >
	        Today
	      </button>
	    </div>
	    {/* 캘린더 영역 */}
	    <div className="border-2 mx-4 rounded-4xl overflow-auto mb-8 shadow-lg">
	      <div className="flex f-full bg-background border-b">
	        {WEEK_DAYS.map(day => (
	          <p key={day} className="flex flex-1 w-full h-10 items-center justify-center">{day}</p>
	        ))}
	      </div>
	      <FullCalendar
	        plugins={[multiMonthPlugin]}
	        initialView="multiMonthYear"
	        multiMonthMaxColumns={1}
	        headerToolbar={{ left: '', right: '' }}
	      />
	    </div>
	  </div>
	);
}
  • 캘린더 헤더 영역에 calendarTitle (YYYY.MM) 과 today 버튼이 들어가도록 수정 (아래에서 추가 수정 예정)
  • 캘린더 영역에 WEEK_DAYS.map() 을 통해 요일에 대한 영역 추가
  • FullCalendar 에 있던 customButtons 속성은 삭제해주고, headerToolbar 속성은 아예 삭제하면 오히려 기본값이 보여지고 있어서 빈값을 지정한 상태로 두었다.

이제 calendarTitle (YYYY.MM) 에 대한 값을 가져오기 위해 개발자 도구를 사용하여 FullCalendar의 요소들을 하나씩 확인했고, 각 월별 달력 div 요소의 data-date 속성에 년도와 월에 대한 데이터가 있는 걸 확인했다.

 

다음으로 저 요소들 중 현재 보여지는 달력이 어떤 요소인지 알아내서 해당 요소의 data-date 속성의 값을 가져와야 했는데, 이와 관련된 개발 경험이 없었기에 좋은 방안이 생각나질 않았고 codex의 도움을 받아 IntersectionObserver() API 를 알게 되어 바로 적용해보았다.

 

IntersectionObserver() 에 대해서 추가로 참고했던 포스팅

 

const MonthlyCalendar = () => {
  const wrapRef = useRef<HTMLDivElement | null>(null);
  const calendarRef = useRef<FullCalendar | null>(null);
  const [calendarTitle, setCalendarTitle] = useState('');
  
  useEffect(() => {
    const root = wrapRef.current;
    if (!root) return;
    const scroller = root.querySelector('.fc-scroller') as HTMLElement;
    const monthCards = Array.from(root.querySelectorAll('div[data-date]')) as HTMLElement[];

    const observer = new IntersectionObserver(
      monthCards => {
        monthCards.forEach(card => {
          if (card.isIntersecting && card.target instanceof HTMLElement) {
            const date = card.target.dataset.date?.replace('-', '.');
            if (!date) return;
            setCalendarTitle(date);
          }
        });
      },
      { root: scroller, threshold: 0.8 },
    );

    monthCards.forEach(card => observer.observe(card));
  }, []);

  return (
    <div ref={wrapRef} className="flex flex-col h-full justify-center">
      {/* 캘린더 헤더 영역 */}
      <div className="flex justify-between mx-4 px-2 pb-4">
        <div className="customTitle">{calendarTitle}</div>
        <button
          type="button"
          className="customToday-Btn shadow-md"
          onClick={() => calendarRef.current?.getApi().today()}
        >
          Today
        </button>
      </div>
      {/* 캘린더 영역 */}
      <div className="border-2 mx-4 rounded-4xl overflow-auto mb-8 shadow-lg">
	      ...요일 부분 생략
        <FullCalendar
          ref={calendarRef}
          plugins={[multiMonthPlugin]}
          initialView="multiMonthYear"
          multiMonthMaxColumns={1}
          headerToolbar={{ left: '', right: '' }}
        />
      </div>
    </div>
  );
};

export default MonthlyCalendar;
  • data-date 속성의 값(YYYY.MM)을 가져오기 위해 dom 요소 탐색시 해당 컴포넌트 내에서만 탐색할 수 있도록 wrapRef 변수 생성 및 최상위 div에 ref 연결 (중복 컴포넌트가 없다면 필수는 아니지만 탐색 범위를 좁혀준다.)
  • today 버튼 클릭시 FullCalendar 의 today() 이벤트가 실행 될 수 있도록 calendarRef 변수 생성 및 FullCalendar에 ref 연결. Today 버튼의 onClick 속성에도 today() 실행 로직 추가
  • 현재 보여지는 달력 요소의 data-date 속성의 값(YYYY.MM)을 저장할 calendarTitle 상태 변수 생성
  • IntersectionObserver()는 실제 DOM 요소를 대상으로 동작하기 때문에 화면이 모두 렌더링 되고 난 이후에 실행될 수 있도록 useEffect() 추가
  • 감시할 영역에 해당하는 DOM 요소를 scroller 변수에 대입
  • 감시할 대상에 해당하는 DOM 요소들을 monthCards 변수에 대입
  • new IntersectionObserver() 를 통해 감시자(=observer)를 생성 하는데, 첫번째 인자로 감시 대상과 내부에 필요한 로직을 정의하여 콜백 함수로 넘겨주고, 두번째 인자로 감시 행동에 대한 옵션을 정의하여 넘겨준다.
  • 첫번째 인자로 넘기는 콜백 함수 안에서 isIntersecting 속성을 통해 가시성 범위 내에 감시 대상이 포함되는 경우 해당 요소의 YYYY.MM 정보를 calendarTitle에 저장 해준다.
  • observer.observe() 로 감시가 시작되도록 해준다.

최종적으로 수정된 전체 코드와 화면은 다음과 같다.

import FullCalendar from '@fullcalendar/react';
import multiMonthPlugin from '@fullcalendar/multimonth';
import { DayText } from '@/components/customUi';
import { weekText } from '@/assets/mockData';

import '@/components/customUi/monthly-calendar.css';
import { useEffect, useRef, useState } from 'react';

const MonthlyCalendar = () => {
  const wrapRef = useRef<HTMLDivElement | null>(null);
  const calendarRef = useRef<FullCalendar | null>(null);
  const [calendarTitle, setCalendarTitle] = useState('');

  useEffect(() => {
    const root = wrapRef.current;
    if (!root) return;
    const scroller = root.querySelector('.fc-scroller') as HTMLElement;
    const monthCards = Array.from(root.querySelectorAll('div[data-date]')) as HTMLElement[];

    const observer = new IntersectionObserver(
      monthCards => {
        monthCards.forEach(card => {
          if (card.isIntersecting && card.target instanceof HTMLElement) {
            const date = card.target.dataset.date?.replace('-', '.');
            if (!date) return;
            setCalendarTitle(date);
          }
        });
      },
      { root: scroller, threshold: 0.8 },
    );

    monthCards.forEach(card => observer.observe(card));
  }, []);

  return (
    <div ref={wrapRef} className="flex flex-col h-full justify-center">
      <div className="flex justify-between mx-4 px-2 pb-4">
        <div className="customTitle">{calendarTitle}</div>
        <button
          type="button"
          className="customToday-Btn shadow-md"
          onClick={() => calendarRef.current?.getApi().today()}
        >
          Today
        </button>
      </div>
      <div className="border-2 mx-4 rounded-4xl overflow-auto mb-8 shadow-lg">
        <div className="flex f-full bg-background border-b">
          {WEEK_DAYS.map(day => (
            <p key={day} className="flex flex-1 w-full h-10 items-center justify-center">{day}</p>
          ))}
        </div>
        <FullCalendar
          ref={calendarRef}
          plugins={[multiMonthPlugin]}
          initialView="multiMonthYear"
          multiMonthMaxColumns={1}
          headerToolbar={{ left: '', right: '' }}
        />
      </div>
    </div>
  );
};

export default MonthlyCalendar;