본문 바로가기

개발 일기

React로 만든 일정 관리를 위한 백오피스 캘린더 컴포넌트

2024년 08월 30일

#개발일기🐕 #React👨🏻‍💻 #캘린더📅 #난오늘_무얼했는가🤷🏻‍♂️

#트러블슈팅아님🙅🏻‍♂️ #그냥_뭐라도쓰기✏️ #일기_습관만들기_챌린지🔥

 

회사에서 백 오피스를 마이그레이션 하고 있는 요즘, 일정 관리를 위한 캘린더 컴포넌트가 필요했다. UI보다는 기능 위주의 빠른 개발이 우선이었기에, 편하게 라이브러리를 사용해 개발하려고 했다.

하지만 적절한 라이브러리를 찾다보니, 딱히 마음에 드는게 없어서 조금 더 시간이 걸리더라도 차라리 직접 한번 만들어보는것도 괜찮겠다 싶어 시작하게된 일정 관리 캘린더.

호로록 내 입맛에만 맞게 편하게 만들어보자는 생각으로 가볍게 시작했던 캘린더였지만, 생각보다 하다보니 계속 디테일한 욕심도 생겼다.. 괜히 더 신경써서 만들어보고싶은 느낌😂


0. 기존의 캘린더

현재 마이그레이션 하기 전 버전의 캘린더는 Ant design 라이브러리를 활용하여 대부분 제작되었다. 캘린더 역시 해당 라이브러리를 활용하고 있었고, 그 모습은 아래에 첨부해둔 구글 캘린더 사진의 모습과 비슷하다. 실제 사용되고있는 캘린더 사진을 사용할 수 없어 구글캘린더로 예시 사진을 대체했다.

 

 

 

1. 기본 구조 설계

첫 번째 시도: 각 날짜별 일정 항목 단순 나열 방식의 설계

일단 기존에 있던 기능은 제대로 해야하기에, 기존의 캘린더처럼 캘린더의 각 셀별로 해당 일자의 이벤트들이 목록으로 표기되도록 설계하고자했다. Calendar, CalendarHeader, CalendarCell 컴포넌트만을 사용해 일정 이벤트를 단순히 각 날짜 셀에 목록형으로 나열하는 방식으로 설계했다.

문제점

이 방식은 간단한 이벤트나 짧은 기간의 일정을 표시할 때는 문제가 없었지만, 일정의 기간이 길어지면 한눈에 파악하기 어렵다는 문제가 있었다. 예를 들어, 5일간 지속되는 일정이 있을 경우, 각 날짜 셀에 독립적으로 이벤트가 나열되어 있어 전체 기간을 직관적으로 파악하기 어려웠다.

 

// Calendar.tsx

const CalendarHeader = ({
  month,
  setMonth,
}: {
  month: Moment | undefined;
  setMonth: React.Dispatch<React.SetStateAction<Moment | undefined>>;
}) => {
  return (
    <div className="flex items-center justify-between">
      <h2 className="text-xl font-semibold">
        {`${month?.clone().year()}년 ${Number(month?.clone().month()) + 1}월`}
      </h2>
      <MonthPickerPopover
        month={month}
        setMonth={setMonth}
        text="달을 선택해주세요"
        reset={false}
      />
    </div>
  );
};

const Calendar: React.FC<CalendarProps> = ({ events }) => {
  // month 상태를 Moment | undefined로 관리
  const [month, setMonth] = React.useState<Moment | undefined>(moment());

  // month 상태에 따라 시작과 끝 날짜를 계산
  {* ...생략.... *}
  
  return (
    <SubContainer className="gap-4">
      {/* MonthPickerPopover를 사용하여 month 상태를 업데이트 */}
      <CalendarHeader month={month} setMonth={setMonth} />
      <div className="grid grid-cols-7 gap-2 mt-4">
        {days.map((day) => (
          <CalendarCell
            key={day.format('YYYY-MM-DD')}
            day={day}
            events={getEventsForDay(day)}
          />
        ))}
      </div>
    </SubContainer>
  );
};

기존 사용하던 캘린더와 같은방식의 캘린더. 하루 이상의 긴 일정이 눈에 잘 들어오지 않는다. (임시데이터를 넣었다)

 

2. 설계 구조 개선

EventBar 컴포넌트 도입

이 문제를 해결하기 위해 일정이 길어져도 가시성을 높일 수 있는 방법을 고민했다. 해결책으로 EventBar 컴포넌트를 도입해, 일정의 시작일과 종료일을 기준으로 하나의 막대(bar)로 시각적으로 연결해 표시하기로 했다. 우리 서비스는 각 날짜의 투두리스트 같은 느낌이 아닌, 주로 하루 이상의 긴 일정 데이터를 관리한다. 따라서 각 셀을 넘어 막대 형태로 일정의 기간을 더 명확히 보여줄 수 있는 방법을 고안하며 다시 설계를 진행했다.

  • Calendar.tsx: 전체 달력을 관리하며, 월 단위의 데이터 처리를 담당한다.
  • CalendarRow.tsx: 주 단위의 행을 관리하여, 일별 셀로 분할한다.
  • CalendarCell.tsx: 각 날짜 셀을 관리하며, 이벤트를 표시한다.
  • EventBar.tsx: 각 일정(event)의 시각적 표시를 담당한다.

EventBar는 각 날짜 셀에 표시되는 일정의 기간을 시각적으로 표현해 주며, 이를 통해 일정이 시작된 날부터 끝나는 날까지 시각적으로 연속성을 갖고 표시할 수 있다. 이로 인해 사용자에게 일정의 전반적인 흐름을 가시적으로 보여줌으로써 사용성을 개선할 수 있었다.

이런 구조로 컴포넌트를 나눔으로써 각 부분의 역할이 명확해지고, 유지보수도 쉬워졌다. 

// Calendar.tsx
const Calendar: React.FC<{ events?: CalendarEventType[] }> = ({ events }) => {
  const [month, setMonth] = React.useState<Moment | undefined>(moment());

  {* 생략 *}

  return (
    <SubContainer className="gap-4">
      <CalendarHeader month={month} setMonth={setMonth} />
      <div className="mt-4 space-y-2 overflow-hidden">
        {weeks.map((startOfWeek) => (
          <CalendarRow
            key={startOfWeek.format('YYYY-MM-DD')}
            startOfWeek={startOfWeek}
            events={events}
            availablePeriod={availablePeriod}
          />
        ))}
      </div>
    </SubContainer>
  );
};

// EventBar.tsx
const EventBar: React.FC<EventBarProps> = ({ event, topOffset, cellWidth, day, dayOfWeek }) => {
  // 현재 셀(day)에서 이벤트의 시작과 끝을 기준으로 duration 계산
  const duration = Math.min(
    Math.abs(Number(event.endDate.format('D')) - Number(day)) + 1,
    7 - dayOfWeek,
  );

  return (
    <div
      className="absolute text-xs text-foreground px-2 py-1 rounded"
      style={{
        backgroundColor: event.isShow ? event.color : 'transparent',
        top: `${topOffset}px`,
        left: 0,
        width: `${duration * cellWidth - 4}px`,
      }}
    >
      {event.isShow ? event.title : ''}
    </div>
  );
};

 

3. 이벤트 레이어와 겹침 문제 해결

이벤트 레이어가 서로 겹치지 않도록 처리하기: createCalendarArray 유틸 함수 설계

캘린더 데이터를 주 단위로 변환하는 createCalendarArray 함수는 이벤트를 날짜별로 분배하고, 주 단위로 배열을 구성하는 역할을 한다. 초기 설계에서는 이벤트가 동일한 날짜에 여러 개 있을 때, EventBar가 겹치는 문제가 발생했다.  복잡한 일정 관리에서는 동일한 날짜에 다수의 이벤트가 발생할 가능성이 높기 때문에, 이를 해결하기 위해 다음과 같은 접근을 시도했다.

 

첫째, 각 이벤트에 대해 고유한 레이어 인덱스를 부여했다. 이를 위해 eventLayerIndexObj 객체를 도입하여 이벤트의 ID를 키로 하고, 레이어 인덱스를 값으로 저장했다. 이벤트가 시작될 때는 가장 작은 사용 가능한 레이어 인덱스를 할당하고, 이벤트가 종료되면 해당 레이어에서 이벤트를 해제하여 다른 이벤트가 사용할 수 있도록 했다.

 

둘째, 이벤트가 종료되는 날짜를 추적하기 위해 deleteStack 배열을 사용했다. 현재 날짜가 이벤트의 종료 날짜를 지난 경우, 해당 이벤트의 레이어 인덱스를 eventLayerIndexObj에서 삭제했다. 이를 통해 새로운 이벤트가 동일한 레이어를 사용할 수 있게 하여 겹침을 방지했다.

 

셋째, 각 주를 순회하면서 이벤트를 적절한 레이어에 배치하도록 했다. convertDailEventsToWeekly 함수에서 주 단위로 끊은 weekObjArr 배열에 이벤트를 배치할 때, 동일한 레이어에 이미 이벤트가 있는지 확인하고, 없다면 해당 레이어에 이벤트를 추가하는 로직을 만들었다. 이를 통해 각 이벤트가 서로 다른 레이어에 배치되어 시각적으로 겹치지 않도록 했다.

 

/**
 * 주어진 이벤트 목록과 선택적 사용 가능한 날짜를 기반으로
 * 주 단위의 캘린더 배열을 생성합니다.
 *
 * @param events - 캘린더 이벤트 목록
 * @param available - 사용 가능한 날짜 범위 (선택적)
 * @returns 주 단위로 정리된 캘린더 이벤트 배열
 */
export const createCalendarArray = (
  events: CalendarEventType[],
  available?: AvailableDates,
): (CalendarEventObjType | null)[][] => {
  // 유효한 날짜 범위 계산
  const { effectiveStartDate, effectiveEndDate } = calculateEffectiveDates(events, available);
  
  // 하루 단위의 이벤트 목록 생성
  const dailyEvents = generateDailyEvents(events, effectiveStartDate, effectiveEndDate);
  
  // 일별 이벤트를 주 단위 이중 배열로 변환
  const weeksWithObject = convertDailyEventsToWeekly(dailyEvents, effectiveStartDate);
  
  return weeksWithObject;
};

 

/**
 * 하루 단위의 이벤트를 주 단위 배열로 변환합니다.
 *
 * @param dailyEvents - 하루 단위의 이벤트 배열
 * @param effectiveStartDate - 유효한 시작 날짜
 * @returns 주 단위의 이벤트 배열
 */
const convertDailyEventsToWeekly = (
  dailyEvents: (CalendarEventType | null)[],
  effectiveStartDate: moment.Moment
): (CalendarEventObjType | null)[][] => {
  const weeks: (CalendarEventType | null)[][][] = [];

  // 하루 단위 이벤트를 7일 단위로 잘라서 주 단위 배열 생성
  for (let i = 0; i < dailyEvents.length; i += 7) {
    weeks.push(dailyEvents.slice(i, i + 7));
  }

  return weeks.map((week, weekIdx) => {
    const weekObjArr: (CalendarEventObjType | null)[] = new Array(7).fill(null);
    const eventLayerIndexObj: { [key: number]: number } = {};
    const deleteStack: { id: number; deleteOn: moment.Moment }[] = [];

    // 각 주에 대해 이벤트를 처리
    week.forEach((dayEvents, dayIndex) => {
      if (dayEvents && dayEvents.length > 0) {
        dayEvents.forEach(event => {
          if (event) {
            const currentDay = effectiveStartDate.clone().add(dayIndex + weekIdx * 7, 'days');
            // 이벤트 레이어를 처리하는 함수 호출
            handleEventLayer(event, currentDay, weekObjArr, eventLayerIndexObj, deleteStack, dayIndex);
          }
        });
      }
    });

    return weekObjArr;
  });
};

/**
 * 이벤트 레이어를 처리합니다.
 *
 * @param event - 캘린더 이벤트
 * @param currentDay - 현재 날짜
 * @param weekObjArr - 주 단위 객체 배열
 * @param eventLayerIndexObj - 이벤트 레이어 인덱스 객체
 * @param deleteStack - 삭제할 이벤트 스택
 * @param dayIndex - 현재 날짜의 인덱스
 */
const handleEventLayer = (
  event: CalendarEventType,
  currentDay: moment.Moment,
  weekObjArr: (CalendarEventObjType | null)[],
  eventLayerIndexObj: { [key: number]: number },
  deleteStack: { id: number; deleteOn: moment.Moment }[],
  dayIndex: number
) => {
  // 삭제할 이벤트를 처리
  const eventsToDelete = deleteStack.filter(i => currentDay.isAfter(i.deleteOn));
  eventsToDelete.forEach(i => {
    delete eventLayerIndexObj[i.id];
  });

  // 이벤트 인덱스를 매핑
  if (!eventLayerIndexObj.hasOwnProperty(event.id)) {
    let eventIndex = 0;
    while (Object.values(eventLayerIndexObj).includes(eventIndex)) {
      eventIndex++;
    }
    eventLayerIndexObj[event.id] = eventIndex;
  }

  const eventLayerIdx = eventLayerIndexObj[event.id as number]!;
  if (!weekObjArr[dayIndex]) {
    weekObjArr[dayIndex] = {};
  }

  // 동일한 인덱스에 같은 이벤트가 없으면 추가
  const isEventInWeekObjDay = Object.values(weekObjArr[dayIndex]!).some(e => e.id === event.id);
  if (!isEventInWeekObjDay) {
    const isEventStartDate = currentDay.isSame(event.startDate);
    const isFirstDayOfWeek = dayIndex === 0;
    const eventObj = (isEventStartDate || isFirstDayOfWeek) 
      ? { ...event, isShow: true } 
      : event;

    weekObjArr[dayIndex]![eventLayerIdx] = eventObj;
  }

  // 다음 날에 이벤트를 삭제할 준비
  if (currentDay.isSame(event.endDate)) {
    deleteStack.push({ id: event.id, deleteOn: event.endDate });
  }
};

 

이러한 접근을 통해 복잡한 일정에서도 EventBar가 겹치지 않고, 각 이벤트가 명확하게 표시되도록 했다. 최종적으로는 사용자에게 직관적이고 깔끔한 캘린더 UI를 제공할 수 있게 되었다.

 

4. 완성

export default function ContractViewPage() {
  return (
    <PageContainer
      heading={
        <MainHeading backButton description={description}>
          {heading}
        </MainHeading>
      }
    >
      <MainContainer>
        <FullCalendar events={data} /> // 깔꼼하게 끼워진 캘린더... 만족..!
      </MainContainer>
    </PageContainer>
  );
}